feat: Enhance admin dashboard with user, team, and event statistics, and system health monitoring
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - LeagueLedger{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Chart.js for statistics visualization -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4">
|
||||
<h1 class="text-3xl font-garamond text-irish-green font-bold mb-6">Admin Dashboard</h1>
|
||||
|
||||
<!-- Dashboard Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<!-- User Stats Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-irish-green">Users</h3>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">Total: {{ user_stats.total_users }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Active:</span>
|
||||
<span class="font-medium">{{ user_stats.active_users }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Verified:</span>
|
||||
<span class="font-medium">{{ user_stats.verified_users }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Admins:</span>
|
||||
<span class="font-medium">{{ user_stats.admin_users }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">New (30d):</span>
|
||||
<span class="font-medium">{{ user_stats.new_registrations_30d }}</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href="/admin/user" class="text-irish-green hover:underline text-sm flex items-center">
|
||||
<i class="fas fa-users mr-1"></i> Manage Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Stats Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-irish-green">Teams</h3>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">Total: {{ team_stats.total_teams }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Active:</span>
|
||||
<span class="font-medium">{{ team_stats.active_teams }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Public:</span>
|
||||
<span class="font-medium">{{ team_stats.public_teams }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Private:</span>
|
||||
<span class="font-medium">{{ team_stats.total_teams - team_stats.public_teams }}</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href="/admin/team" class="text-irish-green hover:underline text-sm flex items-center">
|
||||
<i class="fas fa-arrow-right mr-1"></i> Manage Teams
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Stats Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-irish-green">Events</h3>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded-full">Total: {{ event_stats.total_events }}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Past:</span>
|
||||
<span class="font-medium">{{ event_stats.past_events }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Upcoming:</span>
|
||||
<span class="font-medium">{{ event_stats.upcoming_events_count }}</span>
|
||||
</div>
|
||||
{% if event_stats.upcoming_events %}
|
||||
<div class="text-gray-500 text-sm mt-2">Next event:</div>
|
||||
<div class="text-sm font-medium">{{ event_stats.upcoming_events[0].name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ event_stats.upcoming_events[0].event_date.strftime('%Y-%m-%d') }}</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<a href="/admin/event" class="text-irish-green hover:underline text-sm flex items-center">
|
||||
<i class="fas fa-arrow-right mr-1"></i> Manage Events
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Card -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-irish-green">System</h3>
|
||||
<span class="bg-{{ 'green' if system_health.database_status == 'online' else 'red' }}-100 text-{{ 'green' if system_health.database_status == 'online' else 'red' }}-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
{{ system_health.database_status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Uptime:</span>
|
||||
<span class="font-medium text-sm">{{ system_health.uptime }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Errors:</span>
|
||||
<span class="font-medium">{{ system_health.recent_errors|length }}</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<a href="#" class="text-irish-green hover:underline text-sm flex items-center">
|
||||
<i class="fas fa-cog mr-1"></i> System Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- User Growth Chart -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-irish-green mb-4">User Growth</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="userGrowthChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Distribution Chart -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-irish-green mb-4">Team Sizes</h3>
|
||||
<div class="h-64">
|
||||
<canvas id="teamSizeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Upcoming Events Table -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-irish-green mb-4">Upcoming Events</h3>
|
||||
{% if event_stats.upcoming_events %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for event in event_stats.upcoming_events %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.event_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.location }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-gray-500">No upcoming events</div>
|
||||
{% endif %}
|
||||
<div class="mt-4 flex justify-end">
|
||||
<a href="/admin/event/new" class="bg-irish-green hover:bg-opacity-90 text-white px-4 py-2 rounded-md text-sm">
|
||||
<i class="fas fa-plus mr-2"></i>Add Event
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Events Attendance -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-irish-green mb-4">Top Events by Attendance</h3>
|
||||
{% if event_stats.attendance_rates %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Attendees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for event in event_stats.attendance_rates %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.event_name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ event.attendee_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-gray-500">No attendance data available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// User growth chart with real data from backend
|
||||
const userGrowthCtx = document.getElementById('userGrowthChart').getContext('2d');
|
||||
const userGrowthChart = new Chart(userGrowthCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ user_stats.month_labels | tojson }},
|
||||
datasets: [{
|
||||
label: 'New Users',
|
||||
data: {{ user_stats.monthly_registrations | tojson }},
|
||||
backgroundColor: 'rgba(0, 104, 55, 0.1)',
|
||||
borderColor: 'rgba(0, 104, 55, 1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Team size distribution chart
|
||||
const teamData = [0, 0, 0, 0, 0, 0, 0]; // 0 to 6+ members
|
||||
{% if team_stats.team_distribution %}
|
||||
{% for item in team_stats.team_distribution %}
|
||||
{% if item.member_count < 6 %}
|
||||
teamData[{{ item.member_count }}] = {{ item.count }};
|
||||
{% else %}
|
||||
teamData[6] += {{ item.count }};
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
const teamSizeCtx = document.getElementById('teamSizeChart').getContext('2d');
|
||||
const teamSizeChart = new Chart(teamSizeCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['0', '1', '2', '3', '4', '5', '6+'],
|
||||
datasets: [{
|
||||
label: 'Teams by Member Count',
|
||||
data: teamData,
|
||||
backgroundColor: 'rgba(255, 180, 0, 0.7)',
|
||||
borderColor: 'rgba(255, 180, 0, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Members'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,12 @@
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h1 class="text-2xl font-bold text-irish-green mb-6">Admin Dashboard</h1>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-irish-green">Admin Panel</h1>
|
||||
<a href="/admin/dashboard" class="bg-golden-ale text-black-stout px-4 py-2 rounded-md hover:bg-opacity-90 transition flex items-center">
|
||||
<i class="fas fa-chart-line mr-2"></i> Statistics Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for model_key, display_name in models %}
|
||||
@@ -23,6 +28,9 @@
|
||||
<div class="mt-10 pt-6 border-t border-gray-200">
|
||||
<h2 class="text-xl font-bold text-irish-green mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="/admin/dashboard" class="bg-golden-ale text-black-stout px-4 py-3 rounded-md hover:bg-opacity-90 transition flex items-center justify-center">
|
||||
<i class="fas fa-chart-line mr-2"></i> Statistics Dashboard
|
||||
</a>
|
||||
<a href="/qr" class="bg-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white px-4 py-3 rounded-md transition flex items-center">
|
||||
<i class="fas fa-qrcode mr-2"></i> QR Code Dashboard
|
||||
</a>
|
||||
|
||||
+190
-1
@@ -5,10 +5,15 @@ 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
|
||||
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 (
|
||||
@@ -42,6 +47,168 @@ def get_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)
|
||||
@@ -78,6 +245,28 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
|
||||
@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(
|
||||
|
||||
Reference in New Issue
Block a user