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 4ebb1d9..065166a 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 training and inter-school matches", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 15, + "participants": ["james@mergington.edu", "liam@mergington.edu"] + }, + "Swimming Club": { + "description": "Swimming techniques and competitive training", + "schedule": "Mondays and Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["ava@mergington.edu", "noah@mergington.edu"] + }, + "Drama Club": { + "description": "Acting, theater production, and performing arts", + "schedule": "Wednesdays, 3:30 PM - 5:30 PM", + "max_participants": 25, + "participants": ["isabella@mergington.edu", "ethan@mergington.edu"] + }, + "Art Studio": { + "description": "Painting, drawing, and mixed media art projects", + "schedule": "Thursdays, 3:00 PM - 5:00 PM", + "max_participants": 18, + "participants": ["mia@mergington.edu", "lucas@mergington.edu"] + }, + "Debate Team": { + "description": "Develop critical thinking and public speaking through debates", + "schedule": "Fridays, 4:00 PM - 6:00 PM", + "max_participants": 16, + "participants": ["charlotte@mergington.edu", "william@mergington.edu"] + }, + "Science Olympiad": { + "description": "Competitive science events and hands-on experiments", + "schedule": "Tuesdays, 3:30 PM - 5:30 PM", + "max_participants": 20, + "participants": ["amelia@mergington.edu", "benjamin@mergington.edu"] } } @@ -62,6 +98,29 @@ 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 is already signed up") + # 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 + if email not in activity["participants"]: + raise HTTPException(status_code=400, 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..b95ae46 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -20,11 +20,37 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; + // Build participants list + let participantsList = ''; + if (details.participants.length > 0) { + participantsList = ` +
+ Current Participants: + +
+ `; + } else { + participantsList = ` +
+ Current Participants: +

No participants yet. Be the first to sign up!

+
+ `; + } + activityCard.innerHTML = `

${name}

${details.description}

Schedule: ${details.schedule}

Availability: ${spotsLeft} spots left

+ ${participantsList} `; activitiesList.appendChild(activityCard); @@ -59,6 +85,9 @@ document.addEventListener("DOMContentLoaded", () => { const result = await response.json(); if (response.ok) { + // Refresh the activities list + await fetchActivities(); + messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); @@ -81,6 +110,52 @@ document.addEventListener("DOMContentLoaded", () => { } }); + // Handle participant deletion + activitiesList.addEventListener("click", async (event) => { + if (event.target.classList.contains("delete-icon")) { + const activityName = event.target.getAttribute("data-activity"); + const email = event.target.getAttribute("data-email"); + + if (!confirm(`Are you sure you want to unregister ${email} from ${activityName}?`)) { + return; + } + + try { + const response = await fetch( + `/activities/${encodeURIComponent(activityName)}/unregister?email=${encodeURIComponent(email)}`, + { + method: "DELETE", + } + ); + + const result = await response.json(); + + if (response.ok) { + // Refresh the activities list + await fetchActivities(); + + messageDiv.textContent = result.message; + messageDiv.className = "success"; + } else { + messageDiv.textContent = result.detail || "Failed to unregister participant"; + messageDiv.className = "error"; + } + + messageDiv.classList.remove("hidden"); + + // Hide message after 5 seconds + 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..84e40f0 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,57 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 15px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.participants-section strong { + color: #1a237e; + display: block; + margin-bottom: 8px; +} + +.participants-list { + list-style-type: none; + margin-left: 0; + color: #555; +} + +.participants-list li { + padding: 8px 0; + font-size: 14px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.participant-email { + flex-grow: 1; +} + +.delete-icon { + cursor: pointer; + font-size: 16px; + margin-left: 10px; + opacity: 0.6; + transition: opacity 0.2s, transform 0.2s; + user-select: none; +} + +.delete-icon:hover { + opacity: 1; + transform: scale(1.2); +} + +.no-participants { + color: #999; + font-style: italic; + margin: 5px 0 0 0; + font-size: 14px; +} + .form-group { margin-bottom: 15px; } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1b4366a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Mergington High School API""" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5aef50d --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,256 @@ +""" +Test cases for the Mergington High School Activities API +""" +import pytest +from fastapi.testclient import TestClient +from src.app import app, activities + + +@pytest.fixture +def client(): + """Create a test client""" + return TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_activities(): + """Reset activities data before each test""" + # Store original state + original_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 training and inter-school matches", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 15, + "participants": ["james@mergington.edu", "liam@mergington.edu"] + }, + "Swimming Club": { + "description": "Swimming techniques and competitive training", + "schedule": "Mondays and Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["ava@mergington.edu", "noah@mergington.edu"] + }, + "Drama Club": { + "description": "Acting, theater production, and performing arts", + "schedule": "Wednesdays, 3:30 PM - 5:30 PM", + "max_participants": 25, + "participants": ["isabella@mergington.edu", "ethan@mergington.edu"] + }, + "Art Studio": { + "description": "Painting, drawing, and mixed media art projects", + "schedule": "Thursdays, 3:00 PM - 5:00 PM", + "max_participants": 18, + "participants": ["mia@mergington.edu", "lucas@mergington.edu"] + }, + "Debate Team": { + "description": "Develop critical thinking and public speaking through debates", + "schedule": "Fridays, 4:00 PM - 6:00 PM", + "max_participants": 16, + "participants": ["charlotte@mergington.edu", "william@mergington.edu"] + }, + "Science Olympiad": { + "description": "Competitive science events and hands-on experiments", + "schedule": "Tuesdays, 3:30 PM - 5:30 PM", + "max_participants": 20, + "participants": ["amelia@mergington.edu", "benjamin@mergington.edu"] + } + } + + # Reset to original state + activities.clear() + activities.update(original_activities) + + yield + + # Cleanup after test + activities.clear() + activities.update(original_activities) + + +class TestRootEndpoint: + """Test cases for the root endpoint""" + + def test_root_redirects_to_static(self, client): + """Test that root path redirects to static/index.html""" + response = client.get("/", follow_redirects=False) + assert response.status_code == 307 + assert response.headers["location"] == "/static/index.html" + + +class TestGetActivities: + """Test cases for GET /activities endpoint""" + + def test_get_activities_returns_all_activities(self, client): + """Test that all activities are returned""" + response = client.get("/activities") + assert response.status_code == 200 + data = response.json() + assert len(data) == 9 + assert "Chess Club" in data + assert "Programming Class" in data + assert "Swimming Club" in data + + def test_get_activities_structure(self, client): + """Test that activities have the correct structure""" + response = client.get("/activities") + data = response.json() + + chess_club = data["Chess Club"] + assert "description" in chess_club + assert "schedule" in chess_club + assert "max_participants" in chess_club + assert "participants" in chess_club + assert isinstance(chess_club["participants"], list) + + +class TestSignupForActivity: + """Test cases for POST /activities/{activity_name}/signup endpoint""" + + def test_signup_successful(self, client): + """Test successful signup for an activity""" + response = client.post("/activities/Chess%20Club/signup?email=test@mergington.edu") + assert response.status_code == 200 + data = response.json() + assert "message" in data + assert "test@mergington.edu" in data["message"] + assert "Chess Club" in data["message"] + + # Verify participant was added + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "test@mergington.edu" in activities_data["Chess Club"]["participants"] + + def test_signup_activity_not_found(self, client): + """Test signup for non-existent activity""" + response = client.post("/activities/Fake%20Activity/signup?email=test@mergington.edu") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert "Activity not found" in data["detail"] + + def test_signup_already_registered(self, client): + """Test signing up when already registered""" + # michael@mergington.edu is already in Chess Club + response = client.post("/activities/Chess%20Club/signup?email=michael@mergington.edu") + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert "already signed up" in data["detail"] + + def test_signup_multiple_students(self, client): + """Test signing up multiple students""" + client.post("/activities/Drama%20Club/signup?email=student1@mergington.edu") + client.post("/activities/Drama%20Club/signup?email=student2@mergington.edu") + + activities_response = client.get("/activities") + activities_data = activities_response.json() + participants = activities_data["Drama Club"]["participants"] + + assert "student1@mergington.edu" in participants + assert "student2@mergington.edu" in participants + + +class TestUnregisterFromActivity: + """Test cases for DELETE /activities/{activity_name}/unregister endpoint""" + + def test_unregister_successful(self, client): + """Test successful unregistration from an activity""" + # michael@mergington.edu is in Chess Club + response = client.delete("/activities/Chess%20Club/unregister?email=michael@mergington.edu") + assert response.status_code == 200 + data = response.json() + assert "message" in data + assert "michael@mergington.edu" in data["message"] + assert "Chess Club" in data["message"] + + # Verify participant was removed + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "michael@mergington.edu" not in activities_data["Chess Club"]["participants"] + + def test_unregister_activity_not_found(self, client): + """Test unregister from non-existent activity""" + response = client.delete("/activities/Fake%20Activity/unregister?email=test@mergington.edu") + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert "Activity not found" in data["detail"] + + def test_unregister_not_signed_up(self, client): + """Test unregistering when not signed up""" + response = client.delete("/activities/Chess%20Club/unregister?email=notregistered@mergington.edu") + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert "not signed up" in data["detail"] + + def test_unregister_and_signup_again(self, client): + """Test unregistering and then signing up again""" + # Unregister + response = client.delete("/activities/Programming%20Class/unregister?email=emma@mergington.edu") + assert response.status_code == 200 + + # Verify removed + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "emma@mergington.edu" not in activities_data["Programming Class"]["participants"] + + # Sign up again + response = client.post("/activities/Programming%20Class/signup?email=emma@mergington.edu") + assert response.status_code == 200 + + # Verify added back + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "emma@mergington.edu" in activities_data["Programming Class"]["participants"] + + +class TestIntegrationScenarios: + """Integration test scenarios""" + + def test_full_participant_lifecycle(self, client): + """Test complete participant lifecycle: signup, verify, unregister""" + email = "lifecycle@mergington.edu" + activity = "Art Studio" + + # Initial state - not registered + activities_response = client.get("/activities") + initial_participants = activities_response.json()[activity]["participants"] + assert email not in initial_participants + + # Sign up + signup_response = client.post(f"/activities/{activity}/signup?email={email}") + assert signup_response.status_code == 200 + + # Verify signup + activities_response = client.get("/activities") + after_signup = activities_response.json()[activity]["participants"] + assert email in after_signup + assert len(after_signup) == len(initial_participants) + 1 + + # Unregister + unregister_response = client.delete(f"/activities/{activity}/unregister?email={email}") + assert unregister_response.status_code == 200 + + # Verify unregistration + activities_response = client.get("/activities") + after_unregister = activities_response.json()[activity]["participants"] + assert email not in after_unregister + assert len(after_unregister) == len(initial_participants)