From 38c7ec347bf949704094c8864a9af62b6a84b746 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 22 Jul 2026 10:21:42 -0400 Subject: [PATCH] alerts: Teams webhook fan-out (contract 0.14.0) + printedparts detail revision column send_webhook(title,text) posts alerts to an optional webhook (Teams Incoming Webhook / Workflow, or generic JSON) via alert_webhook_url + alert_webhook_format settings; send_alert fans out to it alongside email; exposed on shopdb.api (0.13.0->0.14.0, PLUGIN-HOOKS synced); low-stock posts on its custom-recipient path too. Also: recent-transactions table shows the consumed print-file revision. --- CLAUDE.md | 2 +- docs/PLUGIN-HOOKS.md | 6 +- plugins/printedparts/api/routes.py | 13 ++- .../frontend/views/PrintedItemDetail.vue | 4 +- shopdb/__init__.py | 2 +- shopdb/api/__init__.py | 3 +- shopdb/core/api/settings.py | 17 ++++ shopdb/utils/mailer.py | 84 ++++++++++++++++++- tests/test_core/test_webhook_alert.py | 42 ++++++++++ 9 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 tests/test_core/test_webhook_alert.py diff --git a/CLAUDE.md b/CLAUDE.md index d2e803b..5a3f1a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely ### Active state - 1077 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8) -- `__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) +- `__contract_version__` at 0.14.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) - 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty - Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 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). - Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins//frontend/` dirs (e.g. `applications`) are core and always ship. diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 97db3a4..e1a84ec 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.13.0' +__contract_version__ = '0.14.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -509,6 +509,10 @@ What `shopdb.api` exposes: - 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 +- `send_webhook(title, text)` (0.14.0) - POST an alert to the configured + `alert_webhook_url` (Teams Incoming Webhook / Workflow, or generic JSON via + the `alert_webhook_format` setting); best-effort, no-op when unset. + `send_alert` fans out to this automatically alongside email. ```python from shopdb.api import db, Asset, AssetType, success_response, paginate_query diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index f55a983..b01eed9 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -330,26 +330,33 @@ def _send_lowstock_alert(item): 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, Setting + from shopdb.api import send_email, send_alert, send_webhook, Setting code = item.gagelabtag or item.itemcode subject = (f'Low stock: {item.itemname} ({code}) - ' f'{item.quantityonhand} left') # Absolute link to the item page when the site base URL is configured # (e.g. https:///ops). Emails have no request context to derive it. base = (Setting.get('site_base_url') or '').rstrip('/') - link_html = (f'

' - f'View {code}

') if base else '' + item_url = f'{base}/printedparts/{item.printeditemid}' if base else '' + link_html = f'

View {code}

' if item_url else '' html = (f'

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

' f'

Bin: {item.binlocation or "-"}

' f'{link_html}' f'

Time to print more.

') + webhook_text = (f'**{item.itemname}** ({code}) is down to ' + f'{item.quantityonhand} (threshold {item.lowstockthreshold}). ' + f'Bin: {item.binlocation or "-"}.' + + (f' [View]({item_url})' if item_url else '')) try: recipients = _alert_recipients() if recipients: + # send_email does not fan to the webhook, so post it explicitly. + send_webhook(subject, webhook_text) send_email(recipients, subject, html) else: + # send_alert already fans out to email + webhook. send_alert(subject, html) except Exception: import logging diff --git a/plugins/printedparts/frontend/views/PrintedItemDetail.vue b/plugins/printedparts/frontend/views/PrintedItemDetail.vue index 52a4cc1..e60d734 100644 --- a/plugins/printedparts/frontend/views/PrintedItemDetail.vue +++ b/plugins/printedparts/frontend/views/PrintedItemDetail.vue @@ -120,6 +120,7 @@ When Type Qty + Rev Who Reason @@ -132,11 +133,12 @@ {{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }} + {{ transaction.revision != null ? transaction.revision : '-' }} {{ transaction.employeename || transaction.employeesso }} {{ transaction.reason || '-' }} - No transactions yet + No transactions yet diff --git a/shopdb/__init__.py b/shopdb/__init__.py index a209c4d..2426774 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.13.0' +__contract_version__ = '0.14.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 be2c5a0..57cb26a 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -80,7 +80,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 +from shopdb.utils.mailer import send_email, send_alert, send_webhook # CMMC USB check-in/out database (read-write) used by the usb plugin from shopdb.utils.cmmc_usb_db import cmmc_usb_connection @@ -271,6 +271,7 @@ __all__ = [ 'employee_connection', 'send_email', 'send_alert', + 'send_webhook', 'User', 'Role', # CMMC USB check-in/out database diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 939ffa4..4f58da5 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -838,6 +838,23 @@ def build_default_settings(): 'description': 'Public base URL of this site (e.g. ' 'https://host/ops), used to build links in emails' }, + { + 'key': 'alert_webhook_url', + 'value': '', + 'valuetype': 'string', + 'category': 'email', + 'description': 'Optional webhook URL alerts also POST to (e.g. a ' + 'Microsoft Teams Incoming Webhook or Workflow)' + }, + { + 'key': 'alert_webhook_format', + 'value': 'teams', + 'valuetype': 'string', + 'category': 'email', + 'description': 'Alert webhook payload format: teams (classic ' + 'Incoming Webhook), adaptivecard (Teams Workflow), ' + 'or json (generic {title,text})' + }, # Audit log settings { 'key': 'audit_retention_days', diff --git a/shopdb/utils/mailer.py b/shopdb/utils/mailer.py index e608102..72b47df 100644 --- a/shopdb/utils/mailer.py +++ b/shopdb/utils/mailer.py @@ -19,6 +19,8 @@ import os import re import smtplib import ssl + +import requests from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr @@ -175,13 +177,89 @@ def send_email(to, subject, html, text=None): return ok +def get_webhook_config(): + """Alert-webhook config from settings: the URL and the payload format.""" + settings = {} + if has_app_context(): + from shopdb.core.api.settings import get_cached_settings + try: + settings = get_cached_settings() or {} + except Exception: + settings = {} + return { + 'url': (settings.get('alert_webhook_url') or '').strip(), + # 'teams' = classic Incoming Webhook (MessageCard); 'adaptivecard' = + # Teams Workflow (Power Automate); 'json' = generic {title,text}. + 'format': (settings.get('alert_webhook_format') or 'teams').strip().lower(), + } + + +def _webhook_payload(fmt, title, text): + if fmt == 'adaptivecard': + return { + 'type': 'message', + 'attachments': [{ + 'contentType': 'application/vnd.microsoft.card.adaptive', + 'content': { + 'type': 'AdaptiveCard', + '$schema': 'http://adaptivecards.io/schemas/adaptive-card.json', + 'version': '1.4', + 'body': [ + {'type': 'TextBlock', 'weight': 'Bolder', + 'size': 'Medium', 'text': title, 'wrap': True}, + {'type': 'TextBlock', 'text': text, 'wrap': True}, + ], + }, + }], + } + if fmt == 'json': + return {'title': title, 'text': text} + # default 'teams' = classic Incoming Webhook connector MessageCard + return { + '@type': 'MessageCard', + '@context': 'https://schema.org/extensions', + 'summary': title, + 'themeColor': 'D93F3F', + 'title': title, + 'text': text, + } + + +def send_webhook(title, text): + """POST an alert to the configured webhook (Teams, etc.). Best-effort: + returns (ok, error); a no-op ((False, None)) when no URL is configured, and + never raises so it can never block the caller.""" + config = get_webhook_config() + if not config['url']: + return False, None + try: + response = requests.post( + config['url'], json=_webhook_payload(config['format'], title, text), + timeout=10) + if response.status_code >= 400: + return False, f'HTTP {response.status_code}' + _log().info('Alert webhook posted: %s', title) + return True, None + except Exception as exception: + error = str(exception) + _log().warning('Alert webhook failed: %s', error) + return False, error + + def send_alert(subject, html, text=None): - """Send an alert to the site's configured alert_recipients. Returns False - when email is off or no alert recipients are configured.""" + """Fan an alert out to the configured channels: the site's alert_recipients + (email) and the alert webhook (Teams, etc.). Each channel is independent and + best-effort; returns True if the EMAIL leg sent.""" + body = text or _html_to_text(html) + # Webhook fans out alongside email, independent of SMTP being configured. + try: + send_webhook(subject, body) + except Exception: + pass config = get_smtp_config() recipients = _normalize_recipients(config['alert_recipients']) if not recipients: - _log().warning('Alert not sent: no alert_recipients configured.') + _log().warning('Alert email not sent: no alert_recipients configured.') return False return send_email(recipients, subject, html, text=text) diff --git a/tests/test_core/test_webhook_alert.py b/tests/test_core/test_webhook_alert.py new file mode 100644 index 0000000..e82684e --- /dev/null +++ b/tests/test_core/test_webhook_alert.py @@ -0,0 +1,42 @@ +"""Alert webhook (Teams etc.): payload formats + send_alert fan-out.""" +from unittest.mock import patch + +from shopdb.utils.mailer import _webhook_payload, send_webhook, send_alert + +CFG = {'url': 'https://teams.example/webhook', 'format': 'teams'} + + +def test_payload_formats(): + teams = _webhook_payload('teams', 'Title', 'Body') + assert teams['@type'] == 'MessageCard' and teams['title'] == 'Title' + card = _webhook_payload('adaptivecard', 'T', 'B') + assert card['attachments'][0]['contentType'].endswith('card.adaptive') + assert _webhook_payload('json', 'T', 'B') == {'title': 'T', 'text': 'B'} + + +def test_noop_when_unset(app): + with app.app_context(): + with patch('shopdb.utils.mailer.get_webhook_config', + return_value={'url': '', 'format': 'teams'}): + ok, err = send_webhook('t', 'b') + assert ok is False and err is None + + +def test_posts_when_configured(app): + with app.app_context(): + with patch('shopdb.utils.mailer.get_webhook_config', return_value=CFG), \ + patch('shopdb.utils.mailer.requests') as mock_requests: + mock_requests.post.return_value.status_code = 200 + ok, err = send_webhook('Low stock', 'x down') + assert ok is True and err is None + assert mock_requests.post.call_args[0][0] == CFG['url'] + assert mock_requests.post.call_args[1]['json']['@type'] == 'MessageCard' + + +def test_send_alert_fans_out_to_webhook(app): + with app.app_context(): + with patch('shopdb.utils.mailer.get_webhook_config', return_value=CFG), \ + patch('shopdb.utils.mailer.requests') as mock_requests: + mock_requests.post.return_value.status_code = 200 + send_alert('Subject', '

Body

') + assert mock_requests.post.called # webhook fired even with no email recipients