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:
401
app/blueprints/groups.py
Normal file
401
app/blueprints/groups.py
Normal 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
|
||||
Reference in New Issue
Block a user