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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Notifications plugin models."""
|
||||
|
||||
from .notification import Notification, NotificationType
|
||||
from .acknowledgement import NotificationAcknowledgement
|
||||
|
||||
__all__ = ['Notification', 'NotificationType']
|
||||
__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement']
|
||||
|
||||
52
plugins/notifications/models/acknowledgement.py
Normal file
52
plugins/notifications/models/acknowledgement.py
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user