diff --git a/README.md b/README.md index e8107c1..bd7b455 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Getting Started with GitHub Copilot +# Getting Started with GitHub Copilot 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..ce6b13b 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 practice and games", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 15, + "participants": ["james@mergington.edu", "lucas@mergington.edu"] + }, + "Swimming Club": { + "description": "Swimming lessons and competitive training", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 25, + "participants": ["ava@mergington.edu", "mia@mergington.edu"] + }, + "Art Club": { + "description": "Explore various art mediums including painting and sculpture", + "schedule": "Mondays, 3:30 PM - 5:00 PM", + "max_participants": 18, + "participants": ["isabella@mergington.edu", "charlotte@mergington.edu"] + }, + "Drama Club": { + "description": "Acting, theater production, and performance arts", + "schedule": "Thursdays, 3:30 PM - 5:30 PM", + "max_participants": 24, + "participants": ["amelia@mergington.edu", "harper@mergington.edu"] + }, + "Debate Team": { + "description": "Develop critical thinking and public speaking skills through debates", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 16, + "participants": ["ethan@mergington.edu", "benjamin@mergington.edu"] + }, + "Science Olympiad": { + "description": "Prepare for science competitions and conduct experiments", + "schedule": "Fridays, 3:30 PM - 5:30 PM", + "max_participants": 20, + "participants": ["alexander@mergington.edu", "william@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 already signed up for this activity") + # 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 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..fb439c1 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -20,11 +20,26 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; + const participantsList = details.participants.length > 0 + ? `` + : `

No participants yet. Be the first to sign up!

`; + activityCard.innerHTML = `

${name}

${details.description}

Schedule: ${details.schedule}

Availability: ${spotsLeft} spots left

+
+

Participants:

+ ${participantsList} +
`; activitiesList.appendChild(activityCard); @@ -59,6 +74,9 @@ document.addEventListener("DOMContentLoaded", () => { const result = await response.json(); if (response.ok) { + // Refresh activities to show updated list + await fetchActivities(); + messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); @@ -81,6 +99,52 @@ document.addEventListener("DOMContentLoaded", () => { } }); + // Handle delete button clicks + 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(`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 activities to show updated list + await fetchActivities(); + + messageDiv.textContent = result.message; + messageDiv.className = "success"; + messageDiv.classList.remove("hidden"); + + // Hide message after 3 seconds + setTimeout(() => { + messageDiv.classList.add("hidden"); + }, 3000); + } else { + messageDiv.textContent = result.detail || "Failed to unregister"; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + } + } catch (error) { + messageDiv.textContent = "Failed to unregister. Please try again."; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + console.error("Error unregistering:", error); + } + } + }); + // Initialize app fetchActivities(); }); diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..b48fac7 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,58 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 15px; + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.participants-header { + margin-bottom: 8px; + color: #1a237e; +} + +.participants-list { + list-style-type: none; + margin-left: 0; + color: #555; +} + +.participants-list li { + margin-bottom: 5px; + padding: 8px; + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f0f0f0; + border-radius: 4px; +} + +.participant-email { + flex-grow: 1; +} + +.delete-btn { + background-color: transparent; + border: none; + cursor: pointer; + font-size: 18px; + padding: 4px 8px; + margin-left: 10px; + transition: transform 0.2s; +} + +.delete-btn:hover { + transform: scale(1.2); + background-color: transparent; +} + +.no-participants { + font-style: italic; + color: #999; + margin: 0; +} + .form-group { margin-bottom: 15px; } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ae26017 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for the Mergington High School API""" diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..732478b --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,241 @@ +""" +Tests for the Mergington High School API +""" + +import pytest +from fastapi.testclient import TestClient +from src.app import app, activities + + +@pytest.fixture +def client(): + """Create a test client for the FastAPI app""" + return TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_activities(): + """Reset activities data before each test""" + # Store original state + original_activities = {} + for name, data in activities.items(): + original_activities[name] = { + "description": data["description"], + "schedule": data["schedule"], + "max_participants": data["max_participants"], + "participants": data["participants"].copy() + } + + yield + + # Restore original state + for name, data in original_activities.items(): + activities[name] = data + + +class TestRoot: + """Tests for the root endpoint""" + + def test_root_redirects_to_static(self, client): + """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 TestGetActivities: + """Tests for the 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 isinstance(data, dict) + assert len(data) > 0 + assert "Chess Club" in data + assert "Programming Class" in data + assert "Gym Class" in data + + def test_activities_have_required_fields(self, client): + """Test that each activity has all required fields""" + response = client.get("/activities") + data = response.json() + + for activity_name, activity_data in data.items(): + 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) + assert isinstance(activity_data["max_participants"], int) + + +class TestSignupForActivity: + """Tests for the POST /activities/{activity_name}/signup endpoint""" + + def test_signup_for_existing_activity(self, client): + """Test successful signup for an activity""" + response = client.post( + "/activities/Chess Club/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 the student 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_for_nonexistent_activity(self, client): + """Test signup for an activity that doesn't exist""" + response = client.post( + "/activities/Nonexistent Club/signup?email=test@mergington.edu" + ) + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == "Activity not found" + + def test_signup_twice_for_same_activity(self, client): + """Test that a student cannot sign up twice for the same activity""" + email = "duplicate@mergington.edu" + + # First signup should succeed + response1 = client.post( + f"/activities/Chess Club/signup?email={email}" + ) + assert response1.status_code == 200 + + # Second signup should fail + response2 = client.post( + f"/activities/Chess Club/signup?email={email}" + ) + assert response2.status_code == 400 + data = response2.json() + assert "detail" in data + assert data["detail"] == "Student already signed up for this activity" + + def test_signup_with_special_characters_in_activity_name(self, client): + """Test signup with URL-encoded activity name""" + response = client.post( + "/activities/Art%20Club/signup?email=artist@mergington.edu" + ) + assert response.status_code == 200 + + # Verify the student was added + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert "artist@mergington.edu" in activities_data["Art Club"]["participants"] + + +class TestUnregisterFromActivity: + """Tests for the DELETE /activities/{activity_name}/unregister endpoint""" + + def test_unregister_from_activity(self, client): + """Test successful unregistration from an activity""" + email = "michael@mergington.edu" + + # Verify student is initially registered (from default data) + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email in activities_data["Chess Club"]["participants"] + + # Unregister + response = client.delete( + f"/activities/Chess Club/unregister?email={email}" + ) + assert response.status_code == 200 + data = response.json() + assert "message" in data + assert email in data["message"] + assert "Chess Club" in data["message"] + + # Verify student was removed + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email not in activities_data["Chess Club"]["participants"] + + def test_unregister_from_nonexistent_activity(self, client): + """Test unregistration from an activity that doesn't exist""" + response = client.delete( + "/activities/Nonexistent Club/unregister?email=test@mergington.edu" + ) + assert response.status_code == 404 + data = response.json() + assert "detail" in data + assert data["detail"] == "Activity not found" + + def test_unregister_when_not_signed_up(self, client): + """Test unregistration when student is not signed up""" + response = client.delete( + "/activities/Chess Club/unregister?email=notsignedup@mergington.edu" + ) + assert response.status_code == 400 + data = response.json() + assert "detail" in data + assert data["detail"] == "Student not signed up for this activity" + + def test_signup_after_unregister(self, client): + """Test that a student can re-signup after unregistering""" + email = "michael@mergington.edu" + + # Unregister + response1 = client.delete( + f"/activities/Chess Club/unregister?email={email}" + ) + assert response1.status_code == 200 + + # Re-signup + response2 = client.post( + f"/activities/Chess Club/signup?email={email}" + ) + assert response2.status_code == 200 + + # Verify student is registered again + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email in activities_data["Chess Club"]["participants"] + + +class TestEdgeCases: + """Tests for edge cases and error handling""" + + def test_activity_name_case_sensitivity(self, client): + """Test that activity names are case-sensitive""" + response = client.post( + "/activities/chess club/signup?email=test@mergington.edu" + ) + assert response.status_code == 404 + + def test_empty_email(self, client): + """Test signup with empty email""" + response = client.post( + "/activities/Chess Club/signup?email=" + ) + # FastAPI should handle this - either 422 for validation error or process it + assert response.status_code in [200, 422] + + def test_multiple_activities_for_same_student(self, client): + """Test that a student can sign up for multiple activities""" + email = "busy@mergington.edu" + + # Sign up for multiple activities + response1 = client.post(f"/activities/Chess Club/signup?email={email}") + assert response1.status_code == 200 + + response2 = client.post(f"/activities/Art Club/signup?email={email}") + assert response2.status_code == 200 + + response3 = client.post(f"/activities/Drama Club/signup?email={email}") + assert response3.status_code == 200 + + # Verify student is in all three activities + activities_response = client.get("/activities") + activities_data = activities_response.json() + assert email in activities_data["Chess Club"]["participants"] + assert email in activities_data["Art Club"]["participants"] + assert email in activities_data["Drama Club"]["participants"]