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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user