From 8476cdf68dd4a31251092ed8208e8257784b0ac4 Mon Sep 17 00:00:00 2001 From: Jesper Grooss Date: Wed, 15 Jul 2026 13:39:09 +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..e9ef090 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 with team training and games", + "schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM", + "max_participants": 15, + "participants": ["james@mergington.edu", "alex@mergington.edu"] + }, + "Tennis Club": { + "description": "Tennis practice and friendly matches", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:00 PM", + "max_participants": 16, + "participants": ["lucas@mergington.edu", "nina@mergington.edu"] + }, + "Art Studio": { + "description": "Drawing, painting, and visual art techniques", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 18, + "participants": ["isabella@mergington.edu", "sophia@mergington.edu"] + }, + "Music Band": { + "description": "Learn instruments and perform in school concerts", + "schedule": "Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 25, + "participants": ["lucas@mergington.edu", "mia@mergington.edu"] + }, + "Debate Club": { + "description": "Develop argumentation and public speaking skills", + "schedule": "Mondays and Fridays, 3:30 PM - 4:30 PM", + "max_participants": 14, + "participants": ["ryan@mergington.edu", "jessica@mergington.edu"] + }, + "Science Club": { + "description": "Explore scientific experiments and research projects", + "schedule": "Wednesdays, 4:00 PM - 5:00 PM", + "max_participants": 20, + "participants": ["nathan@mergington.edu", "ava@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 for this activity") + # Add student activity["participants"].append(email) return {"message": f"Signed up {email} for {activity_name}"} From 5ca25c2159ad03b5208b917e210e43a33004542e Mon Sep 17 00:00:00 2001 From: Jesper Grooss Date: Wed, 15 Jul 2026 13:51:28 +0000 Subject: [PATCH 2/3] Implement participant unregistration feature and enhance UI for activity participants --- src/app.py | 19 +++++++++++ src/static/app.js | 79 +++++++++++++++++++++++++++++++++++++++++++ src/static/styles.css | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/src/app.py b/src/app.py index e9ef090..f5d5a24 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}/participants") +def unregister_from_activity(activity_name: str, email: str): + """Remove 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 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..e6767ac 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -12,6 +12,7 @@ document.addEventListener("DOMContentLoaded", () => { // Clear loading message activitiesList.innerHTML = ""; + activitySelect.innerHTML = ''; // Populate activities list Object.entries(activities).forEach(([name, details]) => { @@ -19,12 +20,40 @@ document.addEventListener("DOMContentLoaded", () => { activityCard.className = "activity-card"; const spotsLeft = details.max_participants - details.participants.length; + const sortedParticipants = [...details.participants].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ); + const participantsList = sortedParticipants + .map( + (participant) => ` +
  • + ${participant} + +
  • + ` + ) + .join(""); activityCard.innerHTML = `

    ${name}

    ${details.description}

    Schedule: ${details.schedule}

    Availability: ${spotsLeft} spots left

    +
    +

    Participants

    + ${sortedParticipants.length > 0 + ? `
      ${participantsList}
    ` + : '

    No participants yet.

    '} +
    `; activitiesList.appendChild(activityCard); @@ -73,6 +102,10 @@ document.addEventListener("DOMContentLoaded", () => { setTimeout(() => { messageDiv.classList.add("hidden"); }, 5000); + + if (response.ok) { + await fetchActivities(); + } } catch (error) { messageDiv.textContent = "Failed to sign up. Please try again."; messageDiv.className = "error"; @@ -81,6 +114,52 @@ document.addEventListener("DOMContentLoaded", () => { } }); + activitiesList.addEventListener("click", async (event) => { + const deleteButton = event.target.closest(".participant-delete"); + + if (!deleteButton) { + return; + } + + const activityName = deleteButton.dataset.activity; + const participantEmail = deleteButton.dataset.email; + + if (!activityName || !participantEmail) { + return; + } + + try { + const response = await fetch( + `/activities/${encodeURIComponent(activityName)}/participants?email=${encodeURIComponent(participantEmail)}`, + { + method: "DELETE", + } + ); + + const result = await response.json(); + + if (response.ok) { + messageDiv.textContent = result.message; + messageDiv.className = "success"; + await fetchActivities(); + } else { + messageDiv.textContent = result.detail || "Could not unregister participant."; + messageDiv.className = "error"; + } + + messageDiv.classList.remove("hidden"); + + setTimeout(() => { + messageDiv.classList.add("hidden"); + }, 5000); + } catch (error) { + messageDiv.textContent = "Failed to unregister participant. Please try again."; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + console.error("Error unregistering participant:", error); + } + }); + // Initialize app fetchActivities(); }); diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..04cf65c 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-top: 10px; + border-top: 1px dashed #cfd8dc; +} + +.participants-title { + font-weight: bold; + color: #1a237e; + margin-bottom: 6px; +} + +.participants-list { + list-style: none; + margin-left: 0; + padding-left: 0; + color: #37474f; +} + +.participants-list li { + margin-bottom: 6px; +} + +.participant-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + background-color: #eef3f8; + border: 1px solid #d7e2ee; + border-radius: 8px; +} + +.participant-email { + font-size: 14px; +} + +.participant-delete { + border: none; + background: transparent; + color: #b71c1c; + padding: 2px 6px; + border-radius: 6px; + cursor: pointer; + font-size: 16px; + line-height: 1; + transition: background-color 0.2s, color 0.2s; +} + +.participant-delete:hover { + background-color: #ffebee; + color: #8e0000; +} + +.participants-empty { + margin-bottom: 0; + color: #607d8b; + font-style: italic; +} + .form-group { margin-bottom: 15px; } From c2c0b7e631c7b33fb781c486a949f92df802ecb1 Mon Sep 17 00:00:00 2001 From: Jesper Grooss Date: Wed, 15 Jul 2026 14:09:39 +0000 Subject: [PATCH 3/3] Add tests for activity signup and unregistration, and update README for dependency installation --- requirements.txt | 3 +- src/README.md | 10 +++- tests/conftest.py | 20 +++++++ tests/test_app.py | 130 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py 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/src/README.md b/src/README.md index a90534b..4da4615 100644 --- a/src/README.md +++ b/src/README.md @@ -12,7 +12,7 @@ A super simple FastAPI application that allows students to view and sign up for 1. Install the dependencies: ``` - pip install fastapi uvicorn + pip install -r requirements.txt ``` 2. Run the application: @@ -25,6 +25,14 @@ A super simple FastAPI application that allows students to view and sign up for - API documentation: http://localhost:8000/docs - Alternative documentation: http://localhost:8000/redoc +## Run Tests + +From the repository root, run: + +``` +pytest -q +``` + ## API Endpoints | Method | Endpoint | Description | diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..092f1aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +from copy import deepcopy + +import pytest +from fastapi.testclient import TestClient + +from src.app import app +import src.app as app_module + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_activities_state(): + original_activities = deepcopy(app_module.activities) + yield + app_module.activities.clear() + app_module.activities.update(original_activities) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..fd72549 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,130 @@ +import src.app as app_module + + +def test_root_redirects_to_static_index(client): + # Arrange + redirect_target = "/static/index.html" + + # Act + response = client.get("/", follow_redirects=False) + + # Assert + assert response.status_code == 307 + assert response.headers["location"] == redirect_target + + +def test_get_activities_returns_expected_shape(client): + # Arrange + expected_activity = "Chess Club" + required_keys = {"description", "schedule", "max_participants", "participants"} + + # Act + response = client.get("/activities") + payload = response.json() + + # Assert + assert response.status_code == 200 + assert isinstance(payload, dict) + assert expected_activity in payload + assert required_keys.issubset(payload[expected_activity].keys()) + assert isinstance(payload[expected_activity]["participants"], list) + + +def test_signup_adds_participant_successfully(client): + # Arrange + activity_name = "Chess Club" + email = "zoe@mergington.edu" + assert email not in app_module.activities[activity_name]["participants"] + + # 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 app_module.activities[activity_name]["participants"] + + +def test_signup_returns_404_for_unknown_activity(client): + # 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_signup_returns_400_for_duplicate_participant(client): + # 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 for this activity"} + + +def test_unregister_removes_participant_successfully(client): + # Arrange + activity_name = "Gym Class" + email = "john@mergington.edu" + assert email in app_module.activities[activity_name]["participants"] + + # Act + response = client.delete( + f"/activities/{activity_name}/participants", + params={"email": email}, + ) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": f"Unregistered {email} from {activity_name}"} + assert email not in app_module.activities[activity_name]["participants"] + + +def test_unregister_returns_404_for_unknown_activity(client): + # Arrange + activity_name = "Unknown Club" + email = "student@mergington.edu" + + # Act + response = client.delete( + f"/activities/{activity_name}/participants", + params={"email": email}, + ) + + # Assert + assert response.status_code == 404 + assert response.json() == {"detail": "Activity not found"} + + +def test_unregister_returns_404_for_non_registered_participant(client): + # Arrange + activity_name = "Gym Class" + email = "not-registered@mergington.edu" + + # Act + response = client.delete( + f"/activities/{activity_name}/participants", + params={"email": email}, + ) + + # Assert + assert response.status_code == 404 + assert response.json() == {"detail": "Student is not signed up for this activity"}