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:
175
app/app.py
Normal file
175
app/app.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
DigiServer v2 - Application Factory
|
||||
Modern Flask application with blueprint architecture
|
||||
"""
|
||||
import os
|
||||
from flask import Flask, render_template
|
||||
from config import get_config
|
||||
from extensions import db, bcrypt, login_manager, migrate, cache
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""
|
||||
Application factory pattern
|
||||
|
||||
Args:
|
||||
config_name: Configuration environment (development, production, testing)
|
||||
|
||||
Returns:
|
||||
Flask application instance
|
||||
"""
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
# Load configuration
|
||||
if config_name is None:
|
||||
config_name = os.getenv('FLASK_ENV', 'development')
|
||||
|
||||
app.config.from_object(get_config(config_name))
|
||||
|
||||
# Ensure instance folder exists
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
|
||||
# Ensure upload folders exist
|
||||
upload_folder = os.path.join(app.root_path, app.config['UPLOAD_FOLDER'])
|
||||
logo_folder = os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'])
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
os.makedirs(logo_folder, exist_ok=True)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
bcrypt.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
cache.init_app(app)
|
||||
|
||||
# Register blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Register CLI commands
|
||||
register_commands(app)
|
||||
|
||||
# Context processors
|
||||
register_context_processors(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register application blueprints"""
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.admin import admin_bp
|
||||
from blueprints.players import players_bp
|
||||
from blueprints.groups import groups_bp
|
||||
from blueprints.content import content_bp
|
||||
from blueprints.api import api_bp
|
||||
|
||||
# Register with appropriate URL prefixes
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||
app.register_blueprint(players_bp, url_prefix='/player')
|
||||
app.register_blueprint(groups_bp, url_prefix='/group')
|
||||
app.register_blueprint(content_bp, url_prefix='/content')
|
||||
app.register_blueprint(api_bp, url_prefix='/api')
|
||||
|
||||
# Main dashboard route
|
||||
from blueprints.main import main_bp
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
"""Register error handlers"""
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return render_template('errors/403.html'), 403
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
db.session.rollback()
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(413)
|
||||
def request_entity_too_large(error):
|
||||
return render_template('errors/413.html'), 413
|
||||
|
||||
@app.errorhandler(408)
|
||||
def request_timeout(error):
|
||||
return render_template('errors/408.html'), 408
|
||||
|
||||
|
||||
def register_commands(app):
|
||||
"""Register CLI commands"""
|
||||
import click
|
||||
|
||||
@app.cli.command('init-db')
|
||||
def init_db():
|
||||
"""Initialize the database"""
|
||||
db.create_all()
|
||||
click.echo('Database initialized.')
|
||||
|
||||
@app.cli.command('create-admin')
|
||||
@click.option('--username', default='admin', help='Admin username')
|
||||
@click.option('--password', prompt=True, hide_input=True, help='Admin password')
|
||||
def create_admin(username, password):
|
||||
"""Create an admin user"""
|
||||
from models.user import User
|
||||
|
||||
# Check if user exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
click.echo(f'User {username} already exists.')
|
||||
return
|
||||
|
||||
# Create admin user
|
||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
admin = User(username=username, password=hashed_password, role='admin')
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
click.echo(f'Admin user {username} created successfully.')
|
||||
|
||||
@app.cli.command('seed-db')
|
||||
def seed_db():
|
||||
"""Seed database with sample data (development only)"""
|
||||
if app.config['ENV'] == 'production':
|
||||
click.echo('Cannot seed database in production.')
|
||||
return
|
||||
|
||||
click.echo('Seeding database with sample data...')
|
||||
# Add your seeding logic here
|
||||
click.echo('Database seeded successfully.')
|
||||
|
||||
|
||||
def register_context_processors(app):
|
||||
"""Register context processors for templates"""
|
||||
from flask_login import current_user
|
||||
|
||||
@app.context_processor
|
||||
def inject_config():
|
||||
"""Inject configuration variables into all templates"""
|
||||
return {
|
||||
'server_version': app.config['SERVER_VERSION'],
|
||||
'build_date': app.config['BUILD_DATE'],
|
||||
'logo_exists': os.path.exists(
|
||||
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
|
||||
)
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
def inject_user_theme():
|
||||
"""Inject user theme preference"""
|
||||
theme = 'light'
|
||||
if current_user.is_authenticated and hasattr(current_user, 'theme'):
|
||||
theme = current_user.theme
|
||||
return {'theme': theme}
|
||||
|
||||
|
||||
# For backwards compatibility and direct running
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
3
app/blueprints/__init__.py
Normal file
3
app/blueprints/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Blueprints package initialization
|
||||
"""
|
||||
300
app/blueprints/admin.py
Normal file
300
app/blueprints/admin.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Admin blueprint for user management and system settings."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from functools import wraps
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Group, Content, ServerLog
|
||||
from app.utils.logger import log_action
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin role for route access."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
flash('Please login to access this page.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
if current_user.role != 'admin':
|
||||
log_action('warning', f'Unauthorized admin access attempt by {current_user.username}')
|
||||
flash('You do not have permission to access this page.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@admin_bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
"""Display admin panel with system overview."""
|
||||
try:
|
||||
# Get statistics
|
||||
total_users = User.query.count()
|
||||
total_players = Player.query.count()
|
||||
total_groups = Group.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Get recent logs
|
||||
recent_logs = ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(10).all()
|
||||
|
||||
# Get all users
|
||||
users = User.query.all()
|
||||
|
||||
# Calculate storage usage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
storage_mb = round(total_size / (1024 * 1024), 2)
|
||||
|
||||
return render_template('admin.html',
|
||||
total_users=total_users,
|
||||
total_players=total_players,
|
||||
total_groups=total_groups,
|
||||
total_content=total_content,
|
||||
storage_mb=storage_mb,
|
||||
users=users,
|
||||
recent_logs=recent_logs)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading admin panel: {str(e)}')
|
||||
flash('Error loading admin panel.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""Create a new user account."""
|
||||
try:
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
role = request.form.get('role', 'user').strip()
|
||||
|
||||
# Validation
|
||||
if not username or len(username) < 3:
|
||||
flash('Username must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
if not password or len(password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
if role not in ['user', 'admin']:
|
||||
flash('Invalid role specified.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Check if username exists
|
||||
existing_user = User.query.filter_by(username=username).first()
|
||||
if existing_user:
|
||||
flash(f'Username "{username}" already exists.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Create user
|
||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
new_user = User(username=username, password=hashed_password, role=role)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'User {username} created by admin {current_user.username}')
|
||||
flash(f'User "{username}" created successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error creating user: {str(e)}')
|
||||
flash('Error creating user. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/role', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def change_user_role(user_id: int):
|
||||
"""Change user role between user and admin."""
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
new_role = request.form.get('role', '').strip()
|
||||
|
||||
# Validation
|
||||
if new_role not in ['user', 'admin']:
|
||||
flash('Invalid role specified.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Prevent changing own role
|
||||
if user.id == current_user.id:
|
||||
flash('You cannot change your own role.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
old_role = user.role
|
||||
user.role = new_role
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'User {user.username} role changed from {old_role} to {new_role} by {current_user.username}')
|
||||
flash(f'User "{user.username}" role changed to {new_role}.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error changing user role: {str(e)}')
|
||||
flash('Error changing user role. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id: int):
|
||||
"""Delete a user account."""
|
||||
try:
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
# Prevent deleting own account
|
||||
if user.id == current_user.id:
|
||||
flash('You cannot delete your own account.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'User {username} deleted by admin {current_user.username}')
|
||||
flash(f'User "{username}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting user: {str(e)}')
|
||||
flash('Error deleting user. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/theme', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def change_theme():
|
||||
"""Change application theme."""
|
||||
try:
|
||||
theme = request.form.get('theme', 'light').strip()
|
||||
|
||||
if theme not in ['light', 'dark']:
|
||||
flash('Invalid theme specified.', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Store theme preference (you can extend this to save to database)
|
||||
# For now, just log the action
|
||||
log_action('info', f'Theme changed to {theme} by {current_user.username}')
|
||||
flash(f'Theme changed to {theme} mode.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error changing theme: {str(e)}')
|
||||
flash('Error changing theme. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/logo/upload', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def upload_logo():
|
||||
"""Upload custom logo for application."""
|
||||
try:
|
||||
if 'logo' not in request.files:
|
||||
flash('No logo file provided.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
file = request.files['logo']
|
||||
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'warning')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Validate file type
|
||||
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg'}
|
||||
filename = secure_filename(file.filename)
|
||||
if not ('.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions):
|
||||
flash('Invalid file type. Please upload an image file (PNG, JPG, GIF, SVG).', 'danger')
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
# Save logo
|
||||
static_folder = current_app.config.get('STATIC_FOLDER', 'app/static')
|
||||
logo_path = os.path.join(static_folder, 'logo.png')
|
||||
|
||||
# Create static folder if it doesn't exist
|
||||
os.makedirs(static_folder, exist_ok=True)
|
||||
|
||||
file.save(logo_path)
|
||||
|
||||
log_action('info', f'Logo uploaded by admin {current_user.username}')
|
||||
flash('Logo uploaded successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error uploading logo: {str(e)}')
|
||||
flash('Error uploading logo. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/logs/clear', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def clear_logs():
|
||||
"""Clear all server logs."""
|
||||
try:
|
||||
ServerLog.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'All logs cleared by admin {current_user.username}')
|
||||
flash('All logs cleared successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error clearing logs: {str(e)}')
|
||||
flash('Error clearing logs. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('admin.admin_panel'))
|
||||
|
||||
|
||||
@admin_bp.route('/system/info')
|
||||
@login_required
|
||||
@admin_required
|
||||
def system_info():
|
||||
"""Get system information as JSON."""
|
||||
try:
|
||||
import platform
|
||||
import psutil
|
||||
|
||||
# Get system info
|
||||
info = {
|
||||
'system': platform.system(),
|
||||
'release': platform.release(),
|
||||
'version': platform.version(),
|
||||
'machine': platform.machine(),
|
||||
'processor': platform.processor(),
|
||||
'cpu_count': psutil.cpu_count(),
|
||||
'cpu_percent': psutil.cpu_percent(interval=1),
|
||||
'memory_total': round(psutil.virtual_memory().total / (1024**3), 2), # GB
|
||||
'memory_used': round(psutil.virtual_memory().used / (1024**3), 2), # GB
|
||||
'memory_percent': psutil.virtual_memory().percent,
|
||||
'disk_total': round(psutil.disk_usage('/').total / (1024**3), 2), # GB
|
||||
'disk_used': round(psutil.disk_usage('/').used / (1024**3), 2), # GB
|
||||
'disk_percent': psutil.disk_usage('/').percent
|
||||
}
|
||||
|
||||
return jsonify(info)
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting system info: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
421
app/blueprints/api.py
Normal file
421
app/blueprints/api.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""API blueprint for REST endpoints and player communication."""
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Group, Content, PlayerFeedback, ServerLog
|
||||
from app.utils.logger import log_action
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
# Simple rate limiting (use Redis-based solution in production)
|
||||
rate_limit_storage = {}
|
||||
|
||||
|
||||
def rate_limit(max_requests: int = 60, window: int = 60):
|
||||
"""Rate limiting decorator.
|
||||
|
||||
Args:
|
||||
max_requests: Maximum number of requests allowed
|
||||
window: Time window in seconds
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get client identifier (IP address or API key)
|
||||
client_id = request.remote_addr
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
client_id = auth_header[7:] # Use API key as identifier
|
||||
|
||||
now = datetime.now()
|
||||
key = f"{client_id}:{f.__name__}"
|
||||
|
||||
# Clean old entries
|
||||
if key in rate_limit_storage:
|
||||
rate_limit_storage[key] = [
|
||||
req_time for req_time in rate_limit_storage[key]
|
||||
if now - req_time < timedelta(seconds=window)
|
||||
]
|
||||
else:
|
||||
rate_limit_storage[key] = []
|
||||
|
||||
# Check rate limit
|
||||
if len(rate_limit_storage[key]) >= max_requests:
|
||||
return jsonify({
|
||||
'error': 'Rate limit exceeded',
|
||||
'retry_after': window
|
||||
}), 429
|
||||
|
||||
# Add current request
|
||||
rate_limit_storage[key].append(now)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def verify_player_auth(f):
|
||||
"""Decorator to verify player authentication via auth code."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid authorization header'}), 401
|
||||
|
||||
auth_code = auth_header[7:] # Remove 'Bearer ' prefix
|
||||
|
||||
# Find player with this auth code
|
||||
player = Player.query.filter_by(auth_code=auth_code).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Invalid auth code attempt: {auth_code}')
|
||||
return jsonify({'error': 'Invalid authentication code'}), 403
|
||||
|
||||
# Store player in request context
|
||||
request.player = player
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""API health check endpoint."""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'version': '2.0.0'
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
@verify_player_auth
|
||||
def get_player_playlist(player_id: int):
|
||||
"""Get playlist for a specific player.
|
||||
|
||||
Requires player authentication via Bearer token.
|
||||
"""
|
||||
try:
|
||||
# Verify the authenticated player matches the requested player_id
|
||||
if request.player.id != player_id:
|
||||
return jsonify({'error': 'Unauthorized access to this player'}), 403
|
||||
|
||||
player = request.player
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player_id)
|
||||
|
||||
# Update player's last seen timestamp
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'group_id': player.group_id,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist for player {player_id}: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player."""
|
||||
from flask import url_for
|
||||
|
||||
player = Player.query.get(player_id)
|
||||
if not player:
|
||||
return []
|
||||
|
||||
# Get content based on group assignment
|
||||
if player.group_id:
|
||||
group = Group.query.get(player.group_id)
|
||||
contents = group.contents.order_by(Content.position).all() if group else []
|
||||
else:
|
||||
# Show all content if not in a group
|
||||
contents = Content.query.order_by(Content.position).all()
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in contents:
|
||||
playlist.append({
|
||||
'id': content.id,
|
||||
'filename': content.filename,
|
||||
'type': content.content_type,
|
||||
'duration': content.duration or 10,
|
||||
'position': content.position,
|
||||
'url': f"/static/uploads/{content.filename}",
|
||||
'description': content.description
|
||||
})
|
||||
|
||||
return playlist
|
||||
|
||||
|
||||
@api_bp.route('/player-feedback', methods=['POST'])
|
||||
@rate_limit(max_requests=100, window=60)
|
||||
@verify_player_auth
|
||||
def receive_player_feedback():
|
||||
"""Receive feedback/status updates from players.
|
||||
|
||||
Expected JSON payload:
|
||||
{
|
||||
"status": "playing|paused|error",
|
||||
"current_content_id": 123,
|
||||
"message": "Optional status message",
|
||||
"error": "Optional error message"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
player = request.player
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
# Create feedback record
|
||||
feedback = PlayerFeedback(
|
||||
player_id=player.id,
|
||||
status=data.get('status', 'unknown'),
|
||||
current_content_id=data.get('current_content_id'),
|
||||
message=data.get('message'),
|
||||
error=data.get('error')
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
# Update player's last seen
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = data.get('status', 'unknown')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Feedback received'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error receiving player feedback: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/player-status/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def get_player_status(player_id: int):
|
||||
"""Get current status of a player (public endpoint for monitoring)."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get latest feedback
|
||||
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.first()
|
||||
|
||||
# Calculate if player is online (seen in last 5 minutes)
|
||||
is_online = False
|
||||
if player.last_seen:
|
||||
is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'name': player.name,
|
||||
'location': player.location,
|
||||
'group_id': player.group_id,
|
||||
'status': player.status,
|
||||
'is_online': is_online,
|
||||
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
||||
'latest_feedback': {
|
||||
'status': latest_feedback.status,
|
||||
'message': latest_feedback.message,
|
||||
'timestamp': latest_feedback.timestamp.isoformat()
|
||||
} if latest_feedback else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting player status: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/upload-progress/<upload_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=120, window=60)
|
||||
def get_upload_progress(upload_id: str):
|
||||
"""Get progress of a file upload."""
|
||||
from app.utils.uploads import get_upload_progress as get_progress
|
||||
|
||||
try:
|
||||
progress = get_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting upload progress: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/system-info', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def system_info():
|
||||
"""Get system information and statistics."""
|
||||
try:
|
||||
# Get counts
|
||||
total_players = Player.query.count()
|
||||
total_groups = Group.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count online players (seen in last 5 minutes)
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
online_players = Player.query.filter(Player.last_seen >= five_min_ago).count()
|
||||
|
||||
# Get recent logs count
|
||||
recent_logs = ServerLog.query.filter(
|
||||
ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24)
|
||||
).count()
|
||||
|
||||
return jsonify({
|
||||
'players': {
|
||||
'total': total_players,
|
||||
'online': online_players
|
||||
},
|
||||
'groups': total_groups,
|
||||
'content': total_content,
|
||||
'logs_24h': recent_logs,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting system info: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/groups', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def list_groups():
|
||||
"""List all groups with basic information."""
|
||||
try:
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
|
||||
groups_data = []
|
||||
for group in groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count(),
|
||||
'content_count': group.contents.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'groups': groups_data,
|
||||
'count': len(groups_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error listing groups: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/content', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def list_content():
|
||||
"""List all content with basic information."""
|
||||
try:
|
||||
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
|
||||
content_data = []
|
||||
for content in contents:
|
||||
content_data.append({
|
||||
'id': content.id,
|
||||
'filename': content.filename,
|
||||
'type': content.content_type,
|
||||
'duration': content.duration,
|
||||
'size': content.file_size,
|
||||
'uploaded_at': content.uploaded_at.isoformat(),
|
||||
'group_count': content.groups.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content': content_data,
|
||||
'count': len(content_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error listing content: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/logs', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_logs():
|
||||
"""Get recent server logs.
|
||||
|
||||
Query parameters:
|
||||
limit: Number of logs to return (default: 50, max: 200)
|
||||
level: Filter by log level (info, warning, error)
|
||||
since: ISO timestamp to get logs since that time
|
||||
"""
|
||||
try:
|
||||
# Get query parameters
|
||||
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||
level = request.args.get('level')
|
||||
since_str = request.args.get('since')
|
||||
|
||||
# Build query
|
||||
query = ServerLog.query
|
||||
|
||||
if level:
|
||||
query = query.filter_by(level=level)
|
||||
|
||||
if since_str:
|
||||
try:
|
||||
since = datetime.fromisoformat(since_str)
|
||||
query = query.filter(ServerLog.timestamp >= since)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid since timestamp format'}), 400
|
||||
|
||||
# Get logs
|
||||
logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||
|
||||
logs_data = []
|
||||
for log in logs:
|
||||
logs_data.append({
|
||||
'id': log.id,
|
||||
'level': log.level,
|
||||
'message': log.message,
|
||||
'timestamp': log.timestamp.isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'logs': logs_data,
|
||||
'count': len(logs_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting logs: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""Handle 404 errors in API."""
|
||||
return jsonify({'error': 'Endpoint not found'}), 404
|
||||
|
||||
|
||||
@api_bp.errorhandler(405)
|
||||
def method_not_allowed(error):
|
||||
"""Handle 405 errors in API."""
|
||||
return jsonify({'error': 'Method not allowed'}), 405
|
||||
|
||||
|
||||
@api_bp.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 errors in API."""
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
147
app/blueprints/auth.py
Normal file
147
app/blueprints/auth.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Authentication Blueprint - Login, Logout, Register
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from extensions import db, bcrypt
|
||||
from models.user import User
|
||||
from utils.logger import log_action, log_user_created
|
||||
from typing import Optional
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login"""
|
||||
# Redirect if already logged in
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
remember = request.form.get('remember', False)
|
||||
|
||||
# Validate input
|
||||
if not username or not password:
|
||||
flash('Please provide both username and password.', 'danger')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
# Find user
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Verify credentials
|
||||
if user and bcrypt.check_password_hash(user.password, password):
|
||||
login_user(user, remember=remember)
|
||||
log_action(f'User {username} logged in')
|
||||
|
||||
# Redirect to next page or dashboard
|
||||
next_page = request.args.get('next')
|
||||
if next_page:
|
||||
return redirect(next_page)
|
||||
return redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
flash('Invalid username or password.', 'danger')
|
||||
log_action(f'Failed login attempt for username: {username}')
|
||||
|
||||
# Check for logo
|
||||
import os
|
||||
login_picture_exists = os.path.exists(
|
||||
os.path.join(auth_bp.root_path or '.', 'static/resurse/login_picture.png')
|
||||
)
|
||||
|
||||
return render_template('auth/login.html', login_picture_exists=login_picture_exists)
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
username = current_user.username
|
||||
logout_user()
|
||||
log_action(f'User {username} logged out')
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""User registration"""
|
||||
# Redirect if already logged in
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
# Validate input
|
||||
if not username or not password:
|
||||
flash('Username and password are required.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Passwords do not match.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
if len(password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# Check if user already exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'danger')
|
||||
return render_template('auth/register.html')
|
||||
|
||||
# Create new user
|
||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
new_user = User(
|
||||
username=username,
|
||||
password=hashed_password,
|
||||
role='viewer' # Default role
|
||||
)
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
log_user_created(username, 'viewer')
|
||||
flash('Registration successful! Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/change-password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""Change user password"""
|
||||
if request.method == 'POST':
|
||||
current_password = request.form.get('current_password', '')
|
||||
new_password = request.form.get('new_password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
# Verify current password
|
||||
if not bcrypt.check_password_hash(current_user.password, current_password):
|
||||
flash('Current password is incorrect.', 'danger')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# Validate new password
|
||||
if new_password != confirm_password:
|
||||
flash('New passwords do not match.', 'danger')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
if len(new_password) < 6:
|
||||
flash('Password must be at least 6 characters long.', 'danger')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# Update password
|
||||
current_user.password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
||||
db.session.commit()
|
||||
|
||||
log_action(f'User {current_user.username} changed password')
|
||||
flash('Password changed successfully.', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('auth/change_password.html')
|
||||
369
app/blueprints/content.py
Normal file
369
app/blueprints/content.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""Content blueprint for media upload and management."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app, send_from_directory)
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Group
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.uploads import (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress
|
||||
)
|
||||
|
||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||
|
||||
|
||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
||||
upload_progress = {}
|
||||
|
||||
|
||||
@content_bp.route('/')
|
||||
@login_required
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
|
||||
# Get group info for each content
|
||||
content_groups = {}
|
||||
for content in contents:
|
||||
content_groups[content.id] = content.groups.count()
|
||||
|
||||
return render_template('content_list.html',
|
||||
contents=contents,
|
||||
content_groups=content_groups)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
return render_template('upload_content.html')
|
||||
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
flash('No file provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Get optional parameters
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
# Generate unique upload ID for progress tracking
|
||||
upload_id = os.urandom(16).hex()
|
||||
|
||||
# Save file with progress tracking
|
||||
filename = secure_filename(file.filename)
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file with progress updates
|
||||
set_upload_progress(upload_id, 0, 'Uploading file...')
|
||||
file.save(filepath)
|
||||
set_upload_progress(upload_id, 50, 'File uploaded, processing...')
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert, optimize, extract metadata)
|
||||
set_upload_progress(upload_id, 60, 'Processing video...')
|
||||
process_video_file(filepath, upload_id)
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(upload_id, 60, 'Processing PDF...')
|
||||
process_pdf_file(filepath, upload_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(upload_id, 60, 'Processing presentation...')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
set_upload_progress(upload_id, 90, 'Creating database entry...')
|
||||
|
||||
# Create content record
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
description=description or None,
|
||||
file_size=os.path.getsize(filepath)
|
||||
)
|
||||
db.session.add(new_content)
|
||||
db.session.commit()
|
||||
|
||||
set_upload_progress(upload_id, 100, 'Complete!')
|
||||
|
||||
# Clear all playlist caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" uploaded successfully (Type: {content_type})')
|
||||
flash(f'Content "{filename}" uploaded successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_content(content_id: int):
|
||||
"""Edit content metadata."""
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
# Update content
|
||||
if duration is not None:
|
||||
content.duration = duration
|
||||
content.description = description or None
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating content: {str(e)}')
|
||||
flash('Error updating content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_content(content_id: int):
|
||||
"""Delete content and associated file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete file from disk
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content: {str(e)}')
|
||||
flash('Error deleting content. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
"""Delete multiple content items at once."""
|
||||
try:
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
||||
|
||||
# Delete content
|
||||
deleted_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(content_id)
|
||||
if content:
|
||||
# Delete file
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk deleting content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/upload-progress/<upload_id>')
|
||||
@login_required
|
||||
def upload_progress_status(upload_id: str):
|
||||
"""Get upload progress for a specific upload."""
|
||||
progress = get_upload_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
|
||||
|
||||
@content_bp.route('/preview/<int:content_id>')
|
||||
@login_required
|
||||
def preview_content(content_id: int):
|
||||
"""Preview content in browser."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Serve file from uploads folder
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=False
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error previewing content: {str(e)}')
|
||||
return "Error loading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/download')
|
||||
@login_required
|
||||
def download_content(content_id: int):
|
||||
"""Download content file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" downloaded')
|
||||
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=True
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error downloading content: {str(e)}')
|
||||
return "Error downloading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/statistics')
|
||||
@login_required
|
||||
def content_statistics():
|
||||
"""Get content statistics."""
|
||||
try:
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
||||
count = Content.query.filter_by(content_type=content_type).count()
|
||||
type_counts[content_type] = count
|
||||
|
||||
# Calculate total storage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
return jsonify({
|
||||
'total': total_content,
|
||||
'by_type': type_counts,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/check-duplicates')
|
||||
@login_required
|
||||
def check_duplicates():
|
||||
"""Check for duplicate filenames."""
|
||||
try:
|
||||
# Get all filenames
|
||||
all_content = Content.query.all()
|
||||
filename_counts = {}
|
||||
|
||||
for content in all_content:
|
||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
||||
|
||||
return jsonify({
|
||||
'has_duplicates': len(duplicates) > 0,
|
||||
'duplicates': duplicates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/groups')
|
||||
@login_required
|
||||
def content_groups_info(content_id: int):
|
||||
"""Get groups that contain this content."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
groups_data = []
|
||||
for group in content.groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content_id': content_id,
|
||||
'filename': content.filename,
|
||||
'groups': groups_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content groups: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
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
|
||||
63
app/blueprints/main.py
Normal file
63
app/blueprints/main.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Main Blueprint - Dashboard and Home Routes
|
||||
"""
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from extensions import db, cache
|
||||
from models.player import Player
|
||||
from models.group import Group
|
||||
from utils.logger import get_recent_logs
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
players = Player.query.all()
|
||||
groups = Group.query.all()
|
||||
server_logs = get_recent_logs(20)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
server_logs=server_logs
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/health')
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
from flask import jsonify
|
||||
import os
|
||||
|
||||
try:
|
||||
# Check database
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
|
||||
# Check disk space
|
||||
upload_folder = os.path.join(
|
||||
main_bp.root_path or '.',
|
||||
'static/uploads'
|
||||
)
|
||||
|
||||
if os.path.exists(upload_folder):
|
||||
stat = os.statvfs(upload_folder)
|
||||
free_space_gb = (stat.f_bavail * stat.f_frsize) / (1024**3)
|
||||
else:
|
||||
free_space_gb = 0
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'database': 'ok',
|
||||
'disk_space_gb': round(free_space_gb, 2)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}), 500
|
||||
368
app/blueprints/players.py
Normal file
368
app/blueprints/players.py
Normal 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
|
||||
123
app/config.py
Normal file
123
app/config.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Configuration settings for DigiServer v2
|
||||
Environment-based configuration with sensible defaults
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
|
||||
# Basic Flask config
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
# File Upload
|
||||
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
|
||||
UPLOAD_FOLDER = 'static/uploads'
|
||||
UPLOAD_FOLDERLOGO = 'static/resurse'
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
|
||||
|
||||
# Session
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(minutes=30)
|
||||
SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Cache
|
||||
SEND_FILE_MAX_AGE_DEFAULT = 300 # 5 minutes for static files
|
||||
|
||||
# Server Info
|
||||
SERVER_VERSION = "2.0.0"
|
||||
BUILD_DATE = "2025-11-12"
|
||||
|
||||
# Pagination
|
||||
ITEMS_PER_PAGE = 20
|
||||
|
||||
# Admin defaults
|
||||
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
||||
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'sqlite:///instance/dev.db'
|
||||
)
|
||||
|
||||
# Cache (simple in-memory for development)
|
||||
CACHE_TYPE = 'simple'
|
||||
CACHE_DEFAULT_TIMEOUT = 60
|
||||
|
||||
# Security (relaxed for development)
|
||||
WTF_CSRF_ENABLED = True
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'sqlite:///instance/dashboard.db'
|
||||
)
|
||||
|
||||
# Redis Cache
|
||||
CACHE_TYPE = 'redis'
|
||||
CACHE_REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
|
||||
CACHE_REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
|
||||
CACHE_REDIS_DB = 0
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
|
||||
# Security
|
||||
SESSION_COOKIE_SECURE = True
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Rate Limiting
|
||||
RATELIMIT_STORAGE_URL = f"redis://{os.getenv('REDIS_HOST', 'redis')}:6379/1"
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration"""
|
||||
|
||||
DEBUG = True
|
||||
TESTING = True
|
||||
|
||||
# Database (in-memory for tests)
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
|
||||
# Cache (simple for tests)
|
||||
CACHE_TYPE = 'simple'
|
||||
|
||||
# Security (disabled for tests)
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
|
||||
# Configuration dictionary
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
|
||||
|
||||
def get_config(env=None):
|
||||
"""Get configuration based on environment"""
|
||||
if env is None:
|
||||
env = os.getenv('FLASK_ENV', 'development')
|
||||
return config.get(env, config['default'])
|
||||
21
app/extensions.py
Normal file
21
app/extensions.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Flask extensions initialization
|
||||
Centralized extension management for the application
|
||||
"""
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_caching import Cache
|
||||
|
||||
# Initialize extensions (will be bound to app in create_app)
|
||||
db = SQLAlchemy()
|
||||
bcrypt = Bcrypt()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
cache = Cache()
|
||||
|
||||
# Configure login manager
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'info'
|
||||
Reference in New Issue
Block a user