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
+333 -6
View File
@@ -1,14 +1,40 @@
#!/usr/bin/env python3
"""
Skeleton for authentication logic.
Placeholder for OAuth or password-based login.
Authentication system using Authlib and session-based auth
"""
from fastapi import APIRouter, Depends, HTTPException
import os
import inspect
import hashlib
from functools import wraps
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Request, status, Depends, HTTPException
from starlette.responses import RedirectResponse
from sqlalchemy.orm import Session
from .db import SessionLocal
from . import models
from passlib.context import CryptContext
from .db import SessionLocal
from .templates_config import templates
from . import models
# Initialize OAuth
oauth = OAuth()
# Check if OAuth is configured via environment variables
OAUTH_CONFIGURED = bool(os.environ.get("OAUTH_CLIENT_ID") and os.environ.get("OAUTH_CLIENT_SECRET"))
OAUTH_PROVIDER_NAME = os.environ.get("OAUTH_PROVIDER_NAME", "Single Sign-On")
# Configure OAuth provider if credentials are provided
if OAUTH_CONFIGURED:
oauth.register(
name="oauth_provider",
client_id=os.environ.get("OAUTH_CLIENT_ID"),
client_secret=os.environ.get("OAUTH_CLIENT_SECRET"),
server_metadata_url=os.environ.get("OAUTH_CONFIG_URL"),
client_kwargs={"scope": "openid profile email"},
)
# Create router and password context
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -19,4 +45,305 @@ def get_db():
finally:
db.close()
# Add routes for login, logout, register, etc.
def get_password_hash(password):
"""Generate password hash"""
return pwd_context.hash(password)
def verify_password(plain_password, hashed_password):
"""Verify password against hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_current_user(request: Request):
"""Get current user from session with improved error handling"""
try:
if "session" not in request.scope:
return None
return request.session.get("user")
except Exception as e:
print(f"Error getting user from session: {str(e)}")
return None
def get_gravatar_url(email):
"""Generate a Gravatar URL for the given email"""
email = email.lower().strip()
email_hash = hashlib.md5(email.encode('utf-8')).hexdigest()
return f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
def require_login(func):
"""Decorator to require login for routes"""
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
try:
if "session" not in request.scope or not request.session.get("user"):
# Store the current URL for redirecting after login
if "session" in request.scope:
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
# Check if the wrapped function is a coroutine function
if inspect.iscoroutinefunction(func):
return await func(request, *args, **kwargs)
else:
return func(request, *args, **kwargs)
except Exception as e:
print(f"Error in require_login decorator: {str(e)}")
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
return wrapper
def require_admin(func):
"""Decorator to require admin access for routes"""
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
try:
if "session" not in request.scope:
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
user = request.session.get("user")
if not user:
request.session["redirect_after_login"] = str(request.url)
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
if not user.get("is_admin", False):
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
# Check if the wrapped function is a coroutine function
if inspect.iscoroutinefunction(func):
return await func(request, *args, **kwargs)
else:
return func(request, *args, **kwargs)
except Exception as e:
print(f"Error in require_admin decorator: {str(e)}")
return RedirectResponse(url="/auth/login", status_code=status.HTTP_302_FOUND)
return wrapper
# Routes for authentication
@router.get("/login")
async def login_page(request: Request):
"""Show login page with appropriate authentication options"""
# If already logged in, redirect to home
if request.session.get("user"):
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"error": request.query_params.get("error"),
"message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME
}
)
@router.post("/login")
async def login(request: Request, db: Session = Depends(get_db)):
"""Handle username/password authentication"""
form_data = await request.form()
username = form_data.get("username")
password = form_data.get("password")
# Check if username exists
user = db.query(models.User).filter(models.User.username == username).first()
if not user or not verify_password(password, user.hashed_password):
return RedirectResponse(
url="/auth/login?error=Invalid+username+or+password",
status_code=status.HTTP_302_FOUND
)
# Create user session
# Make sure we use the actual is_admin field from the User model
is_admin_value = False
if hasattr(user, "is_admin") and user.is_admin is not None:
is_admin_value = user.is_admin
request.session["user"] = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": is_admin_value, # Use the actual is_admin value from the database
"picture": get_gravatar_url(user.email),
"_permanent": True,
"created_at": str(user.created_at)
}
# Log the successful authentication
print(f"User authenticated: {username}, is_admin: {is_admin_value}")
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/")
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/oauth-login")
async def oauth_login(request: Request):
"""Handle OAuth login flow"""
if not OAUTH_CONFIGURED:
return RedirectResponse(
url="/auth/login?error=OAuth+not+configured",
status_code=status.HTTP_302_FOUND
)
redirect_uri = request.url_for("oauth_callback")
return await oauth.oauth_provider.authorize_redirect(request, redirect_uri)
@router.get("/oauth-callback")
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
"""Handle OAuth callback from provider"""
try:
token = await oauth.oauth_provider.authorize_access_token(request)
userinfo = token.get("userinfo")
if not userinfo:
return RedirectResponse(
url="/auth/login?error=Failed+to+retrieve+user+information",
status_code=status.HTTP_302_FOUND
)
# Get or create user in database
email = userinfo.get("email")
if not email:
return RedirectResponse(
url="/auth/login?error=Email+not+provided+by+OAuth+provider",
status_code=status.HTTP_302_FOUND
)
# Find user by email or create a new one
user = db.query(models.User).filter(models.User.email == email).first()
if not user:
# Create new user with OAuth data
username = userinfo.get("preferred_username") or email.split("@")[0]
user = models.User(
username=username,
email=email,
hashed_password=get_password_hash(os.urandom(24).hex()), # Random password
is_active=True, # Set user as active
is_admin=False # Default to non-admin
)
db.add(user)
db.commit()
db.refresh(user)
# Determine if user is admin
is_admin_value = False
if hasattr(user, "is_admin") and user.is_admin is not None:
is_admin_value = user.is_admin
# Store user info in session
user_data = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
"is_admin": is_admin_value, # Set the admin status correctly
"_permanent": True,
"created_at": str(user.created_at),
}
# Add picture from OAuth or Gravatar
if userinfo.get("picture"):
user_data["picture"] = userinfo.get("picture")
elif email:
user_data["picture"] = get_gravatar_url(email)
request.session["user"] = user_data
# Log the successful authentication
print(f"User authenticated via OAuth: {email}, is_admin: {is_admin_value}")
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/")
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
except Exception as e:
print(f"OAuth authentication error: {str(e)}")
return RedirectResponse(
url=f"/auth/login?error=Authentication+failed:+{str(e)}",
status_code=status.HTTP_302_FOUND
)
@router.get("/logout")
async def logout(request: Request):
"""Handle user logout"""
request.session.pop("user", None)
return RedirectResponse(
url="/auth/login?message=You+have+been+logged+out+successfully",
status_code=status.HTTP_302_FOUND
)
@router.get("/profile")
@require_login
async def profile_page(request: Request):
"""Show user profile page"""
user = request.session.get("user")
return templates.TemplateResponse("auth/profile.html", {"request": request, "user": user})
@router.get("/register")
async def register_page(request: Request):
"""Show registration page"""
return templates.TemplateResponse("auth/register.html", {"request": request})
@router.post("/register")
async def register(request: Request, db: Session = Depends(get_db)):
"""Handle user registration"""
form_data = await request.form()
username = form_data.get("username")
email = form_data.get("email")
password = form_data.get("password")
confirm_password = form_data.get("confirm_password")
# Validate input
if not username or not email or not password:
return RedirectResponse(
url="/auth/register?error=All+fields+are+required",
status_code=status.HTTP_302_FOUND
)
if password != confirm_password:
return RedirectResponse(
url="/auth/register?error=Passwords+do+not+match",
status_code=status.HTTP_302_FOUND
)
# Check if username or email already exists
if db.query(models.User).filter(models.User.username == username).first():
return RedirectResponse(
url="/auth/register?error=Username+already+taken",
status_code=status.HTTP_302_FOUND
)
if db.query(models.User).filter(models.User.email == email).first():
return RedirectResponse(
url="/auth/register?error=Email+already+registered",
status_code=status.HTTP_302_FOUND
)
# Create new user with the appropriate fields
user = models.User(
username=username,
email=email,
hashed_password=get_password_hash(password),
is_active=True,
is_admin=False # Explicitly set is_admin to False for new registrations
)
db.add(user)
db.commit()
# Redirect to registration success page
return RedirectResponse(
url="/auth/registration-success",
status_code=status.HTTP_303_SEE_OTHER
)
@router.get("/registration-success")
async def registration_success(request: Request):
"""Show registration success page"""
return templates.TemplateResponse(
"auth/registration_success.html",
{"request": request}
)
@router.get("/api/whoami")
async def whoami(request: Request):
"""API endpoint to get current user information"""
user = request.session.get("user")
return user or {"error": "Not authenticated"}
+25 -8
View File
@@ -3,19 +3,27 @@ import os
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker, declarative_base
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME", "pubquiz_db")
DB_USER = os.getenv("DB_USER", "pubquiz_user")
DB_PASS = os.getenv("DB_PASS", "pubquiz_pass")
# Get database connection details from environment variables with fallbacks
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = os.environ.get("DB_PORT", "3306")
DB_NAME = os.environ.get("DB_NAME", "pubquiz_db")
DB_USER = os.environ.get("DB_USER", "pubquiz_user")
DB_PASS = os.environ.get("DB_PASS", "pubquiz_pass")
SQLALCHEMY_DATABASE_URL = (
f"mysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
# Create database URL
SQLALCHEMY_DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
# Create engine with appropriate parameters
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
)
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for models
Base = declarative_base()
def init_db():
@@ -146,3 +154,12 @@ def migrate_schema():
print(f"Error during schema migration: {e}")
finally:
connection.close()
# Add the missing get_db function
def get_db():
"""Database dependency for FastAPI endpoints"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+35 -8
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from .models import User, Team, TeamMembership, QRTicket
from .db import SessionLocal
from .auth import get_password_hash
def table_has_column(engine, table_name, column_name):
"""Check if a table has a specific column."""
@@ -28,13 +29,39 @@ def seed_db():
print("Database already has data. Skipping seeding.")
return
# Create users
# Create users with properly hashed passwords
users = [
User(username="john_quizmaster", email="john@example.com", hashed_password="password123"),
User(username="sarah_johnson", email="sarah@example.com", hashed_password="password123"),
User(username="mike_peters", email="mike@example.com", hashed_password="password123"),
User(username="emma_wilson", email="emma@example.com", hashed_password="password123"),
User(username="robert_brown", email="robert@example.com", hashed_password="password123"),
User(
username="admin",
email="admin@example.com",
hashed_password=get_password_hash("password"),
is_admin=True # Set admin privileges
),
User(
username="john_quizmaster",
email="john@example.com",
hashed_password=get_password_hash("password123")
),
User(
username="sarah_johnson",
email="sarah@example.com",
hashed_password=get_password_hash("password123")
),
User(
username="mike_peters",
email="mike@example.com",
hashed_password=get_password_hash("password123")
),
User(
username="emma_wilson",
email="emma@example.com",
hashed_password=get_password_hash("password123")
),
User(
username="robert_brown",
email="robert@example.com",
hashed_password=get_password_hash("password123")
),
]
db.add_all(users)
db.commit()
@@ -62,8 +89,8 @@ def seed_db():
memberships = []
membership_data = [
# Quiz Wizards
(1, 1, True, 160),
(2, 1, False, 155),
(1, 1, True, 160), # Admin user is team admin of Quiz Wizards
(2, 1, True, 155),
(3, 1, False, 130),
(4, 1, False, 90),
(5, 1, False, 45),
+29 -100
View File
@@ -1,110 +1,39 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from sqlalchemy import inspect
from typing import Optional
from datetime import datetime
from .db import get_db
from . import models
from .auth import get_current_user, require_login, require_admin
from .db import SessionLocal, engine
from .models import User
from .security import SECRET_KEY, ALGORITHM
from .templates_config import templates
# Reuse functions from auth.py
# This is just for backward compatibility with any code that imported these from dependencies
# OAuth2 scheme for token authentication
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
# Function to get a db session
def get_session_db():
return next(get_db())
def get_db():
"""Database dependency."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Function to get current authenticated user
def get_authenticated_user(request: Request):
return get_current_user(request)
# Check if all required user columns exist
def get_available_user_columns():
inspector = inspect(engine)
if 'users' in inspector.get_table_names():
return [col['name'] for col in inspector.get_columns('users')]
return []
# These are kept for API backward compatibility
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
"""Get the current authenticated user based on the access token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# If no token, return None (not authenticated)
if not token:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
# Update last login time if column exists
columns = get_available_user_columns()
if 'last_login' in columns and hasattr(user, 'last_login'):
user.last_login = datetime.utcnow()
db.commit()
def get_current_active_user(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
"""Check if the current user is active."""
if not current_user:
return None
columns = get_available_user_columns()
if 'is_active' in columns and hasattr(current_user, 'is_active') and not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
# Improved session-based user lookup with better error handling and logging
async def get_user_from_session(request: Request):
"""Get current user from session with improved error handling"""
try:
if not hasattr(request, "session"):
print("No session attribute in request")
return None
user_id = request.session.get("user_id")
if not user_id:
print("No user_id in session")
return None
print(f"Looking up user with ID: {user_id}")
# Manually get a database session from get_db
db = next(get_db())
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
print(f"User with ID {user_id} not found in database")
# Clear invalid session data
request.session.clear()
return None
return user
finally:
db.close()
except Exception as e:
print(f"Error getting user from session: {str(e)}")
return None
# Template context processor to add user to all templates
async def add_user_to_templates(request: Request):
"""Add current user to all template contexts."""
user = await get_user_from_session(request)
return {"current_user": user}
def get_current_admin_user(request: Request):
user = get_current_user(request)
if not user or not user.get("is_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return user
+61 -53
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
from fastapi import FastAPI, Request, status
from fastapi import FastAPI, Request, status, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from datetime import datetime
@@ -8,9 +8,9 @@ import os
from .db import init_db, engine
from . import models
from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard, auth
from .views import qr, redeem, teams, admin, leaderboard, dashboard
from .db_init import seed_db
from .dependencies import get_user_from_session
from .auth import router as auth_router, get_current_user
# Create tables on startup
init_db()
@@ -21,71 +21,84 @@ seed_db()
app = FastAPI()
# IMPORTANT: Add SessionMiddleware FIRST before any other middleware
# This ensures session data is available to all other middleware and route handlers
# Configure session middleware with environment variables or defaults
secret_key = os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions-please-change-this")
if len(secret_key) < 32:
print(f"WARNING: Secret key is too short ({len(secret_key)} chars). Recommended: 32+ chars")
# Apply SessionMiddleware FIRST - it must be the first middleware in the stack
app.add_middleware(
SessionMiddleware,
secret_key=os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions"),
max_age=int(os.environ.get("SESSION_MAX_AGE", 86400)), # 24 hours
secret_key=secret_key,
max_age=int(os.environ.get("SESSION_MAX_AGE", "86400")), # 24 hours by default
same_site="lax", # Important for security while allowing redirects
https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true",
session_cookie="league_ledger_session", # Custom cookie name for clarity
session_cookie="league_ledger_session", # Custom cookie name
)
# Add debugging middleware to help track sessions
# Debug middleware to track session state
@app.middleware("http")
async def debug_session_middleware(request, call_next):
"""Debug middleware to track session state"""
session_cookie = request.cookies.get("league_ledger_session")
print(f"Request path: {request.url.path}")
print(f"Has session attribute: {'session' in request.scope}")
print(f"Has session cookie: {session_cookie is not None}")
if "session" in request.scope:
print(f"Session data before: {dict(request.session)}")
try:
session_cookie = request.cookies.get("league_ledger_session")
print(f"Request path: {request.url.path}")
# Instead of checking request.scope, check if we can access the session dict
has_session = hasattr(request, "session") and isinstance(request.session, dict)
print(f"Has session attribute: {has_session}")
print(f"Has session cookie: {session_cookie is not None}")
# Check session data
if hasattr(request, "session"):
try:
print(f"Session data before: {dict(request.session)}")
except (TypeError, AttributeError):
# The session might not be dict-like
print(f"Session exists but isn't a dictionary")
except Exception as e:
print(f"Error in debug middleware (pre): {str(e)}")
response = await call_next(request)
if "session" in request.scope:
print(f"Session data after: {dict(request.session)}")
try:
if hasattr(request, "session"):
try:
print(f"Session data after: {dict(request.session)}")
except (TypeError, AttributeError):
print(f"Session exists but isn't a dictionary")
except Exception as e:
print(f"Error in debug middleware (post): {str(e)}")
return response
# Update the template globals at app startup to access the request
# User context middleware to make user available in templates
@app.middleware("http")
async def add_user_to_request(request: Request, call_next):
# Print debugging information
print(f"Processing request to: {request.url.path}")
# Add user to request state so templates can access it
"""Add user to request state and update template globals"""
try:
if "session" in request.scope:
print("Session found in request scope")
if "user_id" in request.session:
print(f"User ID in session: {request.session['user_id']}")
# Get user from session
user = await get_user_from_session(request)
request.state.user = user
else:
print("No user_id in session")
request.state.user = None
else:
print("No session in request scope")
request.state.user = None
# Get user from session if available
user = get_current_user(request)
# Store user in request.state for route handlers
request.state.user = user
# Update template globals for all templates
templates.env.globals["current_user"] = user
# Debug output to check user and admin status
if user:
print(f"User in context: {user.get('username')}, Admin: {user.get('is_admin', False)}")
except Exception as e:
print(f"Error in middleware: {e}")
request.state.user = None
# Update template context with current user before processing the request
templates.env.globals["current_user"] = request.state.user
print(f"Error setting user context: {str(e)}")
# Process the request
response = await call_next(request)
return response
# Routers
app.include_router(auth.router, prefix="/auth", tags=["Auth"]) # Auth router should be first
app.include_router(auth_router, prefix="/auth", tags=["Auth"]) # Auth router should be first
app.include_router(qr.router, prefix="/qr", tags=["QR"])
app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"])
app.include_router(teams.router, prefix="/teams", tags=["Teams"])
@@ -97,34 +110,29 @@ app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])
def index(request: Request):
return templates.TemplateResponse("index.html", {
"request": request,
"now": datetime.now,
"current_user": getattr(request.state, "user", None)
"now": datetime.now
})
@app.get("/about", response_class=HTMLResponse)
def about(request: Request):
return templates.TemplateResponse("about.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
"request": request
})
@app.get("/contact", response_class=HTMLResponse)
def contact(request: Request):
return templates.TemplateResponse("contact.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
"request": request
})
@app.get("/privacy", response_class=HTMLResponse)
def privacy(request: Request):
return templates.TemplateResponse("privacy.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
"request": request
})
@app.get("/terms", response_class=HTMLResponse)
def terms(request: Request):
return templates.TemplateResponse("terms.html", {
"request": request,
"current_user": getattr(request.state, "user", None)
"request": request
})
+91 -10
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Table
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime
from .db import Base
class User(Base):
@@ -11,15 +12,20 @@ class User(Base):
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True)
reset_token = Column(String(255), nullable=True)
reset_token_expires_at = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True)
# Relationship to teams
# Relationships
memberships = relationship("TeamMembership", back_populates="user")
teams = relationship("TeamMember", back_populates="user")
points = relationship("UserPoints", back_populates="user")
events_attended = relationship("EventAttendee", back_populates="user")
class OAuthAccount(Base):
@@ -40,14 +46,14 @@ class Team(Base):
__tablename__ = "teams"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
# Add fields for team detail view
description = Column(Text, nullable=True)
is_public = Column(Boolean, default=False) # For team privacy setting
created_at = Column(DateTime, server_default=func.now()) # For team founded date
description = Column(Text, nullable=True) # Optional team description
# Relationship to memberships
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
memberships = relationship("TeamMembership", back_populates="team")
members = relationship("TeamMember", back_populates="team")
class TeamMembership(Base):
@@ -56,14 +62,25 @@ class TeamMembership(Base):
user_id = Column(Integer, ForeignKey("users.id"))
team_id = Column(Integer, ForeignKey("teams.id"))
is_admin = Column(Boolean, default=False)
# Add joined_at to track when members joined
joined_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="memberships")
team = relationship("Team", back_populates="memberships")
class TeamMember(Base):
__tablename__ = "team_members"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
team_id = Column(Integer, ForeignKey("teams.id"), nullable=False)
is_captain = Column(Boolean, default=False)
joined_at = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User", back_populates="teams")
team = relationship("Team", back_populates="members")
class QRTicket(Base):
__tablename__ = "qr_tickets"
id = Column(Integer, primary_key=True, index=True)
@@ -81,7 +98,6 @@ class QRTicket(Base):
event_name = Column(String(255), nullable=True)
# New model for team achievements
class TeamAchievement(Base):
__tablename__ = "team_achievements"
id = Column(Integer, primary_key=True, index=True)
@@ -92,3 +108,68 @@ class TeamAchievement(Base):
achieved_at = Column(DateTime, server_default=func.now())
team = relationship("Team")
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
location = Column(String(200), nullable=True)
event_date = Column(DateTime, nullable=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
attendees = relationship("EventAttendee", back_populates="event")
class EventAttendee(Base):
__tablename__ = "event_attendees"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
check_in_time = Column(DateTime, server_default=func.now())
# Relationships
event = relationship("Event", back_populates="attendees")
user = relationship("User", back_populates="events_attended")
class UserPoints(Base):
__tablename__ = "user_points"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
points = Column(Float, nullable=False, default=0)
reason = Column(String(200), nullable=True)
awarded_at = Column(DateTime, server_default=func.now())
# Relationships
user = relationship("User", back_populates="points")
class QRCode(Base):
__tablename__ = "qr_codes"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(100), unique=True, index=True, nullable=False)
points = Column(Float, default=1.0, nullable=False)
description = Column(String(200), nullable=True)
is_active = Column(Boolean, default=True)
max_uses = Column(Integer, nullable=True) # null = unlimited
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime, nullable=True) # null = never expires
# Relationships
redemptions = relationship("QRCodeRedemption", back_populates="qr_code")
class QRCodeRedemption(Base):
__tablename__ = "qr_code_redemptions"
id = Column(Integer, primary_key=True, index=True)
qr_code_id = Column(Integer, ForeignKey("qr_codes.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
redeemed_at = Column(DateTime, server_default=func.now())
# Relationships
qr_code = relationship("QRCode", back_populates="redemptions")
user = relationship("User")
+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
# ...existing imports...
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from ..db import Base
class User(Base):
__tablename__ = "users"
# ...existing fields...
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True)
email = Column(String(100), unique=True, index=True)
password = Column(String(255))
# Add is_admin field if it doesn't exist
password_hash = Column(String(255)) # Renamed from password to password_hash for clarity
is_admin = Column(Boolean, default=False)
# ...existing methods...
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+25 -17
View File
@@ -5,24 +5,25 @@
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Log In</h1>
<!-- Messages/Alerts -->
{% if messages %}
{% 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>
{% endfor %}
{% if error %}
<div class="mb-4 p-3 rounded bg-red-100 text-red-700">
{{ error }}
</div>
{% endif %}
<form action="/auth/login" method="post" class="space-y-4">
<input type="hidden" name="next" value="{{ next }}">
{% if message %}
<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>
<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
type="text"
id="username"
name="username"
value="{{ username or '' }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
@@ -62,10 +63,6 @@
Log In
</button>
</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>
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
@@ -75,7 +72,17 @@
</a>
</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">
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<div class="flex justify-center space-x-4">
@@ -86,8 +93,9 @@
<i class="fab fa-github mr-2"></i> GitHub
</button>
</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>
{% endif %}
</div>
</div>
{% endblock %}
+40 -169
View File
@@ -1,184 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<!-- Profile Header -->
<div class="bg-irish-green text-white p-6">
<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">
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover">
</div>
<div class="text-center sm:text-left">
<h1 class="text-2xl font-bold">{{ user.username }}</h1>
<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 class="max-w-3xl mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<div class="flex flex-col md:flex-row items-center md:items-start md:space-x-8">
<!-- Profile Image -->
<div class="mb-6 md:mb-0">
{% if user.picture %}
<img src="{{ user.picture }}" alt="Profile Picture" class="w-32 h-32 rounded-full object-cover border-4 border-irish-green">
{% else %}
<div class="w-32 h-32 rounded-full bg-irish-green flex items-center justify-center text-white text-4xl">
{{ user.username[0]|upper }}
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
<!-- Profile Content -->
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Personal Info -->
<div class="md:col-span-1">
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2>
<!-- User Info -->
<div class="flex-grow">
<h1 class="text-2xl font-bold text-irish-green">{{ user.username }}</h1>
<p class="text-gray-600 mb-4">{{ user.email }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<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>
<label class="block text-sm font-medium text-gray-600">Display Name</label>
<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 class="bg-cream-white p-4 rounded-md">
<h3 class="font-semibold text-irish-green mb-1">Member Since</h3>
<p>{{ user.created_at.split(' ')[0] }}</p>
</div>
</div>
<!-- Right Column: Stats & Teams -->
<div class="md:col-span-2">
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Statistics</h2>
<div class="space-y-4">
<h2 class="text-xl font-semibold text-irish-green">Account Settings</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Total Points</p>
<p class="text-2xl font-bold text-irish-green">378</p>
</div>
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">QR Codes Redeemed</p>
<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 class="border-b border-gray-200 pb-4">
<h3 class="text-gray-700 font-medium mb-2">Change Password</h3>
<p class="text-sm text-gray-600 mb-3">Update your password to keep your account secure.</p>
<button class="bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition" disabled>
Change Password
<span class="text-xs">(Coming Soon)</span>
</button>
</div>
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Teams</h2>
<div class="space-y-4">
{% for team_info in user_teams %}
<div class="border rounded-lg overflow-hidden">
<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">
<div class="pt-4">
<h3 class="text-gray-700 font-medium mb-2">Danger Zone</h3>
<p class="text-sm text-gray-600 mb-3">Permanently delete your account and all of your data.</p>
<button class="border border-red-600 text-red-600 py-2 px-4 rounded-md hover:bg-red-600 hover:text-white transition" disabled>
Delete Account
</a>
<span class="text-xs">(Coming Soon)</span>
</button>
</div>
</div>
</div>
+12 -26
View File
@@ -2,15 +2,13 @@
{% block content %}
<div class="max-w-md mx-auto my-8">
<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 -->
{% if messages %}
{% 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>
{% endfor %}
{% if error %}
<div class="mb-4 p-3 rounded bg-red-100 text-red-700">
{{ error }}
</div>
{% endif %}
<form action="/auth/register" method="post" class="space-y-4">
@@ -20,20 +18,17 @@
type="text"
id="username"
name="username"
value="{{ username or '' }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
<p class="text-xs text-gray-500 mt-1">Choose a unique username (3-30 characters, letters, numbers and underscores only)</p>
</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
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
@@ -46,6 +41,7 @@
id="password"
name="password"
required
minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
<p class="text-xs text-gray-500 mt-1">At least 8 characters</p>
@@ -58,36 +54,26 @@
id="confirm_password"
name="confirm_password"
required
minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
>
</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>
<button
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"
>
Create Account
Register
</button>
</div>
</form>
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
<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>
+9 -27
View File
@@ -1,33 +1,15 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-12">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<div class="mb-6">
<div class="inline-block p-4 rounded-full bg-green-100">
<i class="fas fa-check-circle text-irish-green text-5xl"></i>
</div>
</div>
<h1 class="text-2xl font-bold text-irish-green mb-4">Registration Successful!</h1>
<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
<div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<div class="text-center">
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<h1 class="text-2xl font-bold text-irish-green mb-3">Registration Successful!</h1>
<p class="text-gray-600 mb-6">Your account has been created successfully.</p>
<a href="/auth/login" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Log In
</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>
+11 -1
View File
@@ -44,7 +44,7 @@
<!-- Desktop Navigation -->
<div class="flex justify-between items-center py-4">
<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>
</div>
@@ -62,6 +62,11 @@
<a href="/dashboard" class="hover:text-golden-ale transition-colors duration-200">
<i class="fas fa-tachometer-alt"></i> Dashboard
</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">
About
</a>
@@ -111,6 +116,11 @@
<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
</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">
About
</a>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="bg-irish-green text-white p-6">
<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">
<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 class="text-center sm:text-left">
<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">
<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>
<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
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 ..models import User, Team, TeamMembership, QRTicket
from ..templates_config import templates
from ..auth import require_admin
router = APIRouter()
@@ -64,6 +65,7 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
return relationships
@router.get("/", response_class=HTMLResponse)
@require_admin
async def admin_home(request: Request):
"""Admin dashboard home."""
model_list = [(key, name) for key, (_, name) in MODELS.items()]
@@ -73,6 +75,7 @@ async def admin_home(request: Request):
)
@router.get("/{model_name}", response_class=HTMLResponse)
@require_admin
async def list_records(
request: Request,
model_name: str,
@@ -124,6 +127,7 @@ async def list_records(
)
@router.get("/{model_name}/new", response_class=HTMLResponse)
@require_admin
async def create_record_form(
request: Request,
model_name: str,
@@ -164,6 +168,7 @@ async def create_record_form(
)
@router.post("/{model_name}/new")
@require_admin
async def create_record(
request: Request,
model_name: str,
@@ -212,6 +217,7 @@ async def create_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
@require_admin
async def edit_record_form(
request: Request,
model_name: str,
@@ -263,6 +269,7 @@ async def edit_record_form(
)
@router.post("/{model_name}/{record_id}")
@require_admin
async def update_record(
request: Request,
model_name: str,
@@ -311,7 +318,9 @@ async def update_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete")
@require_admin
async def delete_record(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
+75 -128
View File
@@ -1,137 +1,84 @@
#!/usr/bin/env python3
"""
Dashboard views for user-specific information.
"""
from fastapi import APIRouter, Depends, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Request, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from sqlalchemy.sql import func
from fastapi.responses import HTMLResponse, RedirectResponse
from ..db import SessionLocal
from ..models import User, Team, TeamMembership, QRTicket
from ..db import get_db
from ..templates_config import templates
from ..auth import require_login
from .. import models
router = APIRouter()
def get_db():
db = SessionLocal()
@router.get("/")
@require_login
def user_dashboard(request: Request, db: Session = Depends(get_db)):
"""User dashboard showing teams, events and stats"""
try:
yield db
finally:
db.close()
def get_or_create_default_user(db: Session):
"""Get user ID 1 or create it if it doesn't exist."""
user = db.query(User).filter_by(id=1).first()
if not user:
# Create a default user
user = User(
username="default_user",
email="default@example.com",
hashed_password="placeholder"
user = request.session.get("user")
user_id = user.get("id")
# Initialize default values in case of errors
team_count = 0
total_points = 0
event_count = 0
recent_events = []
user_teams = []
# Check if TeamMember model exists before querying
if hasattr(models, "TeamMember"):
# Get the team count for this user
team_count = db.query(func.count(models.TeamMember.team_id))\
.filter(models.TeamMember.user_id == user_id)\
.scalar() or 0
# Get user teams
user_teams = db.query(models.Team)\
.join(models.TeamMember)\
.filter(models.TeamMember.user_id == user_id)\
.all()
# Check if UserPoints model exists before querying
if hasattr(models, "UserPoints"):
# Get the total points safely
total_points_result = db.query(func.sum(models.UserPoints.points))\
.filter(models.UserPoints.user_id == user_id)\
.first()
if total_points_result and total_points_result[0]:
total_points = total_points_result[0]
# Check if EventAttendee model exists before querying
if hasattr(models, "EventAttendee") and hasattr(models, "Event"):
# Get event count safely
event_count_result = db.query(func.count(models.EventAttendee.event_id))\
.filter(models.EventAttendee.user_id == user_id)\
.first()
if event_count_result and event_count_result[0]:
event_count = event_count_result[0]
# Recent events - only if both models exist
recent_events = db.query(models.Event)\
.join(models.EventAttendee)\
.filter(models.EventAttendee.user_id == user_id)\
.order_by(models.Event.event_date.desc())\
.limit(5)\
.all()
return templates.TemplateResponse(
"dashboard/index.html",
{
"request": request,
"user": user,
"team_count": team_count,
"total_points": total_points,
"event_count": event_count,
"recent_events": recent_events,
"user_teams": user_teams
}
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/", response_class=HTMLResponse)
async def user_dashboard(
request: Request,
db: Session = Depends(get_db)
):
"""Show the user's dashboard with team info and recent activity."""
# Get current user - using a default user for now
# In a real app, this would come from auth system
user = get_or_create_default_user(db)
# Get user's teams
user_teams = db.query(Team).join(
TeamMembership,
TeamMembership.team_id == Team.id
).filter(
TeamMembership.user_id == user.id
).all()
# Get team memberships with admin status
team_memberships = db.query(
TeamMembership
).filter(
TeamMembership.user_id == user.id
).all()
admin_team_ids = [tm.team_id for tm in team_memberships if tm.is_admin]
# Get points per team
team_points = {}
for team in user_teams:
points = db.query(func.sum(QRTicket.points)).filter(
QRTicket.redeemed_at_team == team.id
).scalar() or 0
# Get team ranking - simplified approach
higher_teams = db.query(func.count(Team.id)).join(
QRTicket,
QRTicket.redeemed_at_team == Team.id
).group_by(
Team.id
).having(
func.sum(QRTicket.points) > points
).scalar() or 0
rank = higher_teams + 1
team_points[team.id] = {
'points': points,
'rank': rank
}
# Get recent activity
# For simplicity, we're just getting recent QR code redemptions
recent_activity = []
recent_tickets = db.query(QRTicket).filter(
QRTicket.redeemed_by == user.id
).order_by(
QRTicket.id.desc() # Assuming higher ID = newer
).limit(5).all()
for ticket in recent_tickets:
team = db.query(Team).filter(Team.id == ticket.redeemed_at_team).first()
activity = {
'type': 'qr_redeem',
'points': ticket.points,
'team_name': team.name if team else "Unknown team",
'date': "Recently" # Placeholder - would use ticket.created_at
}
recent_activity.append(activity)
# Get total points for user across all teams
total_points = sum(team_data['points'] for team_data in team_points.values())
# Get best ranking
best_rank = min(team_data['rank'] for team_data in team_points.values()) if team_points else None
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"user": user,
"teams": user_teams,
"team_points": team_points,
"admin_team_ids": admin_team_ids,
"recent_activity": recent_activity,
"total_points": total_points,
"best_rank": best_rank,
"team_count": len(user_teams)
}
)
@router.get("/scan", response_class=HTMLResponse)
async def scan_qr(request: Request):
"""Show QR scanning interface."""
return templates.TemplateResponse(
"scan_qr.html",
{"request": request}
)
except Exception as e:
print(f"Dashboard error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)}")