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:
132
plugins/slides/PLAN-slide-manager.md
Normal file
132
plugins/slides/PLAN-slide-manager.md
Normal 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.
|
||||
5
plugins/slides/__init__.py
Normal file
5
plugins/slides/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Slides plugin package."""
|
||||
|
||||
from .plugin import SlidesPlugin
|
||||
|
||||
__all__ = ['SlidesPlugin']
|
||||
5
plugins/slides/api/__init__.py
Normal file
5
plugins/slides/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Slides plugin API."""
|
||||
|
||||
from .routes import slides_bp
|
||||
|
||||
__all__ = ['slides_bp']
|
||||
210
plugins/slides/api/routes.py
Normal file
210
plugins/slides/api/routes.py
Normal 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')
|
||||
12
plugins/slides/manifest.json
Normal file
12
plugins/slides/manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
14
plugins/slides/migrations/env.py
Normal file
14
plugins/slides/migrations/env.py
Normal 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()
|
||||
24
plugins/slides/migrations/script.py.mako
Normal file
24
plugins/slides/migrations/script.py.mako
Normal 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"}
|
||||
28
plugins/slides/migrations/versions/0001_slides_anchor.py
Normal file
28
plugins/slides/migrations/versions/0001_slides_anchor.py
Normal 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
|
||||
5
plugins/slides/models/__init__.py
Normal file
5
plugins/slides/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Slides plugin models."""
|
||||
|
||||
from .tvslide import TvSlide
|
||||
|
||||
__all__ = ['TvSlide']
|
||||
42
plugins/slides/models/tvslide.py
Normal file
42
plugins/slides/models/tvslide.py
Normal 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
74
plugins/slides/plugin.py
Normal 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})")
|
||||
Reference in New Issue
Block a user