Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
fastapi
uvicorn
httpx
watchfiles
watchfiles
pytest
55 changes: 55 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 interschool matches",
"schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM",
"max_participants": 22,
"participants": ["lucas@mergington.edu", "liam@mergington.edu"]
},
"Basketball Team": {
"description": "Basketball drills, scrimmages, and league games",
"schedule": "Mondays and Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 15,
"participants": ["james@mergington.edu", "noah@mergington.edu"]
},
"Art Club": {
"description": "Explore painting, drawing, and mixed media art techniques",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 15,
"participants": ["mia@mergington.edu", "isabella@mergington.edu"]
},
"Drama Club": {
"description": "Act in plays, learn stagecraft, and perform in school productions",
"schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
"max_participants": 20,
"participants": ["charlotte@mergington.edu", "amelia@mergington.edu"]
},
"Debate Club": {
"description": "Practice public speaking and compete in debate tournaments",
"schedule": "Thursdays, 3:30 PM - 5:00 PM",
"max_participants": 16,
"participants": ["ethan@mergington.edu", "alexander@mergington.edu"]
},
"Math Olympiad": {
"description": "Solve challenging math problems and compete in mathematics competitions",
"schedule": "Tuesdays, 3:30 PM - 5:00 PM",
"max_participants": 15,
"participants": ["william@mergington.edu", "benjamin@mergington.edu"]
}
}

Expand All @@ -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="Student already signed up")

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signup_for_activity appends the student without enforcing max_participants, so activities can be overbooked (and the frontend will show negative spots left). Add a capacity check before appending and return an appropriate 4xx error when the activity is full.

Suggested change
# Validate activity capacity
if len(activity["participants"]) >= activity["max_participants"]:
raise HTTPException(status_code=400, detail="Activity is full")

Copilot uses AI. Check for mistakes.
# 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=404, detail="Student not found in activity")

activity["participants"].remove(email)
return {"message": f"Unregistered {email} from {activity_name}"}
51 changes: 51 additions & 0 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,26 @@ document.addEventListener("DOMContentLoaded", () => {

const spotsLeft = details.max_participants - details.participants.length;

const participantsList = details.participants.length
? details.participants.map(email =>
`<li>
<span class="participant-email">${email}</span>
<button class="remove-btn" data-activity="${name}" data-email="${email}" title="Remove participant">&#x2715;</button>

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remove button is icon-only and relies on title for labeling, which is not a reliable accessible name. Add an aria-label (and/or visually hidden text) so screen readers announce what the button does (ideally including the participant email).

Suggested change
<button class="remove-btn" data-activity="${name}" data-email="${email}" title="Remove participant">&#x2715;</button>
<button class="remove-btn" data-activity="${name}" data-email="${email}" title="Remove participant" aria-label="Remove participant ${email}">&#x2715;</button>

Copilot uses AI. Check for mistakes.
</li>`
).join("")
: "<li class='no-participants'>No participants yet</li>";

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}
</ul>
</div>
`;

Comment on lines +23 to 44

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI is built via innerHTML with interpolated name, details.description, and especially email (user-controlled via signup). This allows HTML/attribute injection (XSS) if an email contains markup/quotes. Prefer creating DOM nodes and setting textContent, or sanitize/escape values before inserting into HTML.

Suggested change
const participantsList = details.participants.length
? details.participants.map(email =>
`<li>
<span class="participant-email">${email}</span>
<button class="remove-btn" data-activity="${name}" data-email="${email}" title="Remove participant">&#x2715;</button>
</li>`
).join("")
: "<li class='no-participants'>No participants yet</li>";
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}
</ul>
</div>
`;
// Build activity card content safely using DOM APIs to avoid XSS
const titleEl = document.createElement("h4");
titleEl.textContent = name;
const descEl = document.createElement("p");
descEl.textContent = details.description;
const scheduleEl = document.createElement("p");
const scheduleStrong = document.createElement("strong");
scheduleStrong.textContent = "Schedule:";
scheduleEl.appendChild(scheduleStrong);
scheduleEl.appendChild(document.createTextNode(" " + details.schedule));
const availabilityEl = document.createElement("p");
const availabilityStrong = document.createElement("strong");
availabilityStrong.textContent = "Availability:";
availabilityEl.appendChild(availabilityStrong);
availabilityEl.appendChild(document.createTextNode(" " + spotsLeft + " spots left"));
const participantsSection = document.createElement("div");
participantsSection.className = "participants-section";
const participantsLabel = document.createElement("strong");
participantsLabel.textContent = "Participants:";
participantsSection.appendChild(participantsLabel);
const participantsUl = document.createElement("ul");
participantsUl.className = "participants-list";
if (details.participants.length) {
details.participants.forEach(email => {
const li = document.createElement("li");
const emailSpan = document.createElement("span");
emailSpan.className = "participant-email";
emailSpan.textContent = email;
const removeBtn = document.createElement("button");
removeBtn.className = "remove-btn";
removeBtn.dataset.activity = name;
removeBtn.dataset.email = email;
removeBtn.title = "Remove participant";
removeBtn.textContent = "✕";
li.appendChild(emailSpan);
li.appendChild(removeBtn);
participantsUl.appendChild(li);
});
} else {
const li = document.createElement("li");
li.className = "no-participants";
li.textContent = "No participants yet";
participantsUl.appendChild(li);
}
participantsSection.appendChild(participantsUl);
activityCard.appendChild(titleEl);
activityCard.appendChild(descEl);
activityCard.appendChild(scheduleEl);
activityCard.appendChild(availabilityEl);
activityCard.appendChild(participantsSection);

Copilot uses AI. Check for mistakes.
activitiesList.appendChild(activityCard);
Expand All @@ -41,6 +56,41 @@ document.addEventListener("DOMContentLoaded", () => {
}
}

// Handle remove participant button clicks
activitiesList.addEventListener("click", async (event) => {
const btn = event.target.closest(".remove-btn");
if (!btn) return;

const activity = btn.dataset.activity;
const email = btn.dataset.email;

try {
const response = await fetch(
`/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`,
{ method: "DELETE" }
);

const result = await response.json();

if (response.ok) {
messageDiv.textContent = result.message;
messageDiv.className = "success";
fetchActivities();
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
}

messageDiv.classList.remove("hidden");
setTimeout(() => messageDiv.classList.add("hidden"), 5000);
} catch (error) {
messageDiv.textContent = "Failed to remove participant. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
console.error("Error removing participant:", error);
}
});

// Handle form submission
signupForm.addEventListener("submit", async (event) => {
event.preventDefault();
Expand All @@ -62,6 +112,7 @@ document.addEventListener("DOMContentLoaded", () => {
messageDiv.textContent = result.message;
messageDiv.className = "success";
signupForm.reset();
fetchActivities();
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
Expand Down
56 changes: 56 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,62 @@ section h3 {
margin-bottom: 8px;
}

.participants-section {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed #ddd;
}

.participants-list {
list-style: none;
padding: 0;
margin: 6px 0 0 0;
}

.participants-list li {
padding: 4px 8px;
position: relative;
font-size: 14px;
color: #555;
display: flex;
align-items: center;
justify-content: space-between;
}

.participants-list li::before {
content: none;
}

.participant-email {
flex: 1;
}

.remove-btn {
background: none;
border: none;
color: #c62828;
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 3px;
transition: background-color 0.2s;
line-height: 1;
}

.remove-btn:hover {
background-color: #ffebee;
color: #b71c1c;
}

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.remove-btn has hover styling but no explicit focus styling. Add a :focus-visible (or :focus) style so keyboard users can see which remove button is focused.

Suggested change
.remove-btn:focus-visible,
.remove-btn:focus {
background-color: #ffebee;
color: #b71c1c;
outline: 2px solid #b71c1c;
outline-offset: 2px;
}

Copilot uses AI. Check for mistakes.
.participants-list li.no-participants {
font-style: italic;
color: #999;
}

.participants-list li.no-participants::before {
content: none;
}

.form-group {
margin-bottom: 15px;
}
Expand Down
Empty file added tests/__init__.py
Empty file.
129 changes: 129 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Tests for the Mergington High School API endpoints."""

import copy
import pytest
from fastapi.testclient import TestClient
from src.app import app, activities


@pytest.fixture(autouse=True)
def reset_activities():
"""Reset the activities dict to its original state before each test."""
original = copy.deepcopy(activities)
yield
activities.clear()
activities.update(original)


client = TestClient(app)


def test_get_activities():
# Arrange
expected_keys = {"description", "schedule", "max_participants", "participants"}

# Act
response = client.get("/activities")

# Assert
assert response.status_code == 200
data = response.json()
assert len(data) == 9
for name, details in data.items():
assert expected_keys.issubset(details.keys()), f"{name} missing keys"


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},
)

# Assert
assert response.status_code == 200
assert email in activities[activity_name]["participants"]


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 "already signed up" in response.json()["detail"].lower()


def test_signup_nonexistent_activity():
# Arrange
activity_name = "Nonexistent Activity"
email = "someone@mergington.edu"

# Act
response = client.post(
f"/activities/{activity_name}/signup",
params={"email": email},
)

# Assert
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()


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"]


def test_unregister_not_found():
# Arrange
activity_name = "Chess Club"
email = "nonexistent@mergington.edu"

# Act
response = client.delete(
f"/activities/{activity_name}/signup",
params={"email": email},
)

# Assert
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()


def test_unregister_nonexistent_activity():
# Arrange
activity_name = "Nonexistent Activity"
email = "someone@mergington.edu"

# Act
response = client.delete(
f"/activities/{activity_name}/signup",
params={"email": email},
)

# Assert
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
Loading