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..474a9a3 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",
+ "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM",
+ "max_participants": 22,
+ "participants": ["lucas@mergington.edu", "mia@mergington.edu"]
+ },
+ "Basketball Team": {
+ "description": "Basketball drills, scrimmages, and league games",
+ "schedule": "Wednesdays and Fridays, 3:30 PM - 5:00 PM",
+ "max_participants": 15,
+ "participants": ["liam@mergington.edu", "noah@mergington.edu"]
+ },
+ "Art Club": {
+ "description": "Explore painting, drawing, and mixed media techniques",
+ "schedule": "Mondays, 3:30 PM - 5:00 PM",
+ "max_participants": 15,
+ "participants": ["ava@mergington.edu", "isabella@mergington.edu"]
+ },
+ "Drama Club": {
+ "description": "Theater acting, stagecraft, and seasonal performances",
+ "schedule": "Tuesdays and Thursdays, 3:30 PM - 5:00 PM",
+ "max_participants": 20,
+ "participants": ["amelia@mergington.edu", "harper@mergington.edu"]
+ },
+ "Debate Club": {
+ "description": "Practice public speaking and compete in debate tournaments",
+ "schedule": "Wednesdays, 3:30 PM - 5:00 PM",
+ "max_participants": 16,
+ "participants": ["ethan@mergington.edu", "charlotte@mergington.edu"]
+ },
+ "Science Club": {
+ "description": "Hands-on experiments and science fair preparation",
+ "schedule": "Fridays, 3:30 PM - 5:00 PM",
+ "max_participants": 18,
+ "participants": ["james@mergington.edu", "elijah@mergington.edu"]
}
}
@@ -62,6 +98,25 @@ 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="Already signed up")
+
# 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"""
+ if activity_name not in activities:
+ raise HTTPException(status_code=404, detail="Activity not found")
+
+ activity = activities[activity_name]
+
+ if email not in activity["participants"]:
+ raise HTTPException(status_code=400, detail="Student not signed up for this activity")
+
+ 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..13c33be 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -20,11 +20,24 @@ document.addEventListener("DOMContentLoaded", () => {
const spotsLeft = details.max_participants - details.participants.length;
+ const participantsList = details.participants
+ .map(
+ (p) =>
+ `
${p}`
+ )
+ .join("");
+
activityCard.innerHTML = `
${name}
${details.description}
Schedule: ${details.schedule}
Availability: ${spotsLeft} spots left
+
+
Participants:
+
+ ${participantsList || "- 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,41 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
+ // Handle participant removal
+ activitiesList.addEventListener("click", async (event) => {
+ const btn = event.target.closest(".remove-btn");
+ if (!btn) return;
+
+ const activity = btn.dataset.activity;
+ const email = btn.dataset.email;
+
+ try {
+ const response = await fetch(
+ `/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`,
+ { method: "DELETE" }
+ );
+
+ const result = await response.json();
+
+ if (response.ok) {
+ messageDiv.textContent = result.message;
+ messageDiv.className = "success";
+ fetchActivities();
+ } else {
+ messageDiv.textContent = result.detail || "An error occurred";
+ 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);
+ }
+ });
+
// Initialize app
fetchActivities();
});
diff --git a/src/static/styles.css b/src/static/styles.css
index a533b32..8d73542 100644
--- a/src/static/styles.css
+++ b/src/static/styles.css
@@ -74,6 +74,53 @@ section h3 {
margin-bottom: 8px;
}
+.participants-section {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid #e0e0e0;
+}
+
+.participants-list {
+ list-style: none;
+ padding: 0;
+ margin-top: 6px;
+}
+
+.participants-list li {
+ padding: 4px 10px;
+ margin-bottom: 4px;
+ background-color: #e8eaf6;
+ color: #1a237e;
+ border-radius: 12px;
+ font-size: 14px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-right: 4px;
+}
+
+.remove-btn {
+ background: none;
+ border: none;
+ color: #c62828;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 0 2px;
+ line-height: 1;
+ border-radius: 50%;
+ transition: background-color 0.2s;
+}
+
+.remove-btn:hover {
+ background-color: #ffcdd2;
+}
+
+.participants-list li.no-participants {
+ background-color: transparent;
+ 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_app.py b/tests/test_app.py
new file mode 100644
index 0000000..a6fd48f
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,150 @@
+import copy
+import pytest
+from fastapi.testclient import TestClient
+from src.app import app, activities
+
+@pytest.fixture(autouse=True)
+def reset_activities():
+ """Reset activities to original state before each test."""
+ original = copy.deepcopy(activities)
+ yield
+ activities.clear()
+ activities.update(original)
+
+
+client = TestClient(app)
+
+
+# ── GET / ────────────────────────────────────────────────────────────────
+
+def test_root_redirect():
+ # Arrange
+ url = "/"
+
+ # Act
+ response = client.get(url, follow_redirects=False)
+
+ # Assert
+ assert response.status_code == 307
+ assert response.headers["location"] == "/static/index.html"
+
+
+# ── GET /activities ──────────────────────────────────────────────────────
+
+def test_get_activities_returns_all():
+ # Arrange
+ expected_keys = {"description", "schedule", "max_participants", "participants"}
+
+ # Act
+ response = client.get("/activities")
+
+ # Assert
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, dict)
+ assert len(data) == len(activities)
+ for name, details in data.items():
+ assert expected_keys == set(details.keys())
+ assert isinstance(details["participants"], list)
+
+
+# ── POST /activities/{name}/signup ───────────────────────────────────────
+
+def test_signup_success():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "newstudent@mergington.edu"
+
+ # Act
+ response = client.post(
+ f"/activities/{activity_name}/signup",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 200
+ assert email in activities[activity_name]["participants"]
+ assert response.json()["message"] == f"Signed up {email} for {activity_name}"
+
+
+def test_signup_duplicate():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "michael@mergington.edu" # already in participants
+
+ # Act
+ response = client.post(
+ f"/activities/{activity_name}/signup",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Already signed up"
+
+
+def test_signup_unknown_activity():
+ # Arrange
+ activity_name = "Nonexistent Club"
+ email = "student@mergington.edu"
+
+ # Act
+ response = client.post(
+ f"/activities/{activity_name}/signup",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Activity not found"
+
+
+# ── DELETE /activities/{name}/signup ─────────────────────────────────────
+
+def test_unregister_success():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "michael@mergington.edu" # existing participant
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/signup",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 200
+ assert email not in activities[activity_name]["participants"]
+ assert response.json()["message"] == f"Unregistered {email} from {activity_name}"
+
+
+def test_unregister_not_signed_up():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "unknown@mergington.edu"
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/signup",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 400
+ assert response.json()["detail"] == "Student not signed up for this activity"
+
+
+def test_unregister_unknown_activity():
+ # Arrange
+ activity_name = "Nonexistent Club"
+ email = "student@mergington.edu"
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/signup",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Activity not found"