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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'<p><strong>{item.itemname}</strong> ({item.itemcode}) is down '
|
||||
f'to <strong>{item.quantityonhand}</strong> '
|
||||
f'(threshold {item.lowstockthreshold}).</p>'
|
||||
f'<p>Bin: {item.binlocation or "-"}</p>'
|
||||
f'<p>Time to print more.</p>')
|
||||
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/<int:item_id>/restock', methods=['POST'])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user