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
65 changes: 65 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": "Team practices and inter-school basketball games",
"schedule": "Mondays and Thursdays, 4:00 PM - 5:30 PM",
"max_participants": 15,
"participants": ["liam@mergington.edu", "ava@mergington.edu"]
},
"Soccer Club": {
"description": "Skill drills, scrimmages, and weekend matches",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": ["noah@mergington.edu", "mia@mergington.edu"]
},
"Art Club": {
"description": "Explore drawing, painting, and mixed media projects",
"schedule": "Tuesdays, 3:30 PM - 5:00 PM",
"max_participants": 16,
"participants": ["isabella@mergington.edu", "lucas@mergington.edu"]
},
"Drama Society": {
"description": "Acting workshops and stage performance practice",
"schedule": "Fridays, 2:30 PM - 4:30 PM",
"max_participants": 20,
"participants": ["amelia@mergington.edu", "henry@mergington.edu"]
},
"Debate Team": {
"description": "Develop critical thinking and public speaking through debates",
"schedule": "Mondays, 3:30 PM - 4:30 PM",
"max_participants": 14,
"participants": ["charlotte@mergington.edu", "benjamin@mergington.edu"]
},
"Math Olympiad": {
"description": "Advanced problem-solving and math competition preparation",
"schedule": "Thursdays, 3:30 PM - 5:00 PM",
"max_participants": 12,
"participants": ["elijah@mergington.edu", "harper@mergington.edu"]
}
}

Expand All @@ -59,9 +95,38 @@ def signup_for_activity(activity_name: str, email: str):
if activity_name not in activities:
raise HTTPException(status_code=404, detail="Activity not found")

# Normalize email to prevent case/whitespace bypass
email = email.strip().lower()

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

# Add student
activity["participants"].append(email)
Comment on lines +104 to 109
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The backend accepts email as an unconstrained str and stores it directly. This makes duplicates easy to bypass via case/whitespace differences (e.g., User@... vs user@...) and allows non-email / potentially unsafe strings to be persisted and later rendered by the frontend. Consider validating and normalizing the email (e.g., pydantic.EmailStr + strip() + lowercase) before checking membership / appending.

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


@app.delete("/activities/{activity_name}/participants/{email}")
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")

# Normalize email to prevent case/whitespace bypass
email = email.strip().lower()

# Get the specific activity
activity = activities[activity_name]

# Validate student is currently signed up
if email not in activity["participants"]:
raise HTTPException(status_code=404, detail="Student is not signed up for this activity")

# Remove student
activity["participants"].remove(email)
return {"message": f"Unregistered {email} from {activity_name}"}
145 changes: 138 additions & 7 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ document.addEventListener("DOMContentLoaded", () => {
// Function to fetch activities from API
async function fetchActivities() {
try {
const response = await fetch("/activities");
const response = await fetch(`/activities?t=${Date.now()}`, {
cache: "no-store",
});

if (!response.ok) {
throw new Error("Failed to fetch activities");
}

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]) => {
Expand All @@ -20,13 +28,81 @@ document.addEventListener("DOMContentLoaded", () => {

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

activityCard.innerHTML = `
<h4>${name}</h4>
<p>${details.description}</p>
<p><strong>Schedule:</strong> ${details.schedule}</p>
<p><strong>Availability:</strong> ${spotsLeft} spots left</p>
`;
// Activity title
const titleEl = document.createElement("h4");
titleEl.textContent = name;
activityCard.appendChild(titleEl);

// Activity description
const descriptionEl = document.createElement("p");
descriptionEl.textContent = details.description;
activityCard.appendChild(descriptionEl);

// Activity schedule
const scheduleEl = document.createElement("p");
const scheduleStrong = document.createElement("strong");
scheduleStrong.textContent = "Schedule:";
scheduleEl.appendChild(scheduleStrong);
scheduleEl.append(" " + details.schedule);
activityCard.appendChild(scheduleEl);

// Availability
const availabilityEl = document.createElement("p");
const availabilityStrong = document.createElement("strong");
availabilityStrong.textContent = "Availability:";
availabilityEl.appendChild(availabilityStrong);
availabilityEl.append(` ${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";
const participantsTitleStrong = document.createElement("strong");
participantsTitleStrong.textContent = "Participants";
participantsTitle.appendChild(participantsTitleStrong);
participantsSection.appendChild(participantsTitle);

if (details.participants.length > 0) {
const participantsListEl = document.createElement("ul");
participantsListEl.className = "participants-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-btn";
removeButton.dataset.activity = name;
removeButton.dataset.email = participant;
removeButton.setAttribute(
"aria-label",
`Unregister ${participant} from ${name}`
);
removeButton.title = "Unregister participant";
removeButton.textContent = "🗑️";

itemEl.appendChild(removeButton);
participantsListEl.appendChild(itemEl);
});

participantsSection.appendChild(participantsListEl);
} else {
const noParticipantsEl = document.createElement("p");
noParticipantsEl.className = "no-participants";
noParticipantsEl.textContent = "No one has signed up yet.";
participantsSection.appendChild(noParticipantsEl);
}

activityCard.appendChild(participantsSection);
activitiesList.appendChild(activityCard);

// Add option to select dropdown
Expand Down Expand Up @@ -62,6 +138,7 @@ document.addEventListener("DOMContentLoaded", () => {
messageDiv.textContent = result.message;
messageDiv.className = "success";
signupForm.reset();
await fetchActivities();
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
Expand All @@ -81,6 +158,60 @@ document.addEventListener("DOMContentLoaded", () => {
}
});

activitiesList.addEventListener("click", async (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}

const removeButton = target.closest(".participant-remove-btn");
if (!removeButton) {
return;
}

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

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

removeButton.disabled = true;

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

const result = await response.json();

if (response.ok) {
messageDiv.textContent = result.message;
messageDiv.className = "success";
await 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 participant. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
console.error("Error unregistering participant:", error);
} finally {
removeButton.disabled = false;
}
});

// Initialize app
fetchActivities();
});
57 changes: 57 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,63 @@ section h3 {
margin-bottom: 8px;
}

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

.participants-title {
margin-bottom: 6px;
color: #1a237e;
}

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

.participant-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
padding: 6px 8px;
border-radius: 4px;
background-color: #eef3f8;
}

.participant-email {
color: #37474f;
word-break: break-word;
}

.participant-remove-btn {
border: none;
background: transparent;
padding: 2px;
font-size: 16px;
line-height: 1;
cursor: pointer;
}

.participant-remove-btn:hover {
transform: scale(1.1);
}

.participant-remove-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.no-participants {
color: #607d8b;
font-style: italic;
margin-bottom: 0;
}

.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)


@pytest.fixture(autouse=True)
def reset_activities_state():
snapshot = copy.deepcopy(activities)
yield
activities.clear()
activities.update(snapshot)
Loading