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
402 lines
15 KiB
Python
402 lines
15 KiB
Python
"""Groups blueprint for group management and player assignments."""
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
|
from flask_login import login_required
|
|
from typing import List, Dict
|
|
|
|
from app.extensions import db, cache
|
|
from app.models import Group, Player, Content
|
|
from app.utils.logger import log_action
|
|
from app.utils.group_player_management import get_player_status_info, get_group_statistics
|
|
|
|
groups_bp = Blueprint('groups', __name__, url_prefix='/groups')
|
|
|
|
|
|
@groups_bp.route('/')
|
|
@login_required
|
|
def groups_list():
|
|
"""Display list of all groups."""
|
|
try:
|
|
groups = Group.query.order_by(Group.name).all()
|
|
|
|
# Get statistics for each group
|
|
group_stats = {}
|
|
for group in groups:
|
|
stats = get_group_statistics(group.id)
|
|
group_stats[group.id] = stats
|
|
|
|
return render_template('groups_list.html',
|
|
groups=groups,
|
|
group_stats=group_stats)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading groups list: {str(e)}')
|
|
flash('Error loading groups list.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
|
|
@groups_bp.route('/create', methods=['GET', 'POST'])
|
|
@login_required
|
|
def create_group():
|
|
"""Create a new group."""
|
|
if request.method == 'GET':
|
|
available_content = Content.query.order_by(Content.filename).all()
|
|
return render_template('create_group.html', available_content=available_content)
|
|
|
|
try:
|
|
name = request.form.get('name', '').strip()
|
|
description = request.form.get('description', '').strip()
|
|
content_ids = request.form.getlist('content_ids')
|
|
|
|
# Validation
|
|
if not name or len(name) < 3:
|
|
flash('Group name must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('groups.create_group'))
|
|
|
|
# Check if group name exists
|
|
existing_group = Group.query.filter_by(name=name).first()
|
|
if existing_group:
|
|
flash(f'Group "{name}" already exists.', 'warning')
|
|
return redirect(url_for('groups.create_group'))
|
|
|
|
# Create group
|
|
new_group = Group(
|
|
name=name,
|
|
description=description or None
|
|
)
|
|
|
|
# Add content to group
|
|
if content_ids:
|
|
for content_id in content_ids:
|
|
content = Content.query.get(int(content_id))
|
|
if content:
|
|
new_group.contents.append(content)
|
|
|
|
db.session.add(new_group)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Group "{name}" created with {len(content_ids)} content items')
|
|
flash(f'Group "{name}" created successfully.', 'success')
|
|
|
|
return redirect(url_for('groups.groups_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error creating group: {str(e)}')
|
|
flash('Error creating group. Please try again.', 'danger')
|
|
return redirect(url_for('groups.create_group'))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_group(group_id: int):
|
|
"""Edit group details."""
|
|
group = Group.query.get_or_404(group_id)
|
|
|
|
if request.method == 'GET':
|
|
available_content = Content.query.order_by(Content.filename).all()
|
|
return render_template('edit_group.html',
|
|
group=group,
|
|
available_content=available_content)
|
|
|
|
try:
|
|
name = request.form.get('name', '').strip()
|
|
description = request.form.get('description', '').strip()
|
|
content_ids = request.form.getlist('content_ids')
|
|
|
|
# Validation
|
|
if not name or len(name) < 3:
|
|
flash('Group name must be at least 3 characters long.', 'warning')
|
|
return redirect(url_for('groups.edit_group', group_id=group_id))
|
|
|
|
# Check if group name exists (excluding current group)
|
|
existing_group = Group.query.filter(Group.name == name, Group.id != group_id).first()
|
|
if existing_group:
|
|
flash(f'Group name "{name}" is already in use.', 'warning')
|
|
return redirect(url_for('groups.edit_group', group_id=group_id))
|
|
|
|
# Update group
|
|
group.name = name
|
|
group.description = description or None
|
|
|
|
# Update content
|
|
group.contents = []
|
|
if content_ids:
|
|
for content_id in content_ids:
|
|
content = Content.query.get(int(content_id))
|
|
if content:
|
|
group.contents.append(content)
|
|
|
|
db.session.commit()
|
|
|
|
# Clear cache for all players in this group
|
|
for player in group.players:
|
|
cache.delete_memoized('get_player_playlist', player.id)
|
|
|
|
log_action('info', f'Group "{name}" (ID: {group_id}) updated')
|
|
flash(f'Group "{name}" updated successfully.', 'success')
|
|
|
|
return redirect(url_for('groups.groups_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating group: {str(e)}')
|
|
flash('Error updating group. Please try again.', 'danger')
|
|
return redirect(url_for('groups.edit_group', group_id=group_id))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_group(group_id: int):
|
|
"""Delete a group."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
group_name = group.name
|
|
|
|
# Unassign players from group
|
|
for player in group.players:
|
|
player.group_id = None
|
|
cache.delete_memoized('get_player_playlist', player.id)
|
|
|
|
db.session.delete(group)
|
|
db.session.commit()
|
|
|
|
log_action('info', f'Group "{group_name}" (ID: {group_id}) deleted')
|
|
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting group: {str(e)}')
|
|
flash('Error deleting group. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('groups.groups_list'))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/manage')
|
|
@login_required
|
|
def manage_group(group_id: int):
|
|
"""Manage group with player status cards and content."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
|
|
# Get all players in this group
|
|
players = group.players.order_by(Player.name).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
|
|
|
|
# Get group content
|
|
contents = group.contents.order_by(Content.position).all()
|
|
|
|
# Get available players (not in this group)
|
|
available_players = Player.query.filter(
|
|
(Player.group_id == None) | (Player.group_id != group_id)
|
|
).order_by(Player.name).all()
|
|
|
|
# Get available content (not in this group)
|
|
all_content = Content.query.order_by(Content.filename).all()
|
|
|
|
return render_template('manage_group.html',
|
|
group=group,
|
|
players=players,
|
|
player_statuses=player_statuses,
|
|
contents=contents,
|
|
available_players=available_players,
|
|
all_content=all_content)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading manage group page: {str(e)}')
|
|
flash('Error loading manage group page.', 'danger')
|
|
return redirect(url_for('groups.groups_list'))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/fullscreen')
|
|
def group_fullscreen(group_id: int):
|
|
"""Display group fullscreen view with all player status cards."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
|
|
# Get all players in this group
|
|
players = group.players.order_by(Player.name).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('group_fullscreen.html',
|
|
group=group,
|
|
players=players,
|
|
player_statuses=player_statuses)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading group fullscreen: {str(e)}')
|
|
return "Error loading group fullscreen", 500
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/add-player', methods=['POST'])
|
|
@login_required
|
|
def add_player_to_group(group_id: int):
|
|
"""Add a player to a group."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
player_id = request.form.get('player_id')
|
|
|
|
if not player_id:
|
|
flash('No player selected.', 'warning')
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
player = Player.query.get_or_404(int(player_id))
|
|
player.group_id = group_id
|
|
db.session.commit()
|
|
|
|
# Clear cache
|
|
cache.delete_memoized('get_player_playlist', player.id)
|
|
|
|
log_action('info', f'Player "{player.name}" added to group "{group.name}"')
|
|
flash(f'Player "{player.name}" added to group successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error adding player to group: {str(e)}')
|
|
flash('Error adding player to group. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/remove-player/<int:player_id>', methods=['POST'])
|
|
@login_required
|
|
def remove_player_from_group(group_id: int, player_id: int):
|
|
"""Remove a player from a group."""
|
|
try:
|
|
player = Player.query.get_or_404(player_id)
|
|
|
|
if player.group_id != group_id:
|
|
flash('Player is not in this group.', 'warning')
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
player_name = player.name
|
|
player.group_id = None
|
|
db.session.commit()
|
|
|
|
# Clear cache
|
|
cache.delete_memoized('get_player_playlist', player_id)
|
|
|
|
log_action('info', f'Player "{player_name}" removed from group {group_id}')
|
|
flash(f'Player "{player_name}" removed from group successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error removing player from group: {str(e)}')
|
|
flash('Error removing player from group. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/add-content', methods=['POST'])
|
|
@login_required
|
|
def add_content_to_group(group_id: int):
|
|
"""Add content to a group."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
content_ids = request.form.getlist('content_ids')
|
|
|
|
if not content_ids:
|
|
flash('No content selected.', 'warning')
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
# Add content
|
|
added_count = 0
|
|
for content_id in content_ids:
|
|
content = Content.query.get(int(content_id))
|
|
if content and content not in group.contents:
|
|
group.contents.append(content)
|
|
added_count += 1
|
|
|
|
db.session.commit()
|
|
|
|
# Clear cache for all players in this group
|
|
for player in group.players:
|
|
cache.delete_memoized('get_player_playlist', player.id)
|
|
|
|
log_action('info', f'{added_count} content items added to group "{group.name}"')
|
|
flash(f'{added_count} content items added successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error adding content to group: {str(e)}')
|
|
flash('Error adding content to group. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/remove-content/<int:content_id>', methods=['POST'])
|
|
@login_required
|
|
def remove_content_from_group(group_id: int, content_id: int):
|
|
"""Remove content from a group."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
if content not in group.contents:
|
|
flash('Content is not in this group.', 'warning')
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
group.contents.remove(content)
|
|
db.session.commit()
|
|
|
|
# Clear cache for all players in this group
|
|
for player in group.players:
|
|
cache.delete_memoized('get_player_playlist', player.id)
|
|
|
|
log_action('info', f'Content "{content.filename}" removed from group "{group.name}"')
|
|
flash('Content removed from group successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error removing content from group: {str(e)}')
|
|
flash('Error removing content from group. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/reorder-content', methods=['POST'])
|
|
@login_required
|
|
def reorder_group_content(group_id: int):
|
|
"""Reorder content within a group."""
|
|
try:
|
|
group = Group.query.get_or_404(group_id)
|
|
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 group.contents:
|
|
content.position = idx
|
|
|
|
db.session.commit()
|
|
|
|
# Clear cache for all players in this group
|
|
for player in group.players:
|
|
cache.delete_memoized('get_player_playlist', player.id)
|
|
|
|
log_action('info', f'Content reordered for group "{group.name}"')
|
|
return jsonify({'success': True})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error reordering group content: {str(e)}')
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@groups_bp.route('/<int:group_id>/stats')
|
|
@login_required
|
|
def group_stats(group_id: int):
|
|
"""Get group statistics as JSON."""
|
|
try:
|
|
stats = get_group_statistics(group_id)
|
|
return jsonify(stats)
|
|
except Exception as e:
|
|
log_action('error', f'Error getting group stats: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|