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/README.md b/src/README.md
index a90534b..4da4615 100644
--- a/src/README.md
+++ b/src/README.md
@@ -12,7 +12,7 @@ A super simple FastAPI application that allows students to view and sign up for
1. Install the dependencies:
```
- pip install fastapi uvicorn
+ pip install -r requirements.txt
```
2. Run the application:
@@ -25,6 +25,14 @@ A super simple FastAPI application that allows students to view and sign up for
- API documentation: http://localhost:8000/docs
- Alternative documentation: http://localhost:8000/redoc
+## Run Tests
+
+From the repository root, run:
+
+```
+pytest -q
+```
+
## API Endpoints
| Method | Endpoint | Description |
diff --git a/src/app.py b/src/app.py
index 4ebb1d9..f5d5a24 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 with team training and games",
+ "schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
+ "max_participants": 15,
+ "participants": ["james@mergington.edu", "alex@mergington.edu"]
+ },
+ "Tennis Club": {
+ "description": "Tennis practice and friendly matches",
+ "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:00 PM",
+ "max_participants": 16,
+ "participants": ["lucas@mergington.edu", "nina@mergington.edu"]
+ },
+ "Art Studio": {
+ "description": "Drawing, painting, and visual art techniques",
+ "schedule": "Wednesdays, 3:30 PM - 5:00 PM",
+ "max_participants": 18,
+ "participants": ["isabella@mergington.edu", "sophia@mergington.edu"]
+ },
+ "Music Band": {
+ "description": "Learn instruments and perform in school concerts",
+ "schedule": "Thursdays, 4:00 PM - 5:30 PM",
+ "max_participants": 25,
+ "participants": ["lucas@mergington.edu", "mia@mergington.edu"]
+ },
+ "Debate Club": {
+ "description": "Develop argumentation and public speaking skills",
+ "schedule": "Mondays and Fridays, 3:30 PM - 4:30 PM",
+ "max_participants": 14,
+ "participants": ["ryan@mergington.edu", "jessica@mergington.edu"]
+ },
+ "Science Club": {
+ "description": "Explore scientific experiments and research projects",
+ "schedule": "Wednesdays, 4:00 PM - 5:00 PM",
+ "max_participants": 20,
+ "participants": ["nathan@mergington.edu", "ava@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}/participants")
+def unregister_from_activity(activity_name: str, email: str):
+ """Remove 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=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..e6767ac 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -12,6 +12,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Clear loading message
activitiesList.innerHTML = "";
+ activitySelect.innerHTML = '';
// Populate activities list
Object.entries(activities).forEach(([name, details]) => {
@@ -19,12 +20,40 @@ document.addEventListener("DOMContentLoaded", () => {
activityCard.className = "activity-card";
const spotsLeft = details.max_participants - details.participants.length;
+ const sortedParticipants = [...details.participants].sort((a, b) =>
+ a.localeCompare(b, undefined, { sensitivity: "base" })
+ );
+ const participantsList = sortedParticipants
+ .map(
+ (participant) => `
+
+ ${participant}
+
+
+ `
+ )
+ .join("");
activityCard.innerHTML = `
${name}
${details.description}
Schedule: ${details.schedule}
Availability: ${spotsLeft} spots left
+
+
Participants
+ ${sortedParticipants.length > 0
+ ? `
`
+ : '
No participants yet.
'}
+
`;
activitiesList.appendChild(activityCard);
@@ -73,6 +102,10 @@ document.addEventListener("DOMContentLoaded", () => {
setTimeout(() => {
messageDiv.classList.add("hidden");
}, 5000);
+
+ if (response.ok) {
+ await fetchActivities();
+ }
} catch (error) {
messageDiv.textContent = "Failed to sign up. Please try again.";
messageDiv.className = "error";
@@ -81,6 +114,52 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
+ activitiesList.addEventListener("click", async (event) => {
+ const deleteButton = event.target.closest(".participant-delete");
+
+ if (!deleteButton) {
+ return;
+ }
+
+ const activityName = deleteButton.dataset.activity;
+ const participantEmail = deleteButton.dataset.email;
+
+ if (!activityName || !participantEmail) {
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/activities/${encodeURIComponent(activityName)}/participants?email=${encodeURIComponent(participantEmail)}`,
+ {
+ method: "DELETE",
+ }
+ );
+
+ const result = await response.json();
+
+ if (response.ok) {
+ messageDiv.textContent = result.message;
+ messageDiv.className = "success";
+ await fetchActivities();
+ } else {
+ messageDiv.textContent = result.detail || "Could not unregister participant.";
+ messageDiv.className = "error";
+ }
+
+ messageDiv.classList.remove("hidden");
+
+ setTimeout(() => {
+ messageDiv.classList.add("hidden");
+ }, 5000);
+ } catch (error) {
+ messageDiv.textContent = "Failed to unregister participant. Please try again.";
+ messageDiv.className = "error";
+ messageDiv.classList.remove("hidden");
+ console.error("Error unregistering participant:", error);
+ }
+ });
+
// Initialize app
fetchActivities();
});
diff --git a/src/static/styles.css b/src/static/styles.css
index a533b32..04cf65c 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-top: 10px;
+ border-top: 1px dashed #cfd8dc;
+}
+
+.participants-title {
+ font-weight: bold;
+ color: #1a237e;
+ margin-bottom: 6px;
+}
+
+.participants-list {
+ list-style: none;
+ margin-left: 0;
+ padding-left: 0;
+ color: #37474f;
+}
+
+.participants-list li {
+ margin-bottom: 6px;
+}
+
+.participant-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 6px 8px;
+ background-color: #eef3f8;
+ border: 1px solid #d7e2ee;
+ border-radius: 8px;
+}
+
+.participant-email {
+ font-size: 14px;
+}
+
+.participant-delete {
+ border: none;
+ background: transparent;
+ color: #b71c1c;
+ padding: 2px 6px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ transition: background-color 0.2s, color 0.2s;
+}
+
+.participant-delete:hover {
+ background-color: #ffebee;
+ color: #8e0000;
+}
+
+.participants-empty {
+ margin-bottom: 0;
+ color: #607d8b;
+ font-style: italic;
+}
+
.form-group {
margin-bottom: 15px;
}
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..092f1aa
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,20 @@
+from copy import deepcopy
+
+import pytest
+from fastapi.testclient import TestClient
+
+from src.app import app
+import src.app as app_module
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def reset_activities_state():
+ original_activities = deepcopy(app_module.activities)
+ yield
+ app_module.activities.clear()
+ app_module.activities.update(original_activities)
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..fd72549
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,130 @@
+import src.app as app_module
+
+
+def test_root_redirects_to_static_index(client):
+ # Arrange
+ redirect_target = "/static/index.html"
+
+ # Act
+ response = client.get("/", follow_redirects=False)
+
+ # Assert
+ assert response.status_code == 307
+ assert response.headers["location"] == redirect_target
+
+
+def test_get_activities_returns_expected_shape(client):
+ # Arrange
+ expected_activity = "Chess Club"
+ required_keys = {"description", "schedule", "max_participants", "participants"}
+
+ # Act
+ response = client.get("/activities")
+ payload = response.json()
+
+ # Assert
+ assert response.status_code == 200
+ assert isinstance(payload, dict)
+ assert expected_activity in payload
+ assert required_keys.issubset(payload[expected_activity].keys())
+ assert isinstance(payload[expected_activity]["participants"], list)
+
+
+def test_signup_adds_participant_successfully(client):
+ # Arrange
+ activity_name = "Chess Club"
+ email = "zoe@mergington.edu"
+ assert email not in app_module.activities[activity_name]["participants"]
+
+ # 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 app_module.activities[activity_name]["participants"]
+
+
+def test_signup_returns_404_for_unknown_activity(client):
+ # 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_signup_returns_400_for_duplicate_participant(client):
+ # 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 for this activity"}
+
+
+def test_unregister_removes_participant_successfully(client):
+ # Arrange
+ activity_name = "Gym Class"
+ email = "john@mergington.edu"
+ assert email in app_module.activities[activity_name]["participants"]
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 200
+ assert response.json() == {"message": f"Unregistered {email} from {activity_name}"}
+ assert email not in app_module.activities[activity_name]["participants"]
+
+
+def test_unregister_returns_404_for_unknown_activity(client):
+ # Arrange
+ activity_name = "Unknown Club"
+ email = "student@mergington.edu"
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert response.json() == {"detail": "Activity not found"}
+
+
+def test_unregister_returns_404_for_non_registered_participant(client):
+ # Arrange
+ activity_name = "Gym Class"
+ email = "not-registered@mergington.edu"
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert response.json() == {"detail": "Student is not signed up for this activity"}