Add Impressum and QR code management templates
- Created a new Impressum page with legal information and contact details. - Developed admin link page for linking QR codes to events with form functionality. - Implemented QR code dashboard for managing QR code sets, including creation and quick actions. - Added detailed view for QR code sets, allowing addition of QR codes and management actions. - Introduced static file serving for favicon and related images. - Established views for static content pages (about, contact, privacy, terms). - Implemented translation management scripts for compiling and updating translations.
@@ -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
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
achievement = None
|
||||
if points >= 15: # Only high points get achievements
|
||||
achievement = random.choice(["Winner", "Top Scorer", "Quiz Master", None])
|
||||
|
||||
if has_event_name:
|
||||
ticket_attrs["event_name"] = random.choice(event_names)
|
||||
redeemed_at = datetime.now() - timedelta(days=random.randint(7, 90))
|
||||
|
||||
tickets.append(QRTicket(**ticket_attrs))
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
db.add_all(tickets)
|
||||
# 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(qr_codes)
|
||||
db.commit()
|
||||
|
||||
print("Database seeded successfully!")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <leagueledger@kaufdeinquiz.com>\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"
|
||||
@@ -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 <leagueledger@kaufdeinquiz.com>\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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 398 B |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -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"}
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">About LeagueLedger</h1>
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">{{ _("About LeagueLedger") }}</h1>
|
||||
<p class="mb-4">
|
||||
{% 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 %}
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
{% 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 %}
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
{% 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 %}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Our Mission</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">
|
||||
{% if locale == "de" %}Unsere Mission{% else %}Our Mission{% endif %}
|
||||
</h2>
|
||||
<p class="mb-4">
|
||||
{% 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 %}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Our Team</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">
|
||||
{% if locale == "de" %}Unser Team{% else %}Our Team{% endif %}
|
||||
</h2>
|
||||
<p class="mb-4">
|
||||
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 %}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">
|
||||
{% if locale == "de" %}Lizenz{% else %}License{% endif %}
|
||||
</h2>
|
||||
<p class="mb-4">
|
||||
{% 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 %}
|
||||
</p>
|
||||
|
||||
<p class="mt-8 text-sm text-gray-600">
|
||||
{% if locale == "de" %}Letzte Aktualisierung: April 2025{% else %}Last updated: April 2025{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
|
||||
<div class="mt-10 pt-6 border-t border-gray-200">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="/qr" class="bg-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white px-4 py-3 rounded-md transition flex items-center">
|
||||
<i class="fas fa-qrcode mr-2"></i> QR Code Dashboard
|
||||
</a>
|
||||
<a href="/qr/generate/10" class="bg-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white px-4 py-3 rounded-md transition flex items-center">
|
||||
<i class="fas fa-qrcode mr-2"></i> Generate QR Code (10 points)
|
||||
</a>
|
||||
|
||||
@@ -1,151 +1,256 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<html lang="{{ locale }}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LeagueLedger</title>
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Font Awesome Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'irish-green': '#006837',
|
||||
'golden-ale': '#FFB400',
|
||||
'cream-white': '#F5F0E1',
|
||||
'black-stout': '#1A1A1A',
|
||||
'guinness-red': '#B22222',
|
||||
},
|
||||
fontFamily: {
|
||||
'garamond': ['"EB Garamond"', 'serif'],
|
||||
'inter': ['"Inter"', 'sans-serif'],
|
||||
}
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LeagueLedger - Pub Quiz Tracking{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/favicon/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/static/images/favicon/site.webmanifest">
|
||||
|
||||
|
||||
<!-- Font Awesome for icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'irish-green': '#006837',
|
||||
'golden-ale': '#FFB400',
|
||||
'cream-white': '#F5F0E1',
|
||||
'black-stout': '#1A1A1A',
|
||||
'guinness-red': '#B22222',
|
||||
},
|
||||
fontFamily: {
|
||||
'garamond': ['Garamond', 'Georgia', 'serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'EB Garamond', serif;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/styles.css') }}">
|
||||
|
||||
<!-- HTML5 QR Code Scanner library -->
|
||||
<script src="https://unpkg.com/html5-qrcode"></script>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen bg-cream-white text-black-stout">
|
||||
<header class="bg-irish-green text-cream-white shadow-md">
|
||||
<div class="container mx-auto px-4">
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<img src="https://picsum.photos/40" alt="LeagueLedger Logo" class="h-10 w-10">
|
||||
<h1 class="text-2xl font-bold font-garamond">LeagueLedger</h1>
|
||||
<body class="bg-gray-100 min-h-screen flex flex-col">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-irish-green text-white shadow-md">
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<!-- Logo and site name -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="/" class="flex items-center">
|
||||
<img src="{{ url_for('static', path='images/logos/monogram.png') }}" alt="LeagueLedger Logo" class="h-12">
|
||||
<span class="ml-2 text-xl font-bold">LeagueLedger</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex space-x-6 items-center">
|
||||
<a href="/" class="hover:text-golden-ale transition">{{ _("Home") }}</a>
|
||||
<a href="/teams" class="hover:text-golden-ale transition">{{ _("Teams") }}</a>
|
||||
<a href="/leaderboard" class="hover:text-golden-ale transition">{{ _("Leaderboard") }}</a>
|
||||
<a href="/scan" class="hover:text-golden-ale transition">{{ _("Scan QR Code") }}</a>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<div class="relative dropdown">
|
||||
<button class="dropdown-toggle flex items-center space-x-1 hover:text-golden-ale transition">
|
||||
<i class="fas fa-globe text-lg"></i>
|
||||
<span>{{ SUPPORTED_LANGUAGES[locale] }}</span>
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
{% for code, name in SUPPORTED_LANGUAGES.items() %}
|
||||
<a href="/set-language/{{ code }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 {% if locale == code %}font-bold{% endif %}">
|
||||
{{ name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div class="relative dropdown">
|
||||
<button class="dropdown-toggle flex items-center space-x-1 hover:text-golden-ale transition">
|
||||
<i class="fas fa-user-circle text-lg"></i>
|
||||
<span>{{ user.username }}</span>
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-user mr-2"></i> {{ _("Profile") }}
|
||||
</a>
|
||||
<a href="/dashboard" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-tachometer-alt mr-2"></i> {{ _("Dashboard") }}
|
||||
</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-cog mr-2"></i> {{ _("Admin") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="border-t border-gray-100 my-1"></div>
|
||||
<a href="/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> {{ _("Logout") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/login" class="bg-golden-ale hover:bg-opacity-90 text-black-stout px-4 py-2 rounded-md transition">{{ _("Sign In") }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="md:hidden flex items-center space-x-4">
|
||||
<!-- Mobile Language Selector -->
|
||||
<div class="relative dropdown">
|
||||
<button class="dropdown-toggle flex items-center hover:text-golden-ale transition">
|
||||
<i class="fas fa-globe text-lg"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu hidden absolute right-0 mt-2 w-32 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
{% for code, name in SUPPORTED_LANGUAGES.items() %}
|
||||
<a href="/set-language/{{ code }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 {% if locale == code %}font-bold{% endif %}">
|
||||
{{ name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="mobile-menu-button" class="text-white hover:text-golden-ale transition">
|
||||
<i class="fas fa-bars text-2xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div id="mobile-menu" class="hidden md:hidden mt-3 pb-3 border-t border-irish-green border-opacity-30">
|
||||
<div class="flex flex-col space-y-2 mt-3">
|
||||
<a href="/" class="hover:text-golden-ale transition py-2">{{ _("Home") }}</a>
|
||||
<a href="/teams" class="hover:text-golden-ale transition py-2">{{ _("Teams") }}</a>
|
||||
<a href="/leaderboard" class="hover:text-golden-ale transition py-2">{{ _("Leaderboard") }}</a>
|
||||
<a href="/scan" class="hover:text-golden-ale transition py-2">{{ _("Scan QR Code") }}</a>
|
||||
{% if user %}
|
||||
<a href="/profile" class="hover:text-golden-ale transition py-2">{{ _("Profile") }}</a>
|
||||
<a href="/dashboard" class="hover:text-golden-ale transition py-2">{{ _("Dashboard") }}</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin" class="hover:text-golden-ale transition py-2">{{ _("Admin") }}</a>
|
||||
{% endif %}
|
||||
<a href="/logout" class="hover:text-golden-ale transition py-2">{{ _("Logout") }}</a>
|
||||
{% else %}
|
||||
<a href="/login" class="bg-golden-ale hover:bg-opacity-90 text-black-stout px-4 py-2 rounded-md transition text-center">{{ _("Sign In") }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Desktop Navigation Links -->
|
||||
<nav class="hidden md:flex items-center space-x-6">
|
||||
<a href="/" class="hover:text-golden-ale transition-colors duration-200">
|
||||
<i class="fas fa-home"></i> Home
|
||||
</a>
|
||||
<a href="/teams/" class="hover:text-golden-ale transition-colors duration-200">
|
||||
<i class="fas fa-users"></i> Teams
|
||||
</a>
|
||||
<a href="/leaderboard" class="hover:text-golden-ale transition-colors duration-200">
|
||||
<i class="fas fa-trophy"></i> Leaderboard
|
||||
</a>
|
||||
<a href="/dashboard" class="hover:text-golden-ale transition-colors duration-200">
|
||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||
</a>
|
||||
<a href="/admin/" class="hover:text-golden-ale transition-colors duration-200">
|
||||
<i class="fas fa-lock"></i> Admin
|
||||
</a>
|
||||
<a href="/about" class="hover:text-golden-ale transition-colors duration-200">
|
||||
About
|
||||
</a>
|
||||
<a href="/contact" class="hover:text-golden-ale transition-colors duration-200">
|
||||
Contact
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Main Content -->
|
||||
<main class="flex-grow py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button id="mobile-menu-button" class="md:hidden text-cream-white focus:outline-none">
|
||||
<i class="fas fa-bars text-2xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<footer class="bg-black-stout text-cream-white py-8 mt-auto">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex flex-col md:flex-row justify-between">
|
||||
<div class="mb-6 md:mb-0">
|
||||
<div class="flex items-center mb-4">
|
||||
<img src="{{ url_for('static', path='images/logos/monogram.png') }}" alt="LeagueLedger Logo" class="h-12">
|
||||
<span class="ml-2 text-xl font-bold">LeagueLedger</span>
|
||||
</div>
|
||||
<p class="text-sm">{{ _("Track your pub quiz team's progress.") }}<br>{{ _("Scan QR codes to earn points.") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<div id="mobile-menu" class="md:hidden hidden pb-4">
|
||||
<nav class="flex flex-col space-y-3">
|
||||
<a href="/" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
<i class="fas fa-home"></i> Home
|
||||
</a>
|
||||
<a href="/teams/" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
<i class="fas fa-users"></i> Teams
|
||||
</a>
|
||||
<a href="/leaderboard" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
<i class="fas fa-trophy"></i> Leaderboard
|
||||
</a>
|
||||
<a href="/dashboard" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||
</a>
|
||||
<a href="/admin/" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
<i class="fas fa-lock"></i> Admin
|
||||
</a>
|
||||
<a href="/about" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
About
|
||||
</a>
|
||||
<a href="/contact" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||
Contact
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 class="text-golden-ale font-bold mb-4">{{ _("Navigation") }}</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/" class="hover:text-golden-ale transition">{{ _("Home") }}</a></li>
|
||||
<li><a href="/teams" class="hover:text-golden-ale transition">{{ _("Teams") }}</a></li>
|
||||
<li><a href="/leaderboard" class="hover:text-golden-ale transition">{{ _("Leaderboard") }}</a></li>
|
||||
<li><a href="/scan" class="hover:text-golden-ale transition">{{ _("Scan QR Code") }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-grow container mx-auto px-4 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<div>
|
||||
<h3 class="text-golden-ale font-bold mb-4">{{ _("Account") }}</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/login" class="hover:text-golden-ale transition">{{ _("Sign In") }}</a></li>
|
||||
<li><a href="/register" class="hover:text-golden-ale transition">{{ _("Register") }}</a></li>
|
||||
<li><a href="/profile" class="hover:text-golden-ale transition">{{ _("Profile") }}</a></li>
|
||||
<li><a href="/dashboard" class="hover:text-golden-ale transition">{{ _("Dashboard") }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-black-stout text-cream-white mt-auto">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<h3 class="text-xl font-garamond">LeagueLedger</h3>
|
||||
<p class="text-sm text-cream-white opacity-70">Track Your Triumphs. Celebrate the Quiz.</p>
|
||||
<div>
|
||||
<h3 class="text-golden-ale font-bold mb-4">{{ _("Legal") }}</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="/about" class="hover:text-golden-ale transition">{{ _("About") }}</a></li>
|
||||
<li><a href="/contact" class="hover:text-golden-ale transition">{{ _("Contact") }}</a></li>
|
||||
<li><a href="/terms" class="hover:text-golden-ale transition">{{ _("Terms of Service") }}</a></li>
|
||||
<li><a href="/privacy" class="hover:text-golden-ale transition">{{ _("Privacy Policy") }}</a></li>
|
||||
<li><a href="/cookies" class="hover:text-golden-ale transition">{{ _("Cookie Policy") }}</a></li>
|
||||
<li><a href="/impressum" class="hover:text-golden-ale transition">{{ _("Imprint") }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<p class="text-sm">© 2025 LeagueLedger. {{ _("Licensed under Apache License 2.0") }}</p>
|
||||
</div>
|
||||
<div class="flex space-x-4 mt-4 md:mt-0">
|
||||
{% for code, name in SUPPORTED_LANGUAGES.items() %}
|
||||
<a href="/set-language/{{ code }}" class="hover:text-golden-ale transition {% if locale == code %}font-bold{% endif %}">
|
||||
{{ name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-links">
|
||||
<a href="/about" class="text-cream-white hover:text-golden-ale transition-colors duration-200">About</a>
|
||||
<a href="/contact" class="text-cream-white hover:text-golden-ale transition-colors duration-200">Contact</a>
|
||||
<a href="/privacy" class="text-cream-white hover:text-golden-ale transition-colors duration-200">Privacy</a>
|
||||
<a href="/terms" class="text-cream-white hover:text-golden-ale transition-colors duration-200">Terms</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 mt-6 pt-6 text-center text-sm text-cream-white opacity-70">
|
||||
© {{ now().year }} LeagueLedger. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Mobile menu toggle
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const menuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
menuButton.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</footer>
|
||||
<!-- Custom JavaScript -->
|
||||
<script src="{{ url_for('static', path='js/main.js') }}"></script>
|
||||
<!-- Mobile menu toggle script -->
|
||||
<script>
|
||||
document.getElementById('mobile-menu-button').addEventListener('click', function() {
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
<!-- Email Protection Script -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Decrypt email addresses
|
||||
const emailElements = document.querySelectorAll('.email-protection');
|
||||
emailElements.forEach(function(element) {
|
||||
const email = element.dataset.email;
|
||||
// Create clickable email link
|
||||
const link = document.createElement('a');
|
||||
link.href = 'mailto:' + email;
|
||||
link.textContent = email;
|
||||
link.className = element.className;
|
||||
// Replace placeholder with actual email link
|
||||
element.parentNode.replaceChild(link, element);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Contact Us</h1>
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Kontakt</h1>
|
||||
<p class="mb-4">
|
||||
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!
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Contact Information</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Kontaktinformationen</h2>
|
||||
<p><strong>Christian Louis IT Beratung</strong></p>
|
||||
<p>Alter Steinweg 3</p>
|
||||
<p>20459 Hamburg</p>
|
||||
<p>Deutschland</p>
|
||||
<p><strong>Phone:</strong> +49 179 5183732</p>
|
||||
<p><strong>Email:</strong> <a href="mailto:quizarium@kaufdeinquiz.com">quizarium@kaufdeinquiz.com</a></p>
|
||||
<p><strong>Telefon:</strong> +49 179 5183732</p>
|
||||
<p><strong>E-Mail:</strong> <span class="email-protection" data-email="leagueledger@kaufdeinquiz.com">leagueledger [at] kaufdeinquiz.com</span></p>
|
||||
<p><strong>Fax:</strong> +49 40 97074609</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Connect With Us</h2>
|
||||
<p>This project is part of the KaufDeinQuiz platform and is operated as an Open-Source initiative. All rights reserved.</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Verbinden Sie sich mit uns</h2>
|
||||
<p>Dieses Projekt ist Teil der KaufDeinQuiz-Plattform und wird als Open-Source-Initiative betrieben. Lizenziert unter der Apache License 2.0.</p>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<h2 class="text-xl font-semibold text-irish-green mb-2">Verantwortlicher gemäß § 5 TMG</h2>
|
||||
<p>Christian Krakau-Louis</p>
|
||||
<p>Christian Louis IT Beratung und Medienproduktion</p>
|
||||
<p>Alter Steinweg 3</p>
|
||||
<p>20459 Hamburg</p>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<h2 class="text-xl font-semibold text-irish-green mb-2">Umsatzsteuer-ID</h2>
|
||||
<p>Umsatzsteuer-Identifikationsnummer gemäß §27a Umsatzsteuergesetz:</p>
|
||||
<p>DE202899017</p>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<h2 class="text-xl font-semibold text-irish-green mb-2">Steuernummer</h2>
|
||||
<p>48/148/00526</p>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<h2 class="text-xl font-semibold text-irish-green mb-2">Streitschlichtung</h2>
|
||||
<p>Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" class="text-irish-green hover:underline">https://ec.europa.eu/consumers/odr/</a></p>
|
||||
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-sm text-gray-600">
|
||||
Letzte Aktualisierung: April 2025
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Cookie-Richtlinie</h1>
|
||||
<p class="mb-4">
|
||||
Diese Cookie-Richtlinie erläutert, wie LeagueLedger Cookies und ähnliche Technologien verwendet.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Was sind Cookies?</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Welche Cookies wir verwenden</h2>
|
||||
<p class="mb-4">
|
||||
Wir verwenden die folgenden Arten von Cookies:
|
||||
</p>
|
||||
<ul class="list-disc ml-6 mb-4">
|
||||
<li><strong>Notwendige Cookies:</strong> Diese sind für die Grundfunktionen der Website erforderlich und können nicht deaktiviert werden.</li>
|
||||
<li><strong>Funktionscookies:</strong> Diese ermöglichen erweiterte Funktionen und Personalisierung.</li>
|
||||
<li><strong>Analyse-Cookies:</strong> Diese helfen uns zu verstehen, wie Besucher mit unserer Website interagieren.</li>
|
||||
</ul>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Cookie-Verwaltung</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Detaillierte Cookie-Liste</h2>
|
||||
<table class="min-w-full bg-white border border-gray-300 mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2 px-4 border-b">Name</th>
|
||||
<th class="py-2 px-4 border-b">Zweck</th>
|
||||
<th class="py-2 px-4 border-b">Ablaufzeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="py-2 px-4 border-b">session</td>
|
||||
<td class="py-2 px-4 border-b">Speichert Ihre Sitzungsinformationen</td>
|
||||
<td class="py-2 px-4 border-b">Sitzung</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-4 border-b">auth_token</td>
|
||||
<td class="py-2 px-4 border-b">Authentifizierung</td>
|
||||
<td class="py-2 px-4 border-b">30 Tage</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-4 border-b">cookie_consent</td>
|
||||
<td class="py-2 px-4 border-b">Speichert Ihre Cookie-Präferenzen</td>
|
||||
<td class="py-2 px-4 border-b">1 Jahr</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Änderungen dieser Cookie-Richtlinie</h2>
|
||||
<p class="mb-4">
|
||||
Wir behalten uns das Recht vor, diese Cookie-Richtlinie jederzeit zu ändern. Die aktuelle Version ist stets auf dieser Seite verfügbar.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Kontakt</h2>
|
||||
<p class="mb-4">
|
||||
Wenn Sie Fragen zu unserer Cookie-Richtlinie haben, kontaktieren Sie uns bitte unter <span class="email-protection" data-email="leagueledger@kaufdeinquiz.com">leagueledger [at] kaufdeinquiz.com</span>.
|
||||
</p>
|
||||
<p class="mt-8 text-sm text-gray-600">
|
||||
Letzte Aktualisierung: April 2025
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Impressum</h1>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Angaben gemäß § 5 TMG</h2>
|
||||
<p>Christian Louis IT Beratung</p>
|
||||
<p>Alter Steinweg 3</p>
|
||||
<p>20459 Hamburg</p>
|
||||
<p>Deutschland</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Vertreten durch</h2>
|
||||
<p>Christian Krakau-Louis</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Kontakt</h2>
|
||||
<p>Telefon: +49 179 5183732</p>
|
||||
<p>E-Mail: <span class="email-protection" data-email="leagueledger@kaufdeinquiz.com">leagueledger [at] kaufdeinquiz.com</span></p>
|
||||
<p>Fax: +49 40 97074609</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Umsatzsteuer-ID</h2>
|
||||
<p>Umsatzsteuer-Identifikationsnummer gemäß §27a Umsatzsteuergesetz:</p>
|
||||
<p>DE202899017</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Steuernummer</h2>
|
||||
<p>48/148/00526</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p>Christian Krakau-Louis</p>
|
||||
<p>Alter Steinweg 3</p>
|
||||
<p>20459 Hamburg</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Streitschlichtung</h2>
|
||||
<p>Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" class="text-irish-green hover:underline">https://ec.europa.eu/consumers/odr/</a>.</p>
|
||||
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Haftung für Inhalte</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Haftung für Links</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Urheberrecht</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p class="mt-8 text-sm text-gray-600">
|
||||
Letzte Aktualisierung: April 2025
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,32 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Privacy Policy</h1>
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Datenschutzerklärung</h1>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Information We Collect</h2>
|
||||
<ul class="list-disc ml-6 mb-4">
|
||||
<li>Email addresses</li>
|
||||
<li>Usernames</li>
|
||||
<li>Team names</li>
|
||||
<li>Quiz scores and points</li>
|
||||
</ul>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">How We Use Your Information</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Verantwortlicher im Sinne der DSGVO</h2>
|
||||
<p class="mb-4">
|
||||
We use your information to:
|
||||
Christian Krakau-Louis<br>
|
||||
Christian Louis IT Beratung und Medienproduktion<br>
|
||||
Alter Steinweg 3<br>
|
||||
20459 Hamburg<br>
|
||||
Deutschland<br>
|
||||
E-Mail: <span class="email-protection" data-email="leagueledger@kaufdeinquiz.com">leagueledger [at] kaufdeinquiz.com</span>
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Welche Daten wir sammeln</h2>
|
||||
<ul class="list-disc ml-6 mb-4">
|
||||
<li>E-Mail-Adressen</li>
|
||||
<li>Benutzernamen</li>
|
||||
<li>Team-Namen</li>
|
||||
<li>Quiz-Ergebnisse und Punkte</li>
|
||||
</ul>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Wie wir Ihre Daten verwenden</h2>
|
||||
<p class="mb-4">
|
||||
Wir verwenden Ihre Daten für folgende Zwecke:
|
||||
</p>
|
||||
<ul class="list-disc ml-6 mb-4">
|
||||
<li>Track leaderboard rankings</li>
|
||||
<li>Send notifications about team activities</li>
|
||||
<li>Improve our services and user experience</li>
|
||||
<li>Führen der Bestenliste</li>
|
||||
<li>Senden von Benachrichtigungen über Team-Aktivitäten</li>
|
||||
<li>Verbesserung unserer Dienste und Nutzererfahrung</li>
|
||||
</ul>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Data Sharing</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Rechtsgrundlage für die Verarbeitung</h2>
|
||||
<p class="mb-4">
|
||||
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).
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Datenweitergabe</h2>
|
||||
<p class="mb-4">
|
||||
Wir geben Ihre personenbezogenen Daten nicht an Dritte weiter, außer wenn dies gesetzlich erforderlich ist.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Ihre Rechte</h2>
|
||||
<p class="mb-4">
|
||||
Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch gemäß der DSGVO.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Speicherdauer</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Beschwerderecht</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p class="mt-8 text-sm text-gray-600">
|
||||
Letzte Aktualisierung: April 2025
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto p-4">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="text-center mb-6">
|
||||
<i class="fas fa-link text-irish-green text-4xl mb-4"></i>
|
||||
<h1 class="text-2xl font-bold text-irish-green">Link QR Codes to Event</h1>
|
||||
<p class="text-gray-600">Administrator Access</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-irish-green bg-opacity-10 border border-irish-green rounded-md p-4 mb-6">
|
||||
<h3 class="font-bold text-irish-green mb-1">QR Set: {{ qr_set.name }}</h3>
|
||||
<p class="text-sm">You are about to link all QR codes in this set to an event.</p>
|
||||
</div>
|
||||
|
||||
<form action="/qr/admin-link/{{ admin_code }}" method="post" class="space-y-6">
|
||||
<!-- Select Existing Event -->
|
||||
<div>
|
||||
<label for="event_id" class="block text-sm font-medium text-gray-700 mb-2">Select Existing Event</label>
|
||||
<select name="event_id" id="event_id"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||
onchange="toggleNewEventInput()">
|
||||
<option value="">-- Select an event --</option>
|
||||
{% for event in events %}
|
||||
<option value="{{ event.id }}">{{ event.name }} ({{ event.event_date.strftime('%Y-%m-%d') }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- OR Divider -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">OR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create New Event -->
|
||||
<div>
|
||||
<label for="new_event_name" class="block text-sm font-medium text-gray-700 mb-2">Create New Event</label>
|
||||
<input type="text" name="new_event_name" id="new_event_name"
|
||||
placeholder="e.g., Irish Rover Quiz Night 2025-04-17"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit"
|
||||
class="w-full bg-irish-green hover:bg-opacity-90 text-white font-bold py-3 px-4 rounded-md transition">
|
||||
<i class="fas fa-link mr-2"></i> Link QR Codes to Event
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Information Box -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold text-irish-green mb-4">What This Does</h2>
|
||||
<div class="space-y-4 text-sm">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
This helps with reporting and allows teams to see which events they've participated in and what
|
||||
awards they've received at each event.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> This action only affects QR codes that haven't been redeemed yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleNewEventInput() {
|
||||
const eventSelect = document.getElementById('event_id');
|
||||
const newEventInput = document.getElementById('new_event_name');
|
||||
|
||||
if (eventSelect.value) {
|
||||
newEventInput.disabled = true;
|
||||
newEventInput.classList.add('bg-gray-100');
|
||||
} else {
|
||||
newEventInput.disabled = false;
|
||||
newEventInput.classList.remove('bg-gray-100');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleNewEventInput();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto p-4">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-irish-green mb-2">QR Code Management</h1>
|
||||
<p class="text-gray-600">Create and manage QR code sets for your pub quiz events</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<!-- QR Set Creation -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Create QR Set</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Create a new set of QR codes for your pub quiz event with points and achievements.
|
||||
</p>
|
||||
|
||||
<form id="qrSetForm" action="/qr/sets" method="post" class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Set Name</label>
|
||||
<input type="text" name="name" id="name" required
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||
placeholder="e.g., Irish Rover Quiz Night">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Description (Optional)</label>
|
||||
<textarea name="description" id="description"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||
placeholder="Weekly quiz with 1st, 2nd, and 3rd place prizes" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded transition">
|
||||
Create QR Set
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- QR Set Examples -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Common Templates</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Quickly create QR code sets using these pre-defined templates
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="border rounded-md p-3 hover:bg-gray-50 cursor-pointer"
|
||||
onclick="useTemplate('Standard Pub Quiz',
|
||||
'Contains QR codes for 1st place (25 points), 2nd place (15 points), 3rd place (10 points), and 4th place (5 points)')">
|
||||
<h3 class="font-medium">Standard Pub Quiz</h3>
|
||||
<p class="text-xs text-gray-500">1st, 2nd, 3rd and 4th place</p>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md p-3 hover:bg-gray-50 cursor-pointer"
|
||||
onclick="useTemplate('Trivia Night',
|
||||
'Special trivia night with QR codes for winners and achievement codes for trivia categories')">
|
||||
<h3 class="font-medium">Trivia Night</h3>
|
||||
<p class="text-xs text-gray-500">With category achievements</p>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md p-3 hover:bg-gray-50 cursor-pointer"
|
||||
onclick="useTemplate('Weekly League',
|
||||
'Complete set for tracking weekly league progress with placements and special achievements')">
|
||||
<h3 class="font-medium">Weekly League</h3>
|
||||
<p class="text-xs text-gray-500">For ongoing competitions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-xs text-gray-500">
|
||||
Click a template to pre-fill the form. You can customize it before creating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Quick Actions</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Generate individual QR codes or access your created sets
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="#"
|
||||
class="block border border-dashed border-gray-300 rounded-md p-4 hover:bg-gray-50 text-center"
|
||||
id="quickQrBtn">
|
||||
<i class="fas fa-qrcode text-irish-green text-2xl mb-2"></i>
|
||||
<p class="font-medium">Generate Quick QR</p>
|
||||
<p class="text-xs text-gray-500">Create a single QR code</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Quick QR Modal (hidden by default) -->
|
||||
<div id="quickQrModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold">Generate Quick QR Code</h3>
|
||||
<button id="closeModal" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="quickQrForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="qr-points" class="block text-sm font-medium text-gray-700">Points</label>
|
||||
<input type="number" id="qr-points" name="points" min="0" value="10"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="bg-irish-green text-white px-4 py-2 rounded-md">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="qr-result" class="hidden mt-4 text-center">
|
||||
<div id="qr-image" class="mx-auto mb-2"></div>
|
||||
<p class="text-sm">Right-click to save the QR code</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing QR Sets -->
|
||||
<div class="mt-10">
|
||||
<h2 class="text-2xl font-bold text-irish-green mb-4">Your QR Sets</h2>
|
||||
|
||||
{% if qr_sets %}
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
{% for qr_set in qr_sets %}
|
||||
<div class="bg-white rounded-lg shadow hover:shadow-lg transition-shadow overflow-hidden">
|
||||
<div class="border-b px-6 py-4">
|
||||
<h3 class="font-bold">{{ qr_set.name }}</h3>
|
||||
{% if qr_set.description %}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ qr_set.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
<p>Created: {{ qr_set.created_at.strftime('%Y-%m-%d') }}</p>
|
||||
<p>QR Codes: {% if qr_set.qr_codes %}{{ qr_set.qr_codes | length }}{% else %}0{% endif %}</p>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between">
|
||||
<a href="/qr/sets/{{ qr_set.id }}" class="text-irish-green hover:underline">
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
<a href="/qr/sets/{{ qr_set.id }}/pdf" class="text-irish-green hover:underline">
|
||||
<i class="fas fa-file-pdf mr-1"></i> Generate PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-6 text-center">
|
||||
<i class="fas fa-info-circle text-gray-400 text-3xl mb-3"></i>
|
||||
<p class="text-gray-600">You haven't created any QR sets yet. Create your first set using the form above.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Template selection
|
||||
function useTemplate(name, description) {
|
||||
document.getElementById('name').value = name;
|
||||
document.getElementById('description').value = description;
|
||||
}
|
||||
|
||||
// Quick QR Modal
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const quickQrBtn = document.getElementById('quickQrBtn');
|
||||
const quickQrModal = document.getElementById('quickQrModal');
|
||||
const closeModal = document.getElementById('closeModal');
|
||||
const quickQrForm = document.getElementById('quickQrForm');
|
||||
const qrResult = document.getElementById('qr-result');
|
||||
const qrImage = document.getElementById('qr-image');
|
||||
|
||||
quickQrBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
quickQrModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
closeModal.addEventListener('click', function() {
|
||||
quickQrModal.classList.add('hidden');
|
||||
qrResult.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
quickQrModal.addEventListener('click', function(e) {
|
||||
if (e.target === quickQrModal) {
|
||||
quickQrModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
quickQrForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const points = document.getElementById('qr-points').value;
|
||||
|
||||
// Generate QR code
|
||||
qrImage.innerHTML = '<img src="/qr/generate/' + points + '" alt="QR Code" class="mx-auto" />';
|
||||
qrResult.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,273 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<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">
|
||||
<i class="fas fa-arrow-left"></i> Back to Dashboard
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-irish-green">{{ qr_set.name }}</h1>
|
||||
</div>
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<!-- QR Code Creation -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Add QR Code</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Create QR codes for points and achievements in this set
|
||||
</p>
|
||||
|
||||
<form id="qrCodeForm" action="/qr/sets/{{ qr_set.id }}/codes" method="post" class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
|
||||
<input type="text" name="title" id="title" required
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||
placeholder="e.g., 1st Place, Most Creative, etc.">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="points" class="block text-sm font-medium text-gray-700">Points</label>
|
||||
<input type="number" name="points" id="points" step="0.1" min="0" value="10"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="achievement_name" class="block text-sm font-medium text-gray-700">Achievement (Optional)</label>
|
||||
<input type="text" name="achievement_name" id="achievement_name"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||
placeholder="e.g., Quiz Champion, Top Scorer">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_achievement_only" id="is_achievement_only" class="h-4 w-4 text-irish-green focus:ring-irish-green border-gray-300 rounded">
|
||||
<label for="is_achievement_only" class="ml-2 block text-sm text-gray-700">
|
||||
Achievement only (no points)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700">Description (Optional)</label>
|
||||
<textarea name="description" id="description"
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||
placeholder="Description of this QR code" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded transition">
|
||||
Add QR Code
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Common QR Code Templates -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Quick Templates</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Click to quickly add common QR code types to this set
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button class="w-full border border-gray-200 rounded-md p-3 hover:bg-gray-50 text-left"
|
||||
onclick="useQRTemplate('1st Place', 25, 'First Place Winner', false, 'First place award (25 points)')">
|
||||
<div class="flex items-center">
|
||||
<div class="bg-yellow-500 rounded-full h-6 w-6 flex items-center justify-center mr-3">
|
||||
<i class="fas fa-trophy text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium">1st Place (25 points)</h3>
|
||||
<p class="text-xs text-gray-500">Top winner award</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="w-full border border-gray-200 rounded-md p-3 hover:bg-gray-50 text-left"
|
||||
onclick="useQRTemplate('2nd Place', 15, 'Second Place Winner', false, 'Second place award (15 points)')">
|
||||
<div class="flex items-center">
|
||||
<div class="bg-gray-400 rounded-full h-6 w-6 flex items-center justify-center mr-3">
|
||||
<i class="fas fa-medal text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium">2nd Place (15 points)</h3>
|
||||
<p class="text-xs text-gray-500">Runner-up award</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="w-full border border-gray-200 rounded-md p-3 hover:bg-gray-50 text-left"
|
||||
onclick="useQRTemplate('3rd Place', 10, 'Third Place Winner', false, 'Third place award (10 points)')">
|
||||
<div class="flex items-center">
|
||||
<div class="bg-amber-700 rounded-full h-6 w-6 flex items-center justify-center mr-3">
|
||||
<i class="fas fa-award text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium">3rd Place (10 points)</h3>
|
||||
<p class="text-xs text-gray-500">Third place award</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="w-full border border-gray-200 rounded-md p-3 hover:bg-gray-50 text-left"
|
||||
onclick="useQRTemplate('Estimate Winner', 0, 'Closest Guess Award', true, 'Achievement for closest estimate')">
|
||||
<div class="flex items-center">
|
||||
<div class="bg-blue-500 rounded-full h-6 w-6 flex items-center justify-center mr-3">
|
||||
<i class="fas fa-bullseye text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium">Estimate Winner</h3>
|
||||
<p class="text-xs text-gray-500">Achievement only (no points)</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Set Actions -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">QR Set Actions</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Create printable PDFs and admin codes for this set
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<a href="/qr/sets/{{ qr_set.id }}/pdf" class="block bg-irish-green text-white text-center font-bold py-3 px-4 rounded hover:opacity-90 transition">
|
||||
<i class="fas fa-file-pdf mr-2"></i> Generate PDF
|
||||
</a>
|
||||
|
||||
<div class="border rounded-md p-4">
|
||||
<h3 class="font-medium mb-2">Admin QR Code</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Use this special QR code to link all codes in this set to an event. Print this on each page.
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<img src="/qr/sets/{{ qr_set.id }}/generate-admin" alt="Admin QR Code" class="mx-auto h-32">
|
||||
</div>
|
||||
<p class="text-xs text-center mt-2">Scan to link all QR codes to an event</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<h3 class="font-medium text-red-600">Danger Zone</h3>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">These actions cannot be undone</p>
|
||||
<button id="deleteSetBtn" class="text-red-600 border border-red-600 rounded px-3 py-1 text-sm hover:bg-red-50">
|
||||
<i class="fas fa-trash-alt mr-1"></i> Delete Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Codes List -->
|
||||
<div class="mt-10">
|
||||
<h2 class="text-2xl font-bold text-irish-green mb-4">QR Codes in this Set</h2>
|
||||
|
||||
{% if qr_codes %}
|
||||
<div class="bg-white shadow overflow-hidden rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
QR Code
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Points
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Achievement
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for qr_code in qr_codes %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<img src="/qr/code/{{ qr_code.code }}" alt="QR Code" class="h-16 w-16">
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ qr_code.title or "Untitled" }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
Code: {{ qr_code.code[:10] }}...
|
||||
</div>
|
||||
{% if qr_code.description %}
|
||||
<div class="text-xs text-gray-500 mt-1">{{ qr_code.description }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-sm leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800' if qr_code.points > 0 else 'bg-gray-100 text-gray-800' }}">
|
||||
{{ qr_code.points }} pts
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{% if qr_code.achievement_name %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<i class="fas fa-award mr-1"></i>
|
||||
{{ qr_code.achievement_name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if qr_code.used %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Redeemed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Available
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="{{ base_url }}/redeem/{{ qr_code.code }}" target="_blank" class="text-irish-green hover:underline">
|
||||
<i class="fas fa-external-link-alt mr-1"></i> View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md p-6 text-center">
|
||||
<i class="fas fa-qrcode text-gray-400 text-3xl mb-3"></i>
|
||||
<p class="text-gray-600">No QR codes in this set yet. Add your first QR code using the form.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// QR Code template function
|
||||
function useQRTemplate(title, points, achievement, isAchievementOnly, description) {
|
||||
document.getElementById('title').value = title;
|
||||
document.getElementById('points').value = points;
|
||||
document.getElementById('achievement_name').value = achievement;
|
||||
document.getElementById('is_achievement_only').checked = isAchievementOnly;
|
||||
document.getElementById('description').value = description;
|
||||
|
||||
// Scroll to the form
|
||||
document.getElementById('qrCodeForm').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Delete Set confirmation
|
||||
document.getElementById('deleteSetBtn').addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to delete this entire set? This action cannot be undone.')) {
|
||||
// Send delete request - this would need an endpoint implementation
|
||||
alert('Delete functionality will be implemented in a future update');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,42 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8 text-center">
|
||||
<!-- Success Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-8 mb-6 text-center">
|
||||
<div class="mb-6">
|
||||
<div class="inline-block p-4 rounded-full bg-green-100">
|
||||
<i class="fas fa-check-circle text-irish-green text-5xl"></i>
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-green-100 mb-6">
|
||||
<i class="fas fa-check text-green-600 text-5xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-irish-green">Success!</h1>
|
||||
|
||||
{% if achievement %}
|
||||
<p class="text-gray-600 mt-2">Your team has earned an achievement and points!</p>
|
||||
{% else %}
|
||||
<p class="text-gray-600 mt-2">Your team has earned points!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Success!</h1>
|
||||
<p class="text-xl mb-2">You've earned <span class="font-bold">{{ points }}</span> points</p>
|
||||
<p class="text-gray-600 mb-6">Points have been added to <span class="font-semibold">{{ team.name }}</span></p>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-irish-green bg-opacity-10 rounded-lg border border-irish-green">
|
||||
<p class="font-bold text-irish-green mb-1">Points Earned:</p>
|
||||
<p class="text-3xl">{{ points }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-irish-green bg-opacity-10 rounded-lg border border-irish-green text-left mb-6">
|
||||
<h3 class="font-bold text-irish-green mb-2">What's next?</h3>
|
||||
<ul class="list-disc ml-5 text-gray-700">
|
||||
<li>Check your team's position on the <a href="/leaderboard" class="text-irish-green hover:underline">leaderboard</a></li>
|
||||
<li>Scan another QR code to earn more points</li>
|
||||
<li>Invite friends to your team</li>
|
||||
</ul>
|
||||
{% if achievement %}
|
||||
<div class="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p class="font-bold text-blue-800 mb-1">Achievement Unlocked:</p>
|
||||
<div class="flex items-center justify-center">
|
||||
<i class="fas fa-award text-amber-500 text-xl mr-2"></i>
|
||||
<p class="text-2xl text-blue-800">{{ achievement }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<p class="font-bold mb-1">Team:</p>
|
||||
<p>{{ team.name }}</p>
|
||||
</div>
|
||||
|
||||
{% if event %}
|
||||
<div class="p-4 bg-purple-50 rounded-lg border border-purple-100">
|
||||
<p class="font-bold text-purple-800 mb-1">Event:</p>
|
||||
<p>{{ event.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ event.event_date.strftime('%Y-%m-%d') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-center space-y-3 sm:space-y-0 sm:space-x-4">
|
||||
<a href="/dashboard" class="bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
|
||||
Go to Dashboard
|
||||
<!-- Next Steps -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-xl font-semibold text-irish-green mb-4">What's Next?</h3>
|
||||
<div class="space-y-4">
|
||||
<a href="/teams/{{ team.id }}" class="flex items-start hover:bg-gray-50 p-3 rounded-md cursor-pointer">
|
||||
<div class="bg-cream-white p-2 rounded-full mr-3">
|
||||
<i class="fas fa-users text-irish-green"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">View Team Profile</p>
|
||||
<p class="text-gray-600 text-sm">Check your team's achievements and points.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/dashboard/scan" class="bg-cream-white border border-irish-green text-irish-green font-medium py-2 px-4 rounded-md hover:bg-irish-green hover:text-white transition">
|
||||
Scan Another Code
|
||||
|
||||
<a href="/leaderboard" class="flex items-start hover:bg-gray-50 p-3 rounded-md cursor-pointer">
|
||||
<div class="bg-cream-white p-2 rounded-full mr-3">
|
||||
<i class="fas fa-trophy text-irish-green"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">View Leaderboard</p>
|
||||
<p class="text-gray-600 text-sm">See how your team ranks compared to others.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/scan" class="flex items-start hover:bg-gray-50 p-3 rounded-md cursor-pointer">
|
||||
<div class="bg-cream-white p-2 rounded-full mr-3">
|
||||
<i class="fas fa-qrcode text-irish-green"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Scan Another QR Code</p>
|
||||
<p class="text-gray-600 text-sm">Got more codes to redeem? Scan them now.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-gray-600">
|
||||
<p>Share your achievement!</p>
|
||||
<div class="flex justify-center space-x-4 mt-2">
|
||||
<a href="#" class="text-blue-600 hover:text-opacity-80"><i class="fab fa-facebook fa-lg"></i></a>
|
||||
<a href="#" class="text-blue-400 hover:text-opacity-80"><i class="fab fa-twitter fa-lg"></i></a>
|
||||
<a href="#" class="text-green-600 hover:text-opacity-80"><i class="fab fa-whatsapp fa-lg"></i></a>
|
||||
<!-- Share -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-gray-500 mb-3">Share your achievement</p>
|
||||
<div class="flex justify-center space-x-4">
|
||||
<a href="https://twitter.com/intent/tweet?text=I just earned {{ points }} points{% if achievement %} and the '{{ achievement }}' achievement{% endif %} at {{ base_url }}!"
|
||||
target="_blank" class="text-blue-400 hover:text-blue-500">
|
||||
<i class="fab fa-twitter text-2xl"></i>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ base_url }}"
|
||||
target="_blank" class="text-blue-600 hover:text-blue-700">
|
||||
<i class="fab fa-facebook text-2xl"></i>
|
||||
</a>
|
||||
<a href="https://api.whatsapp.com/send?text=I just earned {{ points }} points{% if achievement %} and the '{{ achievement }}' achievement{% endif %} at {{ base_url }}!"
|
||||
target="_blank" class="text-green-500 hover:text-green-600">
|
||||
<i class="fab fa-whatsapp text-2xl"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Terms of Service</h1>
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-4">Nutzungsbedingungen</h1>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Acceptable Use</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Nutzungsregeln</h2>
|
||||
<ul class="list-disc ml-6 mb-4">
|
||||
<li>No cheating or unfair practices</li>
|
||||
<li>Respectful communication with other users</li>
|
||||
<li>Compliance with all applicable laws and regulations</li>
|
||||
<li>Kein Betrug oder unfaire Praktiken</li>
|
||||
<li>Respektvolle Kommunikation mit anderen Nutzern</li>
|
||||
<li>Einhaltung aller anwendbaren Gesetze und Vorschriften</li>
|
||||
</ul>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Liability Disclaimer</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Haftungsausschluss</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Governing Law</h2>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Geltendes Recht</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Widerrufsbelehrung</h2>
|
||||
<p class="mb-4">
|
||||
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.
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Änderungen der Nutzungsbedingungen</h2>
|
||||
<p class="mb-4">
|
||||
Wir behalten uns das Recht vor, diese Nutzungsbedingungen jederzeit zu ändern. Die aktuelle Version ist stets auf dieser Seite verfügbar.
|
||||
</p>
|
||||
<p>Christian Louis IT Beratung und Medienproduktion ist verantwortlich für den Betrieb dieser Plattform.</p>
|
||||
|
||||
<p class="mt-8 text-sm text-gray-600">
|
||||
Letzte Aktualisierung: April 2025
|
||||
</p>
|
||||
<p>Christian Louis IT Beratung und Medienproduktion is responsible for the operation of this platform.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Import your view modules here
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# Mark the QR code as redeemed
|
||||
qr_code.redeemed_at_team = team.id
|
||||
qr_code.redeemed_at = datetime.now()
|
||||
qr_code.used = True
|
||||
|
||||
# If we have redeemed_at column, update it
|
||||
if hasattr(ticket, 'redeemed_at'):
|
||||
from datetime import datetime
|
||||
ticket.redeemed_at = datetime.now()
|
||||
# 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."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,3 +29,7 @@ qrcode>=7.4.2
|
||||
|
||||
# Image processing library for QR code generation
|
||||
Pillow>=9.0.0
|
||||
|
||||
# PDF generation for QR code sheets
|
||||
reportlab>=3.6.12
|
||||
babel>=2.12.1
|
||||
|
||||
@@ -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()
|
||||