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
369 lines
13 KiB
Python
369 lines
13 KiB
Python
"""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
|