Files
shopdb-flask/plugins/slides/api/routes.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00

211 lines
7.6 KiB
Python

"""Slides API: lobby-display + shopfloor-screensaver slideshows.
Image files live on disk at instance/slides/<surface>/; 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/<surface>/<path:filename>', 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('/<surface>', 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('/<surface>/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('/<surface>/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('/<surface>/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('/<surface>/<int:slideid>', 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 = db.session.get(TvSlide, 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')