From 8dd1fadecaad05b012b9736046459d5a8788d240 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 16 Jul 2026 16:44:54 -0400 Subject: [PATCH 01/19] 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__') From f5cfac33b4b0f6701587da660d87e130d2552caa Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 16 Jul 2026 16:57:21 -0400 Subject: [PATCH 02/19] printedparts stage 2: models, real 0001 baseline, tables live PrintedItem (catalog: code, name, image, cached quantityonhand, per-item threshold, bin) and PrintedItemTransaction (the ledger: signed quantity change attributed to a badge-resolved employee). Both registered in PLUGIN_TABLE_OWNERS; 0001 is a post-cutover real baseline. The migration-guard test learns the new expected head. Routes are a placeholder ping until the next stage - the scaffold's list route imported the deleted scaffold model, which surfaces as an empty 'Migration error' because the alembic env imports the models package. --- plugins/printedparts/api/routes.py | 49 +++------- plugins/printedparts/migrations/env.py | 14 +++ .../printedparts/migrations/script.py.mako | 24 +++++ .../versions/0001_printedparts_baseline.py | 71 ++++++++++++++ plugins/printedparts/models/__init__.py | 4 +- plugins/printedparts/models/printeditem.py | 95 +++++++++++++++++++ plugins/printedparts/models/printedparts.py | 32 ------- plugins/printedparts/plugin.py | 4 +- shopdb/plugins/alembic_template.py | 1 + tests/test_plugin_migrations.py | 2 + 10 files changed, 222 insertions(+), 74 deletions(-) create mode 100644 plugins/printedparts/migrations/env.py create mode 100644 plugins/printedparts/migrations/script.py.mako create mode 100644 plugins/printedparts/migrations/versions/0001_printedparts_baseline.py create mode 100644 plugins/printedparts/models/printeditem.py delete mode 100644 plugins/printedparts/models/printedparts.py diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index d847cad..73d11ff 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -1,45 +1,18 @@ -"""Printedparts plugin API routes.""" +"""Printedparts plugin API routes. -from flask import Blueprint, request -from flask_jwt_extended import jwt_required +Stage 2 placeholder: the blueprint must import cleanly for plugin discovery +and migrations (the alembic env imports the models package, which pulls in +plugin.py and this module). Real endpoints land in the next stage. +""" -from shopdb.api import ( - success_response, - error_response, - paginated_response, - ErrorCodes, - get_pagination_params, - paginate_query, -) - -from ..models import Printedparts +from flask import Blueprint +from shopdb.api import success_response 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()) +@printedparts_bp.route('/ping', methods=['GET']) +def ping(): + """Liveness probe for the lab: proves the blueprint is registered.""" + return success_response({'plugin': 'printedparts', 'status': 'ok'}) diff --git a/plugins/printedparts/migrations/env.py b/plugins/printedparts/migrations/env.py new file mode 100644 index 0000000..1490d7a --- /dev/null +++ b/plugins/printedparts/migrations/env.py @@ -0,0 +1,14 @@ +"""Alembic environment for the printedparts 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_printedparts (ADR-008). This plugin +is NEW (post-cutover): its 0001 baseline really CREATES its tables. +""" +import os + +os.environ['PLUGIN_NAME'] = 'printedparts' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/printedparts/migrations/script.py.mako b/plugins/printedparts/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/plugins/printedparts/migrations/script.py.mako @@ -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"} diff --git a/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py new file mode 100644 index 0000000..f0bb348 --- /dev/null +++ b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py @@ -0,0 +1,71 @@ +"""printedparts plugin baseline (real create). + +Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator +of printeditems and printeditemtransactions - the core chain never knew them. +Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`) +after `flask db upgrade` builds the core schema. + +Both tables are self-contained (the only FK is transactions -> items inside +the plugin), so the shared create_plugin_tables helper would work here; the +ops are written out explicitly anyway to match the measuringtools exemplar +and keep the baseline reviewable. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'printedparts0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'printeditems', + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('itemcode', sa.String(length=20), nullable=True), + sa.Column('itemname', sa.String(length=120), nullable=False), + sa.Column('itemdescription', sa.String(length=500), nullable=True), + sa.Column('imageurl', sa.String(length=255), nullable=True), + sa.Column('quantityonhand', sa.Integer(), nullable=False), + sa.Column('lowstockthreshold', sa.Integer(), nullable=False), + sa.Column('binlocation', sa.String(length=100), nullable=True), + sa.Column('printnotes', sa.Text(), nullable=True), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('printeditemid'), + sa.UniqueConstraint('itemcode'), + ) + op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode']) + + op.create_table( + 'printeditemtransactions', + sa.Column('transactionid', sa.Integer(), nullable=False), + sa.Column('printeditemid', sa.Integer(), nullable=False), + sa.Column('transactiontype', sa.String(length=10), nullable=False), + sa.Column('quantitychange', sa.Integer(), nullable=False), + sa.Column('employeesso', sa.String(length=20), nullable=False), + sa.Column('employeename', sa.String(length=120), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=True), + sa.Column('transactiondate', sa.DateTime(), nullable=False), + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('transactionid'), + ) + op.create_index('ix_printeditemtransactions_printeditemid', + 'printeditemtransactions', ['printeditemid']) + op.create_index('ix_printeditemtransactions_employeesso', + 'printeditemtransactions', ['employeesso']) + op.create_index('ix_printeditemtransactions_transactiondate', + 'printeditemtransactions', ['transactiondate']) + + +def downgrade(): + op.drop_table('printeditemtransactions') + op.drop_table('printeditems') diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py index 05d90e9..bc0397c 100644 --- a/plugins/printedparts/models/__init__.py +++ b/plugins/printedparts/models/__init__.py @@ -1,5 +1,5 @@ """Printedparts plugin models.""" -from .printedparts import Printedparts +from .printeditem import PrintedItem, PrintedItemTransaction, TRANSACTION_TYPES -__all__ = ['Printedparts'] +__all__ = ['PrintedItem', 'PrintedItemTransaction', 'TRANSACTION_TYPES'] diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py new file mode 100644 index 0000000..b45ee49 --- /dev/null +++ b/plugins/printedparts/models/printeditem.py @@ -0,0 +1,95 @@ +"""Printedparts models. + +PrintedItem is a KIND of 3D-printed part with a quantity on hand - a +consumable, not an ADR-001 asset (which is one row per physical thing). +PrintedItemTransaction is the ledger: every take, restock, and adjust as a +signed quantity change attributed to a badge-resolved employee. The ledger is +the source of truth; quantityonhand is a cache moved in the same commit as +each ledger write, and the stock report reconciles the two. +""" + +from datetime import datetime, timezone + +from shopdb.api import db, BaseModel + + +def _utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +TRANSACTION_TYPES = ('take', 'restock', 'adjust') + + +class PrintedItem(BaseModel): + """A printable part the engineers stock in bins.""" + + __tablename__ = 'printeditems' + + printeditemid = db.Column(db.Integer, primary_key=True) + itemcode = db.Column(db.String(20), unique=True, index=True, + comment='Generated bin-label code, e.g. 3DP-0042') + itemname = db.Column(db.String(120), nullable=False) + itemdescription = db.Column(db.String(500)) + imageurl = db.Column(db.String(255)) + quantityonhand = db.Column(db.Integer, nullable=False, default=0) + lowstockthreshold = db.Column(db.Integer, nullable=False, default=5) + binlocation = db.Column(db.String(100)) + printnotes = db.Column(db.Text, comment='Material, print time, slicer file') + + transactions = db.relationship( + 'PrintedItemTransaction', backref='printeditem', + cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic') + + @property + def islowstock(self): + return self.quantityonhand <= self.lowstockthreshold + + def to_dict(self): + return { + 'printeditemid': self.printeditemid, + 'itemcode': self.itemcode, + 'itemname': self.itemname, + 'itemdescription': self.itemdescription, + 'imageurl': self.imageurl, + 'quantityonhand': self.quantityonhand, + 'lowstockthreshold': self.lowstockthreshold, + 'islowstock': self.islowstock, + 'binlocation': self.binlocation, + 'printnotes': self.printnotes, + 'isactive': self.isactive, + 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, + } + + +class PrintedItemTransaction(BaseModel): + """One signed stock movement, always attributed to an employee.""" + + __tablename__ = 'printeditemtransactions' + + transactionid = db.Column(db.Integer, primary_key=True) + printeditemid = db.Column( + db.Integer, + db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'), + nullable=False, index=True) + transactiontype = db.Column(db.String(10), nullable=False, + comment='take, restock, or adjust') + quantitychange = db.Column(db.Integer, nullable=False, + comment='Negative for take, signed for adjust') + employeesso = db.Column(db.String(20), nullable=False, index=True) + employeename = db.Column(db.String(120)) + reason = db.Column(db.String(255)) + transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow, + index=True) + + def to_dict(self): + return { + 'transactionid': self.transactionid, + 'printeditemid': self.printeditemid, + 'transactiontype': self.transactiontype, + 'quantitychange': self.quantitychange, + 'employeesso': self.employeesso, + 'employeename': self.employeename, + 'reason': self.reason, + 'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None, + } diff --git a/plugins/printedparts/models/printedparts.py b/plugins/printedparts/models/printedparts.py deleted file mode 100644 index e070a7f..0000000 --- a/plugins/printedparts/models/printedparts.py +++ /dev/null @@ -1,32 +0,0 @@ -"""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 index fec4782..89cf221 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -16,7 +16,7 @@ from flask import Flask, Blueprint from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.api import db, Setting -from .models import Printedparts +from .models import PrintedItem, PrintedItemTransaction from .api import printedparts_bp logger = logging.getLogger(__name__) @@ -46,7 +46,7 @@ class PrintedpartsPlugin(BasePlugin): return printedparts_bp def get_models(self) -> List[Type]: - return [Printedparts] + return [PrintedItem, PrintedItemTransaction] def init_app(self, app: Flask, db_instance) -> None: logger.info(f'Printedparts plugin initialized (v{self.meta.version})') diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index ff7dd64..5a8f25c 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -53,6 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'measuringtools': ('measuringtooltypes', 'measuringtools'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), + 'printedparts': ('printeditems', 'printeditemtransactions'), 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'), 'slides': ('tvslides',), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 16e0269..a17806b 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -54,6 +54,8 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' # usb drops the dead usbcheckouts.machineid column on top of its anchor. EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' +# printedparts is post-cutover: its 0001 really creates its tables. +EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline' # notifications indexes businessunitid on top of its anchor. EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx' From d1c844d533a46baa92f8934376e20c60f9ddc9c7 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 16 Jul 2026 17:10:42 -0400 Subject: [PATCH 03/19] printedparts stage 3: read API + list page (first visible win) GET /items (paginated, search across code/name/description/bin, lowstock filter) and GET /items/ with recent transactions, both open reads. printedpartsApi client, router file repointed at the renamed views, PrintedItemsList with image thumbs and a red/green quantity badge against the per-item threshold. Nav entry '3D Parts' with a new 'box' Lucide icon mapping (the sidebar renders nothing for unknown icon names - lab gotcha). --- frontend/src/api/index.js | 10 ++ frontend/src/router/routes/printedparts.js | 8 +- frontend/src/views/AppLayout.vue | 3 +- ...dpartsDetail.vue => PrintedItemDetail.vue} | 0 ...intedpartsForm.vue => PrintedItemForm.vue} | 0 .../views/printedparts/PrintedItemsList.vue | 141 +++++++++++++++++ .../views/printedparts/PrintedpartsList.vue | 142 ------------------ plugins/printedparts/api/routes.py | 66 ++++++-- plugins/printedparts/plugin.py | 10 ++ 9 files changed, 224 insertions(+), 156 deletions(-) rename frontend/src/views/printedparts/{PrintedpartsDetail.vue => PrintedItemDetail.vue} (100%) rename frontend/src/views/printedparts/{PrintedpartsForm.vue => PrintedItemForm.vue} (100%) create mode 100644 frontend/src/views/printedparts/PrintedItemsList.vue delete mode 100644 frontend/src/views/printedparts/PrintedpartsList.vue diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ec19d77..e02d22e 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1126,3 +1126,13 @@ export const measuringtoolsApi = { } } } + +// 3D printed parts (printedparts plugin) +export const printedpartsApi = { + list(params = {}) { + return api.get('/printedparts/items', { params }) + }, + get(printeditemid) { + return api.get(`/printedparts/items/${printeditemid}`) + } +} diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js index 244544c..4646e0c 100644 --- a/frontend/src/router/routes/printedparts.js +++ b/frontend/src/router/routes/printedparts.js @@ -11,25 +11,25 @@ export default [ { path: 'printedparts', name: 'printedparts', - component: () => import('../../views/printedparts/PrintedpartsList.vue'), + component: () => import('../../views/printedparts/PrintedItemsList.vue'), meta: { plugin: 'printedparts' } }, { path: 'printedparts/new', name: 'printedparts-new', - component: () => import('../../views/printedparts/PrintedpartsForm.vue'), + component: () => import('../../views/printedparts/PrintedItemForm.vue'), meta: { requiresAuth: true, plugin: 'printedparts' } }, { path: 'printedparts/:id', name: 'printedparts-detail', - component: () => import('../../views/printedparts/PrintedpartsDetail.vue'), + component: () => import('../../views/printedparts/PrintedItemDetail.vue'), meta: { plugin: 'printedparts' } }, { path: 'printedparts/:id/edit', name: 'printedparts-edit', - component: () => import('../../views/printedparts/PrintedpartsForm.vue'), + component: () => import('../../views/printedparts/PrintedItemForm.vue'), meta: { requiresAuth: true, plugin: 'printedparts' } } ] diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 44e61ee..0159b3b 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -101,7 +101,7 @@ import ToastHost from '../components/ToastHost.vue' import { Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, - KeyRound, LogOut + Box, KeyRound, LogOut } from 'lucide-vue-next' import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' @@ -147,6 +147,7 @@ const iconMap = { 'image': Image, 'shield': ShieldCheck, 'ruler': Ruler, + 'box': Box, } // Default navigation (used as fallback if API fails) diff --git a/frontend/src/views/printedparts/PrintedpartsDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue similarity index 100% rename from frontend/src/views/printedparts/PrintedpartsDetail.vue rename to frontend/src/views/printedparts/PrintedItemDetail.vue diff --git a/frontend/src/views/printedparts/PrintedpartsForm.vue b/frontend/src/views/printedparts/PrintedItemForm.vue similarity index 100% rename from frontend/src/views/printedparts/PrintedpartsForm.vue rename to frontend/src/views/printedparts/PrintedItemForm.vue diff --git a/frontend/src/views/printedparts/PrintedItemsList.vue b/frontend/src/views/printedparts/PrintedItemsList.vue new file mode 100644 index 0000000..c444dc8 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemsList.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedpartsList.vue b/frontend/src/views/printedparts/PrintedpartsList.vue deleted file mode 100644 index f7e18d8..0000000 --- a/frontend/src/views/printedparts/PrintedpartsList.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - - - diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 73d11ff..6028e8d 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -1,18 +1,66 @@ """Printedparts plugin API routes. -Stage 2 placeholder: the blueprint must import cleanly for plugin discovery -and migrations (the alembic env imports the models package, which pulls in -plugin.py and this module). Real endpoints land in the next stage. +Reads are open (jwt optional) like every list surface; mutations arrive in +later stages with permission gates. The kiosk endpoints (unauthenticated by +explicit decision - see the proposal) also land later. """ -from flask import Blueprint +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy import or_ -from shopdb.api import success_response +from shopdb.api import ( + db, + success_response, + error_response, + paginated_response, + ErrorCodes, + get_pagination_params, + paginate_query, +) + +from ..models import PrintedItem printedparts_bp = Blueprint('printedparts', __name__) -@printedparts_bp.route('/ping', methods=['GET']) -def ping(): - """Liveness probe for the lab: proves the blueprint is registered.""" - return success_response({'plugin': 'printedparts', 'status': 'ok'}) +@printedparts_bp.route('/items', methods=['GET']) +@jwt_required(optional=True) +def list_items(): + """List printed items, paginated; search + low-stock filter.""" + page, per_page = get_pagination_params(request) + query = PrintedItem.query + if request.args.get('active', 'true').lower() != 'false': + query = query.filter(PrintedItem.isactive == True) + if search := request.args.get('search'): + like = f'%{search}%' + query = query.filter(or_( + PrintedItem.itemcode.ilike(like), + PrintedItem.itemname.ilike(like), + PrintedItem.itemdescription.ilike(like), + PrintedItem.binlocation.ilike(like), + )) + if request.args.get('lowstock', '').lower() == 'true': + query = query.filter( + PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold) + query = query.order_by(PrintedItem.itemname) + items, total = paginate_query(query, page, per_page) + return paginated_response( + [item.to_dict() for item in items], page, per_page, total) + + +@printedparts_bp.route('/items/', methods=['GET']) +@jwt_required(optional=True) +def get_item(item_id: int): + """Get one printed item with its recent transactions.""" + item = db.session.get(PrintedItem, item_id) + if not item: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', + http_code=404) + data = item.to_dict() + recent = (item.transactions + .order_by(db.desc('transactiondate')) + .limit(25).all()) + data['recenttransactions'] = [t.to_dict() for t in recent] + return success_response(data) diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index 89cf221..e62b30c 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -51,6 +51,16 @@ class PrintedpartsPlugin(BasePlugin): def init_app(self, app: Flask, db_instance) -> None: logger.info(f'Printedparts plugin initialized (v{self.meta.version})') + def get_navigation_items(self) -> List[dict]: + return [ + { + 'name': '3D Parts', + 'icon': 'box', + 'route': '/printedparts', + 'position': 46, + }, + ] + def on_install(self, app: Flask) -> None: with app.app_context(): self._seed_settings() From cb367a38f9e40d516aca0b44578c0c5fa3e59b61 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 07:36:54 -0400 Subject: [PATCH 04/19] printedparts stage 4: catalog mutations, item photos, detail + form POST/PUT/DELETE for items: create mints the itemcode from the configured prefix plus the flushed row id, update refuses quantityonhand (ledger-managed - restock/adjust arrive next stage), delete soft-retires. The image upload/serve/delete trio replicates the models.py pattern into instance/printedpartsimages/ with a public GET. PrintedItemDetail follows the unified detail skeleton (hero photo, info list, transaction history table); PrintedItemForm covers create/edit plus photo management on edit. --- frontend/src/api/index.js | 19 ++ .../views/printedparts/PrintedItemDetail.vue | 214 ++++++------- .../views/printedparts/PrintedItemForm.vue | 288 +++++++----------- plugins/printedparts/api/routes.py | 146 +++++++++ 4 files changed, 379 insertions(+), 288 deletions(-) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e02d22e..5684439 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1134,5 +1134,24 @@ export const printedpartsApi = { }, get(printeditemid) { return api.get(`/printedparts/items/${printeditemid}`) + }, + create(data) { + return api.post('/printedparts/items', data) + }, + update(printeditemid, data) { + return api.put(`/printedparts/items/${printeditemid}`, data) + }, + remove(printeditemid) { + return api.delete(`/printedparts/items/${printeditemid}`) + }, + uploadImage(printeditemid, file) { + const formData = new FormData() + formData.append('file', file) + return api.post(`/printedparts/items/${printeditemid}/image`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + deleteImage(printeditemid) { + return api.delete(`/printedparts/items/${printeditemid}/image`) } } diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue index be6a777..2c370a3 100644 --- a/frontend/src/views/printedparts/PrintedItemDetail.vue +++ b/frontend/src/views/printedparts/PrintedItemDetail.vue @@ -1,149 +1,133 @@ diff --git a/frontend/src/views/printedparts/PrintedItemForm.vue b/frontend/src/views/printedparts/PrintedItemForm.vue index 32e1036..10058d0 100644 --- a/frontend/src/views/printedparts/PrintedItemForm.vue +++ b/frontend/src/views/printedparts/PrintedItemForm.vue @@ -1,74 +1,66 @@
Item not found
+ + +
{{ ledgerError }}
+
+ + +
+
+ + +
+
+ + +
+ +
@@ -104,6 +134,7 @@ import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import { printedpartsApi } from '../../api' import { withBase } from '../../utils/basePath' +import Modal from '../../components/Modal.vue' const route = useRoute() const item = ref(null) @@ -120,6 +151,50 @@ onMounted(async () => { } }) +const ledgerOpen = ref(false) +const ledgerMode = ref('restock') +const ledgerQuantity = ref(null) +const ledgerReason = ref('') +const ledgerBadge = ref('') +const ledgerSaving = ref(false) +const ledgerError = ref('') + +function openLedger(mode) { + ledgerMode.value = mode + ledgerQuantity.value = null + ledgerReason.value = '' + ledgerBadge.value = '' + ledgerError.value = '' + ledgerOpen.value = true +} + +async function submitLedger() { + ledgerSaving.value = true + ledgerError.value = '' + try { + if (ledgerMode.value === 'restock') { + await printedpartsApi.restock(item.value.printeditemid, { + quantity: ledgerQuantity.value, badge: ledgerBadge.value + }) + } else { + await printedpartsApi.adjust(item.value.printeditemid, { + quantitychange: ledgerQuantity.value, + reason: ledgerReason.value, + badge: ledgerBadge.value + }) + } + ledgerOpen.value = false + const response = await printedpartsApi.get(item.value.printeditemid) + item.value = response.data.data + } catch (submitError) { + ledgerError.value = + submitError.response?.data?.data?.error?.message || + submitError.response?.data?.error?.message || 'Submit failed' + } finally { + ledgerSaving.value = false + } +} + function formatDate(value) { if (!value) return '-' return new Date(value).toLocaleString() diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 8cb7753..61d5d6b 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -210,3 +210,80 @@ def delete_item_image(item_id: int): item.imageurl = None db.session.commit() return success_response(item.to_dict(), message='Item image removed') + + +# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock) + +from ..models import PrintedItemTransaction +from ..services.badges import BadgeError, resolve_badge + + +def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None): + """Append a ledger row and move the cached quantity in ONE commit. + + The single-commit invariant is what keeps quantityonhand equal to the + ledger sum; every write path must go through here. + """ + item.quantityonhand += quantitychange + db.session.add(PrintedItemTransaction( + printeditemid=item.printeditemid, + transactiontype=transactiontype, + quantitychange=quantitychange, + employeesso=sso, + employeename=name, + reason=reason, + )) + db.session.commit() + + +@printedparts_bp.route('/items//restock', methods=['POST']) +@jwt_required() +def restock_item(item_id: int): + """Add freshly printed stock. Body: {quantity, badge}.""" + item = db.session.get(PrintedItem, item_id) + if not item or not item.isactive: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + data = request.get_json() or {} + quantity = data.get('quantity') + if not isinstance(quantity, int) or quantity < 1: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'quantity must be a positive integer') + try: + sso, name = resolve_badge(data.get('badge')) + except BadgeError as badge_error: + return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), + http_code=422) + _ledger_write(item, 'restock', quantity, sso, name) + return success_response(item.to_dict(), message='Stock added') + + +@printedparts_bp.route('/items//adjust', methods=['POST']) +@jwt_required() +def adjust_item(item_id: int): + """Correct the count (damage, recount). Body: {quantitychange, reason, badge}.""" + item = db.session.get(PrintedItem, item_id) + if not item or not item.isactive: + return error_response(ErrorCodes.NOT_FOUND, + f'Printed item {item_id} not found', http_code=404) + data = request.get_json() or {} + quantitychange = data.get('quantitychange') + if not isinstance(quantitychange, int) or quantitychange == 0: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'quantitychange must be a non-zero integer') + reason = (data.get('reason') or '').strip() + if not reason: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'reason is required for an adjustment') + if item.quantityonhand + quantitychange < 0: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Adjustment would drive stock below zero ' + f'(on hand: {item.quantityonhand})') + try: + sso, name = resolve_badge(data.get('badge')) + except BadgeError as badge_error: + return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), + http_code=422) + _ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason) + return success_response(item.to_dict(), message='Stock adjusted') diff --git a/plugins/printedparts/services/__init__.py b/plugins/printedparts/services/__init__.py new file mode 100644 index 0000000..21758a6 --- /dev/null +++ b/plugins/printedparts/services/__init__.py @@ -0,0 +1 @@ +"""Printedparts plugin services.""" diff --git a/plugins/printedparts/services/badges.py b/plugins/printedparts/services/badges.py new file mode 100644 index 0000000..81c7f43 --- /dev/null +++ b/plugins/printedparts/services/badges.py @@ -0,0 +1,69 @@ +"""Badge resolution for the printedparts plugin. + +Same input contract as the USB plugin (deliberately copied, not imported - +cross-plugin imports break the shopdb.api-only contract): + +- all digits -> an SSO typed or scanned directly +- 0BZ -> a physical badge wrapping a PayNo (keyboard-wedge + scanners emit this shape) +- anything else -> unresolvable + +Names come from the employees plugin's self-hosted directory, looked up by +SSO. The directory carries no PayNo column, so PayNo badges resolve only when +the wrapped digits are themselves the SSO (true at sites whose badges encode +the SSO); otherwise they fall to the unknown-badge policy. + +Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge +with no directory match; 'allow' records the SSO with an empty name. +""" + +import re + +from shopdb.api import Setting + +_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE) + + +class BadgeError(ValueError): + """Raised when a badge cannot be accepted under the site policy.""" + + +def _directory_name(sso): + """Best-effort display name from the employees plugin directory.""" + try: + from plugins.employees.models import DirectoryEmployee + from shopdb.api import db + if sso and str(sso).isdigit(): + employee = db.session.get(DirectoryEmployee, int(sso)) + if employee: + return f'{employee.firstname} {employee.lastname}'.strip() + except Exception: + pass + return None + + +def resolve_badge(badge): + """Return (sso, name) for a scanned badge, enforcing the site policy. + + Raises BadgeError with a kiosk-displayable message when the badge shape is + unrecognized or the policy denies an unmatched badge. + """ + badge = (badge or '').strip() + if not badge: + raise BadgeError('Scan or enter a badge') + + if badge.isdigit(): + sso = badge + else: + match = _PAYNO_BADGE.match(badge) + if not match: + raise BadgeError('Unrecognized badge format') + sso = match.group(1) + + name = _directory_name(sso) + if name is None: + policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower() + if policy != 'allow': + raise BadgeError('Badge not recognized - see the parts team') + return sso, '' + return sso, name diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py new file mode 100644 index 0000000..21f4e55 --- /dev/null +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -0,0 +1,121 @@ +"""Printedparts ledger + badge tests. + +The invariants that make the plugin trustworthy: itemcode minting, the +single-commit cache==ledger rule, the below-zero guard, quantity edits +forced through the ledger, and the badge contract (SSO digits, PayNo +wrap, unknown-badge policy). +""" + +import pytest + +from shopdb.api import db +from shopdb.core.models import Setting +from plugins.printedparts.models import PrintedItem, PrintedItemTransaction + + +@pytest.fixture +def item(app, db): + with app.app_context(): + row = PrintedItem(itemcode='3DP-9001', itemname='Test clip', + quantityonhand=0, lowstockthreshold=5) + db.session.add(row) + db.session.commit() + yield row.printeditemid + + +@pytest.fixture +def directory_employee(app): + with app.app_context(): + from plugins.employees.models import DirectoryEmployee + if not db.session.get(DirectoryEmployee, 502000001): + db.session.add(DirectoryEmployee( + sso=502000001, firstname='Pat', lastname='Printer')) + db.session.commit() + return '502000001' + + +def test_create_mints_itemcode(client, auth_headers): + response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'}, + headers=auth_headers) + assert response.status_code == 201 + data = response.get_json()['data'] + assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}" + assert data['quantityonhand'] == 0 + + +def test_update_refuses_quantity(client, auth_headers, item): + response = client.put(f'/api/printedparts/items/{item}', + json={'quantityonhand': 50}, headers=auth_headers) + assert response.status_code == 400 + + +def test_restock_writes_ledger_and_cache(client, auth_headers, app, item, + directory_employee): + response = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 10, 'badge': directory_employee}, + headers=auth_headers) + assert response.status_code == 200 + assert response.get_json()['data']['quantityonhand'] == 10 + with app.app_context(): + rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all() + assert len(rows) == 1 + assert rows[0].transactiontype == 'restock' + assert rows[0].quantitychange == 10 + assert rows[0].employeename == 'Pat Printer' + cached = db.session.get(PrintedItem, item).quantityonhand + assert cached == sum(r.quantitychange for r in rows) + + +def test_payno_badge_shape_resolves(client, auth_headers, item, + directory_employee): + response = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, + 'badge': f'0{directory_employee}BZ'}, + headers=auth_headers) + assert response.status_code == 200 + + +def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item, + directory_employee): + no_reason = client.post(f'/api/printedparts/items/{item}/adjust', + json={'quantitychange': -1, + 'badge': directory_employee}, + headers=auth_headers) + assert no_reason.status_code == 400 + + below_zero = client.post(f'/api/printedparts/items/{item}/adjust', + json={'quantitychange': -1, 'reason': 'test', + 'badge': directory_employee}, + headers=auth_headers) + assert below_zero.status_code == 400 + + +def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app, + item): + denied = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '999999999'}, + headers=auth_headers) + assert denied.status_code == 422 + + with app.app_context(): + Setting.set('printedparts_unknown_badge', 'allow', + valuetype='string', category='printedparts') + db.session.commit() + try: + allowed = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '999999999'}, + headers=auth_headers) + assert allowed.status_code == 200 + assert allowed.get_json()['data']['quantityonhand'] == 1 + finally: + with app.app_context(): + Setting.set('printedparts_unknown_badge', 'deny', + valuetype='string', category='printedparts') + db.session.commit() + + +def test_anonymous_cannot_mutate(client, item): + assert client.post('/api/printedparts/items', + json={'itemname': 'X'}).status_code == 401 + assert client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '1'}).status_code == 401 From d6a78a72ffe1804b6089e57b4938eedb57788e90 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 07:42:07 -0400 Subject: [PATCH 06/19] printedparts stage 6: RBAC - declared permissions gate every mutation get_permissions declares view/create/edit/delete/restock (seeded on install/enable and by flask seed permissions); every write route adds require_permission on top of jwt_required. New test proves authentication alone is not authorization: a role-less member gets 403 where an admin succeeds. --- plugins/printedparts/api/routes.py | 8 ++++++++ plugins/printedparts/plugin.py | 11 +++++++++++ tests/test_plugins/test_printedparts_ledger.py | 9 +++++++++ 3 files changed, 28 insertions(+) diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 61d5d6b..988035c 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -17,6 +17,7 @@ from shopdb.api import ( ErrorCodes, get_pagination_params, paginate_query, + require_permission, ) from ..models import PrintedItem @@ -95,6 +96,7 @@ def _mint_itemcode(item): @printedparts_bp.route('/items', methods=['POST']) @jwt_required() +@require_permission('printedparts.create') def create_item(): """Create a printed item; the itemcode is minted from the row id.""" data = request.get_json() or {} @@ -124,6 +126,7 @@ def create_item(): @printedparts_bp.route('/items/', methods=['PUT']) @jwt_required() +@require_permission('printedparts.edit') def update_item(item_id: int): """Update catalog fields. Quantity moves ONLY through the ledger.""" item = db.session.get(PrintedItem, item_id) @@ -144,6 +147,7 @@ def update_item(item_id: int): @printedparts_bp.route('/items/', methods=['DELETE']) @jwt_required() +@require_permission('printedparts.delete') def delete_item(item_id: int): """Soft-retire an item; its ledger history stays.""" item = db.session.get(PrintedItem, item_id) @@ -159,6 +163,7 @@ def delete_item(item_id: int): @printedparts_bp.route('/items//image', methods=['POST']) @jwt_required() +@require_permission('printedparts.edit') def upload_item_image(item_id: int): """Upload (or replace) the photo for an item (multipart file=).""" item = db.session.get(PrintedItem, item_id) @@ -195,6 +200,7 @@ def serve_item_image(filename): @printedparts_bp.route('/items//image', methods=['DELETE']) @jwt_required() +@require_permission('printedparts.delete') def delete_item_image(item_id: int): """Clear an item image; delete the file only if this plugin owns it.""" item = db.session.get(PrintedItem, item_id) @@ -238,6 +244,7 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None) @printedparts_bp.route('/items//restock', methods=['POST']) @jwt_required() +@require_permission('printedparts.restock') def restock_item(item_id: int): """Add freshly printed stock. Body: {quantity, badge}.""" item = db.session.get(PrintedItem, item_id) @@ -260,6 +267,7 @@ def restock_item(item_id: int): @printedparts_bp.route('/items//adjust', methods=['POST']) @jwt_required() +@require_permission('printedparts.restock') def adjust_item(item_id: int): """Correct the count (damage, recount). Body: {quantitychange, reason, badge}.""" item = db.session.get(PrintedItem, item_id) diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index e62b30c..c90dc3a 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -51,6 +51,17 @@ class PrintedpartsPlugin(BasePlugin): def init_app(self, app: Flask, db_instance) -> None: logger.info(f'Printedparts plugin initialized (v{self.meta.version})') + def get_permissions(self) -> List: + """RBAC permissions this plugin owns (seeded on install/enable).""" + return [ + ('printedparts.view', 'View 3D printed parts', 'printedparts'), + ('printedparts.create', 'Create printed parts', 'printedparts'), + ('printedparts.edit', 'Edit printed parts', 'printedparts'), + ('printedparts.delete', 'Retire printed parts', 'printedparts'), + ('printedparts.restock', 'Restock and adjust stock counts', + 'printedparts'), + ] + def get_navigation_items(self) -> List[dict]: return [ { diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index 21f4e55..81fa7db 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -119,3 +119,12 @@ def test_anonymous_cannot_mutate(client, item): json={'itemname': 'X'}).status_code == 401 assert client.post(f'/api/printedparts/items/{item}/restock', json={'quantity': 1, 'badge': '1'}).status_code == 401 + + +def test_member_without_permission_gets_403(client, member_headers, item): + """Authentication alone is not authorization: a role-less user is denied.""" + assert client.post('/api/printedparts/items', json={'itemname': 'X'}, + headers=member_headers).status_code == 403 + assert client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 1, 'badge': '1'}, + headers=member_headers).status_code == 403 From 6ed3da1b641fb4c925073d27a205eedfe29049cc Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 07:49:13 -0400 Subject: [PATCH 07/19] printedparts stage 7: the kiosk - scan bin, scan badge, keypad, take Two open endpoints: an item lookup by scanned code and the take POST - the product's first unauthenticated write, held to the decision record's bar (decrement-only, badge-attributed server-side, bounded, physically rate-limited; justification in the plugin README). The /parts-kiosk route is a full-screen no-auth view beside /shopfloor: a hidden always-focused input consumes keyboard-wedge scans for whichever step is active, TouchKeypad (net-new 3x4 grid) takes the quantity, and a success screen resets after a few seconds. Manual type-in fallbacks cover damaged labels. Kiosk test proves open access, the over-take guard, the badge policy, and cache==ledger afterward. --- frontend/src/api/index.js | 6 + frontend/src/components/TouchKeypad.vue | 38 +++ frontend/src/router/index.js | 8 + .../src/views/printedparts/PartsKiosk.vue | 242 ++++++++++++++++++ plugins/printedparts/README.md | 16 ++ plugins/printedparts/api/routes.py | 51 ++++ .../test_plugins/test_printedparts_ledger.py | 38 +++ 7 files changed, 399 insertions(+) create mode 100644 frontend/src/components/TouchKeypad.vue create mode 100644 frontend/src/views/printedparts/PartsKiosk.vue diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index db4dd23..2a00d2b 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1159,5 +1159,11 @@ export const printedpartsApi = { }, adjust(printeditemid, data) { return api.post(`/printedparts/items/${printeditemid}/adjust`, data) + }, + kioskItem(itemcode) { + return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`) + }, + kioskTake(data) { + return api.post('/printedparts/kiosk/take', data) } } diff --git a/frontend/src/components/TouchKeypad.vue b/frontend/src/components/TouchKeypad.vue new file mode 100644 index 0000000..632dcd6 --- /dev/null +++ b/frontend/src/components/TouchKeypad.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ade1a1e..f472551 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -65,6 +65,14 @@ const routes = [ name: 'shopfloor', component: () => import('../views/ShopfloorDashboard.vue') }, + { + // Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad. + // Open on purpose - see the decision record in the printedparts proposal. + path: '/parts-kiosk', + name: 'parts-kiosk', + component: () => import('../views/printedparts/PartsKiosk.vue'), + meta: { plugin: 'printedparts' } + }, { path: '/tv', name: 'tv', diff --git a/frontend/src/views/printedparts/PartsKiosk.vue b/frontend/src/views/printedparts/PartsKiosk.vue new file mode 100644 index 0000000..66a31f0 --- /dev/null +++ b/frontend/src/views/printedparts/PartsKiosk.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/plugins/printedparts/README.md b/plugins/printedparts/README.md index 0fd0cf6..9cec4a1 100644 --- a/plugins/printedparts/README.md +++ b/plugins/printedparts/README.md @@ -41,3 +41,19 @@ pytest plugins/printedparts/tests/ - `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 + +## Why the kiosk take endpoint is unauthenticated + +`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every +other kiosk endpoint is a read). Accepted deliberately, against the criteria +in docs/proposals/printedparts-plugin.md: + +1. Decrement-only: it can reduce stock of an active item, nothing else. +2. Fully attributed: it refuses to act without a badge that resolves under + the site policy; every action lands in the ledger with SSO + name + time. +3. Bounded blast radius: worst case is stock counts driven low - visible in + the ledger and reversible with an adjust. +4. Physically rate-limited: it serves a touch screen on the shop floor; + nothing enumerable, nothing worth scraping. + +Any future open-write endpoint must clear the same bar. diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 988035c..acfce9e 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -295,3 +295,54 @@ def adjust_item(item_id: int): http_code=422) _ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason) return success_response(item.to_dict(), message='Stock adjusted') + + +# --- kiosk: UNauthenticated by decision record -------------------------------- +# The take endpoint is the product's first open WRITE. The proposal's decision +# record sets the bar it must meet: decrement-only, badge-attributed, bounded, +# physically rate-limited. It can reduce stock of an active item and nothing +# else; identity comes from the badge resolved server-side, never the client. + +@printedparts_bp.route('/kiosk/item/', methods=['GET']) +def kiosk_item(itemcode): + """Item summary for a scanned bin barcode (open read for the kiosk).""" + item = PrintedItem.query.filter( + PrintedItem.itemcode == itemcode.strip(), + PrintedItem.isactive == True).first() + if not item: + return error_response(ErrorCodes.NOT_FOUND, + 'No part matches that barcode', http_code=404) + return success_response(item.to_dict()) + + +@printedparts_bp.route('/kiosk/take', methods=['POST']) +def kiosk_take(): + """Take parts from a bin. Body: {itemcode, badge, quantity}.""" + data = request.get_json() or {} + + item = PrintedItem.query.filter( + PrintedItem.itemcode == (data.get('itemcode') or '').strip(), + PrintedItem.isactive == True).first() + if not item: + return error_response(ErrorCodes.NOT_FOUND, + 'No part matches that barcode', http_code=404) + + quantity = data.get('quantity') + if not isinstance(quantity, int) or quantity < 1: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Enter how many you are taking') + if quantity > item.quantityonhand: + return error_response( + ErrorCodes.VALIDATION_ERROR, + f'Only {item.quantityonhand} on hand - take fewer or see the ' + f'parts team') + + try: + sso, name = resolve_badge(data.get('badge')) + except BadgeError as badge_error: + return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), + http_code=422) + + _ledger_write(item, 'take', -quantity, sso, name) + return success_response(item.to_dict(), + message=f'Took {quantity}, {item.quantityonhand} left') diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index 81fa7db..790e8d7 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -128,3 +128,41 @@ def test_member_without_permission_gets_403(client, member_headers, item): assert client.post(f'/api/printedparts/items/{item}/restock', json={'quantity': 1, 'badge': '1'}, headers=member_headers).status_code == 403 + + +def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item, + directory_employee): + """The kiosk endpoint needs no auth but only ever decrements stock.""" + itemcode = '3DP-9001' + stocked = client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': 5, 'badge': directory_employee}, + headers=auth_headers) + assert stocked.status_code == 200 + + lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}') + assert lookup.status_code == 200 + + take = client.post('/api/printedparts/kiosk/take', json={ + 'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2}) + assert take.status_code == 200, take.get_json() + assert take.get_json()['data']['quantityonhand'] == 3 + + too_many = client.post('/api/printedparts/kiosk/take', json={ + 'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99}) + assert too_many.status_code == 400 + + unknown = client.post('/api/printedparts/kiosk/take', json={ + 'itemcode': itemcode, 'badge': '111111111', 'quantity': 1}) + assert unknown.status_code == 422 + + with app.app_context(): + rows = PrintedItemTransaction.query.filter_by( + printeditemid=item, transactiontype='take').all() + assert len(rows) == 1 + assert rows[0].quantitychange == -2 + assert rows[0].employeename == 'Pat Printer' + cached = db.session.get(PrintedItem, item).quantityonhand + ledgersum = sum(r.quantitychange for r in + PrintedItemTransaction.query.filter_by( + printeditemid=item).all()) + assert cached == ledgersum From 6439d1ccd9dd930d378c9268e45e5caa89a897ee Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:00:35 -0400 Subject: [PATCH 08/19] printedparts stage 8: 1x0.5in bin labels New public print view at /print/printedparts-labels following the plugin-owned USB label precedent: multi-select with per-item copies, CODE128 of the item code via JsBarcode (a QR at this size is at the edge of scanner tolerance), one label per page on 1in x 0.5in roll stock via a new @page size. The Detail page's Bin Label button preselects its item through ?item=; the list header gains a batch Print Labels button. --- frontend/src/router/index.js | 6 + .../src/views/print/PrintedPartsLabels.vue | 207 ++++++++++++++++++ .../views/printedparts/PrintedItemDetail.vue | 2 + .../views/printedparts/PrintedItemsList.vue | 8 +- 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/print/PrintedPartsLabels.vue diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index f472551..fadf850 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -117,6 +117,12 @@ const routes = [ component: () => import('../views/print/USBLabelBatch.vue'), meta: { plugin: 'usb' } }, + { + path: '/print/printedparts-labels', + name: 'print-printedparts-labels', + component: () => import('../views/print/PrintedPartsLabels.vue'), + meta: { plugin: 'printedparts' } + }, { path: '/', component: AppLayout, diff --git a/frontend/src/views/print/PrintedPartsLabels.vue b/frontend/src/views/print/PrintedPartsLabels.vue new file mode 100644 index 0000000..c2dd1c6 --- /dev/null +++ b/frontend/src/views/print/PrintedPartsLabels.vue @@ -0,0 +1,207 @@ + + + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue index d14a158..1bd805e 100644 --- a/frontend/src/views/printedparts/PrintedItemDetail.vue +++ b/frontend/src/views/printedparts/PrintedItemDetail.vue @@ -27,6 +27,8 @@ Edit + Bin Label diff --git a/frontend/src/views/printedparts/PrintedItemsList.vue b/frontend/src/views/printedparts/PrintedItemsList.vue index c444dc8..e17f700 100644 --- a/frontend/src/views/printedparts/PrintedItemsList.vue +++ b/frontend/src/views/printedparts/PrintedItemsList.vue @@ -2,7 +2,12 @@
@@ -131,6 +136,7 @@ function debouncedSearch() { text-overflow: ellipsis; white-space: nowrap; } +.header-actions { display: flex; gap: 0.5rem; } .lowstock-filter { display: inline-flex; align-items: center; From b68e927ef6ef344db6bccf640c2a57c651cdea03 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:04:09 -0400 Subject: [PATCH 09/19] printedparts stage 9: reports - stock w/ reconcile, consumption, by-person Three jwt-optional endpoints with ?format=csv, merged into the reports hub via get_reports while the plugin is enabled. The stock report's ledgerdelta column is the reconcile check: 0 for every item whose stock moved through the ledger, nonzero for anything that bypassed it (the hand-seeded dev rows demonstrate the catch). MySQL SUM returns Decimal - cast to int or the delta serializes as a string. --- plugins/printedparts/api/routes.py | 110 +++++++++++++++++++++++++++++ plugins/printedparts/plugin.py | 26 +++++++ 2 files changed, 136 insertions(+) diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index acfce9e..6c8008f 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -346,3 +346,113 @@ def kiosk_take(): _ledger_write(item, 'take', -quantity, sso, name) return success_response(item.to_dict(), message=f'Took {quantity}, {item.quantityonhand} left') + + +# --- reports (merged into GET /api/reports while the plugin is enabled) ------ + +import csv +import io + +from flask import Response +from sqlalchemy import func + + +def _csv_response(rows, columns, filename): + """CSV download; local helper because generate_csv is not on the + contract surface (shopdb.api).""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(columns) + for row in rows: + writer.writerow([row.get(column, '') for column in columns]) + return Response( + output.getvalue(), mimetype='text/csv', + headers={'Content-Disposition': f'attachment; filename={filename}'}) + + +@printedparts_bp.route('/reports/stock', methods=['GET']) +@jwt_required(optional=True) +def report_stock(): + """Stock levels with low-stock flags and the cache-vs-ledger reconcile. + + ledgerdelta should always be 0; anything else means a write path + bypassed the single-commit rule and needs finding. + """ + # int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a + # string and breaks the delta arithmetic's type. + ledger = {itemid: int(total) for itemid, total in + db.session.query( + PrintedItemTransaction.printeditemid, + func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0)) + .group_by(PrintedItemTransaction.printeditemid).all()} + rows = [] + for item in PrintedItem.query.filter_by(isactive=True).order_by( + PrintedItem.itemname).all(): + rows.append({ + 'itemcode': item.itemcode, + 'itemname': item.itemname, + 'binlocation': item.binlocation or '', + 'quantityonhand': item.quantityonhand, + 'lowstockthreshold': item.lowstockthreshold, + 'islowstock': item.islowstock, + 'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0), + }) + columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand', + 'lowstockthreshold', 'islowstock', 'ledgerdelta'] + if request.args.get('format') == 'csv': + return _csv_response(rows, columns, 'printedparts-stock.csv') + return success_response({'columns': columns, 'rows': rows}) + + +@printedparts_bp.route('/reports/consumption', methods=['GET']) +@jwt_required(optional=True) +def report_consumption(): + """Takes per item, optionally bounded by ?days= (default 30).""" + days = request.args.get('days', 30, type=int) + query = (db.session.query( + PrintedItem.itemcode, + PrintedItem.itemname, + func.count(PrintedItemTransaction.transactionid), + func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0)) + .join(PrintedItemTransaction, + PrintedItemTransaction.printeditemid == PrintedItem.printeditemid) + .filter(PrintedItemTransaction.transactiontype == 'take')) + if days > 0: + from datetime import datetime, timedelta, timezone + cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days) + query = query.filter(PrintedItemTransaction.transactiondate >= cutoff) + query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname) + rows = [{'itemcode': code, 'itemname': name, 'takes': takes, + 'quantitytaken': int(taken)} + for code, name, takes, taken in query.all()] + rows.sort(key=lambda row: row['quantitytaken'], reverse=True) + columns = ['itemcode', 'itemname', 'takes', 'quantitytaken'] + if request.args.get('format') == 'csv': + return _csv_response(rows, columns, 'printedparts-consumption.csv') + return success_response({'columns': columns, 'rows': rows, 'days': days}) + + +@printedparts_bp.route('/reports/by-person', methods=['GET']) +@jwt_required(optional=True) +def report_by_person(): + """Takes grouped by employee, optionally bounded by ?days= (default 30).""" + days = request.args.get('days', 30, type=int) + query = (db.session.query( + PrintedItemTransaction.employeesso, + func.max(PrintedItemTransaction.employeename), + func.count(PrintedItemTransaction.transactionid), + func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0)) + .filter(PrintedItemTransaction.transactiontype == 'take')) + if days > 0: + from datetime import datetime, timedelta, timezone + cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days) + query = query.filter(PrintedItemTransaction.transactiondate >= cutoff) + query = query.group_by(PrintedItemTransaction.employeesso) + rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes, + 'quantitytaken': int(taken)} + for sso, name, takes, taken in query.all()] + rows.sort(key=lambda row: row['quantitytaken'], reverse=True) + columns = ['employeesso', 'employeename', 'takes', 'quantitytaken'] + if request.args.get('format') == 'csv': + return _csv_response(rows, columns, 'printedparts-by-person.csv') + return success_response({'columns': columns, 'rows': rows, 'days': days}) diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index c90dc3a..97e14b4 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -62,6 +62,32 @@ class PrintedpartsPlugin(BasePlugin): 'printedparts'), ] + def get_reports(self) -> List[dict]: + return [ + { + 'id': 'printedparts-stock', + 'name': '3D Parts Stock', + 'description': 'Stock levels with low-stock flags and the ' + 'cache-vs-ledger reconcile check', + 'category': 'inventory', + 'endpoint': '/api/printedparts/reports/stock', + }, + { + 'id': 'printedparts-consumption', + 'name': '3D Parts Consumption', + 'description': 'Takes per item over a date range', + 'category': 'usage', + 'endpoint': '/api/printedparts/reports/consumption', + }, + { + 'id': 'printedparts-by-person', + 'name': '3D Parts by Person', + 'description': 'Takes grouped by employee', + 'category': 'usage', + 'endpoint': '/api/printedparts/reports/by-person', + }, + ] + def get_navigation_items(self) -> List[dict]: return [ { From fc0d48a6a73b036d6b4532b7d4ef91a4956f920c Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:11:36 -0400 Subject: [PATCH 10/19] printedparts stage 10: closeout - lab guide rewritten from the real build The lab is now a build-along mirroring what actually happened: ten stages, each with the goal, the divergences, a see-it-work check, and the errors genuinely hit while building (empty Migration error from a broken model import, the migration-guard KeyError, the missing Lucide icon, nested-app-context test writes, Decimal sums, and the authz sweep catching the deliberately open kiosk take). That last one gets its explicit EXEMPT_ENDPOINTS entry with a pointer to the decision record - the net stays, the exception is reviewable. Full suite: 993 backend tests, 49 vitest, frontend build, naming hook, all green. --- docs/PLUGIN-LAB-PRINTEDPARTS.md | 436 ++++++++++++++++++-------------- tests/test_core/test_authz.py | 7 +- 2 files changed, 259 insertions(+), 184 deletions(-) diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 00e6589..8a0ac37 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -1,217 +1,286 @@ -# Plugin lab: build the printedparts plugin yourself +# Plugin lab: build the printedparts plugin -A guided, milestone-based exercise: build the 3D-printed-parts storefront + -kiosk plugin specified in `docs/proposals/printedparts-plugin.md`. Each -milestone lists what to build, which existing code to imitate, and a -checkpoint that proves you are done. Read the spec first, keep it open. - -Prerequisites: a working dev environment (README quick start), the three -plugin docs skimmed once - `PLUGIN-QUICKSTART.md` (mechanics), -`PLUGIN-GUIDE.md` (the measuringtools walkthrough - your narrative reference), -`PLUGIN-HOOKS.md` (hook reference). Naming rules: `CONTRIBUTING.md` - the -pre-commit hook enforces them, read it before naming anything. - -Ground rules -- Import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`). The contract - test fails your build otherwise. -- DB columns: lowercase concatenated (`quantityonhand`, not quantity_on_hand). -- Run `bash scripts/check-naming-and-style.sh` and the test suite at every - checkpoint. -- Commit once per milestone. Working on a branch is fine; so is a fork. +A hand-held, build-along tutorial: construct the 3D-printed-parts storefront + +kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by +stage, seeing each piece work before moving on. Written for someone building +their first plugin. The finished implementation lives on the +`feat/printedparts-plugin` branch with one commit per stage, tagged +`lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03 +lab-stage-04` shows exactly what a stage changes. Know before you start - You are building a BUNDLED plugin inside this repo. Plugin frontend files live in core (`frontend/src/...`), and three core files get small edits: - `frontend/src/api/index.js` (api client), the router, and - `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. That is - normal for all 12 bundled plugins - external-plugin UI packaging does not - exist yet - so do not be confused when a "plugin" touches core. -- This plugin deliberately DIVERGES from the scaffold in three places, each - a teaching point you will hit in order: (1) it is NOT an asset type, so the - scaffold's AssetType seeding gets deleted (M1); (2) its migration is a REAL - baseline that creates tables, not a stamp-only anchor (M1); (3) its kiosk - take endpoint is the product's first UNauthenticated write - read the - decision record in the proposal before building it (M4). -- Instructor option: keep a solution branch with one commit per milestone - (tag `lab-m1`..`lab-m7`); a stuck learner can `git diff lab-m3 lab-m4` to - see exactly what a milestone changes. + `frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and + `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for + all bundled plugins - external-plugin UI packaging does not exist yet. +- Three deliberate divergences from the scaffold, each a teaching point: + (1) NO AssetType - these are quantity consumables, not ADR-001 assets + (stage 1); (2) the migration is a REAL baseline that creates tables, not a + stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's + first UNauthenticated write - read the decision record in the proposal + before stage 7. +- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`); + DB names lowercase concatenated (`quantityonhand`); run + `bash scripts/check-naming-and-style.sh` + the tests at every stage; one + git commit per stage. + +Prerequisites: working dev environment (README quick start), skim +`PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this +lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules. --- -## Milestone 1 - skeleton, models, migration (backend exists) +## Stage 0 - orientation (no code) -Build -1. `flask plugin new printedparts` - scaffolds `plugins/printedparts/`. -2. The scaffold assumes an Asset-extension plugin; ours is standalone. - In `plugin.py` strip the AssetType seeding from `on_install` (imitate - `plugins/knowledgebase/plugin.py` instead of the template). -3. Replace the scaffold model with the two spec tables: `PrintedItem`, - `PrintedItemTransaction` (`models/printeditem.py`). Use `BaseModel` + - `AuditMixin` from `shopdb.api`. Itemcode: leave generation to the API - layer (M2), column just `unique=True, index=True`. -4. Register both tables in `PLUGIN_TABLE_OWNERS` - (`shopdb/plugins/alembic_template.py`). -5. Create `plugins/printedparts/migrations/` with the 3-line `env.py` + - `script.py.mako` (copy from measuringtools) and a REAL baseline - `versions/0001_printedparts_baseline.py` - hand-written - `op.create_table(...)` for both tables (see - `plugins/measuringtools/migrations/versions/0001_measuringtools_baseline.py`). -6. Manifest: api_prefix `/api/printedparts`, `dependencies: ["employees"]`, - `default_enabled: false`. +Read the proposal. Tour the two reference plugins you will imitate: +`plugins/usb/` (checkout ledger + badge contract) and +`plugins/measuringtools/` (post-cutover migration baseline, hooks). +See it work: run the app, log in. -Checkpoint +## Stage 1 - scaffold, minus the AssetType + +``` +flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout" +``` + +Walk the generated tree. Then diverge: +1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its + `on_install` call - a printed part is a kind-with-a-count, not an asset. + Replace it with settings seeding (see the tagged commit): three Setting + rows, category `printedparts` - `printedparts_code_prefix` (3DP), + `printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny). +2. `manifest.json`: `"dependencies": ["employees"]` (badge names), + `"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`, + `"display_name": "3D Printed Parts"`. + +See it work: `flask plugin list` shows printedparts [Available]. +Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec` + +## Stage 2 - models + real migration baseline + tables live + +1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem` + (itemcode unique+indexed, itemname, itemdescription, imageurl, + quantityonhand, lowstockthreshold, binlocation, printnotes) and + `PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype + take/restock/adjust, SIGNED quantitychange, employeesso, employeename, + reason, transactiondate) - both on `BaseModel`. The ledger is the source + of truth; quantityonhand is a cache moved in the same commit. +2. Update `models/__init__.py` exports and `plugin.py` `get_models`. +3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`): + `'printedparts': ('printeditems', 'printeditemtransactions'),` +4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from + measuringtools (change PLUGIN_NAME), then hand-write + `versions/0001_printedparts_baseline.py` with explicit `op.create_table` + for both tables + the three transaction indexes. +5. The scaffold's `api/routes.py` still imports the model you deleted - make + the blueprint import cleanly (a placeholder route is fine for now). + +See it work: ``` flask plugin install printedparts && flask plugin enable printedparts -flask plugin upgrade-all # applies your 0001 -mysql> SHOW TABLES LIKE 'printed%'; -- both tables -mysql> SELECT * FROM alembic_version_printedparts; -- your revision id -pytest tests/ -q # nothing broken, contract tests green +mysql> SHOW TABLES LIKE 'printed%'; -- both tables +mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline +flask plugin upgrade-all -- printedparts: ok (idempotent) ``` -## Milestone 2 - CRUD API + permissions + itemcode +Common errors (both hit for real while building this): +- An empty `Migration error:` on install. Root cause: anything that makes + `plugins.printedparts.models` fail to import - the alembic env imports the + models package, which pulls in plugin.py and routes.py. Here it was the + scaffold routes importing the deleted model; the ImportError gets caught + and retried down a subprocess path with no stderr. Fix the import, not the + migration. +- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add + `EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` - + the guard makes every new plugin declare its expected head on purpose. -Build -1. `api/routes.py`: list/detail/create/update/soft-delete per the spec table. - Imitate a clean plugin blueprint (`plugins/measuringtools/api/routes.py`) - for pagination (`perpage`, `dir`), search, and the shared - `success_response`/`error_response` helpers from `shopdb.api`. -2. Itemcode on create: `-`; prefix from Setting - `printedparts_code_prefix` (read via `self.get_setting` or Setting model - through `shopdb.api`). Two-step: insert, flush to get the id, set code. -3. `get_permissions()` on the plugin class: view/create/edit/delete/restock - (tuples, category `printedparts` - copy shape from - `plugins/usb/plugin.py`). Gate mutations with `@jwt_required()` + - `@require_permission(...)`; reads are `@jwt_required(optional=True)`. -4. Restock + adjust endpoints: both write a LEDGER row and move - `quantityonhand` in the same commit. Adjust requires `reason`, rejects a - result below zero. Both record the operator: accept `badge` in the body - and resolve it (M4 extracts the resolver - for now digits=SSO is enough). -5. Seed the 3 settings in `on_install` (Setting.set pattern - see how other - plugins seed in `on_install`; category `printedparts`). +Commit + tag `lab-stage-02`. -Tip - earlier visible win: as soon as the GET list endpoint works, jump ahead -and wire just the api client + router entry + a bare `PrintedItemsList.vue` -(first two steps of M3), seed two rows by hand, and look at your parts in the -browser. Everything before that moment is invisible; seeing the table makes -the rest of the lab concrete. Then come back and finish the mutations here. +## Stage 3 - read API + list page (the first visible win) -Checkpoint -``` -pytest plugins/printedparts/tests/ -q # write tests as you go: -# - create mints 3DP-0001 style codes -# - restock/adjust move both ledger and cache atomically -# - adjust below zero -> 400/422 -# - permission gates: anonymous create -> 401, wrong-perm user -> 403 -curl -s localhost:5001/api/printedparts/items | jq # anonymous list OK -``` +1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via + `get_pagination_params`/`paginate_query`, search across + code/name/description/bin, `?lowstock=true` filter) and + `GET /items/` returning the item + its 25 most recent transactions. +2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box', + 'route': '/printedparts', 'position': 46}`. +3. Frontend: paste the `printedpartsApi` client into + `frontend/src/api/index.js` (list/get for now, paths under + `/printedparts/items`); rename the scaffold views to + `PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint + `router/routes/printedparts.js`; build the list page from + `PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an + image thumb column and a red/green quantity badge vs the threshold. +4. Seed two or three rows by hand (SQL or flask shell) purely to have + something to look at. NOTE: hand-seeded stock has no ledger backing - the + stage-9 reconcile report will flag exactly these rows, which is the check + working. -## Milestone 3 - management frontend + images +See it work: navigate to `/printedparts` - your parts in a table, low-stock +row red-badged. Everything before this moment was invisible; from here on +every stage shows on screen. -Build -1. The scaffold already dropped `PrintedpartsList/Detail/Form.vue` starters - and a router file; rename/build them into `PrintedItemsList/Detail/Form` - per the spec. Master templates: `PrintersList.vue` (list), - `PrinterDetail.vue` (detail). Global CSS only; CSS variables for colors - (frontend/CLAUDE.md rules). -2. Register the API client in `frontend/src/api/index.js` (paste the - generated `frontend-api-snippet.js`, extend with restock/adjust/image - calls). -3. Low-stock highlighting on the list (`quantityonhand <= lowstockthreshold` - -> danger badge). Filters: search + low-stock-only checkbox. -4. Image upload: replicate the models-image trio - upload/serve/delete - - from `shopdb/core/api/models.py` INTO the plugin blueprint - (`instance/printedpartsimages/`, public GET serve, imageurl column, - prefix-guarded delete). Wire the Form upload + Detail hero image. -5. Nav: `get_navigation_items()` -> "3D Parts" (usb plugin shape). Router - meta: list/detail plugin-gated, new/edit `requiresAuth` (see - `frontend/src/router/routes/usb.js`). +Common error: nav icon missing. The sidebar maps icon NAMES to Lucide +components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing. +Add `'box': Box` to the map (and the import) or reuse an existing name. -Checkpoint: create an item with a photo in the UI; thumbnail on list, hero on -detail; restock from detail updates qty + shows in history; frontend build + -`npx vitest run` green; naming hook green. +Commit + tag `lab-stage-03`. -## Milestone 4 - badge resolution + kiosk +## Stage 4 - catalog mutations + item photos + detail/form pages -Build -1. Badge resolver in the plugin (`services/badges.py`): copy the USB contract - - all-digits -> SSO; `^0(\d+)BZ$` case-insensitive -> PayNo; resolve - display name via the employees plugin directory the way - `plugins/usb/api/selfhosted.py::_resolve_name` does (lazy import inside - the function, degrade gracefully when the plugin is absent). Policy - setting `printedparts_unknown_badge` (deny -> 422). -2. Kiosk endpoints (UNauthenticated - the notifications/employees open-read - precedent): `GET /kiosk/item/` and `POST /kiosk/take` - {itemcode, badge, quantity}. Take: validate active item, 1 <= qty <= - onhand, resolve badge, single-commit ledger row + decrement. Clear error - strings - the kiosk displays them verbatim. -3. Kiosk view `/parts-kiosk`: top-level route, NO requiresAuth, outside - AppLayout (register beside `/shopfloor` in `frontend/src/router/index.js`). - Three-step flow per the spec. The scanner is a keyboard wedge: hidden - always-focused input, submit on Enter, route the scan to whichever step is - active. Build `TouchKeypad.vue` (3x4 grid of big buttons, emits digits/ - clear/backspace) - net-new, nothing to copy, keep it dumb. -4. Manual fallback path (typed item search + badge entry) behind a small - "no scanner?" link. +1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the + id: `-` with the prefix from Setting. `PUT /items/` + updates catalog fields but REFUSES `quantityonhand` (ledger-managed). + `DELETE` soft-retires. All `@jwt_required()` (permissions come in + stage 6). +2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE + `/items//image` + public `GET /image/`, storing + `printeditem-.` in `instance/printedpartsimages/`, wiping prior + extensions on replace, prefix-guarded delete. +3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info + list, transactions table); `PrintedItemForm.vue` create/edit + photo + upload on edit; extend the api client. -Checkpoint: full kiosk walkthrough on a touchscreen (or browser): scan/type -an itemcode -> item card; badge `0123456BZ` and plain SSO both resolve; take 3 --> success screen, qty down 3, ledger row has your name; taking more than -onhand -> friendly error; unknown badge -> denied message. Backend tests for -the resolver shapes + take validation. +See it work: add a part with a photo in the UI; thumbnail on the list, hero +on the detail; `PUT` with `quantityonhand` returns the ledger-managed error. -## Milestone 5 - labels (1in x 0.5in) +Commit + tag `lab-stage-04`. -Build -1. Public print route `/print/printedparts-labels` + view (imitate - `USBLabelBatch.vue` - USB is the precedent for a plugin OWNING its label - page instead of joining TYPE_CONFIG). -2. New stock size: `@page { size: 1in 0.5in; margin: 0 }`, one label per page - (roll-fed label printers treat each page as one label). Layout: CODE128 - via JsBarcode (~0.9in wide, displayValue false), itemcode text ~7pt under - it, optional truncated name. Offer QR as a variant but default barcode. -3. Batch: multi-select items -> sequence of labels; plus a ULINE mini-grid - sheet fallback (mini72 pattern in `AssetLabelBatch.vue`). -4. Print buttons on Detail (single) and List (batch selected). +## Stage 5 - the ledger: restock/adjust with badge attribution -Checkpoint: print preview shows one 1x0.5 label per page; a printed (or -PDF-zoomed) barcode scans back into the kiosk and pulls up the right item. -That round trip - label printed from the catalog, scanned at the kiosk, -stock decremented with your name on it - is the demo moment; make it work -end to end before polishing. +1. `services/badges.py` - COPY the USB badge contract (do not import + `plugins.usb`; cross-plugin imports fail the contract test): + `^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees + plugin `DirectoryEmployee` (lazy import, graceful fallback), and the + `printedparts_unknown_badge` policy - deny raises a kiosk-displayable + `BadgeError`, allow records the SSO with an empty name. +2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant: + append the transaction row and move the cached quantity in ONE commit. + Every write path goes through it. +3. `POST /items//restock` {quantity, badge} and `/adjust` + {quantitychange, reason, badge}; adjust requires a reason and refuses to + drive stock below zero. +4. Detail page: Restock/Adjust modals (shared `Modal.vue`). +5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge + shape, reason-required + below-zero guards, the policy toggle, 401 for + anonymous. See `tests/test_plugins/test_printedparts_ledger.py`. -## Milestone 6 - metrics, reports, widget +See it work: restock from the detail page with your SSO - quantity moves AND +a named transaction row appears. -Build -1. `get_reports()` -> stock, consumption (date range), by-person; endpoints - in the plugin blueprint, `@jwt_required(optional=True)`, `?format=csv` via - the `generate_csv` helper pattern (`shopdb/core/api/reports.py` shows the - shape; a plugin report lives in the plugin and is merged into - `GET /api/reports` automatically when enabled). -2. Stock report includes the reconcile check: flag rows where cached - `quantityonhand` != SUM(ledger). Should always be empty; if not, you have - a non-atomic write path - find it. -3. OPTIONAL/deferred: `get_dashboard_widgets()` -> low-stock count. Caveat: - this hook predates the ADR-010 data-only renderers - the widget names a - frontend component that must already exist in core, so a plugin widget - only renders if you also add that component. Reports are the primary - monitoring surface; skip the widget unless you want the extra credit. -4. Nice-to-have if time: burn rate (avg weekly takes over trailing 4 weeks + - weeks-to-empty). Plain SQL over the ledger. +Common error: in tests, mutating rows through a nested `app.app_context()` +does not reliably stick in the sqlite test env - stock the item through the +real restock endpoint instead (also more honest). -Checkpoint: reports appear on /reports grouped under the plugin, CSV -downloads; widget renders on the dashboard; reconcile column all-clear after -a kiosk session. +Commit + tag `lab-stage-05`. -## Milestone 7 - lifecycle + closeout +## Stage 6 - RBAC -Build/verify -1. Disable/enable cycle: `flask plugin disable printedparts` - nav entry, - routes, reports, and grantable permissions all disappear; enable restores. -2. Fresh-database proof: point DATABASE_URL at a scratch DB, `flask db - upgrade` + `flask plugin install/enable/upgrade-all` - everything works - with zero manual SQL. +1. `get_permissions` on the plugin: view/create/edit/delete/restock, category + `printedparts` (seeded automatically on install/enable and by + `flask seed permissions`). +2. Add `@require_permission('printedparts.')` under `@jwt_required()` on + every mutation: create/edit/delete/image = create/edit/delete; restock + + adjust = restock. +3. Test with the `member_headers` fixture (authenticated, role-less): 403 + where admin succeeds - authentication alone is not authorization. + +See it work: the permissions appear in the role grid (Settings > Roles), and +the member test passes. + +Commit + tag `lab-stage-06`. + +## Stage 7 - the kiosk (the deliberate open write) + +Read the decision record in the proposal first. The take endpoint must stay: +decrement-only, badge-attributed server-side, bounded, physically +rate-limited. Put the justification in the plugin README. + +1. Backend, both UNdecorated: `GET /kiosk/item/` (summary for a + scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} - + validate active item, 1 <= qty <= onhand, resolve the badge, then + `_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown + verbatim on the kiosk - write them for a person standing at a screen. +2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace. +3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside + `/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout, + `meta.plugin` so a disabled plugin dead-ends). Three steps - scan item, + scan badge, keypad quantity - driven by ONE hidden always-focused input + that consumes keyboard-wedge scans (scanners type the code + Enter) for + whichever step is active; manual type-in fallbacks for damaged labels. + Success screen auto-resets after a few seconds. +4. Kiosk test: open access, over-take guard, unknown-badge 422, and + cache==ledger afterward. + +See it work: full walkthrough in a browser - type a code, badge in, keypad 2, +TAKE - stock drops with your name in the ledger. + +Common error (by design): the full suite fails with +`test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`. +That sweep asserts EVERY mutating route rejects a role-less user - the +framework's net against accidentally-open writes. Your kiosk take is open on +purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment +pointing at the decision record. The net stays; the exception is explicit +and reviewable. + +Commit + tag `lab-stage-07`. + +## Stage 8 - 1in x 0.5in bin labels + +1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public + `/print/printedparts-labels` route beside `/print/usb-labels` (a plugin + OWNS its label page - the USB precedent; parts are not in the asset-label + TYPE_CONFIG because they are not assets). +2. The label: CODE128 of the itemcode via JsBarcode + (`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`) + + the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner + tolerance; CODE128 of `3DP-0042` is comfortable. +3. Roll stock = one label per page: a global (unscoped) print style with + `@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on + each `.bin-label`. Multi-select + per-item copies; `?item=` + preselects (the Detail page's Bin Label button). + +See it work: print preview shows one 1x0.5 label per page; scan the printed +barcode (or the on-screen one with a phone scanner app) into the kiosk - +label -> scan -> badge -> take -> ledger is the demo moment. + +Commit + tag `lab-stage-08`. + +## Stage 9 - reports + the reconcile check + +1. Three jwt-optional endpoints in the plugin blueprint, each honoring + `?format=csv` (local CSV helper - `generate_csv` is not on the contract + surface): `/reports/stock`, `/reports/consumption?days=N`, + `/reports/by-person?days=N`. +2. The stock report's `ledgerdelta` column = cached quantityonhand minus the + ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a + write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows + show up here, proving the check works. +3. `get_reports` on the plugin (endpoint-style entries, categories + inventory/usage) - they merge into `GET /api/reports` and the /reports hub + while the plugin is enabled. + +Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries +strings. + +Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only +renderers; needs a core component) and a Settings card (needs a settings page +to link). Reports are the monitoring surface. + +Commit + tag `lab-stage-09`. + +## Stage 10 - closeout + +1. Lifecycle: `flask plugin disable printedparts` - nav, reports, and + grantable permissions disappear; API routes only disappear after a + RESTART (blueprints register at startup - the guide's section 12 gotcha). + Re-enable. +2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` + + `flask plugin install/enable printedparts` + `upgrade-all` - green with + zero manual SQL. 3. Full suite: backend pytest, vitest, frontend build, naming hook. -4. End checklist from `PLUGIN-GUIDE.md` section 12. +4. Walk `PLUGIN-GUIDE.md` section 12's End checklist. Done means: a colleague can clone the repo, enable the plugin, print a bin label, and take a part at the kiosk with their badge - without asking you @@ -233,5 +302,6 @@ anything. | Barcode/QR rendering | JsBarcode usage in `AssetLabel.vue`, `qrLogo.js` | | Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` | | List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` | -| Reports hook + CSV | `plugins/warranty/` report + `shopdb/core/api/reports.py` | +| Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` | | Permissions declaration | `plugins/usb/plugin.py::get_permissions` | +| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..10` | diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 75d4cd8..04a8fee 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'} # shape as the exempt collector blueprint; the geenforce admin endpoints in # the same blueprint are JWT+permission gated and ARE swept. EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', - 'geenforce.post_report'} + 'geenforce.post_report', + # Deliberately open kiosk write: decrement-only, + # badge-attributed server-side. Decision record in + # docs/proposals/printedparts-plugin.md; justification in + # the plugin README. + 'printedparts.kiosk_take'} @pytest.fixture(autouse=True) From df918ed38f70e279f7912d4f8fee75435c21551a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:15:50 -0400 Subject: [PATCH 11/19] printedparts stage 11: low-stock email alerts on threshold crossing Contract 0.12.0: send_email/send_alert join the plugin surface (the mailer was core-only), PLUGIN-HOOKS and status docs updated, manifest pins the new floor. The alert fires inside _ledger_write only when a decrement CROSSES the item's threshold - one alert per depletion, rearmed by restocking above - and is best-effort after the commit so mail trouble can never fail a take. Recipients come from printedparts_alert_email, falling back to the site alert_recipients. on_enable re-seeds settings idempotently so existing installs pick up new keys. Crossing/rearm semantics proven by test. --- CLAUDE.md | 2 +- docs/PLUGIN-HOOKS.md | 5 ++- docs/PLUGIN-LAB-PRINTEDPARTS.md | 20 ++++++++++ docs/ROADMAP.md | 2 +- plugins/printedparts/api/routes.py | 37 ++++++++++++++++++- plugins/printedparts/manifest.json | 2 +- plugins/printedparts/plugin.py | 9 +++++ shopdb/__init__.py | 2 +- shopdb/api/__init__.py | 3 ++ .../test_plugins/test_printedparts_ledger.py | 34 +++++++++++++++++ 10 files changed, 110 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9374f1..adc9001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely ### Active state - 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8) -- `__contract_version__` at 0.11.0 (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) +- `__contract_version__` at 0.12.0 (0.12.0 adds the mailer to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) - 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty - Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8). - Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM). diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index bed411e..4c32eff 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra The framework declares its contract version in `shopdb/__init__.py`: ```python -__contract_version__ = '0.11.0' +__contract_version__ = '0.12.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -479,6 +479,9 @@ What `shopdb.api` exposes: - Import mode: `apply_import_timestamps`, `import_mode_active`, `parse_import_datetime` - Legacy employee directory: `employee_connection` +- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and + `send_alert(subject, html, text=None)` - settings-first, no-op safe when + email is unconfigured; send_alert targets the site's alert_recipients ```python from shopdb.api import db, Asset, AssetType, success_response, paginate_query diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 8a0ac37..6e53376 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -286,6 +286,26 @@ Done means: a colleague can clone the repo, enable the plugin, print a bin label, and take a part at the kiosk with their badge - without asking you anything. +## Stage 11 (extension) - low-stock email alerts + +Per-item thresholds already exist; alerting on them is a worked example of a +CONTRACT ADDITION, because the mailer was not on the plugin surface: +1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump + `__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the + docs-drift guard test fails until the doc's version example matches. + Manifest pins `core_version >=0.12.0` since the plugin now needs it. +2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the + threshold (before > threshold >= after). Crossing, not being-below, is the + natural debounce: one alert per depletion, restocking above rearms. + Best-effort try/except AFTER the commit - mail failure must never fail + the take. +3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty + falls back to the site's alert_recipients via `send_alert`. Seed the new + setting in on_enable too (idempotent) so already-installed sites get it. +4. Test with a monkeypatched sender: no alert above threshold, one on the + crossing, no re-fire while below, rearm after restock (see + `test_lowstock_alert_fires_on_crossing_only`). + --- ## Where each pattern lives (cheat sheet) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5227ead..f460428 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -shopdb-flask is at `__contract_version__ = '0.11.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. +shopdb-flask is at `__contract_version__ = '0.12.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. ## Phase status diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 6c8008f..1ac9408 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -228,8 +228,12 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None) """Append a ledger row and move the cached quantity in ONE commit. The single-commit invariant is what keeps quantityonhand equal to the - ledger sum; every write path must go through here. + ledger sum; every write path must go through here. Fires the low-stock + alert when this write CROSSES the item's threshold downward - crossing + (not being below) is the natural debounce: one alert per depletion, and + restocking above the threshold rearms it. """ + quantitybefore = item.quantityonhand item.quantityonhand += quantitychange db.session.add(PrintedItemTransaction( printeditemid=item.printeditemid, @@ -240,6 +244,37 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None) reason=reason, )) db.session.commit() + if (quantitychange < 0 + and quantitybefore > item.lowstockthreshold + and item.quantityonhand <= item.lowstockthreshold): + _send_lowstock_alert(item) + + +def _send_lowstock_alert(item): + """Best-effort email when an item crosses its low-stock threshold. + + Recipients: Setting printedparts_alert_email (comma-separated), falling + back to the site's alert_recipients. Never fails the transaction - the + ledger write already committed.""" + from shopdb.api import send_email, send_alert + subject = (f'Low stock: {item.itemname} ({item.itemcode}) - ' + f'{item.quantityonhand} left') + html = (f'

{item.itemname} ({item.itemcode}) is down ' + f'to {item.quantityonhand} ' + f'(threshold {item.lowstockthreshold}).

' + f'

Bin: {item.binlocation or "-"}

' + f'

Time to print more.

') + try: + recipients = (Setting.get('printedparts_alert_email') or '').strip() + if recipients: + send_email([address.strip() for address in recipients.split(',') + if address.strip()], subject, html) + else: + send_alert(subject, html) + except Exception: + import logging + logging.getLogger(__name__).exception( + 'Low-stock alert failed for %s', item.itemcode) @printedparts_bp.route('/items//restock', methods=['POST']) diff --git a/plugins/printedparts/manifest.json b/plugins/printedparts/manifest.json index 23d963c..dfb0231 100644 --- a/plugins/printedparts/manifest.json +++ b/plugins/printedparts/manifest.json @@ -5,7 +5,7 @@ "display_name": "3D Printed Parts", "author": "", "dependencies": ["employees"], - "core_version": ">=0.11.0,<1.0.0", + "core_version": ">=0.12.0,<1.0.0", "api_prefix": "/api/printedparts", "default_enabled": false } diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index 97e14b4..9224713 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -103,6 +103,12 @@ class PrintedpartsPlugin(BasePlugin): self._seed_settings() logger.info('Printedparts plugin installed') + def on_enable(self, app: Flask) -> None: + # Idempotent re-seed so settings added in later versions reach sites + # that installed earlier (enable runs on every upgrade cycle). + with app.app_context(): + self._seed_settings() + def _seed_settings(self) -> None: defaults = [ ('printedparts_code_prefix', '3DP', 'string', @@ -111,6 +117,9 @@ class PrintedpartsPlugin(BasePlugin): 'Default low-stock threshold for new items'), ('printedparts_unknown_badge', 'deny', 'string', 'Kiosk policy when a badge resolves to no employee: allow or deny'), + ('printedparts_alert_email', '', 'string', + 'Comma-separated low-stock alert recipients; empty uses the ' + 'site alert_recipients'), ] for key, value, valuetype, description in defaults: if Setting.get(key) is None: diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 2a6857a..19cef02 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -36,7 +36,7 @@ from .plugins import plugin_manager # unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped # managed service token without importing core token internals. Additive name # on the import surface, minor bump. -__contract_version__ = '0.11.0' +__contract_version__ = '0.12.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index d4b2ac9..88cb7fd 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -78,6 +78,7 @@ from shopdb.core.services.dualpath import ( # Legacy employee directory lookup (read-only) used by notifications from shopdb.utils.employee_db import employee_connection +from shopdb.utils.mailer import send_email, send_alert # CMMC USB check-in/out database (read-write) used by the usb plugin from shopdb.utils.cmmc_usb_db import cmmc_usb_connection @@ -266,6 +267,8 @@ __all__ = [ 'parse_import_datetime', # Legacy employee directory 'employee_connection', + 'send_email', + 'send_alert', # CMMC USB check-in/out database 'cmmc_usb_connection', ] diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py index 790e8d7..fc53323 100644 --- a/tests/test_plugins/test_printedparts_ledger.py +++ b/tests/test_plugins/test_printedparts_ledger.py @@ -166,3 +166,37 @@ def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item, PrintedItemTransaction.query.filter_by( printeditemid=item).all()) assert cached == ledgersum + + +def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item, + directory_employee, monkeypatch): + """One alert when stock CROSSES the threshold downward; restocking above + rearms it; staying below does not re-fire.""" + sent = [] + import plugins.printedparts.api.routes as printedparts_routes + monkeypatch.setattr( + printedparts_routes, '_send_lowstock_alert', + lambda alerted_item: sent.append(alerted_item.itemcode)) + + def restock(quantity): + return client.post(f'/api/printedparts/items/{item}/restock', + json={'quantity': quantity, + 'badge': directory_employee}, + headers=auth_headers) + + def take(quantity): + return client.post('/api/printedparts/kiosk/take', + json={'itemcode': '3DP-9001', + 'badge': directory_employee, + 'quantity': quantity}) + + restock(10) # 10 on hand, threshold 5 + assert take(3).status_code == 200 # 7: above threshold, no alert + assert sent == [] + assert take(3).status_code == 200 # 4: CROSSES 5 -> one alert + assert sent == ['3DP-9001'] + assert take(2).status_code == 200 # 2: still below, no re-fire + assert sent == ['3DP-9001'] + restock(20) # 22: rearmed + assert take(18).status_code == 200 # 4: crosses again -> second alert + assert sent == ['3DP-9001', '3DP-9001'] From 427eb0de8cd8a24e36cf50349fc99cfb39d05e51 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:25:33 -0400 Subject: [PATCH 12/19] printedparts stage 12: admin settings page + settings-rail card PrintedPartsSettings edits the four plugin settings (code prefix, default threshold, kiosk badge policy, alert recipients) through the core settings API; the route rides the plugin's router file and the settings shell nests it into the rail; get_settings_cards contributes the catalog card while the plugin is enabled. --- docs/PLUGIN-LAB-PRINTEDPARTS.md | 14 +++ frontend/src/router/routes/printedparts.js | 6 + .../views/settings/PrintedPartsSettings.vue | 109 ++++++++++++++++++ plugins/printedparts/plugin.py | 13 +++ 4 files changed, 142 insertions(+) create mode 100644 frontend/src/views/settings/PrintedPartsSettings.vue diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 6e53376..44116ba 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -306,6 +306,20 @@ CONTRACT ADDITION, because the mailer was not on the plugin surface: crossing, no re-fire while below, rearm after restock (see `test_lowstock_alert_fires_on_crossing_only`). +## Stage 12 (extension) - the admin settings page + +A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred +it. The page is ordinary: +1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four + keys via `settingsApi.list({category: 'printedparts'})`, save each with + `settingsApi.update(key, value)` (admin-gated server-side). +2. Route in the PLUGIN's router file with path `settings/printedparts` + + `requiresAuth, requiresAdmin, plugin` meta - the router shell + automatically nests any `settings/...` path under the two-pane settings + rail. +3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` - + the card appears in the rail's catalog while the plugin is enabled. + --- ## Where each pattern lives (cheat sheet) diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js index 4646e0c..f769ad3 100644 --- a/frontend/src/router/routes/printedparts.js +++ b/frontend/src/router/routes/printedparts.js @@ -31,5 +31,11 @@ export default [ name: 'printedparts-edit', component: () => import('../../views/printedparts/PrintedItemForm.vue'), meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'settings/printedparts', + name: 'settings-printedparts', + component: () => import('../../views/settings/PrintedPartsSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' } } ] diff --git a/frontend/src/views/settings/PrintedPartsSettings.vue b/frontend/src/views/settings/PrintedPartsSettings.vue new file mode 100644 index 0000000..605d40a --- /dev/null +++ b/frontend/src/views/settings/PrintedPartsSettings.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py index 9224713..ee7cd3e 100644 --- a/plugins/printedparts/plugin.py +++ b/plugins/printedparts/plugin.py @@ -62,6 +62,19 @@ class PrintedpartsPlugin(BasePlugin): 'printedparts'), ] + def get_settings_cards(self) -> List[dict]: + return [ + { + 'group': '3D Printed Parts', + 'to': '/settings/printedparts', + 'icon': 'box', + 'title': '3D Parts Settings', + 'description': 'Item code prefix, default threshold, kiosk ' + 'badge policy, low-stock alert recipients', + 'position': 47, + }, + ] + def get_reports(self) -> List[dict]: return [ { From a8a6baf979fbfc4c1a7d0ab01b073b1ddf4ecf3c Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:30:04 -0400 Subject: [PATCH 13/19] printedparts stage 13: pick alert recipients from shopdb users Contract 0.13.0 puts the User model on the plugin surface. The settings page gains a checkbox picker over the user list; selected users receive low-stock alerts at their account email, merged and deduped with the free-text address list, inactive accounts skipped, site alert_recipients still the fallback when both are empty. --- CLAUDE.md | 2 +- docs/PLUGIN-HOOKS.md | 4 +- docs/PLUGIN-LAB-PRINTEDPARTS.md | 15 ++++++ docs/ROADMAP.md | 2 +- .../views/settings/PrintedPartsSettings.vue | 51 +++++++++++++++++-- plugins/printedparts/api/routes.py | 26 ++++++++-- plugins/printedparts/plugin.py | 3 ++ shopdb/__init__.py | 2 +- shopdb/api/__init__.py | 2 + .../test_plugins/test_printedparts_ledger.py | 40 +++++++++++++++ 10 files changed, 136 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adc9001..3516a50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely ### Active state - 966 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8) -- `__contract_version__` at 0.12.0 (0.12.0 adds the mailer to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) +- `__contract_version__` at 0.13.0 (0.12.0 added the mailer, 0.13.0 the User model, to the plugin surface) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) - 12 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty - Core Alembic chain: baseline `68b3947ae14f` -> head `7d25_drop_redundant_indexes` (32 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8). - Legacy import: `docs/IMPORT-API.md` is the schema-agnostic import contract; `docs/IMPORT-ADOPTION.md` + `docs/PILOT-DEPLOY.md` cover adopting a site; `scripts/site_imports/wjf/` is the West Jefferson reference loader (all 15 stages, validated end-to-end including on a Windows + MySQL 8 VM). diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 4c32eff..05f47d8 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra The framework declares its contract version in `shopdb/__init__.py`: ```python -__contract_version__ = '0.12.0' +__contract_version__ = '0.13.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -479,6 +479,8 @@ What `shopdb.api` exposes: - Import mode: `apply_import_timestamps`, `import_mode_active`, `parse_import_datetime` - Legacy employee directory: `employee_connection` +- `User` (0.13.0) - the account model, e.g. resolving alert recipients' + emails from selected user ids - Mailer (0.12.0): `send_email(to, subject, html, text=None)` and `send_alert(subject, html, text=None)` - settings-first, no-op safe when email is unconfigured; send_alert targets the site's alert_recipients diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 44116ba..75b53a9 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -320,6 +320,21 @@ it. The page is ordinary: 3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` - the card appears in the rail's catalog while the plugin is enabled. +## Stage 13 (extension) - alert recipients picked from shopdb users + +Free-text emails rot; user accounts do not. Another contract addition: +`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the +docs-drift guard again). +1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded + beside the others. +2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's + account email, merge with the free-text list, dedupe order-preserving; + empty result still falls back to the site alert_recipients. +3. Settings page: checkbox picker over `usersApi.list()` (the page is + admin-only, matching the endpoint), saving joined ids. +4. Test: active user's email + free-text merge deduped, inactive user + skipped (`test_alert_recipients_merge_users_and_freetext`). + --- ## Where each pattern lives (cheat sheet) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f460428..6e83d00 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -shopdb-flask is at `__contract_version__ = '0.12.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. +shopdb-flask is at `__contract_version__ = '0.13.0'` (pre-1.0; product `__version__ 0.7.0`, tags through v0.7.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs. ## Phase status diff --git a/frontend/src/views/settings/PrintedPartsSettings.vue b/frontend/src/views/settings/PrintedPartsSettings.vue index 605d40a..d06096e 100644 --- a/frontend/src/views/settings/PrintedPartsSettings.vue +++ b/frontend/src/views/settings/PrintedPartsSettings.vue @@ -34,7 +34,23 @@
- + +
+ +

No users loaded

+
+

+ Selected users receive low-stock alerts at their account email. +

+
+ +
+

@@ -53,21 +69,25 @@