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:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -0,0 +1,132 @@
# Plan: Slides plugin -> full slide manager (Flask)
Port the classic-ASP slide manager (tv-dashboard/slidemanager.asp + apislides.asp)
into the shopdb-flask `slides` plugin. Two surfaces (lobby display, shopfloor
screensaver), upload / reorder / delete, consumed by the lobby TV dashboard and
the EventSaver screensaver over HTTP.
Existing plugin is read-only (single-folder GET). This extends it to full CRUD +
per-surface + a management UI.
## Decisions (locked for this plan)
- SURFACES: fixed allowlist `lobby`, `shopfloor` (mirrors the ASP allowlist).
Not user-defined - keeps validation + paths simple.
- STORAGE: image FILES on disk, ORDER/metadata in the DB.
- Files: `instance/slides/<surface>/<filename>` (outside static; served via a
plugin route so we control content-type + path-traversal, like slide.asp).
- Metadata: one table (order + seconds + surface). Files are the source of
truth for existence; DB rows that lost their file are ignored + pruned
(same self-healing as GetOrderList in the ASP lib).
- Rationale: native, simple, no BLOBs. BLOB-in-DB is the fallback only if the
deploy host cannot give Flask a writable volume.
- MIGRATION: add the table to the CORE alembic chain (ADR-004 / 7c04 -
single authoritative chain, no per-plugin chain).
- AUTH: admin/CRUD routes JWT-protected. The FEED + image-serve routes are
PUBLIC (the screensaver + lobby kiosks have no auth - matches apislides.asp).
- NAMING: locked convention v1 - lowercase concatenated table/columns;
Python/JS vars mirror column names exactly.
## Data model (core migration)
Table `tvslides` (new, added to core chain):
| column | type | notes |
|--------------|--------------|-----------------------------------------|
| slideid | int PK | |
| surface | varchar(20) | 'lobby' | 'shopfloor' (indexed) |
| filename | varchar(255) | safe basename, unique per surface |
| sortorder | int | play order within surface |
| seconds | int | 0 = use default interval |
| uploadeddate | datetime | |
Unique (surface, filename). Model lives in `plugins/slides/models/tvslide.py`,
imported via `shopdb.api` surface only (contract purity). Registered in the core
migration chain per ADR-004.
## Backend - plugins/slides/
```
plugins/slides/
plugin.py # SlidesPlugin: get_blueprint, get_models, get_navigation_items, on_install
manifest.json # api_prefix /api/slides, provides slideshow + slidemanager
models/tvslide.py # TvSlide model
api/routes.py # blueprint slides_bp
```
Routes (prefix `/api/slides`):
- PUBLIC (no auth) - consumed by screensaver + lobby:
- `GET /feed?surface=lobby|shopfloor`
-> flat shape the EventSaver .scr + lobby expect:
`{success, surface, basepath, interval, slides:[{filename, seconds}]}`
basepath -> the image route below. (Deliberately NOT the success_response
{data:{}} wrapper, so the .scr parser needs no change.)
- `GET /img/<surface>/<filename>` -> serve the image (content-type +
path-traversal guard; send_file from instance/slides/<surface>/).
- ADMIN (JWT) - consumed by the Vue manager:
- `POST /<surface>/upload` -> request.files (native multipart), save +
create TvSlide rows at end of order. Skips non-images. Unique-renames.
- `POST /<surface>/order` -> body {order:[filename,...]} rewrite sortorder,
preserve seconds.
- `POST /<surface>/delete` -> body {files:[...]} delete file + row (multi).
- `PATCH /<surface>/<slideid>` -> {seconds} (kept server-side; UI hidden for now).
- `GET /<surface>` -> admin list (ordered, with slideid) for the UI.
Ordering/natural-sort: order comes from `sortorder`; newly-uploaded files append
in natural (numeric-aware) filename order so Slide1..Slide11 land right (port the
ASP NatKey).
## Frontend - core (no frontend plugin system)
Vue pages live in core `frontend/src/` (per project note - plugins own backend,
Vue pages are core). Add:
- `frontend/src/views/SlideManager.vue`
- Surface tabs (Lobby Display / Shopfloor Screensaver).
- Upload (file input -> POST /upload), thumbnail grid/table.
- Drag reorder via vuedraggable -> POST /order.
- Checkbox multi-select + Delete Selected -> POST /delete.
- Inherits AppLayout (sidebar + topbar + theme) automatically -> matches the
site with ZERO styling work (the whole reason to move off ASP).
- Router entry in `frontend/src/router` (e.g. /slides), guarded (admin).
- Nav: plugin `get_navigation_items()` returns the sidebar entry; the frontend
nav consumes plugin nav hooks (as knowledgebase/notifications do).
- `frontend/src/api/slidesApi.js` wrapper for the admin calls.
## Consumers
- Lobby: `TVDashboard.vue` already hits /api/slides; repoint at `/feed?surface=lobby`.
- Screensaver: change `EventSaver.ini` `url=` to
`https://<flask-host>/api/slides/feed?surface=shopfloor`, rehash the ini +
bump the manifest DetectionValue. The .scr HTTP mode is unchanged because
/feed returns the flat shape it already parses.
## Tests (pytest, per plugin conventions)
- test_plugins/test_slides.py: upload (fake file) creates rows + file; feed
returns ordered flat shape; order persists + preserves seconds; delete removes
file+row; surface allowlist rejects junk; path-traversal guard on /img;
natural sort on append; feed is public / admin routes require JWT.
- Guard test already enforces contract-only imports.
## Phasing
1. Model + core migration + manifest bump.
2. Backend routes (feed + img public; upload/order/delete/list admin) + tests.
3. Vue SlideManager.vue + router + nav + slidesApi.js.
4. Repoint TVDashboard.vue to /feed?surface=lobby.
5. Cut over screensaver: EventSaver.ini url -> Flask /feed, rehash + manifest.
6. Retire classic ASP tv-dashboard slide pages once Flask is live for this site.
## Open decisions / gates
- DEPLOY GATE: shopdb-flask must be deployed + reachable by the screensaver PCs
and lobby, with a writable `instance/slides` volume. Live shopdb is still the
classic ASP box; Flask prod target is docker, not yet deployed here. This plan
is buildable now but not cutover-able until Flask is live.
- Image serving: via a plugin route (send_file, guarded) vs Flask static. Route
chosen for the traversal guard + content-type control (parity with slide.asp).
- Seconds field: model + PATCH kept; UI control hidden for now (matches the ASP
decision to hide per-slide delay).
- BLOB fallback: only if the host denies a writable slides volume.

View File

@@ -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')

View File

@@ -1,12 +1,12 @@
{
"name": "slides",
"version": "1.0.0",
"description": "TV dashboard slideshow images served from a static folder",
"version": "2.0.0",
"description": "Slide manager for the lobby display and shopfloor screensaver (upload/reorder/delete per surface)",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"core_version": ">=0.2.0,<1.0.0",
"api_prefix": "/api/slides",
"provides": {
"features": ["slideshow"]
"features": ["slideshow", "slidemanager"]
}
}

View File

@@ -0,0 +1,5 @@
"""Slides plugin models."""
from .tvslide import TvSlide
__all__ = ['TvSlide']

View File

@@ -0,0 +1,35 @@
"""Slide model for the TV dashboard / screensaver slideshows."""
from shopdb.api import db
class TvSlide(db.Model):
"""One slide image in a surface's playlist.
Image files live on disk at instance/slides/<surface>/<filename>; this row
holds the play order + per-slide duration. Files are the source of truth for
existence - rows whose file is gone are ignored and pruned.
"""
__tablename__ = 'tvslides'
slideid = db.Column(db.Integer, primary_key=True)
surface = db.Column(db.String(20), nullable=False, index=True)
filename = db.Column(db.String(255), nullable=False)
sortorder = db.Column(db.Integer, default=0, nullable=False)
seconds = db.Column(db.Integer, default=0, nullable=False) # 0 = use default interval
uploadeddate = db.Column(db.DateTime, default=db.func.now())
# No DB-level unique on (surface, filename): utf8mb4 pushes that index past
# MySQL 5.6's 767-byte limit. Upload unique-renames, so dupes can't occur.
__table_args__ = (
db.Index('idx_tvslide_surface', 'surface'),
)
def to_dict(self):
return {
'slideid': self.slideid,
'surface': self.surface,
'filename': self.filename,
'sortorder': self.sortorder,
'seconds': self.seconds,
}

View File

@@ -15,6 +15,7 @@ from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .api import slides_bp
from .models import TvSlide
logger = logging.getLogger(__name__)
@@ -54,8 +55,19 @@ class SlidesPlugin(BasePlugin):
return slides_bp
def get_models(self) -> List[Type]:
"""No models - slides are read from the filesystem."""
return []
"""Slide playlist metadata (image files live on disk)."""
return [TvSlide]
def get_navigation_items(self) -> List[Dict]:
"""Sidebar entry for the slide manager (admin)."""
return [
{
'name': 'Slides',
'icon': 'image',
'route': '/settings/slides',
'position': 7,
},
]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""