From 06503de7f5f02358fef86c707d7a487b6423f9c7 Mon Sep 17 00:00:00 2001 From: Mike Finley Date: Tue, 9 Dec 2025 18:13:06 +0000 Subject: [PATCH 1/3] feat: add new extracurricular activities and signup validation --- src/app.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/app.py b/src/app.py index 4ebb1d9..cfdbd65 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 team with regular matches and tournaments", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 25, + "participants": ["james@mergington.edu", "liam@mergington.edu"] + }, + "Swimming Club": { + "description": "Swim training and competitive meets", + "schedule": "Mondays and Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["ava@mergington.edu", "noah@mergington.edu"] + }, + "Art Studio": { + "description": "Painting, drawing, and mixed media art projects", + "schedule": "Thursdays, 3:30 PM - 5:30 PM", + "max_participants": 15, + "participants": ["isabella@mergington.edu", "mia@mergington.edu"] + }, + "Drama Club": { + "description": "Theater performances, acting workshops, and stage production", + "schedule": "Tuesdays and Fridays, 3:30 PM - 5:00 PM", + "max_participants": 30, + "participants": ["ethan@mergington.edu", "charlotte@mergington.edu"] + }, + "Robotics Team": { + "description": "Build and program robots for regional competitions", + "schedule": "Wednesdays, 3:30 PM - 6:00 PM", + "max_participants": 18, + "participants": ["william@mergington.edu", "benjamin@mergington.edu"] + }, + "Debate Club": { + "description": "Develop critical thinking and public speaking through competitive debates", + "schedule": "Mondays, 3:30 PM - 5:00 PM", + "max_participants": 16, + "participants": ["amelia@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 for the activity + if email in activity["participants"]: + raise HTTPException(status_code=400, detail="Already signed up for this activity") + # Add student activity["participants"].append(email) return {"message": f"Signed up {email} for {activity_name}"} From ec05aaa426be9c20c81e4073e5ee9f0356523fde Mon Sep 17 00:00:00 2001 From: Mike Finley Date: Tue, 9 Dec 2025 18:59:53 +0000 Subject: [PATCH 2/3] chore: clean up unused CSS styles and improve formatting --- src/static/app.js | 12 ++++++++++ src/static/styles.css | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/static/app.js b/src/static/app.js index dcc1e38..cf87c1d 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -19,12 +19,24 @@ document.addEventListener("DOMContentLoaded", () => { activityCard.className = "activity-card"; const spotsLeft = details.max_participants - details.participants.length; + const participantsMarkup = details.participants?.length + ? `` + : `

Be the first to join this activity!

`; activityCard.innerHTML = `

${name}

${details.description}

Schedule: ${details.schedule}

Availability: ${spotsLeft} spots left

+
+
+ Participants + ${details.participants.length} +
+ ${participantsMarkup} +
`; activitiesList.appendChild(activityCard); diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..20d1865 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -142,3 +142,54 @@ footer { padding: 20px; color: #666; } + +.participants { + margin-top: 12px; + padding: 12px; + border-radius: 6px; + background: linear-gradient(135deg, #eef2ff 0%, #f5f7ff 100%); + border: 1px solid #dde1ff; +} + +.participants-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.participants-title { + font-weight: 700; + color: #1a237e; +} + +.participants-count { + min-width: 28px; + padding: 3px 8px; + border-radius: 999px; + background-color: #1a237e; + color: #fff; + font-size: 12px; + text-align: center; +} + +.participants-list { + list-style: disc inside; + margin: 0; + padding-left: 4px; + color: #37474f; +} + +.participants-list li { + margin: 4px 0; + padding: 4px 6px; + border-radius: 4px; + background-color: #fff; + border: 1px solid #e3e6ff; +} + +.participants-empty { + margin: 0; + color: #546e7a; + font-style: italic; +} From 410c6932d0392dda77331fd708685d846cd1cc42 Mon Sep 17 00:00:00 2001 From: Mike Finley Date: Tue, 9 Dec 2025 19:27:14 +0000 Subject: [PATCH 3/3] feat: add unregister endpoint and enhance participant management in activities --- requirements.txt | 2 + src/app.py | 19 ++++ src/static/app.js | 39 +++++++- src/static/styles.css | 25 ++++- tests/__init__.py | 0 tests/conftest.py | 86 +++++++++++++++++ tests/test_app.py | 219 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py diff --git a/requirements.txt b/requirements.txt index 97dc7cd..2522ad0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ fastapi uvicorn +pytest +httpx diff --git a/src/app.py b/src/app.py index cfdbd65..ce6ec89 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}/unregister") +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 for the activity + if email not in activity["participants"]: + raise HTTPException(status_code=400, detail="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 cf87c1d..6a6e9f3 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -13,6 +13,11 @@ document.addEventListener("DOMContentLoaded", () => { // Clear loading message activitiesList.innerHTML = ""; + // Clear existing options from dropdown (except the default option) + while (activitySelect.options.length > 1) { + activitySelect.remove(1); + } + // Populate activities list Object.entries(activities).forEach(([name, details]) => { const activityCard = document.createElement("div"); @@ -21,7 +26,7 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; const participantsMarkup = details.participants?.length ? `
    - ${details.participants.map((p) => `
  • ${p}
  • `).join("")} + ${details.participants.map((p) => `
  • ${p}
  • `).join("")}
` : `

Be the first to join this activity!

`; @@ -47,6 +52,36 @@ document.addEventListener("DOMContentLoaded", () => { option.textContent = name; activitySelect.appendChild(option); }); + + // Add event listeners for delete buttons using event delegation + activitiesList.addEventListener("click", async (event) => { + if (event.target.classList.contains("delete-btn")) { + const activityName = event.target.dataset.activity; + const email = event.target.dataset.email; + + if (confirm(`Remove ${email} from ${activityName}?`)) { + try { + const response = await fetch( + `/activities/${encodeURIComponent(activityName)}/unregister?email=${encodeURIComponent(email)}`, + { + method: "DELETE", + } + ); + + if (response.ok) { + // Refresh the activities list + fetchActivities(); + } else { + const result = await response.json(); + alert(result.detail || "Failed to unregister participant"); + } + } catch (error) { + alert("Failed to unregister participant"); + console.error("Error unregistering:", error); + } + } + } + }); } catch (error) { activitiesList.innerHTML = "

Failed to load activities. Please try again later.

"; console.error("Error fetching activities:", error); @@ -74,6 +109,8 @@ document.addEventListener("DOMContentLoaded", () => { messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); + // Refresh the activities list to show the new participant + 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 20d1865..5e917f1 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -174,18 +174,37 @@ footer { } .participants-list { - list-style: disc inside; + list-style: none; margin: 0; - padding-left: 4px; + padding-left: 0; color: #37474f; } .participants-list li { margin: 4px 0; - padding: 4px 6px; + padding: 6px 8px; border-radius: 4px; background-color: #fff; border: 1px solid #e3e6ff; + display: flex; + justify-content: space-between; + align-items: center; +} + +.delete-btn { + background-color: #d32f2f; + color: white; + border: none; + padding: 2px 8px; + font-size: 14px; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.2s; + margin-left: 10px; +} + +.delete-btn:hover { + background-color: #b71c1c; } .participants-empty { diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..825492a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,86 @@ +import sys +from pathlib import Path + +# Add src directory to path so we can import app +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +import pytest +from fastapi.testclient import TestClient +from app import app + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app""" + return TestClient(app) + + +@pytest.fixture +def reset_activities(): + """Reset activities to initial state before each test""" + from app import activities + + initial_state = { + "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"] + }, + "Soccer Team": { + "description": "Competitive soccer team with regular matches and tournaments", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 25, + "participants": ["james@mergington.edu", "liam@mergington.edu"] + }, + "Swimming Club": { + "description": "Swim training and competitive meets", + "schedule": "Mondays and Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["ava@mergington.edu", "noah@mergington.edu"] + }, + "Art Studio": { + "description": "Painting, drawing, and mixed media art projects", + "schedule": "Thursdays, 3:30 PM - 5:30 PM", + "max_participants": 15, + "participants": ["isabella@mergington.edu", "mia@mergington.edu"] + }, + "Drama Club": { + "description": "Theater performances, acting workshops, and stage production", + "schedule": "Tuesdays and Fridays, 3:30 PM - 5:00 PM", + "max_participants": 30, + "participants": ["ethan@mergington.edu", "charlotte@mergington.edu"] + }, + "Robotics Team": { + "description": "Build and program robots for regional competitions", + "schedule": "Wednesdays, 3:30 PM - 6:00 PM", + "max_participants": 18, + "participants": ["william@mergington.edu", "benjamin@mergington.edu"] + }, + "Debate Club": { + "description": "Develop critical thinking and public speaking through competitive debates", + "schedule": "Mondays, 3:30 PM - 5:00 PM", + "max_participants": 16, + "participants": ["amelia@mergington.edu", "lucas@mergington.edu"] + } + } + + # Clear and reset + activities.clear() + activities.update(initial_state) + yield + # Reset after test + activities.clear() + activities.update(initial_state) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..6dfb0f7 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,219 @@ +""" +Tests for the High School Management System API +""" +import sys +from pathlib import Path + +# Add src directory to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from fastapi.testclient import TestClient +from app import app + + +client = TestClient(app) + + +class TestRoot: + """Test root endpoint""" + + def test_root_redirect(self): + """Test that root redirects to static/index.html""" + response = client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/static/index.html" + + +class TestActivities: + """Test activities endpoints""" + + def test_get_activities(self): + """Test retrieving all activities""" + response = client.get("/activities") + assert response.status_code == 200 + + activities = response.json() + assert isinstance(activities, dict) + assert len(activities) > 0 + + # Check that Chess Club exists and has expected fields + assert "Chess Club" in activities + assert "description" in activities["Chess Club"] + assert "schedule" in activities["Chess Club"] + assert "max_participants" in activities["Chess Club"] + assert "participants" in activities["Chess Club"] + + def test_get_activities_structure(self): + """Test the structure of returned activities""" + response = client.get("/activities") + activities = response.json() + + for activity_name, activity_data in activities.items(): + assert isinstance(activity_name, str) + assert isinstance(activity_data, dict) + assert "description" in activity_data + assert "schedule" in activity_data + assert "max_participants" in activity_data + assert "participants" in activity_data + assert isinstance(activity_data["participants"], list) + + +class TestSignup: + """Test signup endpoint""" + + def test_signup_new_participant(self, reset_activities): + """Test signing up a new participant""" + email = "newstudent@mergington.edu" + activity = "Chess Club" + + response = client.post( + f"/activities/{activity}/signup", + params={"email": email} + ) + + assert response.status_code == 200 + result = response.json() + assert "message" in result + assert email in result["message"] + assert activity in result["message"] + + # Verify participant was added + activities_response = client.get("/activities") + activities = activities_response.json() + assert email in activities["Chess Club"]["participants"] + + def test_signup_duplicate_participant(self, reset_activities): + """Test that duplicate signups return 400 error""" + email = "michael@mergington.edu" + activity = "Chess Club" + + response = client.post( + f"/activities/{activity}/signup", + params={"email": email} + ) + + assert response.status_code == 400 + result = response.json() + assert "Already signed up" in result["detail"] + + def test_signup_nonexistent_activity(self, reset_activities): + """Test signing up for non-existent activity""" + email = "newstudent@mergington.edu" + activity = "Nonexistent Activity" + + response = client.post( + f"/activities/{activity}/signup", + params={"email": email} + ) + + assert response.status_code == 404 + result = response.json() + assert "Activity not found" in result["detail"] + + def test_signup_multiple_activities(self, reset_activities): + """Test that a student can sign up for multiple activities""" + email = "newstudent@mergington.edu" + + # Sign up for Chess Club + response1 = client.post( + "/activities/Chess Club/signup", + params={"email": email} + ) + assert response1.status_code == 200 + + # Sign up for Programming Class + response2 = client.post( + "/activities/Programming Class/signup", + params={"email": email} + ) + assert response2.status_code == 200 + + # Verify in both activities + activities_response = client.get("/activities") + activities = activities_response.json() + assert email in activities["Chess Club"]["participants"] + assert email in activities["Programming Class"]["participants"] + + +class TestUnregister: + """Test unregister endpoint""" + + def test_unregister_participant(self, reset_activities): + """Test unregistering a participant from an activity""" + email = "michael@mergington.edu" + activity = "Chess Club" + + # Verify participant is enrolled + activities_response = client.get("/activities") + assert email in activities_response.json()["Chess Club"]["participants"] + + # Unregister + response = client.delete( + f"/activities/{activity}/unregister", + params={"email": email} + ) + + assert response.status_code == 200 + result = response.json() + assert "message" in result + assert email in result["message"] + assert activity in result["message"] + + # Verify participant was removed + activities_response = client.get("/activities") + assert email not in activities_response.json()["Chess Club"]["participants"] + + def test_unregister_nonexistent_participant(self, reset_activities): + """Test unregistering someone not in the activity""" + email = "nonexistent@mergington.edu" + activity = "Chess Club" + + response = client.delete( + f"/activities/{activity}/unregister", + params={"email": email} + ) + + assert response.status_code == 400 + result = response.json() + assert "Not signed up" in result["detail"] + + def test_unregister_from_nonexistent_activity(self, reset_activities): + """Test unregistering from non-existent activity""" + email = "michael@mergington.edu" + activity = "Nonexistent Activity" + + response = client.delete( + f"/activities/{activity}/unregister", + params={"email": email} + ) + + assert response.status_code == 404 + result = response.json() + assert "Activity not found" in result["detail"] + + +class TestEdgeCases: + """Test edge cases and special scenarios""" + + def test_special_characters_in_email(self, reset_activities): + """Test email with special characters""" + email = "test+special@mergington.edu" + + response = client.post( + "/activities/Chess Club/signup", + params={"email": email} + ) + + assert response.status_code == 200 + + def test_url_encoded_activity_name(self, reset_activities): + """Test activity names with spaces are properly handled""" + email = "newstudent@mergington.edu" + + # Chess Club has a space, should be URL encoded + response = client.post( + "/activities/Chess%20Club/signup", + params={"email": email} + ) + + assert response.status_code == 200