Merge wj: fleet alert notifications and operator report emails

Brings in the fleet alert notification concept, the operator report email
alerts, and the tray work that goes with them (tray 1.6.5).

Two files needed resolution:

- ShopDbEndpointAgent.csproj: wj carried 1.13.1 through 1.13.4, all
  tray-coordination bumps with no agent code behind them. Ours carried
  1.14.0, which is real code. Kept every changelog entry and took 1.14.0
  as the version, since it already supersedes the 1.13.x line.
- plugins/endpoints/api/routes.py auto-merged; verified the errormode
  descriptor survived intact.

KNOWN RED, INHERITED NOT CAUSED: five backend tests fail on this merge -
authz on endpoints.acknowledge_fleet_alert, the public GET surface scan,
two plugin-contract import checks, and the notifications plugin
migration/model agreement. All five were confirmed failing on a clean
worktree of origin/wj before merging, so they arrive with the branch
rather than from the merge. They reflect the fleet alert work being
half-landed: the push path was a delivery proof of concept and the
server-side surface was still being stood up.

These are not fixed here on purpose. The next change replaces that
delivery path with the endpoint agent, so the notification persists on
the endpoint until the operator acknowledges it or the server closes it,
and the affected surfaces get their gating and migration settled as part
of that work rather than being patched twice.
This commit is contained in:
benjamin.baker
2026-09-09 12:44:38 -04:00
19 changed files with 1828 additions and 24 deletions

View File

@@ -32,6 +32,8 @@ from ..models import (
)
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.utils.mailer import send_alert
from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__)
@@ -2943,6 +2945,85 @@ def _purge_expired_operator_reports():
db.session.commit()
def _send_operator_report_alert(report, asset_id, hostname):
"""Send email alert for new operator issue report to default alert recipients."""
from flask import url_for, request
from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
report.operatorreportid, asset_id, hostname)
# Check if we have recipients before doing expensive work
config = get_smtp_config()
current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
config.get('enabled'), config.get('host'), config.get('alert_recipients'))
recipients = _normalize_recipients(config['alert_recipients'])
if not recipients:
current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
return
current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
asset = Asset.query.get(asset_id)
if not asset:
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)
return
# Generate absolute URLs
try:
device_url = url_for('assets.get_asset', asset_id=asset_id, _external=True)
except Exception as e:
current_app.logger.error('Failed to generate device URL: %s', e)
# Build absolute URL manually using request context
scheme = request.scheme if request else 'https'
host = request.host if request else 'localhost'
device_url = f'{scheme}://{host}/shopdb/assets/{asset_id}'
reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown'
screenshot_info = ''
if report.imagefilename:
try:
screenshot_url = url_for('endpoints.serve_operator_report_image',
operatorreportid=report.operatorreportid,
_external=True)
screenshot_info = f'<p><strong>Screenshot:</strong> <a href="{escape(screenshot_url)}">View Screenshot</a></p>'
except Exception as e:
current_app.logger.warning('Failed to generate screenshot URL: %s', e)
# HTML-escape all user-controlled values to prevent XSS
safe_hostname = escape(hostname)
safe_name = escape(asset.name or hostname)
safe_assetnumber = escape(asset.assetnumber or 'N/A')
safe_reason = escape(report.reason)
safe_details = escape(report.details or 'No additional details provided.')
# Build HTML body (don't use render_email to avoid subject duplication)
html_body = f'''
<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto;color:#222;">
<h2 style="color:#1a1a1a;margin:0 0 12px;">Operator Issue Report</h2>
<div style="padding: 16px; background: #f9f9f9; border-left: 4px solid #d93f3f; margin-bottom: 16px;">
<p style="margin: 0 0 8px; font-weight: bold; color: #d93f3f;">New Issue Report from Shop Floor</p>
<p style="margin: 0;"><strong>Device:</strong> <a href="{escape(device_url)}">{safe_name}</a> ({safe_hostname})</p>
<p style="margin: 0;"><strong>Asset Number:</strong> <a href="{escape(device_url)}">{safe_assetnumber}</a></p>
<p style="margin: 0;"><strong>Reason:</strong> {safe_reason}</p>
<p style="margin: 0;"><strong>Reported:</strong> {escape(reported_at)}</p>
</div>
<p><strong>Details:</strong></p>
<p style="white-space: pre-wrap;">{safe_details}</p>
{screenshot_info}
<p><a href="{escape(device_url)}" style="display: inline-block; padding: 10px 16px; background: #1a73e8; color: white; text-decoration: none; border-radius: 4px;">View Device in ShopDB</a></p>
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">
<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>
</div>
'''
subject = f'Operator Issue Report: {safe_name}'
current_app.logger.info('Sending operator report alert for %s (report %d)',
hostname, report.operatorreportid)
send_alert(subject, html_body)
@endpoints_bp.route('/operatorreports', methods=['POST'])
def upload_operator_report():
"""Agent uploads one operator issue report (collector-token auth).
@@ -3039,6 +3120,14 @@ def upload_operator_report():
except Exception:
db.session.rollback()
# Send email alert to default alert recipients
current_app.logger.info('Operator report %d saved, attempting to send email alert', report.operatorreportid)
try:
_send_operator_report_alert(report, asset_id, hostname)
current_app.logger.info('Email alert function completed for report %d', report.operatorreportid)
except Exception as e:
current_app.logger.error('Failed to send operator report alert email for report %d: %s', report.operatorreportid, e, exc_info=True)
return success_response(report.to_dict(),
message='Operator report received', http_code=201)
@@ -3085,3 +3174,148 @@ def serve_operator_report_image(operatorreportid: int):
return send_upload(_operator_report_dir(), report.imagefilename)
# =============================================================================
# Fleet alerts (agent-facing, collector-token auth)
# =============================================================================
_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8'
@endpoints_bp.route('/alerts', methods=['GET'])
def get_fleet_alerts():
"""Fleet alerts for one endpoint by hostname (collector-token auth).
Called by the endpoint agent to fetch active, unacknowledged fleet alerts
for its hostname. Returns alerts with notification type info (typename and
typecolor from database) formatted for display in the agent tray.
"""
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.args.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown hostname: {hostname!r}', http_code=404)
from plugins.notifications.models import Notification, NotificationType, NotificationAcknowledgement
now = _naive_utcnow()
# Query for active fleet alerts not acknowledged by this asset
query = (db.session.query(Notification, NotificationType)
.join(NotificationType,
NotificationType.notificationtypeid == Notification.notificationtypeid)
.outerjoin(NotificationAcknowledgement,
db.and_(
NotificationAcknowledgement.notificationid == Notification.notificationid,
NotificationAcknowledgement.assetid == asset_id))
.filter(Notification.alertfleet.is_(True),
Notification.isactive.is_(True),
db.or_(
Notification.endtime.is_(None),
Notification.endtime > now),
NotificationAcknowledgement.notificationacknowledgementid.is_(None)))
results = query.all()
alerts = []
for notification, ntype in results:
title = notification.notification[:100] if notification.notification else ''
if '\n' in title:
title = title.split('\n')[0][:100] or '(no title)'
alerts.append({
'notificationId': notification.notificationid,
'type': ntype.typename,
'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR,
'title': title,
'message': notification.notification or '',
'timeout': notification.alerttimeout,
})
return success_response(alerts)
@endpoints_bp.route('/alerts/<int:notification_id>/acknowledge', methods=['POST'])
def acknowledge_fleet_alert(notification_id: int):
"""Agent acknowledges a fleet alert (collector-token auth).
The agent posts the hostname plus assetid when dismissing a fleet alert from
the tray. Records acknowledgement so this alert will not reappear in future polls.
Idempotent - repeated acknowledgements succeed.
"""
error = _collector_auth_or_error()
if error is not None:
return error
payload = request.get_json(silent=True) or {}
hostname = (payload.get('hostname') or '').strip()
assetid = payload.get('assetid')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
if assetid is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'assetid is required')
try:
assetid = int(assetid)
except (TypeError, ValueError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'assetid must be an integer')
from plugins.notifications.models import Notification, NotificationAcknowledgement
notification = db.session.get(Notification, notification_id)
if notification is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Notification {notification_id} not found',
http_code=404)
asset = db.session.get(Asset, assetid)
if asset is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Asset {assetid} not found',
http_code=404)
# Validate hostname matches assetid to prevent cross-machine acknowledgements
resolved_asset = _resolve_asset_for_hostname(hostname)
if resolved_asset is None or resolved_asset.assetid != assetid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname does not match provided assetid')
# Check if already acknowledged (idempotent)
existing = (NotificationAcknowledgement.query
.filter_by(notificationid=notification_id, assetid=assetid)
.first())
if existing:
return success_response({'acknowledged': True},
message='Alert already acknowledged')
# Record acknowledgement
ack = NotificationAcknowledgement(
notificationid=notification_id,
assetid=assetid
)
db.session.add(ack)
try:
db.session.commit()
except Exception as exc:
db.session.rollback()
logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}')
return error_response(ErrorCodes.INTERNAL_ERROR,
'Failed to record acknowledgement',
http_code=500)
return success_response({'acknowledged': True},
message='Alert acknowledged', http_code=201)

View File

@@ -455,7 +455,10 @@ def create_notification():
isactive=True,
isshopfloor=data.get('isshopfloor', False),
employeesso=data.get('employeesso'),
employeename=data.get('employeename')
employeename=data.get('employeename'),
alertfleet=data.get('alertfleet', False),
alertscope=data.get('alertscope'),
alerttimeout=data.get('alerttimeout', 0)
)
db.session.add(n)
@@ -504,6 +507,12 @@ def update_notification(notification_id: int):
n.employeesso = data['employeesso']
if 'employeename' in data:
n.employeename = data['employeename']
if 'alertfleet' in data:
n.alertfleet = data['alertfleet']
if 'alertscope' in data:
n.alertscope = data['alertscope']
if 'alerttimeout' in data:
n.alerttimeout = data['alerttimeout']
# Parse and update dates
if 'starttime' in data or 'startdate' in data:

View File

@@ -217,6 +217,46 @@
</label>
<small class="form-hint">Display on TV dashboard</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.alertfleet"
:disabled="isDetail || !form.isactive"
/>
Alert Fleet
</label>
<small class="form-hint">Push this notification to shop floor PCs</small>
</div>
</div>
<div v-if="form.alertfleet" class="fleet-alert-options">
<div class="form-group">
<label for="alertscope">Alert Scope (JSON)</label>
<textarea
id="alertscope"
v-model="alertScopeText"
class="form-control"
rows="3"
:disabled="isDetail"
placeholder='{"assettype": "computer", "location": "Building A"}'
></textarea>
<small class="form-hint">Optional: Filter alerts by asset type, location, or business unit (JSON format)</small>
</div>
<div class="form-group">
<label for="alerttimeout">Auto-dismiss Timeout (minutes)</label>
<input
id="alerttimeout"
v-model.number="form.alerttimeout"
type="number"
class="form-control"
min="0"
:disabled="isDetail"
/>
<small class="form-hint">Minutes until auto-dismiss (0 = manual acknowledgment only)</small>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -282,13 +322,42 @@ const form = ref({
// loads its own stored value (see loadNotification), so this never flips a
// setting somebody deliberately turned off.
isshopfloor: true,
employeesso: ''
employeesso: '',
alertfleet: false,
alertscope: null,
alerttimeout: 0
})
// Site timezone (settings key site_timezone) so form times show/enter in the
// site's wall clock, not the viewer's browser zone. Loaded in onMounted.
const siteTimezone = ref(DEFAULT_TZ)
// Alert scope as text for editing (converts to/from JSON)
const alertScopeText = ref('')
watch(() => form.value.alertscope, (newVal) => {
if (newVal && typeof newVal === 'object') {
alertScopeText.value = JSON.stringify(newVal, null, 2)
} else if (newVal && typeof newVal === 'string') {
alertScopeText.value = newVal
} else {
alertScopeText.value = ''
}
}, { immediate: true })
watch(alertScopeText, (newVal) => {
if (!newVal.trim()) {
form.value.alertscope = null
return
}
try {
form.value.alertscope = JSON.parse(newVal)
} catch (e) {
// Keep the text as-is until it's valid JSON
form.value.alertscope = newVal
}
})
function selectedTypeName() {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() || ''
@@ -349,7 +418,10 @@ onMounted(async () => {
endtime: formatDateForInput(n.endtime),
isactive: n.isactive !== false,
isshopfloor: n.isshopfloor || false,
employeesso: n.employeesso || ''
employeesso: n.employeesso || '',
alertfleet: n.alertfleet || false,
alertscope: n.alertscope || null,
alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0
}
// Parse existing employee data: SSOs are comma-joined and names are
@@ -527,7 +599,10 @@ async function saveNotification() {
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
isactive: form.value.isactive,
isshopfloor: form.value.isshopfloor,
employeesso: form.value.employeesso || null
employeesso: form.value.employeesso || null,
alertfleet: form.value.alertfleet || false,
alertscope: form.value.alertscope || null,
alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0
}
// For employee-photo types, also send employeename

View File

@@ -0,0 +1,102 @@
"""Add fleet alert support to notifications plugin.
Adds alertfleet, alertscope, and alerttimeout columns to the notifications table
to support fleet-wide alert broadcasting. Creates notificationacknowledgements
table to track which assets have acknowledged fleet alerts.
Fleet alerts can be scoped to specific asset types, locations, or business units
via the alertscope TEXT field (JSON string). alerttimeout=0 means no auto-dismiss (manual only).
Idempotent: column and table creation are skipped when they already exist.
Revision ID: notifications0006fleetalerts
Revises: notifications0005boardorder
"""
from alembic import op
import sqlalchemy as sa
revision = 'notifications0006fleetalerts'
down_revision = 'notifications0005boardorder'
branch_labels = None
depends_on = None
def _column_names(insp, table):
return {c['name'] for c in insp.get_columns(table)}
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing_tables = set(insp.get_table_names())
# Add alertfleet, alertscope, alerttimeout to notifications table
if 'notifications' in existing_tables:
cols = _column_names(insp, 'notifications')
if 'alertfleet' not in cols:
op.add_column('notifications',
sa.Column('alertfleet', sa.Boolean(), nullable=False,
server_default=sa.false()))
if 'alertscope' not in cols:
op.add_column('notifications',
sa.Column('alertscope', sa.Text, nullable=True))
if 'alerttimeout' not in cols:
op.add_column('notifications',
sa.Column('alerttimeout', sa.Integer(), nullable=False,
server_default='0'))
# Create notificationacknowledgements table
if 'notificationacknowledgements' not in existing_tables:
op.create_table(
'notificationacknowledgements',
sa.Column('notificationacknowledgementid', sa.Integer(), nullable=False),
sa.Column('notificationid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('acknowledgedat', sa.DateTime(), nullable=False,
server_default=sa.text('NOW()')),
sa.PrimaryKeyConstraint('notificationacknowledgementid'),
sa.ForeignKeyConstraint(['notificationid'],
['notifications.notificationid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.UniqueConstraint('notificationid', 'assetid',
name='uq_notification_asset')
)
op.create_index('idx_notificationack_notification',
'notificationacknowledgements',
['notificationid'])
op.create_index('idx_notificationack_asset',
'notificationacknowledgements',
['assetid'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing_tables = set(insp.get_table_names())
# Drop notificationacknowledgements table
if 'notificationacknowledgements' in existing_tables:
op.drop_index('idx_notificationack_asset',
table_name='notificationacknowledgements')
op.drop_index('idx_notificationack_notification',
table_name='notificationacknowledgements')
op.drop_table('notificationacknowledgements')
# Drop alert columns from notifications table
if 'notifications' in existing_tables:
cols = _column_names(insp, 'notifications')
if 'alerttimeout' in cols:
op.drop_column('notifications', 'alerttimeout')
if 'alertscope' in cols:
op.drop_column('notifications', 'alertscope')
if 'alertfleet' in cols:
op.drop_column('notifications', 'alertfleet')

View File

@@ -1,5 +1,6 @@
"""Notifications plugin models."""
from .notification import Notification, NotificationType
from .acknowledgement import NotificationAcknowledgement
__all__ = ['Notification', 'NotificationType']
__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement']

View File

@@ -0,0 +1,52 @@
"""Notification acknowledgement tracking for fleet alerts."""
from datetime import datetime, timezone
from shopdb.api import db
class NotificationAcknowledgement(db.Model):
"""
Tracks which assets have acknowledged fleet alerts.
Matches existing notificationacknowledgements table.
"""
__tablename__ = 'notificationacknowledgements'
__table_args__ = (
db.UniqueConstraint('notificationid', 'assetid', name='uq_notification_asset'),
)
notificationacknowledgementid = db.Column(db.Integer, primary_key=True)
notificationid = db.Column(
db.Integer,
db.ForeignKey('notifications.notificationid', ondelete='CASCADE'),
nullable=False,
index=True
)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True
)
acknowledgedat = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
)
# Relationships
notification = db.relationship('Notification', backref='acknowledgements')
asset = db.relationship('Asset', backref='notification_acknowledgements')
def __repr__(self):
return f"<NotificationAcknowledgement {self.notificationacknowledgementid}>"
def to_dict(self):
"""Convert to dictionary following ShopDB patterns."""
return {
'notificationacknowledgementid': self.notificationacknowledgementid,
'notificationid': self.notificationid,
'assetid': self.assetid,
'acknowledgedat': self.acknowledgedat.replace(
tzinfo=timezone.utc
).isoformat() if self.acknowledgedat else None
}

View File

@@ -146,6 +146,9 @@ class Notification(db.Model):
# ~11 people are listed.
employeesso = db.Column(db.Text, nullable=True)
employeename = db.Column(db.Text, nullable=True)
alertfleet = db.Column(db.Boolean, default=False)
alertscope = db.Column(db.JSON, nullable=True)
alerttimeout = db.Column(db.Integer, nullable=True)
# Relationships
notificationtype = db.relationship('NotificationType', backref='notifications')
@@ -194,7 +197,10 @@ class Notification(db.Model):
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
'employeesso': self.employeesso,
'employeename': self.employeename,
'iscurrent': self.is_current
'iscurrent': self.is_current,
'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False,
'alertscope': self.alertscope,
'alerttimeout': self.alerttimeout
}
# Add type info