Add multi-league foundation

This commit is contained in:
Christian Krakau-Louis
2026-05-22 20:47:28 +02:00
parent e032994628
commit 15e1dc227b
23 changed files with 775 additions and 205 deletions
+48
View File
@@ -77,6 +77,11 @@ def migrate_schema():
# Check Team table
if 'teams' in tables:
columns = [col['name'] for col in inspector.get_columns('teams')]
if 'league_id' not in columns:
print("Adding league_id column to teams table")
connection.execute(text(
"ALTER TABLE teams ADD COLUMN league_id INT NULL"
))
if 'is_public' not in columns:
print("Adding is_public column to teams table")
connection.execute(text(
@@ -109,6 +114,11 @@ def migrate_schema():
# Check QRCode table (formerly QRTicket)
if 'qr_codes' in tables:
columns = [col['name'] for col in inspector.get_columns('qr_codes')]
if 'league_id' not in columns:
print("Adding league_id column to qr_codes table")
connection.execute(text(
"ALTER TABLE qr_codes ADD COLUMN league_id INT NULL"
))
if 'created_at' not in columns:
print("Adding created_at column to qr_codes table")
connection.execute(text(
@@ -122,6 +132,22 @@ def migrate_schema():
else:
print("QRCodes table doesn't exist yet, skipping QRCode table migrations")
if 'events' in tables:
columns = [col['name'] for col in inspector.get_columns('events')]
if 'league_id' not in columns:
print("Adding league_id column to events table")
connection.execute(text(
"ALTER TABLE events ADD COLUMN league_id INT NULL"
))
if 'qr_sets' in tables:
columns = [col['name'] for col in inspector.get_columns('qr_sets')]
if 'league_id' not in columns:
print("Adding league_id column to qr_sets table")
connection.execute(text(
"ALTER TABLE qr_sets ADD COLUMN league_id INT NULL"
))
# Handle legacy QRTicket table migration if it exists
if 'qr_tickets' in tables and 'qr_codes' in tables:
print("Migrating data from legacy qr_tickets table to qr_codes table")
@@ -178,6 +204,26 @@ def migrate_schema():
"""))
connection.commit()
if 'leagues' not in tables:
print("Creating leagues table")
connection.execute(text("""
CREATE TABLE leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(120) UNIQUE NOT NULL,
description TEXT,
publisher_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
connection.execute(text("""
INSERT INTO leagues (name, slug, description, publisher_name, is_active)
VALUES ('Default League', 'default', 'Default league for existing LeagueLedger data.', 'LeagueLedger', TRUE)
"""))
connection.commit()
# Create team_members table if it doesn't exist and teams table exists
if 'team_members' not in tables and 'teams' in tables:
print("Creating team_members table")
@@ -216,6 +262,7 @@ def migrate_schema():
connection.execute(text("""
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
location VARCHAR(200),
@@ -262,6 +309,7 @@ def migrate_schema():
connection.execute(text("""
CREATE TABLE qr_sets (
id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+28 -2
View File
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from passlib.context import CryptContext
import asyncio
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
from .models import User, League, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
from .db import SessionLocal, engine
from .db_migrations import run_migrations
@@ -151,6 +151,16 @@ def seed_db():
db.add_all(users)
db.commit()
default_league = League(
name="Default League",
slug="default",
description="Default league for LeagueLedger demo data.",
publisher_name="LeagueLedger",
is_active=True
)
db.add(default_league)
db.commit()
# Create teams
has_is_public = table_has_column(db.bind, 'teams', 'is_public')
has_created_at = table_has_column(db.bind, 'teams', 'created_at')
@@ -158,7 +168,7 @@ def seed_db():
teams = []
for i, name in enumerate(["Quiz Wizards", "Trivia Titans", "Beer Brainiacs", "Knowledge Knights"]):
team_attrs = {"name": name}
team_attrs = {"name": name, "league_id": default_league.id}
if has_is_public:
team_attrs["is_public"] = i % 2 == 1 # Alternate public/private
if has_description:
@@ -203,21 +213,27 @@ def seed_db():
# Create events for QR code linking
events = [
Event(name="Music Trivia Night", description="A night of musical quizzes",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=60),
location="Irish Rover Pub"),
Event(name="History Night", description="Test your history knowledge",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=45),
location="Irish Rover Pub"),
Event(name="Movie Trivia Night", description="All about cinema",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=30),
location="Irish Rover Pub"),
Event(name="Sports Quiz", description="For sports enthusiasts",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=15),
location="Irish Rover Pub"),
Event(name="General Knowledge", description="A bit of everything",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=7),
location="Irish Rover Pub"),
Event(name="Irish Rover Pub Quiz April 2025", description="Monthly pub quiz",
league_id=default_league.id,
event_date=datetime.now(),
location="Irish Rover Pub")
]
@@ -227,11 +243,13 @@ def seed_db():
# Create QR sets
qr_sets = [
QRSet(
league_id=default_league.id,
name="Standard Pub Quiz",
description="Contains QR codes for 1st place (25 points), 2nd place (15 points), 3rd place (10 points), and 4th place (5 points)",
created_by=1 # Admin user
),
QRSet(
league_id=default_league.id,
name="Trivia Night with Achievements",
description="Special trivia night with QR codes for winners and achievement codes for trivia categories",
created_by=2 # John the quizmaster
@@ -248,6 +266,7 @@ def seed_db():
qr_set_1 = qr_sets[0]
qr_codes.extend([
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=25,
title="1st Place",
@@ -257,6 +276,7 @@ def seed_db():
used=False
),
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=15,
title="2nd Place",
@@ -266,6 +286,7 @@ def seed_db():
used=False
),
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=10,
title="3rd Place",
@@ -275,6 +296,7 @@ def seed_db():
used=False
),
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=5,
title="4th Place",
@@ -289,6 +311,7 @@ def seed_db():
qr_set_2 = qr_sets[1]
qr_codes.extend([
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=20,
title="Trivia Champion",
@@ -298,6 +321,7 @@ def seed_db():
used=False
),
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=0,
title="Estimate Winner",
@@ -308,6 +332,7 @@ def seed_db():
used=False
),
QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()),
points=0,
title="Film Buff",
@@ -334,6 +359,7 @@ def seed_db():
qr_codes.append(
QRCode(
league_id=default_league.id,
code=f"TICKET{i:03d}",
points=points,
title=f"{points} Points Ticket",
+153
View File
@@ -104,6 +104,9 @@ def run_migrations(engine):
print("Running migrations...")
# Check if the columns already exist before adding them
# Create league structures and attach existing records to a default league
add_league_support(connection)
# Add additional_oauth_providers column if it doesn't exist
add_oauth_providers_column(connection)
@@ -151,6 +154,156 @@ def add_oauth_providers_column(connection):
except Exception as e:
print(f"Error adding additional_oauth_providers column: {str(e)}")
def add_league_support(connection):
"""Add leagues and backfill existing single-league data."""
try:
inspector = inspect(engine)
tables = inspector.get_table_names()
if 'leagues' not in tables:
print("Creating leagues table")
if engine.name == 'sqlite':
connection.execute(text("""
CREATE TABLE leagues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(120) UNIQUE NOT NULL,
description TEXT,
publisher_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
else:
connection.execute(text("""
CREATE TABLE leagues (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(120) UNIQUE NOT NULL,
description TEXT,
publisher_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
connection.commit()
league_id = ensure_default_league(connection)
for table_name in ('teams', 'qr_sets', 'qr_codes', 'events'):
add_nullable_league_id(connection, table_name)
backfill_league_ids(connection, league_id)
drop_global_team_name_unique(connection)
except Exception as e:
print(f"Error adding league support: {str(e)}")
def ensure_default_league(connection):
"""Return the default league id, creating it if needed."""
row = connection.execute(
text("SELECT id FROM leagues WHERE slug = :slug"),
{"slug": "default"}
).first()
if row:
return row[0]
connection.execute(
text("""
INSERT INTO leagues (name, slug, description, publisher_name, is_active)
VALUES (:name, :slug, :description, :publisher_name, :is_active)
"""),
{
"name": "Default League",
"slug": "default",
"description": "Default league for existing LeagueLedger data.",
"publisher_name": "LeagueLedger",
"is_active": True,
}
)
connection.commit()
return connection.execute(
text("SELECT id FROM leagues WHERE slug = :slug"),
{"slug": "default"}
).scalar()
def add_nullable_league_id(connection, table_name):
"""Add a nullable league_id column to an existing table."""
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
return
columns = [col['name'] for col in inspector.get_columns(table_name)]
if 'league_id' in columns:
print(f"Column league_id already exists in {table_name}")
return
print(f"Adding league_id column to {table_name}")
if engine.name == 'sqlite':
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN league_id INTEGER NULL"))
else:
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN league_id INT NULL"))
connection.commit()
def backfill_league_ids(connection, default_league_id):
"""Attach legacy data to the default league."""
inspector = inspect(engine)
tables = inspector.get_table_names()
for table_name in ('teams', 'qr_sets', 'events'):
if table_name in tables and 'league_id' in [col['name'] for col in inspector.get_columns(table_name)]:
connection.execute(
text(f"UPDATE {table_name} SET league_id = :league_id WHERE league_id IS NULL"),
{"league_id": default_league_id}
)
if 'qr_codes' in tables and 'league_id' in [col['name'] for col in inspector.get_columns('qr_codes')]:
connection.execute(text("""
UPDATE qr_codes
SET league_id = (
SELECT qr_sets.league_id
FROM qr_sets
WHERE qr_sets.id = qr_codes.qr_set_id
)
WHERE league_id IS NULL
AND qr_set_id IS NOT NULL
"""))
connection.execute(text("""
UPDATE qr_codes
SET league_id = (
SELECT events.league_id
FROM events
WHERE events.id = qr_codes.event_id
)
WHERE league_id IS NULL
AND event_id IS NOT NULL
"""))
connection.execute(
text("UPDATE qr_codes SET league_id = :league_id WHERE league_id IS NULL"),
{"league_id": default_league_id}
)
connection.commit()
def drop_global_team_name_unique(connection):
"""Best-effort removal of the legacy global team-name uniqueness constraint."""
if engine.name == 'sqlite':
return
inspector = inspect(engine)
if 'teams' not in inspector.get_table_names():
return
for constraint in inspector.get_unique_constraints('teams'):
if constraint.get('column_names') == ['name']:
constraint_name = constraint.get('name')
if constraint_name:
print(f"Dropping global teams.name unique constraint {constraint_name}")
connection.execute(text(f"ALTER TABLE teams DROP INDEX {constraint_name}"))
connection.commit()
break
def add_name_columns(connection):
"""Add first_name and last_name columns to users table"""
try:
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
import re
from typing import Optional
from sqlalchemy.orm import Session
from .models import League, QRCode
DEFAULT_LEAGUE_NAME = "Default League"
DEFAULT_LEAGUE_SLUG = "default"
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or DEFAULT_LEAGUE_SLUG
def get_default_league(db: Session) -> League:
league = db.query(League).filter(League.slug == DEFAULT_LEAGUE_SLUG).first()
if league:
return league
league = db.query(League).order_by(League.id).first()
if league:
return league
league = League(
name=DEFAULT_LEAGUE_NAME,
slug=DEFAULT_LEAGUE_SLUG,
description="Default league for existing LeagueLedger data.",
publisher_name="LeagueLedger",
is_active=True,
)
db.add(league)
db.commit()
db.refresh(league)
return league
def get_active_leagues(db: Session):
leagues = db.query(League).filter(League.is_active == True).order_by(League.name).all()
if leagues:
return leagues
return [get_default_league(db)]
def parse_league_id(value) -> Optional[int]:
try:
return int(value) if value else None
except (TypeError, ValueError):
return None
def resolve_selected_league(db: Session, league_id: Optional[int]) -> League:
if league_id:
league = db.query(League).filter(League.id == league_id, League.is_active == True).first()
if league:
return league
return get_default_league(db)
def qr_code_league_id(qr_code: QRCode) -> Optional[int]:
if qr_code.league_id:
return qr_code.league_id
if qr_code.qr_set and qr_code.qr_set.league_id:
return qr_code.qr_set.league_id
if qr_code.event and qr_code.event.league_id:
return qr_code.event.league_id
return None
+31 -1
View File
@@ -114,10 +114,31 @@ class OAuthAccount(Base):
user = relationship("User")
class League(Base):
__tablename__ = "leagues"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
slug = Column(String(120), unique=True, nullable=False, index=True)
description = Column(Text, nullable=True)
publisher_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
teams = relationship("Team", back_populates="league")
qr_sets = relationship("QRSet", back_populates="league")
qr_codes = relationship("QRCode", back_populates="league")
events = relationship("Event", back_populates="league")
def __repr__(self):
return f"<League {self.name}>"
class Team(Base):
__tablename__ = "teams"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
league_id = Column(Integer, ForeignKey("leagues.id"), nullable=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
logo_url = Column(String(255), nullable=True) # Add logo URL field
is_public = Column(Boolean, default=False) # For team privacy setting
@@ -128,9 +149,12 @@ class Team(Base):
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
league = relationship("League", back_populates="teams")
members = relationship("TeamMembership", back_populates="team", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_teams")
__table_args__ = (UniqueConstraint('league_id', 'name', name='_league_team_name_uc'),)
class TeamJoinRequest(Base):
__tablename__ = "team_join_requests"
@@ -169,12 +193,14 @@ class QRSet(Base):
"""A set of related QR codes, such as codes for different placements in a quiz"""
__tablename__ = "qr_sets"
id = Column(Integer, primary_key=True, index=True)
league_id = Column(Integer, ForeignKey("leagues.id"), nullable=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
league = relationship("League", back_populates="qr_sets")
qr_codes = relationship("QRCode", back_populates="qr_set")
creator = relationship("User")
@@ -183,12 +209,14 @@ class QRCode(Base):
"""Unified QR code model that includes all the functionality of the old QRTicket and QRCode models"""
__tablename__ = "qr_codes"
id = Column(Integer, primary_key=True, index=True)
league_id = Column(Integer, ForeignKey("leagues.id"), nullable=True, index=True)
code = Column(String(128), unique=True, index=True, nullable=False) # Unique token
points = Column(Float, default=0, nullable=False)
title = Column(String(100), nullable=True) # e.g., "1st Place", "2nd Place"
description = Column(String(255), nullable=True)
# Set relationship
league = relationship("League", back_populates="qr_codes")
qr_set_id = Column(Integer, ForeignKey("qr_sets.id"), nullable=True)
qr_set = relationship("QRSet", back_populates="qr_codes")
@@ -236,6 +264,7 @@ class TeamAchievement(Base):
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
league_id = Column(Integer, ForeignKey("leagues.id"), nullable=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
location = Column(String(200), nullable=True)
@@ -244,6 +273,7 @@ class Event(Base):
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
league = relationship("League", back_populates="events")
attendees = relationship("EventAttendee", back_populates="event")
+13 -2
View File
@@ -4,11 +4,22 @@
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl md:text-3xl font-garamond font-bold text-irish-green">League Leaderboard</h1>
<p class="text-gray-600">See how your team ranks against the competition</p>
<p class="text-gray-600">
See how your team ranks in {{ selected_league.name if selected_league else "your league" }}
</p>
</div>
<div class="mt-4 md:mt-0">
<form method="get" action="/leaderboard/">
<form method="get" action="/leaderboard/" class="flex flex-col sm:flex-row gap-3">
<select name="league_id"
onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green bg-white">
{% for league in leagues %}
<option value="{{ league.id }}" {% if selected_league and selected_league.id == league.id %}selected{% endif %}>
{{ league.name }}
</option>
{% endfor %}
</select>
<select name="timeframe"
onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green bg-white">
+19 -2
View File
@@ -6,6 +6,20 @@
<p class="text-gray-600">Create and manage QR code sets for your pub quiz events</p>
</div>
<form method="get" action="/qr/" class="mb-6 flex flex-col sm:flex-row sm:items-end gap-3">
<div>
<label for="league_filter" class="block text-sm font-medium text-gray-700">League</label>
<select id="league_filter" name="league_id" onchange="this.form.submit()"
class="mt-1 border border-gray-300 rounded-md px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-irish-green">
{% for league in leagues %}
<option value="{{ league.id }}" {% if selected_league and selected_league.id == league.id %}selected{% endif %}>
{{ league.name }}
</option>
{% endfor %}
</select>
</div>
</form>
<div class="grid md:grid-cols-3 gap-8">
<!-- QR Set Creation -->
<div class="bg-white rounded-lg shadow-md p-6">
@@ -15,6 +29,7 @@
</p>
<form id="qrSetForm" action="/qr/sets" method="post" class="space-y-4">
<input type="hidden" name="league_id" value="{{ selected_league.id }}">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Set Name</label>
<input type="text" name="name" id="name" required
@@ -140,7 +155,8 @@
<div class="px-6 py-4">
<div class="text-sm text-gray-500">
<p>Created: {{ qr_set.created_at.strftime('%Y-%m-%d') }}</p>
<p>QR Codes: {% if qr_set.qr_codes %}{{ qr_set.qr_codes | length }}{% else %}0{% endif %}</p>
<p>League: {{ qr_set.league.name if qr_set.league else "Default League" }}</p>
<p>QR Codes: {% if qr_set.qr_codes %}{{ qr_set.qr_codes | length }}{% else %}0{% endif %}</p>
</div>
<div class="mt-4 flex justify-between">
<a href="/qr/sets/{{ qr_set.id }}" class="text-irish-green hover:underline">
@@ -199,9 +215,10 @@
quickQrForm.addEventListener('submit', function(e) {
e.preventDefault();
const points = document.getElementById('qr-points').value;
const leagueId = '{{ selected_league.id }}';
// Generate QR code
qrImage.innerHTML = '<img src="/qr/generate/' + points + '" alt="QR Code" class="mx-auto" />';
qrImage.innerHTML = '<img src="/qr/generate/' + points + '?league_id=' + leagueId + '" alt="QR Code" class="mx-auto" />';
qrResult.classList.remove('hidden');
});
});
+4 -2
View File
@@ -3,7 +3,7 @@
<div class="max-w-6xl mx-auto p-4">
<div class="mb-8">
<div class="flex items-center">
<a href="/qr" class="text-irish-green hover:underline mr-4">
<a href="/qr{% if qr_set.league_id %}/?league_id={{ qr_set.league_id }}{% endif %}" class="text-irish-green hover:underline mr-4">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
<h1 class="text-3xl font-bold text-irish-green">{{ qr_set.name }}</h1>
@@ -11,7 +11,9 @@
{% if qr_set.description %}
<p class="text-gray-600 mt-2">{{ qr_set.description }}</p>
{% endif %}
<p class="text-sm text-gray-500 mt-1">Created on {{ qr_set.created_at.strftime('%Y-%m-%d') }}</p>
<p class="text-sm text-gray-500 mt-1">
{{ qr_set.league.name if qr_set.league else "Default League" }} • Created on {{ qr_set.created_at.strftime('%Y-%m-%d') }}
</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
+1
View File
@@ -7,6 +7,7 @@
<div class="flex flex-col md:flex-row justify-between items-center">
<div class="mb-4 md:mb-0">
<h1 class="text-2xl md:text-3xl font-bold text-white">{{ team.name }}</h1>
<p class="text-green-100 mb-1">{{ team.league.name if team.league else "Default League" }}</p>
<div class="flex items-center space-x-2 text-green-100">
<span><i class="fas fa-trophy mr-1"></i> Rank #{{ team_rank }}</span>
<span class="hidden md:inline"></span>
+20 -1
View File
@@ -1,7 +1,25 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-6">Teams</h2>
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-bold">Teams</h2>
{% if selected_league %}
<p class="text-gray-600">{{ selected_league.name }}</p>
{% endif %}
</div>
<form method="get" action="/teams/">
<label for="league_id" class="block text-sm font-medium text-gray-700">League</label>
<select id="league_id" name="league_id" onchange="this.form.submit()"
class="mt-1 border border-gray-300 rounded-md px-3 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-irish-green">
{% for league in leagues %}
<option value="{{ league.id }}" {% if selected_league and selected_league.id == league.id %}selected{% endif %}>
{{ league.name }}
</option>
{% endfor %}
</select>
</form>
</div>
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6" role="alert">
@@ -48,6 +66,7 @@
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Create New Team</h3>
{% if user %}
<form action="/teams/create" method="post" class="mt-4">
<input type="hidden" name="league_id" value="{{ selected_league.id }}">
<div class="mb-4">
<label for="name" class="block text-sm font-medium mb-1">Team Name</label>
<input type="text" name="name" id="name" placeholder="Enter team name" required
+2 -1
View File
@@ -17,7 +17,7 @@ from dateutil.relativedelta import relativedelta
from ..db import SessionLocal, Base
from ..models import (
User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event,
User, League, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event,
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
)
from ..templates_config import templates
@@ -28,6 +28,7 @@ router = APIRouter()
# Dictionary of model classes with their display names
MODELS = {
'user': (User, "Users"),
'league': (League, "Leagues"),
'team': (Team, "Teams"),
'team_membership': (TeamMembership, "Team Memberships"),
'qr_code': (QRCode, "QR Codes"),
+14 -7
View File
@@ -96,21 +96,27 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
user_teams_enhanced = []
if user_teams:
# Get all team points to calculate ranks
# Get all team points to calculate ranks within each league
team_points_query = db.query(
models.Team.id,
models.Team.league_id,
func.sum(models.QRCode.points).label('total_points')
).outerjoin(
models.QRCode,
models.QRCode.redeemed_at_team == models.Team.id
).group_by(models.Team.id)
).group_by(models.Team.id, models.Team.league_id)
# Get results and sort by points in descending order
team_points = {row.id: row.total_points or 0 for row in team_points_query.all()}
ranked_teams = sorted(team_points.items(), key=lambda x: x[1], reverse=True)
team_points = {}
points_by_league = {}
for row in team_points_query.all():
points = row.total_points or 0
team_points[row.id] = points
points_by_league.setdefault(row.league_id, []).append((row.id, points))
# Create a dictionary mapping team ID to rank
team_ranks = {team_id: i+1 for i, (team_id, _) in enumerate(ranked_teams)}
team_ranks = {}
for teams_in_league in points_by_league.values():
ranked_teams = sorted(teams_in_league, key=lambda x: x[1], reverse=True)
team_ranks.update({team_id: i + 1 for i, (team_id, _) in enumerate(ranked_teams)})
# Enhance user teams with rank and points data
for team in user_teams:
@@ -120,6 +126,7 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
user_teams_enhanced.append({
'id': team.id,
'name': team.name,
'league_name': team.league.name if team.league else "Default League",
'points': points,
'rank': rank,
'description': getattr(team, 'description', None)
+9 -2
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
from ..db import SessionLocal
from ..models import Team, TeamMembership, QRCode, User
from ..league_context import get_active_leagues, resolve_selected_league
from ..templates_config import templates
router = APIRouter()
@@ -25,6 +26,7 @@ def get_db():
async def show_leaderboard(
request: Request,
timeframe: str = Query("all", regex="^(week|month|all)$"),
league_id: int = Query(None),
db: Session = Depends(get_db)
):
"""Show the leaderboard with team rankings."""
@@ -35,6 +37,9 @@ async def show_leaderboard(
if user_id:
user = db.query(User).get(user_id)
selected_league = resolve_selected_league(db, league_id)
leagues = get_active_leagues(db)
# Define cutoff date based on timeframe
cutoff_date = None
if timeframe == "week":
@@ -56,7 +61,7 @@ async def show_leaderboard(
QRCode,
QRCode.redeemed_at_team == Team.id,
isouter=True
)
).filter(Team.league_id == selected_league.id)
# Apply time filter if needed
if cutoff_date:
@@ -64,7 +69,7 @@ async def show_leaderboard(
query = query.filter(QRCode.redeemed_at >= cutoff_date)
# Group and order
teams_ranking = query.group_by(Team.id).order_by(desc('total_points')).all()
teams_ranking = query.group_by(Team.id, Team.name).order_by(desc('total_points')).all()
# Add ranks
ranked_teams = []
@@ -88,6 +93,8 @@ async def show_leaderboard(
"top_teams": top_teams,
"timeframe": timeframe,
"time_label": time_label,
"leagues": leagues,
"selected_league": selected_league,
"user": user # Add user to the context
}
)
+19 -8
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel
from ..db import SessionLocal
from ..models import QRCode, QRSet, Event, User
from ..league_context import get_active_leagues, parse_league_id, resolve_selected_league
from ..templates_config import templates
router = APIRouter()
@@ -61,16 +62,21 @@ async def qr_dashboard(request: Request, db: Session = Depends(get_db)):
if user_id:
user = db.query(User).get(user_id)
# Get all QR sets
qr_sets = db.query(QRSet).all()
league_id = request.query_params.get("league_id")
selected_league = resolve_selected_league(db, parse_league_id(league_id))
leagues = get_active_leagues(db)
# Get all events for linking
events = db.query(Event).all()
# Get QR sets and events for the selected league
qr_sets = db.query(QRSet).filter(QRSet.league_id == selected_league.id).all()
events = db.query(Event).filter(Event.league_id == selected_league.id).all()
return templates.TemplateResponse("qr/dashboard.html", {
"request": request,
"qr_sets": qr_sets,
"events": events,
"leagues": leagues,
"selected_league": selected_league,
"user": user # Add user to the context
})
@@ -85,12 +91,14 @@ async def create_qr_set(
form_data = await request.form()
name = form_data.get("name")
description = form_data.get("description", "")
league_id = form_data.get("league_id")
selected_league = resolve_selected_league(db, parse_league_id(league_id))
if not name:
raise HTTPException(status_code=400, detail="Set name is required")
# Create QR set
qr_set = QRSet(name=name, description=description)
qr_set = QRSet(name=name, description=description, league_id=selected_league.id)
db.add(qr_set)
db.commit()
db.refresh(qr_set)
@@ -148,6 +156,7 @@ async def add_qr_code_to_set(
# Create QR code
qr_code = QRCode(
league_id=qr_set.league_id,
code=code_str,
title=title,
points=points,
@@ -165,14 +174,15 @@ async def add_qr_code_to_set(
@router.get("/generate/{points}")
def generate_qr(points: int, db: Session = Depends(get_db)):
def generate_qr(points: int, league_id: Optional[int] = Query(None), db: Session = Depends(get_db)):
"""
Generate a single QR code for awarding `points` points.
Saves a record in the DB, returns the PNG as streaming response.
"""
code_str = str(uuid.uuid4())
qr_code = QRCode(code=code_str, points=points)
selected_league = resolve_selected_league(db, league_id)
qr_code = QRCode(code=code_str, points=points, league_id=selected_league.id)
db.add(qr_code)
db.commit()
db.refresh(qr_code)
@@ -239,7 +249,7 @@ async def admin_link_page(request: Request, admin_code: str, db: Session = Depen
raise HTTPException(status_code=404, detail="QR Set not found")
# Fetch available events
events = db.query(Event).all()
events = db.query(Event).filter(Event.league_id == qr_set.league_id).all()
return templates.TemplateResponse("qr/admin_link.html", {
"request": request,
@@ -273,6 +283,7 @@ async def process_admin_link(
# Create new event
event_date = datetime.now() # Default to current date, can be improved
new_event = Event(
league_id=qr_set.league_id,
name=event_name,
description=f"Created via QR admin link on {event_date.strftime('%Y-%m-%d')}",
event_date=event_date
+28 -3
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from ..db import SessionLocal
from ..models import QRCode, User, Team, TeamMembership, TeamAchievement
from ..templates_config import templates
from ..league_context import get_default_league, qr_code_league_id
router = APIRouter()
@@ -73,14 +74,21 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
}
)
# Get only teams the user is a member of, if logged in
effective_league_id = qr_code_league_id(qr_code)
if not effective_league_id:
effective_league_id = get_default_league(db).id
# Get only teams the user is a member of in the QR code's league, if logged in
user_teams = []
if user:
# Query teams where the user is a member using TeamMembership relation
user_teams = (
db.query(Team)
.join(TeamMembership, Team.id == TeamMembership.team_id)
.filter(TeamMembership.user_id == user.id)
.filter(
TeamMembership.user_id == user.id,
Team.league_id == effective_league_id
)
.all()
)
@@ -101,7 +109,7 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
{
"request": request,
"error_title": "No Teams Available",
"error_message": "You are not a member of any teams. Please join or create a team before redeeming QR codes.",
"error_message": "You are not a member of any teams in this league. Please join or create a team before redeeming this QR code.",
"user": user
}
)
@@ -221,6 +229,21 @@ async def apply_code(
}
)
effective_league_id = qr_code_league_id(qr_code)
if not effective_league_id:
effective_league_id = get_default_league(db).id
if team.league_id != effective_league_id:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Wrong League",
"error_message": "This QR code can only be redeemed by a team in its league.",
"user": user
}
)
# Verify the user is a member of the selected team
is_team_member = db.query(TeamMembership).filter_by(
user_id=user.id,
@@ -239,6 +262,8 @@ async def apply_code(
)
# Mark the QR code as redeemed
qr_code.league_id = effective_league_id
qr_code.redeemed_by = user.id
qr_code.redeemed_at_team = team.id
qr_code.redeemed_at = datetime.now()
qr_code.used = True
+15 -4
View File
@@ -7,6 +7,7 @@ from starlette.status import HTTP_303_SEE_OTHER
from fastapi.templating import Jinja2Templates
from ...models import Team, TeamMembership, User, TeamJoinRequest
from ...league_context import resolve_selected_league
from ...utils.auth import get_current_user
from ...utils.mail import send_team_join_request_notification, send_join_request_response
from ...templates_config import templates
@@ -19,6 +20,7 @@ async def create_team_post(
description: str = Form(""),
logo_url: str = Form(""),
is_open: bool = Form(False),
league_id: int = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
@@ -26,14 +28,23 @@ async def create_team_post(
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if team name already exists
existing_team = db.query(Team).filter(Team.name == name).first()
selected_league = resolve_selected_league(db, league_id)
# Check if team name already exists in this league
existing_team = db.query(Team).filter(
Team.name == name,
Team.league_id == selected_league.id
).first()
if existing_team:
return RedirectResponse("/teams/?error=Team+name+already+exists", status_code=HTTP_303_SEE_OTHER)
return RedirectResponse(
f"/teams/?league_id={selected_league.id}&error=Team+name+already+exists",
status_code=HTTP_303_SEE_OTHER
)
# Create team with only the fields that exist in the model
team_data = {
"name": name,
"league_id": selected_league.id,
"description": description,
"is_open": is_open,
"owner_id": current_user.id
@@ -60,7 +71,7 @@ async def create_team_post(
db.add(team_membership)
db.commit()
return RedirectResponse("/teams/", status_code=HTTP_303_SEE_OTHER)
return RedirectResponse(f"/teams/?league_id={selected_league.id}", status_code=HTTP_303_SEE_OTHER)
async def edit_team_post(
request: Request,
+12 -5
View File
@@ -73,13 +73,20 @@ def get_team_total_points(db: Session, team_id: int):
def calculate_team_rank(db: Session, team_id: int):
"""Calculate team rank based on points"""
try:
# First, get the aggregated points for all teams
team = db.query(Team).filter(Team.id == team_id).first()
if not team:
return 1
# First, get the aggregated points for teams in the same league
team_points = db.query(
QRCode.redeemed_at_team,
Team.id.label("team_id"),
func.sum(QRCode.points).label('total')
).outerjoin(
QRCode,
QRCode.redeemed_at_team == Team.id
).filter(
QRCode.redeemed_at_team != None
).group_by(QRCode.redeemed_at_team).all()
Team.league_id == team.league_id
).group_by(Team.id).all()
# Sort them by points (descending)
sorted_teams = sorted(team_points, key=lambda x: x.total or 0, reverse=True)
@@ -87,7 +94,7 @@ def calculate_team_rank(db: Session, team_id: int):
# Find our team's position
team_rank = 1
for idx, team_data in enumerate(sorted_teams):
if team_data.redeemed_at_team == team_id:
if team_data.team_id == team_id:
team_rank = idx + 1
break
+16 -2
View File
@@ -8,13 +8,17 @@ import random
from sqlalchemy import inspect
from ...models import Team, TeamMembership, User, QRCode, TeamJoinRequest
from ...league_context import get_active_leagues, parse_league_id, resolve_selected_league
from ...templates_config import templates
from ...utils.auth import get_current_user
from . import utils
def list_teams_view(request: Request, db: Session):
"""Render the teams list view"""
teams = db.query(Team).all()
league_id = request.query_params.get("league_id")
selected_league = resolve_selected_league(db, parse_league_id(league_id))
leagues = get_active_leagues(db)
teams = db.query(Team).filter(Team.league_id == selected_league.id).all()
# Get the user's teams to highlight teams they're already in
user_team_ids = []
@@ -25,7 +29,15 @@ def list_teams_view(request: Request, db: Session):
if user_id:
user = db.query(User).get(user_id)
# Get teams that user is a member of
memberships = db.query(TeamMembership).filter(TeamMembership.user_id == user_id).all()
memberships = (
db.query(TeamMembership)
.join(Team)
.filter(
TeamMembership.user_id == user_id,
Team.league_id == selected_league.id
)
.all()
)
user_team_ids = [membership.team_id for membership in memberships]
# Get error message if present
@@ -36,6 +48,8 @@ def list_teams_view(request: Request, db: Session):
{
"request": request,
"teams": teams,
"leagues": leagues,
"selected_league": selected_league,
"user_team_ids": user_team_ids,
"user": user,
"error": error,
+53
View File
@@ -0,0 +1,53 @@
# LeagueLedger Roadmap
This roadmap is based on a senior-developer inspection of the current FastAPI, Jinja, and SQLAlchemy codebase.
## Current Findings
- LeagueLedger currently treats teams, QR sets, events, redemptions, and leaderboards as global records. That blocks multiple pubs or organizers from sharing one installation cleanly.
- Database migrations are split between `app/db.py` and `app/db_migrations.py`. This increases drift risk because schema changes can land in one path and not the other.
- Tests are thin and some existing tests mock SQLAlchemy chains in ways that do not match the current query implementation.
- Several workflows still include compatibility branches for removed model names, which makes behavior harder to reason about.
- QR redemption did not consistently record the redeeming user on the QR code record, weakening auditability.
## Milestones
### 1. Multi-League Foundation
Status: implemented in this branch.
Acceptance criteria:
- Add a `League` model with a default league for existing data.
- Attach teams, QR sets, QR codes, and events to a league.
- Filter team lists, QR dashboards, leaderboards, and redemption team choices by league.
- Keep existing URLs and seeded data working.
- Backfill existing records into the default league during startup migrations.
### 2. League Administration
Acceptance criteria:
- Add a dedicated admin workflow for creating, editing, activating, and archiving leagues.
- Allow league managers to administer only their own league.
- Replace generic admin CRUD for league-sensitive records with validated forms where needed.
- Add tests for league creation, activation, and manager permissions.
### 3. Data Isolation and Permissions
Acceptance criteria:
- Introduce league-level roles for owners, quiz masters, and staff.
- Enforce league boundaries in every query that reads or mutates teams, events, QR sets, QR codes, achievements, and leaderboards.
- Add authorization tests covering cross-league access attempts.
### 4. Migration Cleanup
Acceptance criteria:
- Consolidate schema migrations into one approach.
- Add repeatable migration tests against a fresh database and a simulated legacy database.
- Remove stale compatibility code after migration coverage is in place.
### 5. Release Hardening
Acceptance criteria:
- Add CI checks for syntax, tests, and a lightweight app smoke test.
- Document local development with 1Password-injected environment secrets.
- Add a release checklist with migration, rollback, and verification steps.
@@ -0,0 +1,26 @@
# Senior Developer Inspection Prompt
Use this prompt to inspect LeagueLedger before planning or implementing roadmap work.
```text
You are a senior product-minded software engineer reviewing LeagueLedger, a FastAPI, Jinja, and SQLAlchemy web application for pub quiz leagues, teams, QR-code redemptions, leaderboards, and rewards.
Adopt a code-review stance first. Prioritize correctness, data isolation, migration safety, security, tests, and operational risk over cosmetic cleanup. Inspect the repository before proposing changes.
Goals:
- Identify the current domain model and the boundaries between leagues, teams, QR sets, QR codes, events, users, and admin workflows.
- Find bugs, missing constraints, hardcoded assumptions, migration risks, stale compatibility code, security issues, and test gaps.
- Propose a milestone roadmap that can move the project forward safely in small, releasable increments.
- For each milestone, define user-visible behavior, code areas touched, database changes, verification steps, and rollback considerations.
Special focus:
- Multi-publisher and multi-league support. A league should isolate teams, QR assets, events, redemptions, and leaderboards so multiple pubs or organizers can operate in the same app.
- Backward compatibility with existing single-league data.
- Avoid exposing secrets. Prefer 1Password-injected secrets for any developer environment or deployment credentials.
Output:
1. Findings ordered by severity, with file-level references.
2. Recommended architecture and domain boundaries.
3. A milestone roadmap with acceptance criteria.
4. The smallest safe first milestone to implement immediately.
```
-1
View File
@@ -7,7 +7,6 @@ httpx>=0.25.0 # HTTP client for making requests
# Database
sqlalchemy>=2.0.20
pymysql>=1.1.0
mysqlclient>=2.2.0
# Authentication and Security
authlib>=1.2.1
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import pytest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.league_context import get_default_league, parse_league_id, qr_code_league_id, resolve_selected_league
from app.models import Base, Event, League, QRCode, QRSet, Team
@pytest.fixture()
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
def test_get_default_league_creates_compatible_default(db_session):
league = get_default_league(db_session)
assert league.name == "Default League"
assert league.slug == "default"
assert league.is_active is True
def test_resolve_selected_league_uses_active_requested_league(db_session):
get_default_league(db_session)
requested = League(name="Rover Pub League", slug="rover-pub", is_active=True)
inactive = League(name="Archived Pub League", slug="archived-pub", is_active=False)
db_session.add_all([requested, inactive])
db_session.commit()
assert resolve_selected_league(db_session, requested.id).id == requested.id
assert resolve_selected_league(db_session, inactive.id).slug == "default"
assert resolve_selected_league(db_session, 9999).slug == "default"
def test_parse_league_id_handles_invalid_values():
assert parse_league_id("42") == 42
assert parse_league_id("") is None
assert parse_league_id(None) is None
assert parse_league_id("not-a-number") is None
def test_qr_code_league_id_prefers_direct_then_set_then_event(db_session):
direct = League(name="Direct League", slug="direct", is_active=True)
via_set = League(name="Set League", slug="set", is_active=True)
via_event = League(name="Event League", slug="event", is_active=True)
db_session.add_all([direct, via_set, via_event])
db_session.commit()
qr_set = QRSet(name="Weekly Set", league_id=via_set.id)
event = Event(name="Quiz Night", league_id=via_event.id, event_date=datetime(2026, 5, 22))
team = Team(name="Quiz Team", league_id=direct.id)
db_session.add_all([qr_set, event, team])
db_session.commit()
direct_qr = QRCode(code="direct-code", points=10, league_id=direct.id)
set_qr = QRCode(code="set-code", points=10, qr_set_id=qr_set.id)
event_qr = QRCode(code="event-code", points=10, event_id=event.id)
db_session.add_all([direct_qr, set_qr, event_qr])
db_session.commit()
assert qr_code_league_id(direct_qr) == direct.id
assert qr_code_league_id(set_qr) == via_set.id
assert qr_code_league_id(event_qr) == via_event.id
+110 -148
View File
@@ -1,170 +1,132 @@
#!/usr/bin/env python3
import pytest
from unittest import mock
from sqlalchemy.orm import Session
from fastapi.testclient import TestClient
from datetime import datetime, timedelta
from app.main import app
from app.models import User, Team, Event, QRCode
from app.views.admin import get_user_statistics, get_team_statistics
from app.views.admin import get_event_statistics, get_system_health
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Fixture for mocking the database session
@pytest.fixture
def mock_db():
"""Create a mock database session for testing."""
mock_session = mock.MagicMock(spec=Session)
return mock_session
from app.models import Base, Event, EventAttendee, Team, TeamMembership, User
from app.views.admin import (
get_event_statistics,
get_system_health,
get_team_statistics,
get_user_statistics,
)
# Test cases for user statistics
def test_get_user_statistics(mock_db):
"""Test getting user statistics."""
# Setup mock query results
mock_db.query().count.side_effect = [100, 80, 20]
mock_db.query().filter().count.return_value = 10
# Get last 30 days
thirty_days_ago = datetime.now() - timedelta(days=30)
mock_db.query().filter().filter().count.return_value = 15
@pytest.fixture()
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# Get the statistics
stats = get_user_statistics(mock_db)
# Assert the expected results
assert stats["total_users"] == 100
assert stats["active_users"] == 80
assert stats["verified_users"] == 20
assert stats["admin_users"] == 10
assert stats["new_registrations_30d"] == 15
# Test cases for team statistics
def test_get_team_statistics(mock_db):
"""Test getting team statistics."""
# Setup mock query results
mock_db.query().count.side_effect = [50, 45]
mock_db.query().filter().count.return_value = 5
# Team distribution mock
mock_team_distribution = [
{"member_count": 0, "count": 5},
{"member_count": 1, "count": 10},
{"member_count": 2, "count": 15},
{"member_count": 3, "count": 10},
{"member_count": 4, "count": 7},
{"member_count": 5, "count": 3}
def seed_users(db):
now = datetime.utcnow()
users = [
User(
username="admin",
email="admin@example.com",
hashed_password="hash",
is_active=True,
is_verified=True,
is_admin=True,
created_at=now - timedelta(days=2),
),
User(
username="verified",
email="verified@example.com",
hashed_password="hash",
is_active=True,
is_verified=True,
is_admin=False,
created_at=now - timedelta(days=10),
),
User(
username="inactive",
email="inactive@example.com",
hashed_password="hash",
is_active=False,
is_verified=False,
is_admin=False,
created_at=now - timedelta(days=60),
),
]
mock_db.query().group_by().all.return_value = mock_team_distribution
db.add_all(users)
db.commit()
return users
# Get the statistics
stats = get_team_statistics(mock_db)
# Assert the expected results
assert stats["total_teams"] == 50
assert stats["active_teams"] == 45
assert stats["public_teams"] == 5
assert stats["team_distribution"] == mock_team_distribution
def test_get_user_statistics(db_session):
seed_users(db_session)
# Test cases for event statistics
def test_get_event_statistics(mock_db):
"""Test getting event statistics."""
# Setup mock query results
mock_db.query().count.side_effect = [30, 25, 5]
stats = get_user_statistics(db_session)
# Setup mock for upcoming events
today = datetime.now().date()
upcoming_events = [
mock.MagicMock(name="Event 1", event_date=today + timedelta(days=1), location="Location 1"),
mock.MagicMock(name="Event 2", event_date=today + timedelta(days=3), location="Location 2"),
mock.MagicMock(name="Event 3", event_date=today + timedelta(days=7), location="Location 3")
assert stats["total_users"] == 3
assert stats["active_users"] == 2
assert stats["verified_users"] == 2
assert stats["admin_users"] == 1
assert stats["new_registrations_30d"] == 2
assert len(stats["monthly_registrations"]) == 6
assert len(stats["month_labels"]) == 6
def test_get_team_statistics(db_session):
users = seed_users(db_session)
teams = [
Team(name="Open Team", is_active=True, is_public=True),
Team(name="Private Team", is_active=True, is_public=False),
Team(name="Archived Team", is_active=False, is_public=False),
]
mock_db.query().filter().order_by().limit().all.return_value = upcoming_events
db_session.add_all(teams)
db_session.commit()
db_session.add_all([
TeamMembership(user_id=users[0].id, team_id=teams[0].id),
TeamMembership(user_id=users[1].id, team_id=teams[0].id),
TeamMembership(user_id=users[2].id, team_id=teams[1].id),
])
db_session.commit()
# Setup mock for attendance rates
mock_attendance_rates = [
{"event_id": 1, "event_name": "Event A", "attendee_count": 25},
{"event_id": 2, "event_name": "Event B", "attendee_count": 18},
{"event_id": 3, "event_name": "Event C", "attendee_count": 30}
]
mock_db.query().join().group_by().order_by().limit().all.return_value = mock_attendance_rates
stats = get_team_statistics(db_session)
# Get the statistics
stats = get_event_statistics(mock_db)
assert stats["total_teams"] == 3
assert stats["active_teams"] == 2
assert stats["public_teams"] == 1
assert {"member_count": 2, "count": 1} in stats["team_distribution"]
assert {"member_count": 1, "count": 1} in stats["team_distribution"]
# Assert the expected results
assert stats["total_events"] == 30
assert stats["past_events"] == 25
assert stats["upcoming_events_count"] == 5
assert len(stats["upcoming_events"]) == 3
assert stats["attendance_rates"] == mock_attendance_rates
# Test cases for system health
def test_get_system_health(mock_db):
"""Test getting system health information."""
# Mock database status
mock_db.execute().fetchall.return_value = [{"status": "online"}]
def test_get_event_statistics(db_session):
users = seed_users(db_session)
now = datetime.now()
past_event = Event(name="Past Quiz", event_date=now - timedelta(days=1))
future_event = Event(name="Future Quiz", event_date=now + timedelta(days=7))
db_session.add_all([past_event, future_event])
db_session.commit()
db_session.add_all([
EventAttendee(event_id=past_event.id, user_id=users[0].id),
EventAttendee(event_id=past_event.id, user_id=users[1].id),
])
db_session.commit()
# Get the health information
health_info = get_system_health(mock_db)
stats = get_event_statistics(db_session)
assert stats["total_events"] == 2
assert stats["past_events"] == 1
assert stats["upcoming_events_count"] == 1
assert [event.name for event in stats["upcoming_events"]] == ["Future Quiz"]
assert stats["attendance_rates"][0].event_name == "Past Quiz"
assert stats["attendance_rates"][0].attendee_count == 2
def test_get_system_health(db_session):
health_info = get_system_health(db_session)
# Assert expected results
assert health_info["database_status"] == "online"
assert "uptime" in health_info
assert "recent_errors" in health_info
# Integration test for admin dashboard endpoint
@mock.patch("app.views.admin.get_db")
def test_admin_dashboard_endpoint(mock_get_db, mock_db):
"""Test the admin dashboard endpoint."""
# Setup mock DB to be returned from get_db
mock_get_db.return_value = mock_db
# Mock user stats
mock_user_stats = {
"total_users": 100,
"active_users": 80,
"verified_users": 20,
"admin_users": 10,
"new_registrations_30d": 15
}
# Mock team stats
mock_team_stats = {
"total_teams": 50,
"active_teams": 45,
"public_teams": 5,
"team_distribution": []
}
# Mock event stats
mock_event_stats = {
"total_events": 30,
"past_events": 25,
"upcoming_events_count": 5,
"upcoming_events": [],
"attendance_rates": []
}
# Mock system health
mock_system_health = {
"database_status": "online",
"uptime": "3 days, 2 hours",
"recent_errors": []
}
# Setup mock return values for our statistics functions
with mock.patch("app.views.admin.get_user_statistics", return_value=mock_user_stats), \
mock.patch("app.views.admin.get_team_statistics", return_value=mock_team_stats), \
mock.patch("app.views.admin.get_event_statistics", return_value=mock_event_stats), \
mock.patch("app.views.admin.get_system_health", return_value=mock_system_health), \
mock.patch("app.views.admin.require_admin", return_value=lambda f: f):
client = TestClient(app)
response = client.get("/admin/dashboard")
# Assert the response
assert response.status_code == 200
assert "user_stats" in response.context
assert "team_stats" in response.context
assert "event_stats" in response.context
assert "system_health" in response.context
assert health_info["recent_errors"] == []