diff --git a/requirements.txt b/requirements.txt
index 97dc7cd..18ac922 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,5 @@
fastapi
uvicorn
+httpx
+pytest
+pytest-asyncio
diff --git a/src/app.py b/src/app.py
index 4ebb1d9..85bc7ff 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 training and matches against other schools",
+ "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM",
+ "max_participants": 22,
+ "participants": ["lucas@mergington.edu", "liam@mergington.edu"]
+ },
+ "Swimming Club": {
+ "description": "Swim training for all skill levels and competitive meets",
+ "schedule": "Mondays and Wednesdays, 6:00 AM - 7:30 AM",
+ "max_participants": 20,
+ "participants": ["mia@mergington.edu", "noah@mergington.edu"]
+ },
+ "Art Studio": {
+ "description": "Explore painting, drawing, and mixed media techniques",
+ "schedule": "Thursdays, 3:30 PM - 5:00 PM",
+ "max_participants": 15,
+ "participants": ["Isabella@mergington.edu", "ava@mergington.edu"]
+ },
+ "Drama Club": {
+ "description": "Act, direct, and produce theatrical performances",
+ "schedule": "Mondays and Fridays, 4:00 PM - 5:30 PM",
+ "max_participants": 25,
+ "participants": ["charlotte@mergington.edu", "amelia@mergington.edu"]
+ },
+ "Math Olympiad": {
+ "description": "Prepare for math competitions and solve advanced problems",
+ "schedule": "Wednesdays, 3:30 PM - 5:00 PM",
+ "max_participants": 15,
+ "participants": ["ethan@mergington.edu", "harper@mergington.edu"]
+ },
+ "Science Club": {
+ "description": "Conduct experiments and explore STEM topics beyond the classroom",
+ "schedule": "Fridays, 3:30 PM - 5:00 PM",
+ "max_participants": 20,
+ "participants": ["james@mergington.edu", "sofia@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}/signup")
+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 registered 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..5ed24ca 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -24,9 +24,42 @@ document.addEventListener("DOMContentLoaded", () => {
${name}
${details.description}
Schedule: ${details.schedule}
- Availability: ${spotsLeft} spots left
+ Availability: ${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} left
+
+ Participants ${details.participants.length} / ${details.max_participants}
+ ${details.participants.length === 0 ? 'No participants yet — be the first!
' : ''}
+
`;
+ if (details.participants.length > 0) {
+ const detailsEl = activityCard.querySelector(".participants-section");
+ const ul = document.createElement("ul");
+ ul.className = "participants-list";
+ details.participants.forEach(p => {
+ const li = document.createElement("li");
+
+ const icon = document.createElement("span");
+ icon.className = "participant-icon";
+ icon.textContent = "\u{1F464}";
+
+ const emailSpan = document.createElement("span");
+ emailSpan.className = "participant-email";
+ emailSpan.textContent = p;
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "delete-participant-btn";
+ deleteBtn.innerHTML = "🗑";
+ deleteBtn.title = `Unregister ${p}`;
+ deleteBtn.addEventListener("click", () => unregisterParticipant(name, p));
+
+ li.appendChild(icon);
+ li.appendChild(emailSpan);
+ li.appendChild(deleteBtn);
+ ul.appendChild(li);
+ });
+ detailsEl.appendChild(ul);
+ }
+
activitiesList.appendChild(activityCard);
// Add option to select dropdown
@@ -41,6 +74,25 @@ document.addEventListener("DOMContentLoaded", () => {
}
}
+ // Unregister a participant from an activity
+ async function unregisterParticipant(activityName, email) {
+ try {
+ const response = await fetch(
+ `/activities/${encodeURIComponent(activityName)}/signup?email=${encodeURIComponent(email)}`,
+ { method: "DELETE" }
+ );
+ const result = await response.json();
+ if (response.ok) {
+ fetchActivities();
+ } else {
+ alert(result.detail || "Failed to unregister participant.");
+ }
+ } catch (error) {
+ console.error("Error unregistering participant:", error);
+ alert("Failed to unregister participant.");
+ }
+ }
+
// Handle form submission
signupForm.addEventListener("submit", async (event) => {
event.preventDefault();
@@ -62,6 +114,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..35d99a0 100644
--- a/src/static/styles.css
+++ b/src/static/styles.css
@@ -74,6 +74,124 @@ section h3 {
margin-bottom: 8px;
}
+.spots-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.85em;
+ font-weight: bold;
+}
+
+.spots-ok {
+ background-color: #e8f5e9;
+ color: #2e7d32;
+ border: 1px solid #a5d6a7;
+}
+
+.spots-low {
+ background-color: #fff3e0;
+ color: #e65100;
+ border: 1px solid #ffcc80;
+}
+
+.spots-full {
+ background-color: #ffebee;
+ color: #c62828;
+ border: 1px solid #ef9a9a;
+}
+
+.participants-section {
+ margin-top: 12px;
+ border-top: 1px dashed #ddd;
+ padding-top: 10px;
+}
+
+.participants-section summary {
+ cursor: pointer;
+ user-select: none;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 0;
+ color: #1a237e;
+}
+
+.participants-section summary::-webkit-details-marker {
+ display: none;
+}
+
+.participants-section summary::after {
+ content: '\25BC';
+ font-size: 0.7em;
+ transition: transform 0.2s;
+ color: #888;
+}
+
+.participants-section[open] summary::after {
+ transform: rotate(180deg);
+}
+
+.participant-count {
+ font-size: 0.8em;
+ background-color: #e8eaf6;
+ color: #3949ab;
+ border-radius: 10px;
+ padding: 1px 7px;
+ margin-left: 8px;
+ font-weight: normal;
+}
+
+.participants-list {
+ list-style: none;
+ margin-top: 10px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.participants-list li {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.9em;
+ color: #444;
+ background-color: #f0f4ff;
+ border-radius: 6px;
+ padding: 5px 10px;
+ border: 1px solid #d0d8f0;
+}
+
+.participant-icon {
+ font-size: 1em;
+}
+
+.delete-participant-btn {
+ margin-left: auto;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1em;
+ padding: 0 2px;
+ color: #c62828;
+ opacity: 0.6;
+ transition: opacity 0.15s, transform 0.15s;
+ line-height: 1;
+}
+
+.delete-participant-btn:hover {
+ opacity: 1;
+ transform: scale(1.2);
+}
+
+.no-participants {
+ margin-top: 10px;
+ font-size: 0.85em;
+ color: #888;
+ 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_app.py b/tests/test_app.py
new file mode 100644
index 0000000..50c2c43
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,126 @@
+"""
+Tests for the Mergington High School API.
+"""
+
+import pytest
+from fastapi.testclient import TestClient
+from src.app import app, activities
+
+
+@pytest.fixture(autouse=True)
+def reset_activities():
+ """Reset activities to their original state before each test."""
+ original_state = {name: {**data, "participants": list(data["participants"])}
+ for name, data in activities.items()}
+ yield
+ activities.clear()
+ activities.update(original_state)
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+# ---------------------------------------------------------------------------
+# GET /activities
+# ---------------------------------------------------------------------------
+
+class TestGetActivities:
+ def test_returns_200(self, client):
+ response = client.get("/activities")
+ assert response.status_code == 200
+
+ def test_returns_dict(self, client):
+ response = client.get("/activities")
+ data = response.json()
+ assert isinstance(data, dict)
+
+ def test_contains_expected_activities(self, client):
+ response = client.get("/activities")
+ data = response.json()
+ assert "Chess Club" in data
+ assert "Programming Class" in data
+ assert "Gym Class" in data
+
+ def test_activity_has_required_fields(self, client):
+ response = client.get("/activities")
+ chess = response.json()["Chess Club"]
+ assert "description" in chess
+ assert "schedule" in chess
+ assert "max_participants" in chess
+ assert "participants" in chess
+
+
+# ---------------------------------------------------------------------------
+# POST /activities/{activity_name}/signup
+# ---------------------------------------------------------------------------
+
+class TestSignup:
+ def test_successful_signup(self, client):
+ response = client.post(
+ "/activities/Chess Club/signup",
+ params={"email": "newstudent@mergington.edu"}
+ )
+ assert response.status_code == 200
+ assert "newstudent@mergington.edu" in response.json()["message"]
+
+ def test_participant_added_to_activity(self, client):
+ email = "teststudent@mergington.edu"
+ client.post("/activities/Chess Club/signup", params={"email": email})
+ response = client.get("/activities")
+ assert email in response.json()["Chess Club"]["participants"]
+
+ def test_signup_nonexistent_activity_returns_404(self, client):
+ response = client.post(
+ "/activities/Nonexistent Activity/signup",
+ params={"email": "student@mergington.edu"}
+ )
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Activity not found"
+
+ def test_duplicate_signup_returns_400(self, client):
+ email = "michael@mergington.edu" # already signed up for Chess Club
+ response = client.post(
+ "/activities/Chess Club/signup",
+ params={"email": email}
+ )
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Student already signed up for this activity"
+
+
+# ---------------------------------------------------------------------------
+# DELETE /activities/{activity_name}/signup
+# ---------------------------------------------------------------------------
+
+class TestUnregister:
+ def test_successful_unregister(self, client):
+ email = "michael@mergington.edu" # already in Chess Club
+ response = client.delete(
+ "/activities/Chess Club/signup",
+ params={"email": email}
+ )
+ assert response.status_code == 200
+ assert email in response.json()["message"]
+
+ def test_participant_removed_from_activity(self, client):
+ email = "michael@mergington.edu"
+ client.delete("/activities/Chess Club/signup", params={"email": email})
+ response = client.get("/activities")
+ assert email not in response.json()["Chess Club"]["participants"]
+
+ def test_unregister_nonexistent_activity_returns_404(self, client):
+ response = client.delete(
+ "/activities/Nonexistent Activity/signup",
+ params={"email": "student@mergington.edu"}
+ )
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Activity not found"
+
+ def test_unregister_not_enrolled_returns_400(self, client):
+ response = client.delete(
+ "/activities/Chess Club/signup",
+ params={"email": "notregistered@mergington.edu"}
+ )
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Student is not registered for this activity"