Implement user authentication and management system with FastAPI

- Added authentication routes for login, registration, password reset, and account deletion in `auth.py`.
- Implemented user profile management, including updating user information and changing passwords.
- Created dashboard views to display user-specific information and recent activity in `dashboard.py`.
- Developed leaderboard views to show team rankings and points in `leaderboard.py`.
- Added QR code generation and redemption functionality in `qr.py` and `redeem.py`.
- Implemented team management features, allowing users to create and join teams in `teams.py`.
- Introduced session debugging utility to assist with session-related issues in `debug_session.py`.
- Configured Docker Compose for MySQL database and FastAPI application with environment variables.
- Updated requirements.txt to include necessary dependencies for the application.
This commit is contained in:
Christian Krakau-Louis
2025-04-11 17:46:59 +02:00
parent 90bef433fd
commit 0de2eb5830
43 changed files with 4427 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Use Python 3.11 (or whichever version you prefer)
FROM python:3.11-slim
# Create working directory
WORKDIR /app
# Install system dependencies needed for mysqlclient
RUN apt-get update && apt-get install -y \
pkg-config \
default-libmysqlclient-dev \
build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first (for caching)
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the code
COPY . .
# Expose port
EXPOSE 8000
# Run the FastAPI app with Uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""
Skeleton for authentication logic.
Placeholder for OAuth or password-based login.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .db import SessionLocal
from . import models
from passlib.context import CryptContext
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Add routes for login, logout, register, etc.
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
import os
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker, declarative_base
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME", "pubquiz_db")
DB_USER = os.getenv("DB_USER", "pubquiz_user")
DB_PASS = os.getenv("DB_PASS", "pubquiz_pass")
SQLALCHEMY_DATABASE_URL = (
f"mysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
)
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def init_db():
"""Initialize the database with all tables."""
# Import all models to ensure they're loaded
from . import models
# Create all tables if they don't exist
Base.metadata.create_all(bind=engine)
# Check for missing columns and add them
print("Checking for schema updates...")
migrate_schema()
def migrate_schema():
"""Apply schema migrations for existing tables."""
try:
connection = engine.connect()
inspector = inspect(engine)
# Check User table
if 'users' in inspector.get_table_names():
columns = [col['name'] for col in inspector.get_columns('users')]
# Add all missing columns for User table
user_columns = {
'created_at': "ALTER TABLE users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
'is_active': "ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE",
'is_verified': "ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT FALSE",
'verification_token': "ALTER TABLE users ADD COLUMN verification_token VARCHAR(255)",
'reset_token': "ALTER TABLE users ADD COLUMN reset_token VARCHAR(255)",
'reset_token_expires_at': "ALTER TABLE users ADD COLUMN reset_token_expires_at TIMESTAMP NULL",
'last_login': "ALTER TABLE users ADD COLUMN last_login TIMESTAMP NULL",
'is_admin': "ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE" # Add is_admin column
}
for col_name, sql in user_columns.items():
if col_name not in columns:
print(f"Adding {col_name} column to users table")
try:
connection.execute(text(sql))
connection.commit()
except Exception as e:
print(f"Error adding column {col_name}: {e}")
# Check Team table
if 'teams' in inspector.get_table_names():
columns = [col['name'] for col in inspector.get_columns('teams')]
if 'is_public' not in columns:
print("Adding is_public column to teams table")
connection.execute(text(
"ALTER TABLE teams ADD COLUMN is_public BOOLEAN DEFAULT FALSE"
))
if 'created_at' not in columns:
print("Adding created_at column to teams table")
connection.execute(text(
"ALTER TABLE teams ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
))
if 'description' not in columns:
print("Adding description column to teams table")
connection.execute(text(
"ALTER TABLE teams ADD COLUMN description TEXT"
))
# Check TeamMembership table
if 'team_membership' in inspector.get_table_names():
columns = [col['name'] for col in inspector.get_columns('team_membership')]
if 'joined_at' not in columns:
print("Adding joined_at column to team_membership table")
connection.execute(text(
"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')]
if 'created_at' not in columns:
print("Adding created_at column to qr_tickets table")
connection.execute(text(
"ALTER TABLE qr_tickets ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
))
if 'redeemed_at' not in columns:
print("Adding redeemed_at column to qr_tickets 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)"
))
# Create OAuthAccount table if it doesn't exist
if 'oauth_accounts' not in inspector.get_table_names():
print("Creating oauth_accounts table")
connection.execute(text("""
CREATE TABLE oauth_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
provider VARCHAR(50),
provider_user_id VARCHAR(255),
access_token VARCHAR(255),
expires_at TIMESTAMP NULL,
refresh_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""))
# Create TeamAchievement table if it doesn't exist
if 'team_achievements' not in inspector.get_table_names():
print("Creating team_achievements table")
connection.execute(text("""
CREATE TABLE team_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT,
name VARCHAR(255) NOT NULL,
event_name VARCHAR(255),
description TEXT,
achieved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id)
)
"""))
connection.commit()
print("Schema migrations completed successfully")
except Exception as e:
print(f"Error during schema migration: {e}")
finally:
connection.close()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Seed the database with initial testing data.
"""
import random
from datetime import datetime, timedelta
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from .models import User, Team, TeamMembership, QRTicket
from .db import SessionLocal
def table_has_column(engine, table_name, column_name):
"""Check if a table has a specific column."""
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
return False
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def seed_db():
"""Seed the database with test data."""
db = SessionLocal()
try:
# Only seed if tables are empty
if db.query(User).count() > 0:
print("Database already has data. Skipping seeding.")
return
# Create users
users = [
User(username="john_quizmaster", email="john@example.com", hashed_password="password123"),
User(username="sarah_johnson", email="sarah@example.com", hashed_password="password123"),
User(username="mike_peters", email="mike@example.com", hashed_password="password123"),
User(username="emma_wilson", email="emma@example.com", hashed_password="password123"),
User(username="robert_brown", email="robert@example.com", hashed_password="password123"),
]
db.add_all(users)
db.commit()
# Create teams
has_is_public = table_has_column(db.bind, 'teams', 'is_public')
has_created_at = table_has_column(db.bind, 'teams', 'created_at')
has_description = table_has_column(db.bind, 'teams', 'description')
teams = []
for i, name in enumerate(["Quiz Wizards", "Trivia Titans", "Beer Brainiacs", "Knowledge Knights"]):
team_attrs = {"name": name}
if has_is_public:
team_attrs["is_public"] = i % 2 == 1 # Alternate public/private
if has_description:
team_attrs["description"] = f"A team of quiz enthusiasts called {name}"
teams.append(Team(**team_attrs))
db.add_all(teams)
db.commit()
# Create team memberships
has_joined_at = table_has_column(db.bind, 'team_membership', 'joined_at')
memberships = []
membership_data = [
# Quiz Wizards
(1, 1, True, 160),
(2, 1, False, 155),
(3, 1, False, 130),
(4, 1, False, 90),
(5, 1, False, 45),
# Trivia Titans
(2, 2, True, 150),
(1, 2, False, 145),
# Beer Brainiacs
(3, 3, True, 120),
]
for user_id, team_id, is_admin, days_ago in membership_data:
membership_attrs = {
"user_id": user_id,
"team_id": team_id,
"is_admin": is_admin
}
if has_joined_at:
membership_attrs["joined_at"] = datetime.now() - timedelta(days=days_ago)
memberships.append(TeamMembership(**membership_attrs))
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 some basic tickets
tickets = []
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))
ticket_attrs = {
"code": f"TICKET{i:03d}",
"points": points,
"redeemed_by": user_id,
"redeemed_at_team": team_id,
"used": True
}
if has_event_name:
ticket_attrs["event_name"] = random.choice(event_names)
tickets.append(QRTicket(**ticket_attrs))
db.add_all(tickets)
db.commit()
print("Database seeded successfully!")
except Exception as e:
print(f"Error seeding database: {e}")
finally:
db.close()
if __name__ == "__main__":
seed_db()
+110
View File
@@ -0,0 +1,110 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from typing import Optional
from datetime import datetime
from .db import SessionLocal, engine
from .models import User
from .security import SECRET_KEY, ALGORITHM
from .templates_config import templates
# OAuth2 scheme for token authentication
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
def get_db():
"""Database dependency."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Check if all required user columns exist
def get_available_user_columns():
inspector = inspect(engine)
if 'users' in inspector.get_table_names():
return [col['name'] for col in inspector.get_columns('users')]
return []
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
"""Get the current authenticated user based on the access token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# If no token, return None (not authenticated)
if not token:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
# Update last login time if column exists
columns = get_available_user_columns()
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
"""Check if the current user is active."""
if not current_user:
return None
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(current_user, 'is_active') and not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
# Improved session-based user lookup with better error handling and logging
async def get_user_from_session(request: Request):
"""Get current user from session with improved error handling"""
try:
if not hasattr(request, "session"):
print("No session attribute in request")
return None
user_id = request.session.get("user_id")
if not user_id:
print("No user_id in session")
return None
print(f"Looking up user with ID: {user_id}")
# Manually get a database session from get_db
db = next(get_db())
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
print(f"User with ID {user_id} not found in database")
# Clear invalid session data
request.session.clear()
return None
return user
finally:
db.close()
except Exception as e:
print(f"Error getting user from session: {str(e)}")
return None
# Template context processor to add user to all templates
async def add_user_to_templates(request: Request):
"""Add current user to all template contexts."""
user = await get_user_from_session(request)
return {"current_user": user}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
from fastapi import FastAPI, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from datetime import datetime
import os
from .db import init_db, engine
from . import models
from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard, auth
from .db_init import seed_db
from .dependencies import get_user_from_session
# Create tables on startup
init_db()
# Seed database with initial test data
# In a production app, you would handle this differently
seed_db()
app = FastAPI()
# IMPORTANT: Add SessionMiddleware FIRST before any other middleware
# This ensures session data is available to all other middleware and route handlers
app.add_middleware(
SessionMiddleware,
secret_key=os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions"),
max_age=int(os.environ.get("SESSION_MAX_AGE", 86400)), # 24 hours
same_site="lax", # Important for security while allowing redirects
https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true",
session_cookie="league_ledger_session", # Custom cookie name for clarity
)
# Add debugging middleware to help track sessions
@app.middleware("http")
async def debug_session_middleware(request, call_next):
"""Debug middleware to track session state"""
session_cookie = request.cookies.get("league_ledger_session")
print(f"Request path: {request.url.path}")
print(f"Has session attribute: {'session' in request.scope}")
print(f"Has session cookie: {session_cookie is not None}")
if "session" in request.scope:
print(f"Session data before: {dict(request.session)}")
response = await call_next(request)
if "session" in request.scope:
print(f"Session data after: {dict(request.session)}")
return response
# Update the template globals at app startup to access the request
@app.middleware("http")
async def add_user_to_request(request: Request, call_next):
# Print debugging information
print(f"Processing request to: {request.url.path}")
# Add user to request state so templates can access it
try:
if "session" in request.scope:
print("Session found in request scope")
if "user_id" in request.session:
print(f"User ID in session: {request.session['user_id']}")
# Get user from session
user = await get_user_from_session(request)
request.state.user = user
else:
print("No user_id in session")
request.state.user = None
else:
print("No session in request scope")
request.state.user = None
except Exception as e:
print(f"Error in middleware: {e}")
request.state.user = None
# Update template context with current user before processing the request
templates.env.globals["current_user"] = request.state.user
# Process the request
response = await call_next(request)
return response
# Routers
app.include_router(auth.router, prefix="/auth", tags=["Auth"]) # Auth router should be first
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.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"now": datetime.now,
"current_user": getattr(request.state, "user", None)
})
@app.get("/about", response_class=HTMLResponse)
def about(request: Request):
return templates.TemplateResponse("about.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
})
@app.get("/contact", response_class=HTMLResponse)
def contact(request: Request):
return templates.TemplateResponse("contact.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
})
@app.get("/privacy", response_class=HTMLResponse)
def privacy(request: Request):
return templates.TemplateResponse("privacy.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
})
@app.get("/terms", response_class=HTMLResponse)
def terms(request: Request):
return templates.TemplateResponse("terms.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
})
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Table
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .db import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=True)
created_at = Column(DateTime, server_default=func.now())
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True)
reset_token = Column(String(255), nullable=True)
reset_token_expires_at = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True)
# Relationship to teams
memberships = relationship("TeamMembership", back_populates="user")
class OAuthAccount(Base):
__tablename__ = "oauth_accounts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
provider = Column(String(50)) # e.g., "google", "facebook"
provider_user_id = Column(String(255))
access_token = Column(String(255))
expires_at = Column(DateTime, nullable=True)
refresh_token = Column(String(255), nullable=True)
created_at = Column(DateTime, server_default=func.now())
user = relationship("User")
class Team(Base):
__tablename__ = "teams"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
# Add fields for team detail view
is_public = Column(Boolean, default=False) # For team privacy setting
created_at = Column(DateTime, server_default=func.now()) # For team founded date
description = Column(Text, nullable=True) # Optional team description
# Relationship to memberships
memberships = relationship("TeamMembership", back_populates="team")
class TeamMembership(Base):
__tablename__ = "team_membership"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
team_id = Column(Integer, ForeignKey("teams.id"))
is_admin = Column(Boolean, default=False)
# Add joined_at to track when members joined
joined_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="memberships")
team = relationship("Team", back_populates="memberships")
class QRTicket(Base):
__tablename__ = "qr_tickets"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(128), unique=True, index=True) # Unique token
points = Column(Integer, default=0)
redeemed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
redeemed_at_team = Column(Integer, ForeignKey("teams.id"), nullable=True)
used = Column(Boolean, default=False)
# Add timestamps to track when tickets were created and redeemed
created_at = Column(DateTime, server_default=func.now())
redeemed_at = Column(DateTime, nullable=True)
# Add event name to track which quiz event this ticket belongs to
event_name = Column(String(255), nullable=True)
# New model for team achievements
class TeamAchievement(Base):
__tablename__ = "team_achievements"
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"
description = Column(Text, nullable=True)
achieved_at = Column(DateTime, server_default=func.now())
team = relationship("Team")
+18
View File
@@ -0,0 +1,18 @@
# Add is_admin field to User model if it's missing
from sqlalchemy import Column, Integer, String, Boolean
# ...existing imports...
class User(Base):
__tablename__ = "users"
# ...existing fields...
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True)
email = Column(String(100), unique=True, index=True)
password = Column(String(255))
# Add is_admin field if it doesn't exist
is_admin = Column(Boolean, default=False)
# ...existing methods...
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional, Dict, Any
from datetime import datetime
# User schemas
class UserBase(BaseModel):
username: str
email: EmailStr
class UserCreate(UserBase):
password: str
# Validators could be added here, like:
@field_validator('password')
def password_must_be_strong(cls, v):
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
return v
class UserLogin(BaseModel):
username_or_email: str
password: str
remember_me: bool = False
class UserUpdate(BaseModel):
username: Optional[str] = None
email: Optional[EmailStr] = None
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
created_at: datetime
is_active: bool
is_verified: bool
last_login: Optional[datetime] = None
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
user_id: int
username: str
class TokenData(BaseModel):
username: Optional[str] = None
class PasswordChange(BaseModel):
current_password: str
new_password: str
confirm_password: str
class PasswordReset(BaseModel):
token: str
new_password: str
confirm_password: str
# Team schemas
class TeamCreate(BaseModel):
name: str
class TeamOut(BaseModel):
id: int
name: str
class Config:
from_attributes = True
class TeamMembershipCreate(BaseModel):
user_id: int
team_id: int
is_admin: bool = False
# Validator to handle form data conversion
@field_validator('is_admin')
def parse_boolean(cls, v):
if isinstance(v, str):
return v.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
return v
+39
View File
@@ -0,0 +1,39 @@
from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
import secrets
import string
import bcrypt
# Password hashing
# Use a simpler CryptContext configuration to avoid bcrypt.__about__ error
pwd_context = CryptContext(schemes=["bcrypt"])
# JWT settings
SECRET_KEY = "CHANGE_THIS_TO_A_STRONG_SECRET_KEY_IN_PRODUCTION"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def verify_password(plain_password, hashed_password):
"""Verify if the plain password matches the hashed one."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
"""Hash a password for storing."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta = None):
"""Create JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def generate_token(length=32):
"""Generate a random token for email verification or password reset."""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for i in range(length))
+23
View File
@@ -0,0 +1,23 @@
{% 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>
<p class="mb-4">
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.
</p>
<p class="mb-4">
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.
</p>
<p class="mb-4">
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.
</p>
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Our Mission</h2>
<p class="mb-4">
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.
</p>
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Our Team</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.
</p>
</div>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6">
<!-- Header -->
<h1 class="text-2xl font-bold text-irish-green mb-6">
{% if is_new %}Create{% else %}Edit{% endif %} {{ display_name[:-1] if display_name.endswith('s') else display_name }}
</h1>
<!-- Form -->
<form method="post" action="/admin/{{ model_name }}{% if is_new %}/new{% else %}/{{ record.id }}{% endif %}" class="space-y-6">
{% for column_name, column_info in columns_info.items() %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<label for="{{ column_name }}" class="block text-sm font-medium text-gray-700">
{{ column_name|replace('_', ' ')|title }}
{% if column_info.primary_key %}
<span class="ml-1 text-xs text-irish-green">(Primary Key)</span>
{% endif %}
{% if column_info.foreign_key %}
<span class="ml-1 text-xs text-blue-600">(Foreign Key)</span>
{% endif %}
</label>
<div class="md:col-span-2">
{% if column_info.primary_key and is_new %}
<!-- For new records, primary key is often auto-generated -->
<input type="text" id="{{ column_name }}" name="{{ column_name }}" placeholder="Auto-generated"
class="bg-gray-100 border border-gray-300 text-gray-500 rounded-md px-3 py-2 w-full"
{% if not column_info.nullable %}disabled{% endif %}>
{% elif column_info.foreign_key and column_name in foreign_key_options %}
<!-- Foreign key dropdown -->
<select id="{{ column_name }}" name="{{ column_name }}"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-irish-green"
{% if not column_info.nullable %}required{% endif %}>
{% if column_info.nullable %}
<option value="">-- None --</option>
{% endif %}
{% for value, label in foreign_key_options[column_name] %}
<option value="{{ value }}" {% if record and record[column_name] == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% elif column_info.type.startswith('BOOLEAN') %}
<!-- Boolean field -->
<div class="flex items-center">
<input type="checkbox" id="{{ column_name }}" name="{{ column_name }}" class="h-5 w-5"
value="True" {% if record and record[column_name] %}checked{% endif %}>
<span class="ml-2 text-sm text-gray-600">Yes</span>
</div>
{% elif 'text' in column_info.type.lower() %}
<!-- Text area for longer text -->
<textarea id="{{ column_name }}" name="{{ column_name }}" rows="4"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-irish-green"
{% if not column_info.nullable %}required{% endif %}>{{ record[column_name] if record else '' }}</textarea>
{% else %}
<!-- Standard input field -->
<input type="{{ 'number' if 'int' in column_info.type.lower() else 'text' }}"
id="{{ column_name }}" name="{{ column_name }}"
value="{{ record[column_name] if record and record[column_name] is not none else '' }}"
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-irish-green"
{% if not column_info.nullable and not column_info.primary_key %}required{% endif %}>
{% endif %}
{% if column_info.nullable %}
<p class="text-xs text-gray-500 mt-1">Optional field</p>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Form buttons -->
<div class="flex justify-end space-x-4 pt-6 border-t">
<a href="/admin/{{ model_name }}" class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition">
Cancel
</a>
<button type="submit" class="px-4 py-2 bg-irish-green text-white rounded-md hover:bg-opacity-90 transition">
{% if is_new %}Create{% else %}Update{% endif %}
</button>
</div>
</form>
</div>
<!-- Back links -->
<div class="mt-6 text-center">
<a href="/admin/{{ model_name }}" class="text-irish-green hover:underline mr-4">
<i class="fas fa-list mr-1"></i> Back to {{ display_name }}
</a>
<a href="/admin/" class="text-irish-green hover:underline">
<i class="fas fa-tachometer-alt mr-1"></i> Back to Admin Dashboard
</a>
</div>
</div>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-5xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl font-bold text-irish-green mb-6">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for model_key, display_name in models %}
<div class="bg-cream-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow">
<h3 class="text-xl font-bold text-irish-green mb-3">{{ display_name }}</h3>
<div class="flex items-center justify-between mt-4">
<a href="/admin/{{ model_key }}" class="bg-irish-green text-white px-4 py-2 rounded-md hover:bg-opacity-90 transition">
Manage
</a>
<a href="/admin/{{ model_key }}/new" class="text-irish-green hover:underline">
<i class="fas fa-plus"></i> Add New
</a>
</div>
</div>
{% endfor %}
</div>
<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">
<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>
<a href="/teams/" 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-users mr-2"></i> View Teams
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-6xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6">
<!-- Header -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-irish-green">{{ display_name }}</h1>
<p class="text-gray-600">Manage your {{ display_name.lower() }} data</p>
</div>
<div class="mt-4 md:mt-0">
<a href="/admin/{{ model_name }}/new" class="bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
<i class="fas fa-plus mr-1"></i> Create New
</a>
</div>
</div>
<!-- Records Table -->
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="bg-irish-green text-white">
<th class="p-3 text-left">ID</th>
{% for column in columns %}
{% if column != 'id' %}
<th class="p-3 text-left">{{ column|replace('_', ' ')|title }}</th>
{% endif %}
{% endfor %}
<th class="p-3 text-center">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for record in records %}
<tr class="hover:bg-gray-50">
<td class="p-3 font-medium">{{ record.id }}</td>
{% for column in columns %}
{% if column != 'id' %}
<td class="p-3">
{% if columns_info[column].foreign_key %}
<span class="bg-gray-100 text-gray-800 px-2 py-1 rounded text-xs">FK: {{ record[column] }}</span>
{% elif record[column] is none %}
<span class="text-gray-400 italic">NULL</span>
{% elif columns_info[column].type.startswith('BOOLEAN') %}
{% if record[column] %}
<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs">Yes</span>
{% else %}
<span class="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs">No</span>
{% endif %}
{% else %}
{{ record[column]|string|truncate(50) }}
{% endif %}
</td>
{% endif %}
{% endfor %}
<td class="p-3 text-center">
<div class="flex justify-center space-x-2">
<a href="/admin/{{ model_name }}/{{ record.id }}" class="text-irish-green hover:text-opacity-70" title="Edit">
<i class="fas fa-edit"></i>
</a>
<a href="/admin/{{ model_name }}/{{ record.id }}/delete" class="text-guinness-red hover:text-opacity-70" title="Delete"
onclick="return confirm('Are you sure you want to delete this record?');">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
{% if not records %}
<tr>
<td colspan="{{ columns|length + 1 }}" class="p-4 text-center text-gray-500">
No records found
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="mt-6 flex justify-between items-center">
<div class="text-gray-600 text-sm">
Showing {{ (page - 1) * per_page + 1 }}-{{ [page * per_page, total_records]|min }} of {{ total_records }} records
</div>
<div class="flex space-x-1">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&per_page={{ per_page }}" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded">
&laquo; Prev
</a>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="px-3 py-1 bg-irish-green text-white rounded">{{ p }}</span>
{% elif p <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %}
<a href="?page={{ p }}&per_page={{ per_page }}" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded">
{{ p }}
</a>
{% elif p == 4 and page > 5 or p == total_pages - 3 and page < total_pages - 4 %}
<span class="px-3 py-1">...</span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&per_page={{ per_page }}" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded">
Next &raquo;
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- Back to Admin -->
<div class="mt-6 text-center">
<a href="/admin/" class="text-irish-green hover:underline">
<i class="fas fa-arrow-left mr-1"></i> Back to Admin Dashboard
</a>
</div>
</div>
{% endblock %}
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Log In</h1>
<!-- Messages/Alerts -->
{% if messages %}
{% for message in messages %}
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message.text }}
</div>
{% endfor %}
{% endif %}
<form action="/auth/login" method="post" class="space-y-4">
<input type="hidden" name="next" value="{{ next }}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username or Email</label>
<input
type="text"
id="username"
name="username"
value="{{ username or '' }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
</div>
<!-- Debug info -->
<div class="text-xs text-gray-500">
<p>Having trouble logging in? Make sure your browser accepts cookies.</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="remember"
name="remember"
class="h-4 w-4 text-irish-green focus:ring-irish-green border-gray-300 rounded"
>
<label for="remember" class="ml-2 block text-sm text-gray-700">Remember me</label>
</div>
<div>
<button
type="submit"
class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
>
Log In
</button>
</div>
<div class="text-center text-sm mt-4">
<a href="/auth/forgot-password" class="text-irish-green hover:text-opacity-80">Forgot password?</a>
</div>
</form>
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
<p class="text-gray-600">Don't have an account?</p>
<a href="/auth/register" class="block mt-2 bg-cream-white text-irish-green border border-irish-green py-2 px-4 rounded-md hover:bg-irish-green hover:text-white transition">
Create an account
</a>
</div>
<!-- OAuth login options (to be implemented later) -->
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<div class="flex justify-center space-x-4">
<button class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
<i class="fab fa-google mr-2"></i> Google
</button>
<button class="bg-gray-800 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
<i class="fab fa-github mr-2"></i> GitHub
</button>
</div>
<p class="text-center text-gray-500 text-xs mt-2">OAuth login coming soon</p>
</div>
</div>
</div>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<!-- Profile Header -->
<div class="bg-irish-green text-white p-6">
<div class="flex flex-col sm:flex-row items-center">
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover">
</div>
<div class="text-center sm:text-left">
<h1 class="text-2xl font-bold">{{ user.username }}</h1>
<p class="text-green-100">Member since {{ user.created_at.strftime('%B %Y') }}</p>
<p class="mt-2">
{% if user.is_verified %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Verified</span>
{% endif %}
<span class="bg-white text-irish-green text-xs px-2 py-1 rounded-full font-medium">{{ user.memberships|length }} Teams</span>
</p>
</div>
</div>
</div>
<!-- Messages/Alerts -->
{% if messages %}
<div class="p-4">
{% for message in messages %}
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message.text }}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Profile Content -->
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Personal Info -->
<div class="md:col-span-1">
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2>
<form action="/auth/update-profile" method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600">Display Name</label>
<div class="mt-1">
<input
type="text"
name="username"
value="{{ user.username }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">Email</label>
<div class="mt-1">
<input
type="email"
name="email"
value="{{ user.email }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div class="pt-4">
<button type="submit" class="w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
Update Profile
</button>
</div>
</form>
<div class="mt-6 pt-6 border-t">
<h3 class="text-lg font-medium text-irish-green mb-3">Change Password</h3>
<form action="/auth/change-password" method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600">Current Password</label>
<div class="mt-1">
<input
type="password"
name="current_password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">New Password</label>
<div class="mt-1">
<input
type="password"
name="new_password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">Confirm New Password</label>
<div class="mt-1">
<input
type="password"
name="confirm_password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<button type="submit" class="w-full bg-golden-ale hover:bg-opacity-90 text-black-stout font-medium py-2 px-4 rounded-md transition">
Change Password
</button>
</form>
</div>
</div>
<!-- Right Column: Stats & Teams -->
<div class="md:col-span-2">
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Statistics</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Total Points</p>
<p class="text-2xl font-bold text-irish-green">378</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">QR Codes Redeemed</p>
<p class="text-2xl font-bold text-irish-green">24</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Teams Joined</p>
<p class="text-2xl font-bold text-irish-green">{{ user.memberships|length }}</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Best Position</p>
<p class="text-2xl font-bold text-irish-green">#2</p>
</div>
</div>
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Teams</h2>
<div class="space-y-4">
{% for team_info in user_teams %}
<div class="border rounded-lg overflow-hidden">
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
<div class="font-medium">{{ team_info.team.name }}</div>
{% if team_info.is_admin %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
{% else %}
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
{% endif %}
</div>
<div class="px-4 py-3 flex justify-between items-center">
<div>
<p class="text-sm">Current Points: <span class="font-bold">187</span></p>
<p class="text-sm text-gray-600">Current Rank: #4</p>
</div>
<a href="/teams/{{ team_info.team.id }}" class="text-irish-green hover:underline text-sm">View Team</a>
</div>
</div>
{% else %}
<div class="text-center p-4 bg-gray-50 rounded-lg">
<p class="text-gray-600">You haven't joined any teams yet.</p>
</div>
{% endfor %}
<a href="/teams/" class="block text-center text-irish-green hover:underline text-sm">
<i class="fas fa-plus mr-1"></i> Join or Create Another Team
</a>
</div>
<!-- Account Management -->
<div class="mt-8 pt-6 border-t">
<h3 class="text-xl text-red-600 font-semibold mb-4">Danger Zone</h3>
<p class="text-gray-700 mb-4">These actions cannot be undone. Please be certain.</p>
<a href="/auth/delete-account" class="inline-block bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md">
Delete Account
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+94
View File
@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Create an Account</h1>
<!-- Messages/Alerts -->
{% if messages %}
{% for message in messages %}
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message.text }}
</div>
{% endfor %}
{% endif %}
<form action="/auth/register" method="post" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input
type="text"
id="username"
name="username"
value="{{ username or '' }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
<p class="text-xs text-gray-500 mt-1">Choose a unique username (3-30 characters, letters, numbers and underscores only)</p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
<p class="text-xs text-gray-500 mt-1">At least 8 characters</p>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
<input
type="password"
id="confirm_password"
name="confirm_password"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
</div>
<div class="flex items-start">
<input
type="checkbox"
id="terms"
name="terms"
required
class="h-4 w-4 mt-1 text-irish-green focus:ring-irish-green border-gray-300 rounded"
>
<label for="terms" class="ml-2 block text-sm text-gray-700">
I agree to the <a href="/terms" class="text-irish-green hover:underline" target="_blank">Terms of Service</a> and <a href="/privacy" class="text-irish-green hover:underline" target="_blank">Privacy Policy</a>
</label>
</div>
<div>
<button
type="submit"
class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
>
Create Account
</button>
</div>
</form>
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
<p class="text-gray-600">Already have an account?</p>
<a href="/auth/login" class="text-irish-green hover:underline">Log in</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-12">
<div class="bg-white p-8 rounded-lg shadow-md 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>
</div>
<h1 class="text-2xl font-bold text-irish-green mb-4">Registration Successful!</h1>
<p class="text-gray-600 mb-6">Your account has been created and you're now logged in.</p>
<div class="space-y-4">
<a href="/dashboard" class="block w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
Go to Dashboard
</a>
<a href="/teams/" class="block w-full bg-cream-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white font-medium py-2 px-4 rounded-md transition">
Join a Team
</a>
</div>
<div class="mt-8 pt-6 border-t border-gray-200">
<h2 class="font-semibold mb-2">What's Next?</h2>
<ul class="text-left text-sm space-y-2">
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Explore the pub quiz leaderboards</li>
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Join an existing team or create your own</li>
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Scan QR codes at quiz events to earn points</li>
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Complete your profile information</li>
</ul>
</div>
</div>
</div>
{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<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'],
}
}
}
}
</script>
<style>
body {
font-family: 'Inter', sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'EB Garamond', serif;
}
</style>
</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://via.placeholder.com/40x40" alt="LeagueLedger Logo" class="h-10 w-10">
<h1 class="text-2xl font-bold font-garamond">LeagueLedger</h1>
</div>
<!-- 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="/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>
<!-- User Authentication -->
{% if current_user %}
<div class="relative group">
<button class="flex items-center focus:outline-none">
<span class="mr-1">{{ current_user.username }}</span>
<i class="fas fa-chevron-down text-xs"></i>
</button>
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg overflow-hidden z-20 hidden group-hover:block">
<a href="/auth/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
<a href="/dashboard/" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<div class="border-t border-gray-100"></div>
<a href="/auth/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Logout</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="bg-white text-irish-green px-4 py-1 rounded-md hover:bg-cream-white transition-colors duration-200">
Log In
</a>
{% endif %}
</nav>
<!-- 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>
<!-- 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="/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>
<!-- Mobile Auth Links -->
{% if current_user %}
<a href="/auth/profile" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
<i class="fas fa-user"></i> {{ current_user.username }}
</a>
<a href="/auth/logout" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
{% else %}
<a href="/auth/login" class="bg-white text-irish-green py-2 px-3 rounded-md">
Log In
</a>
<a href="/auth/register" class="border border-white text-white py-2 px-3 rounded-md">
Sign Up
</a>
{% endif %}
</nav>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-grow container mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<!-- 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>
<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">
&copy; {{ 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>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{% 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>
<p class="mb-4">
Have questions, suggestions, or feedback? We'd love to hear from you!
</p>
<div class="mb-6">
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Contact Information</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>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>
</div>
</div>
{% endblock %}
+137
View File
@@ -0,0 +1,137 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- User Welcome -->
<div class="bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl md:text-3xl font-garamond text-irish-green mb-2">Welcome, {{ user.username }}!</h1>
<p class="text-gray-600">Here's your quiz league status</p>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white p-6 rounded-lg shadow-md text-center">
<i class="fas fa-users text-irish-green text-2xl mb-2"></i>
<h3 class="font-semibold text-lg mb-1">Your Teams</h3>
<p class="text-3xl font-bold">{{ team_count }}</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-md text-center">
<i class="fas fa-star text-golden-ale text-2xl mb-2"></i>
<h3 class="font-semibold text-lg mb-1">Total Points</h3>
<p class="text-3xl font-bold">{{ total_points }}</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-md text-center">
<i class="fas fa-trophy text-irish-green text-2xl mb-2"></i>
<h3 class="font-semibold text-lg mb-1">Best Ranking</h3>
<p class="text-3xl font-bold">{% if best_rank %}#{{ best_rank }}{% else %}-{% endif %}</p>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl md:text-2xl font-garamond font-semibold text-irish-green mb-4">Recent Activity</h2>
{% if recent_activity %}
<div class="space-y-3">
{% for activity in recent_activity %}
<div class="flex items-center border-b pb-3">
{% if activity.type == 'qr_redeem' %}
<div class="bg-green-100 p-2 rounded-full mr-3">
<i class="fas fa-qrcode text-irish-green"></i>
</div>
<div>
<p class="font-medium">You redeemed a QR code for {{ activity.points }} points</p>
<p class="text-sm text-gray-600">Team: {{ activity.team_name }} • {{ activity.date }}</p>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 italic">No recent activity to show</p>
{% endif %}
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-garamond font-semibold text-irish-green mb-4">Your Teams</h2>
{% if teams %}
<div class="space-y-3">
{% for team in teams %}
<div class="flex justify-between items-center border-b pb-2">
<div>
<p class="font-medium">{{ team.name }}</p>
<p class="text-sm text-gray-600">{{ team_points[team.id].points }} points • Rank #{{ team_points[team.id].rank }}</p>
</div>
<div class="flex items-center">
{% if team.id in admin_team_ids %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full mr-3">Admin</span>
{% endif %}
<a href="/teams/{{ team.id }}" class="text-irish-green hover:underline">View</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 italic">You haven't joined any teams yet</p>
{% endif %}
<div class="mt-4">
<a href="/teams/" class="inline-block bg-irish-green text-white px-4 py-2 rounded-md text-sm">
{% if teams %}Manage Teams{% else %}Join a Team{% endif %}
</a>
</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-md">
<h2 class="text-xl font-garamond font-semibold text-irish-green mb-4">Redeem Code</h2>
<p class="mb-4">Have a QR code from your quiz master? Scan or enter it here to claim points!</p>
<div class="flex flex-col space-y-3">
<a href="/dashboard/scan" class="bg-irish-green text-white px-4 py-2 rounded-md flex items-center justify-center">
<i class="fas fa-camera mr-2"></i> Scan QR Code
</a>
<form action="/redeem/manual" method="post" class="flex">
<input type="text" name="code" placeholder="Or enter code manually"
class="flex-grow border border-gray-300 rounded-l-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
<button type="submit" class="bg-golden-ale text-black-stout px-4 py-2 rounded-r-md">Submit</button>
</form>
</div>
</div>
</div>
<!-- Leaderboard Preview -->
<div class="bg-white p-6 rounded-lg shadow-md">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-garamond font-semibold text-irish-green">Leaderboard Preview</h2>
<a href="/leaderboard" class="text-irish-green hover:underline">View Full Leaderboard</a>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-irish-green text-white">
<tr>
<th class="py-2 px-4 text-left rounded-tl-lg">Rank</th>
<th class="py-2 px-4 text-left">Team</th>
<th class="py-2 px-4 text-right rounded-tr-lg">Points</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<!-- This would be populated with real data from the backend -->
<tr>
<td class="py-2 px-4 font-medium">1</td>
<td class="py-2 px-4">Quiz Masters</td>
<td class="py-2 px-4 text-right font-medium">287</td>
</tr>
<tr>
<td class="py-2 px-4 font-medium">2</td>
<td class="py-2 px-4">Trivia Titans</td>
<td class="py-2 px-4 text-right font-medium">215</td>
</tr>
<tr>
<td class="py-2 px-4 font-medium">3</td>
<td class="py-2 px-4">Beer Brainiacs</td>
<td class="py-2 px-4 text-right font-medium">194</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto mt-8 p-8 bg-white rounded-lg shadow-md">
<div class="text-center">
<i class="fas fa-exclamation-circle text-red-600 text-5xl mb-4"></i>
<h1 class="text-2xl font-bold mb-4">{{ error_title|default("Error") }}</h1>
<p class="text-gray-700 mb-6">{{ error_message|default("An error occurred. Please try again.") }}</p>
<div class="mt-8">
<a href="/" class="text-irish-green hover:underline mr-6">
<i class="fas fa-home mr-1"></i> Go to Home
</a>
<a href="javascript:history.back()" class="text-irish-green hover:underline">
<i class="fas fa-arrow-left mr-1"></i> Go Back
</a>
</div>
</div>
</div>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col items-center">
<!-- Hero Section -->
<section class="w-full py-8 md:py-16 text-center">
<h1 class="text-4xl md:text-6xl font-bold text-irish-green mb-4">PubQuiz League Tracker</h1>
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">Track your team's triumphs across quiz nights and climb the leaderboard.</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<a href="/teams/" class="bg-irish-green text-white font-semibold px-6 py-3 rounded-md shadow-md hover:bg-opacity-90 transition">Join a Team</a>
<a href="/dashboard" class="bg-golden-ale text-black-stout font-semibold px-6 py-3 rounded-md shadow-md hover:bg-opacity-90 transition">My Dashboard</a>
</div>
</section>
<!-- Features -->
<section class="w-full py-12 bg-white rounded-lg shadow-md my-8">
<div class="container mx-auto">
<h2 class="text-3xl font-bold text-irish-green text-center mb-10">How It Works</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 px-4">
<div class="flex flex-col items-center text-center">
<div class="bg-cream-white p-4 rounded-full mb-4">
<i class="fas fa-users text-irish-green text-3xl"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Create or Join Teams</h3>
<p>Join existing quiz teams or create your own to start competing.</p>
</div>
<div class="flex flex-col items-center text-center">
<div class="bg-cream-white p-4 rounded-full mb-4">
<i class="fas fa-qrcode text-irish-green text-3xl"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Scan QR Codes</h3>
<p>Earn points by scanning QR codes distributed by quiz masters.</p>
</div>
<div class="flex flex-col items-center text-center">
<div class="bg-cream-white p-4 rounded-full mb-4">
<i class="fas fa-trophy text-irish-green text-3xl"></i>
</div>
<h3 class="text-xl font-semibold mb-2">Climb the Leaderboard</h3>
<p>Track your progress and compete to be the top team in the league.</p>
</div>
</div>
</div>
</section>
<!-- Call to Action -->
<section class="w-full py-12 bg-irish-green rounded-lg shadow-md my-8 text-center">
<h2 class="text-3xl font-bold text-white mb-4">Ready to Join the League?</h2>
<p class="text-white text-xl mb-6">Get started now and track your pub quiz achievements!</p>
<a href="/register" class="bg-golden-ale text-black-stout font-semibold px-8 py-3 rounded-md shadow-md hover:bg-opacity-90 transition">Sign Up Now</a>
</section>
</div>
{% endblock %}
+117
View File
@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl md:text-3xl font-garamond font-bold text-irish-green">League Leaderboard</h1>
<p class="text-gray-600">See how your team ranks against the competition</p>
</div>
<div class="mt-4 md:mt-0">
<form method="get" action="/leaderboard/">
<select name="timeframe"
onchange="this.form.submit()"
class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green bg-white">
<option value="all" {% if timeframe == "all" %}selected{% endif %}>All Time</option>
<option value="month" {% if timeframe == "month" %}selected{% endif %}>This Month</option>
<option value="week" {% if timeframe == "week" %}selected{% endif %}>This Week</option>
</select>
</form>
</div>
</div>
<!-- Podium (on larger screens) -->
<div class="hidden md:flex justify-center items-end space-x-8 mb-12">
<!-- 2nd Place -->
{% if top_teams[1] %}
<div class="flex flex-col items-center">
<div class="w-20 h-20 rounded-full bg-white shadow-md flex items-center justify-center mb-4">
<span class="text-4xl font-bold text-gray-500">2</span>
</div>
<div class="bg-gray-100 w-32 h-32 flex flex-col items-center justify-center rounded-t-lg">
<p class="font-bold text-lg">{{ top_teams[1].name }}</p>
<p class="text-irish-green font-bold">{{ top_teams[1].points }} pts</p>
</div>
</div>
{% endif %}
<!-- 1st Place -->
{% if top_teams[0] %}
<div class="flex flex-col items-center">
<div class="w-24 h-24 rounded-full bg-golden-ale shadow-md flex items-center justify-center mb-4">
<span class="text-5xl font-bold text-white">1</span>
</div>
<div class="bg-gray-100 w-36 h-40 flex flex-col items-center justify-center rounded-t-lg shadow-md">
<p class="font-bold text-xl">{{ top_teams[0].name }}</p>
<p class="text-irish-green font-bold text-xl">{{ top_teams[0].points }} pts</p>
</div>
</div>
{% endif %}
<!-- 3rd Place -->
{% if top_teams[2] %}
<div class="flex flex-col items-center">
<div class="w-16 h-16 rounded-full bg-[#CD7F32] shadow-md flex items-center justify-center mb-4">
<span class="text-3xl font-bold text-white">3</span>
</div>
<div class="bg-gray-100 w-28 h-28 flex flex-col items-center justify-center rounded-t-lg">
<p class="font-bold">{{ top_teams[2].name }}</p>
<p class="text-irish-green font-bold">{{ top_teams[2].points }} pts</p>
</div>
</div>
{% endif %}
</div>
<!-- Timeframe Indicator -->
<div class="bg-irish-green bg-opacity-10 text-irish-green px-4 py-3 rounded-md text-center font-bold mb-6">
Showing {{ time_label }} Rankings
</div>
<!-- Leaderboard Table -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<table class="min-w-full">
<thead class="bg-irish-green text-white">
<tr>
<th class="py-3 px-4 text-left">Rank</th>
<th class="py-3 px-4 text-left">Team</th>
<th class="py-3 px-4 text-right">Points</th>
<th class="py-3 px-4 text-center">Change</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for team in teams %}
<tr class="{% if team.rank == 1 %}bg-golden-ale bg-opacity-10{% endif %} hover:bg-gray-50">
<td class="py-3 px-4 font-bold">{{ team.rank }}</td>
<td class="py-3 px-4">{{ team.name }}</td>
<td class="py-3 px-4 text-right font-bold">{{ team.points }}</td>
<td class="py-3 px-4 text-center">
{% if team.change > 0 %}
<span class="text-green-600"><i class="fas fa-arrow-up"></i> {{ team.change }}</span>
{% elif team.change < 0 %}
<span class="text-red-600"><i class="fas fa-arrow-down"></i> {{ team.change|abs }}</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not teams %}
<tr>
<td colspan="4" class="py-8 text-center text-gray-500">No teams found. Start a quiz league to see rankings here!</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Team Formation CTA -->
<div class="bg-irish-green rounded-lg shadow-md p-6 text-center text-white">
<h3 class="text-xl font-garamond font-bold mb-2">Ready to join the rankings?</h3>
<p class="mb-4">Create or join a team and start climbing the leaderboard today!</p>
<a href="/teams/" class="inline-block bg-white text-irish-green font-bold px-6 py-2 rounded-md hover:bg-gray-100 transition">
Join a Team
</a>
</div>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% 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>
<p class="mb-4">
Your privacy is important to us. This Privacy Policy outlines how LeagueLedger collects, uses, and protects your information.
</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>
<p class="mb-4">
We use your information to:
</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>
</ul>
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Data Sharing</h2>
<p class="mb-4">
We do not share your personal information with third parties except as required by law.
</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.
</p>
</div>
{% endblock %}
+144
View File
@@ -0,0 +1,144 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<!-- Profile Header -->
<div class="bg-irish-green text-white p-6">
<div class="flex flex-col sm:flex-row items-center">
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover">
</div>
<div class="text-center sm:text-left">
<h1 class="text-2xl font-bold">John Quizmaster</h1>
<p class="text-green-100">Member since October 2022</p>
<p class="mt-2">
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Quiz Master</span>
<span class="bg-white text-irish-green text-xs px-2 py-1 rounded-full font-medium">Team Captain</span>
</p>
</div>
</div>
</div>
<!-- Profile Content -->
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Personal Info -->
<div class="md:col-span-1">
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600">Display Name</label>
<div class="mt-1 flex">
<input type="text" value="John Quizmaster" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-l-md px-3 py-2">
<button class="bg-irish-green text-white px-3 py-2 rounded-r-md">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">Email</label>
<div class="mt-1 flex">
<input type="email" value="john@example.com" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-l-md px-3 py-2">
<button class="bg-irish-green text-white px-3 py-2 rounded-r-md">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">Password</label>
<div class="mt-1">
<button class="w-full bg-golden-ale hover:bg-opacity-90 text-black font-medium py-2 px-4 rounded-md transition">
Change Password
</button>
</div>
</div>
<div class="pt-4">
<button class="w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
Save Changes
</button>
</div>
</div>
</div>
<!-- Right Column: Stats & Teams -->
<div class="md:col-span-2">
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Statistics</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Total Points</p>
<p class="text-2xl font-bold text-irish-green">378</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">QR Codes Redeemed</p>
<p class="text-2xl font-bold text-irish-green">24</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Teams Joined</p>
<p class="text-2xl font-bold text-irish-green">3</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Best Position</p>
<p class="text-2xl font-bold text-irish-green">#2</p>
</div>
</div>
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Teams</h2>
<div class="space-y-4">
<div class="border rounded-lg overflow-hidden">
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
<div class="font-medium">Quiz Wizards</div>
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
</div>
<div class="px-4 py-3 flex justify-between items-center">
<div>
<p class="text-sm">Current Points: <span class="font-bold">187</span></p>
<p class="text-sm text-gray-600">Current Rank: #4</p>
</div>
<a href="/teams/1" class="text-irish-green hover:underline text-sm">View Team</a>
</div>
</div>
<div class="border rounded-lg overflow-hidden">
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
<div class="font-medium">Trivia Titans</div>
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
</div>
<div class="px-4 py-3 flex justify-between items-center">
<div>
<p class="text-sm">Current Points: <span class="font-bold">215</span></p>
<p class="text-sm text-gray-600">Current Rank: #2</p>
</div>
<a href="/teams/2" class="text-irish-green hover:underline text-sm">View Team</a>
</div>
</div>
<a href="/teams/" class="block text-center text-irish-green hover:underline text-sm">
<i class="fas fa-plus mr-1"></i> Join or Create Another Team
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Danger Zone -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold text-red-600 mb-4">Danger Zone</h3>
<p class="text-gray-600 mb-4">The following actions are irreversible. Please proceed with caution.</p>
<div class="flex flex-col sm:flex-row gap-4">
<button class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition">
Delete Account
</button>
<button class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition">
Leave All Teams
</button>
</div>
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-lg mx-auto">
<!-- QR Code Redemption Box -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<div class="text-center mb-6">
<i class="fas fa-qrcode text-irish-green text-5xl mb-4"></i>
<h1 class="text-2xl font-bold text-irish-green">QR Code Redemption</h1>
<p class="text-gray-600">Collect your points from the quiz master!</p>
</div>
<div class="mb-8 p-4 bg-irish-green bg-opacity-10 rounded-lg border border-irish-green text-center">
<p class="font-bold text-irish-green mb-1">Congratulations!</p>
<p class="text-lg">You've earned <span class="font-bold">{{ ticket.points }}</span> points</p>
<p class="text-sm text-gray-600 mt-2">Code: {{ ticket.code }}</p>
</div>
<form action="/redeem/apply/{{ ticket.code }}" method="post">
<div class="mb-6">
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-2">Select Team to Award Points:</label>
<select name="team_id" id="team_id" required class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
<option value="">-- Select a team --</option>
{% for team in user_teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="w-full bg-irish-green hover:bg-opacity-90 text-white font-bold py-3 px-4 rounded-md transition">
Redeem Points
</button>
</form>
<div class="mt-6 text-center">
<a href="/teams/" class="text-irish-green hover:underline text-sm">
<i class="fas fa-users mr-1"></i> Create a New Team
</a>
</div>
</div>
<!-- How It Works -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold text-irish-green mb-4">How It Works</h3>
<div class="space-y-4">
<div class="flex items-start">
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">1</span>
</div>
<div>
<p class="font-medium">Scan QR Code</p>
<p class="text-gray-600 text-sm">Scan the QR code provided by your quiz master.</p>
</div>
</div>
<div class="flex items-start">
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">2</span>
</div>
<div>
<p class="font-medium">Select Your Team</p>
<p class="text-gray-600 text-sm">Choose which team should receive these points.</p>
</div>
</div>
<div class="flex items-start">
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">3</span>
</div>
<div>
<p class="font-medium">Climb the Leaderboard</p>
<p class="text-gray-600 text-sm">Watch your team rise in the rankings!</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% 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">
<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>
</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="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>
</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
</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>
</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>
</div>
</div>
</div>
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-lg mx-auto space-y-6">
<div class="bg-white rounded-lg shadow-md p-6 text-center">
<h1 class="text-2xl font-garamond font-bold text-irish-green mb-3">Scan QR Code</h1>
<p class="text-gray-600 mb-6">Position the QR code from your quiz master in the camera view</p>
<!-- QR Scanner Container -->
<div class="relative bg-black rounded-lg overflow-hidden" style="height: 300px;">
<div id="qr-reader" class="w-full h-full"></div>
<div id="scanner-overlay" class="absolute inset-0 flex items-center justify-center">
<div class="text-white">
<i class="fas fa-camera text-4xl mb-2 opacity-70"></i>
<p>Camera loading...</p>
</div>
</div>
</div>
<div class="mt-4 text-sm text-gray-500">
Make sure the QR code is well-lit and clearly visible
</div>
<!-- Manual Entry Fallback -->
<div class="mt-8 border-t pt-6">
<h3 class="font-bold mb-3">Or enter code manually</h3>
<form action="/redeem/manual" method="post" class="flex">
<input type="text" name="code" placeholder="Enter code here" required
class="flex-grow border border-gray-300 rounded-l-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
<button type="submit" class="bg-irish-green text-white px-4 py-2 rounded-r-md">Submit</button>
</form>
</div>
</div>
<!-- How It Works -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold font-garamond text-irish-green mb-4">How It Works</h3>
<div class="space-y-4">
<div class="flex items-start">
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">1</span>
</div>
<div>
<p class="font-medium">Scan QR Code</p>
<p class="text-gray-600 text-sm">Scan the QR code provided by your quiz master.</p>
</div>
</div>
<div class="flex items-start">
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">2</span>
</div>
<div>
<p class="font-medium">Select Your Team</p>
<p class="text-gray-600 text-sm">Choose which team should receive these points.</p>
</div>
</div>
<div class="flex items-start">
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">3</span>
</div>
<div>
<p class="font-medium">Climb the Leaderboard</p>
<p class="text-gray-600 text-sm">Watch your team rise in the rankings!</p>
</div>
</div>
</div>
</div>
<!-- Back Link -->
<div class="text-center">
<a href="/dashboard" class="text-irish-green hover:underline">
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
</a>
</div>
</div>
<!-- HTML5 QR Code Scanner Library -->
<script src="https://unpkg.com/html5-qrcode"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const html5QrCode = new Html5Qrcode("qr-reader");
const qrOverlay = document.getElementById('scanner-overlay');
// Config
const config = { fps: 10, qrbox: 250 };
// Success function
function onScanSuccess(decodedText, decodedResult) {
// Stop scanning
html5QrCode.stop();
// Show loading message
qrOverlay.innerHTML = '<div class="text-white text-center"><i class="fas fa-circle-notch fa-spin text-3xl mb-3"></i><p>Code detected!</p><p>Redirecting...</p></div>';
qrOverlay.style.backgroundColor = 'rgba(0, 104, 55, 0.8)'; // Irish green with opacity
// Redirect to redeem page
window.location.href = '/redeem/' + decodedText;
}
// Start scanner
html5QrCode.start(
{ facingMode: "environment" },
config,
onScanSuccess,
(errorMessage) => {
// Handle error if needed
console.log(errorMessage);
})
.then(() => {
// Scanner started successfully
qrOverlay.style.display = 'none';
})
.catch((err) => {
// Update overlay with error message
qrOverlay.innerHTML = `
<div class="text-white text-center">
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
<p>Camera access denied or not available</p>
<p class="text-sm mt-2">Please use manual entry below</p>
</div>
`;
qrOverlay.style.backgroundColor = 'rgba(178, 34, 34, 0.8)'; // Guinness red with opacity
});
});
</script>
{% endblock %}
+189
View File
@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- Team Header -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-irish-green p-6">
<div class="flex flex-col md:flex-row justify-between items-center">
<div class="mb-4 md:mb-0">
<h1 class="text-2xl md:text-3xl font-bold text-white">{{ team.name }}</h1>
<div class="flex items-center space-x-2 text-green-100">
<span><i class="fas fa-trophy mr-1"></i> Rank #{{ team_rank }}</span>
<span class="hidden md:inline"></span>
<span><i class="fas fa-star mr-1"></i> {{ total_points }} Points</span>
<span class="hidden md:inline"></span>
<span><i class="fas fa-users mr-1"></i> {{ team_members|length }} Members</span>
</div>
</div>
<div>
<button class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
<i class="fas fa-share-alt mr-1"></i> Share Team
</button>
</div>
</div>
</div>
<!-- Team Stats -->
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h3 class="text-sm font-medium text-gray-500 mb-1">POINTS THIS MONTH</h3>
<p class="text-2xl font-bold">{{ points_this_month }}</p>
<p class="text-sm {% if point_change_positive %}text-green-600{% else %}text-red-600{% endif %}">
<i class="fas {% if point_change_positive %}fa-arrow-up{% else %}fa-arrow-down{% endif %} mr-1"></i>
{{ point_change|abs }} from last month
</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 mb-1">BEST PERFORMANCE</h3>
<p class="text-2xl font-bold">1st Place</p>
<p class="text-sm text-gray-600">August 12, 2023</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-500 mb-1">TEAM FOUNDED</h3>
<p class="text-2xl font-bold">{{ days_ago }} days ago</p>
<p class="text-sm text-gray-600">{{ founded_date }}</p>
</div>
</div>
</div>
</div>
<!-- Team Members & Performance -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Team Members -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-irish-green">Team Members</h2>
<button class="text-irish-green hover:text-opacity-80">
<i class="fas fa-user-plus"></i> Invite
</button>
</div>
<div class="space-y-4">
{% for member in team_members %}
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full bg-gray-200 mr-3 overflow-hidden">
<img src="https://via.placeholder.com/40" alt="User" class="w-full h-full object-cover">
</div>
<div>
<p class="font-medium">{{ member.user.username }}</p>
<p class="text-xs text-gray-500">Joined {{ member.joined }}</p>
</div>
</div>
{% if member.is_admin %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
{% else %}
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Performance Chart -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Team Performance</h2>
<!-- Placeholder for a chart - in real app, use Chart.js or similar -->
<div class="bg-gray-100 p-4 rounded-lg h-64 flex items-center justify-center">
<div class="text-center">
<i class="fas fa-chart-line text-5xl text-irish-green mb-2"></i>
<p class="text-gray-500">Performance chart would appear here</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div class="flex justify-between items-center">
<p>Last quiz night</p>
<p class="font-medium">{{ performance.last_quiz }}</p>
</div>
<div class="flex justify-between items-center">
<p>Average per quiz</p>
<p class="font-medium">{{ performance.average }}</p>
</div>
<div class="flex justify-between items-center">
<p>Best streak</p>
<p class="font-medium">{{ performance.best_streak }}</p>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Recent Activity</h2>
<div class="space-y-4">
{% for activity in activities %}
{% if activity.type == 'points' %}
<div class="flex items-start">
<div class="bg-green-100 p-2 rounded-full mr-3">
<i class="fas fa-trophy text-irish-green"></i>
</div>
<div>
<p class="font-medium">Earned {{ activity.points }} points in "{{ activity.event }}"</p>
<p class="text-sm text-gray-600">{{ activity.date }}</p>
</div>
</div>
{% elif activity.type == 'join' %}
<div class="flex items-start">
<div class="bg-blue-100 p-2 rounded-full mr-3">
<i class="fas fa-user-plus text-blue-700"></i>
</div>
<div>
<p class="font-medium">{{ activity.user }} joined the team</p>
<p class="text-sm text-gray-600">{{ activity.date }}</p>
</div>
</div>
{% elif activity.type == 'achievement' %}
<div class="flex items-start">
<div class="bg-golden-ale bg-opacity-20 p-2 rounded-full mr-3">
<i class="fas fa-medal text-golden-ale"></i>
</div>
<div>
<p class="font-medium">Achieved {{ activity.achievement }} in "{{ activity.event }}"</p>
<p class="text-sm text-gray-600">{{ activity.date }}</p>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="mt-6 text-center">
<a href="#" class="text-irish-green hover:underline">View Full History</a>
</div>
</div>
<!-- Team Management -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Team Management</h2>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Team Name</label>
<div class="flex">
<input type="text" value="{{ team.name }}" class="flex-grow border border-gray-300 rounded-l-md px-3 py-2" {% if not is_user_admin %}disabled{% endif %}>
<button class="bg-irish-green text-white px-4 py-2 rounded-r-md" {% if not is_user_admin %}disabled{% endif %}>Save</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Team Privacy</label>
<div class="flex items-center">
<input type="checkbox" id="public-team" name="is_public" class="mr-2"
{% if team.is_public %}checked{% endif %}
{% if not is_user_admin %}disabled{% endif %}>
<label for="public-team">Make team publicly joinable (no invitation needed)</label>
</div>
</div>
<div class="pt-4 border-t">
<button class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md">
Leave Team
</button>
</div>
</div>
</div>
</div>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<h2 class="text-2xl font-bold mb-6">Teams</h2>
<div class="grid md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Available Teams</h3>
{% if teams %}
<ul class="space-y-3">
{% for team in teams %}
<li class="border-b pb-2 flex justify-between items-center">
<span class="font-medium">{{ team.name }}</span>
<form action="/teams/join/{{ team.id }}" method="post" class="inline">
<button type="submit"
class="px-3 py-1 text-sm rounded-md"
style="background-color: var(--golden-ale); color: var(--black-stout);">
Join Team
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="italic text-gray-500">No teams available yet.</p>
{% endif %}
</div>
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Create New Team</h3>
<form action="/teams/create" method="post" class="mt-4">
<div class="mb-4">
<label for="name" class="block text-sm font-medium mb-1">Team Name</label>
<input type="text" name="name" id="name" placeholder="Enter team name" required
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
style="border-color: var(--irish-green); focus:ring-color: var(--irish-green);">
</div>
<button type="submit"
class="w-full px-4 py-2 text-white rounded-md font-medium"
style="background-color: var(--irish-green);">
Create Team
</button>
</form>
</div>
</div>
<div class="mt-10 bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">About Teams</h3>
<p class="mb-4">
Teams are the heart of LeagueLedger. Join an existing team or create your own to start tracking your pub quiz triumphs!
</p>
<p class="italic">
Every point counts in the journey to becoming pub quiz champions.
</p>
</div>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% 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>
<p class="mb-4">
Welcome to LeagueLedger! By using our platform, you agree to comply with the following terms and conditions.
</p>
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Acceptable Use</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>
</ul>
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Liability Disclaimer</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.
</p>
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Governing Law</h2>
<p class="mb-4">
These terms shall be governed by and construed in accordance with the laws of Germany.
</p>
<p class="mb-4">
This project is part of the KaufDeinQuiz platform and is operated as an Open-Source initiative. All rights reserved.
</p>
<p>Christian Louis IT Beratung und Medienproduktion is responsible for the operation of this platform.</p>
</div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
from fastapi.templating import Jinja2Templates
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from starlette.templating import _TemplateResponse
from datetime import datetime
from typing import Any
BASE_DIR = Path(__file__).resolve().parent
# Create a Jinja2 Environment with the 'jinja2.ext.do' extension
class MyJinjaTemplates(Jinja2Templates):
def __init__(self, directory: str, **kwargs: Any):
super().__init__(directory=directory, **kwargs)
self.env.add_extension('jinja2.ext.do')
self.env.globals['now'] = datetime.now # Add 'now' function
# Create templates object that can be imported elsewhere
templates = MyJinjaTemplates(directory=str(BASE_DIR / "templates"))
# Register a context processor to add current_user to all templates
templates.env.globals["get_current_user"] = lambda: None # Will be overridden at runtime
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
Admin interface for managing database records.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect
import json
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 ..templates_config import templates
router = APIRouter()
# Dictionary of model classes with their display names
MODELS = {
'user': (User, "Users"),
'team': (Team, "Teams"),
'team_membership': (TeamMembership, "Team Memberships"),
'qr_ticket': (QRTicket, "QR Tickets"),
}
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_model_info(model_class: Type[Base]) -> Dict[str, Dict[str, Any]]:
"""Get column information for a model."""
mapper = inspect(model_class)
columns = {}
for column in mapper.columns:
is_primary = column.primary_key
is_foreign_key = bool(column.foreign_keys)
foreign_key_target = None
if is_foreign_key:
for fk in column.foreign_keys:
foreign_key_target = fk.target_fullname
columns[column.name] = {
'type': str(column.type),
'nullable': column.nullable,
'primary_key': is_primary,
'foreign_key': is_foreign_key,
'foreign_key_target': foreign_key_target,
}
return columns
def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
"""Get relationship information for a model."""
relationships = {}
for name, rel in py_inspect.getmembers(model_class, lambda o: hasattr(o, 'prop')):
if hasattr(rel.prop, 'target'):
relationships[name] = rel.prop.target.name
return relationships
@router.get("/", response_class=HTMLResponse)
async def admin_home(request: Request):
"""Admin dashboard home."""
model_list = [(key, name) for key, (_, name) in MODELS.items()]
return templates.TemplateResponse(
"admin/index.html",
{"request": request, "models": model_list}
)
@router.get("/{model_name}", response_class=HTMLResponse)
async def list_records(
request: Request,
model_name: str,
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=5, le=100),
db: Session = Depends(get_db)
):
"""List records for a model with pagination."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get total count for pagination
total_records = db.query(model_class).count()
total_pages = (total_records + per_page - 1) // per_page
# Get records with pagination
records = db.query(model_class).offset((page - 1) * per_page).limit(per_page).all()
# Get column information
columns_info = get_model_info(model_class)
# Prepare column names for display
column_names = list(columns_info.keys())
# Extract values for each record
records_data = []
for record in records:
record_data = {}
for col in column_names:
record_data[col] = getattr(record, col)
records_data.append(record_data)
return templates.TemplateResponse(
"admin/list.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"records": records_data,
"columns": column_names,
"columns_info": columns_info,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"total_records": total_records,
}
)
@router.get("/{model_name}/new", response_class=HTMLResponse)
async def create_record_form(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Show form for creating a new record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get column information
columns_info = get_model_info(model_class)
# For foreign keys, fetch possible values
foreign_key_options = {}
for col_name, info in columns_info.items():
if info['foreign_key'] and info['foreign_key_target']:
target_table, target_col = info['foreign_key_target'].split('.')
# Try to find the corresponding model class
for model_key, (model_cls, _) in MODELS.items():
if model_cls.__tablename__ == target_table:
# Fetch options for this foreign key
options = db.query(model_cls).all()
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
return templates.TemplateResponse(
"admin/edit.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"columns_info": columns_info,
"record": None, # No record for new form
"foreign_key_options": foreign_key_options,
"is_new": True
}
)
@router.post("/{model_name}/new")
async def create_record(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Create a new record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get form data from request
form_data = await request.form()
# Convert form data to appropriate types
columns_info = get_model_info(model_class)
record_data = {}
for field_name, value in form_data.items():
if field_name in columns_info:
col_type = columns_info[field_name]['type'].lower()
# Skip empty values for nullable fields
if value == '' and columns_info[field_name]['nullable']:
continue
# Convert values based on column type
if 'int' in col_type:
if value:
record_data[field_name] = int(value)
elif 'bool' in col_type or 'boolean' in col_type:
record_data[field_name] = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
else:
record_data[field_name] = value
# Skip primary key for new records if it's auto-increment
for col_name, info in columns_info.items():
if info['primary_key'] and col_name not in record_data:
pass # Skip primary key
# Create record
new_record = model_class(**record_data)
db.add(new_record)
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
async def edit_record_form(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Show form for editing an existing record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Get column information
columns_info = get_model_info(model_class)
# For foreign keys, fetch possible values
foreign_key_options = {}
for col_name, info in columns_info.items():
if info['foreign_key'] and info['foreign_key_target']:
target_table, target_col = info['foreign_key_target'].split('.')
# Try to find the corresponding model class
for model_key, (model_cls, _) in MODELS.items():
if model_cls.__tablename__ == target_table:
# Fetch options for this foreign key
options = db.query(model_cls).all()
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
# Prepare record data
record_data = {}
for col_name in columns_info:
record_data[col_name] = getattr(record, col_name)
return templates.TemplateResponse(
"admin/edit.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"columns_info": columns_info,
"record": record_data,
"foreign_key_options": foreign_key_options,
"is_new": False
}
)
@router.post("/{model_name}/{record_id}")
async def update_record(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Update an existing record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Get form data from request
form_data = await request.form()
# Convert form data to appropriate types and update record
columns_info = get_model_info(model_class)
for field_name, value in form_data.items():
if field_name in columns_info and not columns_info[field_name]['primary_key']:
col_type = columns_info[field_name]['type'].lower()
# Handle nullable fields
if value == '' and columns_info[field_name]['nullable']:
setattr(record, field_name, None)
continue
# Convert values based on column type
if 'int' in col_type:
if value:
setattr(record, field_name, int(value))
elif 'bool' in col_type or 'boolean' in col_type:
bool_value = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
setattr(record, field_name, bool_value)
else:
setattr(record, field_name, value)
# Save changes
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete")
async def delete_record(
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Delete a record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Delete record
db.delete(record)
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
+560
View File
@@ -0,0 +1,560 @@
#!/usr/bin/env python3
"""
Authentication routes for user login, registration, and management.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import smtplib
from email.message import EmailMessage
import os
from ..db import SessionLocal, engine
from ..models import User
from ..security import (
verify_password, get_password_hash, create_access_token, generate_token,
SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
)
from ..dependencies import get_db, get_current_user, get_user_from_session
from ..templates_config import templates
from ..schemas import UserCreate, UserLogin, UserUpdate, PasswordReset
router = APIRouter()
# Check if all required user columns exist
def get_available_user_columns():
inspector = inspect(engine)
if 'users' in inspector.get_table_names():
return [col['name'] for col in inspector.get_columns('users')]
return []
# --- HTML ROUTES (Web UI) ---
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
"""Display login page."""
# Debug print to check what's happening in the route
print("Login page accessed, checking session...")
# DO NOT redirect if user is logged in - there's likely an issue with session handling
# Just render the login page regardless of session state for now
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": []
}
)
@router.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
response: Response,
db: Session = Depends(get_db),
username: str = Form(...),
password: str = Form(...),
remember: bool = Form(False),
next: str = Form("/")
):
"""Process login form."""
# Try to authenticate user
user = db.query(User).filter((User.username == username) | (User.email == username)).first()
if not user or not verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": [{"type": "error", "text": "Invalid username or password"}],
"username": username
},
status_code=status.HTTP_401_UNAUTHORIZED
)
# Check if is_active column exists and if user is active
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": [{"type": "error", "text": "Account is deactivated"}],
"username": username
},
status_code=status.HTTP_401_UNAUTHORIZED
)
# Update last login time if column exists
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
# Debug info
print(f"User authenticated: {user.username}")
# Set session data with better error handling
try:
# Use directly accessible dictionary
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["_permanent"] = True
# Check if is_admin attribute exists
if hasattr(user, "is_admin"):
request.session["is_admin"] = user.is_admin
else:
request.session["is_admin"] = False
# Add timestamp for session creation
request.session["created_at"] = str(datetime.now())
# Debug session data
print(f"Session data set: {dict(request.session)}")
except Exception as e:
print(f"Error setting session: {str(e)}")
# Redirect to next page or home
return RedirectResponse(url=next, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
"""Display registration page."""
# Check if user is already logged in
user = await get_user_from_session(request)
if user:
return RedirectResponse(url="/")
return templates.TemplateResponse("auth/register.html", {"request": request})
@router.post("/register", response_class=HTMLResponse)
async def register(
request: Request,
db: Session = Depends(get_db),
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
):
"""Process registration form."""
# Validate the form data
if password != confirm_password:
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Passwords do not match"}],
"username": username,
"email": email
}
)
# Check if username already exists
if db.query(User).filter(User.username == username).first():
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Username already exists"}],
"username": username,
"email": email
}
)
# Check if email already exists
if db.query(User).filter(User.email == email).first():
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Email already exists"}],
"username": username,
"email": email
}
)
# Create new user
hashed_password = get_password_hash(password)
# Get available columns
columns = get_available_user_columns()
user_data = {
"username": username,
"email": email,
"hashed_password": hashed_password
}
# Add optional fields only if they exist in the database
if 'is_active' in columns:
user_data["is_active"] = True
if 'is_verified' in columns:
user_data["is_verified"] = False
if 'verification_token' in columns:
user_data["verification_token"] = generate_token()
new_user = User(**user_data)
db.add(new_user)
db.commit()
db.refresh(new_user)
# In a real app, send verification email here
# For now, just redirect to a success page
# Log the user in
request.session["user_id"] = new_user.id
return RedirectResponse(
url="/auth/registration-success",
status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/registration-success", response_class=HTMLResponse)
async def registration_success(request: Request):
"""Display registration success page."""
return templates.TemplateResponse("auth/registration_success.html", {"request": request})
@router.get("/logout")
async def logout(request: Request):
"""Log user out by clearing session."""
request.session.clear()
return RedirectResponse(url="/")
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(
request: Request,
db: Session = Depends(get_db)
):
"""Display user profile page."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Get user's teams (using fresh user object from database to ensure relationships are loaded)
user = db.query(User).filter(User.id == user.id).first()
user_teams = []
for membership in user.memberships:
user_teams.append({
"team": membership.team,
"is_admin": membership.is_admin
})
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"user_teams": user_teams
}
)
@router.post("/update-profile", response_class=HTMLResponse)
async def update_profile(
request: Request,
db: Session = Depends(get_db),
username: str = Form(None),
email: str = Form(None),
):
"""Update user profile information."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Check for username collision
if username and username != user.username:
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Username already exists"}]
}
)
user.username = username
# Check for email collision
if email and email != user.email:
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Email already exists"}]
}
)
user.email = email
db.commit()
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "success", "text": "Profile updated successfully"}]
}
)
@router.post("/change-password", response_class=HTMLResponse)
async def change_password(
request: Request,
db: Session = Depends(get_db),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
):
"""Change user password."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Verify current password
if not verify_password(current_password, user.hashed_password):
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Current password is incorrect"}]
}
)
# Check if new passwords match
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "New passwords do not match"}]
}
)
# Update password
user.hashed_password = get_password_hash(new_password)
db.commit()
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "success", "text": "Password changed successfully"}]
}
)
@router.get("/forgot-password", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
"""Display forgot password page."""
return templates.TemplateResponse("auth/forgot_password.html", {"request": request})
@router.post("/forgot-password")
async def forgot_password(
request: Request,
db: Session = Depends(get_db),
email: str = Form(...)
):
"""Process forgot password form."""
# Find user by email
user = db.query(User).filter(User.email == email).first()
# Always show success to prevent email enumeration
if not user:
return templates.TemplateResponse(
"auth/forgot_password_sent.html",
{"request": request}
)
# Generate reset token
reset_token = generate_token()
user.reset_token = reset_token
user.reset_token_expires_at = datetime.utcnow() + timedelta(hours=1)
db.commit()
# In a real app, send email with reset link
# For demo, just show the reset link on the success page
reset_url = f"/auth/reset-password?token={reset_token}"
return templates.TemplateResponse(
"auth/forgot_password_sent.html",
{
"request": request,
"reset_url": reset_url # Remove in production, just for demo
}
)
@router.get("/reset-password", response_class=HTMLResponse)
async def reset_password_page(
request: Request,
token: str,
db: Session = Depends(get_db)
):
"""Display reset password page."""
# Check if token exists and is valid
user = db.query(User).filter(
User.reset_token == token,
User.reset_token_expires_at > datetime.utcnow()
).first()
if not user:
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request}
)
return templates.TemplateResponse(
"auth/reset_password.html",
{"request": request, "token": token}
)
@router.post("/reset-password")
async def reset_password(
request: Request,
db: Session = Depends(get_db),
token: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...)
):
"""Process reset password form."""
# Check if token exists and is valid
user = db.query(User).filter(
User.reset_token == token,
User.reset_token_expires_at > datetime.utcnow()
).first()
if not user:
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request}
)
# Check if passwords match
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/reset_password.html",
{
"request": request,
"token": token,
"messages": [{"type": "error", "text": "Passwords do not match"}]
}
)
# Update password
user.hashed_password = get_password_hash(new_password)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return templates.TemplateResponse(
"auth/reset_password_success.html",
{"request": request}
)
@router.get("/delete-account", response_class=HTMLResponse)
async def delete_account_page(request: Request):
"""Display delete account confirmation page."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
return templates.TemplateResponse("auth/delete_account.html", {"request": request})
@router.post("/delete-account")
async def delete_account(
request: Request,
db: Session = Depends(get_db),
password: str = Form(...)
):
"""Process account deletion."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
# Verify password
if not verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"auth/delete_account.html",
{
"request": request,
"messages": [{"type": "error", "text": "Incorrect password"}]
}
)
# In a real app, you might want to anonymize the user data instead
# of deleting it completely, but for this demo we'll delete
# Clear session
request.session.clear()
# Delete user
db.delete(user)
db.commit()
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# --- API ROUTES (for potential SPA frontend) ---
@router.post("/token")
async def login_api(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""API login endpoint returning JWT token."""
# Authenticate user
user = db.query(User).filter((User.username == form_data.username) | (User.email == form_data.username)).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
# Create access token
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username},
expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer",
"user_id": user.id,
"username": user.username
}
@router.get("/session-test")
async def test_session(request: Request):
"""Test endpoint to verify session data persistence"""
has_session = hasattr(request, "session")
session_data = dict(request.session) if has_session else {}
return {
"has_session": has_session,
"session_data": session_data,
"authenticated": "user_id" in session_data
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Dashboard views for user-specific information.
"""
from fastapi import APIRouter, Depends, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..db import SessionLocal
from ..models import User, Team, TeamMembership, QRTicket
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_or_create_default_user(db: Session):
"""Get user ID 1 or create it if it doesn't exist."""
user = db.query(User).filter_by(id=1).first()
if not user:
# Create a default user
user = User(
username="default_user",
email="default@example.com",
hashed_password="placeholder"
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/", response_class=HTMLResponse)
async def user_dashboard(
request: Request,
db: Session = Depends(get_db)
):
"""Show the user's dashboard with team info and recent activity."""
# Get current user - using a default user for now
# In a real app, this would come from auth system
user = get_or_create_default_user(db)
# Get user's teams
user_teams = db.query(Team).join(
TeamMembership,
TeamMembership.team_id == Team.id
).filter(
TeamMembership.user_id == user.id
).all()
# Get team memberships with admin status
team_memberships = db.query(
TeamMembership
).filter(
TeamMembership.user_id == user.id
).all()
admin_team_ids = [tm.team_id for tm in team_memberships if tm.is_admin]
# Get points per team
team_points = {}
for team in user_teams:
points = db.query(func.sum(QRTicket.points)).filter(
QRTicket.redeemed_at_team == team.id
).scalar() or 0
# Get team ranking - simplified approach
higher_teams = db.query(func.count(Team.id)).join(
QRTicket,
QRTicket.redeemed_at_team == Team.id
).group_by(
Team.id
).having(
func.sum(QRTicket.points) > points
).scalar() or 0
rank = higher_teams + 1
team_points[team.id] = {
'points': points,
'rank': rank
}
# Get recent activity
# For simplicity, we're just getting recent QR code redemptions
recent_activity = []
recent_tickets = db.query(QRTicket).filter(
QRTicket.redeemed_by == user.id
).order_by(
QRTicket.id.desc() # Assuming higher ID = newer
).limit(5).all()
for ticket in recent_tickets:
team = db.query(Team).filter(Team.id == ticket.redeemed_at_team).first()
activity = {
'type': 'qr_redeem',
'points': ticket.points,
'team_name': team.name if team else "Unknown team",
'date': "Recently" # Placeholder - would use ticket.created_at
}
recent_activity.append(activity)
# Get total points for user across all teams
total_points = sum(team_data['points'] for team_data in team_points.values())
# Get best ranking
best_rank = min(team_data['rank'] for team_data in team_points.values()) if team_points else None
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"user": user,
"teams": user_teams,
"team_points": team_points,
"admin_team_ids": admin_team_ids,
"recent_activity": recent_activity,
"total_points": total_points,
"best_rank": best_rank,
"team_count": len(user_teams)
}
)
@router.get("/scan", response_class=HTMLResponse)
async def scan_qr(request: Request):
"""Show QR scanning interface."""
return templates.TemplateResponse(
"scan_qr.html",
{"request": request}
)
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Leaderboard views for displaying team rankings.
"""
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from datetime import datetime, timedelta
from ..db import SessionLocal
from ..models import Team, TeamMembership, QRTicket
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/", response_class=HTMLResponse)
async def show_leaderboard(
request: Request,
timeframe: str = Query("all", regex="^(week|month|all)$"),
db: Session = Depends(get_db)
):
"""Show the leaderboard with team rankings."""
# Define cutoff date based on timeframe
cutoff_date = None
if timeframe == "week":
cutoff_date = datetime.now() - timedelta(days=7)
time_label = "This Week"
elif timeframe == "month":
cutoff_date = datetime.now() - timedelta(days=30)
time_label = "This Month"
else:
timeframe = "all" # Ensure valid value
time_label = "All Time"
# Base query to get teams
query = db.query(
Team.id,
Team.name,
func.coalesce(func.sum(QRTicket.points), 0).label('total_points')
).join(
QRTicket,
QRTicket.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
# Group and order
teams_ranking = query.group_by(Team.id).order_by(desc('total_points')).all()
# Add ranks
ranked_teams = []
for idx, team in enumerate(teams_ranking):
ranked_teams.append({
'rank': idx + 1,
'id': team.id,
'name': team.name,
'points': team.total_points,
'change': 0 # Placeholder for rank change - would require historical data
})
# Get top 3 teams for podium display
top_teams = ranked_teams[:3] if len(ranked_teams) >= 3 else ranked_teams + [None] * (3 - len(ranked_teams))
return templates.TemplateResponse(
"leaderboard.html",
{
"request": request,
"teams": ranked_teams,
"top_teams": top_teams,
"timeframe": timeframe,
"time_label": time_label
}
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
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
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/generate/{points}")
def generate_qr(points: int, db: Session = Depends(get_db)):
"""
Generate a 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)
db.commit()
db.refresh(ticket)
qr_img = qrcode.make(code_str)
buf = io.BytesIO()
qr_img.save(buf, format="PNG")
buf.seek(0)
return StreamingResponse(buf, media_type="image/png")
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Redeem a QR code and attribute points to a team.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import QRTicket, User, Team, TeamMembership
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/{code}", response_class=HTMLResponse)
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.
If not logged in, prompt them.
"""
# In real app, you'd check user session or redirect to login
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket:
return "Invalid or already used code."
# Skeleton: you'd get the user's teams from session user
# For now, we mock a user ID = 1:
user = db.query(User).filter_by(id=1).first()
if not user:
return "User not found. Please log in."
# This is where you'd show the team selection or "create new team" UI
user_teams = [m.team for m in user.memberships]
return templates.TemplateResponse("redeem.html", {
"request": request,
"ticket": ticket,
"user_teams": user_teams
})
@router.post("/apply/{code}")
async def apply_code(
request: Request,
code: str,
db: Session = Depends(get_db)
):
"""
Apply the QR code to a selected team (if user is a member),
or set to pending if user isn't a member yet.
"""
# Get form data
form_data = await request.form()
team_id = int(form_data.get("team_id", 0))
if team_id <= 0:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Team Selection Required",
"error_message": "Please select a team to redeem this code."
}
)
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Invalid Code",
"error_message": "This code is invalid or has already been used."
}
)
# For skeleton, assume user = 1
user = db.query(User).filter_by(id=1).first()
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Team Not Found",
"error_message": "The selected team could not be found."
}
)
# Check membership
membership = db.query(TeamMembership).filter_by(user_id=user.id, team_id=team.id).first()
if membership:
# Redeem
ticket.redeemed_by = user.id
ticket.redeemed_at_team = team.id
ticket.used = True
# If we have redeemed_at column, update it
if hasattr(ticket, 'redeemed_at'):
from datetime import datetime
ticket.redeemed_at = datetime.now()
db.commit()
# Redirect to success page or dashboard
return templates.TemplateResponse(
"redeem_success.html",
{
"request": request,
"points": ticket.points,
"team": team
}
)
else:
# In real app, create a pending record or request flow
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Not a Team Member",
"error_message": "You are not a member of this team. Please join the team first or select another team."
}
)
@router.post("/manual")
async def manual_code_entry(
request: Request,
code: str = Form(...),
db: Session = Depends(get_db)
):
"""
Handle manual code entry from the form.
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()
if not ticket:
# In a real app, add a flash message or error handling
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Invalid Code",
"error_message": "The code you entered is invalid or has already been used."
}
)
# Redirect to the regular redeem flow
return RedirectResponse(f"/redeem/{code}", status_code=303)
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""
Create or join a team, manage membership.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, inspect
from datetime import datetime, timedelta
import random # For demo data
from ..db import SessionLocal
from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement
from ..schemas import TeamCreate
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_or_create_default_user(db: Session):
"""Get user ID 1 or create it if it doesn't exist."""
user = db.query(User).filter_by(id=1).first()
if not user:
# Create a default user
user = User(
username="default_user",
email="default@example.com",
hashed_password="placeholder"
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/", response_class=HTMLResponse)
def list_teams(request: Request, db: Session = Depends(get_db)):
teams = db.query(Team).all()
# Get the user's teams to highlight teams they're already in
user = db.query(User).filter_by(id=1).first()
user_team_ids = []
if user:
memberships = db.query(TeamMembership).filter_by(user_id=user.id).all()
user_team_ids = [m.team_id for m in memberships]
return templates.TemplateResponse(
"teams.html",
{
"request": request,
"teams": teams,
"user_team_ids": user_team_ids,
"brand_colors": {
"irish_green": "#006837",
"golden_ale": "#FFB400",
"cream_white": "#F5F0E1",
"black_stout": "#1A1A1A",
"guinness_red": "#B22222"
}
}
)
@router.post("/create")
def create_team(name: str = Form(...), db: Session = Depends(get_db)):
user = get_or_create_default_user(db)
# Create team
new_team = Team(name=name)
db.add(new_team)
db.commit()
db.refresh(new_team)
# Make user admin of team
membership = TeamMembership(user_id=user.id, team_id=new_team.id, is_admin=True)
db.add(membership)
db.commit()
return RedirectResponse("/teams/", status_code=303)
@router.post("/join/{team_id}")
def join_team(team_id: int, db: Session = Depends(get_db)):
user = get_or_create_default_user(db)
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return RedirectResponse("/teams/", status_code=303)
# Check if membership exists
existing = db.query(TeamMembership).filter_by(user_id=user.id, team_id=team.id).first()
if existing:
return RedirectResponse("/teams/", status_code=303)
# Create membership
new_member = TeamMembership(user_id=user.id, team_id=team.id, is_admin=False)
db.add(new_member)
db.commit()
return RedirectResponse("/teams/", status_code=303)
@router.get("/{team_id}", response_class=HTMLResponse)
def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Show details for a specific team."""
# Get team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Get current user - using a default user for now
user = get_or_create_default_user(db)
# Get team members with admin status
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
is_user_admin = False
for membership in memberships:
member = db.query(User).filter_by(id=membership.user_id).first()
if member:
# Check if current user is admin
if membership.user_id == user.id and membership.is_admin:
is_user_admin = True
# Use joined_at if available, otherwise use placeholder
joined_date = getattr(membership, 'joined_at', None) or datetime.now() - timedelta(days=random.randint(30, 180))
if isinstance(joined_date, datetime):
month_name = joined_date.strftime("%b")
year = joined_date.strftime("%Y")
else:
month_name = "Apr"
year = "2023"
team_members.append({
"user": member,
"is_admin": membership.is_admin,
"joined": f"{month_name} {year}"
})
# Get total points
total_points = db.query(func.sum(QRTicket.points)).filter(
QRTicket.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,
isouter=True
).group_by(Team.id).having(
func.sum(QRTicket.points) > total_points
).scalar() or 0
team_rank = higher_teams + 1
# Generate points data (with fallbacks for missing columns)
points_this_month = 65 # Default value
point_change = 15 # Default value
point_change_positive = True
# Check if redeemed_at column exists before using it
try:
now = datetime.now()
first_day_of_month = datetime(now.year, now.month, 1)
# 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')]:
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
).scalar() or points_this_month
except Exception as e:
print(f"Error calculating monthly points: {e}")
# Activities - simple mock data for now
activities = [
{
"type": "points",
"points": 15,
"event": "Music Trivia Night",
"date": "September 12, 2023"
},
{
"type": "join",
"user": "Robert Brown",
"date": "July 28, 2023"
},
{
"type": "achievement",
"achievement": "1st place",
"event": "History Night",
"date": "July 15, 2023"
},
{
"type": "points",
"points": 20,
"event": "Movie Trivia Night",
"date": "July 1, 2023"
}
]
# Safely get team attributes
is_public = getattr(team, 'is_public', False)
created_at = getattr(team, 'created_at', None)
# Calculate days since team was founded
if created_at and isinstance(created_at, datetime):
days_ago = (datetime.now() - created_at).days
founded_date_str = created_at.strftime("%B %d, %Y")
else:
days_ago = 164 # Default fallback
founded_date_str = "March 22, 2023" # Default fallback
# Performance metrics
performance = {
"last_quiz": "25 points (2nd place)",
"average": "18.7 points",
"best_streak": "3 wins in a row"
}
return templates.TemplateResponse(
"team_detail.html",
{
"request": request,
"team": team,
"team_members": team_members,
"team_rank": team_rank,
"total_points": total_points,
"points_this_month": points_this_month,
"point_change": point_change,
"point_change_positive": point_change_positive,
"activities": activities,
"performance": performance,
"is_user_admin": is_user_admin,
"user": user,
"days_ago": days_ago,
"founded_date": founded_date_str
}
)
@router.post("/{team_id}/update")
def update_team(
team_id: int,
team_name: str = Form(...),
is_public: bool = Form(False),
db: Session = Depends(get_db)
):
"""Update team details."""
user = get_or_create_default_user(db)
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Check if user is admin
membership = db.query(TeamMembership).filter_by(
user_id=user.id,
team_id=team.id,
is_admin=True
).first()
if not membership:
raise HTTPException(status_code=403, detail="You don't have permission to update this team")
# Update team details
team.name = team_name
team.is_public = is_public
db.commit()
return RedirectResponse(f"/teams/{team_id}", status_code=303)
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""
Utility module to help debug session problems.
"""
from fastapi import Request
def print_session_debug(request: Request):
"""Print debug information about the request session."""
print("\n=== SESSION DEBUG INFO ===")
print(f"Request URL: {request.url}")
print(f"Session in scope: {'session' in request.scope}")
if 'session' in request.scope:
print("Session contents:")
try:
for key, value in request.session.items():
print(f" {key}: {value}")
except Exception as e:
print(f"Error accessing session items: {e}")
else:
print("No session found in request scope")
print("Headers:")
for name, value in request.headers.items():
if name.lower() in ('cookie', 'set-cookie'):
print(f" {name}: [REDACTED]") # Don't print actual cookie values
else:
print(f" {name}: {value}")
print("=========================\n")
+47
View File
@@ -0,0 +1,47 @@
version: "3.9"
services:
db:
image: mysql:8.0
container_name: pubquiz_mysql
restart: always
environment:
MYSQL_DATABASE: "pubquiz_db"
MYSQL_USER: "pubquiz_user"
MYSQL_PASSWORD: "pubquiz_pass"
MYSQL_ROOT_PASSWORD: "root_pass"
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 5s
timeout: 5s
retries: 20
app:
build: .
container_name: pubquiz_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DB_HOST: "db"
DB_PORT: "3306"
DB_NAME: "pubquiz_db"
DB_USER: "pubquiz_user"
DB_PASS: "pubquiz_pass"
# Session configuration
SECRET_KEY: "a-stronger-secret-key-for-sessions-32chars"
DEBUG: "True"
SESSION_MAX_AGE: "86400" # 24 hours
COOKIE_SECURE: "False" # Set to True in production with HTTPS
ports:
- "8000:8000"
volumes:
- ./:/app:delegated
volumes:
db_data:
+14
View File
@@ -0,0 +1,14 @@
fastapi
uvicorn[standard]
SQLAlchemy
mysqlclient
Jinja2
python-multipart
passlib[bcrypt]
qrcode
email-validator
pillow
python-jose[cryptography]
itsdangerous
bcrypt==4.0.1
flask-session==0.5.0