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

+
+ Participants: +
+
+ `; + + 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"]