From df918ed38f70e279f7912d4f8fee75435c21551a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 08:15:50 -0400 Subject: [PATCH] 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']