Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session: Settings IA - Replace the flat 27-card settings hub with a persistent two-pane shell (SettingsLayout.vue): grouped, searchable left rail + content pane. - Nest all settings/* routes under the shell via router post-processing; shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers, Equipment, Network) so per-type settings stop scattering. Custom fields (core) - customfields + customfieldvalues tables (migration 7d14), CRUD API at /api/customfields, per-asset value get/save. - Settings management page + reusable CustomFieldsSection (detail) and CustomFieldsInputs (form) wired into all four asset types. Warranty (new plugin) - plugins/warranty: warranties + warrantyassets (migration 7d15), derived coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs). - API CRUD + per-asset panel + report buckets; WarrantyPanel on all four detail pages; Warranties management page; Warranty report + Reports card. - Seed warranty.* permissions. Printer drivers - printerdrivers table (migration 7d13) linked to printer models; drivers now surface on the matching printer's detail page. Other - PCDetail rebalanced (Network + Status + Warranty + custom fields on the right). - Rename PCs list "Features" column to "Remote Access"; fix badge hover underline. - Drop equipment islocationonly field. - Centralize asset-type label/route maps into utils/assetTypes.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,71 +1,210 @@
|
||||
"""Slides API for the TV dashboard slideshow."""
|
||||
"""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
|
||||
from flask import Blueprint, current_app
|
||||
import re
|
||||
|
||||
from shopdb.api import success_response, error_response, ErrorCodes
|
||||
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__)
|
||||
|
||||
# Valid image extensions
|
||||
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
|
||||
|
||||
|
||||
@slides_bp.route('', methods=['GET'])
|
||||
def get_slides():
|
||||
"""Get list of slides for the TV dashboard.
|
||||
def _surface_dir(surface):
|
||||
return os.path.join(current_app.instance_path, 'slides', surface)
|
||||
|
||||
Returns image files from the static/slides directory.
|
||||
"""
|
||||
# Look for slides in static folder
|
||||
static_folder = current_app.static_folder
|
||||
if not static_folder:
|
||||
static_folder = os.path.join(current_app.root_path, 'static')
|
||||
|
||||
slides_folder = os.path.join(static_folder, 'slides')
|
||||
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)]
|
||||
|
||||
# Also check frontend public folder
|
||||
frontend_slides = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(current_app.root_path))),
|
||||
'frontend', 'public', 'slides'
|
||||
)
|
||||
|
||||
# Try multiple possible locations
|
||||
possible_paths = [
|
||||
slides_folder,
|
||||
frontend_slides,
|
||||
'/home/camp/projects/shopdb-flask/shopdb/static/slides',
|
||||
'/home/camp/projects/shopdb-flask/frontend/public/slides',
|
||||
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))
|
||||
]
|
||||
|
||||
slides_path = None
|
||||
for path in possible_paths:
|
||||
if os.path.isdir(path):
|
||||
slides_path = path
|
||||
break
|
||||
|
||||
if not slides_path:
|
||||
return success_response({
|
||||
'slides': [],
|
||||
'basepath': '/static/slides/',
|
||||
'message': 'Slides folder not found'
|
||||
})
|
||||
|
||||
# Get list of image files
|
||||
slides = []
|
||||
try:
|
||||
for filename in sorted(os.listdir(slides_path)):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext in VALID_EXTENSIONS:
|
||||
slides.append({'filename': filename})
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
f'Error reading slides: {str(e)}',
|
||||
http_code=500
|
||||
)
|
||||
|
||||
return success_response({
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'surface': surface,
|
||||
'basepath': f'/api/slides/img/{surface}/',
|
||||
'interval': DEFAULT_INTERVAL,
|
||||
'slides': slides,
|
||||
'basepath': '/static/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 = 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')
|
||||
|
||||
Reference in New Issue
Block a user