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
57 changes: 57 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"]
},
"Basketball Team": {
"description": "Competitive basketball team for intramural and regional tournaments",
"schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
"max_participants": 15,
"participants": ["alex@mergington.edu"]
},
"Tennis Club": {
"description": "Learn tennis skills and participate in friendly matches",
"schedule": "Tuesdays and Thursdays, 4:00 PM - 5:00 PM",
"max_participants": 12,
"participants": ["isabella@mergington.edu", "lucas@mergington.edu"]
},
"Art Studio": {
"description": "Explore painting, drawing, and mixed media techniques",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": ["grace@mergington.edu"]
},
"Music Band": {
"description": "Join our school band and perform at concerts and events",
"schedule": "Mondays and Thursdays, 3:30 PM - 4:30 PM",
"max_participants": 25,
"participants": ["noah@mergington.edu", "ava@mergington.edu"]
},
"Debate Team": {
"description": "Develop critical thinking and public speaking skills through competitive debate",
"schedule": "Fridays, 4:00 PM - 5:30 PM",
"max_participants": 16,
"participants": ["mason@mergington.edu"]
},
"Science Club": {
"description": "Conduct experiments and explore scientific concepts through hands-on projects",
"schedule": "Tuesdays, 3:30 PM - 5:00 PM",
"max_participants": 20,
"participants": ["ethan@mergington.edu", "chloe@mergington.edu"]
}
}

Expand All @@ -62,6 +98,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")

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

signup_for_activity adds the student without enforcing max_participants, so activities can be overbooked and the UI’s "spots left" can become negative/inaccurate. Add a capacity check before appending and return a 400 error when the activity is full.

Suggested change
# Validate activity is not over capacity (if a max is defined)
if "max_participants" in activity and 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}/participants")
def unregister_from_activity(activity_name: str, email: str):
"""Unregister a student from an activity"""
# Validate activity exists
if activity_name not in activities:
raise HTTPException(status_code=404, detail="Activity not found")

activity = activities[activity_name]

# Validate participant exists in this activity
if email not in activity["participants"]:
raise HTTPException(status_code=404, detail="Participant not found in this activity")

activity["participants"].remove(email)
return {"message": f"Removed {email} from {activity_name}"}
89 changes: 75 additions & 14 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,63 @@ document.addEventListener("DOMContentLoaded", () => {
const signupForm = document.getElementById("signup-form");
const messageDiv = document.getElementById("message");

function showMessage(message, type) {
messageDiv.textContent = message;
messageDiv.className = type;
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

messageDiv.className = type replaces all classes on the message element. This makes it easy to accidentally drop baseline styling classes (e.g., a shared message class) or future utility classes. Prefer using classList to toggle success/error while preserving any existing base classes.

Suggested change
messageDiv.className = type;
// Remove any previous status classes while preserving baseline classes
messageDiv.classList.remove("success", "error");
if (type) {
messageDiv.classList.add(type);
}

Copilot uses AI. Check for mistakes.
messageDiv.classList.remove("hidden");

setTimeout(() => {
messageDiv.classList.add("hidden");
Comment on lines 6 to +13
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

showMessage schedules a new setTimeout each time it is called, but doesn’t cancel any prior timeout. If multiple messages are shown within 5 seconds, an earlier timeout can hide the newer message unexpectedly. Track the timeout id and clearTimeout it before scheduling a new hide.

Suggested change
function showMessage(message, type) {
messageDiv.textContent = message;
messageDiv.className = type;
messageDiv.classList.remove("hidden");
setTimeout(() => {
messageDiv.classList.add("hidden");
let messageHideTimeoutId = null;
function showMessage(message, type) {
// Cancel any previously scheduled hide to avoid hiding a newer message
if (messageHideTimeoutId !== null) {
clearTimeout(messageHideTimeoutId);
messageHideTimeoutId = null;
}
messageDiv.textContent = message;
messageDiv.className = type;
messageDiv.classList.remove("hidden");
messageHideTimeoutId = setTimeout(() => {
messageDiv.classList.add("hidden");
messageHideTimeoutId = null;

Copilot uses AI. Check for mistakes.
}, 5000);
}

// Function to fetch activities from API
async function fetchActivities() {
try {
const response = await fetch("/activities");
const response = await fetch("/activities", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to fetch activities: ${response.status}`);
}
const activities = await response.json();

// Clear loading message
activitiesList.innerHTML = "";
activitySelect.innerHTML = '<option value="">-- Select an activity --</option>';

// 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;
const participantsMarkup = details.participants.length
? `<ul class="participant-list">${details.participants
.map(
(participant) =>
`<li class="participant-item">
<span class="participant-email">${participant}</span>
<button
type="button"
class="participant-remove"
data-activity="${name}"
data-email="${participant}"
aria-label="Remove ${participant} from ${name}"
title="Unregister participant"
>&times;</button>
</li>`
)
.join("")}</ul>`
: '<p class="participant-empty">No participants yet. Be the first to sign up.</p>';

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">
<p class="participants-title">Participants</p>
${participantsMarkup}
</div>
`;

Comment on lines +36 to 65
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

activityCard.innerHTML interpolates server-provided fields (including details.participants, which contains user-supplied email values) directly into HTML. Because /signup accepts an arbitrary string for email, a malicious value can be persisted and rendered, resulting in DOM XSS. Build the card/participants list using DOM APIs (createElement + textContent) or apply proper escaping/sanitization before inserting into innerHTML/data-* attributes.

Suggested change
const participantsMarkup = details.participants.length
? `<ul class="participant-list">${details.participants
.map(
(participant) =>
`<li class="participant-item">
<span class="participant-email">${participant}</span>
<button
type="button"
class="participant-remove"
data-activity="${name}"
data-email="${participant}"
aria-label="Remove ${participant} from ${name}"
title="Unregister participant"
>&times;</button>
</li>`
)
.join("")}</ul>`
: '<p class="participant-empty">No participants yet. Be the first to sign up.</p>';
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">
<p class="participants-title">Participants</p>
${participantsMarkup}
</div>
`;
// Title
const titleEl = document.createElement("h4");
titleEl.textContent = name;
activityCard.appendChild(titleEl);
// Description
const descriptionEl = document.createElement("p");
descriptionEl.textContent = details.description;
activityCard.appendChild(descriptionEl);
// Schedule
const scheduleEl = document.createElement("p");
const scheduleStrong = document.createElement("strong");
scheduleStrong.textContent = "Schedule:";
scheduleEl.appendChild(scheduleStrong);
scheduleEl.appendChild(document.createTextNode(" " + details.schedule));
activityCard.appendChild(scheduleEl);
// Availability
const availabilityEl = document.createElement("p");
const availabilityStrong = document.createElement("strong");
availabilityStrong.textContent = "Availability:";
availabilityEl.appendChild(availabilityStrong);
availabilityEl.appendChild(
document.createTextNode(" " + spotsLeft + " spots left")
);
activityCard.appendChild(availabilityEl);
// Participants section
const participantsSection = document.createElement("div");
participantsSection.className = "participants-section";
const participantsTitle = document.createElement("p");
participantsTitle.className = "participants-title";
participantsTitle.textContent = "Participants";
participantsSection.appendChild(participantsTitle);
if (details.participants.length) {
const listEl = document.createElement("ul");
listEl.className = "participant-list";
details.participants.forEach((participant) => {
const itemEl = document.createElement("li");
itemEl.className = "participant-item";
const emailSpan = document.createElement("span");
emailSpan.className = "participant-email";
emailSpan.textContent = participant;
itemEl.appendChild(emailSpan);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "participant-remove";
removeButton.dataset.activity = name;
removeButton.dataset.email = participant;
removeButton.setAttribute(
"aria-label",
`Remove ${participant} from ${name}`
);
removeButton.title = "Unregister participant";
removeButton.textContent = "×";
itemEl.appendChild(removeButton);
listEl.appendChild(itemEl);
});
participantsSection.appendChild(listEl);
} else {
const emptyEl = document.createElement("p");
emptyEl.className = "participant-empty";
emptyEl.textContent =
"No participants yet. Be the first to sign up.";
participantsSection.appendChild(emptyEl);
}
activityCard.appendChild(participantsSection);

Copilot uses AI. Check for mistakes.
activitiesList.appendChild(activityCard);
Expand Down Expand Up @@ -59,25 +95,50 @@ document.addEventListener("DOMContentLoaded", () => {
const result = await response.json();

if (response.ok) {
messageDiv.textContent = result.message;
messageDiv.className = "success";
showMessage(result.message, "success");
signupForm.reset();
await fetchActivities();
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
showMessage(result.detail || "An error occurred", "error");
}
} catch (error) {
showMessage("Failed to sign up. Please try again.", "error");
console.error("Error signing up:", error);
}
});

activitiesList.addEventListener("click", async (event) => {
const removeButton = event.target.closest(".participant-remove");
if (!removeButton) {
return;
}

const activity = removeButton.dataset.activity;
const email = removeButton.dataset.email;

if (!activity || !email) {
return;
}

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

messageDiv.classList.remove("hidden");
const result = await response.json();

// Hide message after 5 seconds
setTimeout(() => {
messageDiv.classList.add("hidden");
}, 5000);
if (response.ok) {
showMessage(result.message, "success");
await fetchActivities();
} else {
showMessage(result.detail || "Failed to unregister participant.", "error");
}
} catch (error) {
messageDiv.textContent = "Failed to sign up. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
console.error("Error signing up:", error);
showMessage("Failed to unregister participant. Please try again.", "error");
console.error("Error unregistering participant:", error);
}
});

Expand Down
67 changes: 67 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,73 @@ section h3 {
margin-bottom: 8px;
}

.participants-section {
margin-top: 14px;
padding: 12px;
border: 1px solid #dbe5ff;
border-radius: 8px;
background: linear-gradient(180deg, #f5f8ff 0%, #eef3ff 100%);
}

.participants-title {
margin-bottom: 8px;
font-weight: 700;
color: #1a237e;
letter-spacing: 0.2px;
}

.participant-list {
margin: 0;
padding: 0;
list-style: none;
}

.participant-item {
margin-bottom: 6px;
color: #24335a;
word-break: break-word;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
background-color: rgba(255, 255, 255, 0.75);
border: 1px solid #d7e2ff;
}

.participant-email {
overflow-wrap: anywhere;
}

.participant-remove {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #f2b8b5;
background-color: #fff4f4;
color: #b42318;
font-size: 18px;
font-weight: 700;
line-height: 1;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}

.participant-remove:hover {
background-color: #ffe7e5;
border-color: #eaa6a2;
}

.participant-empty {
margin: 0;
color: #5c6d9c;
font-style: italic;
}

.form-group {
margin-bottom: 15px;
}
Expand Down
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import copy

import pytest
from fastapi.testclient import TestClient

from src.app import activities, app


@pytest.fixture()
def client():
return TestClient(app)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The client fixture returns a TestClient instance without closing it. Prefer a yield-style fixture (or with TestClient(app) as client: ...) so FastAPI lifespan startup/shutdown handlers run deterministically and resources/sockets are released between tests.

Suggested change
return TestClient(app)
with TestClient(app) as client:
yield client

Copilot uses AI. Check for mistakes.


@pytest.fixture(autouse=True)
def restore_activities_state():
snapshot = copy.deepcopy(activities)
yield
activities.clear()
activities.update(snapshot)
55 changes: 55 additions & 0 deletions tests/test_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from src.app import activities


def test_get_activities_returns_expected_structure(client):
# Arrange

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

# Assert
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "Chess Club" in data
assert {
"description",
"schedule",
"max_participants",
"participants",
}.issubset(data["Chess Club"].keys())


def test_signup_adds_participant_successfully(client):
# Arrange
activity_name = "Chess Club"
email = "new.student@mergington.edu"

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

# Assert
assert response.status_code == 200
assert response.json()["message"] == f"Signed up {email} for {activity_name}"
assert email in activities[activity_name]["participants"]


def test_delete_unregisters_participant_successfully(client):
# Arrange
activity_name = "Chess Club"
email = "remove.me@mergington.edu"
activities[activity_name]["participants"].append(email)

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

# Assert
assert response.status_code == 200
assert response.json()["message"] == f"Removed {email} from {activity_name}"
assert email not in activities[activity_name]["participants"]
Loading
Loading