Add edited media report page and fix image versioning
- New route GET /players/<id>/edited-media-report with tabular report - New template edited_media_report.html with User/Filename/Date/Link columns - Launch Report button (green) next to View All Edited Media button - Fix: on first edit, move original file to versionized folder as original_<name> - Fix: content lookup fallback via PlayerEdit records and path regex - Keep Content.filename pointing to latest edit for player playlist
This commit is contained in:
+61
-31
@@ -734,75 +734,105 @@ def receive_edited_media():
|
||||
"""
|
||||
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
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models.player_edit import PlayerEdit
|
||||
|
||||
# Find the original content by filename
|
||||
|
||||
original_name = metadata['original_name']
|
||||
version = metadata['version']
|
||||
new_filename = metadata['new_name']
|
||||
|
||||
# ── Content lookup ───────────────────────────────────────────────
|
||||
# If the player sends the current filename (pointing to edited_media/...),
|
||||
# we find the content by its ID embedded in the path. Otherwise do a
|
||||
# direct filename match.
|
||||
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"
|
||||
import re
|
||||
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
|
||||
|
||||
# Create versioned folder structure: edited_media/<content_id>/
|
||||
|
||||
# ── Versionized folder ───────────────────────────────────────────
|
||||
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']
|
||||
|
||||
# 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 ─────────────────────────────────────────
|
||||
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||
file.save(edited_file_path)
|
||||
|
||||
# Save metadata JSON file
|
||||
|
||||
# Side-car metadata JSON
|
||||
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
|
||||
|
||||
# ── Point Content.filename to the latest edit ────────────────────
|
||||
# This tells the player to download the latest edited version.
|
||||
old_filename = content.filename
|
||||
content.filename = f"edited_media/{content.id}/{new_filename}"
|
||||
|
||||
# Create edit record
|
||||
|
||||
# ── 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}')
|
||||
@@ -817,7 +847,7 @@ def receive_edited_media():
|
||||
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,
|
||||
@@ -830,22 +860,22 @@ def receive_edited_media():
|
||||
edited_file_path=edited_file_path
|
||||
)
|
||||
db.session.add(edit_record)
|
||||
|
||||
# Update playlist version to force player refresh
|
||||
|
||||
# ── 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',
|
||||
|
||||
+102
-13
@@ -28,10 +28,23 @@ def list():
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
# Build a JSON-safe dict of deployment statuses for the polling JS
|
||||
import json
|
||||
from datetime import datetime as dt
|
||||
player_statuses_json = {}
|
||||
for player in players:
|
||||
player_statuses_json[str(player.id)] = {
|
||||
'deployment_status': player.deployment_status,
|
||||
'last_deployment_status': player.last_deployment_status,
|
||||
'last_deployment_at': player.last_deployment_at.isoformat() if player.last_deployment_at else None,
|
||||
'last_deployment_message': player.last_deployment_message,
|
||||
}
|
||||
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
playlists=playlists,
|
||||
player_statuses=player_statuses)
|
||||
player_statuses=player_statuses,
|
||||
player_statuses_json=json.dumps(player_statuses_json))
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading players list: {str(e)}')
|
||||
flash('Error loading players list.', 'danger')
|
||||
@@ -148,6 +161,13 @@ def add_player():
|
||||
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
|
||||
server_url = f"{flask_request.scheme}://{host}"
|
||||
|
||||
# Mark deployment as "in progress" immediately so the UI polling picks it up
|
||||
from datetime import datetime
|
||||
new_player.deployment_status = 'deploying'
|
||||
new_player.last_deployment_at = datetime.utcnow()
|
||||
new_player.last_deployment_message = 'Deployment in progress...'
|
||||
db.session.commit()
|
||||
|
||||
# Generate API key for player authentication
|
||||
import hashlib
|
||||
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
@@ -173,19 +193,30 @@ def add_player():
|
||||
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
||||
|
||||
# Flash detailed success message
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
'''
|
||||
|
||||
if deployment_initiated:
|
||||
success_msg += f'<strong style="color: #0275d8;">⌛ Deployment in Progress</strong> Deploying to {ssh_hostname} in background...<br>'
|
||||
success_msg += '<small>Check player status to see deployment completion</small><br>'
|
||||
|
||||
success_msg += '<small>Configure the player with these credentials in app_config.json</small>'
|
||||
flash(success_msg, 'success')
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<br>
|
||||
<strong style="color: #0275d8;">⌛ Deployment in Progress</strong><br>
|
||||
Deploying to <strong>{ssh_hostname}</strong> in background...<br>
|
||||
<small>The deployment status will update automatically on the players list.</small>
|
||||
'''
|
||||
flash(success_msg, 'success')
|
||||
else:
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<br>
|
||||
<small>Configure the player with these credentials in app_config.json</small>
|
||||
'''
|
||||
flash(success_msg, 'success')
|
||||
if deploy_player:
|
||||
flash('Player was created but deployment could not be started. You can deploy manually from the Manage page.', 'warning')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
@@ -453,6 +484,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)."""
|
||||
@@ -598,6 +663,30 @@ def bulk_assign_playlist():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/deployment-status')
|
||||
@login_required
|
||||
def deployment_status():
|
||||
"""Return deployment status for all players (used by polling JS)."""
|
||||
try:
|
||||
players = Player.query.with_entities(
|
||||
Player.id, Player.deployment_status,
|
||||
Player.last_deployment_status, Player.last_deployment_at,
|
||||
Player.last_deployment_message
|
||||
).all()
|
||||
data = {}
|
||||
for p in players:
|
||||
data[p.id] = {
|
||||
'deployment_status': p.deployment_status,
|
||||
'last_deployment_status': p.last_deployment_status,
|
||||
'last_deployment_at': p.last_deployment_at.isoformat() if p.last_deployment_at else None,
|
||||
'last_deployment_message': p.last_deployment_message,
|
||||
}
|
||||
return jsonify(data)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error fetching deployment status: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist(player_id: int):
|
||||
|
||||
Reference in New Issue
Block a user