Features implemented: - Application factory pattern with environment-based config - 7 modular blueprints (main, auth, admin, players, groups, content, api) - Flask-Caching with Redis support for production - Flask-Login authentication with bcrypt password hashing - API endpoints with rate limiting and Bearer token auth - Comprehensive error handling and logging - CLI commands (init-db, create-admin, seed-db) Blueprint Structure: - main: Dashboard with caching, health check endpoint - auth: Login, register, logout, password change - admin: User management, system settings, theme, logo upload - players: Full CRUD, fullscreen view, bulk operations, playlist management - groups: Group management, player assignments, content management - content: Upload with progress tracking, file management, preview/download - api: RESTful endpoints with authentication, rate limiting, player feedback Performance Optimizations: - Dashboard caching (60s timeout) - Playlist caching (5min timeout) - Redis caching for production - Memoized functions for expensive operations - Cache clearing on data changes Security Features: - Bcrypt password hashing - Flask-Login session management - admin_required decorator for authorization - Player authentication via auth codes - API Bearer token authentication - Rate limiting on API endpoints (60 req/min default) - Input validation and sanitization Documentation: - README.md: Full project documentation with quick start - PROGRESS.md: Detailed progress tracking and roadmap - BLUEPRINT_GUIDE.md: Quick reference for blueprint architecture Pending work: - Models migration from v1 with database indexes - Utils migration from v1 with type hints - Templates migration with updated route references - Docker multi-stage build configuration - Unit tests for all blueprints Ready for models and utils migration from digiserver v1
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""Content blueprint for media upload and management."""
|
|
from flask import (Blueprint, render_template, request, redirect, url_for,
|
|
flash, jsonify, current_app, send_from_directory)
|
|
from flask_login import login_required
|
|
from werkzeug.utils import secure_filename
|
|
import os
|
|
from typing import Optional, Dict
|
|
import json
|
|
|
|
from app.extensions import db, cache
|
|
from app.models import Content, Group
|
|
from app.utils.logger import log_action
|
|
from app.utils.uploads import (
|
|
save_uploaded_file,
|
|
process_video_file,
|
|
process_pdf_file,
|
|
get_upload_progress,
|
|
set_upload_progress
|
|
)
|
|
|
|
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
|
|
|
|
|
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
|
upload_progress = {}
|
|
|
|
|
|
@content_bp.route('/')
|
|
@login_required
|
|
def content_list():
|
|
"""Display list of all content."""
|
|
try:
|
|
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
|
|
|
|
# Get group info for each content
|
|
content_groups = {}
|
|
for content in contents:
|
|
content_groups[content.id] = content.groups.count()
|
|
|
|
return render_template('content_list.html',
|
|
contents=contents,
|
|
content_groups=content_groups)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading content list: {str(e)}')
|
|
flash('Error loading content list.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
|
|
@content_bp.route('/upload', methods=['GET', 'POST'])
|
|
@login_required
|
|
def upload_content():
|
|
"""Upload new content."""
|
|
if request.method == 'GET':
|
|
return render_template('upload_content.html')
|
|
|
|
try:
|
|
if 'file' not in request.files:
|
|
flash('No file provided.', 'warning')
|
|
return redirect(url_for('content.upload_content'))
|
|
|
|
file = request.files['file']
|
|
|
|
if file.filename == '':
|
|
flash('No file selected.', 'warning')
|
|
return redirect(url_for('content.upload_content'))
|
|
|
|
# Get optional parameters
|
|
duration = request.form.get('duration', type=int)
|
|
description = request.form.get('description', '').strip()
|
|
|
|
# Generate unique upload ID for progress tracking
|
|
upload_id = os.urandom(16).hex()
|
|
|
|
# Save file with progress tracking
|
|
filename = secure_filename(file.filename)
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
filepath = os.path.join(upload_folder, filename)
|
|
|
|
# Save file with progress updates
|
|
set_upload_progress(upload_id, 0, 'Uploading file...')
|
|
file.save(filepath)
|
|
set_upload_progress(upload_id, 50, 'File uploaded, processing...')
|
|
|
|
# Determine content type
|
|
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
|
|
|
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
|
content_type = 'image'
|
|
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
|
content_type = 'video'
|
|
# Process video (convert, optimize, extract metadata)
|
|
set_upload_progress(upload_id, 60, 'Processing video...')
|
|
process_video_file(filepath, upload_id)
|
|
elif file_ext == 'pdf':
|
|
content_type = 'pdf'
|
|
# Process PDF (convert to images)
|
|
set_upload_progress(upload_id, 60, 'Processing PDF...')
|
|
process_pdf_file(filepath, upload_id)
|
|
elif file_ext in ['ppt', 'pptx']:
|
|
content_type = 'presentation'
|
|
# Process presentation (convert to PDF then images)
|
|
set_upload_progress(upload_id, 60, 'Processing presentation...')
|
|
# This would call pptx_converter utility
|
|
else:
|
|
content_type = 'other'
|
|
|
|
set_upload_progress(upload_id, 90, 'Creating database entry...')
|
|
|
|
# Create content record
|
|
new_content = Content(
|
|
filename=filename,
|
|
content_type=content_type,
|
|
duration=duration,
|
|
description=description or None,
|
|
file_size=os.path.getsize(filepath)
|
|
)
|
|
db.session.add(new_content)
|
|
db.session.commit()
|
|
|
|
set_upload_progress(upload_id, 100, 'Complete!')
|
|
|
|
# Clear all playlist caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Content "{filename}" uploaded successfully (Type: {content_type})')
|
|
flash(f'Content "{filename}" uploaded successfully.', 'success')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error uploading content: {str(e)}')
|
|
flash('Error uploading content. Please try again.', 'danger')
|
|
return redirect(url_for('content.upload_content'))
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_content(content_id: int):
|
|
"""Edit content metadata."""
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
if request.method == 'GET':
|
|
return render_template('edit_content.html', content=content)
|
|
|
|
try:
|
|
duration = request.form.get('duration', type=int)
|
|
description = request.form.get('description', '').strip()
|
|
|
|
# Update content
|
|
if duration is not None:
|
|
content.duration = duration
|
|
content.description = description or None
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
|
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating content: {str(e)}')
|
|
flash('Error updating content. Please try again.', 'danger')
|
|
return redirect(url_for('content.edit_content', content_id=content_id))
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_content(content_id: int):
|
|
"""Delete content and associated file."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
filename = content.filename
|
|
|
|
# Delete file from disk
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
|
|
# Delete from database
|
|
db.session.delete(content)
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
|
flash(f'Content "{filename}" deleted successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting content: {str(e)}')
|
|
flash('Error deleting content. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
|
|
@content_bp.route('/bulk/delete', methods=['POST'])
|
|
@login_required
|
|
def bulk_delete_content():
|
|
"""Delete multiple content items at once."""
|
|
try:
|
|
content_ids = request.json.get('content_ids', [])
|
|
|
|
if not content_ids:
|
|
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
|
|
|
# Delete content
|
|
deleted_count = 0
|
|
for content_id in content_ids:
|
|
content = Content.query.get(content_id)
|
|
if content:
|
|
# Delete file
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
|
|
db.session.delete(content)
|
|
deleted_count += 1
|
|
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Bulk deleted {deleted_count} content items')
|
|
return jsonify({'success': True, 'deleted': deleted_count})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error bulk deleting content: {str(e)}')
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/upload-progress/<upload_id>')
|
|
@login_required
|
|
def upload_progress_status(upload_id: str):
|
|
"""Get upload progress for a specific upload."""
|
|
progress = get_upload_progress(upload_id)
|
|
return jsonify(progress)
|
|
|
|
|
|
@content_bp.route('/preview/<int:content_id>')
|
|
@login_required
|
|
def preview_content(content_id: int):
|
|
"""Preview content in browser."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
# Serve file from uploads folder
|
|
return send_from_directory(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
content.filename,
|
|
as_attachment=False
|
|
)
|
|
except Exception as e:
|
|
log_action('error', f'Error previewing content: {str(e)}')
|
|
return "Error loading content", 500
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/download')
|
|
@login_required
|
|
def download_content(content_id: int):
|
|
"""Download content file."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
log_action('info', f'Content "{content.filename}" downloaded')
|
|
|
|
return send_from_directory(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
content.filename,
|
|
as_attachment=True
|
|
)
|
|
except Exception as e:
|
|
log_action('error', f'Error downloading content: {str(e)}')
|
|
return "Error downloading content", 500
|
|
|
|
|
|
@content_bp.route('/statistics')
|
|
@login_required
|
|
def content_statistics():
|
|
"""Get content statistics."""
|
|
try:
|
|
total_content = Content.query.count()
|
|
|
|
# Count by type
|
|
type_counts = {}
|
|
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
|
count = Content.query.filter_by(content_type=content_type).count()
|
|
type_counts[content_type] = count
|
|
|
|
# Calculate total storage
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
total_size = 0
|
|
if os.path.exists(upload_folder):
|
|
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
|
for filename in filenames:
|
|
filepath = os.path.join(dirpath, filename)
|
|
if os.path.exists(filepath):
|
|
total_size += os.path.getsize(filepath)
|
|
|
|
return jsonify({
|
|
'total': total_content,
|
|
'by_type': type_counts,
|
|
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
|
})
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error getting content statistics: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/check-duplicates')
|
|
@login_required
|
|
def check_duplicates():
|
|
"""Check for duplicate filenames."""
|
|
try:
|
|
# Get all filenames
|
|
all_content = Content.query.all()
|
|
filename_counts = {}
|
|
|
|
for content in all_content:
|
|
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
|
|
|
# Find duplicates
|
|
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
|
|
|
return jsonify({
|
|
'has_duplicates': len(duplicates) > 0,
|
|
'duplicates': duplicates
|
|
})
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error checking duplicates: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/groups')
|
|
@login_required
|
|
def content_groups_info(content_id: int):
|
|
"""Get groups that contain this content."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
groups_data = []
|
|
for group in content.groups:
|
|
groups_data.append({
|
|
'id': group.id,
|
|
'name': group.name,
|
|
'description': group.description,
|
|
'player_count': group.players.count()
|
|
})
|
|
|
|
return jsonify({
|
|
'content_id': content_id,
|
|
'filename': content.filename,
|
|
'groups': groups_data
|
|
})
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error getting content groups: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|