Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,878 @@
|
||||
"""API blueprint for REST endpoints and player communication."""
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content, PlayerFeedback, ServerLog
|
||||
from app.utils.logger import log_action
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
# Simple rate limiting (use Redis-based solution in production)
|
||||
rate_limit_storage = {}
|
||||
|
||||
|
||||
def rate_limit(max_requests: int = 60, window: int = 60):
|
||||
"""Rate limiting decorator.
|
||||
|
||||
Args:
|
||||
max_requests: Maximum number of requests allowed
|
||||
window: Time window in seconds
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get client identifier (IP address or API key)
|
||||
client_id = request.remote_addr
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
client_id = auth_header[7:] # Use API key as identifier
|
||||
|
||||
now = datetime.now()
|
||||
key = f"{client_id}:{f.__name__}"
|
||||
|
||||
# Clean old entries
|
||||
if key in rate_limit_storage:
|
||||
rate_limit_storage[key] = [
|
||||
req_time for req_time in rate_limit_storage[key]
|
||||
if now - req_time < timedelta(seconds=window)
|
||||
]
|
||||
else:
|
||||
rate_limit_storage[key] = []
|
||||
|
||||
# Check rate limit
|
||||
if len(rate_limit_storage[key]) >= max_requests:
|
||||
return jsonify({
|
||||
'error': 'Rate limit exceeded',
|
||||
'retry_after': window
|
||||
}), 429
|
||||
|
||||
# Add current request
|
||||
rate_limit_storage[key].append(now)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def verify_player_auth(f):
|
||||
"""Decorator to verify player authentication via auth code."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid authorization header'}), 401
|
||||
|
||||
auth_code = auth_header[7:] # Remove 'Bearer ' prefix
|
||||
|
||||
# Find player with this auth code
|
||||
player = Player.query.filter_by(auth_code=auth_code).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Invalid auth code attempt: {auth_code}')
|
||||
return jsonify({'error': 'Invalid authentication code'}), 403
|
||||
|
||||
# Store player in request context
|
||||
request.player = player
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""API health check endpoint."""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'version': '2.0.0'
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/certificate', methods=['GET'])
|
||||
def get_server_certificate():
|
||||
"""Get server SSL certificate."""
|
||||
return jsonify({'test': 'certificate_endpoint_works'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/player', methods=['POST'])
|
||||
@rate_limit(max_requests=120, window=60)
|
||||
def authenticate_player():
|
||||
"""Authenticate a player and return auth code and configuration.
|
||||
|
||||
Request JSON:
|
||||
hostname: Player hostname/identifier (required)
|
||||
password: Player password (optional if using quickconnect)
|
||||
quickconnect_code: Quick connect code (optional if using password)
|
||||
|
||||
Returns:
|
||||
JSON with auth_code, player_id, group_id, and configuration
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname')
|
||||
password = data.get('password')
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
if not hostname:
|
||||
return jsonify({'error': 'Hostname is required'}), 400
|
||||
|
||||
if not password and not quickconnect_code:
|
||||
return jsonify({'error': 'Password or quickconnect code required'}), 400
|
||||
|
||||
# Authenticate player
|
||||
player = Player.authenticate(hostname, password, quickconnect_code)
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Update player status
|
||||
player.update_status('online')
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
|
||||
|
||||
# Return authentication response
|
||||
response = {
|
||||
'success': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'auth_code': player.auth_code,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/verify', methods=['POST'])
|
||||
@rate_limit(max_requests=300, window=60)
|
||||
def verify_auth_code():
|
||||
"""Verify an auth code and return player information.
|
||||
|
||||
Request JSON:
|
||||
auth_code: Player authentication code
|
||||
|
||||
Returns:
|
||||
JSON with player information if valid
|
||||
"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
auth_code = data.get('auth_code')
|
||||
|
||||
if not auth_code:
|
||||
return jsonify({'error': 'Auth code is required'}), 400
|
||||
|
||||
# Find player with this auth code
|
||||
player = Player.query.filter_by(auth_code=auth_code).first()
|
||||
|
||||
if not player:
|
||||
return jsonify({'error': 'Invalid auth code'}), 401
|
||||
|
||||
# Update last seen
|
||||
player.update_status(player.status)
|
||||
db.session.commit()
|
||||
|
||||
response = {
|
||||
'valid': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/playlists', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_playlist_by_quickconnect():
|
||||
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
|
||||
|
||||
Query parameters:
|
||||
hostname: Player hostname/identifier
|
||||
quickconnect_code: Quick connect code for authentication
|
||||
|
||||
Returns:
|
||||
JSON with playlist, playlist_version, and hashed_quickconnect
|
||||
"""
|
||||
try:
|
||||
import bcrypt
|
||||
|
||||
hostname = request.args.get('hostname')
|
||||
quickconnect_code = request.args.get('quickconnect_code')
|
||||
|
||||
if not hostname or not quickconnect_code:
|
||||
return jsonify({
|
||||
'error': 'hostname and quickconnect_code are required',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 400
|
||||
|
||||
# Find player by hostname and validate quickconnect
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player not found with hostname: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Player not found',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 404
|
||||
|
||||
# Validate quickconnect code
|
||||
if not player.quickconnect_code:
|
||||
log_action('warning', f'Player {hostname} has no quickconnect code set')
|
||||
return jsonify({
|
||||
'error': 'Quickconnect not configured',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Check if quickconnect matches (using bcrypt verification)
|
||||
if not player.check_quickconnect_code(quickconnect_code):
|
||||
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Invalid quickconnect code',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player.id)
|
||||
|
||||
# Update player's last seen timestamp and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = 'online'
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
# Hash the quickconnect code for validation on client side
|
||||
hashed_quickconnect = bcrypt.hashpw(
|
||||
quickconnect_code.encode('utf-8'),
|
||||
bcrypt.gensalt()
|
||||
).decode('utf-8')
|
||||
|
||||
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
|
||||
|
||||
return jsonify({
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'hashed_quickconnect': hashed_quickconnect,
|
||||
'count': len(playlist)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist: {str(e)}')
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=300, window=60)
|
||||
@verify_player_auth
|
||||
def get_player_playlist(player_id: int):
|
||||
"""Get playlist for a specific player.
|
||||
|
||||
Requires player authentication via Bearer token.
|
||||
"""
|
||||
try:
|
||||
# Verify the authenticated player matches the requested player_id
|
||||
if request.player.id != player_id:
|
||||
return jsonify({'error': 'Unauthorized access to this player'}), 403
|
||||
|
||||
player = request.player
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player_id)
|
||||
|
||||
# Update player's last seen timestamp
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist for player {player_id}: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
|
||||
@verify_player_auth
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def get_playlist_version(player_id: int):
|
||||
"""Get current playlist version for a player.
|
||||
|
||||
Lightweight endpoint for players to check if playlist needs updating.
|
||||
Requires player authentication via Bearer token.
|
||||
"""
|
||||
try:
|
||||
# Verify the authenticated player matches the requested player_id
|
||||
if request.player.id != player_id:
|
||||
return jsonify({'error': 'Unauthorized access to this player'}), 403
|
||||
|
||||
player = request.player
|
||||
|
||||
# Update last seen
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player based on assigned playlist."""
|
||||
from flask import url_for
|
||||
from app.models import Playlist
|
||||
|
||||
player = Player.query.get(player_id)
|
||||
if not player or not player.playlist_id:
|
||||
return []
|
||||
|
||||
# Get the playlist assigned to this player
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if not playlist:
|
||||
return []
|
||||
|
||||
# Get content from playlist (ordered)
|
||||
content_list = playlist.get_content_ordered()
|
||||
|
||||
# Build playlist response
|
||||
playlist_data = []
|
||||
for idx, content in enumerate(content_list, start=1):
|
||||
# Generate full URL for content.
|
||||
# request.script_root holds the X-Script-Name prefix set by the umbrella
|
||||
# nginx (e.g. '/digiserver'), so the URL is correct whether the app runs
|
||||
# standalone or behind the portal reverse proxy.
|
||||
from flask import request as current_request
|
||||
server_base = current_request.host_url.rstrip('/')
|
||||
script_root = current_request.script_root.rstrip('/')
|
||||
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
|
||||
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
'file_name': content.filename, # Player expects 'file_name' not 'filename'
|
||||
'type': content.content_type,
|
||||
'duration': content._playlist_duration or content.duration or 10,
|
||||
'position': content._playlist_position or idx,
|
||||
'url': content_url, # Full URL for downloads
|
||||
'description': content.description,
|
||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||
})
|
||||
|
||||
return playlist_data
|
||||
|
||||
|
||||
@api_bp.route('/player-feedback', methods=['POST'])
|
||||
@rate_limit(max_requests=600, window=60)
|
||||
def receive_player_feedback():
|
||||
"""Receive feedback/status updates from players (Kivy player compatible).
|
||||
|
||||
Expected JSON payload:
|
||||
{
|
||||
"player_name": "Screen1",
|
||||
"quickconnect_code": "ABC123",
|
||||
"status": "playing|paused|error|restarting",
|
||||
"message": "Status message",
|
||||
"playlist_version": 1,
|
||||
"error_details": "Optional error details",
|
||||
"timestamp": "ISO timestamp"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
log_action('warning', 'Player feedback received with no data')
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
player_name = data.get('player_name')
|
||||
hostname = data.get('hostname') # Also accept hostname
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
# Find player by hostname first (more reliable), then by name
|
||||
player = None
|
||||
if hostname:
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
if not player and player_name:
|
||||
player = Player.query.filter_by(name=player_name).first()
|
||||
|
||||
# If player not found and no credentials provided, try to infer from IP and recent auth
|
||||
if not player and (not quickconnect_code or (not player_name and not hostname)):
|
||||
# Try to find player by recent authentication from same IP
|
||||
client_ip = request.remote_addr
|
||||
# Look for players with matching IP in recent activity (last 5 minutes)
|
||||
recent_time = datetime.utcnow() - timedelta(minutes=5)
|
||||
possible_player = Player.query.filter(
|
||||
Player.last_seen >= recent_time
|
||||
).order_by(Player.last_seen.desc()).first()
|
||||
|
||||
if possible_player:
|
||||
player = possible_player
|
||||
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
|
||||
|
||||
# Still require quickconnect validation if provided
|
||||
if not player:
|
||||
if not player_name and not hostname:
|
||||
log_action('warning', f'Player feedback missing required fields. Data: {data}')
|
||||
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
|
||||
else:
|
||||
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
# Validate quickconnect code if provided (using bcrypt verification)
|
||||
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
|
||||
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||
|
||||
# Create feedback record
|
||||
status = data.get('status', 'unknown')
|
||||
message = data.get('message', '')
|
||||
error_details = data.get('error_details')
|
||||
|
||||
feedback = PlayerFeedback(
|
||||
player_id=player.id,
|
||||
status=status,
|
||||
message=message,
|
||||
error=error_details
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
# Update player's last seen and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = status
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Feedback received',
|
||||
'player_id': player.id
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error receiving player feedback: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/player-status/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def get_player_status(player_id: int):
|
||||
"""Get current status of a player (public endpoint for monitoring)."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get latest feedback
|
||||
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.first()
|
||||
|
||||
# Calculate if player is online (seen in last 5 minutes)
|
||||
is_online = False
|
||||
if player.last_seen:
|
||||
is_online = (datetime.utcnow() - player.last_seen).total_seconds() < 300
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'name': player.name,
|
||||
'location': player.location,
|
||||
'group_id': player.group_id,
|
||||
'status': player.status,
|
||||
'is_online': is_online,
|
||||
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
||||
'latest_feedback': {
|
||||
'status': latest_feedback.status,
|
||||
'message': latest_feedback.message,
|
||||
'timestamp': latest_feedback.timestamp.isoformat()
|
||||
} if latest_feedback else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting player status: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/upload-progress/<upload_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=120, window=60)
|
||||
def get_upload_progress(upload_id: str):
|
||||
"""Get progress of a file upload."""
|
||||
from app.utils.uploads import get_upload_progress as get_progress
|
||||
|
||||
try:
|
||||
progress = get_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting upload progress: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/system-info', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def system_info():
|
||||
"""Get system information and statistics."""
|
||||
try:
|
||||
# Get counts
|
||||
total_players = Player.query.count()
|
||||
total_groups = Group.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count online players (seen in last 5 minutes)
|
||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
||||
online_players = Player.query.filter(Player.last_seen >= five_min_ago).count()
|
||||
|
||||
# Get recent logs count
|
||||
recent_logs = ServerLog.query.filter(
|
||||
ServerLog.timestamp >= datetime.utcnow() - timedelta(hours=24)
|
||||
).count()
|
||||
|
||||
return jsonify({
|
||||
'players': {
|
||||
'total': total_players,
|
||||
'online': online_players
|
||||
},
|
||||
'groups': total_groups,
|
||||
'content': total_content,
|
||||
'logs_24h': recent_logs,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting system info: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
|
||||
# DEPRECATED: Groups functionality has been archived
|
||||
# @api_bp.route('/groups', methods=['GET'])
|
||||
# @rate_limit(max_requests=60, window=60)
|
||||
# def list_groups():
|
||||
# """List all groups with basic information."""
|
||||
# try:
|
||||
# groups = Group.query.order_by(Group.name).all()
|
||||
#
|
||||
# groups_data = []
|
||||
# for group in groups:
|
||||
# groups_data.append({
|
||||
# 'id': group.id,
|
||||
# 'name': group.name,
|
||||
# 'description': group.description,
|
||||
# 'player_count': group.players.count(),
|
||||
# 'content_count': group.contents.count()
|
||||
# })
|
||||
#
|
||||
# return jsonify({
|
||||
# 'groups': groups_data,
|
||||
# 'count': len(groups_data)
|
||||
# })
|
||||
#
|
||||
# except Exception as e:
|
||||
# log_action('error', f'Error listing groups: {str(e)}')
|
||||
# return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/content', methods=['GET'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
def list_content():
|
||||
"""List all content with basic information."""
|
||||
try:
|
||||
contents = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
|
||||
content_data = []
|
||||
for content in contents:
|
||||
content_data.append({
|
||||
'id': content.id,
|
||||
'filename': content.filename,
|
||||
'type': content.content_type,
|
||||
'duration': content.duration,
|
||||
'size': content.file_size,
|
||||
'uploaded_at': content.uploaded_at.isoformat(),
|
||||
'group_count': content.groups.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content': content_data,
|
||||
'count': len(content_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error listing content: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/logs', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_logs():
|
||||
"""Get recent server logs.
|
||||
|
||||
Query parameters:
|
||||
limit: Number of logs to return (default: 50, max: 200)
|
||||
level: Filter by log level (info, warning, error)
|
||||
since: ISO timestamp to get logs since that time
|
||||
"""
|
||||
try:
|
||||
# Get query parameters
|
||||
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||
level = request.args.get('level')
|
||||
since_str = request.args.get('since')
|
||||
|
||||
# Build query
|
||||
query = ServerLog.query
|
||||
|
||||
if level:
|
||||
query = query.filter_by(level=level)
|
||||
|
||||
if since_str:
|
||||
try:
|
||||
since = datetime.fromisoformat(since_str)
|
||||
query = query.filter(ServerLog.timestamp >= since)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid since timestamp format'}), 400
|
||||
|
||||
# Get logs
|
||||
logs = query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||
|
||||
logs_data = []
|
||||
for log in logs:
|
||||
logs_data.append({
|
||||
'id': log.id,
|
||||
'level': log.level,
|
||||
'message': log.message,
|
||||
'timestamp': log.timestamp.isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'logs': logs_data,
|
||||
'count': len(logs_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting logs: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.route('/player-edit-media', methods=['POST'])
|
||||
@rate_limit(max_requests=60, window=60)
|
||||
@verify_player_auth
|
||||
def receive_edited_media():
|
||||
"""Receive edited media from player.
|
||||
|
||||
Expected multipart/form-data:
|
||||
- image_file: The edited image file
|
||||
- metadata: JSON string with metadata
|
||||
|
||||
Metadata JSON structure:
|
||||
{
|
||||
"time_of_modification": "ISO timestamp",
|
||||
"original_name": "original_file.jpg",
|
||||
"new_name": "original_file_v1.jpg",
|
||||
"version": 1,
|
||||
"user": "player_user"
|
||||
}
|
||||
"""
|
||||
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
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models.player_edit import PlayerEdit
|
||||
|
||||
# Find the original content by filename
|
||||
original_name = metadata['original_name']
|
||||
content = Content.query.filter_by(filename=original_name).first()
|
||||
|
||||
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>/
|
||||
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']
|
||||
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||
file.save(edited_file_path)
|
||||
|
||||
# Save metadata JSON 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}"
|
||||
|
||||
# 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}')
|
||||
if user_code:
|
||||
from app.models.player_user import PlayerUser
|
||||
existing_user = PlayerUser.query.filter_by(user_code=user_code).first()
|
||||
if not existing_user:
|
||||
new_user = PlayerUser(user_code=user_code)
|
||||
db.session.add(new_user)
|
||||
log_action('info', f'Auto-created PlayerUser record for code: {user_code}')
|
||||
else:
|
||||
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,
|
||||
original_name=original_name,
|
||||
new_name=new_filename,
|
||||
version=version,
|
||||
user=user_code,
|
||||
time_of_modification=time_of_mod,
|
||||
metadata_path=metadata_path,
|
||||
edited_file_path=edited_file_path
|
||||
)
|
||||
db.session.add(edit_record)
|
||||
|
||||
# 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',
|
||||
'edit_id': edit_record.id,
|
||||
'version': version,
|
||||
'old_filename': old_filename,
|
||||
'new_filename': new_filename,
|
||||
'new_playlist_version': playlist.version if playlist else None
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error receiving edited media: {str(e)}')
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""Handle 404 errors in API."""
|
||||
return jsonify({'error': 'Endpoint not found'}), 404
|
||||
|
||||
|
||||
@api_bp.errorhandler(405)
|
||||
def method_not_allowed(error):
|
||||
"""Handle 405 errors in API."""
|
||||
return jsonify({'error': 'Method not allowed'}), 405
|
||||
|
||||
|
||||
@api_bp.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""Handle 500 errors in API."""
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
Reference in New Issue
Block a user