Merge pull request #6 from christianlouis/setup

Implement initial setup functionality with admin elevation
This commit is contained in:
Christian Krakau-Louis
2025-04-16 14:24:15 +02:00
committed by GitHub
11 changed files with 1209 additions and 4 deletions
+161
View File
@@ -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
+20 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from passlib.context import CryptContext from passlib.context import CryptContext
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings
from .db import SessionLocal, engine from .db import SessionLocal, engine
from .db_migrations import run_migrations from .db_migrations import run_migrations
@@ -35,6 +35,25 @@ def init_db():
run_migrations(engine) run_migrations(engine)
# Then proceed with seeding if needed # Then proceed with seeding if needed
seed_db() seed_db()
# Initialize system settings if needed
init_system_settings()
def init_system_settings():
"""Initialize the system settings table if it doesn't exist."""
db = SessionLocal()
try:
# Check if there's already a system settings record
settings = db.query(SystemSettings).first()
if not settings:
# Create initial system settings with setup_completed = False
settings = SystemSettings(setup_completed=False)
db.add(settings)
db.commit()
print("System settings initialized.")
except Exception as e:
print(f"Error initializing system settings: {e}")
finally:
db.close()
def seed_db(): def seed_db():
"""Seed the database with test data.""" """Seed the database with test data."""
+2 -1
View File
@@ -15,7 +15,7 @@ import contextlib
from .db import engine, get_db from .db import engine, get_db
from . import models from . import models
from .templates_config import templates from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience, setup
from .db_init import init_db from .db_init import init_db
from .auth.middleware import SessionAuthBackend, on_auth_error from .auth.middleware import SessionAuthBackend, on_auth_error
@@ -125,6 +125,7 @@ async def not_found_exception_handler(request: Request, exc):
) )
# Routers # Routers
app.include_router(setup.router, tags=["Setup"]) # Setup router for initial admin setup
app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages
app.include_router(auth.router, prefix="/auth", tags=["auth"]) # Include the auth router app.include_router(auth.router, prefix="/auth", tags=["auth"]) # Include the auth router
app.include_router(qr.router, prefix="/qr", tags=["QR"]) app.include_router(qr.router, prefix="/qr", tags=["QR"])
+12
View File
@@ -66,6 +66,18 @@ class User(Base, BaseUser):
return f"<User {self.username}>" return f"<User {self.username}>"
# Add SystemSettings model for storing setup configuration
class SystemSettings(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
setup_completed = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f"<SystemSettings setup_completed={self.setup_completed}>"
class OAuthAccount(Base): class OAuthAccount(Base):
__tablename__ = "oauth_accounts" __tablename__ = "oauth_accounts"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
+289
View File
@@ -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 %}
+9 -1
View File
@@ -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>
+226
View File
@@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block title %}LeagueLedger Setup{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-center">
<div class="w-full max-w-3xl">
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
<!-- Header -->
<div class="bg-irish-green text-white p-4">
<h3 class="text-xl font-bold flex items-center">
<i class="fas fa-tools mr-2"></i>LeagueLedger Setup
</h3>
</div>
<!-- Body -->
<div class="p-6">
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
<p>Welcome to the LeagueLedger setup page. This page is only available once during initial setup.</p>
</div>
</div>
{% if setup_completed %}
<div class="bg-green-50 border-l-4 border-green-400 p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<p><strong>Setup has been completed.</strong> The system has been configured with an administrator.</p>
</div>
</div>
{% if is_admin %}
<p class="mb-4">You already have administrator privileges. You can:</p>
<div class="flex justify-center">
<a href="/admin" class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded flex items-center">
<i class="fas fa-cogs mr-2"></i>Go to Admin Dashboard
</a>
</div>
{% else %}
<p class="mb-4">This setup has already been completed by another user. Contact an administrator if you need admin access.</p>
{% endif %}
{% else %}
<p class="mb-4">Welcome, <strong>{{ user.username }}</strong>!</p>
<div class="my-6">
<h4 class="text-lg font-bold text-irish-green mb-2">System Setup</h4>
<p class="mb-2">
You're about to be promoted to administrator. As an administrator, you will be able to:
</p>
<ul class="list-disc pl-6 mb-4 space-y-1">
<li>Access all administrative functions</li>
<li>Manage users and teams</li>
<li>Generate and manage QR codes</li>
<li>Configure system settings</li>
</ul>
</div>
<div class="flex justify-center">
<button id="elevateBtn" class="bg-golden-ale hover:bg-opacity-90 text-black-stout font-bold py-3 px-6 rounded flex items-center">
<i class="fas fa-user-shield mr-2"></i>Make Me Administrator
</button>
</div>
{% endif %}
</div>
<!-- Footer -->
<div class="bg-gray-50 px-6 py-4 border-t">
<div class="flex justify-between items-center">
<a href="/" class="border border-gray-300 text-gray-600 hover:bg-gray-100 font-semibold py-2 px-4 rounded flex items-center">
<i class="fas fa-home mr-2"></i>Return to Home
</a>
{% if not setup_completed %}
<span class="text-gray-500 text-sm flex items-center">
<i class="fas fa-info-circle mr-1"></i>
This page will be disabled after setup
</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Success Modal -->
<div id="successModal" class="fixed z-10 inset-0 overflow-y-auto hidden" aria-labelledby="successModalLabel" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-green-500 text-white px-4 py-3 sm:px-6">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-white" id="successModalLabel">
<i class="fas fa-check-circle mr-2"></i>Setup Complete
</h3>
<button type="button" class="modalClose text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="text-center mb-4">
<i class="fas fa-user-shield text-green-500 text-5xl"></i>
</div>
<p class="mb-2">Congratulations! You have been successfully promoted to administrator.</p>
<p>You can now access all administrative functions of LeagueLedger.</p>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<a href="/admin" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-irish-green text-base font-medium text-white hover:bg-opacity-90 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-cogs mr-2"></i>Go to Admin Dashboard
</a>
<button type="button" class="modalClose mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
<!-- Error Modal -->
<div id="errorModal" class="fixed z-10 inset-0 overflow-y-auto hidden" aria-labelledby="errorModalLabel" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-red-500 text-white px-4 py-3 sm:px-6">
<div class="flex items-center justify-between">
<h3 class="text-lg leading-6 font-medium text-white" id="errorModalLabel">
<i class="fas fa-exclamation-circle mr-2"></i>Error
</h3>
<button type="button" class="modalClose text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<p id="errorMessage" class="text-red-600">An error occurred while processing your request.</p>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="modalClose w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const elevateBtn = document.getElementById('elevateBtn');
const successModal = document.getElementById('successModal');
const errorModal = document.getElementById('errorModal');
const errorMessage = document.getElementById('errorMessage');
const closeButtons = document.querySelectorAll('.modalClose');
// Close modal function
function closeModal(modal) {
modal.classList.add('hidden');
}
// Show modal function
function showModal(modal) {
modal.classList.remove('hidden');
}
// Add event listeners to close buttons
closeButtons.forEach(button => {
button.addEventListener('click', function() {
closeModal(this.closest('.fixed'));
});
});
// Process admin elevation
if (elevateBtn) {
elevateBtn.addEventListener('click', function() {
// Disable button to prevent multiple submissions
elevateBtn.disabled = true;
elevateBtn.innerHTML = '<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Processing...';
// Send AJAX request
fetch('/setup/elevate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success modal
showModal(successModal);
} else {
// Show error modal
errorMessage.textContent = data.message || 'An error occurred.';
showModal(errorModal);
// Re-enable button
elevateBtn.disabled = false;
elevateBtn.innerHTML = '<i class="fas fa-user-shield mr-2"></i>Make Me Administrator';
}
})
.catch(error => {
console.error('Error:', error);
// Show error modal
errorMessage.textContent = 'Network error. Please try again.';
showModal(errorModal);
// Re-enable button
elevateBtn.disabled = false;
elevateBtn.innerHTML = '<i class="fas fa-user-shield mr-2"></i>Make Me Administrator';
});
});
}
});
</script>
{% endblock %}
+190 -1
View File
@@ -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(
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
from sqlalchemy.orm import Session
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from datetime import datetime
import logging
# Set up logging
logger = logging.getLogger(__name__)
from ..db import get_db
from ..models import User, SystemSettings
from ..templates_config import templates
from ..security import verify_password
from ..auth.oauth import oauth_manager
# Create router
router = APIRouter(tags=["Setup"])
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(request: Request, db: Session = Depends(get_db)):
"""
Setup page that allows a logged-in user to elevate themselves to admin.
Only available once before being deactivated.
"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
logger.warning("Unauthorized access attempt to setup page")
# Add a query parameter for redirect back to setup after login
return RedirectResponse("/auth/login?next=/setup", status_code=HTTP_303_SEE_OTHER)
# Get the logged-in user
user = db.query(User).filter(User.id == user_id).first()
if not user:
logger.warning(f"User ID {user_id} found in session but not in database")
request.session.clear()
return RedirectResponse("/auth/login?next=/setup", status_code=HTTP_303_SEE_OTHER)
# Check if setup has already been completed
settings = db.query(SystemSettings).first()
setup_completed = settings and settings.setup_completed
# Check if the current user is already admin
is_admin = user.is_admin
return templates.TemplateResponse(
"admin/setup.html",
{
"request": request,
"user": user,
"setup_completed": setup_completed,
"is_admin": is_admin
}
)
@router.post("/setup/elevate", response_class=JSONResponse)
async def elevate_to_admin(request: Request, db: Session = Depends(get_db)):
"""
API endpoint to elevate current user to admin and mark setup as completed.
Returns JSON response for AJAX handling.
"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
logger.warning("Unauthorized API call to elevate_to_admin")
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content={"success": False, "message": "Authentication required"}
)
# Check if setup has already been completed
settings = db.query(SystemSettings).first()
if settings and settings.setup_completed:
logger.warning("Setup already completed, but elevate_to_admin was called")
return JSONResponse(
content={
"success": False,
"message": "Setup has already been completed"
}
)
try:
# Get the logged-in user
user = db.query(User).filter(User.id == user_id).first()
if not user:
logger.warning(f"User ID {user_id} not found in database during elevate_to_admin")
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content={"success": False, "message": "User not found"}
)
# Elevate user to admin
user.is_admin = True
user.last_login = datetime.utcnow()
# Mark setup as completed in system settings
if not settings:
settings = SystemSettings(setup_completed=True)
db.add(settings)
else:
settings.setup_completed = True
# Save the changes with explicit commit
db.commit()
logger.info(f"User {user.username} (ID: {user.id}) elevated to admin in setup")
# Update the session to reflect admin status
request.session["is_admin"] = True
return JSONResponse(
content={
"success": True,
"message": "You have been successfully promoted to administrator"
}
)
except Exception as e:
db.rollback()
logger.error(f"Error during admin elevation: {str(e)}")
return JSONResponse(
content={
"success": False,
"message": f"An error occurred: {str(e)}"
}
)
+2
View File
@@ -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
+170
View File
@@ -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