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
httpx
pytest-asyncio
64 changes: 62 additions & 2 deletions src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,52 @@
"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 practices and matches",
"schedule": "Tuesdays and Thursdays, 4:00 PM - 6:00 PM",
"max_participants": 22,
"participants": ["alex@mergington.edu", "maria@mergington.edu"]
},
"Basketball Club": {
"description": "Pickup games and skill development for basketball players",
"schedule": "Wednesdays and Fridays, 5:00 PM - 7:00 PM",
"max_participants": 15,
"participants": ["noah@mergington.edu", "lucas@mergington.edu"]
},
# Artistic activities
"Art Club": {
"description": "Explore drawing, painting, and mixed media projects",
"schedule": "Mondays, 3:30 PM - 5:00 PM",
"max_participants": 18,
"participants": ["isabella@mergington.edu", "mia@mergington.edu"]
},
"Drama Club": {
"description": "Acting, stagecraft, and school play productions",
"schedule": "Thursdays, 4:00 PM - 6:00 PM",
"max_participants": 25,
"participants": ["ethan@mergington.edu", "ava@mergington.edu"]
},
# Intellectual activities
"Debate Team": {
"description": "Prepare for local and regional debate competitions",
"schedule": "Mondays and Wednesdays, 4:00 PM - 5:30 PM",
"max_participants": 16,
"participants": ["liam@mergington.edu", "charlotte@mergington.edu"]
},
"Math Olympiad": {
"description": "Advanced problem solving and preparation for math contests",
"schedule": "Tuesdays, 3:30 PM - 5:00 PM",
"max_participants": 20,
"participants": ["oliver@mergington.edu", "amelia@mergington.edu"]
}
}


@app.get("/")
def root():
return RedirectResponse(url="/static/index.html")
async def root():
return RedirectResponse("/static/index.html")


@app.get("/activities")
Expand All @@ -63,5 +102,26 @@ def signup_for_activity(activity_name: str, email: str):
activity = activities[activity_name]

# Add student
# 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")
activity["participants"].append(email)
return {"message": f"Signed up {email} for {activity_name}"}


@app.post("/activities/{activity_name}/unregister")
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")

# Get the specific activity
activity = activities[activity_name]

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

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

// helper to avoid injecting raw HTML from participant data
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

// Function to fetch activities from API
async function fetchActivities() {
try {
Expand All @@ -20,11 +30,29 @@ document.addEventListener("DOMContentLoaded", () => {

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

// Build participants HTML
const participants = Array.isArray(details.participants) ? details.participants : [];
let participantsHtml = "";
if (participants.length > 0) {
participantsHtml = `<div class="participants-section">
<p><strong>Participants:</strong></p>
<ul class="participants-list">
${participants.map(p => `<li class="participant-item">
${escapeHtml(p)}
<span class="delete-participant" data-activity="${escapeHtml(name)}" data-email="${escapeHtml(p)}">&times;</span>
</li>`).join("")}
</ul>
</div>`;
} else {
participantsHtml = `<div class="participants-section"><p class="info">No participants yet</p></div>`;
}

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

activitiesList.appendChild(activityCard);
Expand Down Expand Up @@ -81,6 +109,47 @@ document.addEventListener("DOMContentLoaded", () => {
}
});

// Handle unregistering participants
document.addEventListener('click', async (event) => {
if (event.target.classList.contains('delete-participant')) {
const activity = event.target.dataset.activity;
const email = event.target.dataset.email;

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

const result = await response.json();

if (response.ok) {
messageDiv.textContent = result.message;
messageDiv.className = "success";
// Refresh activities to show updated participants
fetchActivities();
} else {
messageDiv.textContent = result.detail || "An error occurred";
messageDiv.className = "error";
}

messageDiv.classList.remove("hidden");

// Hide message after 5 seconds
setTimeout(() => {
messageDiv.classList.add("hidden");
}, 5000);
} catch (error) {
messageDiv.textContent = "Failed to unregister. Please try again.";
messageDiv.className = "error";
messageDiv.classList.remove("hidden");
console.error("Error unregistering:", error);
}
}
});

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

/* Participants section styling */
.participants-section {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #eee;
}

.participants-section p {
margin: 0;
font-weight: 600;
color: #1a237e;
font-size: 14px;
}

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

.participants-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
color: #444;
font-size: 14px;
}

.delete-participant {
cursor: pointer;
color: #666;
padding: 4px 8px;
border-radius: 4px;
font-size: 16px;
transition: all 0.2s ease;
}

.delete-participant:hover {
color: #d32f2f;
background-color: rgba(211, 47, 47, 0.1);
}

/* make the "no participants" message subtle */
.activity-card .info {
margin-top: 8px;
padding: 6px;
font-size: 13px;
}

.form-group {
margin-bottom: 15px;
}
Expand Down
16 changes: 16 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import pytest
from fastapi.testclient import TestClient
from src.app import app

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

@pytest.fixture
def test_activity():
return {
"description": "Test activity description",
"schedule": "Test schedule",
"max_participants": 10,
"participants": []
}
78 changes: 78 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import pytest
from fastapi.testclient import TestClient
from src.app import app, activities

def test_root_redirect(client):
"""Test that root endpoint redirects to static/index.html"""
response = client.get("/", follow_redirects=True)
assert response.status_code == 200 # Successful GET of index.html
assert "text/html" in response.headers["content-type"]

def test_get_activities(client):
"""Test getting all activities"""
response = client.get("/activities")
assert response.status_code == 200
assert isinstance(response.json(), dict)
# Check if we have our sample activities
assert "Chess Club" in response.json()
assert "Programming Class" in response.json()

def test_signup_success(client):
"""Test successful activity signup"""
activity_name = "Chess Club"
test_email = "test@mergington.edu"

# Make sure test email is not already registered
if test_email in activities[activity_name]["participants"]:
activities[activity_name]["participants"].remove(test_email)

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

def test_signup_duplicate(client):
"""Test signing up an already registered student"""
activity_name = "Chess Club"
test_email = "duplicate@mergington.edu"

# Register the test email first
if test_email not in activities[activity_name]["participants"]:
activities[activity_name]["participants"].append(test_email)

response = client.post(f"/activities/{activity_name}/signup?email={test_email}")
assert response.status_code == 400
assert "already signed up" in response.json()["detail"].lower()

def test_signup_nonexistent_activity(client):
"""Test signing up for a non-existent activity"""
response = client.post("/activities/NonexistentClub/signup?email=test@mergington.edu")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()

def test_unregister_success(client):
"""Test successful unregistration from activity"""
activity_name = "Chess Club"
test_email = "unregister@mergington.edu"

# Register the test email first
if test_email not in activities[activity_name]["participants"]:
activities[activity_name]["participants"].append(test_email)

response = client.post(f"/activities/{activity_name}/unregister?email={test_email}")
assert response.status_code == 200
assert response.json()["message"] == f"Unregistered {test_email} from {activity_name}"
assert test_email not in activities[activity_name]["participants"]

def test_unregister_not_registered(client):
"""Test unregistering a student who isn't registered"""
activity_name = "Chess Club"
test_email = "notregistered@mergington.edu"

# Make sure test email is not registered
if test_email in activities[activity_name]["participants"]:
activities[activity_name]["participants"].remove(test_email)

response = client.post(f"/activities/{activity_name}/unregister?email={test_email}")
assert response.status_code == 400
assert "not registered" in response.json()["detail"].lower()