Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Blueprints package initialization
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user