diff --git a/requirements.txt b/requirements.txt
index 97dc7cd..7dd96cd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,6 @@
fastapi
uvicorn
+
+# Test dependencies
+pytest
+requests
diff --git a/src/app.py b/src/app.py
index 4ebb1d9..387f22f 100644
--- a/src/app.py
+++ b/src/app.py
@@ -38,6 +38,45 @@
"schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM",
"max_participants": 30,
"participants": ["john@mergington.edu", "olivia@mergington.edu"]
+ },
+ # Sports-related activities
+ "Soccer Team": {
+ "description": "Competitive soccer team practicing tactics and fitness",
+ "schedule": "Mondays, Wednesdays, 4:00 PM - 6:00 PM",
+ "max_participants": 22,
+ "participants": ["alex@mergington.edu", "nina@mergington.edu"]
+ },
+ "Basketball Team": {
+ "description": "Team practices and inter-school games",
+ "schedule": "Tuesdays, Thursdays, 4:00 PM - 6:00 PM",
+ "max_participants": 15,
+ "participants": ["tyler@mergington.edu", "maya@mergington.edu"]
+ },
+ # Artistic activities
+ "Drama Club": {
+ "description": "Acting, script work, and stage production for school plays",
+ "schedule": "Wednesdays, 3:30 PM - 5:30 PM",
+ "max_participants": 25,
+ "participants": ["lucy@mergington.edu", "robert@mergington.edu"]
+ },
+ "Music Ensemble": {
+ "description": "Instrumental and vocal ensemble rehearsals and performances",
+ "schedule": "Fridays, 4:00 PM - 6:00 PM",
+ "max_participants": 30,
+ "participants": ["isabella@mergington.edu", "henry@mergington.edu"]
+ },
+ # Intellectual activities
+ "Debate Team": {
+ "description": "Practice formal debating, public speaking, and research",
+ "schedule": "Tuesdays, 5:00 PM - 6:30 PM",
+ "max_participants": 18,
+ "participants": ["oliver@mergington.edu", "grace@mergington.edu"]
+ },
+ "Science Club": {
+ "description": "Hands-on experiments, science fairs, and research projects",
+ "schedule": "Thursdays, 3:30 PM - 5:00 PM",
+ "max_participants": 20,
+ "participants": ["liam@mergington.edu", "ava@mergington.edu"]
}
}
@@ -62,6 +101,27 @@ 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")
+def unregister_participant(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 participant exists
+ if email not in activity["participants"]:
+ raise HTTPException(status_code=404, detail="Participant not found in 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..ab607bb 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -13,6 +13,12 @@ document.addEventListener("DOMContentLoaded", () => {
// Clear loading message
activitiesList.innerHTML = "";
+ // Clear existing options except placeholder
+ // Keep the first placeholder option if it exists
+ while (activitySelect.options.length > 1) {
+ activitySelect.remove(1);
+ }
+
// Populate activities list
Object.entries(activities).forEach(([name, details]) => {
const activityCard = document.createElement("div");
@@ -20,14 +26,88 @@ document.addEventListener("DOMContentLoaded", () => {
const spotsLeft = details.max_participants - details.participants.length;
- activityCard.innerHTML = `
-
${name}
- ${details.description}
- Schedule: ${details.schedule}
- Availability: ${spotsLeft} spots left
- `;
+ // Build activity card skeleton
+ activityCard.innerHTML = `
+ ${name}
+ ${details.description}
+ Schedule: ${details.schedule}
+ Availability: ${spotsLeft} spots left
+
+ `;
+
+ activitiesList.appendChild(activityCard);
+
+ // Populate participants list programmatically so we can attach handlers
+ const participantsContainer = activityCard.querySelector(
+ ".participants-container"
+ );
+
+ if (details.participants && details.participants.length > 0) {
+ const ul = document.createElement("ul");
+ ul.className = "participants-list";
+
+ details.participants.forEach((p) => {
+ const li = document.createElement("li");
+ li.className = "participant-item";
+ const span = document.createElement("span");
+ span.textContent = p;
+
+ const btn = document.createElement("button");
+ btn.className = "delete-btn";
+ btn.setAttribute("aria-label", `Unregister ${p}`);
+ btn.title = "Unregister participant";
+ btn.innerHTML = "✖"; // heavy multiplication X
+
+ // Click handler to unregister
+ btn.addEventListener("click", async () => {
+ try {
+ const resp = await fetch(
+ `/activities/${encodeURIComponent(name)}/participants?email=${encodeURIComponent(p)}`,
+ { method: "DELETE" }
+ );
+
+ if (resp.ok) {
+ // remove li from DOM
+ li.remove();
+
+ // If no more participants, show empty hint
+ if (ul.querySelectorAll("li").length === 0) {
+ participantsContainer.innerHTML =
+ 'No participants yet.
';
+ }
+
+ // Update availability counter
+ const availEl = activityCard.querySelector(".availability");
+ if (availEl) {
+ const match = availEl.textContent.match(/(\d+) spots left/);
+ if (match) {
+ const current = parseInt(match[1], 10);
+ availEl.textContent = `Availability: ${current + 1} spots left`;
+ }
+ }
+ } else {
+ const result = await resp.json();
+ alert(result.detail || "Failed to unregister participant");
+ }
+ } catch (err) {
+ console.error("Error unregistering participant:", err);
+ alert("Network error while unregistering participant");
+ }
+ });
+
+ li.appendChild(span);
+ li.appendChild(btn);
+ ul.appendChild(li);
+ });
- activitiesList.appendChild(activityCard);
+ participantsContainer.appendChild(ul);
+ } else {
+ participantsContainer.innerHTML =
+ 'No participants yet.
';
+ }
// Add option to select dropdown
const option = document.createElement("option");
@@ -62,6 +142,8 @@ document.addEventListener("DOMContentLoaded", () => {
messageDiv.textContent = result.message;
messageDiv.className = "success";
signupForm.reset();
+ // Refresh activities so the newly signed up participant appears immediately
+ await 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..ab1c7e9 100644
--- a/src/static/styles.css
+++ b/src/static/styles.css
@@ -24,6 +24,7 @@ header {
border-radius: 5px;
}
+
header h1 {
margin-bottom: 10px;
}
@@ -142,3 +143,62 @@ footer {
padding: 20px;
color: #666;
}
+
+.participants-section {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px dashed #eee;
+}
+
+.participants-section strong {
+ display: block;
+ margin-bottom: 8px;
+ color: #333;
+}
+
+.participants-list {
+ list-style: none;
+ padding-left: 0;
+ margin: 0;
+ display: block;
+}
+
+.participants-list li,
+.participant-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 6px;
+ padding: 6px 8px;
+ background: linear-gradient(180deg, #ffffff, #fbfbfb);
+ border-radius: 4px;
+ border: 1px solid #f0f0f0;
+ color: #444;
+ font-size: 14px;
+}
+
+.participants-list .delete-btn,
+.participant-item .delete-btn {
+ background: transparent;
+ border: none;
+ color: #c62828;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ padding: 4px 6px;
+ border-radius: 4px;
+}
+
+.participants-list .delete-btn:hover,
+.participant-item .delete-btn:hover {
+ color: #e53935;
+ background: rgba(229, 57, 53, 0.06);
+}
+
+/* A subtle "no participants" hint */
+.participants-empty {
+ font-style: italic;
+ color: #777;
+ padding: 6px 8px;
+}
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..62587ac
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,53 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from src.app import app, activities
+
+
+client = TestClient(app)
+
+
+def test_get_activities():
+ resp = client.get("/activities")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, dict)
+ assert "Chess Club" in data
+
+
+def test_signup_and_get_updates():
+ activity = "Chess Club"
+ email = "test_student@example.com"
+
+ # Ensure clean state
+ if email in activities[activity]["participants"]:
+ activities[activity]["participants"].remove(email)
+
+ resp = client.post(f"/activities/{activity}/signup?email={email}")
+ assert resp.status_code == 200
+ result = resp.json()
+ assert "Signed up" in result["message"]
+
+ # Verify the in-memory activities reflect the new participant
+ resp2 = client.get("/activities")
+ data = resp2.json()
+ assert email in data[activity]["participants"]
+
+
+def test_unregister_participant():
+ activity = "Chess Club"
+ email = "test_student_to_remove@example.com"
+
+ # Ensure participant exists first
+ if email not in activities[activity]["participants"]:
+ activities[activity]["participants"].append(email)
+
+ resp = client.delete(f"/activities/{activity}/participants?email={email}")
+ assert resp.status_code == 200
+ result = resp.json()
+ assert "Unregistered" in result["message"]
+
+ # Confirm removal
+ resp2 = client.get("/activities")
+ data = resp2.json()
+ assert email not in data[activity]["participants"]