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

368
app/blueprints/players.py Normal file
View File

@@ -0,0 +1,368 @@
"""Players blueprint for player management and display."""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required
from werkzeug.security import generate_password_hash
import secrets
from typing import Optional, List
from app.extensions import db, cache
from app.models import Player, Group, Content, PlayerFeedback
from app.utils.logger import log_action
from app.utils.group_player_management import get_player_status_info
players_bp = Blueprint('players', __name__, url_prefix='/players')
@players_bp.route('/')
@login_required
def players_list():
"""Display list of all players."""
try:
players = Player.query.order_by(Player.name).all()
groups = Group.query.all()
# Get player status for each player
player_statuses = {}
for player in players:
status_info = get_player_status_info(player.id)
player_statuses[player.id] = status_info
return render_template('players_list.html',
players=players,
groups=groups,
player_statuses=player_statuses)
except Exception as e:
log_action('error', f'Error loading players list: {str(e)}')
flash('Error loading players list.', 'danger')
return redirect(url_for('main.dashboard'))
@players_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_player():
"""Add a new player."""
if request.method == 'GET':
groups = Group.query.order_by(Group.name).all()
return render_template('add_player.html', groups=groups)
try:
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
group_id = request.form.get('group_id')
# Validation
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.add_player'))
# Generate unique auth code
auth_code = secrets.token_urlsafe(16)
# Create player
new_player = Player(
name=name,
location=location or None,
auth_code=auth_code,
group_id=int(group_id) if group_id else None
)
db.session.add(new_player)
db.session.commit()
log_action('info', f'Player "{name}" created with auth code {auth_code}')
flash(f'Player "{name}" created successfully. Auth code: {auth_code}', 'success')
return redirect(url_for('players.players_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error creating player: {str(e)}')
flash('Error creating player. Please try again.', 'danger')
return redirect(url_for('players.add_player'))
@players_bp.route('/<int:player_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_player(player_id: int):
"""Edit player details."""
player = Player.query.get_or_404(player_id)
if request.method == 'GET':
groups = Group.query.order_by(Group.name).all()
return render_template('edit_player.html', player=player, groups=groups)
try:
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
group_id = request.form.get('group_id')
# Validation
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.edit_player', player_id=player_id))
# Update player
player.name = name
player.location = location or None
player.group_id = int(group_id) if group_id else None
db.session.commit()
# Clear cache for this player
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{name}" (ID: {player_id}) updated')
flash(f'Player "{name}" updated successfully.', 'success')
return redirect(url_for('players.players_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating player: {str(e)}')
flash('Error updating player. Please try again.', 'danger')
return redirect(url_for('players.edit_player', player_id=player_id))
@players_bp.route('/<int:player_id>/delete', methods=['POST'])
@login_required
def delete_player(player_id: int):
"""Delete a player."""
try:
player = Player.query.get_or_404(player_id)
player_name = player.name
# Delete associated feedback
PlayerFeedback.query.filter_by(player_id=player_id).delete()
db.session.delete(player)
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player_name}" (ID: {player_id}) deleted')
flash(f'Player "{player_name}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting player: {str(e)}')
flash('Error deleting player. Please try again.', 'danger')
return redirect(url_for('players.players_list'))
@players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST'])
@login_required
def regenerate_auth_code(player_id: int):
"""Regenerate authentication code for a player."""
try:
player = Player.query.get_or_404(player_id)
# Generate new auth code
new_auth_code = secrets.token_urlsafe(16)
player.auth_code = new_auth_code
db.session.commit()
log_action('info', f'Auth code regenerated for player "{player.name}" (ID: {player_id})')
flash(f'New auth code for "{player.name}": {new_auth_code}', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error regenerating auth code: {str(e)}')
flash('Error regenerating auth code. Please try again.', 'danger')
return redirect(url_for('players.players_list'))
@players_bp.route('/<int:player_id>')
@login_required
def player_page(player_id: int):
"""Display player page with content and controls."""
try:
player = Player.query.get_or_404(player_id)
# Get player's playlist
playlist = get_player_playlist(player_id)
# Get player status
status_info = get_player_status_info(player_id)
# Get recent feedback
recent_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.limit(10)\
.all()
return render_template('player_page.html',
player=player,
playlist=playlist,
status_info=status_info,
recent_feedback=recent_feedback)
except Exception as e:
log_action('error', f'Error loading player page: {str(e)}')
flash('Error loading player page.', 'danger')
return redirect(url_for('players.players_list'))
@players_bp.route('/<int:player_id>/fullscreen')
def player_fullscreen(player_id: int):
"""Display player fullscreen view (no authentication required for players)."""
try:
player = Player.query.get_or_404(player_id)
# Verify auth code if provided
auth_code = request.args.get('auth')
if auth_code and auth_code != player.auth_code:
log_action('warning', f'Invalid auth code attempt for player {player_id}')
return "Invalid authentication code", 403
# Get player's playlist
playlist = get_player_playlist(player_id)
return render_template('player_fullscreen.html',
player=player,
playlist=playlist)
except Exception as e:
log_action('error', f'Error loading player fullscreen: {str(e)}')
return "Error loading player", 500
@cache.memoize(timeout=300) # Cache for 5 minutes
def get_player_playlist(player_id: int) -> List[dict]:
"""Get playlist for a player based on their group assignment.
Args:
player_id: The player's database ID
Returns:
List of content dictionaries with url, type, duration, and position
"""
player = Player.query.get(player_id)
if not player:
return []
# Get content from player's group
if player.group_id:
group = Group.query.get(player.group_id)
if group:
contents = group.contents.order_by(Content.position).all()
else:
contents = []
else:
# Player not in a group - show all content
contents = Content.query.order_by(Content.position).all()
# Build playlist
playlist = []
for content in contents:
playlist.append({
'id': content.id,
'url': url_for('static', filename=f'uploads/{content.filename}'),
'type': content.content_type,
'duration': content.duration or 10, # Default 10 seconds if not set
'position': content.position,
'filename': content.filename
})
return playlist
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_content(player_id: int):
"""Reorder content for a player's group."""
try:
player = Player.query.get_or_404(player_id)
if not player.group_id:
flash('Player is not assigned to a group.', 'warning')
return redirect(url_for('players.player_page', player_id=player_id))
# Get new order from request
content_order = request.json.get('order', [])
# Update positions
for idx, content_id in enumerate(content_order):
content = Content.query.get(content_id)
if content and content in player.group.contents:
content.position = idx
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Content reordered for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/bulk/delete', methods=['POST'])
@login_required
def bulk_delete_players():
"""Delete multiple players at once."""
try:
player_ids = request.json.get('player_ids', [])
if not player_ids:
return jsonify({'success': False, 'error': 'No players selected'}), 400
# Delete players
deleted_count = 0
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
# Delete associated feedback
PlayerFeedback.query.filter_by(player_id=player_id).delete()
db.session.delete(player)
cache.delete_memoized(get_player_playlist, player_id)
deleted_count += 1
db.session.commit()
log_action('info', f'Bulk deleted {deleted_count} players')
return jsonify({'success': True, 'deleted': deleted_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk deleting players: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/bulk/assign-group', methods=['POST'])
@login_required
def bulk_assign_group():
"""Assign multiple players to a group."""
try:
player_ids = request.json.get('player_ids', [])
group_id = request.json.get('group_id')
if not player_ids:
return jsonify({'success': False, 'error': 'No players selected'}), 400
# Validate group
if group_id:
group = Group.query.get(group_id)
if not group:
return jsonify({'success': False, 'error': 'Invalid group'}), 400
# Assign players
updated_count = 0
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
player.group_id = group_id
cache.delete_memoized(get_player_playlist, player_id)
updated_count += 1
db.session.commit()
log_action('info', f'Bulk assigned {updated_count} players to group {group_id}')
return jsonify({'success': True, 'updated': updated_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk assigning players: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500