Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 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

@@ -0,0 +1,5 @@
"""Slides plugin package."""
from .plugin import SlidesPlugin
__all__ = ['SlidesPlugin']

View File

@@ -0,0 +1,5 @@
"""Slides plugin API."""
from .routes import slides_bp
__all__ = ['slides_bp']

View File

@@ -0,0 +1,210 @@
"""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')

View File

@@ -0,0 +1,12 @@
{
"name": "slides",
"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.2.0,<1.0.0",
"api_prefix": "/api/slides",
"provides": {
"features": ["slideshow", "slidemanager"]
}
}

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the slides plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_slides. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'slides'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,28 @@
"""slides plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_slides. From this
anchor forward, new slides schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'slides0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

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

View File

@@ -0,0 +1,42 @@
"""Slide model for the TV dashboard / screensaver slideshows."""
from datetime import datetime, timezone
from shopdb.api import db
def _utcnow():
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
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=_utcnow)
# 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,
}

74
plugins/slides/plugin.py Normal file
View File

@@ -0,0 +1,74 @@
"""Slides plugin main class.
Display/service plugin: contributes a blueprint only (no model, no AssetType,
no nav). Serves TV-dashboard slideshow images. Demonstrates the minimal plugin
shape - a feature that is purely an API surface.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
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__)
class SlidesPlugin(BasePlugin):
"""Slides plugin - TV dashboard slideshow images."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'slides'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'TV dashboard slideshow images'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/slides'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return slides_bp
def get_models(self) -> List[Type]:
"""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."""
logger.info(f"Slides plugin initialized (v{self.meta.version})")