feat: Enhance admin dashboard with user, team, and event statistics, and system health monitoring
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
# LeagueLedger - TODO List
|
||||||
|
|
||||||
|
This document outlines upcoming tasks and improvements for the LeagueLedger application.
|
||||||
|
|
||||||
|
## Admin Dashboard Enhancements
|
||||||
|
|
||||||
|
- [ ] Add a settings section to manage configuration values
|
||||||
|
- [ ] Create a system to invite other admins
|
||||||
|
- [ ] Develop a more robust user management interface
|
||||||
|
- [ ] Add user roles and permissions beyond just admin/non-admin
|
||||||
|
- [x] Create a dashboard overview with system statistics
|
||||||
|
- [ ] Add bulk operations for users and teams
|
||||||
|
- [x] Basic CRUD operations for database models
|
||||||
|
- [x] Add dashboard with system activity metrics and stats
|
||||||
|
- [ ] Improve record filtering and searching capabilities
|
||||||
|
- [ ] Add relationship handling in edit forms
|
||||||
|
- [ ] Implement form validation with meaningful error messages
|
||||||
|
- [ ] Add user action audit logging
|
||||||
|
- [ ] Create specialized interfaces for common admin tasks
|
||||||
|
- [ ] Add admin reports generation functionality
|
||||||
|
- [ ] Implement system backup functionality from admin panel
|
||||||
|
|
||||||
|
## Admin Panel Specific Features
|
||||||
|
|
||||||
|
- [ ] **User Management**
|
||||||
|
- [ ] Add specialized user verification controls
|
||||||
|
- [ ] Implement password reset functionality
|
||||||
|
- [ ] Add user role assignment interface
|
||||||
|
- [ ] Create user activity logs viewer
|
||||||
|
|
||||||
|
- [ ] **Team Management**
|
||||||
|
- [ ] Implement team member management interface
|
||||||
|
- [ ] Add team ownership transfer functionality
|
||||||
|
- [ ] Create team join request approval workflow
|
||||||
|
- [ ] Add team archiving functionality
|
||||||
|
|
||||||
|
- [ ] **QR Code Management**
|
||||||
|
- [ ] Create QR code batch generation tool
|
||||||
|
- [ ] Implement QR code printing functionality
|
||||||
|
- [ ] Add QR code usage tracking dashboard
|
||||||
|
- [ ] Create QR code invalidation controls
|
||||||
|
|
||||||
|
- [ ] **Event Management**
|
||||||
|
- [ ] Add event creation wizard
|
||||||
|
- [ ] Implement event QR set assignment interface
|
||||||
|
- [ ] Create event attendance tracking
|
||||||
|
- [ ] Add event results display
|
||||||
|
|
||||||
|
- [ ] **System Configuration**
|
||||||
|
- [ ] Implement email settings management
|
||||||
|
- [ ] Add OAuth provider configuration interface
|
||||||
|
- [ ] Create appearance/branding settings
|
||||||
|
- [ ] Add general system settings controls
|
||||||
|
|
||||||
|
## User Management & Profile Features
|
||||||
|
|
||||||
|
- [ ] **Profile Management**
|
||||||
|
- [ ] Update profile picture functionality
|
||||||
|
- [ ] Change username capability
|
||||||
|
- [ ] Account deletion process
|
||||||
|
- [ ] Profile privacy settings
|
||||||
|
- [ ] Social media integration
|
||||||
|
|
||||||
|
## Environment Variables & Configuration
|
||||||
|
|
||||||
|
- [ ] Implement a database-backed settings storage system
|
||||||
|
- [ ] Create a UI for managing environment variables in the admin panel
|
||||||
|
- [ ] Add configuration for email templates
|
||||||
|
- [ ] Add configuration for OAuth providers
|
||||||
|
- [ ] Create backup/export functionality for configuration
|
||||||
|
- [ ] Add validation for configuration values
|
||||||
|
|
||||||
|
## Security Improvements
|
||||||
|
|
||||||
|
- [ ] Add logging for administrative actions
|
||||||
|
- [ ] Implement IP-based access restrictions for the setup page
|
||||||
|
- [ ] Add two-factor authentication for admin users
|
||||||
|
- [ ] Review password security policies
|
||||||
|
- [ ] Implement rate limiting for login attempts
|
||||||
|
- [ ] Set up regular security audits
|
||||||
|
- [ ] Improve CSRF protection
|
||||||
|
|
||||||
|
## User Experience Enhancements
|
||||||
|
|
||||||
|
- [ ] Create a guided tour for new administrators
|
||||||
|
- [ ] Add more visual feedback for administrative actions
|
||||||
|
- [ ] Improve mobile responsiveness of admin interfaces
|
||||||
|
- [ ] Implement notifications for important system events
|
||||||
|
- [ ] Add keyboard shortcuts for common actions
|
||||||
|
- [ ] Create a dark mode theme option
|
||||||
|
- [ ] Improve accessibility of the application
|
||||||
|
|
||||||
|
## System Health Monitoring
|
||||||
|
|
||||||
|
- [ ] Add a status dashboard for admins
|
||||||
|
- [ ] Implement database maintenance tools
|
||||||
|
- [ ] Create backup/restore functionality
|
||||||
|
- [ ] Set up regular health checks
|
||||||
|
- [ ] Add monitoring for application errors
|
||||||
|
- [ ] Create performance metrics tracking
|
||||||
|
- [ ] Set up automated alerts for system issues
|
||||||
|
|
||||||
|
## Team Management
|
||||||
|
|
||||||
|
- [ ] Improve team join request workflow
|
||||||
|
- [ ] Add team hierarchy options
|
||||||
|
- [ ] Create team member roles beyond admin/member
|
||||||
|
- [ ] Add team activity logs
|
||||||
|
- [ ] Implement team communication tools
|
||||||
|
- [ ] Add team profile customization options
|
||||||
|
- [ ] Create team achievement badges
|
||||||
|
|
||||||
|
## QR Code System
|
||||||
|
|
||||||
|
- [ ] Add support for dynamic QR codes
|
||||||
|
- [ ] Improve QR code generation options
|
||||||
|
- [ ] Add QR code statistics and usage tracking
|
||||||
|
- [ ] Create a QR code management dashboard
|
||||||
|
- [ ] Support for bulk QR code generation
|
||||||
|
- [ ] Add QR code categories and tagging
|
||||||
|
- [ ] Implement QR code expiration and scheduling
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [ ] Update API documentation
|
||||||
|
- [ ] Create user guides for different roles
|
||||||
|
- [ ] Document database schema and relationships
|
||||||
|
- [ ] Add developer onboarding documentation
|
||||||
|
- [ ] Create deployment guides for different environments
|
||||||
|
- [ ] Document system architecture and design decisions
|
||||||
|
- [ ] Add troubleshooting guides
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- [ ] Expand automated test coverage
|
||||||
|
- [ ] Create end-to-end testing workflows
|
||||||
|
- [ ] Add performance benchmarking tests
|
||||||
|
- [ ] Implement load testing for high-traffic scenarios
|
||||||
|
- [ ] Set up continuous integration testing
|
||||||
|
- [ ] Create testing documentation
|
||||||
|
- [ ] Add visual regression testing
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
- [ ] Complete translation of all UI elements
|
||||||
|
- [ ] Add support for RTL languages
|
||||||
|
- [ ] Implement locale-specific formatting
|
||||||
|
- [ ] Add language selection UI
|
||||||
|
- [ ] Create translation contribution guidelines
|
||||||
|
- [ ] Support for multiple time zones
|
||||||
|
- [ ] Add regional customization options
|
||||||
|
|
||||||
|
## Infrastructure & Deployment
|
||||||
|
|
||||||
|
- [ ] Optimize Docker configuration
|
||||||
|
- [ ] Set up automated deployments
|
||||||
|
- [ ] Implement proper staging environment
|
||||||
|
- [ ] Create database migration tools
|
||||||
|
- [ ] Add support for clustering/high availability
|
||||||
|
- [ ] Implement CDN for static assets
|
||||||
|
- [ ] Create backup and disaster recovery procedures
|
||||||
@@ -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 %}
|
{% block content %}
|
||||||
<div class="max-w-5xl mx-auto">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{% for model_key, display_name in models %}
|
{% for model_key, display_name in models %}
|
||||||
@@ -23,6 +28,9 @@
|
|||||||
<div class="mt-10 pt-6 border-t border-gray-200">
|
<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>
|
<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">
|
<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">
|
<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
|
<i class="fas fa-qrcode mr-2"></i> QR Code Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+190
-1
@@ -5,10 +5,15 @@ Admin interface for managing database records.
|
|||||||
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
|
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect, func, desc, text
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, List, Type, Optional
|
from typing import Dict, Any, List, Type, Optional
|
||||||
import inspect as py_inspect
|
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 ..db import SessionLocal, Base
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@@ -42,6 +47,168 @@ def get_db():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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]]:
|
def get_model_info(model_class: Type[Base]) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Get column information for a model."""
|
"""Get column information for a model."""
|
||||||
mapper = inspect(model_class)
|
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/")
|
@require_admin(redirect_url="/auth/login?next=/admin/")
|
||||||
async def admin_home(request: Request, db: Session = Depends(get_db)):
|
async def admin_home(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Admin dashboard home."""
|
"""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
|
# 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()]
|
model_list = [(key, name) for key, (_, name) in MODELS.items()]
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ python-dotenv>=1.0.0
|
|||||||
email-validator>=2.0.0
|
email-validator>=2.0.0
|
||||||
pydantic>=2.3.0
|
pydantic>=2.3.0
|
||||||
qrcode>=7.4.2
|
qrcode>=7.4.2
|
||||||
|
psutil>=5.9.0 # System monitoring and statistics
|
||||||
|
python-dateutil>=2.8.2 # Date manipulation utilities
|
||||||
|
|
||||||
# Email support
|
# Email support
|
||||||
fastapi-mail>=1.4.2
|
fastapi-mail>=1.4.2
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import pytest
|
||||||
|
from unittest import mock
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import User, Team, Event, QRCode
|
||||||
|
from app.views.admin import get_user_statistics, get_team_statistics
|
||||||
|
from app.views.admin import get_event_statistics, get_system_health
|
||||||
|
|
||||||
|
# Fixture for mocking the database session
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
"""Create a mock database session for testing."""
|
||||||
|
mock_session = mock.MagicMock(spec=Session)
|
||||||
|
return mock_session
|
||||||
|
|
||||||
|
# Test cases for user statistics
|
||||||
|
def test_get_user_statistics(mock_db):
|
||||||
|
"""Test getting user statistics."""
|
||||||
|
# Setup mock query results
|
||||||
|
mock_db.query().count.side_effect = [100, 80, 20]
|
||||||
|
mock_db.query().filter().count.return_value = 10
|
||||||
|
|
||||||
|
# Get last 30 days
|
||||||
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||||
|
mock_db.query().filter().filter().count.return_value = 15
|
||||||
|
|
||||||
|
# Get the statistics
|
||||||
|
stats = get_user_statistics(mock_db)
|
||||||
|
|
||||||
|
# Assert the expected results
|
||||||
|
assert stats["total_users"] == 100
|
||||||
|
assert stats["active_users"] == 80
|
||||||
|
assert stats["verified_users"] == 20
|
||||||
|
assert stats["admin_users"] == 10
|
||||||
|
assert stats["new_registrations_30d"] == 15
|
||||||
|
|
||||||
|
# Test cases for team statistics
|
||||||
|
def test_get_team_statistics(mock_db):
|
||||||
|
"""Test getting team statistics."""
|
||||||
|
# Setup mock query results
|
||||||
|
mock_db.query().count.side_effect = [50, 45]
|
||||||
|
mock_db.query().filter().count.return_value = 5
|
||||||
|
|
||||||
|
# Team distribution mock
|
||||||
|
mock_team_distribution = [
|
||||||
|
{"member_count": 0, "count": 5},
|
||||||
|
{"member_count": 1, "count": 10},
|
||||||
|
{"member_count": 2, "count": 15},
|
||||||
|
{"member_count": 3, "count": 10},
|
||||||
|
{"member_count": 4, "count": 7},
|
||||||
|
{"member_count": 5, "count": 3}
|
||||||
|
]
|
||||||
|
mock_db.query().group_by().all.return_value = mock_team_distribution
|
||||||
|
|
||||||
|
# Get the statistics
|
||||||
|
stats = get_team_statistics(mock_db)
|
||||||
|
|
||||||
|
# Assert the expected results
|
||||||
|
assert stats["total_teams"] == 50
|
||||||
|
assert stats["active_teams"] == 45
|
||||||
|
assert stats["public_teams"] == 5
|
||||||
|
assert stats["team_distribution"] == mock_team_distribution
|
||||||
|
|
||||||
|
# Test cases for event statistics
|
||||||
|
def test_get_event_statistics(mock_db):
|
||||||
|
"""Test getting event statistics."""
|
||||||
|
# Setup mock query results
|
||||||
|
mock_db.query().count.side_effect = [30, 25, 5]
|
||||||
|
|
||||||
|
# Setup mock for upcoming events
|
||||||
|
today = datetime.now().date()
|
||||||
|
upcoming_events = [
|
||||||
|
mock.MagicMock(name="Event 1", event_date=today + timedelta(days=1), location="Location 1"),
|
||||||
|
mock.MagicMock(name="Event 2", event_date=today + timedelta(days=3), location="Location 2"),
|
||||||
|
mock.MagicMock(name="Event 3", event_date=today + timedelta(days=7), location="Location 3")
|
||||||
|
]
|
||||||
|
mock_db.query().filter().order_by().limit().all.return_value = upcoming_events
|
||||||
|
|
||||||
|
# Setup mock for attendance rates
|
||||||
|
mock_attendance_rates = [
|
||||||
|
{"event_id": 1, "event_name": "Event A", "attendee_count": 25},
|
||||||
|
{"event_id": 2, "event_name": "Event B", "attendee_count": 18},
|
||||||
|
{"event_id": 3, "event_name": "Event C", "attendee_count": 30}
|
||||||
|
]
|
||||||
|
mock_db.query().join().group_by().order_by().limit().all.return_value = mock_attendance_rates
|
||||||
|
|
||||||
|
# Get the statistics
|
||||||
|
stats = get_event_statistics(mock_db)
|
||||||
|
|
||||||
|
# Assert the expected results
|
||||||
|
assert stats["total_events"] == 30
|
||||||
|
assert stats["past_events"] == 25
|
||||||
|
assert stats["upcoming_events_count"] == 5
|
||||||
|
assert len(stats["upcoming_events"]) == 3
|
||||||
|
assert stats["attendance_rates"] == mock_attendance_rates
|
||||||
|
|
||||||
|
# Test cases for system health
|
||||||
|
def test_get_system_health(mock_db):
|
||||||
|
"""Test getting system health information."""
|
||||||
|
# Mock database status
|
||||||
|
mock_db.execute().fetchall.return_value = [{"status": "online"}]
|
||||||
|
|
||||||
|
# Get the health information
|
||||||
|
health_info = get_system_health(mock_db)
|
||||||
|
|
||||||
|
# Assert expected results
|
||||||
|
assert health_info["database_status"] == "online"
|
||||||
|
assert "uptime" in health_info
|
||||||
|
assert "recent_errors" in health_info
|
||||||
|
|
||||||
|
# Integration test for admin dashboard endpoint
|
||||||
|
@mock.patch("app.views.admin.get_db")
|
||||||
|
def test_admin_dashboard_endpoint(mock_get_db, mock_db):
|
||||||
|
"""Test the admin dashboard endpoint."""
|
||||||
|
# Setup mock DB to be returned from get_db
|
||||||
|
mock_get_db.return_value = mock_db
|
||||||
|
|
||||||
|
# Mock user stats
|
||||||
|
mock_user_stats = {
|
||||||
|
"total_users": 100,
|
||||||
|
"active_users": 80,
|
||||||
|
"verified_users": 20,
|
||||||
|
"admin_users": 10,
|
||||||
|
"new_registrations_30d": 15
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock team stats
|
||||||
|
mock_team_stats = {
|
||||||
|
"total_teams": 50,
|
||||||
|
"active_teams": 45,
|
||||||
|
"public_teams": 5,
|
||||||
|
"team_distribution": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock event stats
|
||||||
|
mock_event_stats = {
|
||||||
|
"total_events": 30,
|
||||||
|
"past_events": 25,
|
||||||
|
"upcoming_events_count": 5,
|
||||||
|
"upcoming_events": [],
|
||||||
|
"attendance_rates": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock system health
|
||||||
|
mock_system_health = {
|
||||||
|
"database_status": "online",
|
||||||
|
"uptime": "3 days, 2 hours",
|
||||||
|
"recent_errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup mock return values for our statistics functions
|
||||||
|
with mock.patch("app.views.admin.get_user_statistics", return_value=mock_user_stats), \
|
||||||
|
mock.patch("app.views.admin.get_team_statistics", return_value=mock_team_stats), \
|
||||||
|
mock.patch("app.views.admin.get_event_statistics", return_value=mock_event_stats), \
|
||||||
|
mock.patch("app.views.admin.get_system_health", return_value=mock_system_health), \
|
||||||
|
mock.patch("app.views.admin.require_admin", return_value=lambda f: f):
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/admin/dashboard")
|
||||||
|
|
||||||
|
# Assert the response
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "user_stats" in response.context
|
||||||
|
assert "team_stats" in response.context
|
||||||
|
assert "event_stats" in response.context
|
||||||
|
assert "system_health" in response.context
|
||||||
Reference in New Issue
Block a user