Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor

This commit is contained in:
ske087
2026-05-10 21:07:50 +03:00
commit 8d9df56b0b
364 changed files with 73655 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""
Blueprints package initialization
"""
+938
View File
@@ -0,0 +1,938 @@
"""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, Content, ServerLog, Playlist, HTTPSConfig
from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator
from app.utils.nginx_config_reader import get_nginx_status
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
def admin_panel():
"""Display admin panel with system overview."""
try:
# Get statistics
total_users = User.query.count()
total_players = Player.query.count()
total_playlists = Playlist.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/admin.html',
total_users=total_users,
total_players=total_players,
total_playlists=total_playlists,
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():
"""User creation is disabled — accounts are managed by the EDP portal."""
flash('User management is handled through the Enterprise Digital Platform portal.', 'info')
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):
"""Role changes are disabled — roles are set from the EDP portal and synced via SSO."""
flash('Role management is handled through the Enterprise Digital Platform portal.', 'info')
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):
"""User deletion is disabled — accounts are managed by the EDP portal."""
flash('User management is handled through the Enterprise Digital Platform portal.', 'info')
return redirect(url_for('admin.admin_panel'))
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('/users')
@login_required
@admin_required
def user_management():
"""Display user management page."""
try:
users = User.query.order_by(User.created_at.desc()).all()
return render_template('admin/user_management.html', users=users)
except Exception as e:
log_action('error', f'Error loading user management: {str(e)}')
flash('Error loading user management page.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
@login_required
@admin_required
def reset_user_password(user_id: int):
"""Reset user password."""
try:
user = User.query.get_or_404(user_id)
new_password = request.form.get('password', '').strip()
# Validation
if not new_password or len(new_password) < 6:
flash('Password must be at least 6 characters long.', 'warning')
return redirect(url_for('admin.user_management'))
# Prevent changing own password through this route
if user.id == current_user.id:
flash('Use the change password option to update your own password.', 'warning')
return redirect(url_for('admin.user_management'))
# Update password
hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8')
user.password = hashed_password
db.session.commit()
log_action('info', f'Password reset for user {user.username} by admin {current_user.username}')
flash(f'Password reset successfully for user "{user.username}".', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error resetting password: {str(e)}')
flash('Error resetting password. Please try again.', 'danger')
return redirect(url_for('admin.user_management'))
@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
@admin_bp.route('/leftover-media')
@login_required
def leftover_media():
"""Display leftover media files not assigned to any playlist."""
from app.models.playlist import playlist_content
from sqlalchemy import select
try:
# Get all content IDs that are in playlists
stmt = select(playlist_content.c.content_id).distinct()
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
# Get all content
all_content = Content.query.all()
# Filter content not in any playlist
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
# Separate by type
leftover_images = [c for c in leftover_content if c.content_type == 'image']
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
# Calculate storage (handle None values)
def safe_file_size(content_list):
return sum(c.file_size or 0 for c in content_list)
total_leftover_size = safe_file_size(leftover_content)
images_size = safe_file_size(leftover_images)
videos_size = safe_file_size(leftover_videos)
pdfs_size = safe_file_size(leftover_pdfs)
pptx_size = safe_file_size(leftover_pptx)
return render_template('admin/leftover_media.html',
leftover_images=leftover_images,
leftover_videos=leftover_videos,
leftover_pdfs=leftover_pdfs,
leftover_pptx=leftover_pptx,
total_leftover=len(leftover_content),
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
images_size_mb=images_size / (1024 * 1024),
videos_size_mb=videos_size / (1024 * 1024),
pdfs_size_mb=pdfs_size / (1024 * 1024),
pptx_size_mb=pptx_size / (1024 * 1024))
except Exception as e:
log_action('error', f'Error loading leftover media: {str(e)}')
flash('Error loading leftover media.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/delete-leftover-images', methods=['POST'])
@login_required
def delete_leftover_images():
"""Delete all leftover images that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover image content
leftover_images = db.session.query(Content).filter(
Content.content_type == 'image',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_images:
try:
# Delete physical file
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover images: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
@login_required
def delete_leftover_videos():
"""Delete all leftover videos that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover video content
leftover_videos = db.session.query(Content).filter(
Content.content_type == 'video',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_videos:
try:
# Delete physical file
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/delete-single-leftover/<int:content_id>', methods=['POST'])
@login_required
def delete_single_leftover(content_id):
"""Delete a single leftover content file"""
try:
content = Content.query.get_or_404(content_id)
# Delete physical file
if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record
db.session.delete(content)
db.session.commit()
flash(f'Successfully deleted {content.filename}', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting file: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/dependencies')
@login_required
@admin_required
def dependencies():
"""Show system dependencies status."""
import subprocess
# Check LibreOffice
libreoffice_installed = False
libreoffice_version = "Not installed"
try:
result = subprocess.run(['libreoffice', '--version'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0:
libreoffice_installed = True
libreoffice_version = result.stdout.strip()
except Exception:
pass
# Check Poppler (for PDF)
poppler_installed = False
poppler_version = "Not installed"
try:
result = subprocess.run(['pdftoppm', '-v'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0 or 'pdftoppm' in result.stderr:
poppler_installed = True
poppler_version = "Installed"
except Exception:
pass
# Check FFmpeg (for video)
ffmpeg_installed = False
ffmpeg_version = "Not installed"
try:
result = subprocess.run(['ffmpeg', '-version'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0:
ffmpeg_installed = True
ffmpeg_version = result.stdout.split('\n')[0]
except Exception:
pass
# Check Emoji Fonts
emoji_installed = False
emoji_version = 'Not installed'
try:
result = subprocess.run(['dpkg', '-l', 'fonts-noto-color-emoji'],
capture_output=True,
text=True,
timeout=5)
if result.returncode == 0 and 'ii' in result.stdout:
emoji_installed = True
# Get version from dpkg output
lines = result.stdout.split('\n')
for line in lines:
if 'fonts-noto-color-emoji' in line and line.startswith('ii'):
parts = line.split()
if len(parts) >= 3:
emoji_version = f'Noto Color Emoji {parts[2]}'
break
except Exception:
pass
return render_template('admin/dependencies.html',
libreoffice_installed=libreoffice_installed,
libreoffice_version=libreoffice_version,
poppler_installed=poppler_installed,
poppler_version=poppler_version,
ffmpeg_installed=ffmpeg_installed,
ffmpeg_version=ffmpeg_version,
emoji_installed=emoji_installed,
emoji_version=emoji_version)
@admin_bp.route('/install-libreoffice', methods=['POST'])
@login_required
@admin_required
def install_libreoffice():
"""Install LibreOffice for PPTX conversion."""
import subprocess
try:
# Run installation script
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'install_libreoffice.sh')
if not os.path.exists(script_path):
flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', '-n', script_path],
capture_output=True,
text=True,
timeout=300)
if result.returncode == 0:
log_action('info', 'LibreOffice installed successfully')
flash('LibreOffice installed successfully! You can now convert PPTX files.', 'success')
else:
log_action('error', f'LibreOffice installation failed: {result.stderr}')
flash(f'Installation failed: {result.stderr}', 'danger')
except subprocess.TimeoutExpired:
flash('Installation timeout. Please try again.', 'warning')
except Exception as e:
log_action('error', f'Error installing LibreOffice: {str(e)}')
flash(f'Error: {str(e)}', 'danger')
return redirect(url_for('admin.dependencies'))
@admin_bp.route('/install-emoji-fonts', methods=['POST'])
@login_required
@admin_required
def install_emoji_fonts():
"""Install Emoji Fonts for better UI display."""
import subprocess
try:
# Run installation script
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'install_emoji_fonts.sh')
if not os.path.exists(script_path):
flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', '-n', script_path],
capture_output=True,
text=True,
timeout=180)
if result.returncode == 0:
log_action('info', 'Emoji fonts installed successfully')
flash('Emoji fonts installed successfully! Please restart your browser to see changes.', 'success')
else:
log_action('error', f'Emoji fonts installation failed: {result.stderr}')
flash(f'Installation failed: {result.stderr}', 'danger')
except subprocess.TimeoutExpired:
flash('Installation timeout. Please try again.', 'warning')
except Exception as e:
log_action('error', f'Error installing emoji fonts: {str(e)}')
flash(f'Error: {str(e)}', 'danger')
return redirect(url_for('admin.dependencies'))
@admin_bp.route('/customize-logos')
@login_required
@admin_required
def customize_logos():
"""Logo customization page."""
import time
return render_template('admin/customize_logos.html', version=int(time.time()))
@admin_bp.route('/upload-header-logo', methods=['POST'])
@login_required
@admin_required
def upload_header_logo():
"""Upload header logo."""
try:
if 'header_logo' not in request.files:
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
file = request.files['header_logo']
if file.filename == '':
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
if file:
# Save as header_logo.png
filename = 'header_logo.png'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
log_action('info', f'Header logo uploaded: {filename}')
flash('Header logo uploaded successfully!', 'success')
except Exception as e:
log_action('error', f'Error uploading header logo: {str(e)}')
flash(f'Error uploading logo: {str(e)}', 'danger')
return redirect(url_for('admin.customize_logos'))
@admin_bp.route('/upload-login-logo', methods=['POST'])
@login_required
@admin_required
def upload_login_logo():
"""Upload login page logo."""
try:
if 'login_logo' not in request.files:
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
file = request.files['login_logo']
if file.filename == '':
flash('No file selected', 'warning')
return redirect(url_for('admin.customize_logos'))
if file:
# Save as login_logo.png
filename = 'login_logo.png'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
log_action('info', f'Login logo uploaded: {filename}')
flash('Login logo uploaded successfully!', 'success')
except Exception as e:
log_action('error', f'Error uploading login logo: {str(e)}')
flash(f'Error uploading logo: {str(e)}', 'danger')
return redirect(url_for('admin.customize_logos'))
@admin_bp.route('/editing-users')
@login_required
def manage_editing_users():
"""Display and manage users that edit images on players."""
try:
from app.models.player_user import PlayerUser
from app.models.player_edit import PlayerEdit
# Get all editing users
users = PlayerUser.query.order_by(PlayerUser.created_at.desc()).all()
# Get edit counts for each user
user_stats = {}
for user in users:
edit_count = PlayerEdit.query.filter_by(user=user.user_code).count()
user_stats[user.user_code] = edit_count
return render_template('admin/editing_users.html',
users=users,
user_stats=user_stats)
except Exception as e:
log_action('error', f'Error loading editing users: {str(e)}')
flash('Error loading editing users.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/editing-users/<int:user_id>/update', methods=['POST'])
@login_required
def update_editing_user(user_id: int):
"""Update editing user name."""
try:
from app.models.player_user import PlayerUser
user = PlayerUser.query.get_or_404(user_id)
user_name = request.form.get('user_name', '').strip()
user.user_name = user_name if user_name else None
user.updated_at = datetime.utcnow()
db.session.commit()
log_action('info', f'Updated editing user {user.user_code} name to: {user_name or "None"}')
flash('User name updated successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating editing user: {str(e)}')
flash(f'Error updating user: {str(e)}', 'danger')
return redirect(url_for('admin.manage_editing_users'))
@admin_bp.route('/editing-users/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_editing_user(user_id: int):
"""Delete editing user."""
try:
from app.models.player_user import PlayerUser
user = PlayerUser.query.get_or_404(user_id)
user_code = user.user_code
db.session.delete(user)
db.session.commit()
log_action('info', f'Deleted editing user: {user_code}')
flash('User deleted successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting editing user: {str(e)}')
flash(f'Error deleting user: {str(e)}', 'danger')
return redirect(url_for('admin.manage_editing_users'))
# ============================================================================
# HTTPS Configuration Management Routes
# ============================================================================
@admin_bp.route('/https-config', methods=['GET'])
@login_required
@admin_required
def https_config():
"""Display HTTPS configuration management page."""
try:
config = HTTPSConfig.get_config()
# Detect actual current HTTPS status
# Check if current connection is HTTPS
is_https_active = request.scheme == 'https' or request.headers.get('X-Forwarded-Proto') == 'https'
current_host = request.host.split(':')[0] # Remove port if present
# If HTTPS is active but database shows disabled, sync it
if is_https_active and config and not config.https_enabled:
# Update database to reflect actual HTTPS status
config.https_enabled = True
db.session.commit()
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
# Get Nginx configuration status
nginx_status = get_nginx_status()
return render_template('admin/https_config.html',
config=config,
is_https_active=is_https_active,
current_host=current_host,
nginx_status=nginx_status)
except Exception as e:
log_action('error', f'Error loading HTTPS config page: {str(e)}')
flash('Error loading HTTPS configuration page.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/https-config/update', methods=['POST'])
@login_required
@admin_required
def update_https_config():
"""Update HTTPS configuration."""
try:
https_enabled = request.form.get('https_enabled') == 'on'
hostname = request.form.get('hostname', '').strip()
domain = request.form.get('domain', '').strip()
ip_address = request.form.get('ip_address', '').strip()
email = request.form.get('email', '').strip()
port = request.form.get('port', '443').strip()
# Validation
errors = []
if https_enabled:
if not hostname:
errors.append('Hostname is required when HTTPS is enabled.')
if not domain:
errors.append('Domain name is required when HTTPS is enabled.')
if not ip_address:
errors.append('IP address is required when HTTPS is enabled.')
if not email:
errors.append('Email address is required when HTTPS is enabled.')
# Validate domain format (basic)
if domain and '.' not in domain:
errors.append('Please enter a valid domain name (e.g., example.com).')
# Validate IP format (basic)
if ip_address:
ip_parts = ip_address.split('.')
if len(ip_parts) != 4:
errors.append('Please enter a valid IPv4 address (e.g., 10.76.152.164).')
else:
try:
for part in ip_parts:
num = int(part)
if num < 0 or num > 255:
raise ValueError()
except ValueError:
errors.append('Please enter a valid IPv4 address.')
# Validate email format (basic)
if email and '@' not in email:
errors.append('Please enter a valid email address.')
# Validate port
try:
port_num = int(port)
if port_num < 1 or port_num > 65535:
errors.append('Port must be between 1 and 65535.')
port = port_num
except ValueError:
errors.append('Port must be a valid number.')
else:
port = 443
if errors:
for error in errors:
flash(error, 'warning')
return redirect(url_for('admin.https_config'))
# Update configuration
config = HTTPSConfig.create_or_update(
https_enabled=https_enabled,
hostname=hostname if https_enabled else None,
domain=domain if https_enabled else None,
ip_address=ip_address if https_enabled else None,
email=email if https_enabled else None,
port=port if https_enabled else 443,
updated_by=current_user.username
)
# Generate and update Caddyfile
try:
caddyfile_content = CaddyConfigGenerator.generate_caddyfile(config)
if CaddyConfigGenerator.write_caddyfile(caddyfile_content):
# Reload Caddy configuration
if CaddyConfigGenerator.reload_caddy():
caddy_status = '✅ Caddy configuration updated successfully!'
log_action('info', f'Caddy configuration reloaded by {current_user.username}')
else:
caddy_status = '⚠️ Caddyfile updated but reload failed. Please restart containers.'
log_action('warning', f'Caddy reload failed for {current_user.username}')
else:
caddy_status = '⚠️ Configuration saved but Caddyfile update failed.'
log_action('warning', f'Caddyfile write failed for {current_user.username}')
except Exception as caddy_error:
caddy_status = f'⚠️ Configuration saved but Caddy update failed: {str(caddy_error)}'
log_action('error', f'Caddy update error: {str(caddy_error)}')
if https_enabled:
log_action('info', f'HTTPS enabled by {current_user.username}: domain={domain}, hostname={hostname}, ip={ip_address}, email={email}')
flash(f'✅ HTTPS configuration saved successfully!\n{caddy_status}\nServer available at https://{domain}', 'success')
else:
log_action('info', f'HTTPS disabled by {current_user.username}')
flash(f'✅ HTTPS has been disabled. Server running on HTTP only.\n{caddy_status}', 'success')
return redirect(url_for('admin.https_config'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating HTTPS config: {str(e)}')
flash(f'Error updating HTTPS configuration: {str(e)}', 'danger')
return redirect(url_for('admin.https_config'))
@admin_bp.route('/https-config/status')
@login_required
@admin_required
def https_config_status():
"""Get current HTTPS configuration status as JSON."""
try:
config = HTTPSConfig.get_config()
if config:
return jsonify(config.to_dict())
else:
return jsonify({
'https_enabled': False,
'hostname': None,
'domain': None,
'ip_address': None,
'port': 443,
})
except Exception as e:
log_action('error', f'Error getting HTTPS status: {str(e)}')
return jsonify({'error': str(e)}), 500
+878
View File
@@ -0,0 +1,878 @@
"""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
import bcrypt
from typing import Optional, Dict, List
from app.extensions import db, cache
from app.models import Player, 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('/certificate', methods=['GET'])
def get_server_certificate():
"""Get server SSL certificate."""
return jsonify({'test': 'certificate_endpoint_works'}), 200
@api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=120, window=60)
def authenticate_player():
"""Authenticate a player and return auth code and configuration.
Request JSON:
hostname: Player hostname/identifier (required)
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
JSON with auth_code, player_id, group_id, and configuration
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
hostname = data.get('hostname')
password = data.get('password')
quickconnect_code = data.get('quickconnect_code')
if not hostname:
return jsonify({'error': 'Hostname is required'}), 400
if not password and not quickconnect_code:
return jsonify({'error': 'Password or quickconnect code required'}), 400
# Authenticate player
player = Player.authenticate(hostname, password, quickconnect_code)
if not player:
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
return jsonify({'error': 'Invalid credentials'}), 401
# Update player status
player.update_status('online')
db.session.commit()
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
# Return authentication response
response = {
'success': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'auth_code': player.auth_code,
'playlist_id': player.playlist_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/auth/verify', methods=['POST'])
@rate_limit(max_requests=300, window=60)
def verify_auth_code():
"""Verify an auth code and return player information.
Request JSON:
auth_code: Player authentication code
Returns:
JSON with player information if valid
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
auth_code = data.get('auth_code')
if not auth_code:
return jsonify({'error': 'Auth code is required'}), 400
# Find player with this auth code
player = Player.query.filter_by(auth_code=auth_code).first()
if not player:
return jsonify({'error': 'Invalid auth code'}), 401
# Update last seen
player.update_status(player.status)
db.session.commit()
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'playlist_id': player.playlist_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/playlists', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def get_playlist_by_quickconnect():
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
Query parameters:
hostname: Player hostname/identifier
quickconnect_code: Quick connect code for authentication
Returns:
JSON with playlist, playlist_version, and hashed_quickconnect
"""
try:
import bcrypt
hostname = request.args.get('hostname')
quickconnect_code = request.args.get('quickconnect_code')
if not hostname or not quickconnect_code:
return jsonify({
'error': 'hostname and quickconnect_code are required',
'playlist': [],
'playlist_version': 0
}), 400
# Find player by hostname and validate quickconnect
player = Player.query.filter_by(hostname=hostname).first()
if not player:
log_action('warning', f'Player not found with hostname: {hostname}')
return jsonify({
'error': 'Player not found',
'playlist': [],
'playlist_version': 0
}), 404
# Validate quickconnect code
if not player.quickconnect_code:
log_action('warning', f'Player {hostname} has no quickconnect code set')
return jsonify({
'error': 'Quickconnect not configured',
'playlist': [],
'playlist_version': 0
}), 403
# Check if quickconnect matches (using bcrypt verification)
if not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
return jsonify({
'error': 'Invalid quickconnect code',
'playlist': [],
'playlist_version': 0
}), 403
# Get playlist (with caching)
playlist = get_cached_playlist(player.id)
# Update player's last seen timestamp and status
player.last_seen = datetime.utcnow()
player.status = 'online'
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
# Hash the quickconnect code for validation on client side
hashed_quickconnect = bcrypt.hashpw(
quickconnect_code.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
return jsonify({
'player_id': player.id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'playlist': playlist,
'hashed_quickconnect': hashed_quickconnect,
'count': len(playlist)
}), 200
except Exception as e:
log_action('error', f'Error getting playlist: {str(e)}')
return jsonify({
'error': 'Internal server error',
'playlist': [],
'playlist_version': 0
}), 500
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=300, 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()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
return jsonify({
'player_id': player_id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'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
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
@verify_player_auth
@rate_limit(max_requests=60, window=60)
def get_playlist_version(player_id: int):
"""Get current playlist version for a player.
Lightweight endpoint for players to check if playlist needs updating.
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
# Update last seen
player.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'player_id': player_id,
'playlist_version': player.playlist_version,
'content_count': Content.query.filter_by(player_id=player_id).count()
})
except Exception as e:
log_action('error', f'Error getting playlist version 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 based on assigned playlist."""
from flask import url_for
from app.models import Playlist
player = Player.query.get(player_id)
if not player or not player.playlist_id:
return []
# Get the playlist assigned to this player
playlist = Playlist.query.get(player.playlist_id)
if not playlist:
return []
# Get content from playlist (ordered)
content_list = playlist.get_content_ordered()
# Build playlist response
playlist_data = []
for idx, content in enumerate(content_list, start=1):
# Generate full URL for content.
# request.script_root holds the X-Script-Name prefix set by the umbrella
# nginx (e.g. '/digiserver'), so the URL is correct whether the app runs
# standalone or behind the portal reverse proxy.
from flask import request as current_request
server_base = current_request.host_url.rstrip('/')
script_root = current_request.script_root.rstrip('/')
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
playlist_data.append({
'id': content.id,
'file_name': content.filename, # Player expects 'file_name' not 'filename'
'type': content.content_type,
'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads
'description': content.description,
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
})
return playlist_data
@api_bp.route('/player-feedback', methods=['POST'])
@rate_limit(max_requests=600, window=60)
def receive_player_feedback():
"""Receive feedback/status updates from players (Kivy player compatible).
Expected JSON payload:
{
"player_name": "Screen1",
"quickconnect_code": "ABC123",
"status": "playing|paused|error|restarting",
"message": "Status message",
"playlist_version": 1,
"error_details": "Optional error details",
"timestamp": "ISO timestamp"
}
"""
try:
data = request.json
if not data:
log_action('warning', 'Player feedback received with no data')
return jsonify({'error': 'No data provided'}), 400
player_name = data.get('player_name')
hostname = data.get('hostname') # Also accept hostname
quickconnect_code = data.get('quickconnect_code')
# Find player by hostname first (more reliable), then by name
player = None
if hostname:
player = Player.query.filter_by(hostname=hostname).first()
if not player and player_name:
player = Player.query.filter_by(name=player_name).first()
# If player not found and no credentials provided, try to infer from IP and recent auth
if not player and (not quickconnect_code or (not player_name and not hostname)):
# Try to find player by recent authentication from same IP
client_ip = request.remote_addr
# Look for players with matching IP in recent activity (last 5 minutes)
recent_time = datetime.utcnow() - timedelta(minutes=5)
possible_player = Player.query.filter(
Player.last_seen >= recent_time
).order_by(Player.last_seen.desc()).first()
if possible_player:
player = possible_player
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
# Still require quickconnect validation if provided
if not player:
if not player_name and not hostname:
log_action('warning', f'Player feedback missing required fields. Data: {data}')
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
else:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
if not player:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
# Validate quickconnect code if provided (using bcrypt verification)
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
return jsonify({'error': 'Invalid quickconnect code'}), 403
# Create feedback record
status = data.get('status', 'unknown')
message = data.get('message', '')
error_details = data.get('error_details')
feedback = PlayerFeedback(
player_id=player.id,
status=status,
message=message,
error=error_details
)
db.session.add(feedback)
# Update player's last seen and status
player.last_seen = datetime.utcnow()
player.status = status
db.session.commit()
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
return jsonify({
'success': True,
'message': 'Feedback received',
'player_id': player.id
}), 200
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
# DEPRECATED: Groups functionality has been archived
# @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.route('/player-edit-media', methods=['POST'])
@rate_limit(max_requests=60, window=60)
@verify_player_auth
def receive_edited_media():
"""Receive edited media from player.
Expected multipart/form-data:
- image_file: The edited image file
- metadata: JSON string with metadata
Metadata JSON structure:
{
"time_of_modification": "ISO timestamp",
"original_name": "original_file.jpg",
"new_name": "original_file_v1.jpg",
"version": 1,
"user": "player_user"
}
"""
try:
player = request.player
# Check if file is present
if 'image_file' not in request.files:
return jsonify({'error': 'No image file provided'}), 400
file = request.files['image_file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
# Get metadata
import json
metadata_str = request.form.get('metadata')
if not metadata_str:
return jsonify({'error': 'No metadata provided'}), 400
try:
metadata = json.loads(metadata_str)
except json.JSONDecodeError:
return jsonify({'error': 'Invalid metadata JSON'}), 400
# Validate required metadata fields
required_fields = ['time_of_modification', 'original_name', 'new_name', 'version']
for field in required_fields:
if field not in metadata:
return jsonify({'error': f'Missing required field: {field}'}), 400
# Import required modules
import os
from werkzeug.utils import secure_filename
from app.models.player_edit import PlayerEdit
# Find the original content by filename
original_name = metadata['original_name']
content = Content.query.filter_by(filename=original_name).first()
if not content:
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
return jsonify({'error': f'Original content not found: {original_name}'}), 404
# Create versioned folder structure: edited_media/<content_id>/
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
os.makedirs(edited_media_dir, exist_ok=True)
# Save the edited file with version suffix
version = metadata['version']
new_filename = metadata['new_name']
edited_file_path = os.path.join(edited_media_dir, new_filename)
file.save(edited_file_path)
# Save metadata JSON file
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
metadata_path = os.path.join(edited_media_dir, metadata_filename)
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Update the content record to reference the edited version path
# Keep original filename unchanged, point to edited_media folder
old_filename = content.filename
content.filename = f"edited_media/{content.id}/{new_filename}"
# Create edit record
time_of_mod = None
if metadata.get('time_of_modification'):
try:
time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00'))
except:
time_of_mod = datetime.utcnow()
# Auto-create PlayerUser record if user code is provided
user_code = metadata.get('user_card_data')
log_action('debug', f'Metadata user code: {user_code}')
if user_code:
from app.models.player_user import PlayerUser
existing_user = PlayerUser.query.filter_by(user_code=user_code).first()
if not existing_user:
new_user = PlayerUser(user_code=user_code)
db.session.add(new_user)
log_action('info', f'Auto-created PlayerUser record for code: {user_code}')
else:
log_action('debug', f'PlayerUser already exists for code: {user_code}')
else:
log_action('debug', 'No user code in metadata')
edit_record = PlayerEdit(
player_id=player.id,
content_id=content.id,
original_name=original_name,
new_name=new_filename,
version=version,
user=user_code,
time_of_modification=time_of_mod,
metadata_path=metadata_path,
edited_file_path=edited_file_path
)
db.session.add(edit_record)
# Update playlist version to force player refresh
playlist = None
if player.playlist_id:
from app.models.playlist import Playlist
playlist = db.session.get(Playlist, player.playlist_id)
if playlist:
playlist.version += 1
# Clear playlist cache
cache.delete_memoized(get_cached_playlist, player.id)
db.session.commit()
log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})')
return jsonify({
'success': True,
'message': 'Edited media received and processed',
'edit_id': edit_record.id,
'version': version,
'old_filename': old_filename,
'new_filename': new_filename,
'new_playlist_version': playlist.version if playlist else None
}), 200
except Exception as e:
db.session.rollback()
log_action('error', f'Error receiving edited media: {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
+63
View File
@@ -0,0 +1,63 @@
"""
Authentication Blueprint - Login, Logout
User management is handled exclusively by the Enterprise Digital Platform portal.
Direct registration and local user creation are disabled.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import login_user, logout_user, login_required, current_user
from app.extensions import db, bcrypt, login_manager
from app.models import User
from app.utils.logger import log_action
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""
Login handler.
When accessed through the portal nginx gateway the portal_sso.py before_request
hook already logs the user in and redirects to the dashboard — this handler is
only reached if someone accesses DigiServer directly (bypassing the gateway).
In that case we redirect them to the portal login page.
"""
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# If there are X-Auth-Username headers the SSO hook should have handled this
# already. If we still end up here the user has no portal session — send them
# to the portal login so they can authenticate through the proper gateway.
portal_login = current_app.config.get('PORTAL_LOGIN_URL', '/login')
return redirect(portal_login)
@auth_bp.route('/logout')
@login_required
def logout():
"""User logout"""
username = current_user.username
logout_user()
log_action('info', 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():
"""
Self-registration is disabled — users are managed exclusively by the portal.
Redirect to the portal login page.
"""
portal_login = current_app.config.get('PORTAL_LOGIN_URL', '/login')
return redirect(portal_login)
@auth_bp.route('/change-password', methods=['GET', 'POST'])
@login_required
def change_password():
"""
Password changes are managed by the portal.
Passwords for portal-managed users are randomly generated and not user-facing.
"""
flash('Password management is handled through the Enterprise Digital Platform portal.', 'info')
return redirect(url_for('main.dashboard'))
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
"""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:
# Get all unique content files (by filename)
from sqlalchemy import func
# Get content with player information
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
# Group content by filename to show which players have each file
content_map = {}
for content in contents:
if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
# Add player info if assigned to a player
if content.player_id:
from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
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':
# Get parameters for return URL and pre-selection
player_id = request.args.get('player_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players for selection
from app.models import Player
players = Player.query.order_by(Player.name).all()
return render_template('content/upload_content.html',
players=players,
selected_player_id=player_id,
return_url=return_url)
try:
# Get form data
player_id = request.form.get('player_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content'))
if not player_id:
flash('Please select a player.', 'warning')
return redirect(url_for('content.upload_content'))
# Initialize progress tracking using shared utility
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
# Process each file
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
# 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 to Raspberry Pi optimized format)
set_upload_progress(session_id, progress_pct + 5,
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf':
content_type = 'pdf'
# Process PDF (convert to images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation'
# Process presentation (convert to PDF then images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility
else:
content_type = 'other'
# Create content record linked to player
from app.models import Player
player = Player.query.get(player_id)
if player:
new_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player_id
)
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit()
# Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches
cache.clear()
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(return_url)
except Exception as e:
db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
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('content/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('/delete-by-filename', methods=['POST'])
@login_required
def delete_by_filename():
"""Delete all content entries with a specific filename."""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({'success': False, 'message': 'No filename provided'}), 400
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
return jsonify({
'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)',
'deleted_count': deleted_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@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
+89
View File
@@ -0,0 +1,89 @@
"""
Internal blueprint — service-to-service endpoints.
These routes are NOT exposed through nginx to the public internet.
They are called from the portal when it needs to pre-provision users.
Protected with a shared secret in the X-Internal-Token header.
"""
import os
import secrets as _secrets
from flask import Blueprint, request, jsonify, current_app
from app.extensions import db, bcrypt
internal_bp = Blueprint('internal', __name__, url_prefix='/internal')
def _check_token():
"""Return True if the request carries a valid internal sync token."""
expected = current_app.config.get('INTERNAL_SYNC_SECRET', '')
provided = request.headers.get('X-Internal-Token', '')
if not expected or not provided:
return False
# Constant-time comparison to prevent timing attacks
return _secrets.compare_digest(expected, provided)
@internal_bp.route('/sync-user', methods=['POST'])
def sync_user():
"""
Create or update a portal-managed user in DigiServer's local DB.
Request JSON:
{
"username": "<portal username>",
"role": "admin" | "user"
}
"""
if not _check_token():
return jsonify({'error': 'forbidden'}), 403
data = request.get_json(silent=True) or {}
username = (data.get('username') or '').strip()
role_raw = (data.get('role') or 'user').strip()
role = 'admin' if role_raw == 'admin' else 'user'
if not username:
return jsonify({'error': 'username required'}), 400
from app.models.user import User
try:
user = User.query.filter_by(username=username).first()
if user:
if user.role != role:
user.role = role
db.session.commit()
status = 'updated'
else:
pw_hash = bcrypt.generate_password_hash(
_secrets.token_hex(32)
).decode('utf-8')
user = User(username=username, password=pw_hash, role=role)
db.session.add(user)
db.session.commit()
status = 'created'
return jsonify({'status': status, 'username': username, 'role': role}), 200
except Exception as exc:
db.session.rollback()
current_app.logger.error('sync-user error: %s', exc)
return jsonify({'error': 'internal error'}), 500
@internal_bp.route('/users', methods=['GET'])
def list_users():
"""Return a list of portal-managed users (for portal Settings UI)."""
if not _check_token():
return jsonify({'error': 'forbidden'}), 403
from app.models.user import User
users = User.query.order_by(User.username).all()
return jsonify([
{
'username': u.username,
'role': u.role,
'last_login': u.last_login.isoformat() if u.last_login else None,
}
for u in users
]), 200
+80
View File
@@ -0,0 +1,80 @@
"""
Main Blueprint - Dashboard and Home Routes
"""
from flask import Blueprint, render_template, redirect, url_for
from flask_login import login_required, current_user
from app.extensions import db, cache
from app.models.player import Player
from app.models.playlist import Playlist
from app.models.content import Content
from app.utils.logger import get_recent_logs
import os
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"""
# Get statistics
total_players = Player.query.count()
total_playlists = Playlist.query.count()
total_content = Content.query.count()
# Calculate storage usage
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
storage_mb = 0
if os.path.exists(upload_folder):
for filename in os.listdir(upload_folder):
filepath = os.path.join(upload_folder, filename)
if os.path.isfile(filepath):
storage_mb += os.path.getsize(filepath)
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
server_logs = get_recent_logs(20)
return render_template(
'dashboard.html',
total_players=total_players,
total_playlists=total_playlists,
total_content=total_content,
storage_mb=storage_mb,
recent_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
+612
View File
@@ -0,0 +1,612 @@
"""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, Content, PlayerFeedback, Playlist
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('/')
@players_bp.route('/list')
@login_required
def list():
"""Display list of all players."""
try:
players = Player.query.order_by(Player.name).all()
playlists = Playlist.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/players_list.html',
players=players,
playlists=playlists,
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':
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
return render_template('players/add_player.html', playlists=playlists)
try:
name = request.form.get('name', '').strip()
hostname = request.form.get('hostname', '').strip()
location = request.form.get('location', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
orientation = request.form.get('orientation', 'Landscape')
playlist_id = request.form.get('playlist_id', '').strip()
# 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'))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.add_player'))
# Check if hostname already exists
existing_player = Player.query.filter_by(hostname=hostname).first()
if existing_player:
flash(f'A player with hostname "{hostname}" already exists.', 'warning')
return redirect(url_for('players.add_player'))
if not quickconnect_code:
flash('Quick Connect Code is required.', 'warning')
return redirect(url_for('players.add_player'))
# Generate unique auth code
auth_code = secrets.token_urlsafe(32)
# Create player
new_player = Player(
name=name,
hostname=hostname,
location=location or None,
auth_code=auth_code,
orientation=orientation,
playlist_id=int(playlist_id) if playlist_id else None
)
# Set password if provided
if password:
new_player.set_password(password)
else:
# Use quickconnect code as default password
new_player.set_password(quickconnect_code)
# Set quickconnect code
new_player.set_quickconnect_code(quickconnect_code)
db.session.add(new_player)
db.session.commit()
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
# Flash detailed success message
success_msg = f'''
Player "{name}" created successfully!<br>
<strong>Auth Code:</strong> {auth_code}<br>
<strong>Hostname:</strong> {hostname}<br>
<strong>Quick Connect:</strong> {quickconnect_code}<br>
<small>Configure the player with these credentials in app_config.json</small>
'''
flash(success_msg, 'success')
return redirect(url_for('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':
return render_template('players/edit_player.html', player=player)
try:
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
# 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
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.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.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.list'))
@players_bp.route('/<int:player_id>')
@login_required
def player_page(player_id: int):
"""Redirect to manage player page (combined view)."""
return redirect(url_for('players.manage_player', player_id=player_id))
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
@login_required
def manage_player(player_id: int):
"""Manage player - edit credentials, assign playlist, view logs."""
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
action = request.form.get('action')
try:
if action == 'update_credentials':
# Update player name, location, orientation, and authentication
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
orientation = request.form.get('orientation', 'Landscape')
hostname = request.form.get('hostname', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Check if hostname is taken by another player
if hostname != player.hostname:
existing = Player.query.filter_by(hostname=hostname).first()
if existing:
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Update basic info
player.name = name
player.hostname = hostname
player.location = location or None
player.orientation = orientation
# Update password if provided
if password:
player.set_password(password)
log_action('info', f'Password updated for player "{name}"')
# Update quickconnect code if provided
if quickconnect_code:
player.set_quickconnect_code(quickconnect_code)
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
db.session.commit()
log_action('info', f'Player "{name}" (hostname: {hostname}) credentials updated')
flash(f'Player "{name}" updated successfully.', 'success')
elif action == 'assign_playlist':
# Assign playlist to player
playlist_id = request.form.get('playlist_id')
if playlist_id:
playlist = Playlist.query.get(int(playlist_id))
if playlist:
player.playlist_id = int(playlist_id)
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"')
flash(f'Player assigned to playlist "{playlist.name}".', 'success')
else:
flash('Invalid playlist selected.', 'warning')
else:
# Unassign playlist
player.playlist_id = None
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player.name}" unassigned from playlist')
flash('Player unassigned from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error managing player: {str(e)}')
flash('Error updating player. Please try again.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
# GET request - show manage page
playlists = Playlist.query.order_by(Playlist.name).all()
# Get player's current playlist
current_playlist = None
if player.playlist_id:
current_playlist = Playlist.query.get(player.playlist_id)
# Get recent feedback/logs from player
recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.limit(20)\
.all()
# Get edited media history from player
from app.models.player_edit import PlayerEdit
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.limit(20)\
.all()
# Get player status
status_info = get_player_status_info(player_id)
return render_template('players/manage_player.html',
player=player,
playlists=playlists,
current_playlist=current_playlist,
recent_logs=recent_logs,
edited_media=edited_media,
status_info=status_info)
@players_bp.route('/<int:player_id>/edited-media')
@login_required
def edited_media(player_id: int):
"""Display all edited media files from this player."""
try:
player = Player.query.get_or_404(player_id)
# Get all edited media history from player
from app.models.player_edit import PlayerEdit
from app.models.player_user import PlayerUser
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.all()
# Get original content files for each edited media
content_files = {}
for edit in edited_media:
if edit.content_id not in content_files:
content = Content.query.get(edit.content_id)
if content:
content_files[edit.content_id] = content
# Get user mappings for display names
user_mappings = {}
for edit in edited_media:
if edit.user and edit.user not in user_mappings:
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
if player_user:
user_mappings[edit.user] = player_user.user_name or edit.user
else:
user_mappings[edit.user] = edit.user
return render_template('players/edited_media.html',
player=player,
edited_media=edited_media,
content_files=content_files,
user_mappings=user_mappings)
except Exception as e:
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
flash('Error loading edited media.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
@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('players/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 assigned playlist.
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 or not player.playlist_id:
return []
# Get the player's assigned playlist
playlist_obj = Playlist.query.get(player.playlist_id)
if not playlist_obj:
return []
# Get ordered content from the playlist
ordered_content = playlist_obj.get_content_ordered()
# Build playlist
playlist = []
for content in ordered_content:
playlist.append({
'id': content.id,
'url': url_for('static', filename=f'uploads/{content.filename}'),
'type': content.content_type,
'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': getattr(content, '_playlist_position', 0),
'muted': getattr(content, '_playlist_muted', True),
'filename': content.filename
})
return playlist
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_content(player_id: int):
"""Legacy endpoint - Content reordering now handled in playlist management."""
return jsonify({
'success': False,
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
}), 400
@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-playlist', methods=['POST'])
@login_required
def bulk_assign_playlist():
"""Assign multiple players to a playlist."""
try:
player_ids = request.json.get('player_ids', [])
playlist_id = request.json.get('playlist_id')
if not player_ids:
return jsonify({'success': False, 'error': 'No players selected'}), 400
# Validate playlist
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if not playlist:
return jsonify({'success': False, 'error': 'Invalid playlist'}), 400
# Assign players
updated_count = 0
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
player.playlist_id = playlist_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 playlist {playlist_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
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder items in player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
direction = data.get('direction') # 'up' or 'down'
if not content_id or not direction:
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
# Get all content for this player, ordered by position
all_content = Content.query.filter_by(player_id=player_id)\
.order_by(Content.position, Content.uploaded_at).all()
# Find current index
current_index = None
for idx, item in enumerate(all_content):
if item.id == content_id:
current_index = idx
break
if current_index is None:
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
# Swap positions
if direction == 'up' and current_index > 0:
# Swap with previous item
all_content[current_index].position, all_content[current_index - 1].position = \
all_content[current_index - 1].position, all_content[current_index].position
elif direction == 'down' and current_index < len(all_content) - 1:
# Swap with next item
all_content[current_index].position, all_content[current_index + 1].position = \
all_content[current_index + 1].position, all_content[current_index].position
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Reordered playlist for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int):
"""Remove content from player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
if not content_id:
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
filename = content.filename
# Delete from database
db.session.delete(content)
# Increment playlist version
player = Player.query.get(player_id)
if player:
player.playlist_version += 1
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
+310
View File
@@ -0,0 +1,310 @@
"""Playlist blueprint for managing player playlists."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from sqlalchemy import desc, update
import os
from app.extensions import db, cache
from app.models import Player, Content, Playlist
from app.models.playlist import playlist_content
from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@playlist_bp.route('/<int:player_id>')
@login_required
def manage_playlist(player_id: int):
"""Legacy route - redirect to new content management area."""
player = Player.query.get_or_404(player_id)
if player.playlist_id:
# Redirect to the new content management interface
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
else:
# Player has no playlist assigned
flash('This player has no playlist assigned.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@login_required
def add_to_playlist(player_id: int):
"""Add content to player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not content_id:
flash('Please select content.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
# Get max position
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist.id
)
).scalar() or 0
# Add to playlist_content association table
stmt = playlist_content.insert().values(
playlist_id=playlist.id,
content_id=content.id,
position=max_pos + 1,
duration=duration
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding to playlist: {str(e)}')
flash('Error adding to playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int, content_id: int):
"""Remove content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
filename = content.filename
# Remove from playlist_content association table
from sqlalchemy import delete
stmt = delete(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Reorder remaining content
from sqlalchemy import select
remaining = db.session.execute(
select(playlist_content.c.content_id, playlist_content.c.position).where(
playlist_content.c.playlist_id == playlist.id
).order_by(playlist_content.c.position)
).fetchall()
for idx, row in enumerate(remaining, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == row.content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
flash(f'Removed "{filename}" from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
flash('Error removing from playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder playlist items."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
# Get new order from JSON
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions in association table
for idx, content_id in enumerate(content_ids, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
@login_required
def update_duration(player_id: int, content_id: int):
"""Update content duration in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
duration = request.form.get('duration', type=int)
if not duration or duration < 1:
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
# Update duration in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Duration updated',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating duration: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_muted(player_id: int, content_id: int):
"""Update content muted setting in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
@login_required
def clear_playlist(player_id: int):
"""Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
playlist = Playlist.query.get(player.playlist_id)
# Delete all content from playlist
from sqlalchemy import delete
stmt = delete(playlist_content).where(
playlist_content.c.playlist_id == playlist.id
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Cleared playlist for player "{player.name}"')
flash('Playlist cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing playlist: {str(e)}')
flash('Error clearing playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))