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..246ff58 100644
--- a/src/app.py
+++ b/src/app.py
@@ -33,11 +33,47 @@
"max_participants": 20,
"participants": ["emma@mergington.edu", "sophia@mergington.edu"]
},
+ "Debate Team": {
+ "description": "Develop critical thinking and public speaking skills through competitive debate",
+ "schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
+ "max_participants": 15,
+ "participants": ["alex@mergington.edu"]
+ },
+ "Robotics Club": {
+ "description": "Design, build, and program robots for competitions",
+ "schedule": "Tuesdays, Wednesdays, Thursdays, 3:30 PM - 5:00 PM",
+ "max_participants": 25,
+ "participants": ["james@mergington.edu", "sarah@mergington.edu"]
+ },
"Gym Class": {
"description": "Physical education and sports activities",
"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 games",
+ "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM",
+ "max_participants": 20,
+ "participants": ["marcus@mergington.edu", "lucas@mergington.edu"]
+ },
+ "Track and Field": {
+ "description": "Training for running, jumping, and throwing events",
+ "schedule": "Mondays, Wednesdays, Fridays, 3:30 PM - 5:00 PM",
+ "max_participants": 25,
+ "participants": ["ava@mergington.edu"]
+ },
+ "Art Club": {
+ "description": "Explore painting, drawing, and sculpture with expert instructors",
+ "schedule": "Wednesdays, 3:30 PM - 5:00 PM",
+ "max_participants": 18,
+ "participants": ["isabella@mergington.edu", "grace@mergington.edu"]
+ },
+ "Music Ensemble": {
+ "description": "Join our orchestra, band, or choir and perform at school events",
+ "schedule": "Tuesdays and Fridays, 3:30 PM - 4:30 PM",
+ "max_participants": 40,
+ "participants": ["noah@mergington.edu", "mia@mergington.edu", "lucas@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")
+
# 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 currently signed up
+ if email not in activity["participants"]:
+ raise HTTPException(status_code=404, detail="Student is not signed up 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..5f15bf1 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -4,14 +4,27 @@ document.addEventListener("DOMContentLoaded", () => {
const signupForm = document.getElementById("signup-form");
const messageDiv = document.getElementById("message");
+ function showMessage(text, type) {
+ messageDiv.textContent = text;
+ messageDiv.className = type;
+ messageDiv.classList.remove("hidden");
+
+ // Hide message after 5 seconds
+ setTimeout(() => {
+ messageDiv.classList.add("hidden");
+ }, 5000);
+ }
+
// Function to fetch activities from API
async function fetchActivities() {
try {
- const response = await fetch("/activities");
+ // Add a cache-busting query and disable cache to always show latest participant updates.
+ const response = await fetch(`/activities?ts=${Date.now()}`, { cache: "no-store" });
const activities = await response.json();
// Clear loading message
activitiesList.innerHTML = "";
+ activitySelect.innerHTML = '';
// Populate activities list
Object.entries(activities).forEach(([name, details]) => {
@@ -19,12 +32,25 @@ document.addEventListener("DOMContentLoaded", () => {
activityCard.className = "activity-card";
const spotsLeft = details.max_participants - details.participants.length;
+ const participantsMarkup =
+ details.participants.length > 0
+ ? `
${details.participants
+ .map(
+ (participant) =>
+ `- ${participant}
`
+ )
+ .join("")}
`
+ : 'No participants yet.
';
activityCard.innerHTML = `
${name}
${details.description}
Schedule: ${details.schedule}
Availability: ${spotsLeft} spots left
+
+
Participants
+ ${participantsMarkup}
+
`;
activitiesList.appendChild(activityCard);
@@ -53,31 +79,54 @@ document.addEventListener("DOMContentLoaded", () => {
`/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`,
{
method: "POST",
+ cache: "no-store",
}
);
const result = await response.json();
if (response.ok) {
- messageDiv.textContent = result.message;
- messageDiv.className = "success";
+ showMessage(result.message, "success");
signupForm.reset();
+ await fetchActivities();
} else {
- messageDiv.textContent = result.detail || "An error occurred";
- messageDiv.className = "error";
+ showMessage(result.detail || "An error occurred", "error");
}
+ } catch (error) {
+ showMessage("Failed to sign up. Please try again.", "error");
+ console.error("Error signing up:", error);
+ }
+ });
+
+ activitiesList.addEventListener("click", async (event) => {
+ const deleteButton = event.target.closest(".participant-delete");
+ if (!deleteButton) {
+ return;
+ }
- messageDiv.classList.remove("hidden");
+ const activityName = decodeURIComponent(deleteButton.dataset.activity || "");
+ const email = decodeURIComponent(deleteButton.dataset.email || "");
- // Hide message after 5 seconds
- setTimeout(() => {
- messageDiv.classList.add("hidden");
- }, 5000);
+ try {
+ const response = await fetch(
+ `/activities/${encodeURIComponent(activityName)}/signup?email=${encodeURIComponent(email)}`,
+ {
+ method: "DELETE",
+ cache: "no-store",
+ }
+ );
+
+ const result = await response.json();
+
+ if (response.ok) {
+ showMessage(result.message, "success");
+ await fetchActivities();
+ } else {
+ showMessage(result.detail || "Failed to unregister participant.", "error");
+ }
} catch (error) {
- messageDiv.textContent = "Failed to sign up. Please try again.";
- messageDiv.className = "error";
- messageDiv.classList.remove("hidden");
- console.error("Error signing up:", error);
+ showMessage("Failed to unregister participant. Please try again.", "error");
+ console.error("Error unregistering participant:", error);
}
});
diff --git a/src/static/styles.css b/src/static/styles.css
index a533b32..fd39ce1 100644
--- a/src/static/styles.css
+++ b/src/static/styles.css
@@ -74,6 +74,67 @@ section h3 {
margin-bottom: 8px;
}
+.participants-section {
+ margin-top: 12px;
+ padding: 12px;
+ background: linear-gradient(135deg, #eef4ff 0%, #f7fbff 100%);
+ border: 1px solid #d8e6ff;
+ border-radius: 8px;
+}
+
+.participants-title {
+ margin-bottom: 8px;
+ color: #1a237e;
+ letter-spacing: 0.2px;
+}
+
+.participants-list {
+ margin: 0;
+ padding-left: 0;
+ list-style: none;
+}
+
+.participants-list li {
+ margin-bottom: 6px;
+ color: #29406b;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.participants-list li:last-child {
+ margin-bottom: 0;
+}
+
+.participant-email {
+ overflow-wrap: anywhere;
+}
+
+.participant-delete {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 1px solid #d95757;
+ background-color: #fff;
+ color: #c62828;
+ font-weight: bold;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+ transition: background-color 0.2s ease, color 0.2s ease;
+}
+
+.participant-delete:hover {
+ background-color: #ffebee;
+}
+
+.participants-empty {
+ margin: 0;
+ color: #5f6b7a;
+ font-style: italic;
+}
+
.form-group {
margin-bottom: 15px;
}
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..9c73cc0
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,122 @@
+"""Backend API tests for the FastAPI activity service."""
+
+from copy import deepcopy
+
+import pytest
+from fastapi.testclient import TestClient
+
+from src.app import activities, app
+
+client = TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def reset_activities_state():
+ """Reset in-memory activity data before each test for isolation."""
+ # Arrange
+ snapshot = deepcopy(activities)
+
+ # Act
+ yield
+
+ # Assert
+ activities.clear()
+ activities.update(snapshot)
+
+
+def test_get_activities_returns_all_activities():
+ # 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 data
+
+ for activity in data.values():
+ assert expected_keys.issubset(activity.keys())
+
+
+def test_signup_for_activity_succeeds_for_new_student():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "new.student@mergington.edu"
+
+ # Act
+ response = client.post(f"/activities/{activity_name}/signup", params={"email": email})
+
+ # Assert
+ assert response.status_code == 200
+ assert response.json() == {"message": f"Signed up {email} for {activity_name}"}
+ assert email in activities[activity_name]["participants"]
+
+
+def test_signup_for_activity_fails_when_already_signed_up():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "michael@mergington.edu"
+
+ # Act
+ response = client.post(f"/activities/{activity_name}/signup", params={"email": email})
+
+ # Assert
+ assert response.status_code == 400
+ assert response.json() == {"detail": "Student already signed up"}
+
+
+def test_signup_for_activity_fails_for_unknown_activity():
+ # Arrange
+ activity_name = "Unknown 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"}
+
+
+def test_unregister_from_activity_succeeds_for_signed_up_student():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "michael@mergington.edu"
+ assert email in activities[activity_name]["participants"]
+
+ # Act
+ response = client.delete(f"/activities/{activity_name}/signup", params={"email": email})
+
+ # Assert
+ assert response.status_code == 200
+ assert response.json() == {"message": f"Unregistered {email} from {activity_name}"}
+ assert email not in activities[activity_name]["participants"]
+
+
+def test_unregister_from_activity_fails_for_unknown_activity():
+ # Arrange
+ activity_name = "Unknown 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"}
+
+
+def test_unregister_from_activity_fails_when_student_not_signed_up():
+ # Arrange
+ activity_name = "Chess Club"
+ email = "not.signed.up@mergington.edu"
+
+ # Act
+ response = client.delete(f"/activities/{activity_name}/signup", params={"email": email})
+
+ # Assert
+ assert response.status_code == 404
+ assert response.json() == {"detail": "Student is not signed up for this activity"}