alerts: per-support-team webhook; printedparts routes low-stock to a chosen team

Support teams gain a webhookurl (migration 7d29 + API + settings-page field), so
a team is a notification target. send_webhook(url=) lets a caller override the
site default with a team's webhook. Printedparts gains a 'alert support team'
setting (printedparts_alert_supportteamid) + selector on its settings page;
low-stock alerts post to that team's webhook, falling back to the site
alert_webhook_url. Email leg unchanged. Same pattern extends to other alerting
plugins (printers low-toner next).
This commit is contained in:
cproudlock
2026-07-22 13:32:00 -04:00
parent 824863a95a
commit fb188fd302
9 changed files with 142 additions and 21 deletions

View File

@@ -330,7 +330,7 @@ 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, send_webhook, Setting
from shopdb.api import send_email, send_webhook, Setting
code = item.gagelabtag or item.itemcode
subject = (f'Low stock: {item.itemname} ({code}) - '
f'{item.quantityonhand} left')
@@ -350,20 +350,43 @@ def _send_lowstock_alert(item):
f'Bin: {item.binlocation or "-"}.'
+ (f' [View]({item_url})' if item_url else ''))
try:
recipients = _alert_recipients()
# Webhook: the selected support team's own webhook, else the site
# default (send_webhook falls back to alert_webhook_url when url=None).
send_webhook(subject, webhook_text, url=_alert_team_webhook())
# Email: this plugin's recipients, else the site's alert_recipients.
recipients = _alert_recipients() or _site_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
logging.getLogger(__name__).exception(
'Low-stock alert failed for %s', item.itemcode)
def _site_alert_recipients():
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
from shopdb.api import Setting
raw = Setting.get('alert_recipients') or ''
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
def _alert_team_webhook():
"""Webhook URL of the support team chosen for printedparts alerts, or None.
printedparts_alert_supportteamid selects a SupportTeam; alerts route to that
team's webhook. None -> send_webhook uses the site-wide default."""
from shopdb.api import Setting
team_id = Setting.get('printedparts_alert_supportteamid')
if not team_id:
return None
try:
from shopdb.core.models import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None
return (team.webhookurl or None) if team else None
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
@jwt_required()
@require_permission('printedparts.restock')

View File

@@ -76,6 +76,20 @@
</p>
</div>
<div class="form-group">
<label>Alert support team (Teams webhook)</label>
<select v-model="values.printedparts_alert_supportteamid" class="form-control">
<option value="">Site default webhook</option>
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
</option>
</select>
<p class="field-hint">
Low-stock alerts post to this team's webhook (set on Settings &gt;
Support Teams). Empty uses the site-wide alert webhook.
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
@@ -85,7 +99,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi, usersApi } from '@/api'
import { settingsApi, usersApi, supportteamsApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
@@ -93,7 +107,8 @@ const KEYS = [
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids',
'printedparts_alert_roleids'
'printedparts_alert_roleids',
'printedparts_alert_supportteamid'
]
const values = ref({
@@ -102,8 +117,10 @@ const values = ref({
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: '',
printedparts_alert_roleids: ''
printedparts_alert_roleids: '',
printedparts_alert_supportteamid: ''
})
const supportTeams = ref([])
const users = ref([])
const selectedUserids = ref([])
const roles = ref([])
@@ -130,6 +147,8 @@ onMounted(async () => {
.split(',').map(id => id.trim()).filter(Boolean)
const rolesResponse = await usersApi.roles.list()
roles.value = rolesResponse.data.data || []
const teamsResponse = await supportteamsApi.list()
supportTeams.value = teamsResponse.data.data || []
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)

View File

@@ -139,6 +139,9 @@ class PrintedpartsPlugin(BasePlugin):
('printedparts_alert_roleids', '', 'string',
'Comma-separated role ids; every active member of these roles '
'receives low-stock alerts'),
('printedparts_alert_supportteamid', '', 'string',
'Support team whose webhook receives low-stock alerts; empty uses '
'the site alert_webhook_url'),
]
for key, value, valuetype, description in defaults:
if Setting.get(key) is None: