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..81efb31 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": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
+ "max_participants": 15,
+ "participants": ["james@mergington.edu"]
+ },
+ "Tennis Club": {
+ "description": "Tennis instruction and match play",
+ "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:00 PM",
+ "max_participants": 16,
+ "participants": ["sarah@mergington.edu", "alex@mergington.edu"]
+ },
+ "Drama Club": {
+ "description": "Perform in plays and musicals",
+ "schedule": "Wednesdays and Fridays, 3:30 PM - 5:00 PM",
+ "max_participants": 25,
+ "participants": ["grace@mergington.edu"]
+ },
+ "Art Studio": {
+ "description": "Painting, drawing, and sculpture techniques",
+ "schedule": "Mondays and Thursdays, 3:30 PM - 4:45 PM",
+ "max_participants": 18,
+ "participants": ["isabella@mergington.edu", "lucas@mergington.edu"]
+ },
+ "Debate Team": {
+ "description": "Develop argumentation and public speaking skills",
+ "schedule": "Tuesdays, 4:00 PM - 5:30 PM",
+ "max_participants": 10,
+ "participants": ["benjamin@mergington.edu"]
+ },
+ "Science Club": {
+ "description": "Explore experiments and scientific discovery",
+ "schedule": "Fridays, 3:30 PM - 4:45 PM",
+ "max_participants": 20,
+ "participants": ["nina@mergington.edu", "ryan@mergington.edu"]
}
}
@@ -62,6 +98,33 @@ def signup_for_activity(activity_name: str, email: str):
# Get the specific activity
activity = activities[activity_name]
+ # Check if student is already signed up
+ if email in activity["participants"]:
+ raise HTTPException(status_code=400, detail="Student already signed up for this activity")
+
+ # Check if activity is full
+ if len(activity["participants"]) >= activity["max_participants"]:
+ raise HTTPException(status_code=400, detail="Activity is full")
+
# Add student
activity["participants"].append(email)
return {"message": f"Signed up {email} for {activity_name}"}
+
+
+@app.delete("/activities/{activity_name}/participants/{email}")
+def remove_participant(activity_name: str, email: str):
+ """Remove a participant 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 exists
+ if email not in activity["participants"]:
+ raise HTTPException(status_code=404, detail="Participant not found")
+
+ # Remove participant
+ activity["participants"].remove(email)
+ return {"message": f"Removed {email} from {activity_name}"}
+
diff --git a/src/static/app.js b/src/static/app.js
index dcc1e38..16f2ca8 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -25,6 +25,19 @@ document.addEventListener("DOMContentLoaded", () => {
${details.description}
Schedule: ${details.schedule}
Availability: ${spotsLeft} spots left
+
+
Participants (${details.participants.length}):
+
+ ${details.participants.length > 0
+ ? details.participants.map(email => `
+ -
+ ${email}
+
+
+ `).join('')
+ : '- No participants yet
'}
+
+
`;
activitiesList.appendChild(activityCard);
@@ -62,6 +75,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";
@@ -81,6 +95,47 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
+ // Handle delete participant button clicks
+ document.addEventListener("click", async (event) => {
+ if (event.target.classList.contains("delete-participant-btn")) {
+ const activity = event.target.dataset.activity;
+ const email = event.target.dataset.email;
+
+ if (!confirm(`Remove ${email} from ${activity}?`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/activities/${encodeURIComponent(activity)}/participants/${encodeURIComponent(email)}`,
+ { method: "DELETE" }
+ );
+
+ if (response.ok) {
+ // Refresh activities list
+ fetchActivities();
+ messageDiv.textContent = `Removed ${email} from ${activity}`;
+ messageDiv.className = "success";
+ messageDiv.classList.remove("hidden");
+
+ setTimeout(() => {
+ messageDiv.classList.add("hidden");
+ }, 5000);
+ } else {
+ const result = await response.json();
+ messageDiv.textContent = result.detail || "Failed to remove participant";
+ messageDiv.className = "error";
+ messageDiv.classList.remove("hidden");
+ }
+ } catch (error) {
+ messageDiv.textContent = "Error removing participant";
+ messageDiv.className = "error";
+ messageDiv.classList.remove("hidden");
+ console.error("Error removing participant:", error);
+ }
+ }
+ });
+
// Initialize app
fetchActivities();
});
diff --git a/src/static/styles.css b/src/static/styles.css
index a533b32..5af7943 100644
--- a/src/static/styles.css
+++ b/src/static/styles.css
@@ -74,6 +74,64 @@ section h3 {
margin-bottom: 8px;
}
+.participants-section {
+ margin-top: 15px;
+ padding-top: 15px;
+ border-top: 2px solid #e0e0e0;
+}
+
+.participants-section strong {
+ display: block;
+ margin-bottom: 8px;
+ color: #1a237e;
+ font-size: 14px;
+}
+
+.participants-section ul {
+ list-style: none;
+ margin-left: 0;
+ padding-left: 0;
+}
+
+.participant-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 14px;
+ color: #555;
+ margin-bottom: 6px;
+ padding: 8px;
+ background-color: #f0f0f0;
+ border-radius: 3px;
+}
+
+.participant-item span {
+ word-break: break-word;
+ flex: 1;
+}
+
+.delete-participant-btn {
+ background-color: #d32f2f;
+ color: white;
+ border: none;
+ padding: 4px 8px;
+ font-size: 14px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ margin-left: 10px;
+ flex-shrink: 0;
+}
+
+.delete-participant-btn:hover {
+ background-color: #b71c1c;
+}
+
+.participants-section li.no-participants {
+ color: #999;
+ font-style: italic;
+}
+
.form-group {
margin-bottom: 15px;
}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_activities.py b/tests/test_activities.py
new file mode 100644
index 0000000..0b7be28
--- /dev/null
+++ b/tests/test_activities.py
@@ -0,0 +1,176 @@
+"""
+Tests for Mergington High School extracurricular activities API
+Using AAA (Arrange-Act-Assert) pattern
+"""
+
+from fastapi.testclient import TestClient
+from src.app import app
+
+client = TestClient(app)
+
+
+class TestGetActivities:
+ """Test suite for GET /activities endpoint"""
+
+ def test_get_activities_returns_all_activities(self):
+ # Arrange
+ expected_keys = {"Chess Club", "Programming Class", "Gym Class",
+ "Basketball Team", "Tennis Club", "Drama Club",
+ "Art Studio", "Debate Team", "Science Club"}
+
+ # Act
+ response = client.get("/activities")
+
+ # Assert
+ assert response.status_code == 200
+ activities = response.json()
+ assert set(activities.keys()) == expected_keys
+
+ def test_get_activities_returns_correct_structure(self):
+ # Arrange
+ required_fields = {"description", "schedule", "max_participants", "participants"}
+
+ # Act
+ response = client.get("/activities")
+ activities = response.json()
+
+ # Assert
+ assert response.status_code == 200
+ for activity_name, activity_data in activities.items():
+ assert set(activity_data.keys()) == required_fields
+ assert isinstance(activity_data["participants"], list)
+ assert isinstance(activity_data["max_participants"], int)
+
+
+class TestSignupForActivity:
+ """Test suite for POST /activities/{activity_name}/signup endpoint"""
+
+ def test_signup_success(self):
+ # Arrange
+ test_email = "test.student@mergington.edu"
+ activity_name = "Chess Club"
+ initial_participant_count = len(client.get("/activities").json()[activity_name]["participants"])
+
+ # Act
+ response = client.post(
+ f"/activities/{activity_name}/signup",
+ params={"email": test_email}
+ )
+
+ # Assert
+ assert response.status_code == 200
+ assert "Signed up" in response.json()["message"]
+
+ # Verify participant was added
+ updated_activities = client.get("/activities").json()
+ assert test_email in updated_activities[activity_name]["participants"]
+ assert len(updated_activities[activity_name]["participants"]) == initial_participant_count + 1
+
+ def test_signup_duplicate_participant_error(self):
+ # Arrange
+ test_email = "duplicate.test@mergington.edu"
+ activity_name = "Programming Class"
+
+ # Sign up once (should succeed)
+ client.post(f"/activities/{activity_name}/signup", params={"email": test_email})
+
+ # Act - Try to sign up again
+ response = client.post(
+ f"/activities/{activity_name}/signup",
+ params={"email": test_email}
+ )
+
+ # Assert
+ assert response.status_code == 400
+ assert "already signed up" in response.json()["detail"]
+
+ def test_signup_invalid_activity_error(self):
+ # Arrange
+ test_email = "test@mergington.edu"
+ invalid_activity = "Nonexistent Club"
+
+ # Act
+ response = client.post(
+ f"/activities/{invalid_activity}/signup",
+ params={"email": test_email}
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert "Activity not found" in response.json()["detail"]
+
+ def test_signup_activity_full_error(self):
+ # Arrange
+ activity_name = "Debate Team" # Has max_participants: 10
+ activities = client.get("/activities").json()
+
+ # Fill up the activity
+ test_emails = [f"student{i}@mergington.edu" for i in range(10)]
+ for email in test_emails:
+ client.post(f"/activities/{activity_name}/signup", params={"email": email})
+
+ # Act - Try to sign up when full
+ response = client.post(
+ f"/activities/{activity_name}/signup",
+ params={"email": "extra.student@mergington.edu"}
+ )
+
+ # Assert
+ assert response.status_code == 400
+ assert "full" in response.json()["detail"]
+
+
+class TestRemoveParticipant:
+ """Test suite for DELETE /activities/{activity_name}/participants/{email} endpoint"""
+
+ def test_remove_participant_success(self):
+ # Arrange
+ test_email = "remove.test@mergington.edu"
+ activity_name = "Tennis Club"
+
+ # Sign up the participant
+ client.post(f"/activities/{activity_name}/signup", params={"email": test_email})
+ activities = client.get("/activities").json()
+ initial_count = len(activities[activity_name]["participants"])
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants/{test_email}"
+ )
+
+ # Assert
+ assert response.status_code == 200
+ assert "Removed" in response.json()["message"]
+
+ # Verify participant was removed
+ updated_activities = client.get("/activities").json()
+ assert test_email not in updated_activities[activity_name]["participants"]
+ assert len(updated_activities[activity_name]["participants"]) == initial_count - 1
+
+ def test_remove_participant_not_found_error(self):
+ # Arrange
+ nonexistent_email = "notinactivity@mergington.edu"
+ activity_name = "Art Studio"
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants/{nonexistent_email}"
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert "Participant not found" in response.json()["detail"]
+
+ def test_remove_participant_activity_not_found_error(self):
+ # Arrange
+ test_email = "test@mergington.edu"
+ invalid_activity = "Fake Club"
+
+ # Act
+ response = client.delete(
+ f"/activities/{invalid_activity}/participants/{test_email}"
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert "Activity not found" in response.json()["detail"]