diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..4e68f72 Binary files /dev/null and b/.coverage differ 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/app.py b/src/app.py index 4ebb1d9..ff2bd4a 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 tournaments", + "schedule": "Mondays and Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 15, + "participants": ["james@mergington.edu"] + }, + "Tennis Club": { + "description": "Learn tennis skills and compete in matches", + "schedule": "Tuesdays and Saturdays, 3:30 PM - 5:00 PM", + "max_participants": 12, + "participants": ["isabella@mergington.edu"] + }, + "Art Studio": { + "description": "Explore painting, drawing, and sculpture techniques", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 18, + "participants": ["lucas@mergington.edu", "ava@mergington.edu"] + }, + "Drama Club": { + "description": "Theatre arts, script writing, and stage performances", + "schedule": "Fridays, 4:00 PM - 5:30 PM", + "max_participants": 25, + "participants": ["noah@mergington.edu"] + }, + "Debate Team": { + "description": "Develop public speaking and critical thinking skills", + "schedule": "Mondays and Wednesdays, 3:30 PM - 4:30 PM", + "max_participants": 16, + "participants": ["mia@mergington.edu", "ethan@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore scientific concepts", + "schedule": "Tuesdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["charlotte@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}/participants/{email}") +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") + + activity = activities[activity_name] + + # Check if participant is registered + if email not in activity["participants"]: + raise HTTPException(status_code=404, detail="Participant not found") + + # Remove participant + 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..9a30179 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -25,10 +25,53 @@ document.addEventListener("DOMContentLoaded", () => {

${details.description}

Schedule: ${details.schedule}

Availability: ${spotsLeft} spots left

+
+

Participants (${details.participants.length}/${details.max_participants}):

+ +
`; activitiesList.appendChild(activityCard); + // Add delete event listeners to participant buttons + const deleteButtons = activityCard.querySelectorAll(".delete-participant-btn"); + deleteButtons.forEach(btn => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + const email = btn.getAttribute("data-email"); + const activity = btn.getAttribute("data-activity"); + + try { + const response = await fetch( + `/activities/${encodeURIComponent(activity)}/participants/${encodeURIComponent(email)}`, + { method: "DELETE" } + ); + + if (response.ok) { + // Refresh activities list after successful deletion + fetchActivities(); + messageDiv.textContent = `Unregistered ${email} from ${activity}`; + messageDiv.className = "success"; + } else { + const result = await response.json(); + messageDiv.textContent = result.detail || "Failed to unregister"; + messageDiv.className = "error"; + } + messageDiv.classList.remove("hidden"); + setTimeout(() => { + messageDiv.classList.add("hidden"); + }, 5000); + } catch (error) { + messageDiv.textContent = "Failed to unregister. Please try again."; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + console.error("Error unregistering:", error); + } + }); + }); + // Add option to select dropdown const option = document.createElement("option"); option.value = name; @@ -62,6 +105,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 a533b32..5fbe2b8 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -74,6 +74,61 @@ section h3 { margin-bottom: 8px; } +.participants-section { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #e0e0e0; +} + +.participants-section p { + margin-bottom: 10px; + color: #555; + font-size: 14px; +} + +.participants-list { + list-style: none; + margin-left: 0; + font-size: 13px; + color: #666; +} + +.participants-list li { + margin-bottom: 5px; + padding: 4px 8px; + background-color: #f0f7ff; + border-radius: 3px; + border-left: 3px solid #0066cc; + display: flex; + justify-content: space-between; + align-items: center; +} + +.participant-email { + flex-grow: 1; +} + +.delete-participant-btn { + background: none; + border: none; + color: #c62828; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + margin-left: 8px; + transition: color 0.2s, transform 0.1s; +} + +.delete-participant-btn:hover { + color: #d32f2f; + transform: scale(1.2); +} + +.delete-participant-btn:active { + transform: scale(0.95); +} + .form-group { margin-bottom: 15px; } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..739954c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3bbdb25 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from fastapi.testclient import TestClient +from src.app import app + + +@pytest.fixture +def client(): + """Test client fixture for FastAPI app testing.""" + return TestClient(app) \ No newline at end of file diff --git a/tests/test_activities.py b/tests/test_activities.py new file mode 100644 index 0000000..6a8dac2 --- /dev/null +++ b/tests/test_activities.py @@ -0,0 +1,60 @@ +"""Tests for GET /activities endpoint.""" + + +def test_get_activities_returns_all_activities(client): + """Test that GET /activities returns all activities with correct structure.""" + # Arrange: No specific setup needed as activities are predefined in app + + # Act: Make GET request to /activities + response = client.get("/activities") + + # Assert: Response status is 200 + assert response.status_code == 200 + + # Assert: Response contains all 9 activities + activities = response.json() + assert len(activities) == 9 + + # Assert: Each activity has the expected structure + expected_activities = [ + "Chess Club", "Programming Class", "Gym Class", "Basketball Team", + "Tennis Club", "Art Studio", "Drama Club", "Debate Team", "Science Club" + ] + assert set(activities.keys()) == set(expected_activities) + + # Assert: Each activity has required fields + for activity_name, activity_data in activities.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) + + +def test_get_activities_participant_counts(client): + """Test that participant counts are correct for each activity.""" + # Arrange: No specific setup needed + + # Act: Make GET request to /activities + response = client.get("/activities") + + # Assert: Response is successful + assert response.status_code == 200 + + activities = response.json() + + # Assert: Specific participant counts match expected values + expected_counts = { + "Chess Club": 2, # michael@mergington.edu, daniel@mergington.edu + "Programming Class": 2, # emma@mergington.edu, sophia@mergington.edu + "Gym Class": 2, # john@mergington.edu, olivia@mergington.edu + "Basketball Team": 1, # james@mergington.edu + "Tennis Club": 1, # isabella@mergington.edu + "Art Studio": 2, # lucas@mergington.edu, ava@mergington.edu + "Drama Club": 1, # noah@mergington.edu + "Debate Team": 2, # mia@mergington.edu, ethan@mergington.edu + "Science Club": 1, # charlotte@mergington.edu + } + + for activity_name, expected_count in expected_counts.items(): + assert len(activities[activity_name]["participants"]) == expected_count \ No newline at end of file diff --git a/tests/test_signup.py b/tests/test_signup.py new file mode 100644 index 0000000..9e8e054 --- /dev/null +++ b/tests/test_signup.py @@ -0,0 +1,64 @@ +"""Tests for POST /activities/{activity_name}/signup endpoint.""" + + +def test_signup_successful(client): + """Test successful signup for an activity.""" + # Arrange: Choose an existing activity and a new email + activity_name = "Chess Club" + email = "newstudent@mergington.edu" + + # Act: Make POST request to signup endpoint + response = client.post(f"/activities/{activity_name}/signup?email={email}") + + # Assert: Response status is 200 + assert response.status_code == 200 + + # Assert: Response contains success message + result = response.json() + assert "message" in result + assert f"Signed up {email} for {activity_name}" in result["message"] + + # Assert: Participant was added to the activity + activities_response = client.get("/activities") + activities = activities_response.json() + assert email in activities[activity_name]["participants"] + + +def test_signup_non_existent_activity(client): + """Test signup for a non-existent activity returns 404.""" + # Arrange: Use an invalid activity name + invalid_activity = "NonExistentActivity" + email = "student@mergington.edu" + + # Act: Make POST request to signup endpoint + response = client.post(f"/activities/{invalid_activity}/signup?email={email}") + + # Assert: Response status is 404 + assert response.status_code == 404 + + # Assert: Response contains appropriate error message + result = response.json() + assert "detail" in result + assert "Activity not found" in result["detail"] + + +def test_signup_duplicate_registration(client): + """Test that duplicate signup for same activity returns 400.""" + # Arrange: First, sign up a student + activity_name = "Programming Class" + email = "duplicatestudent@mergington.edu" + + # Sign up once (should succeed) + first_response = client.post(f"/activities/{activity_name}/signup?email={email}") + assert first_response.status_code == 200 + + # Act: Attempt to sign up the same student again + second_response = client.post(f"/activities/{activity_name}/signup?email={email}") + + # Assert: Second signup returns 400 + assert second_response.status_code == 400 + + # Assert: Response contains appropriate error message + result = second_response.json() + assert "detail" in result + assert "Student already signed up" in result["detail"] \ No newline at end of file diff --git a/tests/test_unregister.py b/tests/test_unregister.py new file mode 100644 index 0000000..48a0832 --- /dev/null +++ b/tests/test_unregister.py @@ -0,0 +1,69 @@ +"""Tests for DELETE /activities/{activity_name}/participants/{email} endpoint.""" + + +def test_unregister_successful(client): + """Test successful unregister from an activity.""" + # Arrange: First sign up a participant, then unregister them + activity_name = "Gym Class" + email = "tempstudent@mergington.edu" + + # Sign up the participant + signup_response = client.post(f"/activities/{activity_name}/signup?email={email}") + assert signup_response.status_code == 200 + + # Verify they are signed up + activities_response = client.get("/activities") + activities = activities_response.json() + assert email in activities[activity_name]["participants"] + + # Act: Make DELETE request to unregister endpoint + response = client.delete(f"/activities/{activity_name}/participants/{email}") + + # Assert: Response status is 200 + assert response.status_code == 200 + + # Assert: Response contains success message + result = response.json() + assert "message" in result + assert f"Unregistered {email} from {activity_name}" in result["message"] + + # Assert: Participant was removed from the activity + activities_response = client.get("/activities") + activities = activities_response.json() + assert email not in activities[activity_name]["participants"] + + +def test_unregister_non_existent_activity(client): + """Test unregister from a non-existent activity returns 404.""" + # Arrange: Use an invalid activity name + invalid_activity = "NonExistentActivity" + email = "student@mergington.edu" + + # Act: Make DELETE request to unregister endpoint + response = client.delete(f"/activities/{invalid_activity}/participants/{email}") + + # Assert: Response status is 404 + assert response.status_code == 404 + + # Assert: Response contains appropriate error message + result = response.json() + assert "detail" in result + assert "Activity not found" in result["detail"] + + +def test_unregister_non_existent_participant(client): + """Test unregister of a participant not signed up returns 404.""" + # Arrange: Use valid activity but email not registered + activity_name = "Tennis Club" + unregistered_email = "notsignedup@mergington.edu" + + # Act: Make DELETE request to unregister endpoint + response = client.delete(f"/activities/{activity_name}/participants/{unregistered_email}") + + # Assert: Response status is 404 + assert response.status_code == 404 + + # Assert: Response contains appropriate error message + result = response.json() + assert "detail" in result + assert "Participant not found" in result["detail"] \ No newline at end of file