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: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
fastapi
uvicorn
pytest
requests
httpx
62 changes: 62 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,45 @@
"schedule": "Mondays, Wednesdays, Fridays, 2:00 PM - 3:00 PM",
"max_participants": 30,
"participants": ["john@mergington.edu", "olivia@mergington.edu"]
},
# Sports activities
"Soccer Team": {
"description": "Competitive soccer team practice and matches",
"schedule": "Mondays, Wednesdays, 4:00 PM - 6:00 PM",
"max_participants": 22,
"participants": ["liam@mergington.edu", "ava@mergington.edu"]
},
"Basketball Club": {
"description": "Pickup games, drills, and intramural tournaments",
"schedule": "Tuesdays and Thursdays, 5:00 PM - 6:30 PM",
"max_participants": 18,
"participants": ["noah@mergington.edu", "isabella@mergington.edu"]
},
# Artistic activities
"Art Club": {
"description": "Explore drawing, painting, and mixed media projects",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 16,
"participants": ["maya@mergington.edu", "henry@mergington.edu"]
},
"Drama Club": {
"description": "Acting workshops, rehearsals, and school productions",
"schedule": "Thursdays, 4:00 PM - 6:00 PM",
"max_participants": 20,
"participants": ["grace@mergington.edu", "alex@mergington.edu"]
},
# Intellectual activities
"Debate Team": {
"description": "Competitive debating, public speaking, and argumentation",
"schedule": "Tuesdays, 4:30 PM - 6:00 PM",
"max_participants": 14,
"participants": ["emma.w@mergington.edu", "michael.s@mergington.edu"]
},
"Science Club": {
"description": "Hands-on experiments, fairs, and STEM projects",
"schedule": "Fridays, 3:30 PM - 5:00 PM",
"max_participants": 20,
"participants": ["sophia.l@mergington.edu", "ben@mergington.edu"]
}
}

Expand All @@ -62,6 +101,29 @@ 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 for this activity")

# Add student
activity["participants"].append(email)
return {"message": f"Signed up {email} for {activity_name}"}


@app.delete("/activities/{activity_name}/unregister")
def unregister_from_activity(activity_name: str, email: str):
"""Unregister a student from an activity by email"""
# Validate activity exists
if activity_name not in activities:
raise HTTPException(status_code=404, detail="Activity not found")

activity = activities[activity_name]

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

# Remove the student
activity["participants"].remove(email)
return {"message": f"Unregistered {email} from {activity_name}"}

86 changes: 85 additions & 1 deletion src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ document.addEventListener("DOMContentLoaded", () => {
activitiesList.innerHTML = "";

// Populate activities list
Object.entries(activities).forEach(([name, details]) => {
// Reset activity select options (keep placeholder)
activitySelect.innerHTML = '<option value="">-- Select an activity --</option>';

Object.entries(activities).forEach(([name, details]) => {
const activityCard = document.createElement("div");
activityCard.className = "activity-card";

Expand All @@ -27,6 +30,85 @@ document.addEventListener("DOMContentLoaded", () => {
<p><strong>Availability:</strong> ${spotsLeft} spots left</p>
`;

// Participants section (created with DOM methods to avoid XSS)
const participantsSection = document.createElement("div");
participantsSection.className = "participants-section";

const participantsHeading = document.createElement("h5");
participantsHeading.textContent = "Participants";
participantsSection.appendChild(participantsHeading);

if (Array.isArray(details.participants) && details.participants.length > 0) {
const ul = document.createElement("ul");
ul.className = "participant-list";

details.participants.forEach((p) => {
const li = document.createElement("li");

const badge = document.createElement("span");
badge.className = "participant-badge";
const initials = String(p)
.trim()
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map(s => s[0]?.toUpperCase() || "")
.join("");
badge.textContent = initials || "U";

const text = document.createElement("span");
text.className = "participant-text";
text.textContent = p;

// Delete button to unregister participant
const deleteBtn = document.createElement("button");
deleteBtn.className = "participant-delete";
deleteBtn.title = `Remove ${p}`;
deleteBtn.innerHTML = "✖";
deleteBtn.addEventListener("click", async () => {
if (!confirm(`Remove ${p} from ${name}?`)) return;
try {
const response = await fetch(
`/activities/${encodeURIComponent(name)}/unregister?email=${encodeURIComponent(p)}`,
{ method: "DELETE" }
);

const result = await response.json();

if (response.ok) {
// Refresh activities to update counts and list
fetchActivities();
} else {
messageDiv.textContent = result.detail || "Failed to remove participant";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
setTimeout(() => messageDiv.classList.add("hidden"), 5000);
}
} catch (error) {
console.error("Error removing participant:", error);
messageDiv.textContent = "Failed to remove participant. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
setTimeout(() => messageDiv.classList.add("hidden"), 5000);
}
});

li.appendChild(badge);
li.appendChild(text);
li.appendChild(deleteBtn);
ul.appendChild(li);
});

participantsSection.appendChild(ul);
} else {
const emptyP = document.createElement("p");
emptyP.className = "participants-empty";
emptyP.textContent = "No participants yet — be the first to sign up!";
participantsSection.appendChild(emptyP);
}

activityCard.appendChild(participantsSection);

activitiesList.appendChild(activityCard);

// Add option to select dropdown
Expand Down Expand Up @@ -62,6 +144,8 @@ document.addEventListener("DOMContentLoaded", () => {
messageDiv.textContent = result.message;
messageDiv.className = "success";
signupForm.reset();
// Refresh activities so the new participant appears immediately
fetchActivities();
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
Expand Down
69 changes: 69 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,72 @@ footer {
padding: 20px;
color: #666;
}

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

.participants-section h5 {
margin: 0 0 8px 0;
font-size: 14px;
color: #1a237e;
}

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

.participant-list li {
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 10px;
}

.participant-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
border-radius: 50%;
background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%);
color: #fff;
font-weight: 700;
font-size: 12px;
}

.participant-delete {
background: transparent;
border: none;
color: #c62828;
font-size: 14px;
cursor: pointer;
padding: 6px;
margin-left: auto;
border-radius: 4px;
}

.participant-delete:hover {
background: rgba(198, 40, 40, 0.08);
}

.participant-text {
color: #333;
font-size: 14px;
word-break: break-word;
}

.participants-empty {
margin: 0;
padding: 8px;
border-radius: 4px;
background-color: #f1f8ff;
color: #0c5460;
border: 1px solid #dbeeff;
font-size: 13px;
}
62 changes: 62 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import copy

import pytest
from fastapi.testclient import TestClient

from src import app as app_module


@pytest.fixture(autouse=True)
def restore_activities():
# Make a deep copy of the in-memory activities and restore after each test
original = copy.deepcopy(app_module.activities)
yield
app_module.activities.clear()
app_module.activities.update(copy.deepcopy(original))


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


def test_get_activities(client):
resp = client.get("/activities")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
# Ensure one known activity exists
assert "Chess Club" in data
assert "participants" in data["Chess Club"]


def test_signup_then_unregister(client):
activity = "Chess Club"
email = "test.user@mergington.edu"

# Ensure email not already registered
resp = client.get("/activities")
assert resp.status_code == 200
assert email not in resp.json()[activity]["participants"]

# Sign up
resp = client.post(f"/activities/{activity}/signup?email={email}")
assert resp.status_code == 200
body = resp.json()
assert "Signed up" in body.get("message", "")

# Now email should be in participants
resp = client.get("/activities")
assert resp.status_code == 200
assert email in resp.json()[activity]["participants"]

# Unregister
resp = client.delete(f"/activities/{activity}/unregister?email={email}")
assert resp.status_code == 200
body = resp.json()
assert "Unregistered" in body.get("message", "")

# Email should be gone
resp = client.get("/activities")
assert resp.status_code == 200
assert email not in resp.json()[activity]["participants"]