From 8dd1fadecaad05b012b9736046459d5a8788d240 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 16 Jul 2026 16:44:54 -0400 Subject: [PATCH] printedparts stage 1: scaffold, no AssetType, manifest per spec flask plugin new output, minus the scaffold's AssetType seeding: printed parts are quantity-based consumables, not ADR-001 assets. on_install seeds the three plugin settings instead. Manifest pins core >=0.11.0, depends on employees (badge name resolution), ships disabled until a site opts in. --- frontend/src/router/routes/printedparts.js | 35 +++ .../views/printedparts/PrintedpartsDetail.vue | 149 ++++++++++++ .../views/printedparts/PrintedpartsForm.vue | 228 ++++++++++++++++++ .../views/printedparts/PrintedpartsList.vue | 142 +++++++++++ plugins/printedparts/README.md | 43 ++++ plugins/printedparts/__init__.py | 5 + plugins/printedparts/api/__init__.py | 5 + plugins/printedparts/api/routes.py | 45 ++++ plugins/printedparts/frontend-api-snippet.js | 35 +++ plugins/printedparts/manifest.json | 11 + plugins/printedparts/models/__init__.py | 5 + plugins/printedparts/models/printedparts.py | 32 +++ plugins/printedparts/plugin.py | 72 ++++++ plugins/printedparts/schemas/__init__.py | 6 + plugins/printedparts/tests/__init__.py | 0 plugins/printedparts/tests/test_plugin.py | 30 +++ 16 files changed, 843 insertions(+) create mode 100644 frontend/src/router/routes/printedparts.js create mode 100644 frontend/src/views/printedparts/PrintedpartsDetail.vue create mode 100644 frontend/src/views/printedparts/PrintedpartsForm.vue create mode 100644 frontend/src/views/printedparts/PrintedpartsList.vue create mode 100644 plugins/printedparts/README.md create mode 100644 plugins/printedparts/__init__.py create mode 100644 plugins/printedparts/api/__init__.py create mode 100644 plugins/printedparts/api/routes.py create mode 100644 plugins/printedparts/frontend-api-snippet.js create mode 100644 plugins/printedparts/manifest.json create mode 100644 plugins/printedparts/models/__init__.py create mode 100644 plugins/printedparts/models/printedparts.py create mode 100644 plugins/printedparts/plugin.py create mode 100644 plugins/printedparts/schemas/__init__.py create mode 100644 plugins/printedparts/tests/__init__.py create mode 100644 plugins/printedparts/tests/test_plugin.py diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js new file mode 100644 index 0000000..244544c --- /dev/null +++ b/frontend/src/router/routes/printedparts.js @@ -0,0 +1,35 @@ +/** + * Printedparts plugin routes. + * + * Auto-discovered by the router via import.meta.glob, so no registration + * edit is needed. Every route carries meta.plugin 'printedparts' so the ADR-009 + * guard redirects to the dashboard when the printedparts backend plugin is + * disabled. Form routes add requiresAuth so anonymous users cannot reach + * create or edit. + */ +export default [ + { + path: 'printedparts', + name: 'printedparts', + component: () => import('../../views/printedparts/PrintedpartsList.vue'), + meta: { plugin: 'printedparts' } + }, + { + path: 'printedparts/new', + name: 'printedparts-new', + component: () => import('../../views/printedparts/PrintedpartsForm.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/:id', + name: 'printedparts-detail', + component: () => import('../../views/printedparts/PrintedpartsDetail.vue'), + meta: { plugin: 'printedparts' } + }, + { + path: 'printedparts/:id/edit', + name: 'printedparts-edit', + component: () => import('../../views/printedparts/PrintedpartsForm.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + } +] diff --git a/frontend/src/views/printedparts/PrintedpartsDetail.vue b/frontend/src/views/printedparts/PrintedpartsDetail.vue new file mode 100644 index 0000000..be6a777 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedpartsDetail.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedpartsForm.vue b/frontend/src/views/printedparts/PrintedpartsForm.vue new file mode 100644 index 0000000..32e1036 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedpartsForm.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedpartsList.vue b/frontend/src/views/printedparts/PrintedpartsList.vue new file mode 100644 index 0000000..f7e18d8 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedpartsList.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/plugins/printedparts/README.md b/plugins/printedparts/README.md new file mode 100644 index 0000000..0fd0cf6 --- /dev/null +++ b/plugins/printedparts/README.md @@ -0,0 +1,43 @@ +# Printedparts plugin + +3D-printed parts inventory + kiosk checkout + +This plugin was generated by `flask plugin new printedparts`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain. + +## What's here + +- `plugin.py` - the `PrintedpartsPlugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data. +- `models/printedparts.py` - example Asset extension table. Replace `examplefield` with your domain fields. +- `api/routes.py` - example list and detail endpoints. Add CRUD as needed. +- `schemas/__init__.py` - marshmallow schema stub for request/response validation. +- `tests/test_plugin.py` - smoke tests asserting contract compliance. +- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad. + +## Common edits + +| You want to... | Do this | +|---|---| +| Add a hook (search, navigation, dashboard widget) | Override the method in `PrintedpartsPlugin`. See `docs/PLUGIN-HOOKS.md`. | +| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. | +| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. | +| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. | + +## Frontend + +Vue components for this plugin live under `frontend/src/views/printedparts/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. + +## Install and run + +```bash +flask plugin install printedparts +flask db migrate -m "Add printedparts plugin tables" +flask db upgrade +pytest plugins/printedparts/tests/ +``` + +## References + +- `docs/PLUGIN-HOOKS.md` - canonical hook reference +- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough +- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract +- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules diff --git a/plugins/printedparts/__init__.py b/plugins/printedparts/__init__.py new file mode 100644 index 0000000..90bc9c3 --- /dev/null +++ b/plugins/printedparts/__init__.py @@ -0,0 +1,5 @@ +"""Printedparts plugin package.""" + +from .plugin import PrintedpartsPlugin + +__all__ = ['PrintedpartsPlugin'] diff --git a/plugins/printedparts/api/__init__.py b/plugins/printedparts/api/__init__.py new file mode 100644 index 0000000..2043056 --- /dev/null +++ b/plugins/printedparts/api/__init__.py @@ -0,0 +1,5 @@ +"""Printedparts plugin API package.""" + +from .routes import printedparts_bp + +__all__ = ['printedparts_bp'] diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py new file mode 100644 index 0000000..d847cad --- /dev/null +++ b/plugins/printedparts/api/routes.py @@ -0,0 +1,45 @@ +"""Printedparts plugin API routes.""" + +from flask import Blueprint, request +from flask_jwt_extended import jwt_required + +from shopdb.api import ( + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, +) + +from ..models import Printedparts + + +printedparts_bp = Blueprint('printedparts', __name__) + + +@printedparts_bp.route('', methods=['GET']) +@jwt_required(optional=True) +def list_printedparts(): + """List printedparts assets, paginated.""" + page, per_page = get_pagination_params(request) + + query = Printedparts.query + items, total = paginate_query(query, page, per_page) + data = [item.to_dict() for item in items] + + return paginated_response(data, page, per_page, total) + + +@printedparts_bp.route('/', methods=['GET']) +@jwt_required(optional=True) +def get_printedparts(assetid: int): + """Get a single printedparts by assetid.""" + item = Printedparts.query.get(assetid) + if not item: + return error_response( + ErrorCodes.NOT_FOUND, + f'Printedparts with assetid {assetid} not found', + http_code=404, + ) + return success_response(item.to_dict()) diff --git a/plugins/printedparts/frontend-api-snippet.js b/plugins/printedparts/frontend-api-snippet.js new file mode 100644 index 0000000..7dfebb8 --- /dev/null +++ b/plugins/printedparts/frontend-api-snippet.js @@ -0,0 +1,35 @@ +/* + * Printedparts API client snippet. + * + * Paste this printedpartsApi block into frontend/src/api/index.js (next to the + * other per-resource blocks). Then, in the generated PrintedpartsList, PrintedpartsDetail, + * and PrintedpartsForm views, delete the local printedpartsApi const and import the shared + * one instead: + * + * import { printedpartsApi } from '../../api' + * + * The scaffolded views ship with an identical inline client so they build and + * run before you touch the shared api module. This file is NOT auto-merged into + * api/index.js on purpose; that module is hand-maintained and shared. + * + * The create/update/delete calls assume matching POST/PUT/DELETE routes exist + * on the backend. The scaffolded api/routes.py only ships list and get; add the + * write endpoints when you wire up the form. + */ +export const printedpartsApi = { + list(params = {}) { + return api.get('/printedparts', { params }) + }, + get(itemId) { + return api.get(`/printedparts/${itemId}`) + }, + create(data) { + return api.post('/printedparts', data) + }, + update(itemId, data) { + return api.put(`/printedparts/${itemId}`, data) + }, + remove(itemId) { + return api.delete(`/printedparts/${itemId}`) + } +} diff --git a/plugins/printedparts/manifest.json b/plugins/printedparts/manifest.json new file mode 100644 index 0000000..23d963c --- /dev/null +++ b/plugins/printedparts/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "printedparts", + "version": "0.1.0", + "description": "3D-printed parts inventory + kiosk checkout", + "display_name": "3D Printed Parts", + "author": "", + "dependencies": ["employees"], + "core_version": ">=0.11.0,<1.0.0", + "api_prefix": "/api/printedparts", + "default_enabled": false +} diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py new file mode 100644 index 0000000..05d90e9 --- /dev/null +++ b/plugins/printedparts/models/__init__.py @@ -0,0 +1,5 @@ +"""Printedparts plugin models.""" + +from .printedparts import Printedparts + +__all__ = ['Printedparts'] diff --git a/plugins/printedparts/models/printedparts.py b/plugins/printedparts/models/printedparts.py new file mode 100644 index 0000000..e070a7f --- /dev/null +++ b/plugins/printedparts/models/printedparts.py @@ -0,0 +1,32 @@ +"""Printedparts model. + +This is an Asset extension table keyed by assetid. The Asset row holds +the platform fields (assetnumber, name, vendorid, locationid, etc.); +this table holds the printedparts-specific fields. Replace the example fields +below with your domain model. +""" + +from shopdb.api import db, BaseModel + + +class Printedparts(BaseModel): + """Printedparts domain entity, extending Asset by assetid.""" + + __tablename__ = 'printedparts' + + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + primary_key=True, + ) + + # TODO: replace these example fields with your domain fields. + examplefield = db.Column(db.String(255), nullable=True) + + asset = db.relationship('Asset', backref=db.backref('printedparts', uselist=False)) + + def to_dict(self): + return { + 'assetid': self.assetid, + 'examplefield': self.examplefield, + } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py new file mode 100644 index 0000000..fec4782 --- /dev/null +++ b/plugins/printedparts/plugin.py @@ -0,0 +1,72 @@ +"""Printedparts plugin main class. + +3D-printed parts inventory + kiosk checkout. Quantity-based consumables: +one row is a KIND of part with a count, not an individually tracked asset, +so unlike most plugins this one seeds NO AssetType (ADR-001 assets are +one-row-per-physical-thing). See docs/proposals/printedparts-plugin.md. +""" + +import json +import logging +from pathlib import Path +from typing import List, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta +from shopdb.api import db, Setting + +from .models import Printedparts +from .api import printedparts_bp + +logger = logging.getLogger(__name__) + + +class PrintedpartsPlugin(BasePlugin): + """3D-printed parts inventory + kiosk checkout.""" + + def __init__(self): + manifest_path = Path(__file__).parent / 'manifest.json' + with open(manifest_path) as f: + self._manifest = json.load(f) + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest['name'], + version=self._manifest['version'], + description=self._manifest['description'], + author=self._manifest.get('author', ''), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0'), + api_prefix=self._manifest.get('api_prefix'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return printedparts_bp + + def get_models(self) -> List[Type]: + return [Printedparts] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info(f'Printedparts plugin initialized (v{self.meta.version})') + + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._seed_settings() + logger.info('Printedparts plugin installed') + + def _seed_settings(self) -> None: + defaults = [ + ('printedparts_code_prefix', '3DP', 'string', + 'Prefix for generated item codes'), + ('printedparts_default_threshold', '5', 'integer', + 'Default low-stock threshold for new items'), + ('printedparts_unknown_badge', 'deny', 'string', + 'Kiosk policy when a badge resolves to no employee: allow or deny'), + ] + for key, value, valuetype, description in defaults: + if Setting.get(key) is None: + Setting.set(key, value, valuetype=valuetype, + category='printedparts', description=description) + db.session.commit() diff --git a/plugins/printedparts/schemas/__init__.py b/plugins/printedparts/schemas/__init__.py new file mode 100644 index 0000000..adde023 --- /dev/null +++ b/plugins/printedparts/schemas/__init__.py @@ -0,0 +1,6 @@ +"""Printedparts plugin schemas (marshmallow). + +Add schema classes here when you need request/response validation +beyond the simple to_dict() output. The framework wires marshmallow +into the response helpers; see docs/PLUGIN-HOOKS.md for details. +""" diff --git a/plugins/printedparts/tests/__init__.py b/plugins/printedparts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/printedparts/tests/test_plugin.py b/plugins/printedparts/tests/test_plugin.py new file mode 100644 index 0000000..8ec0d05 --- /dev/null +++ b/plugins/printedparts/tests/test_plugin.py @@ -0,0 +1,30 @@ +"""Printedparts plugin smoke tests. + +Asserts the plugin loads cleanly and satisfies the framework contract. +Replace and extend with domain tests as you build the plugin out. +""" + +from plugins.printedparts.plugin import PrintedpartsPlugin + + +def test_printedparts_plugin_meta_is_valid(): + """PrintedpartsPlugin.meta returns a PluginMeta with the expected name.""" + plugin = PrintedpartsPlugin() + assert plugin.meta.name == 'printedparts' + assert plugin.meta.api_prefix == '/api/printedparts' + + +def test_printedparts_plugin_get_blueprint_returns_blueprint(): + """get_blueprint returns a Flask Blueprint, not None.""" + from flask import Blueprint + plugin = PrintedpartsPlugin() + assert isinstance(plugin.get_blueprint(), Blueprint) + + +def test_printedparts_plugin_get_models_returns_a_model(): + """get_models returns a list with at least one SQLAlchemy model.""" + plugin = PrintedpartsPlugin() + models = plugin.get_models() + assert len(models) >= 1 + for model in models: + assert hasattr(model, '__tablename__')