7177cdb9ab
Adds a /help page served from the dashboard navigation, containing the full
Romanian user manual for operators: dashboard, playlist management, adding
media, removing files from playlists, player management, and the advanced
edited-media view.
Implementation notes
- Manual is pre-rendered to HTML with pandoc and shipped as a static asset
(app/static/help/_manual_body.html) rather than parsed at runtime. This
keeps the container free of a Markdown dependency and makes the page render
identically regardless of what is installed.
- /help is login-protected; the sidebar table of contents is derived from the
level-2 headings at request time.
- Screenshots are served from app/static/help/screenshots/ (32 images).
- Regenerate after editing the manual:
documentatie/convert_help_page.sh
docker compose up -d --build
Also adds
- backup_players_playlists.sh / restore_database.sh for DB backup and restore.
- .gitignore rules so local database backups (real production data), generated
.docx files and duplicate screenshot copies are never committed.
169 lines
6.2 KiB
Python
169 lines
6.2 KiB
Python
"""
|
|
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
|
|
|
|
# Recent Activity.
|
|
#
|
|
# Player feedback ("Feedback received from ...") is a ~15s heartbeat: it is
|
|
# ~84% of all log rows, and 18 of the newest 20. Showing it unfiltered
|
|
# buries the events an operator actually cares about (uploads, logins,
|
|
# HTTPS changes, deployments), so the two kinds are queried separately and
|
|
# the card defaults to real activity, with heartbeats available on demand.
|
|
#
|
|
# The split is done in SQL rather than by filtering one wide window in
|
|
# Python: with a fleet of players the heartbeat rate is high enough that a
|
|
# fixed window would return mostly noise and only a handful of real events.
|
|
HEARTBEAT_PREFIX = 'Feedback received from'
|
|
activity_logs = get_recent_logs(25, exclude_prefix=HEARTBEAT_PREFIX)
|
|
heartbeat_logs = get_recent_logs(25, include_prefix=HEARTBEAT_PREFIX)
|
|
|
|
# Per-playlist overview for the dashboard: which playlist holds what, and
|
|
# which players consume it. This answers "where should I add media / which
|
|
# playlist needs editing?" without opening each playlist.
|
|
#
|
|
# Players are fetched in a single query and grouped in Python rather than
|
|
# relying on Playlist.players per row, to avoid one query per playlist.
|
|
playlists = Playlist.query.order_by(Playlist.name).all()
|
|
players_by_playlist = {}
|
|
for player in Player.query.order_by(Player.name).all():
|
|
if player.playlist_id is not None:
|
|
players_by_playlist.setdefault(player.playlist_id, []).append(player)
|
|
|
|
playlist_overview = []
|
|
for playlist in playlists:
|
|
assigned = players_by_playlist.get(playlist.id, [])
|
|
playlist_overview.append({
|
|
'playlist': playlist,
|
|
'content_count': playlist.contents.count(),
|
|
'total_duration': playlist.total_duration,
|
|
'players': assigned,
|
|
'player_count': len(assigned),
|
|
'online_count': sum(1 for p in assigned if p.is_online),
|
|
})
|
|
|
|
# Players with no playlist produce no content on screen, so surface them.
|
|
unassigned_players = Player.query.filter(Player.playlist_id.is_(None))\
|
|
.order_by(Player.name).all()
|
|
|
|
return render_template(
|
|
'dashboard.html',
|
|
total_players=total_players,
|
|
total_playlists=total_playlists,
|
|
total_content=total_content,
|
|
storage_mb=storage_mb,
|
|
recent_logs=activity_logs,
|
|
heartbeat_logs=heartbeat_logs,
|
|
playlist_overview=playlist_overview,
|
|
unassigned_players=unassigned_players
|
|
)
|
|
|
|
|
|
@main_bp.route('/help')
|
|
@login_required
|
|
def help_page():
|
|
"""User manual / help page.
|
|
|
|
The manual lives at ``app/static/help/_manual_body.html`` — a pre-rendered
|
|
HTML fragment generated from ``documentatie/Manual-Utilizare-DigiServer.md``
|
|
with ``pandoc`` (see ``documentatie/convert_help_page.sh``).
|
|
|
|
It is shipped as a static asset rather than parsed at runtime on purpose:
|
|
it keeps the runtime free of a Markdown dependency and means the help page
|
|
renders identically regardless of what is installed in the container.
|
|
"""
|
|
import os
|
|
import re
|
|
|
|
base_dir = os.path.dirname(os.path.dirname(__file__)) # -> app/
|
|
manual_path = os.path.join(base_dir, 'static', 'help', '_manual_body.html')
|
|
|
|
manual_html = ''
|
|
toc = []
|
|
try:
|
|
with open(manual_path, 'r', encoding='utf-8') as f:
|
|
manual_html = f.read()
|
|
|
|
# Build the sidebar from the level-2 headings (`<h2 id="...">`), which
|
|
# is exactly the printed table of contents of the manual. Nested <h3>
|
|
# entries would make the sidebar unwieldy, so they are left out.
|
|
for anchor, title in re.findall(
|
|
r'<h2 id="([^"]+)"[^>]*>(.*?)</h2>', manual_html, re.DOTALL
|
|
):
|
|
clean = re.sub(r'<[^>]+>', '', title).strip()
|
|
if clean:
|
|
toc.append({'anchor': anchor, 'title': clean})
|
|
except FileNotFoundError:
|
|
manual_html = (
|
|
'<h1>Manual indisponibil</h1>'
|
|
'<p>Fișierul manualului nu a fost găsit. '
|
|
'Rulați <code>documentatie/convert_help_page.sh</code> pentru a-l genera.</p>'
|
|
)
|
|
|
|
return render_template('help.html', manual_html=manual_html, toc=toc)
|
|
|
|
|
|
@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
|