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 pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
[pytest]
pythonpath = .
testpaths = tests
python_files = test_*.py
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
57 changes: 56 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 interscholastic play",
"schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
"max_participants": 15,
"participants": ["alex@mergington.edu"]
},
"Tennis Club": {
"description": "Learn tennis skills and compete in matches",
"schedule": "Tuesdays and Thursdays, 3:30 PM - 5:00 PM",
"max_participants": 16,
"participants": ["ava@mergington.edu"]
},
"Art Studio": {
"description": "Explore painting, drawing, and mixed media techniques",
"schedule": "Wednesdays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": ["isabella@mergington.edu", "lucas@mergington.edu"]
},
"Drama Club": {
"description": "Perform in theatrical productions and develop acting skills",
"schedule": "Thursdays, 4:00 PM - 5:30 PM",
"max_participants": 25,
"participants": ["noah@mergington.edu"]
},
"Debate Team": {
"description": "Engage in competitive debate and public speaking",
"schedule": "Mondays and Fridays, 3:30 PM - 4:30 PM",
"max_participants": 14,
"participants": ["mia@mergington.edu", "ethan@mergington.edu"]
},
"Science Club": {
"description": "Conduct experiments and explore advanced scientific concepts",
"schedule": "Tuesdays, 3:30 PM - 5:00 PM",
"max_participants": 20,
"participants": ["charlotte@mergington.edu"]
}
}

Expand All @@ -61,7 +97,26 @@ 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}"}
Comment on lines +100 to 105


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

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

activity["participants"].remove(email)
return {"message": f"Unregistered {email} from {activity_name}"}
91 changes: 89 additions & 2 deletions src/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,71 @@ document.addEventListener("DOMContentLoaded", () => {
const signupForm = document.getElementById("signup-form");
const messageDiv = document.getElementById("message");

function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

// 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
activitiesList.innerHTML = "";
activitySelect.innerHTML = '<option value="">-- Select an activity --</option>';

// 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;
const participants = Array.isArray(details.participants) ? details.participants : [];
const maxParticipants = details.max_participants || 0;
const spotsLeft = maxParticipants - participants.length;
const encodedActivity = encodeURIComponent(name);
Comment on lines +31 to +34
const participantItems = participants.length
? participants
.map((participant) => {
const encodedParticipant = encodeURIComponent(participant);
const safeParticipant = escapeHtml(participant);

return `
<li class="participant-item">
<span class="participant-email">${safeParticipant}</span>
<button
type="button"
class="delete-participant-btn"
data-activity="${encodedActivity}"
data-email="${encodedParticipant}"
aria-label="Unregister ${safeParticipant} from ${escapeHtml(name)}"
title="Unregister participant"
>
<span aria-hidden="true">&#128465;</span>
</button>
</li>
`;
})
.join("")
: '<li class="empty-participant">No students signed up yet</li>';

activityCard.innerHTML = `
<h4>${name}</h4>
<p>${details.description}</p>
<p><strong>Schedule:</strong> ${details.schedule}</p>
<p><strong>Availability:</strong> ${spotsLeft} spots left</p>
Comment on lines 60 to 64
<div class="participants-section">
<p class="participants-title">Participants</p>
<p class="participants-meta">${participants.length}/${maxParticipants} students enrolled</p>
<ul class="participants-list">
${participantItems}
</ul>
</div>
`;

activitiesList.appendChild(activityCard);
Expand Down Expand Up @@ -62,6 +106,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 +126,48 @@ document.addEventListener("DOMContentLoaded", () => {
}
});

activitiesList.addEventListener("click", async (event) => {
const button = event.target.closest(".delete-participant-btn");
if (!button) {
return;
}

const activity = button.dataset.activity;
const email = button.dataset.email;

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

try {
const response = await fetch(`/activities/${activity}/participants/${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 || "Failed to unregister participant";
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);
}
});

// Initialize app
fetchActivities();
});
4 changes: 2 additions & 2 deletions src/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mergington High School Activities</title>
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="styles.css?v=participants2" />
</head>
<body>
<header>
Expand Down Expand Up @@ -45,6 +45,6 @@ <h3>Sign Up for an Activity</h3>
<p>&copy; 2023 Mergington High School</p>
</footer>

<script src="app.js"></script>
<script src="app.js?v=participants2"></script>
</body>
</html>
89 changes: 89 additions & 0 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ section h3 {
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
border-left: 4px solid #3949ab;
box-shadow: 0 4px 12px rgba(26, 35, 126, 0.08);
}

.activity-card h4 {
Expand All @@ -74,6 +76,93 @@ section h3 {
margin-bottom: 8px;
}

.participants-section {
margin-top: 12px;
padding: 12px;
border-radius: 8px;
border: 1px solid #c5cae9;
background: linear-gradient(180deg, #eef1ff 0%, #f7f8ff 100%);
border-left: 5px solid #1a237e;
}

.participants-title {
margin-bottom: 4px;
color: #1a237e;
font-size: 0.98rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}

.participants-meta {
margin-bottom: 8px;
color: #3a4673;
font-size: 0.9rem;
}

.participants-list {
margin: 0;
padding-left: 0;
list-style: none;
display: grid;
gap: 8px;
}

.participant-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
background-color: #ffffff;
border: 1px solid #d7dcf5;
}

.participant-email {
color: #2f3b63;
line-height: 1.4;
word-break: break-word;
font-size: 0.95rem;
}

.delete-participant-btn {
border: 1px solid #f3b5b5;
background-color: #fff3f3;
color: #b42323;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
padding: 0;
transition: background-color 0.2s, transform 0.15s;
}

.delete-participant-btn:hover {
background-color: #ffe4e4;
transform: scale(1.05);
}

.delete-participant-btn:focus-visible {
outline: 2px solid #b42323;
outline-offset: 2px;
}

.empty-participant {
list-style: none;
margin-left: -1.2rem;
padding: 6px 8px;
border: 1px dashed #9fa8da;
border-radius: 6px;
background-color: #ffffff;
color: #5f6b8b;
font-style: italic;
}

.form-group {
margin-bottom: 15px;
}
Expand Down
Empty file added tests/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import copy

import pytest
from fastapi.testclient import TestClient

from src import app as app_module

INITIAL_ACTIVITIES = copy.deepcopy(app_module.activities)


@pytest.fixture(autouse=True)
def reset_activities():
"""Reset in-memory state so tests remain isolated and deterministic."""
app_module.activities.clear()
app_module.activities.update(copy.deepcopy(INITIAL_ACTIVITIES))
yield
app_module.activities.clear()
app_module.activities.update(copy.deepcopy(INITIAL_ACTIVITIES))


@pytest.fixture
def client():
with TestClient(app_module.app) as test_client:
yield test_client
Loading
Loading