Implement user authentication and management system with FastAPI

- Added authentication routes for login, registration, password reset, and account deletion in `auth.py`.
- Implemented user profile management, including updating user information and changing passwords.
- Created dashboard views to display user-specific information and recent activity in `dashboard.py`.
- Developed leaderboard views to show team rankings and points in `leaderboard.py`.
- Added QR code generation and redemption functionality in `qr.py` and `redeem.py`.
- Implemented team management features, allowing users to create and join teams in `teams.py`.
- Introduced session debugging utility to assist with session-related issues in `debug_session.py`.
- Configured Docker Compose for MySQL database and FastAPI application with environment variables.
- Updated requirements.txt to include necessary dependencies for the application.
This commit is contained in:
Christian Krakau-Louis
2025-04-11 17:46:59 +02:00
parent 90bef433fd
commit 0de2eb5830
43 changed files with 4427 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
Admin interface for managing database records.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect
import json
from typing import Dict, Any, List, Type, Optional
import inspect as py_inspect
from ..db import SessionLocal, Base
from ..models import User, Team, TeamMembership, QRTicket
from ..templates_config import templates
router = APIRouter()
# Dictionary of model classes with their display names
MODELS = {
'user': (User, "Users"),
'team': (Team, "Teams"),
'team_membership': (TeamMembership, "Team Memberships"),
'qr_ticket': (QRTicket, "QR Tickets"),
}
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_model_info(model_class: Type[Base]) -> Dict[str, Dict[str, Any]]:
"""Get column information for a model."""
mapper = inspect(model_class)
columns = {}
for column in mapper.columns:
is_primary = column.primary_key
is_foreign_key = bool(column.foreign_keys)
foreign_key_target = None
if is_foreign_key:
for fk in column.foreign_keys:
foreign_key_target = fk.target_fullname
columns[column.name] = {
'type': str(column.type),
'nullable': column.nullable,
'primary_key': is_primary,
'foreign_key': is_foreign_key,
'foreign_key_target': foreign_key_target,
}
return columns
def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
"""Get relationship information for a model."""
relationships = {}
for name, rel in py_inspect.getmembers(model_class, lambda o: hasattr(o, 'prop')):
if hasattr(rel.prop, 'target'):
relationships[name] = rel.prop.target.name
return relationships
@router.get("/", response_class=HTMLResponse)
async def admin_home(request: Request):
"""Admin dashboard home."""
model_list = [(key, name) for key, (_, name) in MODELS.items()]
return templates.TemplateResponse(
"admin/index.html",
{"request": request, "models": model_list}
)
@router.get("/{model_name}", response_class=HTMLResponse)
async def list_records(
request: Request,
model_name: str,
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=5, le=100),
db: Session = Depends(get_db)
):
"""List records for a model with pagination."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get total count for pagination
total_records = db.query(model_class).count()
total_pages = (total_records + per_page - 1) // per_page
# Get records with pagination
records = db.query(model_class).offset((page - 1) * per_page).limit(per_page).all()
# Get column information
columns_info = get_model_info(model_class)
# Prepare column names for display
column_names = list(columns_info.keys())
# Extract values for each record
records_data = []
for record in records:
record_data = {}
for col in column_names:
record_data[col] = getattr(record, col)
records_data.append(record_data)
return templates.TemplateResponse(
"admin/list.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"records": records_data,
"columns": column_names,
"columns_info": columns_info,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"total_records": total_records,
}
)
@router.get("/{model_name}/new", response_class=HTMLResponse)
async def create_record_form(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Show form for creating a new record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get column information
columns_info = get_model_info(model_class)
# For foreign keys, fetch possible values
foreign_key_options = {}
for col_name, info in columns_info.items():
if info['foreign_key'] and info['foreign_key_target']:
target_table, target_col = info['foreign_key_target'].split('.')
# Try to find the corresponding model class
for model_key, (model_cls, _) in MODELS.items():
if model_cls.__tablename__ == target_table:
# Fetch options for this foreign key
options = db.query(model_cls).all()
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
return templates.TemplateResponse(
"admin/edit.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"columns_info": columns_info,
"record": None, # No record for new form
"foreign_key_options": foreign_key_options,
"is_new": True
}
)
@router.post("/{model_name}/new")
async def create_record(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Create a new record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get form data from request
form_data = await request.form()
# Convert form data to appropriate types
columns_info = get_model_info(model_class)
record_data = {}
for field_name, value in form_data.items():
if field_name in columns_info:
col_type = columns_info[field_name]['type'].lower()
# Skip empty values for nullable fields
if value == '' and columns_info[field_name]['nullable']:
continue
# Convert values based on column type
if 'int' in col_type:
if value:
record_data[field_name] = int(value)
elif 'bool' in col_type or 'boolean' in col_type:
record_data[field_name] = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
else:
record_data[field_name] = value
# Skip primary key for new records if it's auto-increment
for col_name, info in columns_info.items():
if info['primary_key'] and col_name not in record_data:
pass # Skip primary key
# Create record
new_record = model_class(**record_data)
db.add(new_record)
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
async def edit_record_form(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Show form for editing an existing record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Get column information
columns_info = get_model_info(model_class)
# For foreign keys, fetch possible values
foreign_key_options = {}
for col_name, info in columns_info.items():
if info['foreign_key'] and info['foreign_key_target']:
target_table, target_col = info['foreign_key_target'].split('.')
# Try to find the corresponding model class
for model_key, (model_cls, _) in MODELS.items():
if model_cls.__tablename__ == target_table:
# Fetch options for this foreign key
options = db.query(model_cls).all()
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
# Prepare record data
record_data = {}
for col_name in columns_info:
record_data[col_name] = getattr(record, col_name)
return templates.TemplateResponse(
"admin/edit.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"columns_info": columns_info,
"record": record_data,
"foreign_key_options": foreign_key_options,
"is_new": False
}
)
@router.post("/{model_name}/{record_id}")
async def update_record(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Update an existing record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Get form data from request
form_data = await request.form()
# Convert form data to appropriate types and update record
columns_info = get_model_info(model_class)
for field_name, value in form_data.items():
if field_name in columns_info and not columns_info[field_name]['primary_key']:
col_type = columns_info[field_name]['type'].lower()
# Handle nullable fields
if value == '' and columns_info[field_name]['nullable']:
setattr(record, field_name, None)
continue
# Convert values based on column type
if 'int' in col_type:
if value:
setattr(record, field_name, int(value))
elif 'bool' in col_type or 'boolean' in col_type:
bool_value = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
setattr(record, field_name, bool_value)
else:
setattr(record, field_name, value)
# Save changes
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete")
async def delete_record(
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Delete a record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Delete record
db.delete(record)
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
+560
View File
@@ -0,0 +1,560 @@
#!/usr/bin/env python3
"""
Authentication routes for user login, registration, and management.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import smtplib
from email.message import EmailMessage
import os
from ..db import SessionLocal, engine
from ..models import User
from ..security import (
verify_password, get_password_hash, create_access_token, generate_token,
SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
)
from ..dependencies import get_db, get_current_user, get_user_from_session
from ..templates_config import templates
from ..schemas import UserCreate, UserLogin, UserUpdate, PasswordReset
router = APIRouter()
# Check if all required user columns exist
def get_available_user_columns():
inspector = inspect(engine)
if 'users' in inspector.get_table_names():
return [col['name'] for col in inspector.get_columns('users')]
return []
# --- HTML ROUTES (Web UI) ---
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
"""Display login page."""
# Debug print to check what's happening in the route
print("Login page accessed, checking session...")
# DO NOT redirect if user is logged in - there's likely an issue with session handling
# Just render the login page regardless of session state for now
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": []
}
)
@router.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
response: Response,
db: Session = Depends(get_db),
username: str = Form(...),
password: str = Form(...),
remember: bool = Form(False),
next: str = Form("/")
):
"""Process login form."""
# Try to authenticate user
user = db.query(User).filter((User.username == username) | (User.email == username)).first()
if not user or not verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": [{"type": "error", "text": "Invalid username or password"}],
"username": username
},
status_code=status.HTTP_401_UNAUTHORIZED
)
# Check if is_active column exists and if user is active
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"next": next,
"messages": [{"type": "error", "text": "Account is deactivated"}],
"username": username
},
status_code=status.HTTP_401_UNAUTHORIZED
)
# Update last login time if column exists
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
# Debug info
print(f"User authenticated: {user.username}")
# Set session data with better error handling
try:
# Use directly accessible dictionary
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["_permanent"] = True
# Check if is_admin attribute exists
if hasattr(user, "is_admin"):
request.session["is_admin"] = user.is_admin
else:
request.session["is_admin"] = False
# Add timestamp for session creation
request.session["created_at"] = str(datetime.now())
# Debug session data
print(f"Session data set: {dict(request.session)}")
except Exception as e:
print(f"Error setting session: {str(e)}")
# Redirect to next page or home
return RedirectResponse(url=next, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
"""Display registration page."""
# Check if user is already logged in
user = await get_user_from_session(request)
if user:
return RedirectResponse(url="/")
return templates.TemplateResponse("auth/register.html", {"request": request})
@router.post("/register", response_class=HTMLResponse)
async def register(
request: Request,
db: Session = Depends(get_db),
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
):
"""Process registration form."""
# Validate the form data
if password != confirm_password:
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Passwords do not match"}],
"username": username,
"email": email
}
)
# Check if username already exists
if db.query(User).filter(User.username == username).first():
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Username already exists"}],
"username": username,
"email": email
}
)
# Check if email already exists
if db.query(User).filter(User.email == email).first():
return templates.TemplateResponse(
"auth/register.html",
{
"request": request,
"messages": [{"type": "error", "text": "Email already exists"}],
"username": username,
"email": email
}
)
# Create new user
hashed_password = get_password_hash(password)
# Get available columns
columns = get_available_user_columns()
user_data = {
"username": username,
"email": email,
"hashed_password": hashed_password
}
# Add optional fields only if they exist in the database
if 'is_active' in columns:
user_data["is_active"] = True
if 'is_verified' in columns:
user_data["is_verified"] = False
if 'verification_token' in columns:
user_data["verification_token"] = generate_token()
new_user = User(**user_data)
db.add(new_user)
db.commit()
db.refresh(new_user)
# In a real app, send verification email here
# For now, just redirect to a success page
# Log the user in
request.session["user_id"] = new_user.id
return RedirectResponse(
url="/auth/registration-success",
status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/registration-success", response_class=HTMLResponse)
async def registration_success(request: Request):
"""Display registration success page."""
return templates.TemplateResponse("auth/registration_success.html", {"request": request})
@router.get("/logout")
async def logout(request: Request):
"""Log user out by clearing session."""
request.session.clear()
return RedirectResponse(url="/")
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(
request: Request,
db: Session = Depends(get_db)
):
"""Display user profile page."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Get user's teams (using fresh user object from database to ensure relationships are loaded)
user = db.query(User).filter(User.id == user.id).first()
user_teams = []
for membership in user.memberships:
user_teams.append({
"team": membership.team,
"is_admin": membership.is_admin
})
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"user_teams": user_teams
}
)
@router.post("/update-profile", response_class=HTMLResponse)
async def update_profile(
request: Request,
db: Session = Depends(get_db),
username: str = Form(None),
email: str = Form(None),
):
"""Update user profile information."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Check for username collision
if username and username != user.username:
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Username already exists"}]
}
)
user.username = username
# Check for email collision
if email and email != user.email:
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Email already exists"}]
}
)
user.email = email
db.commit()
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "success", "text": "Profile updated successfully"}]
}
)
@router.post("/change-password", response_class=HTMLResponse)
async def change_password(
request: Request,
db: Session = Depends(get_db),
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
):
"""Change user password."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/profile")
# Verify current password
if not verify_password(current_password, user.hashed_password):
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "Current password is incorrect"}]
}
)
# Check if new passwords match
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "error", "text": "New passwords do not match"}]
}
)
# Update password
user.hashed_password = get_password_hash(new_password)
db.commit()
return templates.TemplateResponse(
"auth/profile.html",
{
"request": request,
"user": user,
"messages": [{"type": "success", "text": "Password changed successfully"}]
}
)
@router.get("/forgot-password", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
"""Display forgot password page."""
return templates.TemplateResponse("auth/forgot_password.html", {"request": request})
@router.post("/forgot-password")
async def forgot_password(
request: Request,
db: Session = Depends(get_db),
email: str = Form(...)
):
"""Process forgot password form."""
# Find user by email
user = db.query(User).filter(User.email == email).first()
# Always show success to prevent email enumeration
if not user:
return templates.TemplateResponse(
"auth/forgot_password_sent.html",
{"request": request}
)
# Generate reset token
reset_token = generate_token()
user.reset_token = reset_token
user.reset_token_expires_at = datetime.utcnow() + timedelta(hours=1)
db.commit()
# In a real app, send email with reset link
# For demo, just show the reset link on the success page
reset_url = f"/auth/reset-password?token={reset_token}"
return templates.TemplateResponse(
"auth/forgot_password_sent.html",
{
"request": request,
"reset_url": reset_url # Remove in production, just for demo
}
)
@router.get("/reset-password", response_class=HTMLResponse)
async def reset_password_page(
request: Request,
token: str,
db: Session = Depends(get_db)
):
"""Display reset password page."""
# Check if token exists and is valid
user = db.query(User).filter(
User.reset_token == token,
User.reset_token_expires_at > datetime.utcnow()
).first()
if not user:
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request}
)
return templates.TemplateResponse(
"auth/reset_password.html",
{"request": request, "token": token}
)
@router.post("/reset-password")
async def reset_password(
request: Request,
db: Session = Depends(get_db),
token: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...)
):
"""Process reset password form."""
# Check if token exists and is valid
user = db.query(User).filter(
User.reset_token == token,
User.reset_token_expires_at > datetime.utcnow()
).first()
if not user:
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request}
)
# Check if passwords match
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/reset_password.html",
{
"request": request,
"token": token,
"messages": [{"type": "error", "text": "Passwords do not match"}]
}
)
# Update password
user.hashed_password = get_password_hash(new_password)
user.reset_token = None
user.reset_token_expires_at = None
db.commit()
return templates.TemplateResponse(
"auth/reset_password_success.html",
{"request": request}
)
@router.get("/delete-account", response_class=HTMLResponse)
async def delete_account_page(request: Request):
"""Display delete account confirmation page."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
return templates.TemplateResponse("auth/delete_account.html", {"request": request})
@router.post("/delete-account")
async def delete_account(
request: Request,
db: Session = Depends(get_db),
password: str = Form(...)
):
"""Process account deletion."""
user = await get_user_from_session(request)
if not user:
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
# Verify password
if not verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"auth/delete_account.html",
{
"request": request,
"messages": [{"type": "error", "text": "Incorrect password"}]
}
)
# In a real app, you might want to anonymize the user data instead
# of deleting it completely, but for this demo we'll delete
# Clear session
request.session.clear()
# Delete user
db.delete(user)
db.commit()
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# --- API ROUTES (for potential SPA frontend) ---
@router.post("/token")
async def login_api(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""API login endpoint returning JWT token."""
# Authenticate user
user = db.query(User).filter((User.username == form_data.username) | (User.email == form_data.username)).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
# Create access token
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username},
expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer",
"user_id": user.id,
"username": user.username
}
@router.get("/session-test")
async def test_session(request: Request):
"""Test endpoint to verify session data persistence"""
has_session = hasattr(request, "session")
session_data = dict(request.session) if has_session else {}
return {
"has_session": has_session,
"session_data": session_data,
"authenticated": "user_id" in session_data
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Dashboard views for user-specific information.
"""
from fastapi import APIRouter, Depends, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..db import SessionLocal
from ..models import User, Team, TeamMembership, QRTicket
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_or_create_default_user(db: Session):
"""Get user ID 1 or create it if it doesn't exist."""
user = db.query(User).filter_by(id=1).first()
if not user:
# Create a default user
user = User(
username="default_user",
email="default@example.com",
hashed_password="placeholder"
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/", response_class=HTMLResponse)
async def user_dashboard(
request: Request,
db: Session = Depends(get_db)
):
"""Show the user's dashboard with team info and recent activity."""
# Get current user - using a default user for now
# In a real app, this would come from auth system
user = get_or_create_default_user(db)
# Get user's teams
user_teams = db.query(Team).join(
TeamMembership,
TeamMembership.team_id == Team.id
).filter(
TeamMembership.user_id == user.id
).all()
# Get team memberships with admin status
team_memberships = db.query(
TeamMembership
).filter(
TeamMembership.user_id == user.id
).all()
admin_team_ids = [tm.team_id for tm in team_memberships if tm.is_admin]
# Get points per team
team_points = {}
for team in user_teams:
points = db.query(func.sum(QRTicket.points)).filter(
QRTicket.redeemed_at_team == team.id
).scalar() or 0
# Get team ranking - simplified approach
higher_teams = db.query(func.count(Team.id)).join(
QRTicket,
QRTicket.redeemed_at_team == Team.id
).group_by(
Team.id
).having(
func.sum(QRTicket.points) > points
).scalar() or 0
rank = higher_teams + 1
team_points[team.id] = {
'points': points,
'rank': rank
}
# Get recent activity
# For simplicity, we're just getting recent QR code redemptions
recent_activity = []
recent_tickets = db.query(QRTicket).filter(
QRTicket.redeemed_by == user.id
).order_by(
QRTicket.id.desc() # Assuming higher ID = newer
).limit(5).all()
for ticket in recent_tickets:
team = db.query(Team).filter(Team.id == ticket.redeemed_at_team).first()
activity = {
'type': 'qr_redeem',
'points': ticket.points,
'team_name': team.name if team else "Unknown team",
'date': "Recently" # Placeholder - would use ticket.created_at
}
recent_activity.append(activity)
# Get total points for user across all teams
total_points = sum(team_data['points'] for team_data in team_points.values())
# Get best ranking
best_rank = min(team_data['rank'] for team_data in team_points.values()) if team_points else None
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"user": user,
"teams": user_teams,
"team_points": team_points,
"admin_team_ids": admin_team_ids,
"recent_activity": recent_activity,
"total_points": total_points,
"best_rank": best_rank,
"team_count": len(user_teams)
}
)
@router.get("/scan", response_class=HTMLResponse)
async def scan_qr(request: Request):
"""Show QR scanning interface."""
return templates.TemplateResponse(
"scan_qr.html",
{"request": request}
)
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Leaderboard views for displaying team rankings.
"""
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from datetime import datetime, timedelta
from ..db import SessionLocal
from ..models import Team, TeamMembership, QRTicket
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/", response_class=HTMLResponse)
async def show_leaderboard(
request: Request,
timeframe: str = Query("all", regex="^(week|month|all)$"),
db: Session = Depends(get_db)
):
"""Show the leaderboard with team rankings."""
# Define cutoff date based on timeframe
cutoff_date = None
if timeframe == "week":
cutoff_date = datetime.now() - timedelta(days=7)
time_label = "This Week"
elif timeframe == "month":
cutoff_date = datetime.now() - timedelta(days=30)
time_label = "This Month"
else:
timeframe = "all" # Ensure valid value
time_label = "All Time"
# Base query to get teams
query = db.query(
Team.id,
Team.name,
func.coalesce(func.sum(QRTicket.points), 0).label('total_points')
).join(
QRTicket,
QRTicket.redeemed_at_team == Team.id,
isouter=True
)
# Apply time filter if needed
if cutoff_date:
# Note: This assumes QRTicket has a created_at or similar timestamp field
# If not, you would need to add one to track when points were added
# For now, this is a placeholder that assumes all tickets are from "now"
# query = query.filter(QRTicket.created_at >= cutoff_date)
pass
# Group and order
teams_ranking = query.group_by(Team.id).order_by(desc('total_points')).all()
# Add ranks
ranked_teams = []
for idx, team in enumerate(teams_ranking):
ranked_teams.append({
'rank': idx + 1,
'id': team.id,
'name': team.name,
'points': team.total_points,
'change': 0 # Placeholder for rank change - would require historical data
})
# Get top 3 teams for podium display
top_teams = ranked_teams[:3] if len(ranked_teams) >= 3 else ranked_teams + [None] * (3 - len(ranked_teams))
return templates.TemplateResponse(
"leaderboard.html",
{
"request": request,
"teams": ranked_teams,
"top_teams": top_teams,
"timeframe": timeframe,
"time_label": time_label
}
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
Generate QR codes for top teams (quiz master).
"""
import qrcode
import io
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import QRTicket
import uuid
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/generate/{points}")
def generate_qr(points: int, db: Session = Depends(get_db)):
"""
Generate a QR code for awarding `points` points.
Saves a record in the DB, returns the PNG as streaming response.
"""
code_str = str(uuid.uuid4())
ticket = QRTicket(code=code_str, points=points)
db.add(ticket)
db.commit()
db.refresh(ticket)
qr_img = qrcode.make(code_str)
buf = io.BytesIO()
qr_img.save(buf, format="PNG")
buf.seek(0)
return StreamingResponse(buf, media_type="image/png")
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
Redeem a QR code and attribute points to a team.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import QRTicket, User, Team, TeamMembership
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/{code}", response_class=HTMLResponse)
def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
"""
Display a page to let the user choose which team to apply points to.
If not logged in, prompt them.
"""
# In real app, you'd check user session or redirect to login
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket:
return "Invalid or already used code."
# Skeleton: you'd get the user's teams from session user
# For now, we mock a user ID = 1:
user = db.query(User).filter_by(id=1).first()
if not user:
return "User not found. Please log in."
# This is where you'd show the team selection or "create new team" UI
user_teams = [m.team for m in user.memberships]
return templates.TemplateResponse("redeem.html", {
"request": request,
"ticket": ticket,
"user_teams": user_teams
})
@router.post("/apply/{code}")
async def apply_code(
request: Request,
code: str,
db: Session = Depends(get_db)
):
"""
Apply the QR code to a selected team (if user is a member),
or set to pending if user isn't a member yet.
"""
# Get form data
form_data = await request.form()
team_id = int(form_data.get("team_id", 0))
if team_id <= 0:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Team Selection Required",
"error_message": "Please select a team to redeem this code."
}
)
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Invalid Code",
"error_message": "This code is invalid or has already been used."
}
)
# For skeleton, assume user = 1
user = db.query(User).filter_by(id=1).first()
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Team Not Found",
"error_message": "The selected team could not be found."
}
)
# Check membership
membership = db.query(TeamMembership).filter_by(user_id=user.id, team_id=team.id).first()
if membership:
# Redeem
ticket.redeemed_by = user.id
ticket.redeemed_at_team = team.id
ticket.used = True
# If we have redeemed_at column, update it
if hasattr(ticket, 'redeemed_at'):
from datetime import datetime
ticket.redeemed_at = datetime.now()
db.commit()
# Redirect to success page or dashboard
return templates.TemplateResponse(
"redeem_success.html",
{
"request": request,
"points": ticket.points,
"team": team
}
)
else:
# In real app, create a pending record or request flow
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Not a Team Member",
"error_message": "You are not a member of this team. Please join the team first or select another team."
}
)
@router.post("/manual")
async def manual_code_entry(
request: Request,
code: str = Form(...),
db: Session = Depends(get_db)
):
"""
Handle manual code entry from the form.
This redirects to the normal redeem flow after validating the code.
"""
# Check if the code exists
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket:
# In a real app, add a flash message or error handling
return templates.TemplateResponse(
"error.html",
{
"request": request,
"error_title": "Invalid Code",
"error_message": "The code you entered is invalid or has already been used."
}
)
# Redirect to the regular redeem flow
return RedirectResponse(f"/redeem/{code}", status_code=303)
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""
Create or join a team, manage membership.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, inspect
from datetime import datetime, timedelta
import random # For demo data
from ..db import SessionLocal
from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement
from ..schemas import TeamCreate
from ..templates_config import templates
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_or_create_default_user(db: Session):
"""Get user ID 1 or create it if it doesn't exist."""
user = db.query(User).filter_by(id=1).first()
if not user:
# Create a default user
user = User(
username="default_user",
email="default@example.com",
hashed_password="placeholder"
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/", response_class=HTMLResponse)
def list_teams(request: Request, db: Session = Depends(get_db)):
teams = db.query(Team).all()
# Get the user's teams to highlight teams they're already in
user = db.query(User).filter_by(id=1).first()
user_team_ids = []
if user:
memberships = db.query(TeamMembership).filter_by(user_id=user.id).all()
user_team_ids = [m.team_id for m in memberships]
return templates.TemplateResponse(
"teams.html",
{
"request": request,
"teams": teams,
"user_team_ids": user_team_ids,
"brand_colors": {
"irish_green": "#006837",
"golden_ale": "#FFB400",
"cream_white": "#F5F0E1",
"black_stout": "#1A1A1A",
"guinness_red": "#B22222"
}
}
)
@router.post("/create")
def create_team(name: str = Form(...), db: Session = Depends(get_db)):
user = get_or_create_default_user(db)
# Create team
new_team = Team(name=name)
db.add(new_team)
db.commit()
db.refresh(new_team)
# Make user admin of team
membership = TeamMembership(user_id=user.id, team_id=new_team.id, is_admin=True)
db.add(membership)
db.commit()
return RedirectResponse("/teams/", status_code=303)
@router.post("/join/{team_id}")
def join_team(team_id: int, db: Session = Depends(get_db)):
user = get_or_create_default_user(db)
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return RedirectResponse("/teams/", status_code=303)
# Check if membership exists
existing = db.query(TeamMembership).filter_by(user_id=user.id, team_id=team.id).first()
if existing:
return RedirectResponse("/teams/", status_code=303)
# Create membership
new_member = TeamMembership(user_id=user.id, team_id=team.id, is_admin=False)
db.add(new_member)
db.commit()
return RedirectResponse("/teams/", status_code=303)
@router.get("/{team_id}", response_class=HTMLResponse)
def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Show details for a specific team."""
# Get team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Get current user - using a default user for now
user = get_or_create_default_user(db)
# Get team members with admin status
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
is_user_admin = False
for membership in memberships:
member = db.query(User).filter_by(id=membership.user_id).first()
if member:
# Check if current user is admin
if membership.user_id == user.id and membership.is_admin:
is_user_admin = True
# Use joined_at if available, otherwise use placeholder
joined_date = getattr(membership, 'joined_at', None) or datetime.now() - timedelta(days=random.randint(30, 180))
if isinstance(joined_date, datetime):
month_name = joined_date.strftime("%b")
year = joined_date.strftime("%Y")
else:
month_name = "Apr"
year = "2023"
team_members.append({
"user": member,
"is_admin": membership.is_admin,
"joined": f"{month_name} {year}"
})
# Get total points
total_points = db.query(func.sum(QRTicket.points)).filter(
QRTicket.redeemed_at_team == team_id
).scalar() or 0
# Calculate rank based on points
higher_teams = db.query(func.count(Team.id)).join(
QRTicket,
QRTicket.redeemed_at_team == Team.id,
isouter=True
).group_by(Team.id).having(
func.sum(QRTicket.points) > total_points
).scalar() or 0
team_rank = higher_teams + 1
# Generate points data (with fallbacks for missing columns)
points_this_month = 65 # Default value
point_change = 15 # Default value
point_change_positive = True
# Check if redeemed_at column exists before using it
try:
now = datetime.now()
first_day_of_month = datetime(now.year, now.month, 1)
# Use raw SQL to check if column exists and get points
has_redeemed_at = False
inspector = inspect(db.bind)
if 'redeemed_at' in [col['name'] for col in inspector.get_columns('qr_tickets')]:
has_redeemed_at = True
if has_redeemed_at:
points_this_month = db.query(func.sum(QRTicket.points)).filter(
QRTicket.redeemed_at_team == team_id,
QRTicket.redeemed_at >= first_day_of_month
).scalar() or points_this_month
except Exception as e:
print(f"Error calculating monthly points: {e}")
# Activities - simple mock data for now
activities = [
{
"type": "points",
"points": 15,
"event": "Music Trivia Night",
"date": "September 12, 2023"
},
{
"type": "join",
"user": "Robert Brown",
"date": "July 28, 2023"
},
{
"type": "achievement",
"achievement": "1st place",
"event": "History Night",
"date": "July 15, 2023"
},
{
"type": "points",
"points": 20,
"event": "Movie Trivia Night",
"date": "July 1, 2023"
}
]
# Safely get team attributes
is_public = getattr(team, 'is_public', False)
created_at = getattr(team, 'created_at', None)
# Calculate days since team was founded
if created_at and isinstance(created_at, datetime):
days_ago = (datetime.now() - created_at).days
founded_date_str = created_at.strftime("%B %d, %Y")
else:
days_ago = 164 # Default fallback
founded_date_str = "March 22, 2023" # Default fallback
# Performance metrics
performance = {
"last_quiz": "25 points (2nd place)",
"average": "18.7 points",
"best_streak": "3 wins in a row"
}
return templates.TemplateResponse(
"team_detail.html",
{
"request": request,
"team": team,
"team_members": team_members,
"team_rank": team_rank,
"total_points": total_points,
"points_this_month": points_this_month,
"point_change": point_change,
"point_change_positive": point_change_positive,
"activities": activities,
"performance": performance,
"is_user_admin": is_user_admin,
"user": user,
"days_ago": days_ago,
"founded_date": founded_date_str
}
)
@router.post("/{team_id}/update")
def update_team(
team_id: int,
team_name: str = Form(...),
is_public: bool = Form(False),
db: Session = Depends(get_db)
):
"""Update team details."""
user = get_or_create_default_user(db)
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Check if user is admin
membership = db.query(TeamMembership).filter_by(
user_id=user.id,
team_id=team.id,
is_admin=True
).first()
if not membership:
raise HTTPException(status_code=403, detail="You don't have permission to update this team")
# Update team details
team.name = team_name
team.is_public = is_public
db.commit()
return RedirectResponse(f"/teams/{team_id}", status_code=303)