Initial commit: DigiServer v2 with Blueprint Architecture

Features implemented:
- Application factory pattern with environment-based config
- 7 modular blueprints (main, auth, admin, players, groups, content, api)
- Flask-Caching with Redis support for production
- Flask-Login authentication with bcrypt password hashing
- API endpoints with rate limiting and Bearer token auth
- Comprehensive error handling and logging
- CLI commands (init-db, create-admin, seed-db)

Blueprint Structure:
- main: Dashboard with caching, health check endpoint
- auth: Login, register, logout, password change
- admin: User management, system settings, theme, logo upload
- players: Full CRUD, fullscreen view, bulk operations, playlist management
- groups: Group management, player assignments, content management
- content: Upload with progress tracking, file management, preview/download
- api: RESTful endpoints with authentication, rate limiting, player feedback

Performance Optimizations:
- Dashboard caching (60s timeout)
- Playlist caching (5min timeout)
- Redis caching for production
- Memoized functions for expensive operations
- Cache clearing on data changes

Security Features:
- Bcrypt password hashing
- Flask-Login session management
- admin_required decorator for authorization
- Player authentication via auth codes
- API Bearer token authentication
- Rate limiting on API endpoints (60 req/min default)
- Input validation and sanitization

Documentation:
- README.md: Full project documentation with quick start
- PROGRESS.md: Detailed progress tracking and roadmap
- BLUEPRINT_GUIDE.md: Quick reference for blueprint architecture

Pending work:
- Models migration from v1 with database indexes
- Utils migration from v1 with type hints
- Templates migration with updated route references
- Docker multi-stage build configuration
- Unit tests for all blueprints

Ready for models and utils migration from digiserver v1
This commit is contained in:
ske087
2025-11-12 10:00:30 +02:00
commit 244b44f5e0
17 changed files with 3420 additions and 0 deletions

300
app/blueprints/admin.py Normal file
View File

@@ -0,0 +1,300 @@
"""Admin blueprint for user management and system settings."""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from functools import wraps
import os
from datetime import datetime
from typing import Optional
from app.extensions import db, bcrypt
from app.models import User, Player, Group, Content, ServerLog
from app.utils.logger import log_action
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin role for route access."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash('Please login to access this page.', 'warning')
return redirect(url_for('auth.login'))
if current_user.role != 'admin':
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
flash('You do not have permission to access this page.', 'danger')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/')
@login_required
@admin_required
def admin_panel():
"""Display admin panel with system overview."""
try:
# Get statistics
total_users = User.query.count()
total_players = Player.query.count()
total_groups = Group.query.count()
total_content = Content.query.count()
# Get recent logs
recent_logs = ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(10).all()
# Get all users
users = User.query.all()
# Calculate storage usage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_size = 0
if os.path.exists(upload_folder):
for dirpath, dirnames, filenames in os.walk(upload_folder):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
storage_mb = round(total_size / (1024 * 1024), 2)
return render_template('admin.html',
total_users=total_users,
total_players=total_players,
total_groups=total_groups,
total_content=total_content,
storage_mb=storage_mb,
users=users,
recent_logs=recent_logs)
except Exception as e:
log_action('error', f'Error loading admin panel: {str(e)}')
flash('Error loading admin panel.', 'danger')
return redirect(url_for('main.dashboard'))
@admin_bp.route('/user/create', methods=['POST'])
@login_required
@admin_required
def create_user():
"""Create a new user account."""
try:
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
role = request.form.get('role', 'user').strip()
# Validation
if not username or len(username) < 3:
flash('Username must be at least 3 characters long.', 'warning')
return redirect(url_for('admin.admin_panel'))
if not password or len(password) < 6:
flash('Password must be at least 6 characters long.', 'warning')
return redirect(url_for('admin.admin_panel'))
if role not in ['user', 'admin']:
flash('Invalid role specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Check if username exists
existing_user = User.query.filter_by(username=username).first()
if existing_user:
flash(f'Username "{username}" already exists.', 'warning')
return redirect(url_for('admin.admin_panel'))
# Create user
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
new_user = User(username=username, password=hashed_password, role=role)
db.session.add(new_user)
db.session.commit()
log_action('info', f'User {username} created by admin {current_user.username}')
flash(f'User "{username}" created successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error creating user: {str(e)}')
flash('Error creating user. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/role', methods=['POST'])
@login_required
@admin_required
def change_user_role(user_id: int):
"""Change user role between user and admin."""
try:
user = User.query.get_or_404(user_id)
new_role = request.form.get('role', '').strip()
# Validation
if new_role not in ['user', 'admin']:
flash('Invalid role specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Prevent changing own role
if user.id == current_user.id:
flash('You cannot change your own role.', 'warning')
return redirect(url_for('admin.admin_panel'))
old_role = user.role
user.role = new_role
db.session.commit()
log_action('info', f'User {user.username} role changed from {old_role} to {new_role} by {current_user.username}')
flash(f'User "{user.username}" role changed to {new_role}.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error changing user role: {str(e)}')
flash('Error changing user role. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id: int):
"""Delete a user account."""
try:
user = User.query.get_or_404(user_id)
# Prevent deleting own account
if user.id == current_user.id:
flash('You cannot delete your own account.', 'warning')
return redirect(url_for('admin.admin_panel'))
username = user.username
db.session.delete(user)
db.session.commit()
log_action('info', f'User {username} deleted by admin {current_user.username}')
flash(f'User "{username}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting user: {str(e)}')
flash('Error deleting user. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/theme', methods=['POST'])
@login_required
@admin_required
def change_theme():
"""Change application theme."""
try:
theme = request.form.get('theme', 'light').strip()
if theme not in ['light', 'dark']:
flash('Invalid theme specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Store theme preference (you can extend this to save to database)
# For now, just log the action
log_action('info', f'Theme changed to {theme} by {current_user.username}')
flash(f'Theme changed to {theme} mode.', 'success')
except Exception as e:
log_action('error', f'Error changing theme: {str(e)}')
flash('Error changing theme. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/logo/upload', methods=['POST'])
@login_required
@admin_required
def upload_logo():
"""Upload custom logo for application."""
try:
if 'logo' not in request.files:
flash('No logo file provided.', 'warning')
return redirect(url_for('admin.admin_panel'))
file = request.files['logo']
if file.filename == '':
flash('No file selected.', 'warning')
return redirect(url_for('admin.admin_panel'))
# Validate file type
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg'}
filename = secure_filename(file.filename)
if not ('.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions):
flash('Invalid file type. Please upload an image file (PNG, JPG, GIF, SVG).', 'danger')
return redirect(url_for('admin.admin_panel'))
# Save logo
static_folder = current_app.config.get('STATIC_FOLDER', 'app/static')
logo_path = os.path.join(static_folder, 'logo.png')
# Create static folder if it doesn't exist
os.makedirs(static_folder, exist_ok=True)
file.save(logo_path)
log_action('info', f'Logo uploaded by admin {current_user.username}')
flash('Logo uploaded successfully.', 'success')
except Exception as e:
log_action('error', f'Error uploading logo: {str(e)}')
flash('Error uploading logo. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/logs/clear', methods=['POST'])
@login_required
@admin_required
def clear_logs():
"""Clear all server logs."""
try:
ServerLog.query.delete()
db.session.commit()
log_action('info', f'All logs cleared by admin {current_user.username}')
flash('All logs cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing logs: {str(e)}')
flash('Error clearing logs. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/system/info')
@login_required
@admin_required
def system_info():
"""Get system information as JSON."""
try:
import platform
import psutil
# Get system info
info = {
'system': platform.system(),
'release': platform.release(),
'version': platform.version(),
'machine': platform.machine(),
'processor': platform.processor(),
'cpu_count': psutil.cpu_count(),
'cpu_percent': psutil.cpu_percent(interval=1),
'memory_total': round(psutil.virtual_memory().total / (1024**3), 2), # GB
'memory_used': round(psutil.virtual_memory().used / (1024**3), 2), # GB
'memory_percent': psutil.virtual_memory().percent,
'disk_total': round(psutil.disk_usage('/').total / (1024**3), 2), # GB
'disk_used': round(psutil.disk_usage('/').used / (1024**3), 2), # GB
'disk_percent': psutil.disk_usage('/').percent
}
return jsonify(info)
except Exception as e:
log_action('error', f'Error getting system info: {str(e)}')
return jsonify({'error': str(e)}), 500