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.
This commit is contained in:
Christian Krakau-Louis
2025-04-13 18:49:27 +02:00
parent 0de2eb5830
commit 4a79c5cacb
20 changed files with 790 additions and 579 deletions
+3
View File
@@ -18,6 +18,9 @@ COPY requirements.txt .
# Install dependencies # Install dependencies
RUN pip install --no-cache-dir -r requirements.txt 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 the rest of the code
COPY . . COPY . .
+333 -6
View File
@@ -1,14 +1,40 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Skeleton for authentication logic. Authentication system using Authlib and session-based auth
Placeholder for OAuth or password-based login.
""" """
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 sqlalchemy.orm import Session
from .db import SessionLocal
from . import models
from passlib.context import CryptContext 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() router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -19,4 +45,305 @@ def get_db():
finally: finally:
db.close() 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"}
+25 -8
View File
@@ -3,19 +3,27 @@ import os
from sqlalchemy import create_engine, inspect, text from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
DB_HOST = os.getenv("DB_HOST", "localhost") # Get database connection details from environment variables with fallbacks
DB_PORT = os.getenv("DB_PORT", "3306") DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_NAME = os.getenv("DB_NAME", "pubquiz_db") DB_PORT = os.environ.get("DB_PORT", "3306")
DB_USER = os.getenv("DB_USER", "pubquiz_user") DB_NAME = os.environ.get("DB_NAME", "pubquiz_db")
DB_PASS = os.getenv("DB_PASS", "pubquiz_pass") DB_USER = os.environ.get("DB_USER", "pubquiz_user")
DB_PASS = os.environ.get("DB_PASS", "pubquiz_pass")
SQLALCHEMY_DATABASE_URL = ( # Create database URL
f"mysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for models
Base = declarative_base() Base = declarative_base()
def init_db(): def init_db():
@@ -146,3 +154,12 @@ def migrate_schema():
print(f"Error during schema migration: {e}") print(f"Error during schema migration: {e}")
finally: finally:
connection.close() connection.close()
# Add the missing get_db function
def get_db():
"""Database dependency for FastAPI endpoints"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+35 -8
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from .models import User, Team, TeamMembership, QRTicket from .models import User, Team, TeamMembership, QRTicket
from .db import SessionLocal from .db import SessionLocal
from .auth import get_password_hash
def table_has_column(engine, table_name, column_name): def table_has_column(engine, table_name, column_name):
"""Check if a table has a specific column.""" """Check if a table has a specific column."""
@@ -28,13 +29,39 @@ def seed_db():
print("Database already has data. Skipping seeding.") print("Database already has data. Skipping seeding.")
return return
# Create users # Create users with properly hashed passwords
users = [ users = [
User(username="john_quizmaster", email="john@example.com", hashed_password="password123"), User(
User(username="sarah_johnson", email="sarah@example.com", hashed_password="password123"), username="admin",
User(username="mike_peters", email="mike@example.com", hashed_password="password123"), email="admin@example.com",
User(username="emma_wilson", email="emma@example.com", hashed_password="password123"), hashed_password=get_password_hash("password"),
User(username="robert_brown", email="robert@example.com", hashed_password="password123"), 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.add_all(users)
db.commit() db.commit()
@@ -62,8 +89,8 @@ def seed_db():
memberships = [] memberships = []
membership_data = [ membership_data = [
# Quiz Wizards # Quiz Wizards
(1, 1, True, 160), (1, 1, True, 160), # Admin user is team admin of Quiz Wizards
(2, 1, False, 155), (2, 1, True, 155),
(3, 1, False, 130), (3, 1, False, 130),
(4, 1, False, 90), (4, 1, False, 90),
(5, 1, False, 45), (5, 1, False, 45),
+29 -100
View File
@@ -1,110 +1,39 @@
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import inspect from .db import get_db
from typing import Optional from . import models
from datetime import datetime from .auth import get_current_user, require_login, require_admin
from .db import SessionLocal, engine # Reuse functions from auth.py
from .models import User # This is just for backward compatibility with any code that imported these from dependencies
from .security import SECRET_KEY, ALGORITHM
from .templates_config import templates
# OAuth2 scheme for token authentication # Function to get a db session
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) def get_session_db():
return next(get_db())
def get_db(): # Function to get current authenticated user
"""Database dependency.""" def get_authenticated_user(request: Request):
db = SessionLocal() return get_current_user(request)
try:
yield db
finally:
db.close()
# Check if all required user columns exist # These are kept for API backward compatibility
def get_available_user_columns(): oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
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)): def get_current_active_user(request: Request):
"""Get the current authenticated user based on the access token.""" user = get_current_user(request)
credentials_exception = HTTPException( if not user:
status_code=status.HTTP_401_UNAUTHORIZED, raise HTTPException(
detail="Could not validate credentials", status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"}, detail="Not authenticated",
) 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 return user
async def get_current_active_user(current_user: User = Depends(get_current_user)): def get_current_admin_user(request: Request):
"""Check if the current user is active.""" user = get_current_user(request)
if not current_user: if not user or not user.get("is_admin"):
return None raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
columns = get_available_user_columns() detail="Not enough permissions",
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 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}
+61 -53
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from fastapi import FastAPI, Request, status from fastapi import FastAPI, Request, status, Depends
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from datetime import datetime from datetime import datetime
@@ -8,9 +8,9 @@ import os
from .db import init_db, engine from .db import init_db, engine
from . import models from . import models
from .templates_config import templates 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 .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 # Create tables on startup
init_db() init_db()
@@ -21,71 +21,84 @@ seed_db()
app = FastAPI() app = FastAPI()
# IMPORTANT: Add SessionMiddleware FIRST before any other middleware # Configure session middleware with environment variables or defaults
# This ensures session data is available to all other middleware and route handlers 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( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions"), secret_key=secret_key,
max_age=int(os.environ.get("SESSION_MAX_AGE", 86400)), # 24 hours max_age=int(os.environ.get("SESSION_MAX_AGE", "86400")), # 24 hours by default
same_site="lax", # Important for security while allowing redirects same_site="lax", # Important for security while allowing redirects
https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true", 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") @app.middleware("http")
async def debug_session_middleware(request, call_next): async def debug_session_middleware(request, call_next):
"""Debug middleware to track session state""" """Debug middleware to track session state"""
session_cookie = request.cookies.get("league_ledger_session") try:
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"Request path: {request.url.path}")
print(f"Has session cookie: {session_cookie is not None}") # Instead of checking request.scope, check if we can access the session dict
has_session = hasattr(request, "session") and isinstance(request.session, dict)
if "session" in request.scope: print(f"Has session attribute: {has_session}")
print(f"Session data before: {dict(request.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) response = await call_next(request)
if "session" in request.scope: try:
print(f"Session data after: {dict(request.session)}") 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 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") @app.middleware("http")
async def add_user_to_request(request: Request, call_next): async def add_user_to_request(request: Request, call_next):
# Print debugging information """Add user to request state and update template globals"""
print(f"Processing request to: {request.url.path}")
# Add user to request state so templates can access it
try: try:
if "session" in request.scope: # Get user from session if available
print("Session found in request scope") user = get_current_user(request)
if "user_id" in request.session:
print(f"User ID in session: {request.session['user_id']}") # Store user in request.state for route handlers
# Get user from session request.state.user = user
user = await get_user_from_session(request)
request.state.user = user # Update template globals for all templates
else: templates.env.globals["current_user"] = user
print("No user_id in session")
request.state.user = None # Debug output to check user and admin status
else: if user:
print("No session in request scope") print(f"User in context: {user.get('username')}, Admin: {user.get('is_admin', False)}")
request.state.user = None
except Exception as e: except Exception as e:
print(f"Error in middleware: {e}") print(f"Error setting user context: {str(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 # Process the request
response = await call_next(request) response = await call_next(request)
return response return response
# Routers # 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(qr.router, prefix="/qr", tags=["QR"])
app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"]) app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"])
app.include_router(teams.router, prefix="/teams", tags=["Teams"]) 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): def index(request: Request):
return templates.TemplateResponse("index.html", { return templates.TemplateResponse("index.html", {
"request": request, "request": request,
"now": datetime.now, "now": datetime.now
"current_user": getattr(request.state, "user", None)
}) })
@app.get("/about", response_class=HTMLResponse) @app.get("/about", response_class=HTMLResponse)
def about(request: Request): def about(request: Request):
return templates.TemplateResponse("about.html", { return templates.TemplateResponse("about.html", {
"request": request, "request": request
"current_user": getattr(request.state, "user", None)
}) })
@app.get("/contact", response_class=HTMLResponse) @app.get("/contact", response_class=HTMLResponse)
def contact(request: Request): def contact(request: Request):
return templates.TemplateResponse("contact.html", { return templates.TemplateResponse("contact.html", {
"request": request, "request": request
"current_user": getattr(request.state, "user", None)
}) })
@app.get("/privacy", response_class=HTMLResponse) @app.get("/privacy", response_class=HTMLResponse)
def privacy(request: Request): def privacy(request: Request):
return templates.TemplateResponse("privacy.html", { return templates.TemplateResponse("privacy.html", {
"request": request, "request": request
"current_user": getattr(request.state, "user", None)
}) })
@app.get("/terms", response_class=HTMLResponse) @app.get("/terms", response_class=HTMLResponse)
def terms(request: Request): def terms(request: Request):
return templates.TemplateResponse("terms.html", { return templates.TemplateResponse("terms.html", {
"request": request, "request": request
"current_user": getattr(request.state, "user", None)
}) })
+91 -10
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/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.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from datetime import datetime
from .db import Base from .db import Base
class User(Base): class User(Base):
@@ -11,15 +12,20 @@ class User(Base):
email = Column(String(255), unique=True, index=True, nullable=False) email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=True) hashed_password = Column(String(255), nullable=True)
created_at = Column(DateTime, server_default=func.now()) 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_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False) is_verified = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True) verification_token = Column(String(255), nullable=True)
reset_token = Column(String(255), nullable=True) reset_token = Column(String(255), nullable=True)
reset_token_expires_at = Column(DateTime, nullable=True) reset_token_expires_at = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
# Relationship to teams # Relationships
memberships = relationship("TeamMembership", back_populates="user") 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): class OAuthAccount(Base):
@@ -40,14 +46,14 @@ class Team(Base):
__tablename__ = "teams" __tablename__ = "teams"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False) name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
# Add fields for team detail view
is_public = Column(Boolean, default=False) # For team privacy setting is_public = Column(Boolean, default=False) # For team privacy setting
created_at = Column(DateTime, server_default=func.now()) # For team founded date created_at = Column(DateTime, server_default=func.now()) # For team founded date
description = Column(Text, nullable=True) # Optional team description updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationship to memberships # Relationships
memberships = relationship("TeamMembership", back_populates="team") memberships = relationship("TeamMembership", back_populates="team")
members = relationship("TeamMember", back_populates="team")
class TeamMembership(Base): class TeamMembership(Base):
@@ -56,14 +62,25 @@ class TeamMembership(Base):
user_id = Column(Integer, ForeignKey("users.id")) user_id = Column(Integer, ForeignKey("users.id"))
team_id = Column(Integer, ForeignKey("teams.id")) team_id = Column(Integer, ForeignKey("teams.id"))
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
# Add joined_at to track when members joined
joined_at = Column(DateTime, server_default=func.now()) joined_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="memberships") user = relationship("User", back_populates="memberships")
team = relationship("Team", 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): class QRTicket(Base):
__tablename__ = "qr_tickets" __tablename__ = "qr_tickets"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@@ -81,7 +98,6 @@ class QRTicket(Base):
event_name = Column(String(255), nullable=True) event_name = Column(String(255), nullable=True)
# New model for team achievements
class TeamAchievement(Base): class TeamAchievement(Base):
__tablename__ = "team_achievements" __tablename__ = "team_achievements"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@@ -92,3 +108,68 @@ class TeamAchievement(Base):
achieved_at = Column(DateTime, server_default=func.now()) achieved_at = Column(DateTime, server_default=func.now())
team = relationship("Team") 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")
+6 -10
View File
@@ -1,18 +1,14 @@
# Add is_admin field to User model if it's missing from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from sqlalchemy import Column, Integer, String, Boolean from ..db import Base
# ...existing imports...
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
# ...existing fields...
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True) username = Column(String(50), unique=True, index=True)
email = Column(String(100), unique=True, index=True) email = Column(String(100), unique=True, index=True)
password = Column(String(255)) password_hash = Column(String(255)) # Renamed from password to password_hash for clarity
# Add is_admin field if it doesn't exist
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# ...existing methods... updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+25 -17
View File
@@ -5,24 +5,25 @@
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Log In</h1> <h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Log In</h1>
<!-- Messages/Alerts --> <!-- Messages/Alerts -->
{% if messages %} {% if error %}
{% for message in messages %} <div class="mb-4 p-3 rounded bg-red-100 text-red-700">
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}"> {{ error }}
{{ message.text }} </div>
</div>
{% endfor %}
{% endif %} {% endif %}
<form action="/auth/login" method="post" class="space-y-4"> {% if message %}
<input type="hidden" name="next" value="{{ next }}"> <div class="mb-4 p-3 rounded bg-green-100 text-green-700">
{{ message }}
</div>
{% endif %}
<form action="/auth/login" method="post" class="space-y-4">
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username or Email</label> <label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
value="{{ username or '' }}"
required 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" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
> >
@@ -62,10 +63,6 @@
Log In Log In
</button> </button>
</div> </div>
<div class="text-center text-sm mt-4">
<a href="/auth/forgot-password" class="text-irish-green hover:text-opacity-80">Forgot password?</a>
</div>
</form> </form>
<div class="mt-6 pt-6 border-t border-gray-200 text-center"> <div class="mt-6 pt-6 border-t border-gray-200 text-center">
@@ -75,7 +72,17 @@
</a> </a>
</div> </div>
<!-- OAuth login options (to be implemented later) --> <!-- OAuth login options -->
{% if show_oauth %}
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<div class="flex justify-center">
<a href="/auth/oauth-login" class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full flex items-center justify-center">
<i class="fas fa-sign-in-alt mr-2"></i> {{ oauth_provider_name }}
</a>
</div>
</div>
{% else %}
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p> <p class="text-center text-gray-600 mb-4">Or sign in with</p>
<div class="flex justify-center space-x-4"> <div class="flex justify-center space-x-4">
@@ -86,8 +93,9 @@
<i class="fab fa-github mr-2"></i> GitHub <i class="fab fa-github mr-2"></i> GitHub
</button> </button>
</div> </div>
<p class="text-center text-gray-500 text-xs mt-2">OAuth login coming soon</p> <p class="text-center text-gray-500 text-xs mt-2">OAuth login currently disabled</p>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+40 -169
View File
@@ -1,184 +1,55 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto"> <div class="max-w-3xl mx-auto my-8">
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8"> <div class="bg-white p-8 rounded-lg shadow-md">
<!-- Profile Header --> <div class="flex flex-col md:flex-row items-center md:items-start md:space-x-8">
<div class="bg-irish-green text-white p-6"> <!-- Profile Image -->
<div class="flex flex-col sm:flex-row items-center"> <div class="mb-6 md:mb-0">
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6"> {% if user.picture %}
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover"> <img src="{{ user.picture }}" alt="Profile Picture" class="w-32 h-32 rounded-full object-cover border-4 border-irish-green">
</div> {% else %}
<div class="text-center sm:text-left"> <div class="w-32 h-32 rounded-full bg-irish-green flex items-center justify-center text-white text-4xl">
<h1 class="text-2xl font-bold">{{ user.username }}</h1> {{ user.username[0]|upper }}
<p class="text-green-100">Member since {{ user.created_at.strftime('%B %Y') }}</p>
<p class="mt-2">
{% if user.is_verified %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Verified</span>
{% endif %}
<span class="bg-white text-irish-green text-xs px-2 py-1 rounded-full font-medium">{{ user.memberships|length }} Teams</span>
</p>
</div>
</div>
</div>
<!-- Messages/Alerts -->
{% if messages %}
<div class="p-4">
{% for message in messages %}
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message.text }}
</div> </div>
{% endfor %} {% endif %}
</div> </div>
{% endif %}
<!-- User Info -->
<!-- Profile Content --> <div class="flex-grow">
<div class="p-6"> <h1 class="text-2xl font-bold text-irish-green">{{ user.username }}</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <p class="text-gray-600 mb-4">{{ user.email }}</p>
<!-- Left Column: Personal Info -->
<div class="md:col-span-1"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2> <div class="bg-cream-white p-4 rounded-md">
<h3 class="font-semibold text-irish-green mb-1">Account Type</h3>
<p>{% if user.is_admin %}Administrator{% else %}User{% endif %}</p>
</div>
<form action="/auth/update-profile" method="post" class="space-y-4"> <div class="bg-cream-white p-4 rounded-md">
<div> <h3 class="font-semibold text-irish-green mb-1">Member Since</h3>
<label class="block text-sm font-medium text-gray-600">Display Name</label> <p>{{ user.created_at.split(' ')[0] }}</p>
<div class="mt-1">
<input
type="text"
name="username"
value="{{ user.username }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">Email</label>
<div class="mt-1">
<input
type="email"
name="email"
value="{{ user.email }}"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div class="pt-4">
<button type="submit" class="w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
Update Profile
</button>
</div>
</form>
<div class="mt-6 pt-6 border-t">
<h3 class="text-lg font-medium text-irish-green mb-3">Change Password</h3>
<form action="/auth/change-password" method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600">Current Password</label>
<div class="mt-1">
<input
type="password"
name="current_password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">New Password</label>
<div class="mt-1">
<input
type="password"
name="new_password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600">Confirm New Password</label>
<div class="mt-1">
<input
type="password"
name="confirm_password"
required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
>
</div>
</div>
<button type="submit" class="w-full bg-golden-ale hover:bg-opacity-90 text-black-stout font-medium py-2 px-4 rounded-md transition">
Change Password
</button>
</form>
</div> </div>
</div> </div>
<!-- Right Column: Stats & Teams --> <div class="space-y-4">
<div class="md:col-span-2"> <h2 class="text-xl font-semibold text-irish-green">Account Settings</h2>
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Statistics</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div class="border-b border-gray-200 pb-4">
<div class="bg-cream-white p-4 rounded-lg text-center"> <h3 class="text-gray-700 font-medium mb-2">Change Password</h3>
<p class="text-sm text-gray-600">Total Points</p> <p class="text-sm text-gray-600 mb-3">Update your password to keep your account secure.</p>
<p class="text-2xl font-bold text-irish-green">378</p> <button class="bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition" disabled>
</div> Change Password
<div class="bg-cream-white p-4 rounded-lg text-center"> <span class="text-xs">(Coming Soon)</span>
<p class="text-sm text-gray-600">QR Codes Redeemed</p> </button>
<p class="text-2xl font-bold text-irish-green">24</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Teams Joined</p>
<p class="text-2xl font-bold text-irish-green">{{ user.memberships|length }}</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Best Position</p>
<p class="text-2xl font-bold text-irish-green">#2</p>
</div>
</div> </div>
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Teams</h2> <div class="pt-4">
<div class="space-y-4"> <h3 class="text-gray-700 font-medium mb-2">Danger Zone</h3>
{% for team_info in user_teams %} <p class="text-sm text-gray-600 mb-3">Permanently delete your account and all of your data.</p>
<div class="border rounded-lg overflow-hidden"> <button class="border border-red-600 text-red-600 py-2 px-4 rounded-md hover:bg-red-600 hover:text-white transition" disabled>
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
<div class="font-medium">{{ team_info.team.name }}</div>
{% if team_info.is_admin %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
{% else %}
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
{% endif %}
</div>
<div class="px-4 py-3 flex justify-between items-center">
<div>
<p class="text-sm">Current Points: <span class="font-bold">187</span></p>
<p class="text-sm text-gray-600">Current Rank: #4</p>
</div>
<a href="/teams/{{ team_info.team.id }}" class="text-irish-green hover:underline text-sm">View Team</a>
</div>
</div>
{% else %}
<div class="text-center p-4 bg-gray-50 rounded-lg">
<p class="text-gray-600">You haven't joined any teams yet.</p>
</div>
{% endfor %}
<a href="/teams/" class="block text-center text-irish-green hover:underline text-sm">
<i class="fas fa-plus mr-1"></i> Join or Create Another Team
</a>
</div>
<!-- Account Management -->
<div class="mt-8 pt-6 border-t">
<h3 class="text-xl text-red-600 font-semibold mb-4">Danger Zone</h3>
<p class="text-gray-700 mb-4">These actions cannot be undone. Please be certain.</p>
<a href="/auth/delete-account" class="inline-block bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md">
Delete Account Delete Account
</a> <span class="text-xs">(Coming Soon)</span>
</button>
</div> </div>
</div> </div>
</div> </div>
+12 -26
View File
@@ -2,15 +2,13 @@
{% block content %} {% block content %}
<div class="max-w-md mx-auto my-8"> <div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md"> <div class="bg-white p-8 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Create an Account</h1> <h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Create Account</h1>
<!-- Messages/Alerts --> <!-- Messages/Alerts -->
{% if messages %} {% if error %}
{% for message in messages %} <div class="mb-4 p-3 rounded bg-red-100 text-red-700">
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}"> {{ error }}
{{ message.text }} </div>
</div>
{% endfor %}
{% endif %} {% endif %}
<form action="/auth/register" method="post" class="space-y-4"> <form action="/auth/register" method="post" class="space-y-4">
@@ -20,20 +18,17 @@
type="text" type="text"
id="username" id="username"
name="username" name="username"
value="{{ username or '' }}"
required 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" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
> >
<p class="text-xs text-gray-500 mt-1">Choose a unique username (3-30 characters, letters, numbers and underscores only)</p>
</div> </div>
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label> <label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
value="{{ email or '' }}"
required 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" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
> >
@@ -46,6 +41,7 @@
id="password" id="password"
name="password" name="password"
required 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" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
> >
<p class="text-xs text-gray-500 mt-1">At least 8 characters</p> <p class="text-xs text-gray-500 mt-1">At least 8 characters</p>
@@ -58,36 +54,26 @@
id="confirm_password" id="confirm_password"
name="confirm_password" name="confirm_password"
required 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" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
> >
</div> </div>
<div class="flex items-start">
<input
type="checkbox"
id="terms"
name="terms"
required
class="h-4 w-4 mt-1 text-irish-green focus:ring-irish-green border-gray-300 rounded"
>
<label for="terms" class="ml-2 block text-sm text-gray-700">
I agree to the <a href="/terms" class="text-irish-green hover:underline" target="_blank">Terms of Service</a> and <a href="/privacy" class="text-irish-green hover:underline" target="_blank">Privacy Policy</a>
</label>
</div>
<div> <div>
<button <button
type="submit" type="submit"
class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green" class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
> >
Create Account Register
</button> </button>
</div> </div>
</form> </form>
<div class="mt-6 pt-6 border-t border-gray-200 text-center"> <div class="mt-6 pt-6 border-t border-gray-200 text-center">
<p class="text-gray-600">Already have an account?</p> <p class="text-gray-600">Already have an account?</p>
<a href="/auth/login" class="text-irish-green hover:underline">Log in</a> <a href="/auth/login" class="block mt-2 bg-cream-white text-irish-green border border-irish-green py-2 px-4 rounded-md hover:bg-irish-green hover:text-white transition">
Log in
</a>
</div> </div>
</div> </div>
</div> </div>
+9 -27
View File
@@ -1,33 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="max-w-md mx-auto my-12"> <div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md text-center"> <div class="bg-white p-8 rounded-lg shadow-md">
<div class="mb-6"> <div class="text-center">
<div class="inline-block p-4 rounded-full bg-green-100"> <i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<i class="fas fa-check-circle text-irish-green text-5xl"></i> <h1 class="text-2xl font-bold text-irish-green mb-3">Registration Successful!</h1>
</div> <p class="text-gray-600 mb-6">Your account has been created successfully.</p>
</div>
<a href="/auth/login" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
<h1 class="text-2xl font-bold text-irish-green mb-4">Registration Successful!</h1> Log In
<p class="text-gray-600 mb-6">Your account has been created and you're now logged in.</p>
<div class="space-y-4">
<a href="/dashboard" class="block w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
Go to Dashboard
</a> </a>
<a href="/teams/" class="block w-full bg-cream-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white font-medium py-2 px-4 rounded-md transition">
Join a Team
</a>
</div>
<div class="mt-8 pt-6 border-t border-gray-200">
<h2 class="font-semibold mb-2">What's Next?</h2>
<ul class="text-left text-sm space-y-2">
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Explore the pub quiz leaderboards</li>
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Join an existing team or create your own</li>
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Scan QR codes at quiz events to earn points</li>
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Complete your profile information</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
+11 -1
View File
@@ -44,7 +44,7 @@
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<div class="flex justify-between items-center py-4"> <div class="flex justify-between items-center py-4">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<img src="https://via.placeholder.com/40x40" alt="LeagueLedger Logo" class="h-10 w-10"> <img src="https://picsum.photos/40" alt="LeagueLedger Logo" class="h-10 w-10">
<h1 class="text-2xl font-bold font-garamond">LeagueLedger</h1> <h1 class="text-2xl font-bold font-garamond">LeagueLedger</h1>
</div> </div>
@@ -62,6 +62,11 @@
<a href="/dashboard" class="hover:text-golden-ale transition-colors duration-200"> <a href="/dashboard" class="hover:text-golden-ale transition-colors duration-200">
<i class="fas fa-tachometer-alt"></i> Dashboard <i class="fas fa-tachometer-alt"></i> Dashboard
</a> </a>
{% if current_user and current_user.is_admin %}
<a href="/admin/" class="hover:text-golden-ale transition-colors duration-200">
<i class="fas fa-lock"></i> Admin
</a>
{% endif %}
<a href="/about" class="hover:text-golden-ale transition-colors duration-200"> <a href="/about" class="hover:text-golden-ale transition-colors duration-200">
About About
</a> </a>
@@ -111,6 +116,11 @@
<a href="/dashboard" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200"> <a href="/dashboard" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
<i class="fas fa-tachometer-alt"></i> Dashboard <i class="fas fa-tachometer-alt"></i> Dashboard
</a> </a>
{% if current_user and current_user.is_admin %}
<a href="/admin/" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
<i class="fas fa-lock"></i> Admin
</a>
{% endif %}
<a href="/about" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200"> <a href="/about" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
About About
</a> </a>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="bg-irish-green text-white p-6"> <div class="bg-irish-green text-white p-6">
<div class="flex flex-col sm:flex-row items-center"> <div class="flex flex-col sm:flex-row items-center">
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6"> <div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover"> <img src="https://picsum.photos/150" alt="Profile" class="w-full h-full object-cover">
</div> </div>
<div class="text-center sm:text-left"> <div class="text-center sm:text-left">
<h1 class="text-2xl font-bold">John Quizmaster</h1> <h1 class="text-2xl font-bold">John Quizmaster</h1>
+1 -1
View File
@@ -64,7 +64,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-10 h-10 rounded-full bg-gray-200 mr-3 overflow-hidden"> <div class="w-10 h-10 rounded-full bg-gray-200 mr-3 overflow-hidden">
<img src="https://via.placeholder.com/40" alt="User" class="w-full h-full object-cover"> <img src="https://picsum.photos/40" alt="User" class="w-full h-full object-cover">
</div> </div>
<div> <div>
<p class="font-medium">{{ member.user.username }}</p> <p class="font-medium">{{ member.user.username }}</p>
+1
View File
@@ -20,3 +20,4 @@ templates = MyJinjaTemplates(directory=str(BASE_DIR / "templates"))
# Register a context processor to add current_user to all 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["get_current_user"] = lambda: None # Will be overridden at runtime
templates.env.globals["current_user"] = None
+9
View File
@@ -13,6 +13,7 @@ import inspect as py_inspect
from ..db import SessionLocal, Base from ..db import SessionLocal, Base
from ..models import User, Team, TeamMembership, QRTicket from ..models import User, Team, TeamMembership, QRTicket
from ..templates_config import templates from ..templates_config import templates
from ..auth import require_admin
router = APIRouter() router = APIRouter()
@@ -64,6 +65,7 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
return relationships return relationships
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
@require_admin
async def admin_home(request: Request): async def admin_home(request: Request):
"""Admin dashboard home.""" """Admin dashboard home."""
model_list = [(key, name) for key, (_, name) in MODELS.items()] 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) @router.get("/{model_name}", response_class=HTMLResponse)
@require_admin
async def list_records( async def list_records(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -124,6 +127,7 @@ async def list_records(
) )
@router.get("/{model_name}/new", response_class=HTMLResponse) @router.get("/{model_name}/new", response_class=HTMLResponse)
@require_admin
async def create_record_form( async def create_record_form(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -164,6 +168,7 @@ async def create_record_form(
) )
@router.post("/{model_name}/new") @router.post("/{model_name}/new")
@require_admin
async def create_record( async def create_record(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -212,6 +217,7 @@ async def create_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303) return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse) @router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
@require_admin
async def edit_record_form( async def edit_record_form(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -263,6 +269,7 @@ async def edit_record_form(
) )
@router.post("/{model_name}/{record_id}") @router.post("/{model_name}/{record_id}")
@require_admin
async def update_record( async def update_record(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -311,7 +318,9 @@ async def update_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303) return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete") @router.get("/{model_name}/{record_id}/delete")
@require_admin
async def delete_record( async def delete_record(
request: Request,
model_name: str, model_name: str,
record_id: int, record_id: int,
db: Session = Depends(get_db) db: Session = Depends(get_db)
+75 -128
View File
@@ -1,137 +1,84 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" from fastapi import APIRouter, Request, Depends, HTTPException
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.orm import Session
from sqlalchemy import func from sqlalchemy.sql import func
from fastapi.responses import HTMLResponse, RedirectResponse
from ..db import SessionLocal from ..db import get_db
from ..models import User, Team, TeamMembership, QRTicket
from ..templates_config import templates from ..templates_config import templates
from ..auth import require_login
from .. import models
router = APIRouter() router = APIRouter()
def get_db(): @router.get("/")
db = SessionLocal() @require_login
def user_dashboard(request: Request, db: Session = Depends(get_db)):
"""User dashboard showing teams, events and stats"""
try: try:
yield db user = request.session.get("user")
finally: user_id = user.get("id")
db.close()
# Initialize default values in case of errors
def get_or_create_default_user(db: Session): team_count = 0
"""Get user ID 1 or create it if it doesn't exist.""" total_points = 0
user = db.query(User).filter_by(id=1).first() event_count = 0
if not user: recent_events = []
# Create a default user user_teams = []
user = User(
username="default_user", # Check if TeamMember model exists before querying
email="default@example.com", if hasattr(models, "TeamMember"):
hashed_password="placeholder" # 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) except Exception as e:
db.commit() print(f"Dashboard error: {str(e)}")
db.refresh(user) raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)}")
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}
)
+6 -1
View File
@@ -13,7 +13,7 @@ services:
ports: ports:
- "3306:3306" - "3306:3306"
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql:delegated
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 5s interval: 5s
@@ -38,6 +38,11 @@ services:
DEBUG: "True" DEBUG: "True"
SESSION_MAX_AGE: "86400" # 24 hours SESSION_MAX_AGE: "86400" # 24 hours
COOKIE_SECURE: "False" # Set to True in production with HTTPS 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: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
+17 -13
View File
@@ -1,14 +1,18 @@
fastapi fastapi>=0.95.0
uvicorn[standard] uvicorn[standard]>=0.21.1
SQLAlchemy sqlalchemy>=2.0.9
mysqlclient pymysql>=1.0.3
Jinja2 cryptography>=40.0.2
python-multipart python-multipart>=0.0.6
passlib[bcrypt] authlib>=1.2.0
qrcode # Specify specific versions for passlib and bcrypt to avoid compatibility issues
email-validator passlib==1.7.4
pillow
python-jose[cryptography]
itsdangerous
bcrypt==4.0.1 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