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.
This commit is contained in:
cproudlock
2026-07-22 10:21:42 -04:00
parent d141fef203
commit 38c7ec347b
9 changed files with 162 additions and 11 deletions

View File

@@ -45,7 +45,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
### Active state ### 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) - 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 - 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). - 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/<name>/frontend/` dirs (e.g. `applications`) are core and always ship. - 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/<name>/frontend/` dirs (e.g. `applications`) are core and always ship.

View File

@@ -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`: The framework declares its contract version in `shopdb/__init__.py`:
```python ```python
__contract_version__ = '0.13.0' __contract_version__ = '0.14.0'
``` ```
Each plugin's `manifest.json` declares the range of contract versions it supports: 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 - Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
`send_alert(subject, html, text=None)` - settings-first, no-op safe when `send_alert(subject, html, text=None)` - settings-first, no-op safe when
email is unconfigured; send_alert targets the site's alert_recipients 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 ```python
from shopdb.api import db, Asset, AssetType, success_response, paginate_query from shopdb.api import db, Asset, AssetType, success_response, paginate_query

View File

@@ -330,26 +330,33 @@ def _send_lowstock_alert(item):
Recipients: Setting printedparts_alert_email (comma-separated), falling Recipients: Setting printedparts_alert_email (comma-separated), falling
back to the site's alert_recipients. Never fails the transaction - the back to the site's alert_recipients. Never fails the transaction - the
ledger write already committed.""" 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 code = item.gagelabtag or item.itemcode
subject = (f'Low stock: {item.itemname} ({code}) - ' subject = (f'Low stock: {item.itemname} ({code}) - '
f'{item.quantityonhand} left') f'{item.quantityonhand} left')
# Absolute link to the item page when the site base URL is configured # Absolute link to the item page when the site base URL is configured
# (e.g. https://<host>/ops). Emails have no request context to derive it. # (e.g. https://<host>/ops). Emails have no request context to derive it.
base = (Setting.get('site_base_url') or '').rstrip('/') base = (Setting.get('site_base_url') or '').rstrip('/')
link_html = (f'<p><a href="{base}/printedparts/{item.printeditemid}">' item_url = f'{base}/printedparts/{item.printeditemid}' if base else ''
f'View {code}</a></p>') if base else '' link_html = f'<p><a href="{item_url}">View {code}</a></p>' if item_url else ''
html = (f'<p><strong>{item.itemname}</strong> ({code}) is down ' html = (f'<p><strong>{item.itemname}</strong> ({code}) is down '
f'to <strong>{item.quantityonhand}</strong> ' f'to <strong>{item.quantityonhand}</strong> '
f'(threshold {item.lowstockthreshold}).</p>' f'(threshold {item.lowstockthreshold}).</p>'
f'<p>Bin: {item.binlocation or "-"}</p>' f'<p>Bin: {item.binlocation or "-"}</p>'
f'{link_html}' f'{link_html}'
f'<p>Time to print more.</p>') f'<p>Time to print more.</p>')
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: try:
recipients = _alert_recipients() recipients = _alert_recipients()
if 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) send_email(recipients, subject, html)
else: else:
# send_alert already fans out to email + webhook.
send_alert(subject, html) send_alert(subject, html)
except Exception: except Exception:
import logging import logging

View File

@@ -120,6 +120,7 @@
<th>When</th> <th>When</th>
<th>Type</th> <th>Type</th>
<th>Qty</th> <th>Qty</th>
<th>Rev</th>
<th>Who</th> <th>Who</th>
<th>Reason</th> <th>Reason</th>
</tr> </tr>
@@ -132,11 +133,12 @@
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'"> <td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }} {{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
</td> </td>
<td>{{ transaction.revision != null ? transaction.revision : '-' }}</td>
<td>{{ transaction.employeename || transaction.employeesso }}</td> <td>{{ transaction.employeename || transaction.employeesso }}</td>
<td>{{ transaction.reason || '-' }}</td> <td>{{ transaction.reason || '-' }}</td>
</tr> </tr>
<tr v-if="!item.recenttransactions?.length"> <tr v-if="!item.recenttransactions?.length">
<td colspan="5" class="empty-state">No transactions yet</td> <td colspan="6" class="empty-state">No transactions yet</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -36,7 +36,7 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped # unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name # managed service token without importing core token internals. Additive name
# on the import surface, minor bump. # 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 # Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent # plugin-contract version above are distinct series with independent

View File

@@ -80,7 +80,7 @@ from shopdb.core.services.dualpath import (
# Legacy employee directory lookup (read-only) used by notifications # Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection 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 # CMMC USB check-in/out database (read-write) used by the usb plugin
from shopdb.utils.cmmc_usb_db import cmmc_usb_connection from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
@@ -271,6 +271,7 @@ __all__ = [
'employee_connection', 'employee_connection',
'send_email', 'send_email',
'send_alert', 'send_alert',
'send_webhook',
'User', 'User',
'Role', 'Role',
# CMMC USB check-in/out database # CMMC USB check-in/out database

View File

@@ -838,6 +838,23 @@ def build_default_settings():
'description': 'Public base URL of this site (e.g. ' 'description': 'Public base URL of this site (e.g. '
'https://host/ops), used to build links in emails' '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 # Audit log settings
{ {
'key': 'audit_retention_days', 'key': 'audit_retention_days',

View File

@@ -19,6 +19,8 @@ import os
import re import re
import smtplib import smtplib
import ssl import ssl
import requests
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr from email.utils import formataddr
@@ -175,13 +177,89 @@ def send_email(to, subject, html, text=None):
return ok 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): def send_alert(subject, html, text=None):
"""Send an alert to the site's configured alert_recipients. Returns False """Fan an alert out to the configured channels: the site's alert_recipients
when email is off or no alert recipients are configured.""" (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() config = get_smtp_config()
recipients = _normalize_recipients(config['alert_recipients']) recipients = _normalize_recipients(config['alert_recipients'])
if not 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 False
return send_email(recipients, subject, html, text=text) return send_email(recipients, subject, html, text=text)

View File

@@ -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', '<p>Body</p>')
assert mock_requests.post.called # webhook fired even with no email recipients