From 1abf4937fa2ccffcf2a39e1a2d10c060976ee27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Alcobia?= <98830742+abalcobia@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:59:42 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat(app):=20update=20app.py=20?= =?UTF-8?q?with=20new=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app.py b/src/app.py index 4ebb1d9..9b13175 100644 --- a/src/app.py +++ b/src/app.py @@ -62,6 +62,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 bca1c0943ccb6382550662b563baacf963be8224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Alcobia?= <98830742+abalcobia@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:04:20 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat(activities):=20add=206=20n?= =?UTF-8?q?ew=20extracurricular=20activities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/app.py b/src/app.py index 9b13175..5964f43 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"] + }, + "Soccer Team": { + "description": "Competitive soccer training and matches against other schools", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 22, + "participants": ["lucas@mergington.edu", "liam@mergington.edu"] + }, + "Swimming Club": { + "description": "Swim training for all skill levels and competitive meets", + "schedule": "Mondays and Wednesdays, 6:00 AM - 7:30 AM", + "max_participants": 20, + "participants": ["mia@mergington.edu", "noah@mergington.edu"] + }, + "Art Studio": { + "description": "Explore painting, drawing, and mixed media techniques", + "schedule": "Thursdays, 3:30 PM - 5:00 PM", + "max_participants": 15, + "participants": ["Isabella@mergington.edu", "ava@mergington.edu"] + }, + "Drama Club": { + "description": "Act, direct, and produce theatrical performances", + "schedule": "Mondays and Fridays, 4:00 PM - 5:30 PM", + "max_participants": 25, + "participants": ["charlotte@mergington.edu", "amelia@mergington.edu"] + }, + "Math Olympiad": { + "description": "Prepare for math competitions and solve advanced problems", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 15, + "participants": ["ethan@mergington.edu", "harper@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore STEM topics beyond the classroom", + "schedule": "Fridays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["james@mergington.edu", "sofia@mergington.edu"] } } From 0b6d60123f9ba20078e8996bc3a3656e6b9da5be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Alcobia?= <98830742+abalcobia@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:14:19 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20feat(activity-card):=20enhance?= =?UTF-8?q?=20participant=20display=20and=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/static/app.js | 10 ++++- src/static/styles.css | 100 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/static/app.js b/src/static/app.js index dcc1e38..e8fa8f4 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -20,11 +20,19 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; + const participantsHTML = details.participants.length > 0 + ? `` + : `

No participants yet — be the first!

`; + activityCard.innerHTML = `

${name}

${details.description}

Schedule: ${details.schedule}

-

Availability: ${spotsLeft} spots left

+

Availability: ${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} left

+
+ Participants ${details.participants.length} / ${details.max_participants} + ${participantsHTML} +
`; activitiesList.appendChild(activityCard); diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..4d2b05e 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,106 @@ section h3 { margin-bottom: 8px; } +.spots-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.85em; + font-weight: bold; +} + +.spots-ok { + background-color: #e8f5e9; + color: #2e7d32; + border: 1px solid #a5d6a7; +} + +.spots-low { + background-color: #fff3e0; + color: #e65100; + border: 1px solid #ffcc80; +} + +.spots-full { + background-color: #ffebee; + color: #c62828; + border: 1px solid #ef9a9a; +} + +.participants-section { + margin-top: 12px; + border-top: 1px dashed #ddd; + padding-top: 10px; +} + +.participants-section summary { + cursor: pointer; + user-select: none; + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; + color: #1a237e; +} + +.participants-section summary::-webkit-details-marker { + display: none; +} + +.participants-section summary::after { + content: '\25BC'; + font-size: 0.7em; + transition: transform 0.2s; + color: #888; +} + +.participants-section[open] summary::after { + transform: rotate(180deg); +} + +.participant-count { + font-size: 0.8em; + background-color: #e8eaf6; + color: #3949ab; + border-radius: 10px; + padding: 1px 7px; + margin-left: 8px; + font-weight: normal; +} + +.participants-list { + list-style: none; + margin-top: 10px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.participants-list li { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9em; + color: #444; + background-color: #f0f4ff; + border-radius: 6px; + padding: 5px 10px; + border: 1px solid #d0d8f0; +} + +.participant-icon { + font-size: 1em; +} + +.no-participants { + margin-top: 10px; + font-size: 0.85em; + color: #888; + font-style: italic; +} + .form-group { margin-bottom: 15px; } From 7ad42cd11b7bda7cb08923f13357006644708ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Alcobia?= <98830742+abalcobia@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:27:05 +0000 Subject: [PATCH 4/4] Add pytest tests for FastAPI endpoints and update dependencies --- requirements.txt | 3 + src/app.py | 19 +++++++ src/static/app.js | 55 ++++++++++++++++-- src/static/styles.css | 18 ++++++ tests/__init__.py | 0 tests/test_app.py | 126 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_app.py diff --git a/requirements.txt b/requirements.txt index 97dc7cd..18ac922 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ fastapi uvicorn +httpx +pytest +pytest-asyncio diff --git a/src/app.py b/src/app.py index 5964f43..85bc7ff 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 signed up + if email not in activity["participants"]: + raise HTTPException(status_code=400, detail="Student is not registered 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 e8fa8f4..5ed24ca 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -20,10 +20,6 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; - const participantsHTML = details.participants.length > 0 - ? `` - : `

No participants yet — be the first!

`; - activityCard.innerHTML = `

${name}

${details.description}

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

Availability: ${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} left

Participants ${details.participants.length} / ${details.max_participants} - ${participantsHTML} + ${details.participants.length === 0 ? '

No participants yet — be the first!

' : ''}
`; + if (details.participants.length > 0) { + const detailsEl = activityCard.querySelector(".participants-section"); + const ul = document.createElement("ul"); + ul.className = "participants-list"; + details.participants.forEach(p => { + const li = document.createElement("li"); + + const icon = document.createElement("span"); + icon.className = "participant-icon"; + icon.textContent = "\u{1F464}"; + + const emailSpan = document.createElement("span"); + emailSpan.className = "participant-email"; + emailSpan.textContent = p; + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "delete-participant-btn"; + deleteBtn.innerHTML = "🗑"; + deleteBtn.title = `Unregister ${p}`; + deleteBtn.addEventListener("click", () => unregisterParticipant(name, p)); + + li.appendChild(icon); + li.appendChild(emailSpan); + li.appendChild(deleteBtn); + ul.appendChild(li); + }); + detailsEl.appendChild(ul); + } + activitiesList.appendChild(activityCard); // Add option to select dropdown @@ -49,6 +74,25 @@ document.addEventListener("DOMContentLoaded", () => { } } + // Unregister a participant from an activity + async function unregisterParticipant(activityName, email) { + try { + const response = await fetch( + `/activities/${encodeURIComponent(activityName)}/signup?email=${encodeURIComponent(email)}`, + { method: "DELETE" } + ); + const result = await response.json(); + if (response.ok) { + fetchActivities(); + } else { + alert(result.detail || "Failed to unregister participant."); + } + } catch (error) { + console.error("Error unregistering participant:", error); + alert("Failed to unregister participant."); + } + } + // Handle form submission signupForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -70,6 +114,7 @@ document.addEventListener("DOMContentLoaded", () => { messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); + fetchActivities(); } else { messageDiv.textContent = result.detail || "An error occurred"; messageDiv.className = "error"; diff --git a/src/static/styles.css b/src/static/styles.css index 4d2b05e..35d99a0 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -167,6 +167,24 @@ section h3 { font-size: 1em; } +.delete-participant-btn { + margin-left: auto; + background: none; + border: none; + cursor: pointer; + font-size: 1em; + padding: 0 2px; + color: #c62828; + opacity: 0.6; + transition: opacity 0.15s, transform 0.15s; + line-height: 1; +} + +.delete-participant-btn:hover { + opacity: 1; + transform: scale(1.2); +} + .no-participants { margin-top: 10px; font-size: 0.85em; diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..50c2c43 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,126 @@ +""" +Tests for the Mergington High School API. +""" + +import pytest +from fastapi.testclient import TestClient +from src.app import app, activities + + +@pytest.fixture(autouse=True) +def reset_activities(): + """Reset activities to their original state before each test.""" + original_state = {name: {**data, "participants": list(data["participants"])} + for name, data in activities.items()} + yield + activities.clear() + activities.update(original_state) + + +@pytest.fixture +def client(): + return TestClient(app) + + +# --------------------------------------------------------------------------- +# GET /activities +# --------------------------------------------------------------------------- + +class TestGetActivities: + def test_returns_200(self, client): + response = client.get("/activities") + assert response.status_code == 200 + + def test_returns_dict(self, client): + response = client.get("/activities") + data = response.json() + assert isinstance(data, dict) + + def test_contains_expected_activities(self, client): + response = client.get("/activities") + data = response.json() + assert "Chess Club" in data + assert "Programming Class" in data + assert "Gym Class" in data + + def test_activity_has_required_fields(self, client): + response = client.get("/activities") + chess = response.json()["Chess Club"] + assert "description" in chess + assert "schedule" in chess + assert "max_participants" in chess + assert "participants" in chess + + +# --------------------------------------------------------------------------- +# POST /activities/{activity_name}/signup +# --------------------------------------------------------------------------- + +class TestSignup: + def test_successful_signup(self, client): + response = client.post( + "/activities/Chess Club/signup", + params={"email": "newstudent@mergington.edu"} + ) + assert response.status_code == 200 + assert "newstudent@mergington.edu" in response.json()["message"] + + def test_participant_added_to_activity(self, client): + email = "teststudent@mergington.edu" + client.post("/activities/Chess Club/signup", params={"email": email}) + response = client.get("/activities") + assert email in response.json()["Chess Club"]["participants"] + + def test_signup_nonexistent_activity_returns_404(self, client): + response = client.post( + "/activities/Nonexistent Activity/signup", + params={"email": "student@mergington.edu"} + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Activity not found" + + def test_duplicate_signup_returns_400(self, client): + email = "michael@mergington.edu" # already signed up for Chess Club + response = client.post( + "/activities/Chess Club/signup", + params={"email": email} + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Student already signed up for this activity" + + +# --------------------------------------------------------------------------- +# DELETE /activities/{activity_name}/signup +# --------------------------------------------------------------------------- + +class TestUnregister: + def test_successful_unregister(self, client): + email = "michael@mergington.edu" # already in Chess Club + response = client.delete( + "/activities/Chess Club/signup", + params={"email": email} + ) + assert response.status_code == 200 + assert email in response.json()["message"] + + def test_participant_removed_from_activity(self, client): + email = "michael@mergington.edu" + client.delete("/activities/Chess Club/signup", params={"email": email}) + response = client.get("/activities") + assert email not in response.json()["Chess Club"]["participants"] + + def test_unregister_nonexistent_activity_returns_404(self, client): + response = client.delete( + "/activities/Nonexistent Activity/signup", + params={"email": "student@mergington.edu"} + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Activity not found" + + def test_unregister_not_enrolled_returns_400(self, client): + response = client.delete( + "/activities/Chess Club/signup", + params={"email": "notregistered@mergington.edu"} + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Student is not registered for this activity"