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..e7498cb 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 team for intramural and regional tournaments",
+ "schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
+ "max_participants": 15,
+ "participants": ["alex@mergington.edu"]
+ },
+ "Tennis Club": {
+ "description": "Learn tennis skills and participate in friendly matches",
+ "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:00 PM",
+ "max_participants": 12,
+ "participants": ["isabella@mergington.edu", "lucas@mergington.edu"]
+ },
+ "Art Studio": {
+ "description": "Explore painting, drawing, and mixed media techniques",
+ "schedule": "Wednesdays, 3:30 PM - 5:00 PM",
+ "max_participants": 18,
+ "participants": ["grace@mergington.edu"]
+ },
+ "Music Band": {
+ "description": "Join our school band and perform at concerts and events",
+ "schedule": "Mondays and Thursdays, 3:30 PM - 4:30 PM",
+ "max_participants": 25,
+ "participants": ["noah@mergington.edu", "ava@mergington.edu"]
+ },
+ "Debate Team": {
+ "description": "Develop critical thinking and public speaking skills through competitive debate",
+ "schedule": "Fridays, 4:00 PM - 5:30 PM",
+ "max_participants": 16,
+ "participants": ["mason@mergington.edu"]
+ },
+ "Science Club": {
+ "description": "Conduct experiments and explore scientific concepts through hands-on projects",
+ "schedule": "Tuesdays, 3:30 PM - 5:00 PM",
+ "max_participants": 20,
+ "participants": ["ethan@mergington.edu", "chloe@mergington.edu"]
}
}
@@ -62,6 +98,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")
+
# 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):
+ """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]
+
+ # Validate participant exists in this activity
+ if email not in activity["participants"]:
+ raise HTTPException(status_code=404, detail="Participant not found in this activity")
+
+ activity["participants"].remove(email)
+ return {"message": f"Removed {email} from {activity_name}"}
diff --git a/src/static/app.js b/src/static/app.js
index dcc1e38..641798c 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -4,14 +4,28 @@ document.addEventListener("DOMContentLoaded", () => {
const signupForm = document.getElementById("signup-form");
const messageDiv = document.getElementById("message");
+ function showMessage(message, type) {
+ messageDiv.textContent = message;
+ messageDiv.className = type;
+ messageDiv.classList.remove("hidden");
+
+ setTimeout(() => {
+ messageDiv.classList.add("hidden");
+ }, 5000);
+ }
+
// Function to fetch activities from API
async function fetchActivities() {
try {
- const response = await fetch("/activities");
+ const response = await fetch("/activities", { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(`Failed to fetch activities: ${response.status}`);
+ }
const activities = await response.json();
// Clear loading message
activitiesList.innerHTML = "";
+ activitySelect.innerHTML = '';
// Populate activities list
Object.entries(activities).forEach(([name, details]) => {
@@ -19,12 +33,34 @@ document.addEventListener("DOMContentLoaded", () => {
activityCard.className = "activity-card";
const spotsLeft = details.max_participants - details.participants.length;
+ const participantsMarkup = details.participants.length
+ ? `
${details.participants
+ .map(
+ (participant) =>
+ `-
+ ${participant}
+
+
`
+ )
+ .join("")}
`
+ : 'No participants yet. Be the first to sign up.
';
activityCard.innerHTML = `
${name}
${details.description}
Schedule: ${details.schedule}
Availability: ${spotsLeft} spots left
+
+
Participants
+ ${participantsMarkup}
+
`;
activitiesList.appendChild(activityCard);
@@ -59,25 +95,50 @@ document.addEventListener("DOMContentLoaded", () => {
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 removeButton = event.target.closest(".participant-remove");
+ if (!removeButton) {
+ return;
+ }
+
+ const activity = removeButton.dataset.activity;
+ const email = removeButton.dataset.email;
+
+ if (!activity || !email) {
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `/activities/${encodeURIComponent(activity)}/participants?email=${encodeURIComponent(email)}`,
+ {
+ method: "DELETE",
+ }
+ );
- messageDiv.classList.remove("hidden");
+ const result = await response.json();
- // Hide message after 5 seconds
- setTimeout(() => {
- messageDiv.classList.add("hidden");
- }, 5000);
+ 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..2c9402b 100644
--- a/src/static/styles.css
+++ b/src/static/styles.css
@@ -74,6 +74,73 @@ section h3 {
margin-bottom: 8px;
}
+.participants-section {
+ margin-top: 14px;
+ padding: 12px;
+ border: 1px solid #dbe5ff;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #f5f8ff 0%, #eef3ff 100%);
+}
+
+.participants-title {
+ margin-bottom: 8px;
+ font-weight: 700;
+ color: #1a237e;
+ letter-spacing: 0.2px;
+}
+
+.participant-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.participant-item {
+ margin-bottom: 6px;
+ color: #24335a;
+ word-break: break-word;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ background-color: rgba(255, 255, 255, 0.75);
+ border: 1px solid #d7e2ff;
+}
+
+.participant-email {
+ overflow-wrap: anywhere;
+}
+
+.participant-remove {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1px solid #f2b8b5;
+ background-color: #fff4f4;
+ color: #b42318;
+ font-size: 18px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.participant-remove:hover {
+ background-color: #ffe7e5;
+ border-color: #eaa6a2;
+}
+
+.participant-empty {
+ margin: 0;
+ color: #5c6d9c;
+ font-style: italic;
+}
+
.form-group {
margin-bottom: 15px;
}
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..d6eaa44
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,19 @@
+import copy
+
+import pytest
+from fastapi.testclient import TestClient
+
+from src.app import activities, app
+
+
+@pytest.fixture()
+def client():
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def restore_activities_state():
+ snapshot = copy.deepcopy(activities)
+ yield
+ activities.clear()
+ activities.update(snapshot)
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
new file mode 100644
index 0000000..dce0b8d
--- /dev/null
+++ b/tests/test_endpoints.py
@@ -0,0 +1,55 @@
+from src.app import activities
+
+
+def test_get_activities_returns_expected_structure(client):
+ # Arrange
+
+ # Act
+ response = client.get("/activities")
+
+ # Assert
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, dict)
+ assert "Chess Club" in data
+ assert {
+ "description",
+ "schedule",
+ "max_participants",
+ "participants",
+ }.issubset(data["Chess Club"].keys())
+
+
+def test_signup_adds_participant_successfully(client):
+ # 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_delete_unregisters_participant_successfully(client):
+ # Arrange
+ activity_name = "Chess Club"
+ email = "remove.me@mergington.edu"
+ activities[activity_name]["participants"].append(email)
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 200
+ assert response.json()["message"] == f"Removed {email} from {activity_name}"
+ assert email not in activities[activity_name]["participants"]
diff --git a/tests/test_validations.py b/tests/test_validations.py
new file mode 100644
index 0000000..b5fe821
--- /dev/null
+++ b/tests/test_validations.py
@@ -0,0 +1,62 @@
+def test_signup_fails_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_fails_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"
+
+
+def test_delete_fails_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_delete_fails_for_non_participant(client):
+ # Arrange
+ activity_name = "Chess Club"
+ email = "not.enrolled@mergington.edu"
+
+ # Act
+ response = client.delete(
+ f"/activities/{activity_name}/participants",
+ params={"email": email},
+ )
+
+ # Assert
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Participant not found in this activity"