diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b513a6a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..ae11efc --- /dev/null +++ b/app/auth.py @@ -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. diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..dec61c4 --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/db_init.py b/app/db_init.py new file mode 100644 index 0000000..03ec4ea --- /dev/null +++ b/app/db_init.py @@ -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() diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..00ab546 --- /dev/null +++ b/app/dependencies.py @@ -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} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..41ebd61 --- /dev/null +++ b/app/main.py @@ -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) + }) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..1cdacfc --- /dev/null +++ b/app/models.py @@ -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") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..e0eae7a --- /dev/null +++ b/app/models/user.py @@ -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... diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..41303c2 --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..4ca8acd --- /dev/null +++ b/app/security.py @@ -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)) diff --git a/app/templates/about.html b/app/templates/about.html new file mode 100644 index 0000000..5bf701d --- /dev/null +++ b/app/templates/about.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} +
+

About LeagueLedger

+

+ 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. +

+

+ 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. +

+

+ 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. +

+

Our Mission

+

+ 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. +

+

Our Team

+

+ 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. +

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

+ {% if is_new %}Create{% else %}Edit{% endif %} {{ display_name[:-1] if display_name.endswith('s') else display_name }} +

+ + +
+ {% for column_name, column_info in columns_info.items() %} +
+ +
+ {% if column_info.primary_key and is_new %} + + + + {% elif column_info.foreign_key and column_name in foreign_key_options %} + + + + {% elif column_info.type.startswith('BOOLEAN') %} + +
+ + Yes +
+ + {% elif 'text' in column_info.type.lower() %} + + + + {% else %} + + + {% endif %} + + {% if column_info.nullable %} +

Optional field

+ {% endif %} +
+
+ {% endfor %} + + +
+ + Cancel + + +
+
+
+ + +
+ + Back to {{ display_name }} + + + Back to Admin Dashboard + +
+
+{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html new file mode 100644 index 0000000..a60128f --- /dev/null +++ b/app/templates/admin/index.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Admin Dashboard

+ +
+ {% for model_key, display_name in models %} +
+

{{ display_name }}

+ +
+ {% endfor %} +
+ + +
+
+{% endblock %} diff --git a/app/templates/admin/list.html b/app/templates/admin/list.html new file mode 100644 index 0000000..0afe2f2 --- /dev/null +++ b/app/templates/admin/list.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +
+
+

{{ display_name }}

+

Manage your {{ display_name.lower() }} data

+
+ +
+ + +
+ + + + + {% for column in columns %} + {% if column != 'id' %} + + {% endif %} + {% endfor %} + + + + + {% for record in records %} + + + {% for column in columns %} + {% if column != 'id' %} + + {% endif %} + {% endfor %} + + + {% endfor %} + + {% if not records %} + + + + {% endif %} + +
ID{{ column|replace('_', ' ')|title }}Actions
{{ record.id }} + {% if columns_info[column].foreign_key %} + FK: {{ record[column] }} + {% elif record[column] is none %} + NULL + {% elif columns_info[column].type.startswith('BOOLEAN') %} + {% if record[column] %} + Yes + {% else %} + No + {% endif %} + {% else %} + {{ record[column]|string|truncate(50) }} + {% endif %} + + +
+ No records found +
+
+ + + {% if total_pages > 1 %} +
+
+ Showing {{ (page - 1) * per_page + 1 }}-{{ [page * per_page, total_records]|min }} of {{ total_records }} records +
+
+ {% if page > 1 %} + + « Prev + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + {{ p }} + {% elif p <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %} + + {{ p }} + + {% elif p == 4 and page > 5 or p == total_pages - 3 and page < total_pages - 4 %} + ... + {% endif %} + {% endfor %} + + {% if page < total_pages %} + + Next » + + {% endif %} +
+
+ {% endif %} +
+ + +
+ + Back to Admin Dashboard + +
+
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..fb8d8ea --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Log In

+ + + {% if messages %} + {% for message in messages %} +
+ {{ message.text }} +
+ {% endfor %} + {% endif %} + +
+ + +
+ + +
+ +
+ + +
+ + +
+

Having trouble logging in? Make sure your browser accepts cookies.

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

Don't have an account?

+ + Create an account + +
+ + +
+

Or sign in with

+
+ + +
+

OAuth login coming soon

+
+
+
+{% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html new file mode 100644 index 0000000..42b2fdc --- /dev/null +++ b/app/templates/auth/profile.html @@ -0,0 +1,188 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +
+
+
+ Profile +
+
+

{{ user.username }}

+

Member since {{ user.created_at.strftime('%B %Y') }}

+

+ {% if user.is_verified %} + Verified + {% endif %} + {{ user.memberships|length }} Teams +

+
+
+
+ + + {% if messages %} +
+ {% for message in messages %} +
+ {{ message.text }} +
+ {% endfor %} +
+ {% endif %} + + +
+
+ +
+

Personal Information

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

Change Password

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

Your Statistics

+ +
+
+

Total Points

+

378

+
+
+

QR Codes Redeemed

+

24

+
+
+

Teams Joined

+

{{ user.memberships|length }}

+
+
+

Best Position

+

#2

+
+
+ +

Your Teams

+
+ {% for team_info in user_teams %} +
+
+
{{ team_info.team.name }}
+ {% if team_info.is_admin %} + Captain + {% else %} + Member + {% endif %} +
+
+
+

Current Points: 187

+

Current Rank: #4

+
+ View Team +
+
+ {% else %} +
+

You haven't joined any teams yet.

+
+ {% endfor %} + + + Join or Create Another Team + +
+ + +
+

Danger Zone

+

These actions cannot be undone. Please be certain.

+ + + Delete Account + +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..bb95c28 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Create an Account

+ + + {% if messages %} + {% for message in messages %} +
+ {{ message.text }} +
+ {% endfor %} + {% endif %} + +
+
+ + +

Choose a unique username (3-30 characters, letters, numbers and underscores only)

+
+ +
+ + +
+ +
+ + +

At least 8 characters

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

Already have an account?

+ Log in +
+
+
+{% endblock %} diff --git a/app/templates/auth/registration_success.html b/app/templates/auth/registration_success.html new file mode 100644 index 0000000..7c0628b --- /dev/null +++ b/app/templates/auth/registration_success.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+
+ +
+
+ +

Registration Successful!

+

Your account has been created and you're now logged in.

+ + + +
+

What's Next?

+
    +
  • Explore the pub quiz leaderboards
  • +
  • Join an existing team or create your own
  • +
  • Scan QR codes at quiz events to earn points
  • +
  • Complete your profile information
  • +
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..04dd9b6 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,182 @@ + + + + + + LeagueLedger + + + + + + + + + + +
+
+ +
+
+ LeagueLedger Logo +

LeagueLedger

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

Contact Us

+

+ Have questions, suggestions, or feedback? We'd love to hear from you! +

+
+

Contact Information

+

Christian Louis IT Beratung

+

Alter Steinweg 3

+

20459 Hamburg

+

Deutschland

+

Phone: +49 179 5183732

+

Email: quizarium@kaufdeinquiz.com

+

Fax: +49 40 97074609

+
+
+

Connect With Us

+

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

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

Welcome, {{ user.username }}!

+

Here's your quiz league status

+
+ + +
+
+ +

Your Teams

+

{{ team_count }}

+
+
+ +

Total Points

+

{{ total_points }}

+
+
+ +

Best Ranking

+

{% if best_rank %}#{{ best_rank }}{% else %}-{% endif %}

+
+
+ + +
+

Recent Activity

+ {% if recent_activity %} +
+ {% for activity in recent_activity %} +
+ {% if activity.type == 'qr_redeem' %} +
+ +
+
+

You redeemed a QR code for {{ activity.points }} points

+

Team: {{ activity.team_name }} • {{ activity.date }}

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

No recent activity to show

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

Your Teams

+ {% if teams %} +
+ {% for team in teams %} +
+
+

{{ team.name }}

+

{{ team_points[team.id].points }} points • Rank #{{ team_points[team.id].rank }}

+
+
+ {% if team.id in admin_team_ids %} + Admin + {% endif %} + View +
+
+ {% endfor %} +
+ {% else %} +

You haven't joined any teams yet

+ {% endif %} + +
+ +
+

Redeem Code

+

Have a QR code from your quiz master? Scan or enter it here to claim points!

+
+ + Scan QR Code + +
+ + +
+
+
+
+ + +
+
+

Leaderboard Preview

+ View Full Leaderboard +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
RankTeamPoints
1Quiz Masters287
2Trivia Titans215
3Beer Brainiacs194
+
+
+
+{% endblock %} diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..7944c50 --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +

{{ error_title|default("Error") }}

+

{{ error_message|default("An error occurred. Please try again.") }}

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

PubQuiz League Tracker

+

Track your team's triumphs across quiz nights and climb the leaderboard.

+ +
+ + +
+
+

How It Works

+
+
+
+ +
+

Create or Join Teams

+

Join existing quiz teams or create your own to start competing.

+
+ +
+
+ +
+

Scan QR Codes

+

Earn points by scanning QR codes distributed by quiz masters.

+
+ +
+
+ +
+

Climb the Leaderboard

+

Track your progress and compete to be the top team in the league.

+
+
+
+
+ + +
+

Ready to Join the League?

+

Get started now and track your pub quiz achievements!

+ Sign Up Now +
+
+{% endblock %} diff --git a/app/templates/leaderboard.html b/app/templates/leaderboard.html new file mode 100644 index 0000000..a166e82 --- /dev/null +++ b/app/templates/leaderboard.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

League Leaderboard

+

See how your team ranks against the competition

+
+ +
+
+ +
+
+
+ + + + + +
+ Showing {{ time_label }} Rankings +
+ + +
+ + + + + + + + + + + {% for team in teams %} + + + + + + + {% endfor %} + + {% if not teams %} + + + + {% endif %} + +
RankTeamPointsChange
{{ team.rank }}{{ team.name }}{{ team.points }} + {% if team.change > 0 %} + {{ team.change }} + {% elif team.change < 0 %} + {{ team.change|abs }} + {% else %} + - + {% endif %} +
No teams found. Start a quiz league to see rankings here!
+
+ + +
+

Ready to join the rankings?

+

Create or join a team and start climbing the leaderboard today!

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

Privacy Policy

+

+ Your privacy is important to us. This Privacy Policy outlines how LeagueLedger collects, uses, and protects your information. +

+

Information We Collect

+ +

How We Use Your Information

+

+ We use your information to: +

+ +

Data Sharing

+

+ We do not share your personal information with third parties except as required by law. +

+

+ Christian Louis IT Beratung und Medienproduktion, as the operator of this platform, takes data privacy seriously and implements measures to protect your personal information. +

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

John Quizmaster

+

Member since October 2022

+

+ Quiz Master + Team Captain +

+
+
+
+ + +
+
+ +
+

Personal Information

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

Your Statistics

+ +
+
+

Total Points

+

378

+
+
+

QR Codes Redeemed

+

24

+
+
+

Teams Joined

+

3

+
+
+

Best Position

+

#2

+
+
+ +

Your Teams

+
+
+
+
Quiz Wizards
+ Captain +
+
+
+

Current Points: 187

+

Current Rank: #4

+
+ View Team +
+
+ +
+
+
Trivia Titans
+ Member +
+
+
+

Current Points: 215

+

Current Rank: #2

+
+ View Team +
+
+ + + Join or Create Another Team + +
+
+
+
+
+ + +
+

Danger Zone

+

The following actions are irreversible. Please proceed with caution.

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

QR Code Redemption

+

Collect your points from the quiz master!

+
+ +
+

Congratulations!

+

You've earned {{ ticket.points }} points

+

Code: {{ ticket.code }}

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

How It Works

+
+
+
+ 1 +
+
+

Scan QR Code

+

Scan the QR code provided by your quiz master.

+
+
+ +
+
+ 2 +
+
+

Select Your Team

+

Choose which team should receive these points.

+
+
+ +
+
+ 3 +
+
+

Climb the Leaderboard

+

Watch your team rise in the rankings!

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

Success!

+

You've earned {{ points }} points

+

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

+ +
+

What's next?

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

Share your achievement!

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

Scan QR Code

+

Position the QR code from your quiz master in the camera view

+ + +
+
+
+
+ +

Camera loading...

+
+
+
+ +
+ Make sure the QR code is well-lit and clearly visible +
+ + +
+

Or enter code manually

+
+ + +
+
+
+ + +
+

How It Works

+
+
+
+ 1 +
+
+

Scan QR Code

+

Scan the QR code provided by your quiz master.

+
+
+ +
+
+ 2 +
+
+

Select Your Team

+

Choose which team should receive these points.

+
+
+ +
+
+ 3 +
+
+

Climb the Leaderboard

+

Watch your team rise in the rankings!

+
+
+
+
+ + +
+ + Back to Dashboard + +
+
+ + + + +{% endblock %} diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html new file mode 100644 index 0000000..ed94a45 --- /dev/null +++ b/app/templates/team_detail.html @@ -0,0 +1,189 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+
+
+
+

{{ team.name }}

+
+ Rank #{{ team_rank }} + + {{ total_points }} Points + + {{ team_members|length }} Members +
+
+
+ +
+
+
+ + +
+
+
+

POINTS THIS MONTH

+

{{ points_this_month }}

+

+ + {{ point_change|abs }} from last month +

+
+
+

BEST PERFORMANCE

+

1st Place

+

August 12, 2023

+
+
+

TEAM FOUNDED

+

{{ days_ago }} days ago

+

{{ founded_date }}

+
+
+
+
+ + +
+ +
+
+

Team Members

+ +
+ +
+ {% for member in team_members %} +
+
+
+ User +
+
+

{{ member.user.username }}

+

Joined {{ member.joined }}

+
+
+ {% if member.is_admin %} + Captain + {% else %} + Member + {% endif %} +
+ {% endfor %} +
+
+ + +
+

Team Performance

+ + +
+
+ +

Performance chart would appear here

+
+
+ +
+
+

Last quiz night

+

{{ performance.last_quiz }}

+
+
+

Average per quiz

+

{{ performance.average }}

+
+
+

Best streak

+

{{ performance.best_streak }}

+
+
+
+
+ + +
+

Recent Activity

+ +
+ {% for activity in activities %} + {% if activity.type == 'points' %} +
+
+ +
+
+

Earned {{ activity.points }} points in "{{ activity.event }}"

+

{{ activity.date }}

+
+
+ {% elif activity.type == 'join' %} +
+
+ +
+
+

{{ activity.user }} joined the team

+

{{ activity.date }}

+
+
+ {% elif activity.type == 'achievement' %} +
+
+ +
+
+

Achieved {{ activity.achievement }} in "{{ activity.event }}"

+

{{ activity.date }}

+
+
+ {% endif %} + {% endfor %} +
+ + +
+ + +
+

Team Management

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

Teams

+
+
+

Available Teams

+ {% if teams %} + + {% else %} +

No teams available yet.

+ {% endif %} +
+ +
+

Create New Team

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

About Teams

+

+ Teams are the heart of LeagueLedger. Join an existing team or create your own to start tracking your pub quiz triumphs! +

+

+ Every point counts in the journey to becoming pub quiz champions. +

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

Terms of Service

+

+ Welcome to LeagueLedger! By using our platform, you agree to comply with the following terms and conditions. +

+

Acceptable Use

+ +

Liability Disclaimer

+

+ LeagueLedger is provided "as is" without any warranties. We are not liable for any damages arising from your use of the platform. +

+

Governing Law

+

+ These terms shall be governed by and construed in accordance with the laws of Germany. +

+

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

+

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

+
+{% endblock %} diff --git a/app/templates_config.py b/app/templates_config.py new file mode 100644 index 0000000..6f21492 --- /dev/null +++ b/app/templates_config.py @@ -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 diff --git a/app/views/admin.py b/app/views/admin.py new file mode 100644 index 0000000..ca651b3 --- /dev/null +++ b/app/views/admin.py @@ -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) diff --git a/app/views/auth.py b/app/views/auth.py new file mode 100644 index 0000000..9a99030 --- /dev/null +++ b/app/views/auth.py @@ -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 + } diff --git a/app/views/dashboard.py b/app/views/dashboard.py new file mode 100644 index 0000000..a0ca73d --- /dev/null +++ b/app/views/dashboard.py @@ -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} + ) diff --git a/app/views/leaderboard.py b/app/views/leaderboard.py new file mode 100644 index 0000000..00e3a7c --- /dev/null +++ b/app/views/leaderboard.py @@ -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 + } + ) diff --git a/app/views/qr.py b/app/views/qr.py new file mode 100644 index 0000000..3d19876 --- /dev/null +++ b/app/views/qr.py @@ -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") diff --git a/app/views/redeem.py b/app/views/redeem.py new file mode 100644 index 0000000..5e44267 --- /dev/null +++ b/app/views/redeem.py @@ -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) diff --git a/app/views/teams.py b/app/views/teams.py new file mode 100644 index 0000000..310a886 --- /dev/null +++ b/app/views/teams.py @@ -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) diff --git a/debug_session.py b/debug_session.py new file mode 100644 index 0000000..70106cf --- /dev/null +++ b/debug_session.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d8e551 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9f0725c --- /dev/null +++ b/requirements.txt @@ -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