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
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
fastapi
uvicorn
pytest
httpx
80 changes: 80 additions & 0 deletions src/activities.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"Chess Club": {
"description": "Learn strategies and compete in chess tournaments",
"schedule": "Fridays, 3:30 PM - 5:00 PM",
"max_participants": 12,
"participants": [
"michael@mergington.edu",
"daniel@mergington.edu"
]
},
"Programming Class": {
"description": "Learn programming fundamentals and build software projects",
"schedule": "Tuesdays and Thursdays, 3:30 PM - 4:30 PM",
"max_participants": 20,
"participants": [
"emma@mergington.edu",
"sophia@mergington.edu"
]
},
"Gym Class": {
"description": "Physical education and sports activities",
"schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM",
"max_participants": 30,
"participants": [
"john@mergington.edu",
"olivia@mergington.edu"
]
},
"Soccer Team": {
"description": "Practice and compete in interschool soccer matches",
"schedule": "Tuesdays and Thursdays, 4:00 PM - 5:30 PM",
"max_participants": 25,
"participants": [
"alex@mergington.edu",
"mia@mergington.edu"
]
},
"Swimming Club": {
"description": "Swimming lessons and competitive training",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 15,
"participants": [
"lucas@mergington.edu"
]
},
"Art Studio": {
"description": "Explore painting, drawing, and mixed media art",
"schedule": "Mondays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": [
"isabella@mergington.edu",
"ava@mergington.edu"
]
},
"Drama Club": {
"description": "Acting, theater production, and performance arts",
"schedule": "Thursdays, 4:00 PM - 6:00 PM",
"max_participants": 20,
"participants": [
"noah@mergington.edu"
]
},
"Debate Team": {
"description": "Develop critical thinking and public speaking skills",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 16,
"participants": [
"liam@mergington.edu",
"charlotte@mergington.edu"
]
},
"Science Olympiad": {
"description": "Compete in science and engineering challenges",
"schedule": "Fridays, 3:30 PM - 5:30 PM",
"max_participants": 15,
"participants": [
"ethan@mergington.edu"
]
}
}
66 changes: 38 additions & 28 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
for extracurricular activities at Mergington High School.
"""

from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse

import os
from pathlib import Path
import json
from threading import Lock

app = FastAPI(title="Mergington High School API",
description="API for viewing and signing up for extracurricular activities")
Expand All @@ -19,27 +22,20 @@
app.mount("/static", StaticFiles(directory=os.path.join(Path(__file__).parent,
"static")), name="static")

# In-memory activity database
activities = {
"Chess Club": {
"description": "Learn strategies and compete in chess tournaments",
"schedule": "Fridays, 3:30 PM - 5:00 PM",
"max_participants": 12,
"participants": ["michael@mergington.edu", "daniel@mergington.edu"]
},
"Programming Class": {
"description": "Learn programming fundamentals and build software projects",
"schedule": "Tuesdays and Thursdays, 3:30 PM - 4:30 PM",
"max_participants": 20,
"participants": ["emma@mergington.edu", "sophia@mergington.edu"]
},
"Gym Class": {
"description": "Physical education and sports activities",
"schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM",
"max_participants": 30,
"participants": ["john@mergington.edu", "olivia@mergington.edu"]
}
}

# Persistent activities store
DATA_FILE = os.path.join(Path(__file__).parent, "activities.json")
_activities_lock = Lock()

def load_activities():
with _activities_lock:
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
Comment on lines +30 to +33
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The load_activities() function should handle the case where the data file doesn't exist or contains invalid JSON. Without error handling, the application will crash on startup if activities.json is missing or corrupted. Consider adding a try-except block to return default data or create the file if it doesn't exist.

Copilot uses AI. Check for mistakes.

def save_activities(activities):
with _activities_lock:
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(activities, f, indent=2)


@app.get("/")
Expand All @@ -49,19 +45,33 @@ def root():

@app.get("/activities")
def get_activities():
return activities
return load_activities()


@app.post("/activities/{activity_name}/signup")
def signup_for_activity(activity_name: str, email: str):
"""Sign up a student for an activity"""
# Validate activity exists
activities = load_activities()
if activity_name not in activities:
raise HTTPException(status_code=404, detail="Activity not found")

# Get the specific activity
activity = activities[activity_name]

# Add student
if email in activity["participants"]:
raise HTTPException(status_code=400, detail="Student already signed up for this activity")
Comment on lines +58 to +59
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The duplicate signup validation logic added in the signup endpoint lacks test coverage. Consider adding a test case that attempts to sign up the same email twice and verifies that a 400 error is returned with the appropriate error message.

Copilot uses AI. Check for mistakes.
activity["participants"].append(email)
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

The signup endpoint doesn't validate whether the activity has reached max_participants. Participants can be added beyond the maximum capacity, making the 'spots left' display in the UI inaccurate. Add a check to compare len(activity['participants']) against activity['max_participants'] before appending.

Copilot uses AI. Check for mistakes.
save_activities(activities)
return {"message": f"Signed up {email} for {activity_name}"}


# Unregister a participant from an activity
@app.delete("/activities/{activity_name}/unregister")
def unregister_from_activity(activity_name: str, email: str = Query(...)):
"""Unregister a student from an activity"""
activities = load_activities()
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 is not registered for this activity")
activity["participants"].remove(email)
save_activities(activities)
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,13 +20,63 @@ document.addEventListener("DOMContentLoaded", () => {

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

// Participants section
let participantsHTML = "";
if (details.participants.length > 0) {
participantsHTML = `
<div class="participants-section">
<strong>Participants:</strong>
<ul class="participants-list no-bullets">
${details.participants
.map(
(p) =>
`<li class="participant-item">${p} <span class="delete-participant" title="Remove participant" data-activity="${name}" data-email="${p}">🗑️</span></li>`
)
.join("")}
</ul>
</div>
`;
} else {
participantsHTML = `
<div class="participants-section no-participants">
<em>No participants yet</em>
</div>
`;
}


activityCard.innerHTML = `
<h4>${name}</h4>
<p>${details.description}</p>
<p><strong>Schedule:</strong> ${details.schedule}</p>
<p><strong>Availability:</strong> ${spotsLeft} spots left</p>
${participantsHTML}
`;

// Add event listeners for delete icons after rendering
setTimeout(() => {
activityCard.querySelectorAll('.delete-participant').forEach((icon) => {
icon.addEventListener('click', async (e) => {
const activity = icon.getAttribute('data-activity');
const email = icon.getAttribute('data-email');
if (!confirm(`Remove ${email} from ${activity}?`)) return;
try {
const response = await fetch(`/activities/${encodeURIComponent(activity)}/unregister?email=${encodeURIComponent(email)}`, {
method: 'DELETE',
});
const result = await response.json();
if (response.ok) {
fetchActivities();
} else {
alert(result.detail || 'Failed to remove participant.');
}
} catch (err) {
alert('Failed to remove participant.');
}
});
});
}, 0);
Comment on lines +57 to +78
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

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

Using setTimeout with 0ms delay is unnecessary here since the elements are already in the DOM after innerHTML assignment. The event listeners can be attached immediately without the setTimeout wrapper, improving code clarity and reducing potential timing issues.

Suggested change
setTimeout(() => {
activityCard.querySelectorAll('.delete-participant').forEach((icon) => {
icon.addEventListener('click', async (e) => {
const activity = icon.getAttribute('data-activity');
const email = icon.getAttribute('data-email');
if (!confirm(`Remove ${email} from ${activity}?`)) return;
try {
const response = await fetch(`/activities/${encodeURIComponent(activity)}/unregister?email=${encodeURIComponent(email)}`, {
method: 'DELETE',
});
const result = await response.json();
if (response.ok) {
fetchActivities();
} else {
alert(result.detail || 'Failed to remove participant.');
}
} catch (err) {
alert('Failed to remove participant.');
}
});
});
}, 0);
activityCard.querySelectorAll('.delete-participant').forEach((icon) => {
icon.addEventListener('click', async (e) => {
const activity = icon.getAttribute('data-activity');
const email = icon.getAttribute('data-email');
if (!confirm(`Remove ${email} from ${activity}?`)) return;
try {
const response = await fetch(`/activities/${encodeURIComponent(activity)}/unregister?email=${encodeURIComponent(email)}`, {
method: 'DELETE',
});
const result = await response.json();
if (response.ok) {
fetchActivities();
} else {
alert(result.detail || 'Failed to remove participant.');
}
} catch (err) {
alert('Failed to remove participant.');
}
});
});

Copilot uses AI. Check for mistakes.

activitiesList.appendChild(activityCard);

// Add option to select dropdown
Expand Down Expand Up @@ -62,6 +112,7 @@ document.addEventListener("DOMContentLoaded", () => {
messageDiv.textContent = result.message;
messageDiv.className = "success";
signupForm.reset();
fetchActivities(); // Refresh activities list after successful signup
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
Expand Down
65 changes: 65 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,68 @@ footer {
padding: 20px;
color: #666;
}

.participants-section {
margin-top: 12px;
padding: 10px;
background-color: #e3eafc;
border-radius: 4px;
}

.participants-section strong {
color: #1a237e;
font-size: 15px;
}

.participants-list {
margin: 8px 0 0 18px;
padding-left: 0;
list-style-type: none;
}

.no-bullets {
list-style-type: none !important;
margin-left: 0 !important;
padding-left: 0 !important;
}

.participant-item {
color: #3949ab;
font-size: 15px;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
}

.delete-participant {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: #e53935;
color: #fff;
border-radius: 50%;
font-size: 16px;
cursor: pointer;
margin-left: 8px;
border: none;
transition: background 0.2s, box-shadow 0.2s;
box-shadow: 0 1px 3px rgba(229,57,53,0.08);
line-height: 1;
}
.delete-participant:hover {
background: #b71c1c;
box-shadow: 0 2px 6px rgba(229,57,53,0.18);
}

.no-participants {
background-color: #f5f5f5;
color: #888;
font-style: italic;
padding: 8px 10px;
border-radius: 4px;
margin-top: 10px;
text-align: left;
}
37 changes: 37 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pytest
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
data = response.json()
assert isinstance(data, dict)
assert "Chess Club" in data

def test_signup_and_unregister():
test_email = "pytestuser@mergington.edu"
activity = "Chess Club"

# Ensure not already signed up
client.delete(f"/activities/{activity}/unregister", params={"email": test_email})

# Sign up
response = client.post(f"/activities/{activity}/signup?email={test_email}")
assert response.status_code == 200
assert f"Signed up {test_email}" in response.json()["message"]

# Check participant is in list
activities = client.get("/activities").json()
assert test_email in activities[activity]["participants"]

# Unregister
response = client.delete(f"/activities/{activity}/unregister", params={"email": test_email})
assert response.status_code == 200
assert f"Unregistered {test_email}" in response.json()["message"]

# Check participant is removed
activities = client.get("/activities").json()
assert test_email not in activities[activity]["participants"]