diff --git a/requirements.txt b/requirements.txt index 97dc7cd..9c42c4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ +pytest +httpx +pytest-cov fastapi uvicorn diff --git a/src/app.py b/src/app.py index 4ebb1d9..c23ac90 100644 --- a/src/app.py +++ b/src/app.py @@ -1,3 +1,6 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +# ...existing code... """ High School Management System API @@ -16,11 +19,46 @@ # Mount the static files directory current_dir = Path(__file__).parent -app.mount("/static", StaticFiles(directory=os.path.join(Path(__file__).parent, - "static")), name="static") +app.mount("/static", StaticFiles(directory=os.path.join(Path(__file__).parent, "static")), name="static") # In-memory activity database activities = { + "Basketball Team": { + "description": "Join the school basketball team for training and competitions", + "schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM", + "max_participants": 15, + "participants": [] + }, + "Soccer Club": { + "description": "Practice soccer skills and play friendly matches", + "schedule": "Wednesdays, 3:30 PM - 5:30 PM", + "max_participants": 18, + "participants": [] + }, + "Drama Club": { + "description": "Participate in acting, stage production, and school plays", + "schedule": "Mondays, 4:00 PM - 5:30 PM", + "max_participants": 25, + "participants": [] + }, + "Art Workshop": { + "description": "Explore painting, drawing, and other visual arts", + "schedule": "Fridays, 2:30 PM - 4:00 PM", + "max_participants": 20, + "participants": [] + }, + "Math Olympiad": { + "description": "Prepare for math competitions and solve challenging problems", + "schedule": "Thursdays, 3:30 PM - 5:00 PM", + "max_participants": 10, + "participants": [] + }, + "Science Club": { + "description": "Conduct experiments and explore scientific concepts", + "schedule": "Wednesdays, 4:00 PM - 5:00 PM", + "max_participants": 15, + "participants": [] + }, "Chess Club": { "description": "Learn strategies and compete in chess tournaments", "schedule": "Fridays, 3:30 PM - 5:00 PM", @@ -55,6 +93,10 @@ def get_activities(): @app.post("/activities/{activity_name}/signup") def signup_for_activity(activity_name: str, email: str): """Sign up a student for an activity""" + # Validate email is not empty or whitespace-only + if not email.strip(): + raise HTTPException(status_code=400, detail="Email cannot be empty or whitespace-only") + # Validate activity exists if activity_name not in activities: raise HTTPException(status_code=404, detail="Activity not found") @@ -62,6 +104,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.post("/activities/{activity_name}/unregister") +def unregister_from_activity(activity_name: str, email: str): + """Unregister a student from an activity""" + # Validate email is not empty or whitespace-only + if not email.strip(): + raise HTTPException(status_code=400, detail="Email cannot be empty or whitespace-only") + + if activity_name not in activities: + raise HTTPException(status_code=404, detail="Activity not found") + activity = activities[activity_name] + if email not in activity["participants"]: + raise HTTPException(status_code=400, detail="Participant not found") + activity["participants"].remove(email) + return {"message": f"{email} removed from {activity_name}"} diff --git a/src/static/app.js b/src/static/app.js index dcc1e38..e9fed8b 100644 --- a/src/static/app.js +++ b/src/static/app.js @@ -20,13 +20,102 @@ document.addEventListener("DOMContentLoaded", () => { const spotsLeft = details.max_participants - details.participants.length; - activityCard.innerHTML = ` -

${name}

-

${details.description}

-

Schedule: ${details.schedule}

-

Availability: ${spotsLeft} spots left

- `; + // Create title + const title = document.createElement("h4"); + title.textContent = name; + activityCard.appendChild(title); + // Create description + const description = document.createElement("p"); + description.textContent = details.description; + activityCard.appendChild(description); + + // Create schedule + const schedule = document.createElement("p"); + const scheduleLabel = document.createElement("strong"); + scheduleLabel.textContent = "Schedule: "; + schedule.appendChild(scheduleLabel); + schedule.appendChild(document.createTextNode(details.schedule)); + activityCard.appendChild(schedule); + + // Create availability + const availability = document.createElement("p"); + const availabilityLabel = document.createElement("strong"); + availabilityLabel.textContent = "Availability: "; + availability.appendChild(availabilityLabel); + availability.appendChild(document.createTextNode(`${spotsLeft} spots left`)); + activityCard.appendChild(availability); + + // Create participants section + const participantsSection = document.createElement("div"); + participantsSection.className = "participants-section"; + + const participantsLabel = document.createElement("strong"); + participantsLabel.textContent = "Participants:"; + participantsSection.appendChild(participantsLabel); + + if (details.participants.length > 0) { + const participantsList = document.createElement("div"); + participantsList.className = "participants-list"; + + details.participants.forEach((email) => { + const participantItem = document.createElement("div"); + participantItem.className = "participant-item"; + + const emailSpan = document.createElement("span"); + emailSpan.className = "participant-email"; + emailSpan.textContent = email; + participantItem.appendChild(emailSpan); + + const deleteIcon = document.createElement("span"); + deleteIcon.className = "delete-icon"; + deleteIcon.title = "Remove participant"; + deleteIcon.textContent = "🗑️"; + deleteIcon.setAttribute("role", "button"); + deleteIcon.setAttribute("aria-label", "Remove participant"); + deleteIcon.setAttribute("tabindex", "0"); + + const handleDelete = async () => { + // Create a safe confirmation message + const confirmMsg = `Remove participant from activity?\n\nParticipant: ${email}\nActivity: ${name}`; + if (!confirm(confirmMsg)) return; + try { + const response = await fetch(`/activities/${encodeURIComponent(name)}/unregister?email=${encodeURIComponent(email)}`, { + method: "POST", + }); + if (response.ok) { + fetchActivities(); + } else { + const result = await response.json(); + alert(result.detail || "Failed to remove participant."); + } + } catch (error) { + alert("Error removing participant."); + } + }; + + // Store data in closure instead of data attributes for better security + deleteIcon.addEventListener("click", handleDelete); + deleteIcon.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleDelete(); + } + }); + + participantItem.appendChild(deleteIcon); + participantsList.appendChild(participantItem); + }); + + participantsSection.appendChild(participantsList); + } else { + const noParticipants = document.createElement("p"); + noParticipants.className = "participants-none"; + noParticipants.textContent = "No participants yet."; + participantsSection.appendChild(noParticipants); + } + + activityCard.appendChild(participantsSection); activitiesList.appendChild(activityCard); // Add option to select dropdown @@ -62,6 +151,7 @@ document.addEventListener("DOMContentLoaded", () => { messageDiv.textContent = result.message; messageDiv.className = "success"; signupForm.reset(); + fetchActivities(); // Refresh activities list } 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..2fc4f73 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -142,3 +142,35 @@ footer { padding: 20px; color: #666; } + +.participants-section { + margin-top: 12px; + padding: 10px; + background-color: #e3f2fd; + border-radius: 4px; + border: 1px solid #bbdefb; +} + +.participants-section strong { + color: #1976d2; + font-size: 15px; +} + +.participants-list { + margin-top: 6px; + margin-left: 18px; + list-style-type: disc; +} + +.participant-item { + color: #333; + font-size: 14px; + padding: 2px 0; +} + +.participants-none { + color: #757575; + font-style: italic; + font-size: 14px; + margin-top: 6px; +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..5ced7c9 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,95 @@ +from fastapi.testclient import TestClient +from src.app import app + +client = TestClient(app) + +def test_get_activities(): + response = client.get("/activities") + assert response.status_code == 200 + assert isinstance(response.json(), dict) + +def test_signup_and_unregister(): + activity = "Basketball Team" + email = "testuser@example.com" + # Signup + signup_resp = client.post(f"/activities/{activity}/signup?email={email}") + assert signup_resp.status_code == 200 or signup_resp.status_code == 400 + # Unregister + unregister_resp = client.post(f"/activities/{activity}/unregister?email={email}") + assert unregister_resp.status_code == 200 or unregister_resp.status_code == 400 + +def test_signup_nonexistent_activity(): + email = "ghost@example.com" + resp = client.post("/activities/Nonexistent Activity/signup?email=" + email) + assert resp.status_code == 404 + assert resp.json()["detail"] == "Activity not found" + + +def test_unregister_non_participant(): + activity = "Drama Club" + email = "not_signed_up@example.com" + resp = client.post(f"/activities/{activity}/unregister?email={email}") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Participant not found" + + +def test_max_participants_limit(): + activity = "Art Workshop" + emails = [f"user{i}@example.com" for i in range(1, 22)] + # Fill up the activity + for email in emails[:20]: + resp = client.post(f"/activities/{activity}/signup?email={email}") + assert resp.status_code == 200 or resp.status_code == 400 + # Try to add one more + resp = client.post(f"/activities/{activity}/signup?email={emails[20]}") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Activity is full" + + +def test_static_index(): + resp = client.get("/static/index.html") + assert resp.status_code == 200 + + +def test_signup_twice(): + activity = "Soccer Club" + email = "twiceuser@example.com" + # First signup + resp1 = client.post(f"/activities/{activity}/signup?email={email}") + assert resp1.status_code == 200 + # Second signup should fail + resp2 = client.post(f"/activities/{activity}/signup?email={email}") + assert resp2.status_code == 400 + assert resp2.json()["detail"] == "Student already signed up for this activity" + + +def test_signup_empty_email(): + activity = "Basketball Team" + # Test with empty email + resp = client.post(f"/activities/{activity}/signup?email=") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Email cannot be empty or whitespace-only" + + +def test_signup_whitespace_email(): + activity = "Basketball Team" + # Test with whitespace-only email + resp = client.post(f"/activities/{activity}/signup?email= ") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Email cannot be empty or whitespace-only" + + +def test_unregister_empty_email(): + activity = "Drama Club" + # Test with empty email + resp = client.post(f"/activities/{activity}/unregister?email=") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Email cannot be empty or whitespace-only" + + +def test_unregister_whitespace_email(): + activity = "Drama Club" + # Test with whitespace-only email + resp = client.post(f"/activities/{activity}/unregister?email= ") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Email cannot be empty or whitespace-only"