"""Slides API: lobby-display + shopfloor-screensaver slideshows. Image files live on disk at instance/slides//; TvSlide rows hold play order + per-slide seconds. Feed + image routes are PUBLIC (kiosks/screensaver have no auth); management routes are admin-only. """ import os import re from flask import Blueprint, request, current_app, jsonify, send_from_directory from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename from shopdb.api import db, success_response, error_response, ErrorCodes, require_role from ..models import TvSlide slides_bp = Blueprint('slides', __name__) SURFACES = frozenset({'lobby', 'shopfloor'}) VALID_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} DEFAULT_INTERVAL = 10 # seconds per slide when a slide's own seconds is 0 def _surface_dir(surface): return os.path.join(current_app.instance_path, 'slides', surface) def _natkey(name): """Natural sort key so Slide1..Slide11 order numerically, not lexically.""" return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', name)] def _valid_surface(surface): return surface in SURFACES def _is_image(filename): return os.path.splitext(filename)[1].lower() in VALID_EXTENSIONS # ============================================================================= # PUBLIC - consumed by the lobby dashboard + the screensaver # ============================================================================= @slides_bp.route('/feed', methods=['GET']) def feed(): """Flat playlist for a surface. NOT wrapped in the success_response envelope so the screensaver's existing parser needs no change.""" surface = request.args.get('surface', 'lobby') if not _valid_surface(surface): surface = 'lobby' directory = _surface_dir(surface) rows = (TvSlide.query.filter_by(surface=surface) .order_by(TvSlide.sortorder, TvSlide.slideid).all()) slides = [ {'filename': r.filename, 'seconds': r.seconds or DEFAULT_INTERVAL} for r in rows if os.path.isfile(os.path.join(directory, r.filename)) ] return jsonify({ 'success': True, 'surface': surface, 'basepath': f'/api/slides/img/{surface}/', 'interval': DEFAULT_INTERVAL, 'slides': slides, }) @slides_bp.route('/img//', methods=['GET']) def serve_image(surface, filename): """Serve a slide image with a path-traversal guard.""" if not _valid_surface(surface): return error_response(ErrorCodes.NOT_FOUND, 'Unknown surface', http_code=404) safe = os.path.basename(filename) if safe != filename or not safe: return error_response(ErrorCodes.NOT_FOUND, 'Invalid filename', http_code=404) directory = _surface_dir(surface) if not os.path.isfile(os.path.join(directory, safe)): return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) return send_from_directory(directory, safe) # ============================================================================= # ADMIN - the Vue slide manager # ============================================================================= @slides_bp.route('/', methods=['GET']) @jwt_required() @require_role('admin') def list_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') directory = _surface_dir(surface) rows = (TvSlide.query.filter_by(surface=surface) .order_by(TvSlide.sortorder, TvSlide.slideid).all()) out = [] for r in rows: if os.path.isfile(os.path.join(directory, r.filename)): item = r.to_dict() item['url'] = f'/api/slides/img/{surface}/{r.filename}' out.append(item) return success_response(out) @slides_bp.route('//upload', methods=['POST']) @jwt_required() @require_role('admin') def upload_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') files = request.files.getlist('files') if not files and 'file' in request.files: files = [request.files['file']] if not files: return error_response(ErrorCodes.VALIDATION_ERROR, 'No files uploaded') directory = _surface_dir(surface) os.makedirs(directory, exist_ok=True) # Append after the current max order; upload in natural filename order. maxorder = db.session.query(db.func.max(TvSlide.sortorder)).filter_by(surface=surface).scalar() order = (maxorder or 0) existing = {r.filename for r in TvSlide.query.filter_by(surface=surface).all()} added = [] for f in sorted(files, key=lambda x: _natkey(x.filename or '')): if not f or not f.filename or not _is_image(f.filename): continue base = secure_filename(os.path.basename(f.filename)) if not base: continue # Unique-rename if the name is taken (on disk or in db). stem, ext = os.path.splitext(base) name = base n = 1 while name in existing or os.path.exists(os.path.join(directory, name)): name = f"{stem}_{n}{ext}" n += 1 f.save(os.path.join(directory, name)) existing.add(name) order += 1 db.session.add(TvSlide(surface=surface, filename=name, sortorder=order, seconds=0)) added.append(name) db.session.commit() return success_response({'added': added}, message=f'{len(added)} slide(s) uploaded') @slides_bp.route('//order', methods=['POST']) @jwt_required() @require_role('admin') def reorder_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') data = request.get_json() or {} order = data.get('order') or [] for i, filename in enumerate(order): row = TvSlide.query.filter_by(surface=surface, filename=filename).first() if row: row.sortorder = i db.session.commit() return success_response(message='Order saved') @slides_bp.route('//delete', methods=['POST']) @jwt_required() @require_role('admin') def delete_slides(surface): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') data = request.get_json() or {} files = data.get('files') or [] directory = _surface_dir(surface) removed = 0 for filename in files: safe = os.path.basename(filename) row = TvSlide.query.filter_by(surface=surface, filename=safe).first() fpath = os.path.join(directory, safe) if os.path.isfile(fpath): try: os.remove(fpath) except OSError: pass if row: db.session.delete(row) removed += 1 db.session.commit() return success_response(message=f'{removed} slide(s) deleted') @slides_bp.route('//', methods=['PATCH']) @jwt_required() @require_role('admin') def update_slide(surface, slideid): if not _valid_surface(surface): return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface') row = TvSlide.query.get(slideid) if not row or row.surface != surface: return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) data = request.get_json() or {} if 'seconds' in data: try: row.seconds = max(0, int(data['seconds'])) except (TypeError, ValueError): return error_response(ErrorCodes.VALIDATION_ERROR, 'seconds must be an integer') db.session.commit() return success_response(row.to_dict(), message='Slide updated')