Files
gh-christianlouis-leagueledger/app/views/admin.py
T
Christian Krakau-Louis 15e1dc227b Add multi-league foundation
2026-05-22 20:47:28 +02:00

627 lines
22 KiB
Python

#!/usr/bin/env python3
"""
Admin interface for managing database records.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect, func, desc, text
import json
from typing import Dict, Any, List, Type, Optional
import inspect as py_inspect
from datetime import datetime, timedelta
import time
import os
import psutil
from dateutil.relativedelta import relativedelta
from ..db import SessionLocal, Base
from ..models import (
User, League, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event,
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
)
from ..templates_config import templates
from ..auth.permissions import require_admin
router = APIRouter()
# Dictionary of model classes with their display names
MODELS = {
'user': (User, "Users"),
'league': (League, "Leagues"),
'team': (Team, "Teams"),
'team_membership': (TeamMembership, "Team Memberships"),
'qr_code': (QRCode, "QR Codes"),
'qr_set': (QRSet, "QR Sets"),
'team_achievement': (TeamAchievement, "Team Achievements"),
'event': (Event, "Events"),
'oauth_account': (OAuthAccount, "OAuth Accounts"),
'team_join_request': (TeamJoinRequest, "Team Join Requests"),
'event_attendee': (EventAttendee, "Event Attendees"),
'user_points': (UserPoints, "User Points"),
}
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Get user statistics for the dashboard
def get_user_statistics(db: Session) -> Dict[str, Any]:
"""Get user statistics for the admin dashboard."""
stats = {}
# Total users
stats["total_users"] = db.query(User).count()
# Active users (not disabled)
stats["active_users"] = db.query(User).filter(User.is_active == True).count()
# Verified users
stats["verified_users"] = db.query(User).filter(User.is_verified == True).count()
# Admin users
stats["admin_users"] = db.query(User).filter(User.is_admin == True).count()
# New registrations in the last 30 days
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
stats["new_registrations_30d"] = db.query(User).filter(
User.created_at >= thirty_days_ago
).count()
# Monthly user registration data for the chart (last 6 months)
monthly_data = []
month_labels = []
# Get the current month and year
current_date = datetime.now()
# Loop through the last 6 months
for i in range(5, -1, -1):
# Calculate month and year for this data point
month_date = current_date - relativedelta(months=i)
start_of_month = datetime(month_date.year, month_date.month, 1)
# For the current month, only count until today
if i == 0:
end_of_month = current_date
else:
# Calculate the end of the month
if month_date.month == 12:
end_of_month = datetime(month_date.year + 1, 1, 1) - timedelta(days=1)
else:
end_of_month = datetime(month_date.year, month_date.month + 1, 1) - timedelta(days=1)
# Format month as abbreviated month name
month_name = month_date.strftime('%b')
month_labels.append(month_name)
# Count users registered in this month
monthly_count = db.query(User).filter(
User.created_at >= start_of_month,
User.created_at <= end_of_month
).count()
monthly_data.append(monthly_count)
# Add the data to the stats
stats["monthly_registrations"] = monthly_data
stats["month_labels"] = month_labels
return stats
# Get team statistics for the dashboard
def get_team_statistics(db: Session) -> Dict[str, Any]:
"""Get team statistics for the admin dashboard."""
stats = {}
# Total teams
stats["total_teams"] = db.query(Team).count()
# Active teams
stats["active_teams"] = db.query(Team).filter(Team.is_active == True).count()
# Public teams
stats["public_teams"] = db.query(Team).filter(Team.is_public == True).count()
# Team size distribution - teams grouped by member count
# Modified query to correctly count team members and group by team
team_sizes = db.query(
TeamMembership.team_id,
func.count(TeamMembership.user_id).label('member_count')
).group_by(TeamMembership.team_id).subquery()
# Now we can query the distribution from the subquery
team_distribution = db.query(
team_sizes.c.member_count,
func.count().label('count')
).group_by(team_sizes.c.member_count).all()
# Convert to a list of dictionaries for easier handling in the template
team_dist_list = [{"member_count": size[0], "count": size[1]} for size in team_distribution]
stats["team_distribution"] = team_dist_list
return stats
# Get event statistics for the dashboard
def get_event_statistics(db: Session) -> Dict[str, Any]:
"""Get event statistics for the admin dashboard."""
stats = {}
# Total events
stats["total_events"] = db.query(Event).count()
# Past events
today = datetime.now().date()
stats["past_events"] = db.query(Event).filter(Event.event_date < today).count()
# Upcoming events
stats["upcoming_events_count"] = db.query(Event).filter(Event.event_date >= today).count()
# List of upcoming events
upcoming_events = db.query(Event).filter(
Event.event_date >= today
).order_by(Event.event_date).limit(5).all()
stats["upcoming_events"] = upcoming_events
# Events with highest attendance
attendance_rates = db.query(
Event.id.label('event_id'),
Event.name.label('event_name'),
func.count(EventAttendee.id).label('attendee_count')
).join(EventAttendee).group_by(Event.id, Event.name).order_by(
desc('attendee_count')
).limit(5).all()
stats["attendance_rates"] = attendance_rates
return stats
# Get system health information
def get_system_health(db: Session) -> Dict[str, Any]:
"""Get system health information for the admin dashboard."""
health_info = {}
# Database status
try:
result = db.execute(text("SELECT 'online' as status")).fetchall()
health_info["database_status"] = "online" if result else "offline"
except Exception as e:
health_info["database_status"] = "error"
health_info["database_error"] = str(e)
# System uptime
try:
uptime_seconds = time.time() - psutil.boot_time()
days, remainder = divmod(uptime_seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
health_info["uptime"] = f"{int(days)} days, {int(hours)} hours, {int(minutes)} minutes"
except Exception:
health_info["uptime"] = "Unknown"
# Recent errors (would be fetched from a logging system in production)
# For this example, we'll return a placeholder
health_info["recent_errors"] = []
return health_info
def get_model_info(model_class: Type[Base]) -> Dict[str, Dict[str, Any]]:
"""Get column information for a model."""
mapper = inspect(model_class)
columns = {}
for column in mapper.columns:
is_primary = column.primary_key
is_foreign_key = bool(column.foreign_keys)
foreign_key_target = None
if is_foreign_key:
for fk in column.foreign_keys:
foreign_key_target = fk.target_fullname
columns[column.name] = {
'type': str(column.type),
'nullable': column.nullable,
'primary_key': is_primary,
'foreign_key': is_foreign_key,
'foreign_key_target': foreign_key_target,
}
return columns
def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
"""Get relationship information for a model."""
relationships = {}
for name, rel in py_inspect.getmembers(model_class, lambda o: hasattr(o, 'prop')):
if hasattr(rel.prop, 'target'):
relationships[name] = rel.prop.target.name
return relationships
@router.get("/", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login?next=/admin/")
async def admin_home(request: Request, db: Session = Depends(get_db)):
"""Admin dashboard home."""
# Get statistics
user_stats = get_user_statistics(db)
team_stats = get_team_statistics(db)
event_stats = get_event_statistics(db)
system_health = get_system_health(db)
return templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"user": request.user,
"user_stats": user_stats,
"team_stats": team_stats,
"event_stats": event_stats,
"system_health": system_health
}
)
@router.get("/models", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login?next=/admin/models")
async def admin_models(request: Request, db: Session = Depends(get_db)):
"""Admin models overview."""
# Since we're using Starlette's authentication, the user is now available in request.user
model_list = [(key, name) for key, (_, name) in MODELS.items()]
return templates.TemplateResponse(
"admin/index.html",
{"request": request, "models": model_list, "user": request.user}
)
@router.get("/{model_name}", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def list_records(
request: Request,
model_name: str,
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=5, le=100),
db: Session = Depends(get_db)
):
"""List records for a model with pagination."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get total count for pagination
total_records = db.query(model_class).count()
total_pages = (total_records + per_page - 1) // per_page
# Get records with pagination
records = db.query(model_class).offset((page - 1) * per_page).limit(per_page).all()
# Get column information
columns_info = get_model_info(model_class)
# Prepare column names for display
column_names = list(columns_info.keys())
# Extract values for each record
records_data = []
for record in records:
record_data = {}
for col in column_names:
record_data[col] = getattr(record, col)
records_data.append(record_data)
return templates.TemplateResponse(
"admin/list.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"records": records_data,
"columns": column_names,
"columns_info": columns_info,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"total_records": total_records,
"user": request.user # Add user to the context
}
)
@router.get("/{model_name}/new", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def create_record_form(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Show form for creating a new record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get column information
columns_info = get_model_info(model_class)
# For foreign keys, fetch possible values
foreign_key_options = {}
for col_name, info in columns_info.items():
if info['foreign_key'] and info['foreign_key_target']:
target_table, target_col = info['foreign_key_target'].split('.')
# Try to find the corresponding model class
for model_key, (model_cls, _) in MODELS.items():
if model_cls.__tablename__ == target_table:
# Fetch options for this foreign key
options = db.query(model_cls).all()
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
return templates.TemplateResponse(
"admin/edit.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"columns_info": columns_info,
"record": None, # No record for new form
"foreign_key_options": foreign_key_options,
"is_new": True,
"user": request.user # Use request.user from Starlette authentication
}
)
@router.post("/{model_name}/new")
@require_admin(redirect_url="/auth/login")
async def create_record(
request: Request,
model_name: str,
db: Session = Depends(get_db)
):
"""Create a new record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get form data from request
form_data = await request.form()
# Convert form data to appropriate types
columns_info = get_model_info(model_class)
record_data = {}
for field_name, value in form_data.items():
if field_name in columns_info:
col_type = columns_info[field_name]['type'].lower()
# Skip empty values for nullable fields
if value == '' and columns_info[field_name]['nullable']:
continue
# Convert values based on column type
if 'int' in col_type:
if value:
record_data[field_name] = int(value)
elif 'bool' in col_type or 'boolean' in col_type:
record_data[field_name] = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
else:
record_data[field_name] = value
# Skip primary key for new records if it's auto-increment
for col_name, info in columns_info.items():
if info['primary_key'] and col_name not in record_data:
pass # Skip primary key
# Create record
new_record = model_class(**record_data)
db.add(new_record)
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def edit_record_form(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Show form for editing an existing record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, display_name = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Get column information
columns_info = get_model_info(model_class)
# For foreign keys, fetch possible values
foreign_key_options = {}
for col_name, info in columns_info.items():
if info['foreign_key'] and info['foreign_key_target']:
target_table, target_col = info['foreign_key_target'].split('.')
# Try to find the corresponding model class
for model_key, (model_cls, _) in MODELS.items():
if model_cls.__tablename__ == target_table:
# Fetch options for this foreign key
options = db.query(model_cls).all()
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
# Prepare record data
record_data = {}
for col_name in columns_info:
record_data[col_name] = getattr(record, col_name)
return templates.TemplateResponse(
"admin/edit.html",
{
"request": request,
"model_name": model_name,
"display_name": display_name,
"columns_info": columns_info,
"record": record_data,
"foreign_key_options": foreign_key_options,
"is_new": False,
"user": request.user # Use request.user from Starlette authentication
}
)
@router.post("/{model_name}/{record_id}")
@require_admin(redirect_url="/auth/login")
async def update_record(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Update an existing record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Get form data from request
form_data = await request.form()
# Convert form data to appropriate types and update record
columns_info = get_model_info(model_class)
for field_name, value in form_data.items():
if field_name in columns_info and not columns_info[field_name]['primary_key']:
col_type = columns_info[field_name]['type'].lower()
# Handle nullable fields
if value == '' and columns_info[field_name]['nullable']:
setattr(record, field_name, None)
continue
# Convert values based on column type
if 'int' in col_type:
if value:
setattr(record, field_name, int(value))
elif 'bool' in col_type or 'boolean' in col_type:
bool_value = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
setattr(record, field_name, bool_value)
else:
setattr(record, field_name, value)
# Save changes
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete")
@require_admin(redirect_url="/auth/login")
async def delete_record(
request: Request,
model_name: str,
record_id: int,
db: Session = Depends(get_db)
):
"""Delete a record."""
if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
model_class, _ = MODELS[model_name]
# Get the record
record = db.query(model_class).filter_by(id=record_id).first()
if not record:
raise HTTPException(status_code=404, detail=f"Record not found")
# Special handling for different models due to foreign key constraints
if model_name == 'team':
# First clear any QR code references to this team
db.query(QRCode).filter(QRCode.redeemed_at_team == record_id).update(
{"redeemed_at_team": None}, synchronize_session=False
)
# Clear any team achievements
db.query(TeamAchievement).filter(TeamAchievement.team_id == record_id).delete(
synchronize_session=False
)
# Handle team memberships (should be auto-deleted via cascade, but just to be safe)
db.query(TeamMembership).filter(TeamMembership.team_id == record_id).delete(
synchronize_session=False
)
# Handle pending join requests
db.query(TeamJoinRequest).filter(TeamJoinRequest.team_id == record_id).delete(
synchronize_session=False
)
elif model_name == 'user':
# Clear QR code references to this user
db.query(QRCode).filter(QRCode.redeemed_by == record_id).update(
{"redeemed_by": None}, synchronize_session=False
)
# Handle QR sets created by this user - set created_by to NULL
db.query(QRSet).filter(QRSet.created_by == record_id).update(
{"created_by": None}, synchronize_session=False
)
# Handle team ownerships - could set to None or reassign
db.query(Team).filter(Team.owner_id == record_id).update(
{"owner_id": None}, synchronize_session=False
)
# Remove team memberships
db.query(TeamMembership).filter(TeamMembership.user_id == record_id).delete(
synchronize_session=False
)
# Remove team join requests
db.query(TeamJoinRequest).filter(TeamJoinRequest.user_id == record_id).delete(
synchronize_session=False
)
# Remove OAuth accounts
db.query(OAuthAccount).filter(OAuthAccount.user_id == record_id).delete(
synchronize_session=False
)
# Remove event attendance records
db.query(EventAttendee).filter(EventAttendee.user_id == record_id).delete(
synchronize_session=False
)
# Remove user points
db.query(UserPoints).filter(UserPoints.user_id == record_id).delete(
synchronize_session=False
)
elif model_name == 'event':
# Clear QR code references to this event
db.query(QRCode).filter(QRCode.event_id == record_id).update(
{"event_id": None}, synchronize_session=False
)
# Clear team achievements associated with this event
db.query(TeamAchievement).filter(TeamAchievement.event_id == record_id).update(
{"event_id": None}, synchronize_session=False
)
# Remove event attendees
db.query(EventAttendee).filter(EventAttendee.event_id == record_id).delete(
synchronize_session=False
)
# Delete record
db.delete(record)
db.commit()
return RedirectResponse(f"/admin/{model_name}", status_code=303)