-
Notifications
You must be signed in to change notification settings - Fork 0
Improve student activity registration system #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| fastapi | ||
| uvicorn | ||
| httpx | ||
| watchfiles | ||
| watchfiles | ||
| pytest |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,42 @@ | |
| "schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM", | ||
| "max_participants": 30, | ||
| "participants": ["john@mergington.edu", "olivia@mergington.edu"] | ||
| }, | ||
| "Soccer Team": { | ||
| "description": "Competitive soccer training and matches", | ||
| "schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM", | ||
| "max_participants": 22, | ||
| "participants": ["lucas@mergington.edu", "mia@mergington.edu"] | ||
| }, | ||
| "Basketball Team": { | ||
| "description": "Basketball drills, scrimmages, and league games", | ||
| "schedule": "Wednesdays and Fridays, 3:30 PM - 5:00 PM", | ||
| "max_participants": 15, | ||
| "participants": ["liam@mergington.edu", "noah@mergington.edu"] | ||
| }, | ||
| "Art Club": { | ||
| "description": "Explore painting, drawing, and mixed media techniques", | ||
| "schedule": "Mondays, 3:30 PM - 5:00 PM", | ||
| "max_participants": 15, | ||
| "participants": ["ava@mergington.edu", "isabella@mergington.edu"] | ||
| }, | ||
| "Drama Club": { | ||
| "description": "Theater acting, stagecraft, and seasonal performances", | ||
| "schedule": "Tuesdays and Thursdays, 3:30 PM - 5:00 PM", | ||
| "max_participants": 20, | ||
| "participants": ["amelia@mergington.edu", "harper@mergington.edu"] | ||
| }, | ||
| "Debate Club": { | ||
| "description": "Practice public speaking and compete in debate tournaments", | ||
| "schedule": "Wednesdays, 3:30 PM - 5:00 PM", | ||
| "max_participants": 16, | ||
| "participants": ["ethan@mergington.edu", "charlotte@mergington.edu"] | ||
| }, | ||
| "Science Club": { | ||
| "description": "Hands-on experiments and science fair preparation", | ||
| "schedule": "Fridays, 3:30 PM - 5:00 PM", | ||
| "max_participants": 18, | ||
| "participants": ["james@mergington.edu", "elijah@mergington.edu"] | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -62,6 +98,25 @@ 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="Already signed up") | ||
|
|
||
| # Add student | ||
| activity["participants"].append(email) | ||
| return {"message": f"Signed up {email} for {activity_name}"} | ||
|
|
||
|
|
||
| @app.delete("/activities/{activity_name}/signup") | ||
| def unregister_from_activity(activity_name: str, email: str): | ||
| """Unregister a student from an activity""" | ||
| 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="Student not signed up for this activity") | ||
|
Comment on lines
+110
to
+119
|
||
|
|
||
| activity["participants"].remove(email) | ||
| return {"message": f"Unregistered {email} from {activity_name}"} | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,11 +20,24 @@ document.addEventListener("DOMContentLoaded", () => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const spotsLeft = details.max_participants - details.participants.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const participantsList = details.participants | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (p) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `<li><span class="participant-email">${p}</span><button class="remove-btn" data-activity="${name}" data-email="${p}" title="Remove ${p}">✕</button></li>` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `<li><span class="participant-email">${p}</span><button class="remove-btn" data-activity="${name}" data-email="${p}" title="Remove ${p}">✕</button></li>` | |
| `<li><span class="participant-email">${p}</span><button type="button" class="remove-btn" data-activity="${name}" data-email="${p}" aria-label="Remove ${p}" title="Remove ${p}">✕</button></li>` |
Copilot
AI
Apr 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
details.participants (and name) are interpolated directly into innerHTML and into HTML attributes. Since email comes from user input, this enables DOM/attribute injection (XSS) if a malicious value is stored. Build these list items with DOM APIs (createElement, textContent, dataset) or escape/sanitize values before inserting into HTML/attributes.
| const participantsList = details.participants | |
| .map( | |
| (p) => | |
| `<li><span class="participant-email">${p}</span><button class="remove-btn" data-activity="${name}" data-email="${p}" title="Remove ${p}">✕</button></li>` | |
| ) | |
| .join(""); | |
| activityCard.innerHTML = ` | |
| <h4>${name}</h4> | |
| <p>${details.description}</p> | |
| <p><strong>Schedule:</strong> ${details.schedule}</p> | |
| <p><strong>Availability:</strong> ${spotsLeft} spots left</p> | |
| <div class="participants-section"> | |
| <strong>Participants:</strong> | |
| <ul class="participants-list"> | |
| ${participantsList || "<li class='no-participants'>No participants yet</li>"} | |
| </ul> | |
| </div> | |
| `; | |
| const title = document.createElement("h4"); | |
| title.textContent = name; | |
| const description = document.createElement("p"); | |
| description.textContent = details.description; | |
| const schedule = document.createElement("p"); | |
| const scheduleLabel = document.createElement("strong"); | |
| scheduleLabel.textContent = "Schedule:"; | |
| schedule.appendChild(scheduleLabel); | |
| schedule.appendChild(document.createTextNode(` ${details.schedule}`)); | |
| const availability = document.createElement("p"); | |
| const availabilityLabel = document.createElement("strong"); | |
| availabilityLabel.textContent = "Availability:"; | |
| availability.appendChild(availabilityLabel); | |
| availability.appendChild(document.createTextNode(` ${spotsLeft} spots left`)); | |
| const participantsSection = document.createElement("div"); | |
| participantsSection.className = "participants-section"; | |
| const participantsLabel = document.createElement("strong"); | |
| participantsLabel.textContent = "Participants:"; | |
| const participantsUl = document.createElement("ul"); | |
| participantsUl.className = "participants-list"; | |
| if (details.participants.length > 0) { | |
| details.participants.forEach((p) => { | |
| const participantItem = document.createElement("li"); | |
| const participantEmail = document.createElement("span"); | |
| participantEmail.className = "participant-email"; | |
| participantEmail.textContent = p; | |
| const removeButton = document.createElement("button"); | |
| removeButton.className = "remove-btn"; | |
| removeButton.dataset.activity = name; | |
| removeButton.dataset.email = p; | |
| removeButton.title = `Remove ${p}`; | |
| removeButton.textContent = "✕"; | |
| participantItem.appendChild(participantEmail); | |
| participantItem.appendChild(removeButton); | |
| participantsUl.appendChild(participantItem); | |
| }); | |
| } else { | |
| const noParticipantsItem = document.createElement("li"); | |
| noParticipantsItem.className = "no-participants"; | |
| noParticipantsItem.textContent = "No participants yet"; | |
| participantsUl.appendChild(noParticipantsItem); | |
| } | |
| participantsSection.appendChild(participantsLabel); | |
| participantsSection.appendChild(participantsUl); | |
| activityCard.appendChild(title); | |
| activityCard.appendChild(description); | |
| activityCard.appendChild(schedule); | |
| activityCard.appendChild(availability); | |
| activityCard.appendChild(participantsSection); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -74,6 +74,53 @@ section h3 { | |||||||||||||||
| margin-bottom: 8px; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| .participants-section { | ||||||||||||||||
| margin-top: 10px; | ||||||||||||||||
| padding-top: 10px; | ||||||||||||||||
| border-top: 1px solid #e0e0e0; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| .participants-list { | ||||||||||||||||
| list-style: none; | ||||||||||||||||
| padding: 0; | ||||||||||||||||
| margin-top: 6px; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| .participants-list li { | ||||||||||||||||
| padding: 4px 10px; | ||||||||||||||||
| margin-bottom: 4px; | ||||||||||||||||
| background-color: #e8eaf6; | ||||||||||||||||
| color: #1a237e; | ||||||||||||||||
| border-radius: 12px; | ||||||||||||||||
| font-size: 14px; | ||||||||||||||||
| display: inline-flex; | ||||||||||||||||
| align-items: center; | ||||||||||||||||
| gap: 6px; | ||||||||||||||||
| margin-right: 4px; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| .remove-btn { | ||||||||||||||||
| background: none; | ||||||||||||||||
| border: none; | ||||||||||||||||
| color: #c62828; | ||||||||||||||||
| cursor: pointer; | ||||||||||||||||
| font-size: 12px; | ||||||||||||||||
| padding: 0 2px; | ||||||||||||||||
| line-height: 1; | ||||||||||||||||
| border-radius: 50%; | ||||||||||||||||
| transition: background-color 0.2s; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| .remove-btn:hover { | ||||||||||||||||
| background-color: #ffcdd2; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
||||||||||||||||
| .remove-btn:focus-visible { | |
| background-color: #ffcdd2; | |
| outline: 2px solid #c62828; | |
| outline-offset: 2px; | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import copy | ||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
| from src.app import app, activities | ||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def reset_activities(): | ||
| """Reset activities to original state before each test.""" | ||
| original = copy.deepcopy(activities) | ||
| yield | ||
| activities.clear() | ||
| activities.update(original) | ||
|
|
||
|
|
||
| client = TestClient(app) | ||
|
|
||
|
|
||
| # ── GET / ──────────────────────────────────────────────────────────────── | ||
|
|
||
| def test_root_redirect(): | ||
| # Arrange | ||
| url = "/" | ||
|
|
||
| # Act | ||
| response = client.get(url, follow_redirects=False) | ||
|
|
||
| # Assert | ||
| assert response.status_code == 307 | ||
| assert response.headers["location"] == "/static/index.html" | ||
|
|
||
|
|
||
| # ── GET /activities ────────────────────────────────────────────────────── | ||
|
|
||
| def test_get_activities_returns_all(): | ||
| # Arrange | ||
| expected_keys = {"description", "schedule", "max_participants", "participants"} | ||
|
|
||
| # Act | ||
| response = client.get("/activities") | ||
|
|
||
| # Assert | ||
| assert response.status_code == 200 | ||
| data = response.json() | ||
| assert isinstance(data, dict) | ||
| assert len(data) == len(activities) | ||
| for name, details in data.items(): | ||
| assert expected_keys == set(details.keys()) | ||
| assert isinstance(details["participants"], list) | ||
|
|
||
|
|
||
| # ── POST /activities/{name}/signup ─────────────────────────────────────── | ||
|
|
||
| def test_signup_success(): | ||
| # Arrange | ||
| activity_name = "Chess Club" | ||
| email = "newstudent@mergington.edu" | ||
|
|
||
| # Act | ||
| response = client.post( | ||
| f"/activities/{activity_name}/signup", | ||
| params={"email": email}, | ||
| ) | ||
|
Comment on lines
+59
to
+62
|
||
|
|
||
| # Assert | ||
| assert response.status_code == 200 | ||
| assert email in activities[activity_name]["participants"] | ||
| assert response.json()["message"] == f"Signed up {email} for {activity_name}" | ||
|
|
||
|
|
||
| def test_signup_duplicate(): | ||
| # Arrange | ||
| activity_name = "Chess Club" | ||
| email = "michael@mergington.edu" # already in participants | ||
|
|
||
| # Act | ||
| response = client.post( | ||
| f"/activities/{activity_name}/signup", | ||
| params={"email": email}, | ||
| ) | ||
|
|
||
| # Assert | ||
| assert response.status_code == 400 | ||
| assert response.json()["detail"] == "Already signed up" | ||
|
|
||
|
|
||
| def test_signup_unknown_activity(): | ||
| # Arrange | ||
| activity_name = "Nonexistent 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" | ||
|
|
||
|
|
||
| # ── DELETE /activities/{name}/signup ───────────────────────────────────── | ||
|
|
||
| def test_unregister_success(): | ||
| # Arrange | ||
| activity_name = "Chess Club" | ||
| email = "michael@mergington.edu" # existing participant | ||
|
|
||
| # Act | ||
| response = client.delete( | ||
| f"/activities/{activity_name}/signup", | ||
| params={"email": email}, | ||
| ) | ||
|
|
||
| # Assert | ||
| assert response.status_code == 200 | ||
| assert email not in activities[activity_name]["participants"] | ||
| assert response.json()["message"] == f"Unregistered {email} from {activity_name}" | ||
|
|
||
|
|
||
| def test_unregister_not_signed_up(): | ||
| # Arrange | ||
| activity_name = "Chess Club" | ||
| email = "unknown@mergington.edu" | ||
|
|
||
| # Act | ||
| response = client.delete( | ||
| f"/activities/{activity_name}/signup", | ||
| params={"email": email}, | ||
| ) | ||
|
|
||
| # Assert | ||
| assert response.status_code == 400 | ||
| assert response.json()["detail"] == "Student not signed up for this activity" | ||
|
|
||
|
|
||
| def test_unregister_unknown_activity(): | ||
| # Arrange | ||
| activity_name = "Nonexistent Club" | ||
| email = "student@mergington.edu" | ||
|
|
||
| # Act | ||
| response = client.delete( | ||
| f"/activities/{activity_name}/signup", | ||
| params={"email": email}, | ||
| ) | ||
|
|
||
| # Assert | ||
| assert response.status_code == 404 | ||
| assert response.json()["detail"] == "Activity not found" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The "Validate student is not already signed up" comment is not indented with the rest of the function body. While it doesn’t break execution, it’s easy to misread as being outside the function—indent it to match surrounding code.