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:
@@ -63,6 +63,10 @@
|
|||||||
<label for="teamurl">Team URL</label>
|
<label for="teamurl">Team URL</label>
|
||||||
<input id="teamurl" v-model="teamForm.teamurl" type="text" class="form-control" placeholder="ServiceNow group link" />
|
<input id="teamurl" v-model="teamForm.teamurl" type="text" class="form-control" placeholder="ServiceNow group link" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="webhookurl">Alert Webhook URL</label>
|
||||||
|
<input id="webhookurl" v-model="teamForm.webhookurl" type="text" class="form-control" placeholder="Microsoft Teams webhook for this team's alerts" />
|
||||||
|
</div>
|
||||||
<div v-if="teamError" class="error-message">{{ teamError }}</div>
|
<div v-if="teamError" class="error-message">{{ teamError }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -202,7 +206,7 @@ function contactEmail(contact) {
|
|||||||
const showTeamModal = ref(false)
|
const showTeamModal = ref(false)
|
||||||
const editingTeam = ref(null)
|
const editingTeam = ref(null)
|
||||||
const teamError = ref('')
|
const teamError = ref('')
|
||||||
const teamForm = ref({ teamname: '', teamurl: '' })
|
const teamForm = ref({ teamname: '', teamurl: '', webhookurl: '' })
|
||||||
|
|
||||||
const showContactModal = ref(false)
|
const showContactModal = ref(false)
|
||||||
const editingContact = ref(null)
|
const editingContact = ref(null)
|
||||||
@@ -238,8 +242,8 @@ function openContactsModal(team) {
|
|||||||
function openTeamModal(team = null) {
|
function openTeamModal(team = null) {
|
||||||
editingTeam.value = team
|
editingTeam.value = team
|
||||||
teamForm.value = team
|
teamForm.value = team
|
||||||
? { teamname: team.teamname || '', teamurl: team.teamurl || '' }
|
? { teamname: team.teamname || '', teamurl: team.teamurl || '', webhookurl: team.webhookurl || '' }
|
||||||
: { teamname: '', teamurl: '' }
|
: { teamname: '', teamurl: '', webhookurl: '' }
|
||||||
teamError.value = ''
|
teamError.value = ''
|
||||||
showTeamModal.value = true
|
showTeamModal.value = true
|
||||||
}
|
}
|
||||||
@@ -250,7 +254,8 @@ async function saveTeam() {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
teamname: teamForm.value.teamname,
|
teamname: teamForm.value.teamname,
|
||||||
teamurl: teamForm.value.teamurl || null
|
teamurl: teamForm.value.teamurl || null,
|
||||||
|
webhookurl: teamForm.value.webhookurl || null
|
||||||
}
|
}
|
||||||
if (editingTeam.value) {
|
if (editingTeam.value) {
|
||||||
await supportteamsApi.update(editingTeam.value.supportteamid, payload)
|
await supportteamsApi.update(editingTeam.value.supportteamid, payload)
|
||||||
|
|||||||
41
migrations/versions/7d29_supportteam_webhookurl.py
Normal file
41
migrations/versions/7d29_supportteam_webhookurl.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Add supportteams.webhookurl (per-team alert webhook)
|
||||||
|
|
||||||
|
A support team can carry a Teams (or other) webhook; alerting plugins route
|
||||||
|
alerts to a team by selecting it. Nullable. Idempotent guard.
|
||||||
|
|
||||||
|
Revision ID: 7d29_supportteam_webhookurl
|
||||||
|
Revises: 7d28_dashboarddefault_displayrole
|
||||||
|
Create Date: 2026-07-22
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '7d29_supportteam_webhookurl'
|
||||||
|
down_revision = '7d28_dashboarddefault_displayrole'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_col(insp):
|
||||||
|
return any(c['name'] == 'webhookurl'
|
||||||
|
for c in insp.get_columns('supportteams'))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if 'supportteams' not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
if not _has_col(insp):
|
||||||
|
op.add_column('supportteams', sa.Column('webhookurl', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if 'supportteams' not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
if _has_col(insp):
|
||||||
|
op.drop_column('supportteams', 'webhookurl')
|
||||||
@@ -330,7 +330,7 @@ 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, send_webhook, Setting
|
from shopdb.api import send_email, 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')
|
||||||
@@ -350,20 +350,43 @@ def _send_lowstock_alert(item):
|
|||||||
f'Bin: {item.binlocation or "-"}.'
|
f'Bin: {item.binlocation or "-"}.'
|
||||||
+ (f' [View]({item_url})' if item_url else ''))
|
+ (f' [View]({item_url})' if item_url else ''))
|
||||||
try:
|
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:
|
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:
|
|
||||||
# send_alert already fans out to email + webhook.
|
|
||||||
send_alert(subject, html)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger(__name__).exception(
|
logging.getLogger(__name__).exception(
|
||||||
'Low-stock alert failed for %s', item.itemcode)
|
'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'])
|
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('printedparts.restock')
|
@require_permission('printedparts.restock')
|
||||||
|
|||||||
@@ -76,6 +76,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 >
|
||||||
|
Support Teams). Empty uses the site-wide alert webhook.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||||
{{ saving ? 'Saving...' : 'Save' }}
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -85,7 +99,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { settingsApi, usersApi } from '@/api'
|
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
'printedparts_code_prefix',
|
'printedparts_code_prefix',
|
||||||
@@ -93,7 +107,8 @@ const KEYS = [
|
|||||||
'printedparts_unknown_badge',
|
'printedparts_unknown_badge',
|
||||||
'printedparts_alert_email',
|
'printedparts_alert_email',
|
||||||
'printedparts_alert_userids',
|
'printedparts_alert_userids',
|
||||||
'printedparts_alert_roleids'
|
'printedparts_alert_roleids',
|
||||||
|
'printedparts_alert_supportteamid'
|
||||||
]
|
]
|
||||||
|
|
||||||
const values = ref({
|
const values = ref({
|
||||||
@@ -102,8 +117,10 @@ const values = ref({
|
|||||||
printedparts_unknown_badge: 'deny',
|
printedparts_unknown_badge: 'deny',
|
||||||
printedparts_alert_email: '',
|
printedparts_alert_email: '',
|
||||||
printedparts_alert_userids: '',
|
printedparts_alert_userids: '',
|
||||||
printedparts_alert_roleids: ''
|
printedparts_alert_roleids: '',
|
||||||
|
printedparts_alert_supportteamid: ''
|
||||||
})
|
})
|
||||||
|
const supportTeams = ref([])
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const selectedUserids = ref([])
|
const selectedUserids = ref([])
|
||||||
const roles = ref([])
|
const roles = ref([])
|
||||||
@@ -130,6 +147,8 @@ onMounted(async () => {
|
|||||||
.split(',').map(id => id.trim()).filter(Boolean)
|
.split(',').map(id => id.trim()).filter(Boolean)
|
||||||
const rolesResponse = await usersApi.roles.list()
|
const rolesResponse = await usersApi.roles.list()
|
||||||
roles.value = rolesResponse.data.data || []
|
roles.value = rolesResponse.data.data || []
|
||||||
|
const teamsResponse = await supportteamsApi.list()
|
||||||
|
supportTeams.value = teamsResponse.data.data || []
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
error.value = 'Could not load settings'
|
error.value = 'Could not load settings'
|
||||||
console.error(loadError)
|
console.error(loadError)
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
('printedparts_alert_roleids', '', 'string',
|
('printedparts_alert_roleids', '', 'string',
|
||||||
'Comma-separated role ids; every active member of these roles '
|
'Comma-separated role ids; every active member of these roles '
|
||||||
'receives low-stock alerts'),
|
'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:
|
for key, value, valuetype, description in defaults:
|
||||||
if Setting.get(key) is None:
|
if Setting.get(key) is None:
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def create_support_team():
|
|||||||
team = SupportTeam(
|
team = SupportTeam(
|
||||||
teamname=data['teamname'],
|
teamname=data['teamname'],
|
||||||
teamurl=data.get('teamurl'),
|
teamurl=data.get('teamurl'),
|
||||||
|
webhookurl=data.get('webhookurl'),
|
||||||
isactive=data.get('isactive', True))
|
isactive=data.get('isactive', True))
|
||||||
|
|
||||||
db.session.add(team)
|
db.session.add(team)
|
||||||
@@ -99,7 +100,7 @@ def update_support_team(team_id: int):
|
|||||||
f"Support team '{data['teamname']}' already exists",
|
f"Support team '{data['teamname']}' already exists",
|
||||||
http_code=409)
|
http_code=409)
|
||||||
|
|
||||||
for key in ['teamname', 'teamurl', 'isactive']:
|
for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']:
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(team, key, data[key])
|
setattr(team, key, data[key])
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class SupportTeam(BaseModel):
|
|||||||
supportteamid = db.Column(db.Integer, primary_key=True)
|
supportteamid = db.Column(db.Integer, primary_key=True)
|
||||||
teamname = db.Column(db.String(100), unique=True, nullable=False)
|
teamname = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable
|
teamurl = db.Column(db.Text) # ServiceNow group deep link, nullable
|
||||||
|
# Teams (or other) webhook this team receives alerts on; alerting plugins
|
||||||
|
# route to it by selecting this team. Nullable = no webhook for this team.
|
||||||
|
webhookurl = db.Column(db.Text)
|
||||||
|
|
||||||
# Contacts cascade-delete with the team.
|
# Contacts cascade-delete with the team.
|
||||||
contacts = db.relationship(
|
contacts = db.relationship(
|
||||||
|
|||||||
@@ -225,16 +225,18 @@ def _webhook_payload(fmt, title, text):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def send_webhook(title, text):
|
def send_webhook(title, text, url=None):
|
||||||
"""POST an alert to the configured webhook (Teams, etc.). Best-effort:
|
"""POST an alert to a webhook (Teams, etc.). `url` overrides the site
|
||||||
returns (ok, error); a no-op ((False, None)) when no URL is configured, and
|
alert_webhook_url (e.g. a support team's own webhook); the payload format
|
||||||
never raises so it can never block the caller."""
|
still follows alert_webhook_format. Best-effort: returns (ok, error), a
|
||||||
|
no-op ((False, None)) when no URL resolves, and never raises."""
|
||||||
config = get_webhook_config()
|
config = get_webhook_config()
|
||||||
if not config['url']:
|
target = (url or '').strip() or config['url']
|
||||||
|
if not target:
|
||||||
return False, None
|
return False, None
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
config['url'], json=_webhook_payload(config['format'], title, text),
|
target, json=_webhook_payload(config['format'], title, text),
|
||||||
timeout=10)
|
timeout=10)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
return False, f'HTTP {response.status_code}'
|
return False, f'HTTP {response.status_code}'
|
||||||
|
|||||||
@@ -398,3 +398,27 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
|||||||
.filter_by(printeditemid=iid, transactiontype='take')
|
.filter_by(printeditemid=iid, transactiontype='take')
|
||||||
.order_by(PrintedItemTransaction.transactionid.desc()).first())
|
.order_by(PrintedItemTransaction.transactionid.desc()).first())
|
||||||
assert last.revision is None
|
assert last.revision is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_employee):
|
||||||
|
"""A low-stock crossing posts to the selected support team's webhook URL."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from shopdb.core.models import SupportTeam, Setting
|
||||||
|
with app.app_context():
|
||||||
|
team = SupportTeam(teamname='3D Print Team',
|
||||||
|
webhookurl='https://teams.example/3dprint')
|
||||||
|
db.session.add(team)
|
||||||
|
db.session.commit()
|
||||||
|
Setting.set('printedparts_alert_supportteamid', str(team.supportteamid))
|
||||||
|
row = PrintedItem(itemcode='3DP-7100', gagelabtag='WJRP7100',
|
||||||
|
itemname='Widget', quantityonhand=6, lowstockthreshold=5)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with patch('shopdb.api.send_webhook') as mock_send:
|
||||||
|
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
||||||
|
resp = client.post('/api/printedparts/kiosk/take', json={
|
||||||
|
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert mock_send.called
|
||||||
|
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
|
||||||
|
|||||||
Reference in New Issue
Block a user