diff --git a/requirements.txt b/requirements.txt index 5d9efb5..f2821b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi uvicorn httpx -watchfiles \ No newline at end of file +watchfiles +pytest \ No newline at end of file diff --git a/src/app.py b/src/app.py index 4ebb1d9..dbe27dc 100644 --- a/src/app.py +++ b/src/app.py @@ -38,6 +38,42 @@ "schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM", "max_participants": 30, "participants": ["john@mergington.edu", "olivia@mergington.edu"] + }, + "Basketball Team": { + "description": "Competitive basketball team for interscholastic games", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 15, + "participants": ["alex@mergington.edu", "jordan@mergington.edu"] + }, + "Tennis Club": { + "description": "Learn tennis skills and compete in matches", + "schedule": "Mondays and Wednesdays, 4:00 PM - 5:00 PM", + "max_participants": 16, + "participants": ["sarah@mergington.edu", "james@mergington.edu"] + }, + "Drama Club": { + "description": "Perform in theatrical productions and develop acting skills", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 25, + "participants": ["lisa@mergington.edu", "david@mergington.edu"] + }, + "Art Class": { + "description": "Explore painting, sculpture, and other visual arts", + "schedule": "Fridays, 4:00 PM - 5:30 PM", + "max_participants": 18, + "participants": ["maya@mergington.edu", "chris@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore scientific concepts", + "schedule": "Thursdays, 3:30 PM - 4:45 PM", + "max_participants": 20, + "participants": ["ryan@mergington.edu", "natalie@mergington.edu"] + }, + "Debate Team": { + "description": "Develop public speaking and critical thinking skills through competitive debate", + "schedule": "Mondays, 3:30 PM - 5:00 PM", + "max_participants": 14, + "participants": ["tyler@mergington.edu", "jessica@mergington.edu"] } } @@ -62,6 +98,25 @@ def signup_for_activity(activity_name: str, email: str): # Get the specific activity activity = activities[activity_name] + # Validate student is not already signed up + if email in activity["participants"]: + raise HTTPException(status_code=400, detail="Student already signed up for this activity") + # Add student activity["participants"].append(email) return {"message": f"Signed up {email} for {activity_name}"} + + +@app.delete("/activities/{activity_name}/participants") +def unregister_from_activity(activity_name: str, email: str): + """Remove a student from an activity""" + if activity_name not in activities: + raise HTTPException(status_code=404, detail="Activity not found") + + activity = activities[activity_name] + + if email not in activity["participants"]: + raise HTTPException(status_code=404, detail="Participant not found in activity") + + activity["participants"].remove(email) + return {"message": f"Unregistered {email} from {activity_name}"} diff --git a/src/static/app.js b/src/static/app.js index dcc1e38..a306308 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -4,14 +4,25 @@ document.addEventListener("DOMContentLoaded", () => { const signupForm = document.getElementById("signup-form"); const messageDiv = document.getElementById("message"); + function showMessage(text, type) { + messageDiv.textContent = text; + messageDiv.className = `message ${type}`; + messageDiv.classList.remove("hidden"); + + setTimeout(() => { + messageDiv.classList.add("hidden"); + }, 5000); + } + // Function to fetch activities from API async function fetchActivities() { try { const response = await fetch("/activities"); const activities = await response.json(); - // Clear loading message + // Clear loading message and reset activity dropdown activitiesList.innerHTML = ""; + activitySelect.innerHTML = ''; // Populate activities list Object.entries(activities).forEach(([name, details]) => { @@ -19,6 +30,7 @@ document.addEventListener("DOMContentLoaded", () => { activityCard.className = "activity-card"; const spotsLeft = details.max_participants - details.participants.length; + const participants = details.participants || []; activityCard.innerHTML = `

${name}

@@ -27,6 +39,51 @@ document.addEventListener("DOMContentLoaded", () => {

Availability: ${spotsLeft} spots left

`; + const participantsSection = document.createElement("div"); + participantsSection.className = "participants-section"; + + const participantsTitle = document.createElement("p"); + participantsTitle.innerHTML = "Participants:"; + participantsSection.appendChild(participantsTitle); + + if (participants.length) { + const participantsList = document.createElement("ul"); + participantsList.className = "participants-list"; + + participants.forEach((participant) => { + const listItem = document.createElement("li"); + listItem.className = "participant-item"; + + const nameSpan = document.createElement("span"); + nameSpan.className = "participant-name"; + nameSpan.textContent = participant; + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "remove-participant"; + removeButton.dataset.activity = name; + removeButton.dataset.email = participant; + removeButton.title = `Remove ${participant}`; + removeButton.innerHTML = ` + + `; + + listItem.appendChild(nameSpan); + listItem.appendChild(removeButton); + participantsList.appendChild(listItem); + }); + + participantsSection.appendChild(participantsList); + } else { + const emptyMessage = document.createElement("p"); + emptyMessage.className = "no-participants"; + emptyMessage.textContent = "No participants yet. Be the first to sign up!"; + participantsSection.appendChild(emptyMessage); + } + + activityCard.appendChild(participantsSection); activitiesList.appendChild(activityCard); // Add option to select dropdown @@ -41,6 +98,36 @@ document.addEventListener("DOMContentLoaded", () => { } } + activitiesList.addEventListener("click", async (event) => { + const removeButton = event.target.closest(".remove-participant"); + if (!removeButton) return; + + const activity = removeButton.dataset.activity; + const email = removeButton.dataset.email; + + if (!activity || !email) return; + + try { + const response = await fetch( + `/activities/${encodeURIComponent(activity)}/participants?email=${encodeURIComponent(email)}`, + { + method: "DELETE", + } + ); + const result = await response.json(); + + if (response.ok) { + showMessage(result.message, "success"); + fetchActivities(); + } else { + showMessage(result.detail || "An error occurred", "error"); + } + } catch (error) { + showMessage("Failed to unregister participant. Please try again.", "error"); + console.error("Error removing participant:", error); + } + }); + // Handle form submission signupForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -59,20 +146,12 @@ document.addEventListener("DOMContentLoaded", () => { const result = await response.json(); if (response.ok) { - messageDiv.textContent = result.message; - messageDiv.className = "success"; + showMessage(result.message, "success"); signupForm.reset(); + fetchActivities(); } else { - messageDiv.textContent = result.detail || "An error occurred"; - messageDiv.className = "error"; + showMessage(result.detail || "An error occurred", "error"); } - - messageDiv.classList.remove("hidden"); - - // Hide message after 5 seconds - setTimeout(() => { - messageDiv.classList.add("hidden"); - }, 5000); } catch (error) { messageDiv.textContent = "Failed to sign up. Please try again."; messageDiv.className = "error"; diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..a3ac704 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,73 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 15px; + padding: 16px 14px 12px; + border-top: 1px solid #e0e0e0; + background-color: #fafbff; + border-radius: 0 0 5px 5px; +} + +.participants-section p { + margin-bottom: 10px; +} + +.participants-list { + list-style: none; + margin: 0; + padding: 0; +} + +.participant-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + margin-bottom: 10px; + background-color: #eef1ff; + border: 1px solid #d6dbff; + border-radius: 999px; +} + +.participant-name { + color: #1f2a55; + font-size: 0.95rem; + word-break: break-word; +} + +.remove-participant { + border: none; + background: transparent; + color: #c62828; + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 50%; + transition: background-color 0.2s ease; + padding: 0; +} + +.remove-participant svg { + width: 18px; + height: 18px; + fill: currentColor; +} + +.remove-participant:hover { + background-color: rgba(198, 40, 40, 0.12); +} + +.no-participants { + margin: 0; + font-style: italic; + color: #666; +} + .form-group { margin-bottom: 15px; } diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..21c35a3 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,160 @@ +import pytest +from fastapi.testclient import TestClient +from src.app import app, activities + + +# Baseline activities data for reset +INITIAL_ACTIVITIES = { + "Chess Club": { + "description": "Learn strategies and compete in chess tournaments", + "schedule": "Fridays, 3:30 PM - 5:00 PM", + "max_participants": 12, + "participants": ["michael@mergington.edu", "daniel@mergington.edu"] + }, + "Programming Class": { + "description": "Learn programming fundamentals and build software projects", + "schedule": "Tuesdays and Thursdays, 3:30 PM - 4:30 PM", + "max_participants": 20, + "participants": ["emma@mergington.edu", "sophia@mergington.edu"] + }, + "Gym Class": { + "description": "Physical education and sports activities", + "schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM", + "max_participants": 30, + "participants": ["john@mergington.edu", "olivia@mergington.edu"] + }, + "Basketball Team": { + "description": "Competitive basketball team for interscholastic games", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 15, + "participants": ["alex@mergington.edu", "jordan@mergington.edu"] + }, + "Tennis Club": { + "description": "Learn tennis skills and compete in matches", + "schedule": "Mondays and Wednesdays, 4:00 PM - 5:00 PM", + "max_participants": 16, + "participants": ["sarah@mergington.edu", "james@mergington.edu"] + }, + "Drama Club": { + "description": "Perform in theatrical productions and develop acting skills", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 25, + "participants": ["lisa@mergington.edu", "david@mergington.edu"] + }, + "Art Class": { + "description": "Explore painting, sculpture, and other visual arts", + "schedule": "Fridays, 4:00 PM - 5:30 PM", + "max_participants": 18, + "participants": ["maya@mergington.edu", "chris@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore scientific concepts", + "schedule": "Thursdays, 3:30 PM - 4:45 PM", + "max_participants": 20, + "participants": ["ryan@mergington.edu", "natalie@mergington.edu"] + }, + "Debate Team": { + "description": "Develop public speaking and critical thinking skills through competitive debate", + "schedule": "Mondays, 3:30 PM - 5:00 PM", + "max_participants": 14, + "participants": ["tyler@mergington.edu", "jessica@mergington.edu"] + } +} + + +@pytest.fixture +def client(): + """Fixture to reset activities state and provide a test client.""" + # Arrange: Reset activities to initial state + activities.clear() + activities.update(INITIAL_ACTIVITIES) + + yield TestClient(app) + + +def test_get_activities_returns_activities(client): + """Test that GET /activities returns all activities with correct structure.""" + # Arrange + expected_activity_count = len(INITIAL_ACTIVITIES) + + # Act + response = client.get("/activities") + + # Assert + assert response.status_code == 200 + data = response.json() + assert len(data) == expected_activity_count + assert "Chess Club" in data + assert "participants" in data["Chess Club"] + assert isinstance(data["Chess Club"]["participants"], list) + + +def test_signup_for_activity_adds_participant(client): + """Test that POST /activities/{activity}/signup adds a new participant.""" + # Arrange + activity_name = "Chess Club" + new_email = "newstudent@mergington.edu" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": new_email} + ) + + # Assert + assert response.status_code == 200 + assert response.json()["message"] == f"Signed up {new_email} for {activity_name}" + assert new_email in activities[activity_name]["participants"] + + +def test_signup_duplicate_returns_400(client): + """Test that signing up with a duplicate email returns 400.""" + # Arrange + activity_name = "Chess Club" + existing_email = "michael@mergington.edu" + + # Act + response = client.post( + f"/activities/{activity_name}/signup", + params={"email": existing_email} + ) + + # Assert + assert response.status_code == 400 + assert "already signed up" in response.json()["detail"] + + +def test_unregister_from_activity_removes_participant(client): + """Test that DELETE /activities/{activity}/participants removes a participant.""" + # Arrange + activity_name = "Chess Club" + email_to_remove = "michael@mergington.edu" + assert email_to_remove in activities[activity_name]["participants"] + + # Act + response = client.delete( + f"/activities/{activity_name}/participants", + params={"email": email_to_remove} + ) + + # Assert + assert response.status_code == 200 + assert response.json()["message"] == f"Unregistered {email_to_remove} from {activity_name}" + assert email_to_remove not in activities[activity_name]["participants"] + + +def test_unregister_missing_participant_returns_404(client): + """Test that unregistering a nonexistent participant returns 404.""" + # Arrange + activity_name = "Chess Club" + nonexistent_email = "ghost@mergington.edu" + + # Act + response = client.delete( + f"/activities/{activity_name}/participants", + params={"email": nonexistent_email} + ) + + # Assert + assert response.status_code == 404 + assert "not found" in response.json()["detail"]