From e1a9db0d903f0dc1b91dbf85c5b8933be7c7f98c Mon Sep 17 00:00:00 2001 From: Matt Hietpas Date: Thu, 18 Jun 2026 18:59:37 +0000 Subject: [PATCH 1/3] Add extracurricular activities and signup validation to the API --- src/app.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/app.py b/src/app.py index 4ebb1d9..c7ec6df 100644 --- a/src/app.py +++ b/src/app.py @@ -33,11 +33,47 @@ "max_participants": 20, "participants": ["emma@mergington.edu", "sophia@mergington.edu"] }, + "Debate Team": { + "description": "Develop critical thinking and public speaking skills through competitive debate", + "schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM", + "max_participants": 15, + "participants": ["alex@mergington.edu"] + }, + "Robotics Club": { + "description": "Design, build, and program robots for competitions", + "schedule": "Tuesdays, Wednesdays, Thursdays, 3:30 PM - 5:00 PM", + "max_participants": 25, + "participants": ["james@mergington.edu", "sarah@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 training and games", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 20, + "participants": ["marcus@mergington.edu", "lucas@mergington.edu"] + }, + "Track and Field": { + "description": "Training for running, jumping, and throwing events", + "schedule": "Mondays, Wednesdays, Fridays, 3:30 PM - 5:00 PM", + "max_participants": 25, + "participants": ["ava@mergington.edu"] + }, + "Art Club": { + "description": "Explore painting, drawing, and sculpture with expert instructors", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 18, + "participants": ["isabella@mergington.edu", "grace@mergington.edu"] + }, + "Music Ensemble": { + "description": "Join our orchestra, band, or choir and perform at school events", + "schedule": "Tuesdays and Fridays, 3:30 PM - 4:30 PM", + "max_participants": 40, + "participants": ["noah@mergington.edu", "mia@mergington.edu", "lucas@mergington.edu"] } } @@ -62,6 +98,10 @@ 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") + # Add student activity["participants"].append(email) return {"message": f"Signed up {email} for {activity_name}"} From 4222a422002f22b03788f1b6d88a3287e1d204f8 Mon Sep 17 00:00:00 2001 From: Matt Hietpas Date: Thu, 18 Jun 2026 19:14:51 +0000 Subject: [PATCH 2/3] Implement activity unregistration feature and enhance participant display --- src/app.py | 19 +++++++++++ src/static/app.js | 77 +++++++++++++++++++++++++++++++++++-------- src/static/styles.css | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 14 deletions(-) diff --git a/src/app.py b/src/app.py index c7ec6df..246ff58 100644 --- a/src/app.py +++ b/src/app.py @@ -105,3 +105,22 @@ def signup_for_activity(activity_name: str, email: str): # Add student activity["participants"].append(email) return {"message": f"Signed up {email} for {activity_name}"} + + +@app.delete("/activities/{activity_name}/signup") +def unregister_from_activity(activity_name: str, email: str): + """Unregister a student from an activity""" + # Validate activity exists + if activity_name not in activities: + raise HTTPException(status_code=404, detail="Activity not found") + + # Get the specific activity + activity = activities[activity_name] + + # Validate student is currently signed up + if email not in activity["participants"]: + raise HTTPException(status_code=404, detail="Student is not signed up for this activity") + + # Remove student + 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..5f15bf1 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -4,14 +4,27 @@ document.addEventListener("DOMContentLoaded", () => { const signupForm = document.getElementById("signup-form"); const messageDiv = document.getElementById("message"); + function showMessage(text, type) { + messageDiv.textContent = text; + messageDiv.className = type; + messageDiv.classList.remove("hidden"); + + // Hide message after 5 seconds + setTimeout(() => { + messageDiv.classList.add("hidden"); + }, 5000); + } + // Function to fetch activities from API async function fetchActivities() { try { - const response = await fetch("/activities"); + // Add a cache-busting query and disable cache to always show latest participant updates. + const response = await fetch(`/activities?ts=${Date.now()}`, { cache: "no-store" }); const activities = await response.json(); // Clear loading message activitiesList.innerHTML = ""; + activitySelect.innerHTML = ''; // Populate activities list Object.entries(activities).forEach(([name, details]) => { @@ -19,12 +32,25 @@ document.addEventListener("DOMContentLoaded", () => { activityCard.className = "activity-card"; const spotsLeft = details.max_participants - details.participants.length; + const participantsMarkup = + details.participants.length > 0 + ? `` + : '

No participants yet.

'; activityCard.innerHTML = `

${name}

${details.description}

Schedule: ${details.schedule}

Availability: ${spotsLeft} spots left

+
+

Participants

+ ${participantsMarkup} +
`; activitiesList.appendChild(activityCard); @@ -53,31 +79,54 @@ document.addEventListener("DOMContentLoaded", () => { `/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`, { method: "POST", + cache: "no-store", } ); const result = await response.json(); if (response.ok) { - messageDiv.textContent = result.message; - messageDiv.className = "success"; + showMessage(result.message, "success"); signupForm.reset(); + await fetchActivities(); } else { - messageDiv.textContent = result.detail || "An error occurred"; - messageDiv.className = "error"; + showMessage(result.detail || "An error occurred", "error"); } + } catch (error) { + showMessage("Failed to sign up. Please try again.", "error"); + console.error("Error signing up:", error); + } + }); + + activitiesList.addEventListener("click", async (event) => { + const deleteButton = event.target.closest(".participant-delete"); + if (!deleteButton) { + return; + } - messageDiv.classList.remove("hidden"); + const activityName = decodeURIComponent(deleteButton.dataset.activity || ""); + const email = decodeURIComponent(deleteButton.dataset.email || ""); - // Hide message after 5 seconds - setTimeout(() => { - messageDiv.classList.add("hidden"); - }, 5000); + try { + const response = await fetch( + `/activities/${encodeURIComponent(activityName)}/signup?email=${encodeURIComponent(email)}`, + { + method: "DELETE", + cache: "no-store", + } + ); + + const result = await response.json(); + + if (response.ok) { + showMessage(result.message, "success"); + await fetchActivities(); + } else { + showMessage(result.detail || "Failed to unregister participant.", "error"); + } } catch (error) { - messageDiv.textContent = "Failed to sign up. Please try again."; - messageDiv.className = "error"; - messageDiv.classList.remove("hidden"); - console.error("Error signing up:", error); + showMessage("Failed to unregister participant. Please try again.", "error"); + console.error("Error unregistering participant:", error); } }); diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..fd39ce1 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,67 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 12px; + padding: 12px; + background: linear-gradient(135deg, #eef4ff 0%, #f7fbff 100%); + border: 1px solid #d8e6ff; + border-radius: 8px; +} + +.participants-title { + margin-bottom: 8px; + color: #1a237e; + letter-spacing: 0.2px; +} + +.participants-list { + margin: 0; + padding-left: 0; + list-style: none; +} + +.participants-list li { + margin-bottom: 6px; + color: #29406b; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.participants-list li:last-child { + margin-bottom: 0; +} + +.participant-email { + overflow-wrap: anywhere; +} + +.participant-delete { + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid #d95757; + background-color: #fff; + color: #c62828; + font-weight: bold; + line-height: 1; + cursor: pointer; + padding: 0; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.participant-delete:hover { + background-color: #ffebee; +} + +.participants-empty { + margin: 0; + color: #5f6b7a; + font-style: italic; +} + .form-group { margin-bottom: 15px; } From 996c5339e3fc6348d3426e65734b2d769ed30958 Mon Sep 17 00:00:00 2001 From: Matt Hietpas Date: Thu, 18 Jun 2026 19:20:55 +0000 Subject: [PATCH 3/3] Add pytest as a dependency and implement backend API tests for activity service --- requirements.txt | 3 +- tests/test_app.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/test_app.py 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/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..9c73cc0 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,122 @@ +"""Backend API tests for the FastAPI activity service.""" + +from copy import deepcopy + +import pytest +from fastapi.testclient import TestClient + +from src.app import activities, app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_activities_state(): + """Reset in-memory activity data before each test for isolation.""" + # Arrange + snapshot = deepcopy(activities) + + # Act + yield + + # Assert + activities.clear() + activities.update(snapshot) + + +def test_get_activities_returns_all_activities(): + # Arrange + expected_keys = {"description", "schedule", "max_participants", "participants"} + + # Act + response = client.get("/activities") + + # Assert + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + assert data + + for activity in data.values(): + assert expected_keys.issubset(activity.keys()) + + +def test_signup_for_activity_succeeds_for_new_student(): + # Arrange + activity_name = "Chess Club" + email = "new.student@mergington.edu" + + # Act + response = client.post(f"/activities/{activity_name}/signup", params={"email": email}) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": f"Signed up {email} for {activity_name}"} + assert email in activities[activity_name]["participants"] + + +def test_signup_for_activity_fails_when_already_signed_up(): + # Arrange + activity_name = "Chess Club" + email = "michael@mergington.edu" + + # Act + response = client.post(f"/activities/{activity_name}/signup", params={"email": email}) + + # Assert + assert response.status_code == 400 + assert response.json() == {"detail": "Student already signed up"} + + +def test_signup_for_activity_fails_for_unknown_activity(): + # Arrange + activity_name = "Unknown Club" + email = "student@mergington.edu" + + # Act + response = client.post(f"/activities/{activity_name}/signup", params={"email": email}) + + # Assert + assert response.status_code == 404 + assert response.json() == {"detail": "Activity not found"} + + +def test_unregister_from_activity_succeeds_for_signed_up_student(): + # Arrange + activity_name = "Chess Club" + email = "michael@mergington.edu" + assert email in activities[activity_name]["participants"] + + # Act + response = client.delete(f"/activities/{activity_name}/signup", params={"email": email}) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": f"Unregistered {email} from {activity_name}"} + assert email not in activities[activity_name]["participants"] + + +def test_unregister_from_activity_fails_for_unknown_activity(): + # Arrange + activity_name = "Unknown Club" + email = "student@mergington.edu" + + # Act + response = client.delete(f"/activities/{activity_name}/signup", params={"email": email}) + + # Assert + assert response.status_code == 404 + assert response.json() == {"detail": "Activity not found"} + + +def test_unregister_from_activity_fails_when_student_not_signed_up(): + # Arrange + activity_name = "Chess Club" + email = "not.signed.up@mergington.edu" + + # Act + response = client.delete(f"/activities/{activity_name}/signup", params={"email": email}) + + # Assert + assert response.status_code == 404 + assert response.json() == {"detail": "Student is not signed up for this activity"}