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
64 changes: 63 additions & 1 deletion 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 Thursdays, 4:00 PM - 5:30 PM",
"max_participants": 15,
"participants": ["james@mergington.edu"]
},
"Tennis Club": {
"description": "Learn tennis skills and participate in friendly matches",
"schedule": "Wednesdays and Saturdays, 3:00 PM - 4:30 PM",
"max_participants": 16,
"participants": ["alex@mergington.edu", "sarah@mergington.edu"]
},
"Art Studio": {
"description": "Explore painting, drawing, and other visual arts",
"schedule": "Tuesdays and Fridays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": ["grace@mergington.edu"]
},
"Music Band": {
"description": "Join our school band and perform at events",
"schedule": "Mondays, Wednesdays, Fridays, 3:30 PM - 4:30 PM",
"max_participants": 25,
"participants": ["lucas@mergington.edu", "sophia@mergington.edu"]
},
"Debate Society": {
"description": "Develop public speaking and critical thinking skills",
"schedule": "Thursdays, 3:30 PM - 5:00 PM",
"max_participants": 20,
"participants": ["charlotte@mergington.edu", "henry@mergington.edu"]
},
"Science Club": {
"description": "Conduct experiments and explore STEM concepts",
"schedule": "Wednesdays, 3:30 PM - 4:30 PM",
"max_participants": 22,
"participants": ["noah@mergington.edu"]
}
}

Expand All @@ -62,6 +98,32 @@ 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}"}
return {"message": f"Signed up {email} for {activity_name}", "participants": activity["participants"]}


@app.delete("/activities/{activity_name}/participants")
def unregister_participant(activity_name: str, email: str):
"""Unregister a student from an activity

Calls should pass the student email as a query parameter, e.g.
DELETE /activities/Chess%20Club/participants?email=student@example.com
"""
# 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 currently signed up
if email not in activity.get("participants", []):
raise HTTPException(status_code=404, detail="Student not signed up for this activity")

# Remove the student
activity["participants"].remove(email)
return {"message": f"Unregistered {email} from {activity_name}", "participants": activity["participants"]}
116 changes: 103 additions & 13 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,117 @@ document.addEventListener("DOMContentLoaded", () => {
const signupForm = document.getElementById("signup-form");
const messageDiv = document.getElementById("message");

// Function to safely set text
function setText(node, text) { node.textContent = text || ""; }

// Function to fetch activities from API
async function fetchActivities() {
try {
const response = await fetch("/activities");
const response = await fetch("/activities", { cache: "no-store" });
const activities = await response.json();

// Clear loading message
// Clear loading message / list
activitiesList.innerHTML = "";

// Reset dropdown (keep placeholder)
while (activitySelect.options.length > 1) activitySelect.remove(1);

// 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;
// Header
const title = document.createElement("h4");
setText(title, name);
activityCard.appendChild(title);

// Description
const desc = document.createElement("p");
setText(desc, details.description);
activityCard.appendChild(desc);

// Schedule
const sched = document.createElement("p");
sched.innerHTML = `<strong>Schedule:</strong> ${details.schedule || ""}`;
activityCard.appendChild(sched);

// Availability
const spotsLeft = Math.max(0, (details.max_participants || 0) - (details.participants?.length || 0));
const avail = document.createElement("p");
avail.innerHTML = `<strong>Availability:</strong> ${spotsLeft} spots left`;
activityCard.appendChild(avail);

// Participants section
const participantsSection = document.createElement("div");
participantsSection.className = "participants-section";

const participantsHeader = document.createElement("h5");
participantsHeader.innerHTML = `Participants <span class="badge">${(details.participants || []).length}</span>`;
participantsSection.appendChild(participantsHeader);

if (details.participants && details.participants.length) {
const ul = document.createElement("ul");
ul.className = "participant-list";
details.participants.forEach(p => {
const li = document.createElement("li");
li.className = "participant-item";

activityCard.innerHTML = `
<h4>${name}</h4>
<p>${details.description}</p>
<p><strong>Schedule:</strong> ${details.schedule}</p>
<p><strong>Availability:</strong> ${spotsLeft} spots left</p>
`;
// name/email text
const span = document.createElement("span");
setText(span, p);

// delete / unregister button
const btn = document.createElement("button");
btn.className = "delete-btn";
btn.title = `Unregister ${p}`;
btn.setAttribute("aria-label", `Unregister ${p} from ${name}`);
btn.innerHTML = `<span class="delete-icon">✖</span>`;

// call API to unregister when clicked
btn.addEventListener("click", async () => {
const ok = window.confirm(`Remove ${p} from ${name}?`);
if (!ok) return;

try {
const resp = await fetch(`/activities/${encodeURIComponent(name)}/participants?email=${encodeURIComponent(p)}`, { method: "DELETE", cache: "no-store" });
const resBody = await resp.json();

if (resp.ok) {
messageDiv.textContent = resBody.message || 'Removed participant';
messageDiv.className = 'message success';
} else {
messageDiv.textContent = resBody.detail || 'Failed to remove participant';
messageDiv.className = 'message error';
}
messageDiv.classList.remove('hidden');

// Refresh activities list
await fetchActivities();

setTimeout(() => messageDiv.classList.add('hidden'), 4000);
} catch (err) {
console.error('Error unregistering participant:', err);
messageDiv.textContent = 'Failed to remove participant — please try again.';
messageDiv.className = 'message error';
messageDiv.classList.remove('hidden');
setTimeout(() => messageDiv.classList.add('hidden'), 4000);
}
});

li.appendChild(span);
li.appendChild(btn);
ul.appendChild(li);
});
participantsSection.appendChild(ul);
} else {
const none = document.createElement("p");
none.className = "no-participants";
setText(none, "No participants yet.");
participantsSection.appendChild(none);
}

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

// Add option to select dropdown
Expand All @@ -45,26 +133,28 @@ document.addEventListener("DOMContentLoaded", () => {
signupForm.addEventListener("submit", async (event) => {
event.preventDefault();

const email = document.getElementById("email").value;
const email = document.getElementById("email").value.trim();
const activity = document.getElementById("activity").value;

try {
const response = await fetch(
`/activities/${encodeURIComponent(activity)}/signup?email=${encodeURIComponent(email)}`,
{
method: "POST",
cache: "no-store",
}
);

const result = await response.json();

if (response.ok) {
messageDiv.textContent = result.message;
messageDiv.className = "success";
messageDiv.className = "message success";
signupForm.reset();
await fetchActivities(); // refresh participants immediately
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
messageDiv.className = "message error";
}

messageDiv.classList.remove("hidden");
Expand All @@ -75,7 +165,7 @@ document.addEventListener("DOMContentLoaded", () => {
}, 5000);
} catch (error) {
messageDiv.textContent = "Failed to sign up. Please try again.";
messageDiv.className = "error";
messageDiv.className = "message error";
messageDiv.classList.remove("hidden");
console.error("Error signing up:", error);
}
Expand Down
80 changes: 80 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,83 @@ footer {
padding: 20px;
color: #666;
}

.participants-section {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(26, 35, 126, 0.08);
}

.participants-section h5 {
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #2b2b2b;
font-weight: 600;
}

.participants-section .badge {
background: linear-gradient(180deg,#ffffff,#eef4ff);
border: 1px solid #dbe6ff;
color: #1a237e;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
box-shadow: inset 0 -1px 0 rgba(0,0,0,0.03);
}

.participant-list {
list-style: none; /* hide default bullets */
padding-left: 0;
margin: 6px 0 0 0;
}

.participant-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
margin-bottom: 8px;
border-radius: 8px;
background: linear-gradient(180deg,#fbfbff,#ffffff);
border: 1px solid #f0f0f5;
font-size: 14px;
color: #333;
transition: transform .08s ease, box-shadow .12s ease;
}

.participant-item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(24, 39, 128, 0.06);
}

.delete-btn {
background: transparent;
border: none;
color: #d32f2f;
font-weight: 700;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
}

.delete-btn:hover {
background: rgba(211, 47, 47, 0.08);
}

.delete-icon {
font-size: 14px;
line-height: 1;
}

.no-participants {
font-size: 13px;
color: #777;
margin: 6px 0 0 0;
}
53 changes: 53 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from fastapi.testclient import TestClient
from src.app import app, activities


client = TestClient(app)


def test_get_activities():
resp = client.get("/activities")
assert resp.status_code == 200
data = resp.json()
# Expect to get the same keys as the in-memory activities mapping
assert set(data.keys()) == set(activities.keys())


def test_signup_and_unregister_flow():
activity_name = "Chess Club"
email = "test_student@example.com"

# Ensure email is not already registered (cleanup if necessary)
if email in activities[activity_name]["participants"]:
activities[activity_name]["participants"].remove(email)

# Sign up
resp = client.post(f"/activities/{activity_name}/signup?email={email}")
assert resp.status_code == 200
body = resp.json()
assert "Signed up" in body.get("message", "")
# participants should include newly added email
assert email in body.get("participants", [])

# Attempt duplicate signup should return 400
dup = client.post(f"/activities/{activity_name}/signup?email={email}")
assert dup.status_code == 400

# Now unregister
rem = client.delete(f"/activities/{activity_name}/participants?email={email}")
assert rem.status_code == 200
rbody = rem.json()
assert "Unregistered" in rbody.get("message", "")
assert email not in rbody.get("participants", [])


def test_unregister_not_found():
activity_name = "Chess Club"
email = "not_registered@example.com"

# Make sure this email is not present
if email in activities[activity_name]["participants"]:
activities[activity_name]["participants"].remove(email)

resp = client.delete(f"/activities/{activity_name}/participants?email={email}")
assert resp.status_code == 404