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

401
app/blueprints/groups.py Normal file
View File

@@ -0,0 +1,401 @@
"""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