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

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

Copilot AI Apr 3, 2026

Copy link

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.

Suggested change
# Validate student is not already signed up
# Validate student is not already signed up

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

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

email is accepted as an unconstrained str and stored/echoed back to the UI where it’s displayed. To prevent malicious payloads and reduce inconsistent duplicates, validate/normalize it at the API boundary (e.g., strip whitespace, enforce email format and length; optionally enforce the school domain).

Copilot uses AI. Check for mistakes.

activity["participants"].remove(email)
return {"message": f"Unregistered {email} from {activity_name}"}
49 changes: 49 additions & 0 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}">&#x2715;</button></li>`

Copilot AI Apr 3, 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 control is rendered as an icon-only <button> and relies on title for labeling. For screen readers, add an aria-label (and consider type="button" to avoid accidental form submission if markup changes).

Suggested change
`<li><span class="participant-email">${p}</span><button class="remove-btn" data-activity="${name}" data-email="${p}" title="Remove ${p}">&#x2715;</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}">&#x2715;</button></li>`

Copilot uses AI. Check for mistakes.
)
.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>
`;

Comment on lines +23 to 42

Copilot AI Apr 3, 2026

Copy link

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.

Suggested change
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}">&#x2715;</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);

Copilot uses AI. Check for mistakes.
activitiesList.appendChild(activityCard);
Expand Down Expand Up @@ -62,6 +75,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 All @@ -81,6 +95,41 @@ document.addEventListener("DOMContentLoaded", () => {
}
});

// Handle participant removal
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 unregister. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
console.error("Error unregistering:", error);
}
});

// Initialize app
fetchActivities();
});
47 changes: 47 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Copilot AI Apr 3, 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 adds custom styling for an interactive control but only defines a hover state. Add a visible :focus-visible style (e.g., outline/background) so keyboard users can see which remove button is focused.

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

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

.form-group {
margin-bottom: 15px;
}
Expand Down
Empty file added tests/__init__.py
Empty file.
150 changes: 150 additions & 0 deletions tests/test_app.py
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

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

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

The activity name contains spaces (e.g., "Chess Club"), but the test builds the path using the raw string. HTTP clients typically require path segments to be URL-encoded; otherwise this can raise an invalid URL error. Encode activity_name when interpolating into the URL (as the frontend does) so the test matches real requests.

This issue also appears in the following locations of the same file:

  • line 76
  • line 92
  • line 110
  • line 127
  • line 143

Copilot uses AI. Check for mistakes.

# 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"
Loading