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 # Check Team table
if 'teams' in tables: if 'teams' in tables:
columns = [col['name'] for col in inspector.get_columns('teams')] 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: if 'is_public' not in columns:
print("Adding is_public column to teams table") print("Adding is_public column to teams table")
connection.execute(text( connection.execute(text(
@@ -109,6 +114,11 @@ def migrate_schema():
# Check QRCode table (formerly QRTicket) # Check QRCode table (formerly QRTicket)
if 'qr_codes' in tables: if 'qr_codes' in tables:
columns = [col['name'] for col in inspector.get_columns('qr_codes')] 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: if 'created_at' not in columns:
print("Adding created_at column to qr_codes table") print("Adding created_at column to qr_codes table")
connection.execute(text( connection.execute(text(
@@ -122,6 +132,22 @@ def migrate_schema():
else: else:
print("QRCodes table doesn't exist yet, skipping QRCode table migrations") 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 # Handle legacy QRTicket table migration if it exists
if 'qr_tickets' in tables and 'qr_codes' in tables: if 'qr_tickets' in tables and 'qr_codes' in tables:
print("Migrating data from legacy qr_tickets table to qr_codes table") print("Migrating data from legacy qr_tickets table to qr_codes table")
@@ -178,6 +204,26 @@ def migrate_schema():
""")) """))
connection.commit() 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 # Create team_members table if it doesn't exist and teams table exists
if 'team_members' not in tables and 'teams' in tables: if 'team_members' not in tables and 'teams' in tables:
print("Creating team_members table") print("Creating team_members table")
@@ -216,6 +262,7 @@ def migrate_schema():
connection.execute(text(""" connection.execute(text("""
CREATE TABLE events ( CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NULL,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
location VARCHAR(200), location VARCHAR(200),
@@ -262,6 +309,7 @@ def migrate_schema():
connection.execute(text(""" connection.execute(text("""
CREATE TABLE qr_sets ( CREATE TABLE qr_sets (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
league_id INT NULL,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+28 -2
View File
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from passlib.context import CryptContext from passlib.context import CryptContext
import asyncio 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 import SessionLocal, engine
from .db_migrations import run_migrations from .db_migrations import run_migrations
@@ -151,6 +151,16 @@ def seed_db():
db.add_all(users) db.add_all(users)
db.commit() 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 # Create teams
has_is_public = table_has_column(db.bind, 'teams', 'is_public') has_is_public = table_has_column(db.bind, 'teams', 'is_public')
has_created_at = table_has_column(db.bind, 'teams', 'created_at') has_created_at = table_has_column(db.bind, 'teams', 'created_at')
@@ -158,7 +168,7 @@ def seed_db():
teams = [] teams = []
for i, name in enumerate(["Quiz Wizards", "Trivia Titans", "Beer Brainiacs", "Knowledge Knights"]): 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: if has_is_public:
team_attrs["is_public"] = i % 2 == 1 # Alternate public/private team_attrs["is_public"] = i % 2 == 1 # Alternate public/private
if has_description: if has_description:
@@ -203,21 +213,27 @@ def seed_db():
# Create events for QR code linking # Create events for QR code linking
events = [ events = [
Event(name="Music Trivia Night", description="A night of musical quizzes", Event(name="Music Trivia Night", description="A night of musical quizzes",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=60), event_date=datetime.now() - timedelta(days=60),
location="Irish Rover Pub"), location="Irish Rover Pub"),
Event(name="History Night", description="Test your history knowledge", Event(name="History Night", description="Test your history knowledge",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=45), event_date=datetime.now() - timedelta(days=45),
location="Irish Rover Pub"), location="Irish Rover Pub"),
Event(name="Movie Trivia Night", description="All about cinema", Event(name="Movie Trivia Night", description="All about cinema",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=30), event_date=datetime.now() - timedelta(days=30),
location="Irish Rover Pub"), location="Irish Rover Pub"),
Event(name="Sports Quiz", description="For sports enthusiasts", Event(name="Sports Quiz", description="For sports enthusiasts",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=15), event_date=datetime.now() - timedelta(days=15),
location="Irish Rover Pub"), location="Irish Rover Pub"),
Event(name="General Knowledge", description="A bit of everything", Event(name="General Knowledge", description="A bit of everything",
league_id=default_league.id,
event_date=datetime.now() - timedelta(days=7), event_date=datetime.now() - timedelta(days=7),
location="Irish Rover Pub"), location="Irish Rover Pub"),
Event(name="Irish Rover Pub Quiz April 2025", description="Monthly pub quiz", Event(name="Irish Rover Pub Quiz April 2025", description="Monthly pub quiz",
league_id=default_league.id,
event_date=datetime.now(), event_date=datetime.now(),
location="Irish Rover Pub") location="Irish Rover Pub")
] ]
@@ -227,11 +243,13 @@ def seed_db():
# Create QR sets # Create QR sets
qr_sets = [ qr_sets = [
QRSet( QRSet(
league_id=default_league.id,
name="Standard Pub Quiz", 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)", 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 created_by=1 # Admin user
), ),
QRSet( QRSet(
league_id=default_league.id,
name="Trivia Night with Achievements", name="Trivia Night with Achievements",
description="Special trivia night with QR codes for winners and achievement codes for trivia categories", description="Special trivia night with QR codes for winners and achievement codes for trivia categories",
created_by=2 # John the quizmaster created_by=2 # John the quizmaster
@@ -248,6 +266,7 @@ def seed_db():
qr_set_1 = qr_sets[0] qr_set_1 = qr_sets[0]
qr_codes.extend([ qr_codes.extend([
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=25, points=25,
title="1st Place", title="1st Place",
@@ -257,6 +276,7 @@ def seed_db():
used=False used=False
), ),
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=15, points=15,
title="2nd Place", title="2nd Place",
@@ -266,6 +286,7 @@ def seed_db():
used=False used=False
), ),
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=10, points=10,
title="3rd Place", title="3rd Place",
@@ -275,6 +296,7 @@ def seed_db():
used=False used=False
), ),
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=5, points=5,
title="4th Place", title="4th Place",
@@ -289,6 +311,7 @@ def seed_db():
qr_set_2 = qr_sets[1] qr_set_2 = qr_sets[1]
qr_codes.extend([ qr_codes.extend([
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=20, points=20,
title="Trivia Champion", title="Trivia Champion",
@@ -298,6 +321,7 @@ def seed_db():
used=False used=False
), ),
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=0, points=0,
title="Estimate Winner", title="Estimate Winner",
@@ -308,6 +332,7 @@ def seed_db():
used=False used=False
), ),
QRCode( QRCode(
league_id=default_league.id,
code=str(uuid.uuid4()), code=str(uuid.uuid4()),
points=0, points=0,
title="Film Buff", title="Film Buff",
@@ -334,6 +359,7 @@ def seed_db():
qr_codes.append( qr_codes.append(
QRCode( QRCode(
league_id=default_league.id,
code=f"TICKET{i:03d}", code=f"TICKET{i:03d}",
points=points, points=points,
title=f"{points} Points Ticket", title=f"{points} Points Ticket",
+153
View File
@@ -104,6 +104,9 @@ def run_migrations(engine):
print("Running migrations...") print("Running migrations...")
# Check if the columns already exist before adding them # 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 additional_oauth_providers column if it doesn't exist
add_oauth_providers_column(connection) add_oauth_providers_column(connection)
@@ -151,6 +154,156 @@ def add_oauth_providers_column(connection):
except Exception as e: except Exception as e:
print(f"Error adding additional_oauth_providers column: {str(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): def add_name_columns(connection):
"""Add first_name and last_name columns to users table""" """Add first_name and last_name columns to users table"""
try: 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") 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): class Team(Base):
__tablename__ = "teams" __tablename__ = "teams"
id = Column(Integer, primary_key=True, index=True) 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) description = Column(Text, nullable=True)
logo_url = Column(String(255), nullable=True) # Add logo URL field logo_url = Column(String(255), nullable=True) # Add logo URL field
is_public = Column(Boolean, default=False) # For team privacy setting 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) owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships # Relationships
league = relationship("League", back_populates="teams")
members = relationship("TeamMembership", back_populates="team", cascade="all, delete-orphan") members = relationship("TeamMembership", back_populates="team", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_teams") owner = relationship("User", back_populates="owned_teams")
__table_args__ = (UniqueConstraint('league_id', 'name', name='_league_team_name_uc'),)
class TeamJoinRequest(Base): class TeamJoinRequest(Base):
__tablename__ = "team_join_requests" __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""" """A set of related QR codes, such as codes for different placements in a quiz"""
__tablename__ = "qr_sets" __tablename__ = "qr_sets"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(100), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
created_by = Column(Integer, ForeignKey("users.id"), nullable=True) created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships # Relationships
league = relationship("League", back_populates="qr_sets")
qr_codes = relationship("QRCode", back_populates="qr_set") qr_codes = relationship("QRCode", back_populates="qr_set")
creator = relationship("User") 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""" """Unified QR code model that includes all the functionality of the old QRTicket and QRCode models"""
__tablename__ = "qr_codes" __tablename__ = "qr_codes"
id = Column(Integer, primary_key=True, index=True) 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 code = Column(String(128), unique=True, index=True, nullable=False) # Unique token
points = Column(Float, default=0, nullable=False) points = Column(Float, default=0, nullable=False)
title = Column(String(100), nullable=True) # e.g., "1st Place", "2nd Place" title = Column(String(100), nullable=True) # e.g., "1st Place", "2nd Place"
description = Column(String(255), nullable=True) description = Column(String(255), nullable=True)
# Set relationship # Set relationship
league = relationship("League", back_populates="qr_codes")
qr_set_id = Column(Integer, ForeignKey("qr_sets.id"), nullable=True) qr_set_id = Column(Integer, ForeignKey("qr_sets.id"), nullable=True)
qr_set = relationship("QRSet", back_populates="qr_codes") qr_set = relationship("QRSet", back_populates="qr_codes")
@@ -236,6 +264,7 @@ class TeamAchievement(Base):
class Event(Base): class Event(Base):
__tablename__ = "events" __tablename__ = "events"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(100), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
location = Column(String(200), 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()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships # Relationships
league = relationship("League", back_populates="events")
attendees = relationship("EventAttendee", back_populates="event") 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 class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div> <div>
<h1 class="text-2xl md:text-3xl font-garamond font-bold text-irish-green">League Leaderboard</h1> <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>
<div class="mt-4 md:mt-0"> <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" <select name="timeframe"
onchange="this.form.submit()" 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"> class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green bg-white">
+18 -1
View File
@@ -6,6 +6,20 @@
<p class="text-gray-600">Create and manage QR code sets for your pub quiz events</p> <p class="text-gray-600">Create and manage QR code sets for your pub quiz events</p>
</div> </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"> <div class="grid md:grid-cols-3 gap-8">
<!-- QR Set Creation --> <!-- QR Set Creation -->
<div class="bg-white rounded-lg shadow-md p-6"> <div class="bg-white rounded-lg shadow-md p-6">
@@ -15,6 +29,7 @@
</p> </p>
<form id="qrSetForm" action="/qr/sets" method="post" class="space-y-4"> <form id="qrSetForm" action="/qr/sets" method="post" class="space-y-4">
<input type="hidden" name="league_id" value="{{ selected_league.id }}">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700">Set Name</label> <label for="name" class="block text-sm font-medium text-gray-700">Set Name</label>
<input type="text" name="name" id="name" required <input type="text" name="name" id="name" required
@@ -140,6 +155,7 @@
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
<p>Created: {{ qr_set.created_at.strftime('%Y-%m-%d') }}</p> <p>Created: {{ qr_set.created_at.strftime('%Y-%m-%d') }}</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> <p>QR Codes: {% if qr_set.qr_codes %}{{ qr_set.qr_codes | length }}{% else %}0{% endif %}</p>
</div> </div>
<div class="mt-4 flex justify-between"> <div class="mt-4 flex justify-between">
@@ -199,9 +215,10 @@
quickQrForm.addEventListener('submit', function(e) { quickQrForm.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
const points = document.getElementById('qr-points').value; const points = document.getElementById('qr-points').value;
const leagueId = '{{ selected_league.id }}';
// Generate QR code // 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'); qrResult.classList.remove('hidden');
}); });
}); });
+4 -2
View File
@@ -3,7 +3,7 @@
<div class="max-w-6xl mx-auto p-4"> <div class="max-w-6xl mx-auto p-4">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center"> <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 <i class="fas fa-arrow-left"></i> Back to Dashboard
</a> </a>
<h1 class="text-3xl font-bold text-irish-green">{{ qr_set.name }}</h1> <h1 class="text-3xl font-bold text-irish-green">{{ qr_set.name }}</h1>
@@ -11,7 +11,9 @@
{% if qr_set.description %} {% if qr_set.description %}
<p class="text-gray-600 mt-2">{{ qr_set.description }}</p> <p class="text-gray-600 mt-2">{{ qr_set.description }}</p>
{% endif %} {% 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>
<div class="grid md:grid-cols-3 gap-8"> <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="flex flex-col md:flex-row justify-between items-center">
<div class="mb-4 md:mb-0"> <div class="mb-4 md:mb-0">
<h1 class="text-2xl md:text-3xl font-bold text-white">{{ team.name }}</h1> <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"> <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><i class="fas fa-trophy mr-1"></i> Rank #{{ team_rank }}</span>
<span class="hidden md:inline"></span> <span class="hidden md:inline"></span>
+20 -1
View File
@@ -1,7 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto"> <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 %} {% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6" role="alert"> <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> <h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Create New Team</h3>
{% if user %} {% if user %}
<form action="/teams/create" method="post" class="mt-4"> <form action="/teams/create" method="post" class="mt-4">
<input type="hidden" name="league_id" value="{{ selected_league.id }}">
<div class="mb-4"> <div class="mb-4">
<label for="name" class="block text-sm font-medium mb-1">Team Name</label> <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 <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 ..db import SessionLocal, Base
from ..models import ( from ..models import (
User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, User, League, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event,
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
) )
from ..templates_config import templates from ..templates_config import templates
@@ -28,6 +28,7 @@ router = APIRouter()
# Dictionary of model classes with their display names # Dictionary of model classes with their display names
MODELS = { MODELS = {
'user': (User, "Users"), 'user': (User, "Users"),
'league': (League, "Leagues"),
'team': (Team, "Teams"), 'team': (Team, "Teams"),
'team_membership': (TeamMembership, "Team Memberships"), 'team_membership': (TeamMembership, "Team Memberships"),
'qr_code': (QRCode, "QR Codes"), '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 = [] user_teams_enhanced = []
if user_teams: 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( team_points_query = db.query(
models.Team.id, models.Team.id,
models.Team.league_id,
func.sum(models.QRCode.points).label('total_points') func.sum(models.QRCode.points).label('total_points')
).outerjoin( ).outerjoin(
models.QRCode, models.QRCode,
models.QRCode.redeemed_at_team == models.Team.id 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 = {}
team_points = {row.id: row.total_points or 0 for row in team_points_query.all()} points_by_league = {}
ranked_teams = sorted(team_points.items(), key=lambda x: x[1], reverse=True) 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_ranks = {team_id: i+1 for i, (team_id, _) in enumerate(ranked_teams)} 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 # Enhance user teams with rank and points data
for team in user_teams: for team in user_teams:
@@ -120,6 +126,7 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
user_teams_enhanced.append({ user_teams_enhanced.append({
'id': team.id, 'id': team.id,
'name': team.name, 'name': team.name,
'league_name': team.league.name if team.league else "Default League",
'points': points, 'points': points,
'rank': rank, 'rank': rank,
'description': getattr(team, 'description', None) 'description': getattr(team, 'description', None)
+9 -2
View File
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
from ..db import SessionLocal from ..db import SessionLocal
from ..models import Team, TeamMembership, QRCode, User from ..models import Team, TeamMembership, QRCode, User
from ..league_context import get_active_leagues, resolve_selected_league
from ..templates_config import templates from ..templates_config import templates
router = APIRouter() router = APIRouter()
@@ -25,6 +26,7 @@ def get_db():
async def show_leaderboard( async def show_leaderboard(
request: Request, request: Request,
timeframe: str = Query("all", regex="^(week|month|all)$"), timeframe: str = Query("all", regex="^(week|month|all)$"),
league_id: int = Query(None),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Show the leaderboard with team rankings.""" """Show the leaderboard with team rankings."""
@@ -35,6 +37,9 @@ async def show_leaderboard(
if user_id: if user_id:
user = db.query(User).get(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 # Define cutoff date based on timeframe
cutoff_date = None cutoff_date = None
if timeframe == "week": if timeframe == "week":
@@ -56,7 +61,7 @@ async def show_leaderboard(
QRCode, QRCode,
QRCode.redeemed_at_team == Team.id, QRCode.redeemed_at_team == Team.id,
isouter=True isouter=True
) ).filter(Team.league_id == selected_league.id)
# Apply time filter if needed # Apply time filter if needed
if cutoff_date: if cutoff_date:
@@ -64,7 +69,7 @@ async def show_leaderboard(
query = query.filter(QRCode.redeemed_at >= cutoff_date) query = query.filter(QRCode.redeemed_at >= cutoff_date)
# Group and order # 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 # Add ranks
ranked_teams = [] ranked_teams = []
@@ -88,6 +93,8 @@ async def show_leaderboard(
"top_teams": top_teams, "top_teams": top_teams,
"timeframe": timeframe, "timeframe": timeframe,
"time_label": time_label, "time_label": time_label,
"leagues": leagues,
"selected_league": selected_league,
"user": user # Add user to the context "user": user # Add user to the context
} }
) )
+19 -8
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel
from ..db import SessionLocal from ..db import SessionLocal
from ..models import QRCode, QRSet, Event, User 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 from ..templates_config import templates
router = APIRouter() router = APIRouter()
@@ -61,16 +62,21 @@ async def qr_dashboard(request: Request, db: Session = Depends(get_db)):
if user_id: if user_id:
user = db.query(User).get(user_id) user = db.query(User).get(user_id)
# Get all QR sets league_id = request.query_params.get("league_id")
qr_sets = db.query(QRSet).all() selected_league = resolve_selected_league(db, parse_league_id(league_id))
leagues = get_active_leagues(db)
# Get all events for linking # Get QR sets and events for the selected league
events = db.query(Event).all() 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", { return templates.TemplateResponse("qr/dashboard.html", {
"request": request, "request": request,
"qr_sets": qr_sets, "qr_sets": qr_sets,
"events": events, "events": events,
"leagues": leagues,
"selected_league": selected_league,
"user": user # Add user to the context "user": user # Add user to the context
}) })
@@ -85,12 +91,14 @@ async def create_qr_set(
form_data = await request.form() form_data = await request.form()
name = form_data.get("name") name = form_data.get("name")
description = form_data.get("description", "") 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: if not name:
raise HTTPException(status_code=400, detail="Set name is required") raise HTTPException(status_code=400, detail="Set name is required")
# Create QR set # 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.add(qr_set)
db.commit() db.commit()
db.refresh(qr_set) db.refresh(qr_set)
@@ -148,6 +156,7 @@ async def add_qr_code_to_set(
# Create QR code # Create QR code
qr_code = QRCode( qr_code = QRCode(
league_id=qr_set.league_id,
code=code_str, code=code_str,
title=title, title=title,
points=points, points=points,
@@ -165,14 +174,15 @@ async def add_qr_code_to_set(
@router.get("/generate/{points}") @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. Generate a single QR code for awarding `points` points.
Saves a record in the DB, returns the PNG as streaming response. Saves a record in the DB, returns the PNG as streaming response.
""" """
code_str = str(uuid.uuid4()) 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.add(qr_code)
db.commit() db.commit()
db.refresh(qr_code) 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") raise HTTPException(status_code=404, detail="QR Set not found")
# Fetch available events # 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", { return templates.TemplateResponse("qr/admin_link.html", {
"request": request, "request": request,
@@ -273,6 +283,7 @@ async def process_admin_link(
# Create new event # Create new event
event_date = datetime.now() # Default to current date, can be improved event_date = datetime.now() # Default to current date, can be improved
new_event = Event( new_event = Event(
league_id=qr_set.league_id,
name=event_name, name=event_name,
description=f"Created via QR admin link on {event_date.strftime('%Y-%m-%d')}", description=f"Created via QR admin link on {event_date.strftime('%Y-%m-%d')}",
event_date=event_date event_date=event_date
+28 -3
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from ..db import SessionLocal from ..db import SessionLocal
from ..models import QRCode, User, Team, TeamMembership, TeamAchievement from ..models import QRCode, User, Team, TeamMembership, TeamAchievement
from ..templates_config import templates from ..templates_config import templates
from ..league_context import get_default_league, qr_code_league_id
router = APIRouter() 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 = [] user_teams = []
if user: if user:
# Query teams where the user is a member using TeamMembership relation # Query teams where the user is a member using TeamMembership relation
user_teams = ( user_teams = (
db.query(Team) db.query(Team)
.join(TeamMembership, Team.id == TeamMembership.team_id) .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() .all()
) )
@@ -101,7 +109,7 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
{ {
"request": request, "request": request,
"error_title": "No Teams Available", "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 "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 # Verify the user is a member of the selected team
is_team_member = db.query(TeamMembership).filter_by( is_team_member = db.query(TeamMembership).filter_by(
user_id=user.id, user_id=user.id,
@@ -239,6 +262,8 @@ async def apply_code(
) )
# Mark the QR code as redeemed # 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_team = team.id
qr_code.redeemed_at = datetime.now() qr_code.redeemed_at = datetime.now()
qr_code.used = True 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 fastapi.templating import Jinja2Templates
from ...models import Team, TeamMembership, User, TeamJoinRequest from ...models import Team, TeamMembership, User, TeamJoinRequest
from ...league_context import resolve_selected_league
from ...utils.auth import get_current_user from ...utils.auth import get_current_user
from ...utils.mail import send_team_join_request_notification, send_join_request_response from ...utils.mail import send_team_join_request_notification, send_join_request_response
from ...templates_config import templates from ...templates_config import templates
@@ -19,6 +20,7 @@ async def create_team_post(
description: str = Form(""), description: str = Form(""),
logo_url: str = Form(""), logo_url: str = Form(""),
is_open: bool = Form(False), is_open: bool = Form(False),
league_id: int = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
@@ -26,14 +28,23 @@ async def create_team_post(
if not current_user: if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER) return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if team name already exists selected_league = resolve_selected_league(db, league_id)
existing_team = db.query(Team).filter(Team.name == name).first()
# 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: 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 # Create team with only the fields that exist in the model
team_data = { team_data = {
"name": name, "name": name,
"league_id": selected_league.id,
"description": description, "description": description,
"is_open": is_open, "is_open": is_open,
"owner_id": current_user.id "owner_id": current_user.id
@@ -60,7 +71,7 @@ async def create_team_post(
db.add(team_membership) db.add(team_membership)
db.commit() 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( async def edit_team_post(
request: Request, 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): def calculate_team_rank(db: Session, team_id: int):
"""Calculate team rank based on points""" """Calculate team rank based on points"""
try: 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( team_points = db.query(
QRCode.redeemed_at_team, Team.id.label("team_id"),
func.sum(QRCode.points).label('total') func.sum(QRCode.points).label('total')
).outerjoin(
QRCode,
QRCode.redeemed_at_team == Team.id
).filter( ).filter(
QRCode.redeemed_at_team != None Team.league_id == team.league_id
).group_by(QRCode.redeemed_at_team).all() ).group_by(Team.id).all()
# Sort them by points (descending) # Sort them by points (descending)
sorted_teams = sorted(team_points, key=lambda x: x.total or 0, reverse=True) 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 # Find our team's position
team_rank = 1 team_rank = 1
for idx, team_data in enumerate(sorted_teams): 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 team_rank = idx + 1
break break
+16 -2
View File
@@ -8,13 +8,17 @@ import random
from sqlalchemy import inspect from sqlalchemy import inspect
from ...models import Team, TeamMembership, User, QRCode, TeamJoinRequest 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 ...templates_config import templates
from ...utils.auth import get_current_user from ...utils.auth import get_current_user
from . import utils from . import utils
def list_teams_view(request: Request, db: Session): def list_teams_view(request: Request, db: Session):
"""Render the teams list view""" """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 # Get the user's teams to highlight teams they're already in
user_team_ids = [] user_team_ids = []
@@ -25,7 +29,15 @@ def list_teams_view(request: Request, db: Session):
if user_id: if user_id:
user = db.query(User).get(user_id) user = db.query(User).get(user_id)
# Get teams that user is a member of # 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] user_team_ids = [membership.team_id for membership in memberships]
# Get error message if present # Get error message if present
@@ -36,6 +48,8 @@ def list_teams_view(request: Request, db: Session):
{ {
"request": request, "request": request,
"teams": teams, "teams": teams,
"leagues": leagues,
"selected_league": selected_league,
"user_team_ids": user_team_ids, "user_team_ids": user_team_ids,
"user": user, "user": user,
"error": error, "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 # Database
sqlalchemy>=2.0.20 sqlalchemy>=2.0.20
pymysql>=1.1.0 pymysql>=1.1.0
mysqlclient>=2.2.0
# Authentication and Security # Authentication and Security
authlib>=1.2.1 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 #!/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 datetime import datetime, timedelta
from app.main import app import pytest
from app.models import User, Team, Event, QRCode from sqlalchemy import create_engine
from app.views.admin import get_user_statistics, get_team_statistics from sqlalchemy.orm import sessionmaker
from app.views.admin import get_event_statistics, get_system_health
# Fixture for mocking the database session from app.models import Base, Event, EventAttendee, Team, TeamMembership, User
@pytest.fixture from app.views.admin import (
def mock_db(): get_event_statistics,
"""Create a mock database session for testing.""" get_system_health,
mock_session = mock.MagicMock(spec=Session) get_team_statistics,
return mock_session 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 @pytest.fixture()
thirty_days_ago = datetime.now() - timedelta(days=30) def db_session():
mock_db.query().filter().filter().count.return_value = 15 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 def seed_users(db):
assert stats["total_users"] == 100 now = datetime.utcnow()
assert stats["active_users"] == 80 users = [
assert stats["verified_users"] == 20 User(
assert stats["admin_users"] == 10 username="admin",
assert stats["new_registrations_30d"] == 15 email="admin@example.com",
hashed_password="hash",
# Test cases for team statistics is_active=True,
def test_get_team_statistics(mock_db): is_verified=True,
"""Test getting team statistics.""" is_admin=True,
# Setup mock query results created_at=now - timedelta(days=2),
mock_db.query().count.side_effect = [50, 45] ),
mock_db.query().filter().count.return_value = 5 User(
username="verified",
# Team distribution mock email="verified@example.com",
mock_team_distribution = [ hashed_password="hash",
{"member_count": 0, "count": 5}, is_active=True,
{"member_count": 1, "count": 10}, is_verified=True,
{"member_count": 2, "count": 15}, is_admin=False,
{"member_count": 3, "count": 10}, created_at=now - timedelta(days=10),
{"member_count": 4, "count": 7}, ),
{"member_count": 5, "count": 3} 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 def test_get_user_statistics(db_session):
assert stats["total_teams"] == 50 seed_users(db_session)
assert stats["active_teams"] == 45
assert stats["public_teams"] == 5
assert stats["team_distribution"] == mock_team_distribution
# Test cases for event statistics stats = get_user_statistics(db_session)
def test_get_event_statistics(mock_db):
"""Test getting event statistics."""
# Setup mock query results
mock_db.query().count.side_effect = [30, 25, 5]
# Setup mock for upcoming events assert stats["total_users"] == 3
today = datetime.now().date() assert stats["active_users"] == 2
upcoming_events = [ assert stats["verified_users"] == 2
mock.MagicMock(name="Event 1", event_date=today + timedelta(days=1), location="Location 1"), assert stats["admin_users"] == 1
mock.MagicMock(name="Event 2", event_date=today + timedelta(days=3), location="Location 2"), assert stats["new_registrations_30d"] == 2
mock.MagicMock(name="Event 3", event_date=today + timedelta(days=7), location="Location 3") 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 stats = get_team_statistics(db_session)
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
# Get the statistics assert stats["total_teams"] == 3
stats = get_event_statistics(mock_db) 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_event_statistics(db_session):
def test_get_system_health(mock_db): users = seed_users(db_session)
"""Test getting system health information.""" now = datetime.now()
# Mock database status past_event = Event(name="Past Quiz", event_date=now - timedelta(days=1))
mock_db.execute().fetchall.return_value = [{"status": "online"}] 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 stats = get_event_statistics(db_session)
health_info = get_system_health(mock_db)
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 health_info["database_status"] == "online"
assert "uptime" in health_info assert "uptime" in health_info
assert "recent_errors" in health_info assert health_info["recent_errors"] == []
# 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