From 15e1dc227beff45a3946ba16d9cd8985acb4a426 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Fri, 22 May 2026 20:47:28 +0200 Subject: [PATCH] Add multi-league foundation --- app/db.py | 48 +++ app/db_init.py | 30 +- app/db_migrations.py | 153 ++++++++++ app/league_context.py | 69 +++++ app/models.py | 32 +- app/templates/leaderboard.html | 15 +- app/templates/qr/dashboard.html | 23 +- app/templates/qr/set_detail.html | 8 +- app/templates/team_detail.html | 1 + app/templates/teams.html | 21 +- app/views/admin.py | 3 +- app/views/dashboard.py | 23 +- app/views/leaderboard.py | 11 +- app/views/qr.py | 27 +- app/views/redeem.py | 31 +- app/views/teams/actions.py | 19 +- app/views/teams/utils.py | 17 +- app/views/teams/views.py | 18 +- docs/development/roadmap.md | 53 ++++ .../senior-dev-inspection-prompt.md | 26 ++ requirements.txt | 1 - tests/unit/test_league_context.py | 71 +++++ tests/unit/views/test_admin_dashboard.py | 280 ++++++++---------- 23 files changed, 775 insertions(+), 205 deletions(-) create mode 100644 app/league_context.py create mode 100644 docs/development/roadmap.md create mode 100644 docs/development/senior-dev-inspection-prompt.md create mode 100644 tests/unit/test_league_context.py diff --git a/app/db.py b/app/db.py index 9b42053..f309850 100644 --- a/app/db.py +++ b/app/db.py @@ -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( @@ -121,6 +131,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: @@ -177,6 +203,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: @@ -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, diff --git a/app/db_init.py b/app/db_init.py index 775f0c8..a730278 100644 --- a/app/db_init.py +++ b/app/db_init.py @@ -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 @@ -150,6 +150,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') @@ -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", diff --git a/app/db_migrations.py b/app/db_migrations.py index 75c7495..832bab3 100644 --- a/app/db_migrations.py +++ b/app/db_migrations.py @@ -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: diff --git a/app/league_context.py b/app/league_context.py new file mode 100644 index 0000000..60e7833 --- /dev/null +++ b/app/league_context.py @@ -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 diff --git a/app/models.py b/app/models.py index 69b7091..b26e0af 100644 --- a/app/models.py +++ b/app/models.py @@ -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"" + + 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") diff --git a/app/templates/leaderboard.html b/app/templates/leaderboard.html index f214177..7d685dc 100644 --- a/app/templates/leaderboard.html +++ b/app/templates/leaderboard.html @@ -4,11 +4,22 @@

League Leaderboard

-

See how your team ranks against the competition

+

+ See how your team ranks in {{ selected_league.name if selected_league else "your league" }} +

-
+ + + {% for league in leagues %} + + {% endfor %} + +
+ +
@@ -15,6 +29,7 @@

+

Created: {{ qr_set.created_at.strftime('%Y-%m-%d') }}

-

QR Codes: {% if qr_set.qr_codes %}{{ qr_set.qr_codes | length }}{% else %}0{% endif %}

+

League: {{ qr_set.league.name if qr_set.league else "Default League" }}

+

QR Codes: {% if qr_set.qr_codes %}{{ qr_set.qr_codes | length }}{% else %}0{% endif %}

@@ -199,11 +215,12 @@ quickQrForm.addEventListener('submit', function(e) { e.preventDefault(); const points = document.getElementById('qr-points').value; + const leagueId = '{{ selected_league.id }}'; // Generate QR code - qrImage.innerHTML = 'QR Code'; + qrImage.innerHTML = 'QR Code'; qrResult.classList.remove('hidden'); }); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/qr/set_detail.html b/app/templates/qr/set_detail.html index 40b4b3a..9bfcb65 100644 --- a/app/templates/qr/set_detail.html +++ b/app/templates/qr/set_detail.html @@ -3,7 +3,7 @@
- + Back to Dashboard

{{ qr_set.name }}

@@ -11,7 +11,9 @@ {% if qr_set.description %}

{{ qr_set.description }}

{% endif %} -

Created on {{ qr_set.created_at.strftime('%Y-%m-%d') }}

+

+ {{ qr_set.league.name if qr_set.league else "Default League" }} • Created on {{ qr_set.created_at.strftime('%Y-%m-%d') }} +

@@ -270,4 +272,4 @@ } }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html index fb26523..e5446af 100644 --- a/app/templates/team_detail.html +++ b/app/templates/team_detail.html @@ -7,6 +7,7 @@

{{ team.name }}

+

{{ team.league.name if team.league else "Default League" }}

Rank #{{ team_rank }} diff --git a/app/templates/teams.html b/app/templates/teams.html index ad4389c..dbf6f38 100644 --- a/app/templates/teams.html +++ b/app/templates/teams.html @@ -1,7 +1,25 @@ {% extends "base.html" %} {% block content %}
-

Teams

+
+
+

Teams

+ {% if selected_league %} +

{{ selected_league.name }}

+ {% endif %} +
+ + + + +
{% if error %}