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 + ? `` + : '

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"