feat(digiserver-v2): port edited media report and deploy badge features from standalone
- Added edited_media_report route to players blueprint with tabular report view - Created edited_media_report.html template with summary stats, dark mode, print/PDF support - Created _deploy_badge.html partial template for deployment status display - Added deployment status column and badge CSS to players_list.html - Added 'View Report' button to manage_player.html next to 'View All Edited Media' - Fixed api.py receive_edited_media with robust content lookup: - Fallback content lookup by regex path matching (edited_media/<id>/) - Fallback via PlayerEdit record if direct lookup fails - First-edit original file preservation (moves original to versionized folder) Enterprise-specific features (portal SSO, internal sync, playlist permissions, WAL mode) are preserved unchanged.
This commit is contained in:
@@ -765,6 +765,8 @@ def receive_edited_media():
|
||||
|
||||
# Import required modules
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models.player_edit import PlayerEdit
|
||||
|
||||
@@ -772,6 +774,19 @@ def receive_edited_media():
|
||||
original_name = metadata['original_name']
|
||||
content = Content.query.filter_by(filename=original_name).first()
|
||||
|
||||
if not content:
|
||||
# Try to extract content_id from a path like "edited_media/<id>/file"
|
||||
m = re.match(r'edited_media/(\d+)/', original_name)
|
||||
if m:
|
||||
content = db.session.get(Content, int(m.group(1)))
|
||||
if not content:
|
||||
# Last resort – look up the most recent PlayerEdit for this
|
||||
# content and use its content_id.
|
||||
fallback_edit = PlayerEdit.query.filter_by(new_name=original_name)\
|
||||
.order_by(PlayerEdit.created_at.desc()).first()
|
||||
if fallback_edit:
|
||||
content = db.session.get(Content, fallback_edit.content_id)
|
||||
|
||||
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
|
||||
@@ -781,20 +796,31 @@ def receive_edited_media():
|
||||
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
|
||||
os.makedirs(edited_media_dir, exist_ok=True)
|
||||
|
||||
# On the very first edit (v1) move the original file into the
|
||||
# versionized folder so it is never orphaned.
|
||||
is_first_edit = PlayerEdit.query.filter_by(content_id=content.id).count() == 0
|
||||
if is_first_edit:
|
||||
orig_upload = os.path.join(base_upload_dir, content.filename)
|
||||
# content.filename might already be an edited_media/ path if
|
||||
# this is a re-process; only move if it's a plain filename.
|
||||
if os.path.isfile(orig_upload) and not content.filename.startswith('edited_media/'):
|
||||
orig_stored = f"original_{content.filename}"
|
||||
shutil.move(orig_upload, os.path.join(edited_media_dir, orig_stored))
|
||||
log_action('info', f'Moved original file "{content.filename}" to versionized folder as "{orig_stored}"')
|
||||
|
||||
# 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
|
||||
# Save metadata JSON side-car 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}"
|
||||
|
||||
|
||||
@@ -460,6 +460,40 @@ def edited_media(player_id: int):
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/edited-media-report')
|
||||
@login_required
|
||||
def edited_media_report(player_id: int):
|
||||
"""Display a tabular report of all edited media from this player."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
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()
|
||||
|
||||
# Build user display name mapping
|
||||
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 and player_user.user_name:
|
||||
user_mappings[edit.user] = player_user.user_name
|
||||
else:
|
||||
user_mappings[edit.user] = edit.user
|
||||
|
||||
return render_template('players/edited_media_report.html',
|
||||
player=player,
|
||||
edited_media=edited_media,
|
||||
user_mappings=user_mappings)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading edited media report for player {player_id}: {str(e)}')
|
||||
flash('Error loading edited media report.', '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)."""
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% if player.deployment_status == 'deploying' %}
|
||||
<span class="deploy-badge deploying" id="deploying-badge-{{ player.id }}">
|
||||
<span class="spinner"></span>Deploying...
|
||||
</span>
|
||||
{% elif player.deployment_status == 'deployed' %}
|
||||
<span class="deploy-badge deployed" title="{{ player.last_deployment_message or 'Deployed successfully' }}">
|
||||
✅ Deployed
|
||||
{% if player.last_deployment_at %}
|
||||
<span class="deploy-timestamp">{{ player.last_deployment_at | localtime }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% elif player.deployment_status == 'failed' %}
|
||||
<span class="deploy-badge failed deploy-tooltip" title="{{ player.last_deployment_message or 'Deployment failed' }}">
|
||||
❌ Failed
|
||||
{% if player.last_deployment_at %}
|
||||
<span class="deploy-timestamp">{{ player.last_deployment_at | localtime }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% elif player.deployment_status == 'pending' %}
|
||||
<span class="deploy-badge pending" title="Awaiting deployment">
|
||||
⏳ Pending
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,342 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edited Media Report - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.report-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.report-header h1 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
body.dark-mode .summary-stat {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.summary-stat .stat-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.summary-stat .stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.report-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
body.dark-mode .report-table-wrapper {
|
||||
background: #1a202c;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.report-table thead {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.report-table thead th {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-table tbody tr {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table tbody tr {
|
||||
border-bottom-color: #4a5568;
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
}
|
||||
|
||||
body.dark-mode .report-table tbody tr:hover {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
.report-table tbody tr:nth-child(even) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table tbody tr:nth-child(even) {
|
||||
background: #162032;
|
||||
}
|
||||
|
||||
.report-table tbody td {
|
||||
padding: 0.75rem 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.report-table .col-user {
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-user {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.report-table .col-filename {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #475569;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-filename {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.report-table .col-date {
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.report-table .col-link a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-table .col-link a:hover {
|
||||
background: #6d28d9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-link a {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
body.dark-mode .report-table .col-link a:hover {
|
||||
background: #5b21b6;
|
||||
}
|
||||
|
||||
.report-table .col-version {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
body.dark-mode .version-badge {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.no-data .icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #64748b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.print-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
.report-table-wrapper {
|
||||
border: 1px solid #ccc !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.report-table thead {
|
||||
background: #333 !important;
|
||||
}
|
||||
.btn, .print-btn, nav, .back-link {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="report-container">
|
||||
<div class="report-header">
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn back-link"
|
||||
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
← Back to Player
|
||||
</a>
|
||||
<h1>
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 28px; height: 28px;">
|
||||
Edited Media Report — {{ player.name }}
|
||||
</h1>
|
||||
<button class="print-btn" onclick="window.print()">
|
||||
🖨️ Print / PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if edited_media %}
|
||||
{% set total_edits = edited_media|length %}
|
||||
{% set unique_files = edited_media|map(attribute='content_id')|unique|list|length %}
|
||||
{% set unique_users = edited_media|selectattr('user')|map(attribute='user')|unique|list|length %}
|
||||
|
||||
<div class="report-summary">
|
||||
<div class="summary-stat">
|
||||
<span class="stat-value">{{ total_edits }}</span>
|
||||
<span class="stat-label">Total Edits</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="stat-value">{{ unique_files }}</span>
|
||||
<span class="stat-label">Files Edited</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="stat-value">{{ unique_users }}</span>
|
||||
<span class="stat-label">Editors</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-table-wrapper">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22%;">User</th>
|
||||
<th style="width: 30%;">Edited File</th>
|
||||
<th style="width: 15%;">Version</th>
|
||||
<th style="width: 18%;">Date</th>
|
||||
<th style="width: 15%;">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for edit in edited_media %}
|
||||
<tr>
|
||||
<td class="col-user">
|
||||
{% if edit.user %}
|
||||
{% set display_name = user_mappings.get(edit.user, edit.user) %}
|
||||
👤 {{ display_name }}
|
||||
{% else %}
|
||||
<span style="color: #94a3b8;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-filename">
|
||||
📄 {{ edit.new_name }}
|
||||
</td>
|
||||
<td class="col-version">
|
||||
<span class="version-badge">v{{ edit.version }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
{% if edit.time_of_modification %}
|
||||
{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||
{% elif edit.created_at %}
|
||||
{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span style="color: #94a3b8;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-link">
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
target="_blank"
|
||||
title="Open {{ edit.new_name }}">
|
||||
🔗 Open File
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-data">
|
||||
<div class="icon">📋</div>
|
||||
<p style="font-size: 1.1rem; font-weight: 500;">No edited media found</p>
|
||||
<p>This player has not submitted any edited media yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -653,6 +653,13 @@ document.addEventListener('keydown', function(event) {
|
||||
onmouseout="this.style.background='#7c3aed'">
|
||||
📋 View All Edited Media
|
||||
</a>
|
||||
<a href="{{ url_for('players.edited_media_report', player_id=player.id) }}"
|
||||
class="btn"
|
||||
style="background: #0891b2; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#0e7490'"
|
||||
onmouseout="this.style.background='#0891b2'">
|
||||
📊 View Report
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||
|
||||
@@ -99,6 +99,75 @@
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Deployment status */
|
||||
.deploy-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.deploy-badge.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
body.dark-mode .deploy-badge.pending {
|
||||
background: #2d2a0e;
|
||||
color: #f0d45e;
|
||||
}
|
||||
.deploy-badge.deployed {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
body.dark-mode .deploy-badge.deployed {
|
||||
background: #0e2d1a;
|
||||
color: #68d98b;
|
||||
}
|
||||
.deploy-badge.failed {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
body.dark-mode .deploy-badge.failed {
|
||||
background: #2d0e11;
|
||||
color: #f05a6a;
|
||||
}
|
||||
.deploy-badge.deploying {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
body.dark-mode .deploy-badge.deploying {
|
||||
background: #0e1d2d;
|
||||
color: #5aadf0;
|
||||
}
|
||||
.deploy-badge .spinner {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #004085;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: deploy-spin 0.8s linear infinite;
|
||||
}
|
||||
body.dark-mode .deploy-badge .spinner {
|
||||
border-color: #5aadf0;
|
||||
border-top-color: transparent;
|
||||
}
|
||||
@keyframes deploy-spin { to { transform: rotate(360deg); } }
|
||||
.deploy-timestamp {
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
display: block;
|
||||
}
|
||||
body.dark-mode .deploy-timestamp {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.deploy-tooltip {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
body.dark-mode .text-muted {
|
||||
color: #718096;
|
||||
}
|
||||
@@ -142,6 +211,7 @@
|
||||
<th>Location</th>
|
||||
<th>Orientation</th>
|
||||
<th>Status</th>
|
||||
<th>Deployment</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -168,6 +238,9 @@
|
||||
<span class="status-badge offline">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td id="deploy-cell-{{ player.id }}">
|
||||
{% include "players/_deploy_badge.html" %}
|
||||
</td>
|
||||
<td>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen | localtime }}
|
||||
|
||||
Reference in New Issue
Block a user