Add multi-league foundation
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,6 +155,7 @@
|
||||
<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>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">
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user