From 4a79c5cacb4a92ffc7ef28dadb3da40f70f5fd6d Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sun, 13 Apr 2025 18:49:27 +0200 Subject: [PATCH] Refactor user authentication and dashboard features - Added `is_admin` field to User model for role management. - Updated user context middleware to include current user in templates. - Enhanced session management with improved debug middleware. - Refactored dashboard view to fetch user-specific data and recent events. - Improved login and registration templates for better user experience. - Added admin routes with access control for admin users. - Updated Docker configuration for better error logging and dependency management. - Updated requirements to include new dependencies and specify versions. --- Dockerfile | 3 + app/auth.py | 339 ++++++++++++++++++- app/db.py | 33 +- app/db_init.py | 43 ++- app/dependencies.py | 129 ++----- app/main.py | 114 ++++--- app/models.py | 101 +++++- app/models/user.py | 16 +- app/templates/auth/login.html | 42 ++- app/templates/auth/profile.html | 209 +++--------- app/templates/auth/register.html | 38 +-- app/templates/auth/registration_success.html | 36 +- app/templates/base.html | 12 +- app/templates/profile.html | 2 +- app/templates/team_detail.html | 2 +- app/templates_config.py | 1 + app/views/admin.py | 9 + app/views/dashboard.py | 203 ++++------- docker-compose.yml | 7 +- requirements.txt | 30 +- 20 files changed, 790 insertions(+), 579 deletions(-) diff --git a/Dockerfile b/Dockerfile index b513a6a..8c11abf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,9 @@ COPY requirements.txt . # Install dependencies RUN pip install --no-cache-dir -r requirements.txt +# Explicitly install pymysql (in case it's missing from requirements.txt) +RUN pip install --no-cache-dir pymysql cryptography + # Copy the rest of the code COPY . . diff --git a/app/auth.py b/app/auth.py index ae11efc..71b344a 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,14 +1,40 @@ #!/usr/bin/env python3 """ -Skeleton for authentication logic. -Placeholder for OAuth or password-based login. +Authentication system using Authlib and session-based auth """ -from fastapi import APIRouter, Depends, HTTPException +import os +import inspect +import hashlib +from functools import wraps + +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, Request, status, Depends, HTTPException +from starlette.responses import RedirectResponse from sqlalchemy.orm import Session -from .db import SessionLocal -from . import models from passlib.context import CryptContext +from .db import SessionLocal +from .templates_config import templates +from . import models + +# Initialize OAuth +oauth = OAuth() + +# Check if OAuth is configured via environment variables +OAUTH_CONFIGURED = bool(os.environ.get("OAUTH_CLIENT_ID") and os.environ.get("OAUTH_CLIENT_SECRET")) +OAUTH_PROVIDER_NAME = os.environ.get("OAUTH_PROVIDER_NAME", "Single Sign-On") + +# Configure OAuth provider if credentials are provided +if OAUTH_CONFIGURED: + oauth.register( + name="oauth_provider", + client_id=os.environ.get("OAUTH_CLIENT_ID"), + client_secret=os.environ.get("OAUTH_CLIENT_SECRET"), + server_metadata_url=os.environ.get("OAUTH_CONFIG_URL"), + client_kwargs={"scope": "openid profile email"}, + ) + +# Create router and password context router = APIRouter() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -19,4 +45,305 @@ def get_db(): finally: db.close() -# Add routes for login, logout, register, etc. +def get_password_hash(password): + """Generate password hash""" + return pwd_context.hash(password) + +def verify_password(plain_password, hashed_password): + """Verify password against hash""" + return pwd_context.verify(plain_password, hashed_password) + +def get_current_user(request: Request): + """Get current user from session with improved error handling""" + try: + if "session" not in request.scope: + return None + return request.session.get("user") + except Exception as e: + print(f"Error getting user from session: {str(e)}") + return None + +def get_gravatar_url(email): + """Generate a Gravatar URL for the given email""" + email = email.lower().strip() + email_hash = hashlib.md5(email.encode('utf-8')).hexdigest() + return f"https://www.gravatar.com/avatar/{email_hash}?d=identicon" + +def require_login(func): + """Decorator to require login for routes""" + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + try: + if "session" not in request.scope or not request.session.get("user"): + # Store the current URL for redirecting after login + if "session" in request.scope: + request.session["redirect_after_login"] = str(request.url) + return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) + + # Check if the wrapped function is a coroutine function + if inspect.iscoroutinefunction(func): + return await func(request, *args, **kwargs) + else: + return func(request, *args, **kwargs) + except Exception as e: + print(f"Error in require_login decorator: {str(e)}") + return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) + + return wrapper + +def require_admin(func): + """Decorator to require admin access for routes""" + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + try: + if "session" not in request.scope: + return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) + + user = request.session.get("user") + if not user: + request.session["redirect_after_login"] = str(request.url) + return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) + + if not user.get("is_admin", False): + return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + + # Check if the wrapped function is a coroutine function + if inspect.iscoroutinefunction(func): + return await func(request, *args, **kwargs) + else: + return func(request, *args, **kwargs) + except Exception as e: + print(f"Error in require_admin decorator: {str(e)}") + return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND) + + return wrapper + +# Routes for authentication + +@router.get("/login") +async def login_page(request: Request): + """Show login page with appropriate authentication options""" + # If already logged in, redirect to home + if request.session.get("user"): + return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + + return templates.TemplateResponse( + "auth/login.html", + { + "request": request, + "error": request.query_params.get("error"), + "message": request.query_params.get("message"), + "show_oauth": OAUTH_CONFIGURED, + "oauth_provider_name": OAUTH_PROVIDER_NAME + } + ) + +@router.post("/login") +async def login(request: Request, db: Session = Depends(get_db)): + """Handle username/password authentication""" + form_data = await request.form() + username = form_data.get("username") + password = form_data.get("password") + + # Check if username exists + user = db.query(models.User).filter(models.User.username == username).first() + if not user or not verify_password(password, user.hashed_password): + return RedirectResponse( + url="/auth/login?error=Invalid+username+or+password", + status_code=status.HTTP_302_FOUND + ) + + # Create user session + # Make sure we use the actual is_admin field from the User model + is_admin_value = False + if hasattr(user, "is_admin") and user.is_admin is not None: + is_admin_value = user.is_admin + + request.session["user"] = { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": is_admin_value, # Use the actual is_admin value from the database + "picture": get_gravatar_url(user.email), + "_permanent": True, + "created_at": str(user.created_at) + } + + # Log the successful authentication + print(f"User authenticated: {username}, is_admin: {is_admin_value}") + + # Redirect to original destination or default + redirect_url = request.session.pop("redirect_after_login", "/") + return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER) + +@router.get("/oauth-login") +async def oauth_login(request: Request): + """Handle OAuth login flow""" + if not OAUTH_CONFIGURED: + return RedirectResponse( + url="/auth/login?error=OAuth+not+configured", + status_code=status.HTTP_302_FOUND + ) + + redirect_uri = request.url_for("oauth_callback") + return await oauth.oauth_provider.authorize_redirect(request, redirect_uri) + +@router.get("/oauth-callback") +async def oauth_callback(request: Request, db: Session = Depends(get_db)): + """Handle OAuth callback from provider""" + try: + token = await oauth.oauth_provider.authorize_access_token(request) + userinfo = token.get("userinfo") + if not userinfo: + return RedirectResponse( + url="/auth/login?error=Failed+to+retrieve+user+information", + status_code=status.HTTP_302_FOUND + ) + + # Get or create user in database + email = userinfo.get("email") + if not email: + return RedirectResponse( + url="/auth/login?error=Email+not+provided+by+OAuth+provider", + status_code=status.HTTP_302_FOUND + ) + + # Find user by email or create a new one + user = db.query(models.User).filter(models.User.email == email).first() + if not user: + # Create new user with OAuth data + username = userinfo.get("preferred_username") or email.split("@")[0] + user = models.User( + username=username, + email=email, + hashed_password=get_password_hash(os.urandom(24).hex()), # Random password + is_active=True, # Set user as active + is_admin=False # Default to non-admin + ) + db.add(user) + db.commit() + db.refresh(user) + + # Determine if user is admin + is_admin_value = False + if hasattr(user, "is_admin") and user.is_admin is not None: + is_admin_value = user.is_admin + + # Store user info in session + user_data = { + "id": user.id, + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "is_admin": is_admin_value, # Set the admin status correctly + "_permanent": True, + "created_at": str(user.created_at), + } + + # Add picture from OAuth or Gravatar + if userinfo.get("picture"): + user_data["picture"] = userinfo.get("picture") + elif email: + user_data["picture"] = get_gravatar_url(email) + + request.session["user"] = user_data + + # Log the successful authentication + print(f"User authenticated via OAuth: {email}, is_admin: {is_admin_value}") + + # Redirect to original destination or default + redirect_url = request.session.pop("redirect_after_login", "/") + return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER) + except Exception as e: + print(f"OAuth authentication error: {str(e)}") + return RedirectResponse( + url=f"/auth/login?error=Authentication+failed:+{str(e)}", + status_code=status.HTTP_302_FOUND + ) + +@router.get("/logout") +async def logout(request: Request): + """Handle user logout""" + request.session.pop("user", None) + return RedirectResponse( + url="/auth/login?message=You+have+been+logged+out+successfully", + status_code=status.HTTP_302_FOUND + ) + +@router.get("/profile") +@require_login +async def profile_page(request: Request): + """Show user profile page""" + user = request.session.get("user") + return templates.TemplateResponse("auth/profile.html", {"request": request, "user": user}) + +@router.get("/register") +async def register_page(request: Request): + """Show registration page""" + return templates.TemplateResponse("auth/register.html", {"request": request}) + +@router.post("/register") +async def register(request: Request, db: Session = Depends(get_db)): + """Handle user registration""" + form_data = await request.form() + username = form_data.get("username") + email = form_data.get("email") + password = form_data.get("password") + confirm_password = form_data.get("confirm_password") + + # Validate input + if not username or not email or not password: + return RedirectResponse( + url="/auth/register?error=All+fields+are+required", + status_code=status.HTTP_302_FOUND + ) + + if password != confirm_password: + return RedirectResponse( + url="/auth/register?error=Passwords+do+not+match", + status_code=status.HTTP_302_FOUND + ) + + # Check if username or email already exists + if db.query(models.User).filter(models.User.username == username).first(): + return RedirectResponse( + url="/auth/register?error=Username+already+taken", + status_code=status.HTTP_302_FOUND + ) + + if db.query(models.User).filter(models.User.email == email).first(): + return RedirectResponse( + url="/auth/register?error=Email+already+registered", + status_code=status.HTTP_302_FOUND + ) + + # Create new user with the appropriate fields + user = models.User( + username=username, + email=email, + hashed_password=get_password_hash(password), + is_active=True, + is_admin=False # Explicitly set is_admin to False for new registrations + ) + db.add(user) + db.commit() + + # Redirect to registration success page + return RedirectResponse( + url="/auth/registration-success", + status_code=status.HTTP_303_SEE_OTHER + ) + +@router.get("/registration-success") +async def registration_success(request: Request): + """Show registration success page""" + return templates.TemplateResponse( + "auth/registration_success.html", + {"request": request} + ) + +@router.get("/api/whoami") +async def whoami(request: Request): + """API endpoint to get current user information""" + user = request.session.get("user") + return user or {"error": "Not authenticated"} diff --git a/app/db.py b/app/db.py index dec61c4..c22178c 100644 --- a/app/db.py +++ b/app/db.py @@ -3,19 +3,27 @@ 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") +# Get database connection details from environment variables with fallbacks +DB_HOST = os.environ.get("DB_HOST", "localhost") +DB_PORT = os.environ.get("DB_PORT", "3306") +DB_NAME = os.environ.get("DB_NAME", "pubquiz_db") +DB_USER = os.environ.get("DB_USER", "pubquiz_user") +DB_PASS = os.environ.get("DB_PASS", "pubquiz_pass") -SQLALCHEMY_DATABASE_URL = ( - f"mysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +# Create database URL +SQLALCHEMY_DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +# Create engine with appropriate parameters +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, ) -engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) +# Create session factory SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# Create base class for models Base = declarative_base() def init_db(): @@ -146,3 +154,12 @@ def migrate_schema(): print(f"Error during schema migration: {e}") finally: connection.close() + +# Add the missing get_db function +def get_db(): + """Database dependency for FastAPI endpoints""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/db_init.py b/app/db_init.py index 03ec4ea..79a1f2a 100644 --- a/app/db_init.py +++ b/app/db_init.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from .models import User, Team, TeamMembership, QRTicket from .db import SessionLocal +from .auth import get_password_hash def table_has_column(engine, table_name, column_name): """Check if a table has a specific column.""" @@ -28,13 +29,39 @@ def seed_db(): print("Database already has data. Skipping seeding.") return - # Create users + # Create users with properly hashed passwords 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"), + User( + username="admin", + email="admin@example.com", + hashed_password=get_password_hash("password"), + is_admin=True # Set admin privileges + ), + User( + username="john_quizmaster", + email="john@example.com", + hashed_password=get_password_hash("password123") + ), + User( + username="sarah_johnson", + email="sarah@example.com", + hashed_password=get_password_hash("password123") + ), + User( + username="mike_peters", + email="mike@example.com", + hashed_password=get_password_hash("password123") + ), + User( + username="emma_wilson", + email="emma@example.com", + hashed_password=get_password_hash("password123") + ), + User( + username="robert_brown", + email="robert@example.com", + hashed_password=get_password_hash("password123") + ), ] db.add_all(users) db.commit() @@ -62,8 +89,8 @@ def seed_db(): memberships = [] membership_data = [ # Quiz Wizards - (1, 1, True, 160), - (2, 1, False, 155), + (1, 1, True, 160), # Admin user is team admin of Quiz Wizards + (2, 1, True, 155), (3, 1, False, 130), (4, 1, False, 90), (5, 1, False, 45), diff --git a/app/dependencies.py b/app/dependencies.py index 00ab546..0b4d7ca 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,110 +1,39 @@ 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 get_db +from . import models +from .auth import get_current_user, require_login, require_admin -from .db import SessionLocal, engine -from .models import User -from .security import SECRET_KEY, ALGORITHM -from .templates_config import templates +# Reuse functions from auth.py +# This is just for backward compatibility with any code that imported these from dependencies -# OAuth2 scheme for token authentication -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) +# Function to get a db session +def get_session_db(): + return next(get_db()) -def get_db(): - """Database dependency.""" - db = SessionLocal() - try: - yield db - finally: - db.close() +# Function to get current authenticated user +def get_authenticated_user(request: Request): + return get_current_user(request) -# 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 [] +# These are kept for API backward compatibility +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") -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() - +def get_current_active_user(request: Request): + user = get_current_user(request) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) 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} +def get_current_admin_user(request: Request): + user = get_current_user(request) + if not user or not user.get("is_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + return user diff --git a/app/main.py b/app/main.py index 41ebd61..5c1f7fa 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from fastapi import FastAPI, Request, status +from fastapi import FastAPI, Request, status, Depends from fastapi.responses import HTMLResponse, RedirectResponse from starlette.middleware.sessions import SessionMiddleware from datetime import datetime @@ -8,9 +8,9 @@ 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 .views import qr, redeem, teams, admin, leaderboard, dashboard from .db_init import seed_db -from .dependencies import get_user_from_session +from .auth import router as auth_router, get_current_user # Create tables on startup init_db() @@ -21,71 +21,84 @@ 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 +# Configure session middleware with environment variables or defaults +secret_key = os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions-please-change-this") +if len(secret_key) < 32: + print(f"WARNING: Secret key is too short ({len(secret_key)} chars). Recommended: 32+ chars") + +# Apply SessionMiddleware FIRST - it must be the first middleware in the stack 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 + secret_key=secret_key, + max_age=int(os.environ.get("SESSION_MAX_AGE", "86400")), # 24 hours by default 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 + session_cookie="league_ledger_session", # Custom cookie name ) -# Add debugging middleware to help track sessions +# Debug middleware to track session state @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)}") + try: + session_cookie = request.cookies.get("league_ledger_session") + + print(f"Request path: {request.url.path}") + # Instead of checking request.scope, check if we can access the session dict + has_session = hasattr(request, "session") and isinstance(request.session, dict) + print(f"Has session attribute: {has_session}") + print(f"Has session cookie: {session_cookie is not None}") + + # Check session data + if hasattr(request, "session"): + try: + print(f"Session data before: {dict(request.session)}") + except (TypeError, AttributeError): + # The session might not be dict-like + print(f"Session exists but isn't a dictionary") + except Exception as e: + print(f"Error in debug middleware (pre): {str(e)}") response = await call_next(request) - if "session" in request.scope: - print(f"Session data after: {dict(request.session)}") + try: + if hasattr(request, "session"): + try: + print(f"Session data after: {dict(request.session)}") + except (TypeError, AttributeError): + print(f"Session exists but isn't a dictionary") + except Exception as e: + print(f"Error in debug middleware (post): {str(e)}") return response -# Update the template globals at app startup to access the request +# User context middleware to make user available in templates @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 + """Add user to request state and update template globals""" 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 + # Get user from session if available + user = get_current_user(request) + + # Store user in request.state for route handlers + request.state.user = user + + # Update template globals for all templates + templates.env.globals["current_user"] = user + + # Debug output to check user and admin status + if user: + print(f"User in context: {user.get('username')}, Admin: {user.get('is_admin', False)}") + 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 + print(f"Error setting user context: {str(e)}") # 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(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"]) @@ -97,34 +110,29 @@ app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"]) def index(request: Request): return templates.TemplateResponse("index.html", { "request": request, - "now": datetime.now, - "current_user": getattr(request.state, "user", None) + "now": datetime.now }) @app.get("/about", response_class=HTMLResponse) def about(request: Request): return templates.TemplateResponse("about.html", { - "request": request, - "current_user": getattr(request.state, "user", None) + "request": request }) @app.get("/contact", response_class=HTMLResponse) def contact(request: Request): return templates.TemplateResponse("contact.html", { - "request": request, - "current_user": getattr(request.state, "user", None) + "request": request }) @app.get("/privacy", response_class=HTMLResponse) def privacy(request: Request): return templates.TemplateResponse("privacy.html", { - "request": request, - "current_user": getattr(request.state, "user", None) + "request": request }) @app.get("/terms", response_class=HTMLResponse) def terms(request: Request): return templates.TemplateResponse("terms.html", { - "request": request, - "current_user": getattr(request.state, "user", None) + "request": request }) diff --git a/app/models.py b/app/models.py index 1cdacfc..b0e4fa4 100644 --- a/app/models.py +++ b/app/models.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Table +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float from sqlalchemy.orm import relationship from sqlalchemy.sql import func +from datetime import datetime from .db import Base class User(Base): @@ -11,15 +12,20 @@ class User(Base): 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()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) is_active = Column(Boolean, default=True) is_verified = Column(Boolean, default=False) + is_admin = 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 + # Relationships memberships = relationship("TeamMembership", back_populates="user") + teams = relationship("TeamMember", back_populates="user") + points = relationship("UserPoints", back_populates="user") + events_attended = relationship("EventAttendee", back_populates="user") class OAuthAccount(Base): @@ -40,14 +46,14 @@ 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 + description = Column(Text, nullable=True) 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 + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships memberships = relationship("TeamMembership", back_populates="team") + members = relationship("TeamMember", back_populates="team") class TeamMembership(Base): @@ -56,14 +62,25 @@ class TeamMembership(Base): 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 TeamMember(Base): + __tablename__ = "team_members" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) + is_captain = Column(Boolean, default=False) + joined_at = Column(DateTime, server_default=func.now()) + + # Relationships + user = relationship("User", back_populates="teams") + team = relationship("Team", back_populates="members") + + class QRTicket(Base): __tablename__ = "qr_tickets" id = Column(Integer, primary_key=True, index=True) @@ -81,7 +98,6 @@ class QRTicket(Base): 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) @@ -92,3 +108,68 @@ class TeamAchievement(Base): achieved_at = Column(DateTime, server_default=func.now()) team = relationship("Team") + + +class Event(Base): + __tablename__ = "events" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + location = Column(String(200), nullable=True) + event_date = Column(DateTime, nullable=False) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + attendees = relationship("EventAttendee", back_populates="event") + + +class EventAttendee(Base): + __tablename__ = "event_attendees" + id = Column(Integer, primary_key=True, index=True) + event_id = Column(Integer, ForeignKey("events.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + check_in_time = Column(DateTime, server_default=func.now()) + + # Relationships + event = relationship("Event", back_populates="attendees") + user = relationship("User", back_populates="events_attended") + + +class UserPoints(Base): + __tablename__ = "user_points" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + points = Column(Float, nullable=False, default=0) + reason = Column(String(200), nullable=True) + awarded_at = Column(DateTime, server_default=func.now()) + + # Relationships + user = relationship("User", back_populates="points") + + +class QRCode(Base): + __tablename__ = "qr_codes" + id = Column(Integer, primary_key=True, index=True) + code = Column(String(100), unique=True, index=True, nullable=False) + points = Column(Float, default=1.0, nullable=False) + description = Column(String(200), nullable=True) + is_active = Column(Boolean, default=True) + max_uses = Column(Integer, nullable=True) # null = unlimited + created_at = Column(DateTime, server_default=func.now()) + expires_at = Column(DateTime, nullable=True) # null = never expires + + # Relationships + redemptions = relationship("QRCodeRedemption", back_populates="qr_code") + + +class QRCodeRedemption(Base): + __tablename__ = "qr_code_redemptions" + id = Column(Integer, primary_key=True, index=True) + qr_code_id = Column(Integer, ForeignKey("qr_codes.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + redeemed_at = Column(DateTime, server_default=func.now()) + + # Relationships + qr_code = relationship("QRCode", back_populates="redemptions") + user = relationship("User") diff --git a/app/models/user.py b/app/models/user.py index e0eae7a..281e2ba 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,18 +1,14 @@ -# Add is_admin field to User model if it's missing - -from sqlalchemy import Column, Integer, String, Boolean -# ...existing imports... +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from ..db import Base 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 + password_hash = Column(String(255)) # Renamed from password to password_hash for clarity is_admin = Column(Boolean, default=False) - - # ...existing methods... + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index fb8d8ea..afcb07e 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -5,24 +5,25 @@

Log In

- {% if messages %} - {% for message in messages %} -
- {{ message.text }} -
- {% endfor %} + {% if error %} +
+ {{ error }} +
{% endif %} -
- - + {% if message %} +
+ {{ message }} +
+ {% endif %} + +
- + @@ -62,10 +63,6 @@ Log In
- -
@@ -75,7 +72,17 @@
- + + {% if show_oauth %} +
+

Or sign in with

+ +
+ {% else %}

Or sign in with

@@ -86,8 +93,9 @@ GitHub
-

OAuth login coming soon

+

OAuth login currently disabled

+ {% endif %} {% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index 42b2fdc..df4157d 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -1,184 +1,55 @@ {% 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 }} +
+
+
+ +
+ {% if user.picture %} + Profile Picture + {% else %} +
+ {{ user.username[0]|upper }}
- {% endfor %} + {% endif %}
- {% endif %} - - -
-
- -
-

Personal Information

+ + +
+

{{ user.username }}

+

{{ user.email }}

+ +
+
+

Account Type

+

{% if user.is_admin %}Administrator{% else %}User{% endif %}

+
-
-
- -
- -
-
- -
- -
- -
-
- -
- -
-
- -
-

Change Password

-
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- - -
+
+

Member Since

+

{{ user.created_at.split(' ')[0] }}

- -
-

Your Statistics

+
+

Account Settings

-
-
-

Total Points

-

378

-
-
-

QR Codes Redeemed

-

24

-
-
-

Teams Joined

-

{{ user.memberships|length }}

-
-
-

Best Position

-

#2

-
+
+

Change Password

+

Update your password to keep your account secure.

+
-

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.

- - +
diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html index bb95c28..f3fb580 100644 --- a/app/templates/auth/register.html +++ b/app/templates/auth/register.html @@ -2,15 +2,13 @@ {% block content %}
-

Create an Account

+

Create Account

- {% if messages %} - {% for message in messages %} -
- {{ message.text }} -
- {% endfor %} + {% if error %} +
+ {{ error }} +
{% endif %}
@@ -20,20 +18,17 @@ type="text" id="username" name="username" - value="{{ username or '' }}" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green" > -

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

- + @@ -46,6 +41,7 @@ id="password" name="password" required + minlength="8" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green" >

At least 8 characters

@@ -58,36 +54,26 @@ id="confirm_password" name="confirm_password" required + minlength="8" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green" >
-
- - -
-

Already have an account?

- Log in + + Log in +
diff --git a/app/templates/auth/registration_success.html b/app/templates/auth/registration_success.html index 7c0628b..8358647 100644 --- a/app/templates/auth/registration_success.html +++ b/app/templates/auth/registration_success.html @@ -1,33 +1,15 @@ {% extends "base.html" %} {% block content %} -
-
-
-
- -
-
- -

Registration Successful!

-

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

- -
- - Go to Dashboard +
+
+ - -
-

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
  • -
diff --git a/app/templates/base.html b/app/templates/base.html index 04dd9b6..6357b8a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -44,7 +44,7 @@
- LeagueLedger Logo + LeagueLedger Logo

LeagueLedger

@@ -62,6 +62,11 @@ Dashboard + {% if current_user and current_user.is_admin %} + + Admin + + {% endif %} About @@ -111,6 +116,11 @@ Dashboard + {% if current_user and current_user.is_admin %} + + Admin + + {% endif %} About diff --git a/app/templates/profile.html b/app/templates/profile.html index d183165..7f01ded 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -6,7 +6,7 @@
- Profile + Profile

John Quizmaster

diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html index ed94a45..c4a109d 100644 --- a/app/templates/team_detail.html +++ b/app/templates/team_detail.html @@ -64,7 +64,7 @@
- User + User

{{ member.user.username }}

diff --git a/app/templates_config.py b/app/templates_config.py index 6f21492..1d6ed42 100644 --- a/app/templates_config.py +++ b/app/templates_config.py @@ -20,3 +20,4 @@ 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 +templates.env.globals["current_user"] = None diff --git a/app/views/admin.py b/app/views/admin.py index ca651b3..7a21c11 100644 --- a/app/views/admin.py +++ b/app/views/admin.py @@ -13,6 +13,7 @@ import inspect as py_inspect from ..db import SessionLocal, Base from ..models import User, Team, TeamMembership, QRTicket from ..templates_config import templates +from ..auth import require_admin router = APIRouter() @@ -64,6 +65,7 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]: return relationships @router.get("/", response_class=HTMLResponse) +@require_admin async def admin_home(request: Request): """Admin dashboard home.""" model_list = [(key, name) for key, (_, name) in MODELS.items()] @@ -73,6 +75,7 @@ async def admin_home(request: Request): ) @router.get("/{model_name}", response_class=HTMLResponse) +@require_admin async def list_records( request: Request, model_name: str, @@ -124,6 +127,7 @@ async def list_records( ) @router.get("/{model_name}/new", response_class=HTMLResponse) +@require_admin async def create_record_form( request: Request, model_name: str, @@ -164,6 +168,7 @@ async def create_record_form( ) @router.post("/{model_name}/new") +@require_admin async def create_record( request: Request, model_name: str, @@ -212,6 +217,7 @@ async def create_record( return RedirectResponse(f"/admin/{model_name}", status_code=303) @router.get("/{model_name}/{record_id}", response_class=HTMLResponse) +@require_admin async def edit_record_form( request: Request, model_name: str, @@ -263,6 +269,7 @@ async def edit_record_form( ) @router.post("/{model_name}/{record_id}") +@require_admin async def update_record( request: Request, model_name: str, @@ -311,7 +318,9 @@ async def update_record( return RedirectResponse(f"/admin/{model_name}", status_code=303) @router.get("/{model_name}/{record_id}/delete") +@require_admin async def delete_record( + request: Request, model_name: str, record_id: int, db: Session = Depends(get_db) diff --git a/app/views/dashboard.py b/app/views/dashboard.py index a0ca73d..ae5899b 100644 --- a/app/views/dashboard.py +++ b/app/views/dashboard.py @@ -1,137 +1,84 @@ #!/usr/bin/env python3 -""" -Dashboard views for user-specific information. -""" -from fastapi import APIRouter, Depends, Request, Form -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi import APIRouter, Request, Depends, HTTPException from sqlalchemy.orm import Session -from sqlalchemy import func +from sqlalchemy.sql import func +from fastapi.responses import HTMLResponse, RedirectResponse -from ..db import SessionLocal -from ..models import User, Team, TeamMembership, QRTicket +from ..db import get_db from ..templates_config import templates +from ..auth import require_login +from .. import models router = APIRouter() -def get_db(): - db = SessionLocal() +@router.get("/") +@require_login +def user_dashboard(request: Request, db: Session = Depends(get_db)): + """User dashboard showing teams, events and stats""" 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" + user = request.session.get("user") + user_id = user.get("id") + + # Initialize default values in case of errors + team_count = 0 + total_points = 0 + event_count = 0 + recent_events = [] + user_teams = [] + + # Check if TeamMember model exists before querying + if hasattr(models, "TeamMember"): + # Get the team count for this user + team_count = db.query(func.count(models.TeamMember.team_id))\ + .filter(models.TeamMember.user_id == user_id)\ + .scalar() or 0 + + # Get user teams + user_teams = db.query(models.Team)\ + .join(models.TeamMember)\ + .filter(models.TeamMember.user_id == user_id)\ + .all() + + # Check if UserPoints model exists before querying + if hasattr(models, "UserPoints"): + # Get the total points safely + total_points_result = db.query(func.sum(models.UserPoints.points))\ + .filter(models.UserPoints.user_id == user_id)\ + .first() + + if total_points_result and total_points_result[0]: + total_points = total_points_result[0] + + # Check if EventAttendee model exists before querying + if hasattr(models, "EventAttendee") and hasattr(models, "Event"): + # Get event count safely + event_count_result = db.query(func.count(models.EventAttendee.event_id))\ + .filter(models.EventAttendee.user_id == user_id)\ + .first() + + if event_count_result and event_count_result[0]: + event_count = event_count_result[0] + + # Recent events - only if both models exist + recent_events = db.query(models.Event)\ + .join(models.EventAttendee)\ + .filter(models.EventAttendee.user_id == user_id)\ + .order_by(models.Event.event_date.desc())\ + .limit(5)\ + .all() + + return templates.TemplateResponse( + "dashboard/index.html", + { + "request": request, + "user": user, + "team_count": team_count, + "total_points": total_points, + "event_count": event_count, + "recent_events": recent_events, + "user_teams": user_teams + } ) - 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} - ) + except Exception as e: + print(f"Dashboard error: {str(e)}") + raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)}") diff --git a/docker-compose.yml b/docker-compose.yml index 4d8e551..ca6f548 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: ports: - "3306:3306" volumes: - - db_data:/var/lib/mysql + - db_data:/var/lib/mysql:delegated healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] interval: 5s @@ -38,6 +38,11 @@ services: DEBUG: "True" SESSION_MAX_AGE: "86400" # 24 hours COOKIE_SECURE: "False" # Set to True in production with HTTPS + # Add better error logging + PYTHONUNBUFFERED: "1" + # Add dependency installation command + command: > + bash -c "pip install pymysql && uvicorn app.main:app --host 0.0.0.0 --reload" ports: - "8000:8000" volumes: diff --git a/requirements.txt b/requirements.txt index 9f0725c..ff22cc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,18 @@ -fastapi -uvicorn[standard] -SQLAlchemy -mysqlclient -Jinja2 -python-multipart -passlib[bcrypt] -qrcode -email-validator -pillow -python-jose[cryptography] -itsdangerous +fastapi>=0.95.0 +uvicorn[standard]>=0.21.1 +sqlalchemy>=2.0.9 +pymysql>=1.0.3 +cryptography>=40.0.2 +python-multipart>=0.0.6 +authlib>=1.2.0 +# Specify specific versions for passlib and bcrypt to avoid compatibility issues +passlib==1.7.4 bcrypt==4.0.1 -flask-session==0.5.0 +starlette>=0.27.0 +jinja2>=3.1.2 +itsdangerous>=2.1.2 +python-jose[cryptography]>=3.3.0 +python-dotenv>=1.0.0 +qrcode +pydantic[email]>=1.10.7 +httpx \ No newline at end of file