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..5bd14ce 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 Thursdays, 4:00 PM - 5:30 PM", + "max_participants": 15, + "participants": ["james@mergington.edu"] + }, + "Tennis Club": { + "description": "Learn tennis skills and participate in friendly matches", + "schedule": "Wednesdays and Saturdays, 3:00 PM - 4:30 PM", + "max_participants": 16, + "participants": ["alex@mergington.edu", "sarah@mergington.edu"] + }, + "Art Studio": { + "description": "Explore painting, drawing, and other visual arts", + "schedule": "Tuesdays and Fridays, 3:30 PM - 5:00 PM", + "max_participants": 18, + "participants": ["grace@mergington.edu"] + }, + "Music Band": { + "description": "Join our school band and perform at events", + "schedule": "Mondays, Wednesdays, Fridays, 3:30 PM - 4:30 PM", + "max_participants": 25, + "participants": ["lucas@mergington.edu", "sophia@mergington.edu"] + }, + "Debate Society": { + "description": "Develop public speaking and critical thinking skills", + "schedule": "Thursdays, 3:30 PM - 5:00 PM", + "max_participants": 20, + "participants": ["charlotte@mergington.edu", "henry@mergington.edu"] + }, + "Science Club": { + "description": "Conduct experiments and explore STEM concepts", + "schedule": "Wednesdays, 3:30 PM - 4:30 PM", + "max_participants": 22, + "participants": ["noah@mergington.edu"] } } @@ -62,6 +98,32 @@ 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}"} + return {"message": f"Signed up {email} for {activity_name}", "participants": activity["participants"]} + + +@app.delete("/activities/{activity_name}/participants") +def unregister_participant(activity_name: str, email: str): + """Unregister a student from an activity + + Calls should pass the student email as a query parameter, e.g. + DELETE /activities/Chess%20Club/participants?email=student@example.com + """ + # 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 currently signed up + if email not in activity.get("participants", []): + raise HTTPException(status_code=404, detail="Student not signed up for this activity") + + # Remove the student + activity["participants"].remove(email) + return {"message": f"Unregistered {email} from {activity_name}", "participants": activity["participants"]} diff --git a/src/static/app.js b/src/static/app.js index dcc1e38..0378ed1 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -4,29 +4,117 @@ document.addEventListener("DOMContentLoaded", () => { const signupForm = document.getElementById("signup-form"); const messageDiv = document.getElementById("message"); + // Function to safely set text + function setText(node, text) { node.textContent = text || ""; } + // Function to fetch activities from API async function fetchActivities() { try { - const response = await fetch("/activities"); + const response = await fetch("/activities", { cache: "no-store" }); const activities = await response.json(); - // Clear loading message + // Clear loading message / list activitiesList.innerHTML = ""; + // Reset dropdown (keep placeholder) + while (activitySelect.options.length > 1) activitySelect.remove(1); + // Populate activities list Object.entries(activities).forEach(([name, details]) => { const activityCard = document.createElement("div"); activityCard.className = "activity-card"; - const spotsLeft = details.max_participants - details.participants.length; + // Header + const title = document.createElement("h4"); + setText(title, name); + activityCard.appendChild(title); + + // Description + const desc = document.createElement("p"); + setText(desc, details.description); + activityCard.appendChild(desc); + + // Schedule + const sched = document.createElement("p"); + sched.innerHTML = `Schedule: ${details.schedule || ""}`; + activityCard.appendChild(sched); + + // Availability + const spotsLeft = Math.max(0, (details.max_participants || 0) - (details.participants?.length || 0)); + const avail = document.createElement("p"); + avail.innerHTML = `Availability: ${spotsLeft} spots left`; + activityCard.appendChild(avail); + + // Participants section + const participantsSection = document.createElement("div"); + participantsSection.className = "participants-section"; + + const participantsHeader = document.createElement("h5"); + participantsHeader.innerHTML = `Participants ${(details.participants || []).length}`; + participantsSection.appendChild(participantsHeader); + + if (details.participants && details.participants.length) { + const ul = document.createElement("ul"); + ul.className = "participant-list"; + details.participants.forEach(p => { + const li = document.createElement("li"); + li.className = "participant-item"; - activityCard.innerHTML = ` -
${details.description}
-Schedule: ${details.schedule}
-Availability: ${spotsLeft} spots left
- `; + // name/email text + const span = document.createElement("span"); + setText(span, p); + + // delete / unregister button + const btn = document.createElement("button"); + btn.className = "delete-btn"; + btn.title = `Unregister ${p}`; + btn.setAttribute("aria-label", `Unregister ${p} from ${name}`); + btn.innerHTML = ``; + + // call API to unregister when clicked + btn.addEventListener("click", async () => { + const ok = window.confirm(`Remove ${p} from ${name}?`); + if (!ok) return; + + try { + const resp = await fetch(`/activities/${encodeURIComponent(name)}/participants?email=${encodeURIComponent(p)}`, { method: "DELETE", cache: "no-store" }); + const resBody = await resp.json(); + + if (resp.ok) { + messageDiv.textContent = resBody.message || 'Removed participant'; + messageDiv.className = 'message success'; + } else { + messageDiv.textContent = resBody.detail || 'Failed to remove participant'; + messageDiv.className = 'message error'; + } + messageDiv.classList.remove('hidden'); + + // Refresh activities list + await fetchActivities(); + + setTimeout(() => messageDiv.classList.add('hidden'), 4000); + } catch (err) { + console.error('Error unregistering participant:', err); + messageDiv.textContent = 'Failed to remove participant — please try again.'; + messageDiv.className = 'message error'; + messageDiv.classList.remove('hidden'); + setTimeout(() => messageDiv.classList.add('hidden'), 4000); + } + }); + + li.appendChild(span); + li.appendChild(btn); + ul.appendChild(li); + }); + participantsSection.appendChild(ul); + } else { + const none = document.createElement("p"); + none.className = "no-participants"; + setText(none, "No participants yet."); + participantsSection.appendChild(none); + } + activityCard.appendChild(participantsSection); activitiesList.appendChild(activityCard); // Add option to select dropdown @@ -45,7 +133,7 @@ document.addEventListener("DOMContentLoaded", () => { signupForm.addEventListener("submit", async (event) => { event.preventDefault(); - const email = document.getElementById("email").value; + const email = document.getElementById("email").value.trim(); const activity = document.getElementById("activity").value; try { @@ -53,6 +141,7 @@ document.addEventListener("DOMContentLoaded", () => { `/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`, { method: "POST", + cache: "no-store", } ); @@ -60,11 +149,12 @@ document.addEventListener("DOMContentLoaded", () => { if (response.ok) { messageDiv.textContent = result.message; - messageDiv.className = "success"; + messageDiv.className = "message success"; signupForm.reset(); + await fetchActivities(); // refresh participants immediately } else { messageDiv.textContent = result.detail || "An error occurred"; - messageDiv.className = "error"; + messageDiv.className = "message error"; } messageDiv.classList.remove("hidden"); @@ -75,7 +165,7 @@ document.addEventListener("DOMContentLoaded", () => { }, 5000); } catch (error) { messageDiv.textContent = "Failed to sign up. Please try again."; - messageDiv.className = "error"; + messageDiv.className = "message error"; messageDiv.classList.remove("hidden"); console.error("Error signing up:", error); } diff --git a/src/static/styles.css b/src/static/styles.css index a533b32..f0fb71d 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -142,3 +142,83 @@ footer { padding: 20px; color: #666; } + +.participants-section { + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed rgba(26, 35, 126, 0.08); +} + +.participants-section h5 { + margin: 0 0 8px 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #2b2b2b; + font-weight: 600; +} + +.participants-section .badge { + background: linear-gradient(180deg,#ffffff,#eef4ff); + border: 1px solid #dbe6ff; + color: #1a237e; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + box-shadow: inset 0 -1px 0 rgba(0,0,0,0.03); +} + +.participant-list { + list-style: none; /* hide default bullets */ + padding-left: 0; + margin: 6px 0 0 0; +} + +.participant-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + margin-bottom: 8px; + border-radius: 8px; + background: linear-gradient(180deg,#fbfbff,#ffffff); + border: 1px solid #f0f0f5; + font-size: 14px; + color: #333; + transition: transform .08s ease, box-shadow .12s ease; +} + +.participant-item:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(24, 39, 128, 0.06); +} + +.delete-btn { + background: transparent; + border: none; + color: #d32f2f; + font-weight: 700; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.delete-btn:hover { + background: rgba(211, 47, 47, 0.08); +} + +.delete-icon { + font-size: 14px; + line-height: 1; +} + +.no-participants { + font-size: 13px; + color: #777; + margin: 6px 0 0 0; +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..85e4a65 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,53 @@ +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() + # Expect to get the same keys as the in-memory activities mapping + assert set(data.keys()) == set(activities.keys()) + + +def test_signup_and_unregister_flow(): + activity_name = "Chess Club" + email = "test_student@example.com" + + # Ensure email is not already registered (cleanup if necessary) + if email in activities[activity_name]["participants"]: + activities[activity_name]["participants"].remove(email) + + # Sign up + resp = client.post(f"/activities/{activity_name}/signup?email={email}") + assert resp.status_code == 200 + body = resp.json() + assert "Signed up" in body.get("message", "") + # participants should include newly added email + assert email in body.get("participants", []) + + # Attempt duplicate signup should return 400 + dup = client.post(f"/activities/{activity_name}/signup?email={email}") + assert dup.status_code == 400 + + # Now unregister + rem = client.delete(f"/activities/{activity_name}/participants?email={email}") + assert rem.status_code == 200 + rbody = rem.json() + assert "Unregistered" in rbody.get("message", "") + assert email not in rbody.get("participants", []) + + +def test_unregister_not_found(): + activity_name = "Chess Club" + email = "not_registered@example.com" + + # Make sure this email is not present + if email in activities[activity_name]["participants"]: + activities[activity_name]["participants"].remove(email) + + resp = client.delete(f"/activities/{activity_name}/participants?email={email}") + assert resp.status_code == 404 \ No newline at end of file