diff --git a/app/db.py b/app/db.py index c22178c..892a263 100644 --- a/app/db.py +++ b/app/db.py @@ -97,25 +97,38 @@ def migrate_schema(): "ALTER TABLE team_membership ADD COLUMN joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP" )) - # Check QRTicket table - if 'qr_tickets' in inspector.get_table_names(): - columns = [col['name'] for col in inspector.get_columns('qr_tickets')] + # Check QRCode table (formerly QRTicket) + if 'qr_codes' in inspector.get_table_names(): + columns = [col['name'] for col in inspector.get_columns('qr_codes')] if 'created_at' not in columns: - print("Adding created_at column to qr_tickets table") + print("Adding created_at column to qr_codes table") connection.execute(text( - "ALTER TABLE qr_tickets ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + "ALTER TABLE qr_codes ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP" )) if 'redeemed_at' not in columns: - print("Adding redeemed_at column to qr_tickets table") + print("Adding redeemed_at column to qr_codes table") connection.execute(text( - "ALTER TABLE qr_tickets ADD COLUMN redeemed_at TIMESTAMP NULL" - )) - if 'event_name' not in columns: - print("Adding event_name column to qr_tickets table") - connection.execute(text( - "ALTER TABLE qr_tickets ADD COLUMN event_name VARCHAR(255)" + "ALTER TABLE qr_codes ADD COLUMN redeemed_at TIMESTAMP NULL" )) + # Handle legacy QRTicket table migration if it exists + if 'qr_tickets' in inspector.get_table_names() and 'qr_codes' in inspector.get_table_names(): + print("Migrating data from legacy qr_tickets table to qr_codes table") + try: + # Check if migration has already been done + ticket_count = connection.execute(text("SELECT COUNT(*) FROM qr_tickets")).scalar() + if ticket_count > 0: + # Migrate data from qr_tickets to qr_codes + connection.execute(text(""" + INSERT INTO qr_codes (code, points, redeemed_by, redeemed_at_team, used, redeemed_at) + SELECT code, points, redeemed_by, redeemed_at_team, used, redeemed_at + FROM qr_tickets + """)) + connection.commit() + print(f"Migrated {ticket_count} tickets from qr_tickets to qr_codes") + except Exception as e: + print(f"Error during qr_tickets migration: {e}") + # Create OAuthAccount table if it doesn't exist if 'oauth_accounts' not in inspector.get_table_names(): print("Creating oauth_accounts table") @@ -141,10 +154,42 @@ def migrate_schema(): id INT AUTO_INCREMENT PRIMARY KEY, team_id INT, name VARCHAR(255) NOT NULL, - event_name VARCHAR(255), + event_id INT, description TEXT, achieved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (team_id) REFERENCES teams(id) + qr_code_id INT, + FOREIGN KEY (team_id) REFERENCES teams(id), + FOREIGN KEY (event_id) REFERENCES events(id), + FOREIGN KEY (qr_code_id) REFERENCES qr_codes(id) + ) + """)) + + # Create QRSet table if it doesn't exist + if 'qr_sets' not in inspector.get_table_names(): + print("Creating qr_sets table") + connection.execute(text(""" + CREATE TABLE qr_sets ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by INT, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + """)) + + # Create Event table if it doesn't exist + if 'events' not in inspector.get_table_names(): + print("Creating events table") + connection.execute(text(""" + CREATE TABLE events ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + location VARCHAR(200), + event_date TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) """)) diff --git a/app/db_init.py b/app/db_init.py index b49cc3d..4bf2473 100644 --- a/app/db_init.py +++ b/app/db_init.py @@ -3,12 +3,13 @@ Seed the database with initial testing data. """ import random +import uuid from datetime import datetime, timedelta from sqlalchemy import inspect from sqlalchemy.orm import Session from passlib.context import CryptContext -from .models import User, Team, TeamMembership, QRTicket +from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event from .db import SessionLocal pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -119,40 +120,164 @@ def seed_db(): db.add_all(memberships) db.commit() - # Create QR tickets - has_created_at = table_has_column(db.bind, 'qr_tickets', 'created_at') - has_redeemed_at = table_has_column(db.bind, 'qr_tickets', 'redeemed_at') - has_event_name = table_has_column(db.bind, 'qr_tickets', 'event_name') - - event_names = [ - "Music Trivia Night", - "History Night", - "Movie Trivia Night", - "Sports Quiz", - "General Knowledge" + # Create events for QR code linking + events = [ + Event(name="Music Trivia Night", description="A night of musical quizzes", + event_date=datetime.now() - timedelta(days=60), + location="Irish Rover Pub"), + Event(name="History Night", description="Test your history knowledge", + event_date=datetime.now() - timedelta(days=45), + location="Irish Rover Pub"), + Event(name="Movie Trivia Night", description="All about cinema", + event_date=datetime.now() - timedelta(days=30), + location="Irish Rover Pub"), + Event(name="Sports Quiz", description="For sports enthusiasts", + event_date=datetime.now() - timedelta(days=15), + location="Irish Rover Pub"), + Event(name="General Knowledge", description="A bit of everything", + event_date=datetime.now() - timedelta(days=7), + location="Irish Rover Pub"), + Event(name="Irish Rover Pub Quiz April 2025", description="Monthly pub quiz", + event_date=datetime.now(), + location="Irish Rover Pub") ] + db.add_all(events) + db.commit() - # Create some basic tickets - tickets = [] + # Create QR sets + qr_sets = [ + QRSet( + 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( + 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 + ) + ] + db.add_all(qr_sets) + db.commit() + + # Create QR codes (both used and unused) + qr_codes = [] + + # First, create some QR codes in sets (unused) + # Standard Pub Quiz set + qr_set_1 = qr_sets[0] + qr_codes.extend([ + QRCode( + code=str(uuid.uuid4()), + points=25, + title="1st Place", + description="First place award (25 points)", + achievement_name="First Place Winner", + qr_set_id=qr_set_1.id, + used=False + ), + QRCode( + code=str(uuid.uuid4()), + points=15, + title="2nd Place", + description="Second place award (15 points)", + achievement_name="Second Place Winner", + qr_set_id=qr_set_1.id, + used=False + ), + QRCode( + code=str(uuid.uuid4()), + points=10, + title="3rd Place", + description="Third place award (10 points)", + achievement_name="Third Place Winner", + qr_set_id=qr_set_1.id, + used=False + ), + QRCode( + code=str(uuid.uuid4()), + points=5, + title="4th Place", + description="Fourth place award (5 points)", + achievement_name=None, + qr_set_id=qr_set_1.id, + used=False + ) + ]) + + # Trivia Night set with special achievements + qr_set_2 = qr_sets[1] + qr_codes.extend([ + QRCode( + code=str(uuid.uuid4()), + points=20, + title="Trivia Champion", + description="Overall winner of trivia night", + achievement_name="Trivia Champion", + qr_set_id=qr_set_2.id, + used=False + ), + QRCode( + code=str(uuid.uuid4()), + points=0, + title="Estimate Winner", + description="Closest guess to the correct answer", + achievement_name="Closest Guess Award", + is_achievement_only=True, + qr_set_id=qr_set_2.id, + used=False + ), + QRCode( + code=str(uuid.uuid4()), + points=0, + title="Film Buff", + description="Most movie questions correct", + achievement_name="Film Buff", + is_achievement_only=True, + qr_set_id=qr_set_2.id, + used=False + ) + ]) + + # Now create some already used/redeemed QR codes for i in range(15): points = random.choice([5, 10, 15, 20, 25]) team_id = random.randint(1, len(teams)) user_id = random.randint(1, len(users)) + event_id = random.randint(1, len(events) - 1) # Exclude the latest event - ticket_attrs = { - "code": f"TICKET{i:03d}", - "points": points, - "redeemed_by": user_id, - "redeemed_at_team": team_id, - "used": True - } - - if has_event_name: - ticket_attrs["event_name"] = random.choice(event_names) + achievement = None + if points >= 15: # Only high points get achievements + achievement = random.choice(["Winner", "Top Scorer", "Quiz Master", None]) - tickets.append(QRTicket(**ticket_attrs)) + redeemed_at = datetime.now() - timedelta(days=random.randint(7, 90)) + + qr_codes.append( + QRCode( + code=f"TICKET{i:03d}", + points=points, + title=f"{points} Points Ticket", + achievement_name=achievement, + redeemed_by=user_id, + redeemed_at_team=team_id, + redeemed_at=redeemed_at, + event_id=event_id, + used=True + ) + ) + + # Create achievement record if applicable + if achievement: + team_achievement = TeamAchievement( + team_id=team_id, + name=achievement, + event_id=event_id, + achieved_at=redeemed_at, + qr_code_id=i + 1 # This will be assigned after the QR codes are committed + ) + db.add(team_achievement) - db.add_all(tickets) + db.add_all(qr_codes) db.commit() print("Database seeded successfully!") diff --git a/app/i18n/__init__.py b/app/i18n/__init__.py new file mode 100644 index 0000000..bedbbf3 --- /dev/null +++ b/app/i18n/__init__.py @@ -0,0 +1,74 @@ +import gettext +import os +from typing import Dict, List, Callable, Any +from fastapi import Request, Depends +from babel.support import Translations +from functools import lru_cache +from gettext import gettext as _ # Ensure `_` is imported for translations + +# Define supported languages +SUPPORTED_LANGUAGES = { + 'en': 'English', + 'de': 'Deutsch', +} + +DEFAULT_LANGUAGE = 'de' # Default is German + +# Path to translations +LOCALE_DIR = os.path.join(os.path.dirname(__file__), 'locales') + +@lru_cache(maxsize=None) +def get_translation(lang_code: str) -> gettext.NullTranslations: + """ + Load and cache translations for the given language + """ + translations = gettext.translation( + 'messages', + localedir=LOCALE_DIR, + languages=[lang_code], + fallback=True + ) + return translations + +def get_locale_from_request(request: Request) -> str: + """ + Determine the best language based on: + 1. URL parameter (e.g., '?lang=de') + 2. User session + 3. Accept-Language header + 4. Default language + """ + # Check URL parameter + lang_param = request.query_params.get('lang') + if lang_param in SUPPORTED_LANGUAGES: + return lang_param + + # Check session + session = request.session.get('language') + if session in SUPPORTED_LANGUAGES: + return session + + # Check Accept-Language header + accept_language = request.headers.get('accept-language', '') + if accept_language: + for lang in accept_language.split(','): + lang_code = lang.split(';')[0].strip().lower() + lang_code = lang_code.split('-')[0] # Convert 'en-US' to 'en' + if lang_code in SUPPORTED_LANGUAGES: + return lang_code + + return DEFAULT_LANGUAGE + +def get_translator(locale: str = Depends(get_locale_from_request)): + """ + Return a FastAPI dependency that provides the translation function + """ + translations = get_translation(locale) + gettext_func = translations.gettext + + # Make the gettext function available with both _ and gettext names + return { + "_": gettext_func, + "gettext": gettext_func, + "locale": locale + } diff --git a/app/i18n/locales/de/LC_MESSAGES/messages.po b/app/i18n/locales/de/LC_MESSAGES/messages.po new file mode 100644 index 0000000..be5b2e9 --- /dev/null +++ b/app/i18n/locales/de/LC_MESSAGES/messages.po @@ -0,0 +1,108 @@ +# German translations for LeagueLedger. +# Copyright (C) 2025 Christian Krakau-Louis +# This file is distributed under the same license as the LeagueLedger project. +msgid "" +msgstr "" +"Project-Id-Version: LeagueLedger 1.0\n" +"Report-Msgid-Bugs-To: leagueledger@kaufdeinquiz.com\n" +"POT-Creation-Date: 2025-04-15 12:00+0200\n" +"PO-Revision-Date: 2025-04-15 12:00+0200\n" +"Last-Translator: Christian Krakau-Louis \n" +"Language-Team: German\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: app/templates/base.html:43 +msgid "Home" +msgstr "Startseite" + +#: app/templates/base.html:44 +msgid "Teams" +msgstr "Teams" + +#: app/templates/base.html:45 +msgid "Leaderboard" +msgstr "Bestenliste" + +#: app/templates/base.html:46 +msgid "Scan QR Code" +msgstr "QR-Code scannen" + +#: app/templates/base.html:55 +msgid "Profile" +msgstr "Profil" + +#: app/templates/base.html:58 +msgid "Dashboard" +msgstr "Dashboard" + +#: app/templates/base.html:61 +msgid "Admin" +msgstr "Admin" + +#: app/templates/base.html:66 +msgid "Logout" +msgstr "Abmelden" + +#: app/templates/base.html:71 +msgid "Sign In" +msgstr "Anmelden" + +#: app/templates/base.html:149 +msgid "Track your pub quiz team's progress." +msgstr "Verfolgen Sie den Fortschritt Ihres Quiz-Teams." + +#: app/templates/base.html:149 +msgid "Scan QR codes to earn points." +msgstr "Scannen Sie QR-Codes, um Punkte zu sammeln." + +#: app/templates/base.html:155 +msgid "Navigation" +msgstr "Navigation" + +#: app/templates/base.html:167 +msgid "Account" +msgstr "Konto" + +#: app/templates/base.html:169 +msgid "Register" +msgstr "Registrieren" + +#: app/templates/base.html:179 +msgid "Legal" +msgstr "Rechtliches" + +#: app/templates/base.html:181 +msgid "About" +msgstr "Über uns" + +#: app/templates/base.html:182 +msgid "Contact" +msgstr "Kontakt" + +#: app/templates/base.html:183 +msgid "Terms of Service" +msgstr "Nutzungsbedingungen" + +#: app/templates/base.html:184 +msgid "Privacy Policy" +msgstr "Datenschutz" + +#: app/templates/base.html:185 +msgid "Cookie Policy" +msgstr "Cookie-Richtlinie" + +#: app/templates/base.html:186 +msgid "Imprint" +msgstr "Impressum" + +#: app/templates/base.html:196 +msgid "Licensed under Apache License 2.0" +msgstr "Lizenziert unter der Apache License 2.0" + +#: app/templates/about.html:4 +msgid "About LeagueLedger" +msgstr "Über LeagueLedger" diff --git a/app/i18n/locales/en/LC_MESSAGES/messages.po b/app/i18n/locales/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..19cb586 --- /dev/null +++ b/app/i18n/locales/en/LC_MESSAGES/messages.po @@ -0,0 +1,108 @@ +# English translations for LeagueLedger. +# Copyright (C) 2025 Christian Krakau-Louis +# This file is distributed under the same license as the LeagueLedger project. +msgid "" +msgstr "" +"Project-Id-Version: LeagueLedger 1.0\n" +"Report-Msgid-Bugs-To: leagueledger@kaufdeinquiz.com\n" +"POT-Creation-Date: 2025-04-15 12:00+0200\n" +"PO-Revision-Date: 2025-04-15 12:00+0200\n" +"Last-Translator: Christian Krakau-Louis \n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: app/templates/base.html:43 +msgid "Home" +msgstr "Home" + +#: app/templates/base.html:44 +msgid "Teams" +msgstr "Teams" + +#: app/templates/base.html:45 +msgid "Leaderboard" +msgstr "Leaderboard" + +#: app/templates/base.html:46 +msgid "Scan QR Code" +msgstr "Scan QR Code" + +#: app/templates/base.html:55 +msgid "Profile" +msgstr "Profile" + +#: app/templates/base.html:58 +msgid "Dashboard" +msgstr "Dashboard" + +#: app/templates/base.html:61 +msgid "Admin" +msgstr "Admin" + +#: app/templates/base.html:66 +msgid "Logout" +msgstr "Logout" + +#: app/templates/base.html:71 +msgid "Sign In" +msgstr "Sign In" + +#: app/templates/base.html:149 +msgid "Track your pub quiz team's progress." +msgstr "Track your pub quiz team's progress." + +#: app/templates/base.html:149 +msgid "Scan QR codes to earn points." +msgstr "Scan QR codes to earn points." + +#: app/templates/base.html:155 +msgid "Navigation" +msgstr "Navigation" + +#: app/templates/base.html:167 +msgid "Account" +msgstr "Account" + +#: app/templates/base.html:169 +msgid "Register" +msgstr "Register" + +#: app/templates/base.html:179 +msgid "Legal" +msgstr "Legal" + +#: app/templates/base.html:181 +msgid "About" +msgstr "About" + +#: app/templates/base.html:182 +msgid "Contact" +msgstr "Contact" + +#: app/templates/base.html:183 +msgid "Terms of Service" +msgstr "Terms of Service" + +#: app/templates/base.html:184 +msgid "Privacy Policy" +msgstr "Privacy Policy" + +#: app/templates/base.html:185 +msgid "Cookie Policy" +msgstr "Cookie Policy" + +#: app/templates/base.html:186 +msgid "Imprint" +msgstr "Imprint" + +#: app/templates/base.html:196 +msgid "Licensed under Apache License 2.0" +msgstr "Licensed under Apache License 2.0" + +#: app/templates/about.html:4 +msgid "About LeagueLedger" +msgstr "About LeagueLedger" diff --git a/app/main.py b/app/main.py index 171945d..a183627 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 -from fastapi import FastAPI, Request, status +from fastapi import FastAPI, Request, Depends, Form +from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, RedirectResponse -from datetime import datetime +from fastapi.templating import Jinja2Templates +from pathlib import Path import os +from starlette.middleware.sessions import SessionMiddleware from .db import init_db, engine from . import models from .templates_config import templates -from .views import qr, redeem, teams, admin, leaderboard, dashboard +from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages from .db_init import seed_db +from app.i18n import get_translator, SUPPORTED_LANGUAGES, _ # Ensure `_` is correctly imported # Create tables on startup init_db() @@ -17,7 +21,22 @@ init_db() # In a production app, you would handle this differently seed_db() -app = FastAPI() +# Create the FastAPI application +app = FastAPI(title="LeagueLedger") + +# Add SessionMiddleware with a secure secret key +app.add_middleware(SessionMiddleware, secret_key="your-very-secret-session-key") + +# Mount static files +app.mount("/static", StaticFiles(directory="app/static"), name="static") + +# Configure static files +static.configure_static_files(app) + +# Setup Jinja2 templates +templates = Jinja2Templates(directory="app/templates") +templates.env.globals["SUPPORTED_LANGUAGES"] = SUPPORTED_LANGUAGES +templates.env.globals["_"] = _ # User context middleware to make template globals available @app.middleware("http") @@ -33,41 +52,46 @@ async def add_template_globals(request: Request, call_next): response = await call_next(request) return response -# Routers - auth router removed +# Add translation context processor to Jinja templates +@app.middleware("http") +async def add_translation_context(request: Request, call_next): + response = await call_next(request) + return response + +@app.get("/", response_class=HTMLResponse) +async def read_root(request: Request, i18n: dict = Depends(get_translator)): + return templates.TemplateResponse( + "index.html", + {"request": request, "user": None, **i18n} + ) + +@app.api_route("/set-language/{language_code}", methods=["GET", "POST"]) +async def set_language(request: Request, language_code: str): + if request.method == "POST": + form = await request.form() + language = form.get("language", "en") + else: + language = language_code + + if language in SUPPORTED_LANGUAGES: + request.session["language"] = language + response = RedirectResponse(url=request.headers.get("referer", "/")) + response.set_cookie(key="language", value=language, max_age=31536000) # 1 year + return response + + return {"message": f"Invalid language code: {language}"} + +# Routers +app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages app.include_router(qr.router, prefix="/qr", tags=["QR"]) app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"]) app.include_router(teams.router, prefix="/teams", tags=["Teams"]) app.include_router(admin.router, prefix="/admin", tags=["Admin"]) app.include_router(leaderboard.router, prefix="/leaderboard", tags=["Leaderboard"]) app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"]) +app.include_router(static.router, tags=["Static"]) # Include the static router -@app.get("/", response_class=HTMLResponse) -def index(request: Request): - return templates.TemplateResponse("index.html", { - "request": request, - "now": datetime.now - }) - -@app.get("/about", response_class=HTMLResponse) -def about(request: Request): - return templates.TemplateResponse("about.html", { - "request": request - }) - -@app.get("/contact", response_class=HTMLResponse) -def contact(request: Request): - return templates.TemplateResponse("contact.html", { - "request": request - }) - -@app.get("/privacy", response_class=HTMLResponse) -def privacy(request: Request): - return templates.TemplateResponse("privacy.html", { - "request": request - }) - -@app.get("/terms", response_class=HTMLResponse) -def terms(request: Request): - return templates.TemplateResponse("terms.html", { - "request": request - }) +# Add a direct route for /scan that redirects to /redeem/scan +@app.get("/scan") +async def scan_redirect(): + return RedirectResponse("/redeem/scan", status_code=303) diff --git a/app/models.py b/app/models.py index b0e4fa4..6a7a3e5 100644 --- a/app/models.py +++ b/app/models.py @@ -81,21 +81,56 @@ class TeamMember(Base): team = relationship("Team", back_populates="members") -class QRTicket(Base): - __tablename__ = "qr_tickets" +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) - code = Column(String(128), unique=True, index=True) # Unique token - points = Column(Integer, default=0) + 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 + qr_codes = relationship("QRCode", back_populates="qr_set") + creator = relationship("User") + + +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) + 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 + qr_set_id = Column(Integer, ForeignKey("qr_sets.id"), nullable=True) + qr_set = relationship("QRSet", back_populates="qr_codes") + + # Achievement relationship + achievement_name = Column(String(255), nullable=True) # Name of achievement this QR code grants + is_achievement_only = Column(Boolean, default=False) # True for QR codes that grant achievements but no points + + # Redemption info redeemed_by = Column(Integer, ForeignKey("users.id"), nullable=True) redeemed_at_team = Column(Integer, ForeignKey("teams.id"), nullable=True) + redeemed_at = Column(DateTime, nullable=True) used = Column(Boolean, default=False) - # Add timestamps to track when tickets were created and redeemed + # Extended functionality + max_uses = Column(Integer, nullable=True) # null = single use, >1 for multi-use codes created_at = Column(DateTime, server_default=func.now()) - redeemed_at = Column(DateTime, nullable=True) + expires_at = Column(DateTime, nullable=True) # null = never expires - # Add event name to track which quiz event this ticket belongs to - event_name = Column(String(255), nullable=True) + # Event tracking + event_id = Column(Integer, ForeignKey("events.id"), nullable=True) + event = relationship("Event") + + def __repr__(self): + if self.title: + return f"QR Code: {self.title} ({self.points} points)" + return f"QR Code: {self.points} points" class TeamAchievement(Base): @@ -103,11 +138,15 @@ class TeamAchievement(Base): id = Column(Integer, primary_key=True, index=True) team_id = Column(Integer, ForeignKey("teams.id")) name = Column(String(255), nullable=False) # e.g., "1st Place" - event_name = Column(String(255), nullable=True) # e.g., "History Night" + event_id = Column(Integer, ForeignKey("events.id"), nullable=True) description = Column(Text, nullable=True) achieved_at = Column(DateTime, server_default=func.now()) + qr_code_id = Column(Integer, ForeignKey("qr_codes.id"), nullable=True) + # Relationships team = relationship("Team") + event = relationship("Event") + qr_code = relationship("QRCode") class Event(Base): @@ -146,30 +185,3 @@ class UserPoints(Base): # Relationships user = relationship("User", back_populates="points") - - -class QRCode(Base): - __tablename__ = "qr_codes" - id = Column(Integer, primary_key=True, index=True) - code = Column(String(100), unique=True, index=True, nullable=False) - points = Column(Float, default=1.0, nullable=False) - description = Column(String(200), nullable=True) - is_active = Column(Boolean, default=True) - max_uses = Column(Integer, nullable=True) # null = unlimited - created_at = Column(DateTime, server_default=func.now()) - expires_at = Column(DateTime, nullable=True) # null = never expires - - # Relationships - redemptions = relationship("QRCodeRedemption", back_populates="qr_code") - - -class QRCodeRedemption(Base): - __tablename__ = "qr_code_redemptions" - id = Column(Integer, primary_key=True, index=True) - qr_code_id = Column(Integer, ForeignKey("qr_codes.id"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - redeemed_at = Column(DateTime, server_default=func.now()) - - # Relationships - qr_code = relationship("QRCode", back_populates="redemptions") - user = relationship("User") diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..5c9e04b --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.88.0 +uvicorn==0.20.0 +jinja2==3.1.2 +babel==2.11.0 +python-gettext==5.0 +aiofiles==22.1.0 +python-multipart==0.0.5 +pydantic==1.10.2 diff --git a/app/static/css/styles.css b/app/static/css/styles.css new file mode 100644 index 0000000..f6e753c --- /dev/null +++ b/app/static/css/styles.css @@ -0,0 +1,158 @@ +/* + * LeagueLedger Main Stylesheet + * Version: 1.0.0 + * Date: April 13, 2025 + */ + +:root { + /* LeagueLedger brand color palette */ + --irish-green: #006837; + --golden-ale: #FFB400; + --cream-white: #F5F0E1; + --black-stout: #1A1A1A; + --guinness-red: #B22222; + + /* UI colors */ + --success: #28a745; + --danger: #dc3545; + --warning: #ffc107; + --info: #17a2b8; +} + +/* Base styles */ +body { + font-family: 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: var(--black-stout); + background-color: #f8f9fa; +} + +h1, h2, h3, h4, h5, h6 { + font-family: 'Garamond', 'Georgia', serif; + color: var(--irish-green); +} + +/* Custom button styles */ +.btn-irish { + background-color: var(--irish-green); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + transition: opacity 0.2s ease; +} + +.btn-irish:hover { + opacity: 0.9; +} + +.btn-ale { + background-color: var(--golden-ale); + color: var(--black-stout); + border: none; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + transition: opacity 0.2s ease; +} + +.btn-ale:hover { + opacity: 0.9; +} + +/* QR Code styles */ +.qr-container { + padding: 1rem; + background-color: white; + border-radius: 0.5rem; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + display: inline-block; +} + +.qr-title { + margin-top: 0.5rem; + font-weight: bold; + color: var(--irish-green); + text-align: center; +} + +.qr-points { + font-size: 0.9rem; + color: var(--golden-ale); + font-weight: bold; + text-align: center; +} + +/* Leaderboard styles */ +.leaderboard-table { + width: 100%; + border-collapse: collapse; +} + +.leaderboard-table th { + background-color: var(--irish-green); + color: white; + padding: 0.75rem; + text-align: left; +} + +.leaderboard-table tr:nth-child(even) { + background-color: var(--cream-white); +} + +.leaderboard-table td { + padding: 0.75rem; + border-bottom: 1px solid #e2e8f0; +} + +/* Rank badges */ +.rank-badge { + display: inline-block; + width: 2rem; + height: 2rem; + line-height: 2rem; + text-align: center; + border-radius: 50%; + font-weight: bold; +} + +.rank-1 { + background-color: gold; + color: var(--black-stout); +} + +.rank-2 { + background-color: silver; + color: var(--black-stout); +} + +.rank-3 { + background-color: #cd7f32; /* bronze */ + color: white; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .leaderboard-table thead { + display: none; + } + + .leaderboard-table tr { + display: block; + margin-bottom: 1rem; + border: 1px solid #e2e8f0; + border-radius: 0.25rem; + } + + .leaderboard-table td { + display: block; + text-align: right; + padding: 0.5rem; + } + + .leaderboard-table td::before { + content: attr(data-label); + float: left; + font-weight: bold; + color: var(--irish-green); + } +} \ No newline at end of file diff --git a/app/static/images/favicon/android-chrome-192x192.png b/app/static/images/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..055b874 Binary files /dev/null and b/app/static/images/favicon/android-chrome-192x192.png differ diff --git a/app/static/images/favicon/android-chrome-512x512.png b/app/static/images/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..1cb8926 Binary files /dev/null and b/app/static/images/favicon/android-chrome-512x512.png differ diff --git a/app/static/images/favicon/apple-touch-icon.png b/app/static/images/favicon/apple-touch-icon.png new file mode 100644 index 0000000..62b3735 Binary files /dev/null and b/app/static/images/favicon/apple-touch-icon.png differ diff --git a/app/static/images/favicon/favicon-16x16.png b/app/static/images/favicon/favicon-16x16.png new file mode 100644 index 0000000..b78ebf1 Binary files /dev/null and b/app/static/images/favicon/favicon-16x16.png differ diff --git a/app/static/images/favicon/favicon-32x32.png b/app/static/images/favicon/favicon-32x32.png new file mode 100644 index 0000000..6c6d658 Binary files /dev/null and b/app/static/images/favicon/favicon-32x32.png differ diff --git a/app/static/images/favicon/favicon.ico b/app/static/images/favicon/favicon.ico new file mode 100644 index 0000000..817f9b6 Binary files /dev/null and b/app/static/images/favicon/favicon.ico differ diff --git a/app/static/images/favicon/site.webmanifest b/app/static/images/favicon/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/app/static/images/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/app/static/images/logos/emblem.png b/app/static/images/logos/emblem.png new file mode 100644 index 0000000..b0ce128 Binary files /dev/null and b/app/static/images/logos/emblem.png differ diff --git a/app/static/images/logos/logotype.png b/app/static/images/logos/logotype.png new file mode 100644 index 0000000..e54fb36 Binary files /dev/null and b/app/static/images/logos/logotype.png differ diff --git a/app/static/images/logos/monogram.png b/app/static/images/logos/monogram.png new file mode 100644 index 0000000..8d2e2b7 Binary files /dev/null and b/app/static/images/logos/monogram.png differ diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..bd68bfd --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,191 @@ +/** + * LeagueLedger Main JavaScript + * Version: 1.0.0 + * Date: April 13, 2025 + */ + +// Initialize all components when document is ready +document.addEventListener('DOMContentLoaded', () => { + initializeQrScanner(); + setupModalHandlers(); + initializeTooltips(); + initializeDropdowns(); +}); + +/** + * QR Code Scanner initialization + */ +function initializeQrScanner() { + const scannerContainer = document.getElementById('qr-reader'); + + if (!scannerContainer) return; // Exit if scanner container doesn't exist + + // Check if camera access is available + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + // QR Scanner library configuration + const html5QrCode = new Html5Qrcode("qr-reader"); + const config = { fps: 10, qrbox: 250 }; + + // Remove overlay once scanner is ready + const overlay = document.getElementById('scanner-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + + // Start scanner + html5QrCode.start( + { facingMode: "environment" }, // Use rear camera + config, + onScanSuccess, + onScanFailure + ); + + // Save scanner instance for later use + window.qrScanner = html5QrCode; + } else { + // Camera unavailable, show manual entry form + const manualEntry = document.getElementById('manual-entry-container'); + if (manualEntry) { + manualEntry.style.display = 'block'; + } + + const scannerElement = document.getElementById('scanner-container'); + if (scannerElement) { + scannerElement.style.display = 'none'; + } + + console.warn("Camera access not available"); + } +} + +/** + * Handle successful QR code scan + */ +function onScanSuccess(decodedText) { + // Stop scanner once code is detected + if (window.qrScanner) { + window.qrScanner.stop(); + } + + // Extract code from URL if present + let code = decodedText; + if (decodedText.includes('/redeem/')) { + code = decodedText.split('/redeem/').pop(); + } + + // Redirect to redemption page + window.location.href = '/redeem/' + code; +} + +/** + * Handle QR scan errors + */ +function onScanFailure(error) { + // We don't need to show errors for normal operation + console.debug("QR scan error: " + error); +} + +/** + * Initialize modal handlers + */ +function setupModalHandlers() { + // Find all elements meant to open modals + const modalOpeners = document.querySelectorAll('[data-modal-target]'); + const modalClosers = document.querySelectorAll('[data-modal-close]'); + + modalOpeners.forEach(opener => { + opener.addEventListener('click', (e) => { + e.preventDefault(); + const modalId = opener.getAttribute('data-modal-target'); + const modal = document.getElementById(modalId); + + if (modal) { + modal.classList.remove('hidden'); + } + }); + }); + + modalClosers.forEach(closer => { + closer.addEventListener('click', (e) => { + e.preventDefault(); + const modal = closer.closest('.modal'); + + if (modal) { + modal.classList.add('hidden'); + } + }); + }); + + // Close modal when clicking outside + document.addEventListener('click', (e) => { + const modals = document.querySelectorAll('.modal:not(.hidden)'); + modals.forEach(modal => { + if (e.target === modal) { + modal.classList.add('hidden'); + } + }); + }); +} + +/** + * Initialize tooltips + */ +function initializeTooltips() { + const tooltips = document.querySelectorAll('[data-tooltip]'); + + tooltips.forEach(tooltip => { + tooltip.addEventListener('mouseenter', (e) => { + const text = tooltip.getAttribute('data-tooltip'); + + // Create tooltip element + const tooltipEl = document.createElement('div'); + tooltipEl.classList.add('tooltip'); + tooltipEl.textContent = text; + + // Position the tooltip + const rect = tooltip.getBoundingClientRect(); + tooltipEl.style.top = (rect.top - 30) + 'px'; + tooltipEl.style.left = (rect.left + rect.width/2) + 'px'; + + // Add to DOM + document.body.appendChild(tooltipEl); + + // Save reference to remove it later + tooltip._tooltipElement = tooltipEl; + }); + + tooltip.addEventListener('mouseleave', () => { + if (tooltip._tooltipElement) { + tooltip._tooltipElement.remove(); + tooltip._tooltipElement = null; + } + }); + }); +} + +/** + * Initialize dropdown menus + */ +function initializeDropdowns() { + const dropdowns = document.querySelectorAll('.dropdown-toggle'); + + dropdowns.forEach(dropdown => { + dropdown.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const menu = dropdown.nextElementSibling; + if (menu && menu.classList.contains('dropdown-menu')) { + menu.classList.toggle('hidden'); + } + }); + }); + + // Close dropdowns when clicking elsewhere + document.addEventListener('click', () => { + const openDropdowns = document.querySelectorAll('.dropdown-menu:not(.hidden)'); + openDropdowns.forEach(menu => { + menu.classList.add('hidden'); + }); + }); +} \ No newline at end of file diff --git a/app/templates/about.html b/app/templates/about.html index 5bf701d..ac15e6d 100644 --- a/app/templates/about.html +++ b/app/templates/about.html @@ -1,23 +1,61 @@ {% extends "base.html" %} {% block content %}
-

About LeagueLedger

+

{{ _("About LeagueLedger") }}

+ {% if locale == "de" %} + LeagueLedger ist Ihr ultimativer Begleiter für die Verfolgung von Pub-Quiz-Team-Erfolgen. Wir möchten eine unterhaltsame und ansprechende Plattform für Quiz-Enthusiasten bieten, um sich zu vernetzen, zu wetteifern und ihr Wissen zu feiern. + {% else %} LeagueLedger is your ultimate companion for tracking pub quiz team achievements. We aim to provide a fun and engaging platform for quiz enthusiasts to connect, compete, and celebrate their knowledge. + {% endif %}

+ {% if locale == "de" %} + Als Pub-Quiz-Meister können Sie QR-Codes für Ihre bestplatzierten Teams generieren, diese verteilen und Teams diese auf unserer Website für Punkte einlösen lassen. + {% else %} As a pub quiz master, you can generate printout QR codes for your top-ranking teams, distribute them, and let teams redeem them for points on our website. + {% endif %}

+ {% if locale == "de" %} + Als Pub-Quiz-Teammitglied können Sie QR-Codes einlösen, einen Teamnamen erstellen, andere Mitglieder einladen und Social-Logins für einen einfachen Zugang nutzen. + {% else %} As a pub quiz team member, you can redeem QR codes, create a team name, invite other members, and use social logins for easy access. + {% endif %}

-

Our Mission

+

+ {% if locale == "de" %}Unsere Mission{% else %}Our Mission{% endif %} +

+ {% if locale == "de" %} + Die Pub-Quiz-Erfahrung zu verbessern, indem wir eine nahtlose und intuitive Plattform für die Verfolgung des Teamfortschritts bieten, freundlichen Wettbewerb fördern und den Geist des Quiz feiern. + {% else %} To enhance the pub quiz experience by providing a seamless and intuitive platform for tracking team progress, fostering friendly competition, and celebrating the spirit of trivia. + {% endif %}

-

Our Team

+

+ {% if locale == "de" %}Unser Team{% else %}Our Team{% endif %} +

- LeagueLedger is an Open-Source initiative and part of the KaufDeinQuiz platform. It is brought to you by Christian Louis IT Beratung und Medienproduktion, a team of dedicated quiz enthusiasts and software developers passionate about creating innovative solutions for the pub quiz community. + {% if locale == "de" %} + LeagueLedger ist eine Open-Source-Initiative und Teil der KaufDeinQuiz-Plattform. Es wird von Christian Louis IT Beratung und Medienproduktion entwickelt, unter der Leitung von Christian Krakau-Louis, einem Team von engagierten Quiz-Enthusiasten und Softwareentwicklern, die leidenschaftlich daran arbeiten, innovative Lösungen für die Pub-Quiz-Community zu schaffen. + {% else %} + LeagueLedger is an Open-Source initiative and part of the KaufDeinQuiz platform. It is brought to you by Christian Louis IT Beratung und Medienproduktion, led by Christian Krakau-Louis, a team of dedicated quiz enthusiasts and software developers passionate about creating innovative solutions for the pub quiz community. + {% endif %} +

+

+ {% if locale == "de" %}Lizenz{% else %}License{% endif %} +

+

+ {% if locale == "de" %} + LeagueLedger ist unter der Apache License 2.0 lizenziert. Die vollständige Lizenz finden Sie in unserer GitHub-Repository oder auf Anfrage. + {% else %} + LeagueLedger is licensed under the Apache License 2.0. You can find the full license in our GitHub repository or upon request. + {% endif %} +

+ +

+ {% if locale == "de" %}Letzte Aktualisierung: April 2025{% else %}Last updated: April 2025{% endif %}

{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html index a60128f..8df7993 100644 --- a/app/templates/admin/index.html +++ b/app/templates/admin/index.html @@ -22,7 +22,10 @@

Quick Actions

-
+
+ + QR Code Dashboard + Generate QR Code (10 points) diff --git a/app/templates/base.html b/app/templates/base.html index a24f7a4..9fa2674 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,151 +1,256 @@ - + - - - LeagueLedger - - - - - - - + - + + + + + + + + + {% block extra_head %}{% endblock %} - -
-
- -
-
- LeagueLedger Logo -

LeagueLedger

+ + +
- - - -
-
- - -
- {% block content %}{% endblock %} -
- - - - - + + + + + + + + {% block extra_scripts %}{% endblock %} - + \ No newline at end of file diff --git a/app/templates/contact.html b/app/templates/contact.html index 6d7c70a..b8845ff 100644 --- a/app/templates/contact.html +++ b/app/templates/contact.html @@ -1,23 +1,48 @@ {% extends "base.html" %} {% block content %}
-

Contact Us

+

Kontakt

- Have questions, suggestions, or feedback? We'd love to hear from you! + Haben Sie Fragen, Anregungen oder Feedback? Wir freuen uns, von Ihnen zu hören!

-

Contact Information

+

Kontaktinformationen

Christian Louis IT Beratung

Alter Steinweg 3

20459 Hamburg

Deutschland

-

Phone: +49 179 5183732

-

Email: quizarium@kaufdeinquiz.com

+

Telefon: +49 179 5183732

+

E-Mail:

Fax: +49 40 97074609

-

Connect With Us

-

This project is part of the KaufDeinQuiz platform and is operated as an Open-Source initiative. All rights reserved.

+

Verbinden Sie sich mit uns

+

Dieses Projekt ist Teil der KaufDeinQuiz-Plattform und wird als Open-Source-Initiative betrieben. Lizenziert unter der Apache License 2.0.

+
+

Verantwortlicher gemäß § 5 TMG

+

Christian Krakau-Louis

+

Christian Louis IT Beratung und Medienproduktion

+

Alter Steinweg 3

+

20459 Hamburg

+
+
+

Umsatzsteuer-ID

+

Umsatzsteuer-Identifikationsnummer gemäß §27a Umsatzsteuergesetz:

+

DE202899017

+
+
+

Steuernummer

+

48/148/00526

+
+
+

Streitschlichtung

+

Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/

+

Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.

+
+ +

+ Letzte Aktualisierung: April 2025 +

{% endblock %} diff --git a/app/templates/cookies.html b/app/templates/cookies.html new file mode 100644 index 0000000..32f76b3 --- /dev/null +++ b/app/templates/cookies.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block content %} +
+

Cookie-Richtlinie

+

+ Diese Cookie-Richtlinie erläutert, wie LeagueLedger Cookies und ähnliche Technologien verwendet. +

+

Was sind Cookies?

+

+ Cookies sind kleine Textdateien, die auf Ihrem Gerät gespeichert werden, wenn Sie eine Website besuchen. Sie werden weithin verwendet, um Websites funktionsfähig zu machen oder effizienter zu arbeiten, sowie um Informationen für die Website-Betreiber bereitzustellen. +

+

Welche Cookies wir verwenden

+

+ Wir verwenden die folgenden Arten von Cookies: +

+
    +
  • Notwendige Cookies: Diese sind für die Grundfunktionen der Website erforderlich und können nicht deaktiviert werden.
  • +
  • Funktionscookies: Diese ermöglichen erweiterte Funktionen und Personalisierung.
  • +
  • Analyse-Cookies: Diese helfen uns zu verstehen, wie Besucher mit unserer Website interagieren.
  • +
+

Cookie-Verwaltung

+

+ Sie können Ihre Cookie-Einstellungen jederzeit ändern, indem Sie die Einstellungen Ihres Browsers anpassen. Beachten Sie, dass das Blockieren einiger Cookies die Funktionalität unserer Website beeinträchtigen kann. +

+

Detaillierte Cookie-Liste

+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameZweckAblaufzeit
sessionSpeichert Ihre SitzungsinformationenSitzung
auth_tokenAuthentifizierung30 Tage
cookie_consentSpeichert Ihre Cookie-Präferenzen1 Jahr
+

Änderungen dieser Cookie-Richtlinie

+

+ Wir behalten uns das Recht vor, diese Cookie-Richtlinie jederzeit zu ändern. Die aktuelle Version ist stets auf dieser Seite verfügbar. +

+

Kontakt

+

+ Wenn Sie Fragen zu unserer Cookie-Richtlinie haben, kontaktieren Sie uns bitte unter . +

+

+ Letzte Aktualisierung: April 2025 +

+
+{% endblock %} diff --git a/app/templates/impressum.html b/app/templates/impressum.html new file mode 100644 index 0000000..611ee28 --- /dev/null +++ b/app/templates/impressum.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block content %} +
+

Impressum

+ +

Angaben gemäß § 5 TMG

+

Christian Louis IT Beratung

+

Alter Steinweg 3

+

20459 Hamburg

+

Deutschland

+ +

Vertreten durch

+

Christian Krakau-Louis

+ +

Kontakt

+

Telefon: +49 179 5183732

+

E-Mail:

+

Fax: +49 40 97074609

+ +

Umsatzsteuer-ID

+

Umsatzsteuer-Identifikationsnummer gemäß §27a Umsatzsteuergesetz:

+

DE202899017

+ +

Steuernummer

+

48/148/00526

+ +

Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV

+

Christian Krakau-Louis

+

Alter Steinweg 3

+

20459 Hamburg

+ +

Streitschlichtung

+

Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/.

+

Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.

+ +

Haftung für Inhalte

+

+ Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +

+

+ Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. +

+ +

Haftung für Links

+

+ Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. +

+

+ Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen. +

+ +

Urheberrecht

+

+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. +

+

+ Dieses Projekt ist Teil der Plattform KaufDeinQuiz und wird als Open-Source-Initiative betrieben. Der Quellcode dieser Anwendung ist unter der Apache License 2.0 lizenziert. Details finden Sie in der LICENSE-Datei im Repository. +

+ +

+ Letzte Aktualisierung: April 2025 +

+
+{% endblock %} diff --git a/app/templates/privacy.html b/app/templates/privacy.html index ab2165c..1aa762f 100644 --- a/app/templates/privacy.html +++ b/app/templates/privacy.html @@ -1,32 +1,61 @@ {% extends "base.html" %} {% block content %}
-

Privacy Policy

+

Datenschutzerklärung

- Your privacy is important to us. This Privacy Policy outlines how LeagueLedger collects, uses, and protects your information. + Der Schutz Ihrer Daten ist uns wichtig. Diese Datenschutzerklärung erläutert, wie LeagueLedger Ihre Informationen sammelt, verwendet und schützt.

-

Information We Collect

-
    -
  • Email addresses
  • -
  • Usernames
  • -
  • Team names
  • -
  • Quiz scores and points
  • -
-

How We Use Your Information

+

Verantwortlicher im Sinne der DSGVO

- We use your information to: + Christian Krakau-Louis
+ Christian Louis IT Beratung und Medienproduktion
+ Alter Steinweg 3
+ 20459 Hamburg
+ Deutschland
+ E-Mail: +

+

Welche Daten wir sammeln

+
    +
  • E-Mail-Adressen
  • +
  • Benutzernamen
  • +
  • Team-Namen
  • +
  • Quiz-Ergebnisse und Punkte
  • +
+

Wie wir Ihre Daten verwenden

+

+ Wir verwenden Ihre Daten für folgende Zwecke:

    -
  • Track leaderboard rankings
  • -
  • Send notifications about team activities
  • -
  • Improve our services and user experience
  • +
  • Führen der Bestenliste
  • +
  • Senden von Benachrichtigungen über Team-Aktivitäten
  • +
  • Verbesserung unserer Dienste und Nutzererfahrung
-

Data Sharing

+

Rechtsgrundlage für die Verarbeitung

- We do not share your personal information with third parties except as required by law. + Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie zur Erfüllung des Vertrages (Art. 6 Abs. 1 lit. b DSGVO). +

+

Datenweitergabe

+

+ Wir geben Ihre personenbezogenen Daten nicht an Dritte weiter, außer wenn dies gesetzlich erforderlich ist. +

+

Ihre Rechte

+

+ Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch gemäß der DSGVO. +

+

Speicherdauer

+

+ Wir speichern Ihre personenbezogenen Daten nur so lange, wie es für die Zwecke, für die sie erhoben wurden, erforderlich ist oder gesetzlich vorgeschrieben ist. +

+

Beschwerderecht

+

+ Sie haben das Recht, sich bei einer Aufsichtsbehörde zu beschweren, wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer personenbezogenen Daten gegen die DSGVO verstößt.

- Christian Louis IT Beratung und Medienproduktion, as the operator of this platform, takes data privacy seriously and implements measures to protect your personal information. + Christian Louis IT Beratung und Medienproduktion als Betreiber dieser Plattform nimmt den Datenschutz ernst und implementiert Maßnahmen zum Schutz Ihrer personenbezogenen Daten. +

+ +

+ Letzte Aktualisierung: April 2025

{% endblock %} diff --git a/app/templates/qr/admin_link.html b/app/templates/qr/admin_link.html new file mode 100644 index 0000000..25e938f --- /dev/null +++ b/app/templates/qr/admin_link.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ +

Link QR Codes to Event

+

Administrator Access

+
+ +
+

QR Set: {{ qr_set.name }}

+

You are about to link all QR codes in this set to an event.

+
+ +
+ +
+ + +
+ + +
+
+
+
+
+ OR +
+
+ + +
+ + +
+ + + +
+
+ + +
+

What This Does

+
+

+ By linking QR codes to an event, you'll be able to track which achievements and points were earned + during specific quiz nights or events. +

+

+ This helps with reporting and allows teams to see which events they've participated in and what + awards they've received at each event. +

+

+ Note: This action only affects QR codes that haven't been redeemed yet. +

+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/qr/dashboard.html b/app/templates/qr/dashboard.html new file mode 100644 index 0000000..1db29fc --- /dev/null +++ b/app/templates/qr/dashboard.html @@ -0,0 +1,209 @@ +{% extends "base.html" %} +{% block content %} +
+
+

QR Code Management

+

Create and manage QR code sets for your pub quiz events

+
+ +
+ +
+

Create QR Set

+

+ Create a new set of QR codes for your pub quiz event with points and achievements. +

+ +
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+

Common Templates

+

+ Quickly create QR code sets using these pre-defined templates +

+ +
+
+

Standard Pub Quiz

+

1st, 2nd, 3rd and 4th place

+
+ +
+

Trivia Night

+

With category achievements

+
+ +
+

Weekly League

+

For ongoing competitions

+
+
+ +
+

+ Click a template to pre-fill the form. You can customize it before creating. +

+
+
+ + +
+

Quick Actions

+

+ Generate individual QR codes or access your created sets +

+ + + + + +
+
+ + +
+

Your QR Sets

+ + {% if qr_sets %} +
+ {% for qr_set in qr_sets %} +
+
+

{{ qr_set.name }}

+ {% if qr_set.description %} +

{{ qr_set.description }}

+ {% endif %} +
+
+
+

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

+

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

+
+ +
+
+ {% endfor %} +
+ {% else %} +
+ +

You haven't created any QR sets yet. Create your first set using the form above.

+
+ {% endif %} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/qr/set_detail.html b/app/templates/qr/set_detail.html new file mode 100644 index 0000000..40b4b3a --- /dev/null +++ b/app/templates/qr/set_detail.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ + Back to Dashboard + +

{{ qr_set.name }}

+
+ {% if qr_set.description %} +

{{ qr_set.description }}

+ {% endif %} +

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

+
+ +
+ +
+

Add QR Code

+

+ Create QR codes for points and achievements in this set +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+

Quick Templates

+

+ Click to quickly add common QR code types to this set +

+ +
+ + + + + + + +
+
+ + +
+

QR Set Actions

+

+ Create printable PDFs and admin codes for this set +

+ +
+ + Generate PDF + + +
+

Admin QR Code

+

+ Use this special QR code to link all codes in this set to an event. Print this on each page. +

+
+ Admin QR Code +
+

Scan to link all QR codes to an event

+
+ +
+

Danger Zone

+

These actions cannot be undone

+ +
+
+
+
+ + +
+

QR Codes in this Set

+ + {% if qr_codes %} +
+ + + + + + + + + + + + + {% for qr_code in qr_codes %} + + + + + + + + + {% endfor %} + +
+ QR Code + + Details + + Points + + Achievement + + Status + + Actions +
+ QR Code + +
{{ qr_code.title or "Untitled" }}
+
+ Code: {{ qr_code.code[:10] }}... +
+ {% if qr_code.description %} +
{{ qr_code.description }}
+ {% endif %} +
+ + {{ qr_code.points }} pts + + + {% if qr_code.achievement_name %} + + + {{ qr_code.achievement_name }} + + {% else %} + None + {% endif %} + + {% if qr_code.used %} + + Redeemed + + {% else %} + + Available + + {% endif %} + + + View + +
+
+ {% else %} +
+ +

No QR codes in this set yet. Add your first QR code using the form.

+
+ {% endif %} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/redeem_success.html b/app/templates/redeem_success.html index f581b91..34757f0 100644 --- a/app/templates/redeem_success.html +++ b/app/templates/redeem_success.html @@ -1,42 +1,104 @@ {% extends "base.html" %} {% block content %}
-
+ +
-
- +
+
+

Success!

+ + {% if achievement %} +

Your team has earned an achievement and points!

+ {% else %} +

Your team has earned points!

+ {% endif %}
-

Success!

-

You've earned {{ points }} points

-

Points have been added to {{ team.name }}

- -
-

What's next?

-
    -
  • Check your team's position on the leaderboard
  • -
  • Scan another QR code to earn more points
  • -
  • Invite friends to your team
  • -
+
+
+

Points Earned:

+

{{ points }}

+
+ + {% if achievement %} +
+

Achievement Unlocked:

+
+ +

{{ achievement }}

+
+
+ {% endif %} + +
+

Team:

+

{{ team.name }}

+
+ + {% if event %} +
+

Event:

+

{{ event.name }}

+

{{ event.event_date.strftime('%Y-%m-%d') }}

+
+ {% endif %}
- - + + + -
-

Share your achievement!

-
- - - + +
+

Share your achievement

+
diff --git a/app/templates/terms.html b/app/templates/terms.html index f615401..043462c 100644 --- a/app/templates/terms.html +++ b/app/templates/terms.html @@ -1,27 +1,39 @@ {% extends "base.html" %} {% block content %}
-

Terms of Service

+

Nutzungsbedingungen

- Welcome to LeagueLedger! By using our platform, you agree to comply with the following terms and conditions. + Willkommen bei LeagueLedger! Mit der Nutzung unserer Plattform erklären Sie sich mit den folgenden Bedingungen einverstanden.

-

Acceptable Use

+

Nutzungsregeln

    -
  • No cheating or unfair practices
  • -
  • Respectful communication with other users
  • -
  • Compliance with all applicable laws and regulations
  • +
  • Kein Betrug oder unfaire Praktiken
  • +
  • Respektvolle Kommunikation mit anderen Nutzern
  • +
  • Einhaltung aller anwendbaren Gesetze und Vorschriften
-

Liability Disclaimer

+

Haftungsausschluss

- LeagueLedger is provided "as is" without any warranties. We are not liable for any damages arising from your use of the platform. + LeagueLedger wird "wie besehen" ohne jegliche Garantie bereitgestellt. Wir haften nicht für Schäden, die aus Ihrer Nutzung der Plattform entstehen.

-

Governing Law

+

Geltendes Recht

- These terms shall be governed by and construed in accordance with the laws of Germany. + Diese Bedingungen unterliegen dem deutschen Recht und werden in Übereinstimmung mit diesem ausgelegt.

- This project is part of the KaufDeinQuiz platform and is operated as an Open-Source initiative. All rights reserved. + Dieses Projekt ist Teil der KaufDeinQuiz-Plattform und wird als Open-Source-Initiative betrieben. Lizenziert unter der Apache License 2.0. +

+

Widerrufsbelehrung

+

+ Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. +

+

Änderungen der Nutzungsbedingungen

+

+ Wir behalten uns das Recht vor, diese Nutzungsbedingungen jederzeit zu ändern. Die aktuelle Version ist stets auf dieser Seite verfügbar. +

+

Christian Louis IT Beratung und Medienproduktion ist verantwortlich für den Betrieb dieser Plattform.

+ +

+ Letzte Aktualisierung: April 2025

-

Christian Louis IT Beratung und Medienproduktion is responsible for the operation of this platform.

{% endblock %} diff --git a/app/views/__init__.py b/app/views/__init__.py new file mode 100644 index 0000000..e147c2d --- /dev/null +++ b/app/views/__init__.py @@ -0,0 +1 @@ +# Import your view modules here diff --git a/app/views/admin.py b/app/views/admin.py index 836a617..ae39915 100644 --- a/app/views/admin.py +++ b/app/views/admin.py @@ -11,7 +11,7 @@ from typing import Dict, Any, List, Type, Optional import inspect as py_inspect from ..db import SessionLocal, Base -from ..models import User, Team, TeamMembership, QRTicket +from ..models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event from ..templates_config import templates router = APIRouter() @@ -21,7 +21,10 @@ MODELS = { 'user': (User, "Users"), 'team': (Team, "Teams"), 'team_membership': (TeamMembership, "Team Memberships"), - 'qr_ticket': (QRTicket, "QR Tickets"), + 'qr_code': (QRCode, "QR Codes"), + 'qr_set': (QRSet, "QR Sets"), + 'team_achievement': (TeamAchievement, "Team Achievements"), + 'event': (Event, "Events"), } def get_db(): diff --git a/app/views/leaderboard.py b/app/views/leaderboard.py index 00e3a7c..2292282 100644 --- a/app/views/leaderboard.py +++ b/app/views/leaderboard.py @@ -9,7 +9,7 @@ from sqlalchemy import func, desc from datetime import datetime, timedelta from ..db import SessionLocal -from ..models import Team, TeamMembership, QRTicket +from ..models import Team, TeamMembership, QRCode from ..templates_config import templates router = APIRouter() @@ -45,20 +45,17 @@ async def show_leaderboard( query = db.query( Team.id, Team.name, - func.coalesce(func.sum(QRTicket.points), 0).label('total_points') + func.coalesce(func.sum(QRCode.points), 0).label('total_points') ).join( - QRTicket, - QRTicket.redeemed_at_team == Team.id, + QRCode, + QRCode.redeemed_at_team == Team.id, isouter=True ) # Apply time filter if needed if cutoff_date: - # Note: This assumes QRTicket has a created_at or similar timestamp field - # If not, you would need to add one to track when points were added - # For now, this is a placeholder that assumes all tickets are from "now" - # query = query.filter(QRTicket.created_at >= cutoff_date) - pass + # Filter by redeemed_at timestamp if available + query = query.filter(QRCode.redeemed_at >= cutoff_date) # Group and order teams_ranking = query.group_by(Team.id).order_by(desc('total_points')).all() diff --git a/app/views/pages.py b/app/views/pages.py new file mode 100644 index 0000000..3777192 --- /dev/null +++ b/app/views/pages.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" +Router for static content pages like about, contact, privacy, and terms. +""" +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from ..templates_config import templates +from datetime import datetime + +router = APIRouter() + +@router.get("/", response_class=HTMLResponse) +def index(request: Request): + """Home page.""" + return templates.TemplateResponse("index.html", { + "request": request, + "now": datetime.now + }) + +@router.get("/about", response_class=HTMLResponse) +def about(request: Request): + """About page.""" + return templates.TemplateResponse("about.html", { + "request": request + }) + +@router.get("/contact", response_class=HTMLResponse) +def contact(request: Request): + """Contact page.""" + return templates.TemplateResponse("contact.html", { + "request": request + }) + +@router.get("/privacy", response_class=HTMLResponse) +def privacy(request: Request): + """Privacy policy page.""" + return templates.TemplateResponse("privacy.html", { + "request": request + }) + +@router.get("/terms", response_class=HTMLResponse) +def terms(request: Request): + """Terms and conditions page.""" + return templates.TemplateResponse("terms.html", { + "request": request + }) diff --git a/app/views/qr.py b/app/views/qr.py index 3d19876..f168afa 100644 --- a/app/views/qr.py +++ b/app/views/qr.py @@ -4,15 +4,31 @@ Generate QR codes for top teams (quiz master). """ import qrcode import io -from fastapi import APIRouter, Depends -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session -from ..db import SessionLocal -from ..models import QRTicket import uuid +import os +from typing import List, Optional +from datetime import datetime +from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query +from fastapi.responses import StreamingResponse, HTMLResponse, FileResponse +from sqlalchemy.orm import Session +from reportlab.lib.pagesizes import A4 +from reportlab.lib import colors +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as ReportLabImage +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import inch, cm +from reportlab.lib.enums import TA_CENTER +from pydantic import BaseModel + +from ..db import SessionLocal +from ..models import QRCode, QRSet, Event +from ..templates_config import templates router = APIRouter() +# Get base URL from environment variable or use default +BASE_URL = os.environ.get("LEAGUELEDGER_BASE_URL", "https://rover.leagueledger.net") + + def get_db(): db = SessionLocal() try: @@ -20,22 +36,387 @@ def get_db(): finally: db.close() + +# Models for request validation +class QRSetRequest(BaseModel): + name: str + description: Optional[str] = None + + +class QRCodeRequest(BaseModel): + title: str + points: float + achievement_name: Optional[str] = None + is_achievement_only: bool = False + max_uses: Optional[int] = None + description: Optional[str] = None + + +@router.get("/", response_class=HTMLResponse) +async def qr_dashboard(request: Request, db: Session = Depends(get_db)): + """QR code management dashboard.""" + # Get all QR sets + qr_sets = db.query(QRSet).all() + + # Get all events for linking + events = db.query(Event).all() + + return templates.TemplateResponse("qr/dashboard.html", { + "request": request, + "qr_sets": qr_sets, + "events": events + }) + + +@router.post("/sets") +async def create_qr_set( + request: Request, + db: Session = Depends(get_db) +): + """Create a new QR set.""" + # Process form data + form_data = await request.form() + name = form_data.get("name") + description = form_data.get("description", "") + + if not name: + raise HTTPException(status_code=400, detail="Set name is required") + + # Create QR set + qr_set = QRSet(name=name, description=description) + db.add(qr_set) + db.commit() + db.refresh(qr_set) + + return {"id": qr_set.id, "name": qr_set.name, "message": f"QR Set '{name}' created successfully"} + + +@router.get("/sets/{set_id}", response_class=HTMLResponse) +async def view_qr_set(request: Request, set_id: int, db: Session = Depends(get_db)): + """View details of a QR set.""" + qr_set = db.query(QRSet).filter(QRSet.id == set_id).first() + if not qr_set: + raise HTTPException(status_code=404, detail="QR Set not found") + + # Get all QR codes in this set + qr_codes = db.query(QRCode).filter(QRCode.qr_set_id == set_id).all() + + return templates.TemplateResponse("qr/set_detail.html", { + "request": request, + "qr_set": qr_set, + "qr_codes": qr_codes, + "base_url": BASE_URL + }) + + +@router.post("/sets/{set_id}/codes") +async def add_qr_code_to_set( + request: Request, + set_id: int, + db: Session = Depends(get_db) +): + """Add a QR code to a set.""" + # Check if set exists + qr_set = db.query(QRSet).filter(QRSet.id == set_id).first() + if not qr_set: + raise HTTPException(status_code=404, detail="QR Set not found") + + # Process form data + form_data = await request.form() + title = form_data.get("title") + points = float(form_data.get("points", 0)) + achievement_name = form_data.get("achievement_name") + is_achievement_only = form_data.get("is_achievement_only", "").lower() in ("true", "yes", "1", "on") + description = form_data.get("description", "") + + # Generate a unique code + code_str = str(uuid.uuid4()) + + # Create QR code + qr_code = QRCode( + code=code_str, + title=title, + points=points, + qr_set_id=set_id, + achievement_name=achievement_name, + is_achievement_only=is_achievement_only, + description=description + ) + + db.add(qr_code) + db.commit() + db.refresh(qr_code) + + return {"id": qr_code.id, "code": code_str, "message": "QR Code added to set"} + + @router.get("/generate/{points}") def generate_qr(points: int, db: Session = Depends(get_db)): """ - Generate a 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. """ code_str = str(uuid.uuid4()) - ticket = QRTicket(code=code_str, points=points) - db.add(ticket) + qr_code = QRCode(code=code_str, points=points) + db.add(qr_code) db.commit() - db.refresh(ticket) + db.refresh(qr_code) - qr_img = qrcode.make(code_str) + qr_img = qrcode.make(f"{BASE_URL}/redeem/{code_str}") buf = io.BytesIO() qr_img.save(buf, format="PNG") buf.seek(0) return StreamingResponse(buf, media_type="image/png") + + +@router.get("/code/{code}") +def get_qr_image(code: str): + """ + Generate a QR code image from a code string without creating a database record. + Useful for viewing existing codes. + """ + qr_img = qrcode.make(f"{BASE_URL}/redeem/{code}") + buf = io.BytesIO() + qr_img.save(buf, format="PNG") + buf.seek(0) + + return StreamingResponse(buf, media_type="image/png") + + +@router.get("/sets/{set_id}/generate-admin") +def generate_admin_qr(set_id: int, db: Session = Depends(get_db)): + """Generate admin QR code for linking to events.""" + qr_set = db.query(QRSet).filter(QRSet.id == set_id).first() + if not qr_set: + raise HTTPException(status_code=404, detail="QR Set not found") + + # Create unique admin code + admin_code = f"admin-{set_id}-{uuid.uuid4()}" + + # Generate QR code with special admin URL + qr_img = qrcode.make(f"{BASE_URL}/qr/admin-link/{admin_code}") + buf = io.BytesIO() + qr_img.save(buf, format="PNG") + buf.seek(0) + + return StreamingResponse(buf, media_type="image/png", + headers={"Content-Disposition": f"inline; filename=admin-{set_id}.png"}) + + +@router.get("/admin-link/{admin_code}", response_class=HTMLResponse) +async def admin_link_page(request: Request, admin_code: str, db: Session = Depends(get_db)): + """Page for linking QR sets to events via admin code.""" + # Extract set_id from admin code + try: + set_id = int(admin_code.split("-")[1]) + except (IndexError, ValueError): + raise HTTPException(status_code=400, detail="Invalid admin code") + + qr_set = db.query(QRSet).filter(QRSet.id == set_id).first() + if not qr_set: + raise HTTPException(status_code=404, detail="QR Set not found") + + # Fetch available events + events = db.query(Event).all() + + return templates.TemplateResponse("qr/admin_link.html", { + "request": request, + "qr_set": qr_set, + "admin_code": admin_code, + "events": events + }) + + +@router.post("/admin-link/{admin_code}") +async def process_admin_link( + request: Request, + admin_code: str, + db: Session = Depends(get_db) +): + """Process linking QR codes to an event.""" + # Extract set_id from admin code + try: + set_id = int(admin_code.split("-")[1]) + except (IndexError, ValueError): + raise HTTPException(status_code=400, detail="Invalid admin code") + + # Get form data + form_data = await request.form() + event_id = form_data.get("event_id") + event_name = form_data.get("new_event_name") + + # If no event ID provided, create a new event with the given name + if not event_id and event_name: + # Create new event + event_date = datetime.now() # Default to current date, can be improved + new_event = Event( + name=event_name, + description=f"Created via QR admin link on {event_date.strftime('%Y-%m-%d')}", + event_date=event_date + ) + db.add(new_event) + db.commit() + db.refresh(new_event) + event_id = new_event.id + + if not event_id: + raise HTTPException(status_code=400, detail="Event ID or new event name is required") + + # Update all QR codes in the set + qr_codes = db.query(QRCode).filter(QRCode.qr_set_id == set_id).all() + for qr_code in qr_codes: + qr_code.event_id = event_id + + db.commit() + + return {"message": f"Successfully linked {len(qr_codes)} QR codes to event ID {event_id}"} + + +@router.get("/sets/{set_id}/pdf") +def generate_pdf(set_id: int, db: Session = Depends(get_db)): + """ + Generate a PDF with QR codes for a set. + """ + # Get QR set + qr_set = db.query(QRSet).filter(QRSet.id == set_id).first() + if not qr_set: + raise HTTPException(status_code=404, detail="QR Set not found") + + # Get QR codes for this set + qr_codes = db.query(QRCode).filter(QRCode.qr_set_id == set_id).all() + if not qr_codes: + raise HTTPException(status_code=404, detail="No QR codes found in this set") + + # Create PDF buffer + buffer = io.BytesIO() + + # Create PDF document + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + title=f"QR Codes - {qr_set.name}", + rightMargin=1*cm, + leftMargin=1*cm, + topMargin=1*cm, + bottomMargin=1*cm + ) + + # Container for elements + elements = [] + + # Add styles + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + 'TitleStyle', + parent=styles['Heading1'], + alignment=TA_CENTER, + fontName='Helvetica-Bold' + ) + subtitle_style = ParagraphStyle( + 'SubtitleStyle', + parent=styles['Heading2'], + alignment=TA_CENTER + ) + code_style = ParagraphStyle( + 'CodeStyle', + parent=styles['Normal'], + alignment=TA_CENTER, + fontName='Courier' + ) + + # Add title + elements.append(Paragraph(f"QR Codes for {qr_set.name}", title_style)) + today = datetime.now().strftime('%Y-%m-%d') + elements.append(Paragraph(f"Generated on {today}", subtitle_style)) + elements.append(Spacer(1, 0.5*inch)) + + # Generate admin QR code + admin_code = f"admin-{set_id}-{uuid.uuid4()}" + admin_qr = qrcode.make(f"{BASE_URL}/qr/admin-link/{admin_code}") + admin_img_io = io.BytesIO() + admin_qr.save(admin_img_io, format="PNG") + admin_img_io.seek(0) + + # Add admin QR code to first page + admin_width = 2 * inch + admin_img = ReportLabImage(admin_img_io, width=admin_width, height=admin_width) + + # Create a 1x3 table for admin QR code + admin_data = [[admin_img], + [Paragraph("Admin QR Code", subtitle_style)], + [Paragraph("Scan to link these QR codes to an event", styles['Normal'])]] + admin_table = Table(admin_data, colWidths=[4*inch]) + admin_table.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('BOX', (0, 0), (-1, -1), 1, colors.black), + ('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey) + ])) + + elements.append(admin_table) + elements.append(Spacer(1, 0.5*inch)) + + # Create a table with 2 QR codes per row + table_data = [] + row = [] + + for idx, qr_code in enumerate(qr_codes): + # Generate QR code image + qr_img = qrcode.make(f"{BASE_URL}/redeem/{qr_code.code}") + img_io = io.BytesIO() + qr_img.save(img_io, format="PNG") + img_io.seek(0) + + # Create image element + img_width = 2.5 * inch + img = ReportLabImage(img_io, width=img_width, height=img_width) + + # Create cell content + title = qr_code.title if qr_code.title else f"{qr_code.points} Points" + cell_content = [ + img, + Paragraph(title, subtitle_style), + Paragraph(f"{qr_code.points} Points", styles['Normal']), + Paragraph(qr_code.code[:8] + "...", code_style), + Paragraph(f"{BASE_URL}/redeem/{qr_code.code[:8]}...", code_style) + ] + + row.append(cell_content) + + # Create a new row after every 2 cells + if len(row) == 2 or idx == len(qr_codes) - 1: + # If we have an odd number at the end, add an empty cell + if len(row) == 1: + row.append([]) + + table_data.append(row) + row = [] + + # Create the table + col_width = doc.width / 2 + qr_table = Table(table_data, colWidths=[col_width, col_width]) + + # Style the table + table_style = [ + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), + ('TOPPADDING', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ] + qr_table.setStyle(TableStyle(table_style)) + + elements.append(qr_table) + + # Build the PDF + doc.build(elements) + buffer.seek(0) + + # Return PDF as a download + return StreamingResponse( + buffer, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=qr_codes_{set_id}.pdf"} + ) diff --git a/app/views/redeem.py b/app/views/redeem.py index 39d8fac..c60e765 100644 --- a/app/views/redeem.py +++ b/app/views/redeem.py @@ -2,15 +2,20 @@ """ Redeem a QR code and attribute points to a team. """ +import os from fastapi import APIRouter, Depends, Request, Form, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session +from datetime import datetime from ..db import SessionLocal -from ..models import QRTicket, User, Team, TeamMembership +from ..models import QRCode, User, Team, TeamMembership, TeamAchievement from ..templates_config import templates router = APIRouter() +# Get base URL from environment variable or use default +BASE_URL = os.environ.get("LEAGUELEDGER_BASE_URL", "https://rover.leagueledger.net") + def get_db(): db = SessionLocal() try: @@ -24,14 +29,38 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)): Display a page to let the user choose which team to apply points to. No login required. """ - ticket = db.query(QRTicket).filter_by(code=code, used=False).first() - if not ticket: + # Find the QR code record + qr_code = db.query(QRCode).filter_by(code=code).first() + + # Handle invalid or already used codes + if not qr_code: return templates.TemplateResponse( "error.html", { "request": request, "error_title": "Invalid Code", - "error_message": "This code is invalid or has already been used." + "error_message": "This QR code is invalid or does not exist." + } + ) + + if qr_code.used and (not qr_code.max_uses or qr_code.max_uses <= 1): + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Code Already Used", + "error_message": "This QR code has already been redeemed." + } + ) + + # Check expiration if applicable + if qr_code.expires_at and qr_code.expires_at < datetime.now(): + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Expired Code", + "error_message": "This QR code has expired and can no longer be redeemed." } ) @@ -40,8 +69,19 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("redeem.html", { "request": request, - "ticket": ticket, - "user_teams": all_teams # Now showing all teams + "ticket": qr_code, # Using the same template variable name for compatibility + "user_teams": all_teams, + "has_achievement": bool(qr_code.achievement_name), + "base_url": BASE_URL + }) + +@router.get("/scan", response_class=HTMLResponse) +def scan_qr_code(request: Request): + """ + Display the QR code scanner page + """ + return templates.TemplateResponse("scan_qr.html", { + "request": request }) @router.post("/apply/{code}") @@ -51,7 +91,7 @@ async def apply_code( db: Session = Depends(get_db) ): """ - Apply the QR code to a selected team (without authentication) + Apply the QR code to a selected team and award points and/or achievements """ # Get form data form_data = await request.form() @@ -67,19 +107,41 @@ async def apply_code( } ) - ticket = db.query(QRTicket).filter_by(code=code, used=False).first() - if not ticket: + # Get the QR code + qr_code = db.query(QRCode).filter_by(code=code).first() + if not qr_code: return templates.TemplateResponse( "error.html", { "request": request, "error_title": "Invalid Code", - "error_message": "This code is invalid or has already been used." + "error_message": "This QR code is invalid or does not exist." + } + ) + + # Check if the code is already used (for single-use codes) + if qr_code.used and (not qr_code.max_uses or qr_code.max_uses <= 1): + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Code Already Used", + "error_message": "This QR code has already been redeemed." + } + ) + + # Check expiration if applicable + if qr_code.expires_at and qr_code.expires_at < datetime.now(): + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Expired Code", + "error_message": "This QR code has expired and can no longer be redeemed." } ) team = db.query(Team).filter_by(id=team_id).first() - if not team: return templates.TemplateResponse( "error.html", @@ -90,24 +152,35 @@ async def apply_code( } ) - # Redeem without checking membership - ticket.redeemed_at_team = team.id - ticket.used = True - - # If we have redeemed_at column, update it - if hasattr(ticket, 'redeemed_at'): - from datetime import datetime - ticket.redeemed_at = datetime.now() - + # Mark the QR code as redeemed + qr_code.redeemed_at_team = team.id + qr_code.redeemed_at = datetime.now() + qr_code.used = True + + # Handle achievements if present + if qr_code.achievement_name: + achievement = TeamAchievement( + team_id=team.id, + name=qr_code.achievement_name, + description=qr_code.description, + event_id=qr_code.event_id, + qr_code_id=qr_code.id, + achieved_at=datetime.now() + ) + db.add(achievement) + db.commit() - - # Redirect to success page or dashboard + + # Return the success page with appropriate information return templates.TemplateResponse( "redeem_success.html", { "request": request, - "points": ticket.points, - "team": team + "points": qr_code.points, + "achievement": qr_code.achievement_name if qr_code.achievement_name else None, + "team": team, + "event": qr_code.event if qr_code.event else None, + "base_url": BASE_URL } ) @@ -122,16 +195,26 @@ async def manual_code_entry( This redirects to the normal redeem flow after validating the code. """ # Check if the code exists - ticket = db.query(QRTicket).filter_by(code=code, used=False).first() + qr_code = db.query(QRCode).filter_by(code=code).first() - if not ticket: - # In a real app, add a flash message or error handling + if not qr_code: return templates.TemplateResponse( "error.html", { "request": request, "error_title": "Invalid Code", - "error_message": "The code you entered is invalid or has already been used." + "error_message": "The code you entered is invalid or does not exist." + } + ) + + # Check if already used (for single-use codes) + if qr_code.used and (not qr_code.max_uses or qr_code.max_uses <= 1): + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Code Already Used", + "error_message": "This code has already been redeemed." } ) diff --git a/app/views/static.py b/app/views/static.py new file mode 100644 index 0000000..dc48074 --- /dev/null +++ b/app/views/static.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +import os + +router = APIRouter() + +# Function to setup static files that will be called from main.py +def configure_static_files(app): + """Configure static files mounting for the application.""" + static_dir = os.path.join(os.path.dirname(__file__), "..", "static") + app.mount("/static", StaticFiles(directory=static_dir), name="static") + +# Serve favicon.ico +@router.get("/favicon.ico") +async def serve_favicon(): + """Serve favicon.ico.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "favicon.ico") + return FileResponse(file_path) + +# Serve android-chrome-192x192.png +@router.get("/android-chrome-192x192.png") +async def serve_android_chrome_192(): + """Serve android-chrome-192x192.png.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "android-chrome-192x192.png") + return FileResponse(file_path) + +# Serve android-chrome-512x512.png +@router.get("/android-chrome-512x512.png") +async def serve_android_chrome_512(): + """Serve android-chrome-512x512.png.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "android-chrome-512x512.png") + return FileResponse(file_path) + +# Serve apple-touch-icon.png +@router.get("/apple-touch-icon.png") +async def serve_apple_touch_icon(): + """Serve apple-touch-icon.png.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "apple-touch-icon.png") + return FileResponse(file_path) + +# Serve favicon-16x16.png +@router.get("/favicon-16x16.png") +async def serve_favicon_16(): + """Serve favicon-16x16.png.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "favicon-16x16.png") + return FileResponse(file_path) + +# Serve favicon-32x32.png +@router.get("/favicon-32x32.png") +async def serve_favicon_32(): + """Serve favicon-32x32.png.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "favicon-32x32.png") + return FileResponse(file_path) + +# Serve site.webmanifest +@router.get("/images/favicon/site.webmanifest") +async def serve_site_webmanifest(): + """Serve site.webmanifest.""" + file_path = os.path.join(os.path.dirname(__file__), "..", "static", "images", "favicon", "site.webmanifest") + return FileResponse(file_path) \ No newline at end of file diff --git a/app/views/teams.py b/app/views/teams.py index f4440e1..4ab94f8 100644 --- a/app/views/teams.py +++ b/app/views/teams.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Create or join a team, manage membership. +Teams management for users and admins. """ from fastapi import APIRouter, Depends, Request, Form, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse @@ -10,7 +10,7 @@ from datetime import datetime, timedelta import random # For demo data from ..db import SessionLocal -from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement +from ..models import Team, TeamMembership, User, QRCode, TeamAchievement from ..schemas import TeamCreate from ..templates_config import templates @@ -106,17 +106,17 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): }) # Get total points - total_points = db.query(func.sum(QRTicket.points)).filter( - QRTicket.redeemed_at_team == team_id + total_points = db.query(func.sum(QRCode.points)).filter( + QRCode.redeemed_at_team == team_id ).scalar() or 0 # Calculate rank based on points higher_teams = db.query(func.count(Team.id)).join( - QRTicket, - QRTicket.redeemed_at_team == Team.id, + QRCode, + QRCode.redeemed_at_team == Team.id, isouter=True ).group_by(Team.id).having( - func.sum(QRTicket.points) > total_points + func.sum(QRCode.points) > total_points ).scalar() or 0 team_rank = higher_teams + 1 @@ -134,13 +134,13 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): # Use raw SQL to check if column exists and get points has_redeemed_at = False inspector = inspect(db.bind) - if 'redeemed_at' in [col['name'] for col in inspector.get_columns('qr_tickets')]: + if 'redeemed_at' in [col['name'] for col in inspector.get_columns('qr_codes')]: has_redeemed_at = True if has_redeemed_at: - points_this_month = db.query(func.sum(QRTicket.points)).filter( - QRTicket.redeemed_at_team == team_id, - QRTicket.redeemed_at >= first_day_of_month + points_this_month = db.query(func.sum(QRCode.points)).filter( + QRCode.redeemed_at_team == team_id, + QRCode.redeemed_at >= first_day_of_month ).scalar() or points_this_month except Exception as e: print(f"Error calculating monthly points: {e}") diff --git a/docker-compose.yml b/docker-compose.yml index 093e5a5..c93cf25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: MYSQL_ROOT_PASSWORD: "root_pass" ports: - "3306:3306" - volumes: - - db_data:/var/lib/mysql:delegated + tmpfs: + - /var/lib/mysql # Use RAM-based ephemeral storage instead of persistent volume healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] interval: 5s @@ -33,16 +33,14 @@ services: DB_NAME: "pubquiz_db" DB_USER: "pubquiz_user" DB_PASS: "pubquiz_pass" - # Add better error logging PYTHONUNBUFFERED: "1" - # Just run uvicorn directly, no need for pymysql install since it's in requirements.txt + LEAGUELEDGER_BASE_URL: "https://rover.leagueledger.net" # Base URL for QR codes command: uvicorn app.main:app --host 0.0.0.0 --reload ports: - "8000:8000" volumes: - ./:/app:delegated - phpmyadmin: image: phpmyadmin/phpmyadmin container_name: pubquiz_phpmyadmin @@ -57,6 +55,4 @@ services: ports: - "8001:80" - -volumes: - db_data: +# No persistent volumes defined - database will reset when container stops diff --git a/requirements.txt b/requirements.txt index 8b928ea..1feb892 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,4 +28,8 @@ pydantic>=2.3.0 qrcode>=7.4.2 # Image processing library for QR code generation -Pillow>=9.0.0 \ No newline at end of file +Pillow>=9.0.0 + +# PDF generation for QR code sheets +reportlab>=3.6.12 +babel>=2.12.1 diff --git a/scripts/compile_translations.py b/scripts/compile_translations.py new file mode 100644 index 0000000..b7f5557 --- /dev/null +++ b/scripts/compile_translations.py @@ -0,0 +1,100 @@ +import os +import subprocess +from pathlib import Path + +# Define base directory +BASE_DIR = Path(__file__).parent.parent +LOCALE_DIR = BASE_DIR / "app" / "i18n" / "locales" + +def compile_all_translations(): + """Compile .po files into .mo files for all languages""" + print("Compiling translations...") + + for lang_dir in LOCALE_DIR.iterdir(): + if lang_dir.is_dir(): + lang_code = lang_dir.name + po_file = lang_dir / "LC_MESSAGES" / "messages.po" + + if po_file.exists(): + print(f"Compiling {lang_code} translations...") + try: + subprocess.run([ + "pybabel", "compile", + "-f", "-i", str(po_file), + "-o", str(po_file.parent / "messages.mo"), + "--statistics" + ], check=True) + print(f"Successfully compiled {lang_code} translations") + except subprocess.CalledProcessError as e: + print(f"Error compiling {lang_code} translations: {e}") + else: + print(f"No .po file found for {lang_code}") + +def update_pot_file(): + """Extract translatable strings from templates and create a POT file""" + print("Extracting strings from templates...") + + pot_file = BASE_DIR / "app" / "i18n" / "messages.pot" + template_dir = BASE_DIR / "app" / "templates" + + try: + subprocess.run([ + "pybabel", "extract", + "-F", str(BASE_DIR / "babel.cfg"), + "-o", str(pot_file), + str(template_dir) + ], check=True) + print("Successfully created template file") + except subprocess.CalledProcessError as e: + print(f"Error creating template file: {e}") + +def update_po_files(): + """Update all .po files with the strings from the POT file""" + print("Updating .po files...") + + pot_file = BASE_DIR / "app" / "i18n" / "messages.pot" + + for lang_dir in LOCALE_DIR.iterdir(): + if lang_dir.is_dir(): + lang_code = lang_dir.name + po_file = lang_dir / "LC_MESSAGES" / "messages.po" + + if po_file.exists(): + print(f"Updating {lang_code} translations...") + try: + subprocess.run([ + "pybabel", "update", + "-i", str(pot_file), + "-o", str(po_file), + "-l", lang_code + ], check=True) + print(f"Successfully updated {lang_code} translations") + except subprocess.CalledProcessError as e: + print(f"Error updating {lang_code} translations: {e}") + else: + print(f"Initializing {lang_code} translations...") + # Create LC_MESSAGES directory if it doesn't exist + os.makedirs(lang_dir / "LC_MESSAGES", exist_ok=True) + try: + subprocess.run([ + "pybabel", "init", + "-i", str(pot_file), + "-o", str(po_file), + "-l", lang_code + ], check=True) + print(f"Successfully initialized {lang_code} translations") + except subprocess.CalledProcessError as e: + print(f"Error initializing {lang_code} translations: {e}") + +if __name__ == "__main__": + # Create babel.cfg if it doesn't exist + babel_cfg = BASE_DIR / "babel.cfg" + if not babel_cfg.exists(): + with open(babel_cfg, "w") as f: + f.write("[python: **.py]\n") + f.write("[jinja2: **/templates/**.html]\n") + f.write("extensions=jinja2.ext.autoescape,jinja2.ext.with_\n") + + update_pot_file() + update_po_files() + compile_all_translations()