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 @@
Or sign in with
+ +Or sign in with
OAuth login coming soon
+OAuth login currently disabled
Member since {{ user.created_at.strftime('%B %Y') }}
-- {% if user.is_verified %} - Verified - {% endif %} - {{ user.memberships|length }} Teams -
-