Refactor authentication and session management

- Removed session-based user retrieval from dependencies.py and main.py.
- Eliminated SessionMiddleware and related session handling code.
- Updated dashboard and team views to directly query user data from the database.
- Simplified redeem and team management views to remove authentication checks.
- Adjusted templates to reflect changes in user context handling.
- Added camera selection feature in scan_qr.html for improved QR code scanning.
- Cleaned up Docker configuration and requirements for better deployment.
This commit is contained in:
Christian Krakau-Louis
2025-04-13 21:43:24 +02:00
parent 9a0479cb37
commit 9eef726d9b
13 changed files with 126 additions and 745 deletions
-374
View File
@@ -1,374 +0,0 @@
#!/usr/bin/env python3
"""
Authentication system using Authlib and session-based auth
"""
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 passlib.context import CryptContext
from .db import SessionLocal
from .templates_config import templates
from . import models
from .dependencies import get_user_from_session
# 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")
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
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, db: Session = Depends(get_db)):
"""Get current user from session"""
return get_user_from_session(request, db)
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:
db = next(get_db())
user = get_user_from_session(request, db)
if not user:
# Store the current URL for redirecting after login
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:
db = next(get_db())
user = get_user_from_session(request, db)
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.is_admin:
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, db: Session = Depends(get_db)):
"""Show login page with appropriate authentication options"""
# If already logged in, redirect to home
if get_user_from_session(request, db):
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
)
# 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
# Store user info in session - STANDARDIZED APPROACH
# Store both the user_id (for new code) and the full user dict (for backward compatibility)
request.session["user_id"] = user.id
# Also store the legacy format for backward compatibility
request.session["user"] = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": is_admin_value,
"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 ID in session (new standardized approach)
request.session["user_id"] = user.id
# Also store legacy user data format
user_data = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": getattr(user, "is_active", True),
"is_admin": is_admin_value,
"_permanent": True,
"created_at": str(getattr(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)
request.session.pop("user_id", 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, db: Session = Depends(get_db)):
"""Show user profile page"""
user = get_user_from_session(request, db)
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()
db.refresh(user)
# Log the user in immediately after registration
request.session["user_id"] = user.id
request.session["user"] = {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": False,
"picture": get_gravatar_url(user.email),
"_permanent": True,
"created_at": str(getattr(user, "created_at", ""))
}
# 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, db: Session = Depends(get_db)):
"""API endpoint to get current user information"""
user = get_user_from_session(request, db)
if not user:
return {"error": "Not authenticated"}
return {
"id": user.id,
"username": user.username,
"email": user.email,
"is_admin": user.is_admin if hasattr(user, "is_admin") else False
}
+6 -1
View File
@@ -6,10 +6,15 @@ import random
from datetime import datetime, timedelta
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from .models import User, Team, TeamMembership, QRTicket
from .db import SessionLocal
from .auth import get_password_hash
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def table_has_column(engine, table_name, column_name):
"""Check if a table has a specific column."""
-75
View File
@@ -1,75 +0,0 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from .db import get_db
from . import models
# OAuth2 configuration for API-based authentication
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
# Function to get user from session
def get_user_from_session(request: Request, db: Session = Depends(get_db)):
"""
Get the current user from session.
This is the SINGLE source of truth for session-based auth.
"""
try:
# First check if request has session attribute and it's a dictionary
if not hasattr(request, "session"):
print("No session object found in request")
return None
# Make sure session is a dictionary before trying to access it
if not isinstance(request.session, dict):
print(f"Session is not a dictionary: {type(request.session)}")
return None
# Standard method - get user_id from session
user_id = request.session.get("user_id")
if user_id:
return db.query(models.User).filter(models.User.id == user_id).first()
# Legacy method - get user from session.user
user_dict = request.session.get("user")
if user_dict and isinstance(user_dict, dict) and "id" in user_dict:
return db.query(models.User).filter(models.User.id == user_dict["id"]).first()
return None
except Exception as e:
print(f"Error getting user from session: {str(e)}")
return None
# Function to get current authenticated user
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Get current authenticated user from session"""
return get_user_from_session(request, db)
def get_current_active_user(request: Request, db: Session = Depends(get_db)):
"""Get current user and ensure they're authenticated"""
user = get_user_from_session(request, db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def get_current_admin_user(request: Request, db: Session = Depends(get_db)):
"""Get current user and ensure they're an admin"""
user = get_user_from_session(request, db)
if not user or not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return user
# Keep for backward compatibility
def get_session_db():
"""Legacy function to get DB session directly"""
return next(get_db())
def get_authenticated_user(request: Request, db: Session = Depends(get_db)):
"""Legacy function for getting the current user"""
return get_user_from_session(request, db)
+7 -81
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env python3
from fastapi import FastAPI, Request, status, Depends
from fastapi import FastAPI, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from datetime import datetime
import os
@@ -10,8 +9,6 @@ from . import models
from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard
from .db_init import seed_db
from .auth import router as auth_router
from .dependencies import get_user_from_session
# Create tables on startup
init_db()
@@ -20,94 +17,23 @@ init_db()
# In a production app, you would handle this differently
seed_db()
# Initialize FastAPI app
app = FastAPI()
# Configure session middleware settings
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")
# IMPORTANT: Add SessionMiddleware before anything else
# This must be the FIRST middleware in the stack
app.add_middleware(
SessionMiddleware,
secret_key=secret_key,
max_age=int(os.environ.get("SESSION_MAX_AGE", "86400")), # 24 hours by default
same_site="lax",
https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true",
session_cookie="league_ledger_session",
)
# Debug middleware - ADDED AFTER SessionMiddleware
# User context middleware to make template globals available
@app.middleware("http")
async def debug_session_middleware(request, call_next):
"""Debug middleware to track session state"""
async def add_template_globals(request: Request, call_next):
"""Add template globals"""
try:
session_cookie = request.cookies.get("league_ledger_session")
print(f"Request path: {request.url.path}")
# First check if the session attribute exists properly
has_session = hasattr(request, "session")
is_dict = has_session and isinstance(request.session, dict)
print(f"Has session attribute: {has_session}")
print(f"Session is dict: {is_dict}")
print(f"Has session cookie: {session_cookie is not None}")
# Safely check session data
if has_session and is_dict:
print(f"Session data before: {dict(request.session)}")
except Exception as e:
print(f"Error in debug middleware (pre): {str(e)}")
response = await call_next(request)
try:
if hasattr(request, "session"):
print(f"Session data after: {dict(request.session)}")
except Exception as e:
print(f"Error in debug middleware (post): {str(e)}")
return response
# User context middleware
@app.middleware("http")
async def add_user_to_request(request: Request, call_next):
"""Add user to request state and update template globals"""
try:
# Get database connection from context
from .db import SessionLocal
db = SessionLocal()
# Get user from session with safer approach
user = None
try:
user = get_user_from_session(request, db)
except Exception as e:
print(f"Error getting user for request context: {str(e)}")
# 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.username}, Admin: {getattr(user, 'is_admin', False)}")
templates.env.globals["current_user"] = None
except Exception as e:
print(f"Error setting user context: {str(e)}")
finally:
# Always close the DB connection
if 'db' in locals():
db.close()
print(f"Error setting template globals: {str(e)}")
# Process the request
response = await call_next(request)
return response
# Routers - include after middleware setup is complete
app.include_router(auth_router, prefix="/auth", tags=["Auth"])
# Routers - auth router removed
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"])
-41
View File
@@ -62,37 +62,15 @@
<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>
<a href="/contact" class="hover:text-golden-ale transition-colors duration-200">
Contact
</a>
<!-- User Authentication -->
{% if current_user %}
<div class="relative group">
<button class="flex items-center focus:outline-none">
<span class="mr-1">{{ current_user.username }}</span>
<i class="fas fa-chevron-down text-xs"></i>
</button>
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg overflow-hidden z-20 hidden group-hover:block">
<a href="/auth/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
<a href="/dashboard/" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<div class="border-t border-gray-100"></div>
<a href="/auth/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Logout</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="bg-white text-irish-green px-4 py-1 rounded-md hover:bg-cream-white transition-colors duration-200">
Log In
</a>
{% endif %}
</nav>
<!-- Mobile Menu Button -->
@@ -116,34 +94,15 @@
<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>
<a href="/contact" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
Contact
</a>
<!-- Mobile Auth Links -->
{% if current_user %}
<a href="/auth/profile" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
<i class="fas fa-user"></i> {{ current_user.username }}
</a>
<a href="/auth/logout" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
{% else %}
<a href="/auth/login" class="bg-white text-irish-green py-2 px-3 rounded-md">
Log In
</a>
<a href="/auth/register" class="border border-white text-white py-2 px-3 rounded-md">
Sign Up
</a>
{% endif %}
</nav>
</div>
</div>
+63 -40
View File
@@ -19,6 +19,14 @@
<div class="mt-4 text-sm text-gray-500">
Make sure the QR code is well-lit and clearly visible
</div>
<!-- Camera Selection Dropdown -->
<div class="mt-4">
<label for="camera-select" class="block text-sm font-medium text-gray-700">Select Camera:</label>
<select id="camera-select" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-irish-green focus:border-irish-green sm:text-sm rounded-md">
<option value="">Loading cameras...</option>
</select>
</div>
<!-- Manual Entry Fallback -->
<div class="mt-8 border-t pt-6">
@@ -78,50 +86,65 @@
<!-- HTML5 QR Code Scanner Library -->
<script src="https://unpkg.com/html5-qrcode"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
const cameraSelect = document.getElementById('camera-select');
const html5QrCode = new Html5Qrcode("qr-reader");
const qrOverlay = document.getElementById('scanner-overlay');
// Config
const config = { fps: 10, qrbox: 250 };
// Success function
function onScanSuccess(decodedText, decodedResult) {
// Stop scanning
html5QrCode.stop();
// Show loading message
qrOverlay.innerHTML = '<div class="text-white text-center"><i class="fas fa-circle-notch fa-spin text-3xl mb-3"></i><p>Code detected!</p><p>Redirecting...</p></div>';
qrOverlay.style.backgroundColor = 'rgba(0, 104, 55, 0.8)'; // Irish green with opacity
// Redirect to redeem page
window.location.href = '/redeem/' + decodedText;
// Fetch available cameras
try {
const devices = await Html5Qrcode.getCameras();
cameraSelect.innerHTML = devices.map(device => `<option value="${device.id}">${device.label || 'Camera'}</option>`).join('');
} catch (error) {
console.error("Error fetching cameras: ", error);
cameraSelect.innerHTML = '<option value="">No cameras found</option>';
}
// Start scanner
html5QrCode.start(
{ facingMode: "environment" },
config,
onScanSuccess,
(errorMessage) => {
// Handle error if needed
console.log(errorMessage);
})
.then(() => {
// Scanner started successfully
qrOverlay.style.display = 'none';
})
.catch((err) => {
// Update overlay with error message
qrOverlay.innerHTML = `
<div class="text-white text-center">
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
<p>Camera access denied or not available</p>
<p class="text-sm mt-2">Please use manual entry below</p>
</div>
`;
qrOverlay.style.backgroundColor = 'rgba(178, 34, 34, 0.8)'; // Guinness red with opacity
});
// Start scanner on camera selection
cameraSelect.addEventListener('change', async function() {
const selectedCameraId = cameraSelect.value;
if (selectedCameraId) {
try {
// Stop the scanner if it is running
if (html5QrCode && html5QrCode.isScanning) {
await html5QrCode.stop();
}
// Start the scanner with the newly selected camera
await html5QrCode.start(
{ deviceId: { exact: selectedCameraId } },
{ fps: 10, qrbox: { width: 250, height: 250 } },
onScanSuccess,
(errorMessage) => {
if (!errorMessage.includes("No MultiFormat Readers")) {
console.log("QR code parse error: ", errorMessage);
}
}
);
qrOverlay.style.display = 'none';
} catch (err) {
console.error("Error switching camera: ", err);
}
}
});
// Success function
function onScanSuccess(decodedText, decodedResult) {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.stop().then(() => {
console.log("Scanner stopped successfully.");
qrOverlay.innerHTML = '<div class="text-white text-center"><i class="fas fa-circle-notch fa-spin text-3xl mb-3"></i><p>Code detected!</p><p>Redirecting...</p></div>';
qrOverlay.style.backgroundColor = 'rgba(0, 104, 55, 0.8)';
qrOverlay.style.display = 'flex';
window.location.href = '/redeem/' + decodedText;
}).catch(err => {
console.error("Error stopping the scanner: ", err);
window.location.href = '/redeem/' + decodedText;
});
} else {
window.location.href = '/redeem/' + decodedText;
}
}
});
</script>
{% endblock %}
-8
View File
@@ -13,7 +13,6 @@ 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()
@@ -65,7 +64,6 @@ 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()]
@@ -75,7 +73,6 @@ async def admin_home(request: Request):
)
@router.get("/{model_name}", response_class=HTMLResponse)
@require_admin
async def list_records(
request: Request,
model_name: str,
@@ -127,7 +124,6 @@ async def list_records(
)
@router.get("/{model_name}/new", response_class=HTMLResponse)
@require_admin
async def create_record_form(
request: Request,
model_name: str,
@@ -168,7 +164,6 @@ async def create_record_form(
)
@router.post("/{model_name}/new")
@require_admin
async def create_record(
request: Request,
model_name: str,
@@ -217,7 +212,6 @@ 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,
@@ -269,7 +263,6 @@ async def edit_record_form(
)
@router.post("/{model_name}/{record_id}")
@require_admin
async def update_record(
request: Request,
model_name: str,
@@ -318,7 +311,6 @@ 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,
+20 -13
View File
@@ -6,59 +6,61 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from ..db import get_db
from ..templates_config import templates
from ..auth import require_login
from .. import models
router = APIRouter()
@router.get("/")
@require_login
def user_dashboard(request: Request, db: Session = Depends(get_db)):
"""User dashboard showing teams, events and stats"""
try:
user = request.session.get("user")
user_id = user.get("id")
# Fetch user data directly from the database
user = db.query(models.User).filter_by(email="admin@example.com").first() # Example user lookup
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_id = user.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)\
@@ -66,7 +68,7 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
.order_by(models.Event.event_date.desc())\
.limit(5)\
.all()
return templates.TemplateResponse(
"dashboard/index.html",
{
@@ -82,3 +84,8 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)):
except Exception as e:
print(f"Dashboard error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)}")
@router.get("/scan", response_class=HTMLResponse)
def scan_qr_page(request: Request):
"""Render the QR code scanning page."""
return templates.TemplateResponse("scan_qr.html", {"request": request})
+22 -62
View File
@@ -8,8 +8,6 @@ from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import QRTicket, User, Team, TeamMembership
from ..templates_config import templates
from ..dependencies import get_user_from_session
from ..auth import require_login
router = APIRouter()
@@ -24,7 +22,7 @@ def get_db():
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.
No login required.
"""
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
if not ticket:
@@ -37,42 +35,24 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
}
)
# Get the authenticated user
user = get_user_from_session(request, db)
if not user:
# Store the redeem URL for after login - with try/except
try:
if hasattr(request, "session") and isinstance(request.session, dict):
request.session["redirect_after_login"] = f"/redeem/{code}"
except Exception as e:
print(f"Error setting redirect URL in session: {str(e)}")
return RedirectResponse(url="/auth/login", status_code=302)
# Get user teams
user_teams = [m.team for m in user.memberships] if hasattr(user, 'memberships') else []
# Get all available teams
all_teams = db.query(Team).all()
return templates.TemplateResponse("redeem.html", {
"request": request,
"ticket": ticket,
"user_teams": user_teams
"user_teams": all_teams # Now showing all teams
})
@router.post("/apply/{code}")
@require_login
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.
Apply the QR code to a selected team (without authentication)
"""
# Get the authenticated user
user = get_user_from_session(request, db)
if not user:
return RedirectResponse(url=f"/auth/login?next=/redeem/{code}", status_code=302)
# Get form data
form_data = await request.form()
team_id = int(form_data.get("team_id", 0))
@@ -110,43 +90,28 @@ async def apply_code(
}
)
# 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
# Redeem without checking membership
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()
# If we have redeemed_at column, update it
if hasattr(ticket, 'redeemed_at'):
from datetime import datetime
ticket.redeemed_at = datetime.now()
db.commit()
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."
}
)
# Redirect to success page or dashboard
return templates.TemplateResponse(
"redeem_success.html",
{
"request": request,
"points": ticket.points,
"team": team
}
)
@router.post("/manual")
@require_login
async def manual_code_entry(
request: Request,
code: str = Form(...),
@@ -156,11 +121,6 @@ async def manual_code_entry(
Handle manual code entry from the form.
This redirects to the normal redeem flow after validating the code.
"""
# Make sure the user is authenticated
user = get_user_from_session(request, db)
if not user:
return RedirectResponse(url="/auth/login?next=/dashboard/", status_code=302)
# Check if the code exists
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
+2 -41
View File
@@ -13,8 +13,6 @@ from ..db import SessionLocal
from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement
from ..schemas import TeamCreate
from ..templates_config import templates
from ..dependencies import get_user_from_session
from ..auth import require_login
router = APIRouter()
@@ -30,13 +28,8 @@ 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 = get_user_from_session(request, db)
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",
{
@@ -54,43 +47,28 @@ def list_teams(request: Request, db: Session = Depends(get_db)):
)
@router.post("/create")
@require_login
def create_team(request: Request, name: str = Form(...), db: Session = Depends(get_db)):
user = get_user_from_session(request, db)
if not user:
return RedirectResponse("/auth/login?next=/teams/", status_code=302)
# 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}")
@require_login
def join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
user = get_user_from_session(request, db)
if not user:
return RedirectResponse("/auth/login?next=/teams/", status_code=302)
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()
existing = db.query(TeamMembership).filter_by(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)
new_member = TeamMembership(team_id=team.id, is_admin=False)
db.add(new_member)
db.commit()
@@ -104,12 +82,6 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Get current user from session
user = get_user_from_session(request, db)
if not user:
# If no authenticated user, redirect to login
return RedirectResponse("/auth/login?next=/teams/"+str(team_id), status_code=302)
# Get team members with admin status
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
@@ -118,10 +90,6 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
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):
@@ -237,14 +205,12 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"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")
@require_login
def update_team(
request: Request,
team_id: int,
@@ -253,10 +219,6 @@ def update_team(
db: Session = Depends(get_db)
):
"""Update team details."""
user = get_user_from_session(request, db)
if not user:
return RedirectResponse("/auth/login?next=/teams/"+str(team_id), status_code=302)
team = db.query(Team).filter_by(id=team_id).first()
if not team:
@@ -264,7 +226,6 @@ def update_team(
# Check if user is admin
membership = db.query(TeamMembership).filter_by(
user_id=user.id,
team_id=team.id,
is_admin=True
).first()
+2 -8
View File
@@ -33,16 +33,10 @@ services:
DB_NAME: "pubquiz_db"
DB_USER: "pubquiz_user"
DB_PASS: "pubquiz_pass"
# Session configuration
SECRET_KEY: "a-stronger-secret-key-for-sessions-32chars"
DEBUG: "True"
SESSION_MAX_AGE: "86400" # 24 hours
COOKIE_SECURE: "False" # Set to True in production with HTTPS
# Add better error logging
PYTHONUNBUFFERED: "1"
# Add dependency installation command
command: >
bash -c "pip install pymysql && uvicorn app.main:app --host 0.0.0.0 --reload"
# Just run uvicorn directly, no need for pymysql install since it's in requirements.txt
command: uvicorn app.main:app --host 0.0.0.0 --reload
ports:
- "8000:8000"
volumes:
+4 -1
View File
@@ -25,4 +25,7 @@ aiofiles>=23.2.1
python-dotenv>=1.0.0
email-validator>=2.0.0
pydantic>=2.3.0
qrcode>=7.4.2
qrcode>=7.4.2
# Image processing library for QR code generation
Pillow>=9.0.0