Files
digiserver-v2/app/blueprints/admin.py
T
ske087 6b0e51898c Make player token reset non-breaking (deferred rotation)
The maintenance-page reset used to rotate auth_code immediately. Because the
token also guards the playlist endpoints, that stopped a player from syncing
content the instant the button was pressed -- before the player ever had a
chance to react -- and clients that do not understand the heartbeat's
401 auth_required would have stayed broken until redeployed.

Split the model behaviour:

* request_reauth()  -- sets the flag only (used by the admin reset endpoints
  and the automatic interval). The current token stays valid, so there is no
  outage while the player is being asked to re-authenticate.
* issue_new_token() -- replaces the token now (used by the existing
  "regenerate auth code" action, where the admin is shown the new value).
* mark_authenticated() -- if a reset was pending, this is where the old token
  is retired: a new token is issued and returned to the player in the
  /api/auth/player response.

Net effect: the reset is a request the player acts on, never a forced logout.
A player that ignores it keeps working and shows as "awaiting re-auth" in the
UI; one that handles it gets a fresh token and the old one stops working.

Maintenance page wording updated to match (request vs reset; "awaiting
re-auth" badge), plus a themed info note explaining it is not a forced logout.

Adds docs/player-auth-reset.md describing the protocol and exactly what the
player client (Kiwy-Signage) still needs to implement: detect 401 /
auth_required on the heartbeat (and on the token-guarded endpoints), re-run
POST /api/auth/player with the stored quick-connect code, save the new
auth_code and retry. No password or SSH redeploy required.

Verified end-to-end: token unchanged and still accepted after the request
(200), heartbeat answers 401 auth_required=token_reset, re-auth issues a
different token, the old token is then rejected (403) and the new one accepted
(200), flag cleared; the interval path only flags without rotating.
2026-10-14 23:14:11 +03:00

1660 lines
63 KiB
Python

"""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
import shutil
from datetime import datetime
from typing import Optional
from sqlalchemy import text
from app.extensions import db, bcrypt
from app.models import (User, Player, Content, ServerLog, Playlist, HTTPSConfig,
PlayerAuthConfig)
from app.utils.logger import log_action
from app.utils.caddy_manager import CaddyConfigGenerator
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():
"""Create a new user account."""
try:
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
role = request.form.get('role', 'user').strip()
# Validation
if not username or len(username) < 3:
flash('Username must be at least 3 characters long.', 'warning')
return redirect(url_for('admin.admin_panel'))
if not password or len(password) < 6:
flash('Password must be at least 6 characters long.', 'warning')
return redirect(url_for('admin.admin_panel'))
if role not in ['user', 'admin']:
flash('Invalid role specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Check if username exists
existing_user = User.query.filter_by(username=username).first()
if existing_user:
flash(f'Username "{username}" already exists.', 'warning')
return redirect(url_for('admin.admin_panel'))
# Create user
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
new_user = User(username=username, password=hashed_password, role=role)
db.session.add(new_user)
db.session.commit()
log_action('info', f'User {username} created by admin {current_user.username}')
flash(f'User "{username}" created successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error creating user: {str(e)}')
flash('Error creating user. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/role', methods=['POST'])
@login_required
@admin_required
def change_user_role(user_id: int):
"""Change user role between user and admin."""
try:
user = User.query.get_or_404(user_id)
new_role = request.form.get('role', '').strip()
# Validation
if new_role not in ['user', 'admin']:
flash('Invalid role specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Prevent changing own role
if user.id == current_user.id:
flash('You cannot change your own role.', 'warning')
return redirect(url_for('admin.admin_panel'))
old_role = user.role
user.role = new_role
db.session.commit()
log_action('info', f'User {user.username} role changed from {old_role} to {new_role} by {current_user.username}')
flash(f'User "{user.username}" role changed to {new_role}.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error changing user role: {str(e)}')
flash('Error changing user role. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id: int):
"""Delete a user account."""
try:
user = User.query.get_or_404(user_id)
# Prevent deleting own account
if user.id == current_user.id:
flash('You cannot delete your own account.', 'warning')
return redirect(url_for('admin.admin_panel'))
username = user.username
db.session.delete(user)
db.session.commit()
log_action('info', f'User {username} deleted by admin {current_user.username}')
flash(f'User "{username}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting user: {str(e)}')
flash('Error deleting user. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/theme', methods=['POST'])
@login_required
@admin_required
def change_theme():
"""Change application theme."""
try:
theme = request.form.get('theme', 'light').strip()
if theme not in ['light', 'dark']:
flash('Invalid theme specified.', 'danger')
return redirect(url_for('admin.admin_panel'))
# Store theme preference (you can extend this to save to database)
# For now, just log the action
log_action('info', f'Theme changed to {theme} by {current_user.username}')
flash(f'Theme changed to {theme} mode.', 'success')
except Exception as e:
log_action('error', f'Error changing theme: {str(e)}')
flash('Error changing theme. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/logo/upload', methods=['POST'])
@login_required
@admin_required
def upload_logo():
"""Upload custom logo for application."""
try:
if 'logo' not in request.files:
flash('No logo file provided.', 'warning')
return redirect(url_for('admin.admin_panel'))
file = request.files['logo']
if file.filename == '':
flash('No file selected.', 'warning')
return redirect(url_for('admin.admin_panel'))
# Validate file type
allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'svg'}
filename = secure_filename(file.filename)
if not ('.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions):
flash('Invalid file type. Please upload an image file (PNG, JPG, GIF, SVG).', 'danger')
return redirect(url_for('admin.admin_panel'))
# Save logo
static_folder = current_app.config.get('STATIC_FOLDER', 'app/static')
logo_path = os.path.join(static_folder, 'logo.png')
# Create static folder if it doesn't exist
os.makedirs(static_folder, exist_ok=True)
file.save(logo_path)
log_action('info', f'Logo uploaded by admin {current_user.username}')
flash('Logo uploaded successfully.', 'success')
except Exception as e:
log_action('error', f'Error uploading logo: {str(e)}')
flash('Error uploading logo. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/logs/clear', methods=['POST'])
@login_required
@admin_required
def clear_logs():
"""Clear all server logs."""
try:
ServerLog.query.delete()
db.session.commit()
log_action('info', f'All logs cleared by admin {current_user.username}')
flash('All logs cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing logs: {str(e)}')
flash('Error clearing logs. Please try again.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/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)')
return render_template('admin/https_config.html',
config=config,
is_https_active=is_https_active,
current_host=current_host)
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
# ============================================================================
# Player Build & Deployment Routes
# ============================================================================
def _player_build_meta_path() -> str:
"""Path to the persisted player-build settings (in the instance folder)."""
from app.utils.player_build import BUILD_META_FILENAME
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
@admin_bp.route('/build-player', methods=['GET'])
@login_required
@admin_required
def build_player():
"""Display the 'Build player files for deployment' admin page."""
from app.utils.ssh_deploy import get_local_player_code_status
from app.utils.player_build import load_build_settings
player_code_dir = current_app.config['PLAYER_CODE_DIR']
settings = load_build_settings(_player_build_meta_path()) or {}
# Prefill server address from saved settings, else from HTTPS config.
if not settings.get('server_ip'):
https_cfg = HTTPSConfig.get_config()
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
settings.setdefault('port', str(https_cfg.port or 443))
settings.setdefault('use_https', bool(https_cfg.https_enabled))
# Sensible defaults.
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
settings.setdefault('branch', 'main')
default_host = request.host.split(':')[0]
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
from app.utils.ssh_deploy import detect_server_ip
default_host = detect_server_ip() or default_host
settings.setdefault('server_ip', default_host)
settings.setdefault('port', '443')
settings.setdefault('use_https', True)
settings.setdefault('verify_ssl', False)
settings.setdefault('orientation', 'Landscape')
settings.setdefault('max_resolution', '1920x1080')
code_status = get_local_player_code_status(player_code_dir)
if code_status.get('updated'):
code_status['updated_str'] = datetime.fromtimestamp(
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
return render_template(
'admin/build_player.html',
settings=settings,
code_status=code_status,
player_code_dir=player_code_dir,
)
@admin_bp.route('/build-player', methods=['POST'])
@login_required
@admin_required
def build_player_action():
"""Start building/refreshing the staged player code.
The build runs in a background thread because a full clone of the player
repository takes far longer than gunicorn's worker timeout; running it
in-request would get the worker killed mid-clone and leave a broken
checkout. The page then polls ``admin.build_player_status`` for progress.
"""
from app.utils.player_build import start_background_build, is_build_running
player_code_dir = current_app.config['PLAYER_CODE_DIR']
action = request.form.get('action', 'build_and_config')
repo_url = request.form.get('repo_url', '').strip()
branch = request.form.get('branch', 'main').strip() or 'main'
server_ip = request.form.get('server_ip', '').strip()
port = request.form.get('port', '443').strip()
use_https = request.form.get('use_https') == 'on'
verify_ssl = request.form.get('verify_ssl') == 'on'
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
# Validation (unchanged — fail fast before starting any work)
errors = []
if action in ('build_files', 'build_and_config') and not repo_url:
errors.append('Repository URL is required to build player files.')
if action in ('save_config', 'build_and_config') and not server_ip:
errors.append('Server IP / domain is required for the player configuration.')
try:
int(port)
except ValueError:
errors.append('Port must be a valid number.')
if errors:
for err in errors:
flash(err, 'warning')
return redirect(url_for('admin.build_player'))
# 'save_config' only writes the config file — it touches no network and is
# fast, so it stays synchronous.
if action == 'save_config':
from app.utils.player_build import (
write_base_config, get_short_head, save_build_settings, make_build_record,
)
cfg_result = write_base_config(
player_code_dir=player_code_dir,
server_ip=server_ip, port=port, use_https=use_https,
verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution,
)
version = get_short_head(player_code_dir)
save_build_settings(
_player_build_meta_path(),
make_build_record(
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution, version=version,
built_by=current_user.username,
),
)
if cfg_result['success']:
log_action('info', f'Player config saved by {current_user.username}')
flash(f"✅ {cfg_result['message']}", 'success')
else:
log_action('error', f'Player config write failed: {cfg_result["message"]}')
flash(f"⚠️ {cfg_result['message']}", 'danger')
return redirect(url_for('admin.build_player'))
# build_files / build_and_config → background thread.
if is_build_running():
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
return redirect(url_for('admin.build_player'))
config_payload = None
if action == 'build_and_config':
config_payload = {
'server_ip': server_ip, 'port': port, 'use_https': use_https,
'verify_ssl': verify_ssl, 'orientation': orientation,
'max_resolution': max_resolution,
}
started = start_background_build(
player_code_dir=player_code_dir,
repo_url=repo_url,
branch=branch,
config_payload=config_payload,
meta_path=_player_build_meta_path(),
built_by=current_user.username,
)
if not started:
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
else:
log_action('info', f'Player build started by {current_user.username} '
f'({branch} @ {repo_url})')
flash('⏳ Build started — this page will update automatically.', 'info')
return redirect(url_for('admin.build_player'))
@admin_bp.route('/build-player/status', methods=['GET'])
@login_required
@admin_required
def build_player_status():
"""JSON progress for the running/last player build (polled by the page)."""
from app.utils.player_build import get_build_state
return jsonify(get_build_state())
# ===========================================================================
# Maintenance console (admin only)
#
# Centralises the "housekeeping" operations that were previously only possible
# by hand on the server:
# * SQLite integrity / foreign-key verification (+ VACUUM/ANALYZE)
# * detection and removal of orphaned rows left behind by deleted players
# and content
# * cleanup of edited-media folders whose content row is gone
# * player authentication-token reset (manual, bulk, or on an interval)
# ===========================================================================
# Orphaned-row checks.
#
# Deleting a player or a content item does not always cascade everywhere:
# the ``player_feedback`` foreign key has no ON DELETE action, and rows created
# before the constraints existed are simply unenforced. Each entry below finds
# (``count``) or removes (``clean``) rows pointing at a parent that is gone.
# ``action`` only changes the label shown in the UI.
_ORPHAN_CHECKS = [
{
'key': 'feedback_missing_player',
'label': 'Player heartbeat feedback for deleted players',
'table': 'player_feedback',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM player_feedback f "
"LEFT JOIN player p ON f.player_id = p.id WHERE p.id IS NULL"),
'clean': ("DELETE FROM player_feedback "
"WHERE player_id NOT IN (SELECT id FROM player)"),
},
{
'key': 'feedback_missing_content',
'label': 'Feedback referencing deleted content (link cleared)',
'table': 'player_feedback',
'action': 'unlink',
'count': ("SELECT COUNT(*) FROM player_feedback f "
"LEFT JOIN content c ON f.current_content_id = c.id "
"WHERE f.current_content_id IS NOT NULL AND c.id IS NULL"),
'clean': ("UPDATE player_feedback SET current_content_id = NULL "
"WHERE current_content_id IS NOT NULL "
"AND current_content_id NOT IN (SELECT id FROM content)"),
},
{
'key': 'edits_missing_player',
'label': 'Player edits recorded for deleted players',
'table': 'player_edit',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM player_edit e "
"LEFT JOIN player p ON e.player_id = p.id WHERE p.id IS NULL"),
'clean': ("DELETE FROM player_edit "
"WHERE player_id NOT IN (SELECT id FROM player)"),
},
{
'key': 'edits_missing_content',
'label': 'Player edits recorded for deleted content',
'table': 'player_edit',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM player_edit e "
"LEFT JOIN content c ON e.content_id = c.id WHERE c.id IS NULL"),
'clean': ("DELETE FROM player_edit "
"WHERE content_id NOT IN (SELECT id FROM content)"),
},
{
'key': 'playlist_links_missing_content',
'label': 'Playlist entries pointing at deleted content',
'table': 'playlist_content',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM playlist_content pc "
"LEFT JOIN content c ON pc.content_id = c.id WHERE c.id IS NULL"),
'clean': ("DELETE FROM playlist_content "
"WHERE content_id NOT IN (SELECT id FROM content)"),
},
{
'key': 'playlist_links_missing_playlist',
'label': 'Playlist entries pointing at deleted playlists',
'table': 'playlist_content',
'action': 'delete',
'count': ("SELECT COUNT(*) FROM playlist_content pc "
"LEFT JOIN playlist pl ON pc.playlist_id = pl.id WHERE pl.id IS NULL"),
'clean': ("DELETE FROM playlist_content "
"WHERE playlist_id NOT IN (SELECT id FROM playlist)"),
},
{
'key': 'player_missing_playlist',
'label': 'Players assigned to deleted playlists (assignment cleared)',
'table': 'player',
'action': 'unlink',
'count': ("SELECT COUNT(*) FROM player p LEFT JOIN playlist pl "
"ON p.playlist_id = pl.id "
"WHERE p.playlist_id IS NOT NULL AND pl.id IS NULL"),
'clean': ("UPDATE player SET playlist_id = NULL "
"WHERE playlist_id IS NOT NULL "
"AND playlist_id NOT IN (SELECT id FROM playlist)"),
},
]
def _edited_media_root() -> str:
"""Absolute path to the per-content edited-media archive folder."""
return os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media')
def _dir_size_bytes(path: str) -> int:
"""Total size in bytes of all files under *path*."""
total = 0
for dirpath, _dirnames, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total += os.path.getsize(filepath)
return total
def _run_orphan_scan():
"""Run every orphan check and return ``(results, total_row_count)``.
Each check is isolated: a missing table or column in an older database is
reported as an error on that single check rather than aborting the scan.
"""
results = []
total = 0
for check in _ORPHAN_CHECKS:
entry = {
'key': check['key'],
'label': check['label'],
'table': check['table'],
'action': check['action'],
'count': 0,
'error': None,
}
try:
entry['count'] = int(
db.session.execute(text(check['count'])).scalar() or 0
)
total += entry['count']
except Exception as exc: # pragma: no cover - defensive
db.session.rollback()
entry['error'] = str(exc)
results.append(entry)
return results, total
def _scan_orphaned_edited_media() -> dict:
"""Find ``edited_media/<content_id>`` folders whose content row is gone."""
entry = {'count': 0, 'size_mb': 0.0, 'ids': [], 'error': None}
root = _edited_media_root()
if not os.path.isdir(root):
return entry
try:
valid_ids = {
row[0] for row in db.session.execute(text('SELECT id FROM content'))
}
size_total = 0
for name in sorted(os.listdir(root)):
path = os.path.join(root, name)
if not os.path.isdir(path) or not name.isdigit():
continue
if int(name) in valid_ids:
continue
size_total += _dir_size_bytes(path)
entry['ids'].append(int(name))
entry['count'] += 1
entry['size_mb'] = round(size_total / (1024 * 1024), 2)
except Exception as exc: # pragma: no cover - defensive
entry['error'] = str(exc)
return entry
@admin_bp.route('/maintenance')
@login_required
@admin_required
def maintenance():
"""Admin-only maintenance console."""
auth_config = PlayerAuthConfig.get_config()
players = Player.query.order_by(Player.name).all()
return render_template(
'admin/maintenance.html',
players=players,
auth_config=auth_config,
interval_days=PlayerAuthConfig.get_interval_days(),
)
@admin_bp.route('/maintenance/db-integrity', methods=['POST'])
@login_required
@admin_required
def maintenance_db_integrity():
"""Run SQLite integrity checks and return the results as JSON."""
try:
integrity = [
row[0] for row in
db.session.execute(text('PRAGMA integrity_check')).fetchall()
]
quick = [
row[0] for row in
db.session.execute(text('PRAGMA quick_check')).fetchall()
]
violations = [
{
'table': row[0],
'rowid': row[1],
'parent': row[2],
'constraint': row[3],
}
for row in db.session.execute(text('PRAGMA foreign_key_check')).fetchall()
]
page_count = db.session.execute(text('PRAGMA page_count')).scalar() or 0
page_size = db.session.execute(text('PRAGMA page_size')).scalar() or 0
freelist = db.session.execute(text('PRAGMA freelist_count')).scalar() or 0
db_path = db.engine.url.database
db_size_mb = None
if db_path and os.path.exists(db_path):
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
ok = integrity == ['ok'] and not violations
log_action('info',
f'Database integrity check by {current_user.username}: '
f'{"OK" if ok else "ISSUES FOUND"} '
f'({len(violations)} foreign-key violation(s))')
return jsonify({
'success': True,
'ok': ok,
'integrity': integrity,
'quick_check': quick,
'foreign_key_violations': violations,
'fk_violation_count': len(violations),
'page_count': page_count,
'page_size': page_size,
'freelist_count': freelist,
'freelist_mb': round(freelist * page_size / (1024 * 1024), 2),
'db_size_mb': db_size_mb,
})
except Exception as exc:
log_action('error', f'Database integrity check failed: {exc}')
return jsonify({'success': False, 'error': str(exc)}), 500
@admin_bp.route('/maintenance/optimize-db', methods=['POST'])
@login_required
@admin_required
def maintenance_optimize_db():
"""Compact the SQLite database (VACUUM) and refresh statistics (ANALYZE).
VACUUM cannot run inside an open transaction, so it is issued on an
AUTOCOMMIT connection.
"""
try:
db.session.commit()
with db.engine.connect().execution_options(
isolation_level='AUTOCOMMIT') as conn:
conn.execute(text('VACUUM'))
conn.execute(text('ANALYZE'))
db_path = db.engine.url.database
db_size_mb = None
if db_path and os.path.exists(db_path):
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2)
log_action('info',
f'Database optimized (VACUUM + ANALYZE) by '
f'{current_user.username}')
return jsonify({'success': True, 'db_size_mb': db_size_mb})
except Exception as exc:
log_action('error', f'Database optimization failed: {exc}')
return jsonify({'success': False, 'error': str(exc)}), 500
@admin_bp.route('/maintenance/scan-orphans', methods=['POST'])
@login_required
@admin_required
def maintenance_scan_orphans():
"""Scan for orphaned database rows and edited-media folders."""
checks, total = _run_orphan_scan()
edited_media = _scan_orphaned_edited_media()
if total or edited_media['count']:
log_action('info',
f'Orphan scan by {current_user.username}: {total} orphaned '
f'row(s), {edited_media["count"]} orphaned media folder(s)')
return jsonify({
'success': True,
'checks': checks,
'total': total,
'edited_media': edited_media,
})
@admin_bp.route('/maintenance/clean-orphans', methods=['POST'])
@login_required
@admin_required
def maintenance_clean_orphans():
"""Delete orphaned database rows and orphaned edited-media folders."""
cleaned = []
errors = []
total_rows = 0
for check in _ORPHAN_CHECKS:
try:
result = db.session.execute(text(check['clean']))
changed = result.rowcount or 0
if changed:
total_rows += changed
cleaned.append({
'label': check['label'],
'action': check['action'],
'rows': changed,
})
except Exception as exc: # pragma: no cover - defensive
db.session.rollback()
errors.append({'label': check['label'], 'error': str(exc)})
try:
db.session.commit()
except Exception as exc: # pragma: no cover - defensive
db.session.rollback()
errors.append({'label': 'commit', 'error': str(exc)})
# Orphaned edited-media folders (files on disk whose content row is gone).
removed_dirs = 0
freed_bytes = 0
media = _scan_orphaned_edited_media()
if media['error']:
errors.append({'label': 'edited media scan', 'error': media['error']})
elif media['ids']:
root = _edited_media_root()
for content_id in media['ids']:
path = os.path.join(root, str(content_id))
try:
freed_bytes += _dir_size_bytes(path)
shutil.rmtree(path)
removed_dirs += 1
except Exception as exc: # pragma: no cover - defensive
errors.append({
'label': f'edited_media/{content_id}',
'error': str(exc),
})
log_action('info',
f'Orphan cleanup by {current_user.username}: removed '
f'{total_rows} row(s) and {removed_dirs} media folder(s)')
return jsonify({
'success': not errors,
'total_rows': total_rows,
'cleaned': cleaned,
'removed_dirs': removed_dirs,
'freed_mb': round(freed_bytes / (1024 * 1024), 2),
'errors': errors,
})
# ---------------------------------------------------------------------------
# Player authentication tokens
# ---------------------------------------------------------------------------
@admin_bp.route('/maintenance/set-interval', methods=['POST'])
@login_required
@admin_required
def maintenance_set_interval():
"""Save the automatic authentication-token reset interval."""
raw = (request.form.get('interval_days') or '').strip()
try:
days = int(raw)
except (TypeError, ValueError):
flash('Reset interval must be a whole number of days.', 'warning')
return redirect(url_for('admin.maintenance'))
if days < 0:
flash('Reset interval cannot be negative.', 'warning')
return redirect(url_for('admin.maintenance'))
PlayerAuthConfig.create_or_update(days, updated_by=current_user.username)
log_action('info',
f'Player auth token reset interval set to {days} day(s) by '
f'{current_user.username}')
if days == 0:
flash('Automatic token reset disabled.', 'success')
else:
flash(f'Players will be asked to re-authenticate every {days} day(s).',
'success')
return redirect(url_for('admin.maintenance'))
@admin_bp.route('/maintenance/reset-token/<int:player_id>', methods=['POST'])
@login_required
@admin_required
def maintenance_reset_token(player_id: int):
"""Request that a single player re-authenticate.
The token is not replaced here (see ``Player.request_reauth``): the player
is flagged, told to authenticate again on its next heartbeat, and receives
a fresh token at that point.
"""
try:
player = Player.query.get_or_404(player_id)
player.request_reauth()
db.session.commit()
log_action('info',
f'Re-authentication requested for player "{player.name}" '
f'(ID: {player.id}) by {current_user.username}')
flash(f'"{player.name}" will be asked to re-authenticate on its next '
f'heartbeat and then receive a new token.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error requesting token reset: {exc}')
flash('Error requesting the re-authentication.', 'danger')
return redirect(url_for('admin.maintenance'))
@admin_bp.route('/maintenance/reset-all-tokens', methods=['POST'])
@login_required
@admin_required
def maintenance_reset_all_tokens():
"""Request that every player re-authenticate (see ``request_reauth``)."""
try:
players = Player.query.all()
for player in players:
player.request_reauth()
db.session.commit()
log_action('info',
f'Re-authentication requested for all {len(players)} '
f'player(s) by {current_user.username}')
flash(f'{len(players)} player(s) will be asked to re-authenticate on '
f'their next heartbeat and then receive new tokens.', 'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error requesting all token resets: {exc}')
flash('Error requesting the re-authentications.', 'danger')
return redirect(url_for('admin.maintenance'))
@admin_bp.route('/maintenance/cancel-reset/<int:player_id>', methods=['POST'])
@login_required
@admin_required
def maintenance_cancel_reset(player_id: int):
"""Cancel a pending re-authentication request for a player."""
try:
player = Player.query.get_or_404(player_id)
player.auth_reset_required = False
db.session.commit()
log_action('info',
f'Pending re-authentication cancelled for player '
f'"{player.name}" by {current_user.username}')
flash(f'Pending re-authentication cancelled for "{player.name}".',
'success')
except Exception as exc:
db.session.rollback()
log_action('error', f'Error cancelling pending re-auth: {exc}')
flash('Error cancelling the pending re-authentication.', 'danger')
return redirect(url_for('admin.maintenance'))