diff --git a/requirements.txt b/requirements.txt index 97dc7cd..f4ca909 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ fastapi uvicorn +pytest +requests +httpx diff --git a/src/app.py b/src/app.py index 4ebb1d9..31cdc1f 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 activities + "Soccer Team": { + "description": "Competitive soccer team practice and matches", + "schedule": "Mondays, Wednesdays, 4:00 PM - 6:00 PM", + "max_participants": 22, + "participants": ["liam@mergington.edu", "ava@mergington.edu"] + }, + "Basketball Club": { + "description": "Pickup games, drills, and intramural tournaments", + "schedule": "Tuesdays and Thursdays, 5:00 PM - 6:30 PM", + "max_participants": 18, + "participants": ["noah@mergington.edu", "isabella@mergington.edu"] + }, + # Artistic activities + "Art Club": { + "description": "Explore drawing, painting, and mixed media projects", + "schedule": "Wednesdays, 3:30 PM - 5:00 PM", + "max_participants": 16, + "participants": ["maya@mergington.edu", "henry@mergington.edu"] + }, + "Drama Club": { + "description": "Acting workshops, rehearsals, and school productions", + "schedule": "Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 20, + "participants": ["grace@mergington.edu", "alex@mergington.edu"] + }, + # Intellectual activities + "Debate Team": { + "description": "Competitive debating, public speaking, and argumentation", + "schedule": "Tuesdays, 4:30 PM - 6:00 PM", + "max_participants": 14, + "participants": ["emma.w@mergington.edu", "michael.s@mergington.edu"] + }, + "Science Club": { + "description": "Hands-on experiments, fairs, and STEM projects", + "schedule": "Fridays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["sophia.l@mergington.edu", "ben@mergington.edu"] } } @@ -62,6 +101,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}/unregister") +def unregister_from_activity(activity_name: str, email: str): + """Unregister a student from an activity by email""" + # Validate activity exists + if activity_name not in activities: + raise HTTPException(status_code=404, detail="Activity not found") + + activity = activities[activity_name] + + # Validate student is signed up + if email not in activity["participants"]: + raise HTTPException(status_code=404, detail="Student not registered for this activity") + + # Remove the 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..dc1f301 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -14,7 +14,10 @@ document.addEventListener("DOMContentLoaded", () => { activitiesList.innerHTML = ""; // Populate activities list - Object.entries(activities).forEach(([name, details]) => { + // Reset activity select options (keep placeholder) + activitySelect.innerHTML = ''; + + Object.entries(activities).forEach(([name, details]) => { const activityCard = document.createElement("div"); activityCard.className = "activity-card"; @@ -27,6 +30,85 @@ document.addEventListener("DOMContentLoaded", () => {
Availability: ${spotsLeft} spots left
`; + // Participants section (created with DOM methods to avoid XSS) + const participantsSection = document.createElement("div"); + participantsSection.className = "participants-section"; + + const participantsHeading = document.createElement("h5"); + participantsHeading.textContent = "Participants"; + participantsSection.appendChild(participantsHeading); + + if (Array.isArray(details.participants) && details.participants.length > 0) { + const ul = document.createElement("ul"); + ul.className = "participant-list"; + + details.participants.forEach((p) => { + const li = document.createElement("li"); + + const badge = document.createElement("span"); + badge.className = "participant-badge"; + const initials = String(p) + .trim() + .split(/[\s@._-]+/) + .filter(Boolean) + .slice(0, 2) + .map(s => s[0]?.toUpperCase() || "") + .join(""); + badge.textContent = initials || "U"; + + const text = document.createElement("span"); + text.className = "participant-text"; + text.textContent = p; + + // Delete button to unregister participant + const deleteBtn = document.createElement("button"); + deleteBtn.className = "participant-delete"; + deleteBtn.title = `Remove ${p}`; + deleteBtn.innerHTML = "✖"; + deleteBtn.addEventListener("click", async () => { + if (!confirm(`Remove ${p} from ${name}?`)) return; + try { + const response = await fetch( + `/activities/${encodeURIComponent(name)}/unregister?email=${encodeURIComponent(p)}`, + { method: "DELETE" } + ); + + const result = await response.json(); + + if (response.ok) { + // Refresh activities to update counts and list + fetchActivities(); + } else { + messageDiv.textContent = result.detail || "Failed to remove participant"; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + setTimeout(() => messageDiv.classList.add("hidden"), 5000); + } + } catch (error) { + console.error("Error removing participant:", error); + messageDiv.textContent = "Failed to remove participant. Please try again."; + messageDiv.className = "error"; + messageDiv.classList.remove("hidden"); + setTimeout(() => messageDiv.classList.add("hidden"), 5000); + } + }); + + li.appendChild(badge); + li.appendChild(text); + li.appendChild(deleteBtn); + ul.appendChild(li); + }); + + participantsSection.appendChild(ul); + } else { + const emptyP = document.createElement("p"); + emptyP.className = "participants-empty"; + emptyP.textContent = "No participants yet — be the first to sign up!"; + participantsSection.appendChild(emptyP); + } + + activityCard.appendChild(participantsSection); + activitiesList.appendChild(activityCard); // Add option to select dropdown @@ -62,6 +144,8 @@ document.addEventListener("DOMContentLoaded", () => { messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); + // Refresh activities so the new participant appears immediately + 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..42ee8a2 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -142,3 +142,72 @@ footer { padding: 20px; color: #666; } + +.participants-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed #eee; +} + +.participants-section h5 { + margin: 0 0 8px 0; + font-size: 14px; + color: #1a237e; +} + +.participant-list { + list-style: none; + padding-left: 0; + margin: 0; +} + +.participant-list li { + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 10px; +} + +.participant-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 30px; + border-radius: 50%; + background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); + color: #fff; + font-weight: 700; + font-size: 12px; +} + +.participant-delete { + background: transparent; + border: none; + color: #c62828; + font-size: 14px; + cursor: pointer; + padding: 6px; + margin-left: auto; + border-radius: 4px; +} + +.participant-delete:hover { + background: rgba(198, 40, 40, 0.08); +} + +.participant-text { + color: #333; + font-size: 14px; + word-break: break-word; +} + +.participants-empty { + margin: 0; + padding: 8px; + border-radius: 4px; + background-color: #f1f8ff; + color: #0c5460; + border: 1px solid #dbeeff; + font-size: 13px; +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..20b3b1a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,62 @@ +import copy + +import pytest +from fastapi.testclient import TestClient + +from src import app as app_module + + +@pytest.fixture(autouse=True) +def restore_activities(): + # Make a deep copy of the in-memory activities and restore after each test + original = copy.deepcopy(app_module.activities) + yield + app_module.activities.clear() + app_module.activities.update(copy.deepcopy(original)) + + +@pytest.fixture() +def client(): + return TestClient(app_module.app) + + +def test_get_activities(client): + resp = client.get("/activities") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, dict) + # Ensure one known activity exists + assert "Chess Club" in data + assert "participants" in data["Chess Club"] + + +def test_signup_then_unregister(client): + activity = "Chess Club" + email = "test.user@mergington.edu" + + # Ensure email not already registered + resp = client.get("/activities") + assert resp.status_code == 200 + assert email not in resp.json()[activity]["participants"] + + # Sign up + resp = client.post(f"/activities/{activity}/signup?email={email}") + assert resp.status_code == 200 + body = resp.json() + assert "Signed up" in body.get("message", "") + + # Now email should be in participants + resp = client.get("/activities") + assert resp.status_code == 200 + assert email in resp.json()[activity]["participants"] + + # Unregister + resp = client.delete(f"/activities/{activity}/unregister?email={email}") + assert resp.status_code == 200 + body = resp.json() + assert "Unregistered" in body.get("message", "") + + # Email should be gone + resp = client.get("/activities") + assert resp.status_code == 200 + assert email not in resp.json()[activity]["participants"]