Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session: Settings IA - Replace the flat 27-card settings hub with a persistent two-pane shell (SettingsLayout.vue): grouped, searchable left rail + content pane. - Nest all settings/* routes under the shell via router post-processing; shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers, Equipment, Network) so per-type settings stop scattering. Custom fields (core) - customfields + customfieldvalues tables (migration 7d14), CRUD API at /api/customfields, per-asset value get/save. - Settings management page + reusable CustomFieldsSection (detail) and CustomFieldsInputs (form) wired into all four asset types. Warranty (new plugin) - plugins/warranty: warranties + warrantyassets (migration 7d15), derived coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs). - API CRUD + per-asset panel + report buckets; WarrantyPanel on all four detail pages; Warranties management page; Warranty report + Reports card. - Seed warranty.* permissions. Printer drivers - printerdrivers table (migration 7d13) linked to printer models; drivers now surface on the matching printer's detail page. Other - PCDetail rebalanced (Network + Status + Warranty + custom fields on the right). - Rename PCs list "Features" column to "Remote Access"; fix badge hover underline. - Drop equipment islocationonly field. - Centralize asset-type label/route maps into utils/assetTypes.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
"""Notifications plugin API endpoints - adapted to existing schema."""
|
||||
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
@@ -8,8 +12,157 @@ from shopdb.api import db, success_response, error_response, paginated_response,
|
||||
|
||||
from ..models import Notification, NotificationType
|
||||
|
||||
from shopdb.api import require_permission, require_role
|
||||
|
||||
notifications_bp = Blueprint('notifications', __name__)
|
||||
|
||||
# Notification types whose multi-employee cards get split into one card per
|
||||
# employee on the shopfloor dashboard. typecolor drives this (not typename), so
|
||||
# recognition, training and recertification all fan out; every other type
|
||||
# stays one card.
|
||||
SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})
|
||||
|
||||
# How long a card stays up on the shopfloor board when the creator does not set
|
||||
# an explicit end time, by notification typecolor.
|
||||
# recognition - clears at the next 8:00 AM Eastern (daily reset)
|
||||
# recertification - stays up two weeks (employees have time to book the course)
|
||||
EASTERN = ZoneInfo('America/New_York')
|
||||
RECERTIFICATION_DAYS = 14
|
||||
|
||||
|
||||
def _next_eastern_time(after, hour, minute=0):
|
||||
"""Next hour:minute America/New_York strictly after `after` (naive UTC),
|
||||
returned as naive UTC. Uses the tz database so it is correct across EST/EDT."""
|
||||
after_east = after.replace(tzinfo=timezone.utc).astimezone(EASTERN)
|
||||
target = after_east.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
|
||||
if target <= after_east:
|
||||
target += timedelta(days=1)
|
||||
return target.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _auto_endtime(ntype, starttime):
|
||||
"""Default display window for a notification with no explicit end time, from
|
||||
the notification type's configured expiry rule:
|
||||
'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
|
||||
'duration' -> starttime + expirydays days
|
||||
'none' -> None (show indefinitely)
|
||||
Falls back to the legacy typecolor rules when the expiry columns are unset,
|
||||
so it is safe before/after the 7d03 migration."""
|
||||
mode = getattr(ntype, 'expirymode', None) or 'none'
|
||||
if mode == 'dailytime':
|
||||
hour = ntype.expiryhour if ntype.expiryhour is not None else 8
|
||||
return _next_eastern_time(starttime, hour, ntype.expiryminute or 0)
|
||||
if mode == 'duration' and ntype.expirydays:
|
||||
return starttime + timedelta(days=int(ntype.expirydays))
|
||||
if mode == 'none':
|
||||
# legacy fallback for rule-bearing types created before the expiry columns
|
||||
if getattr(ntype, 'typecolor', None) == 'recognition':
|
||||
return _next_eastern_time(starttime, 8, 0)
|
||||
if getattr(ntype, 'typecolor', None) == 'recertification':
|
||||
return starttime + timedelta(days=RECERTIFICATION_DAYS)
|
||||
return None
|
||||
|
||||
|
||||
_EXPIRY_MODES = ('none', 'duration', 'dailytime')
|
||||
|
||||
|
||||
def _apply_expiry_fields(t, data):
|
||||
"""Set expiry-rule columns on a NotificationType from request data. Only
|
||||
touches fields that are present. Returns an error string, or None on ok."""
|
||||
if 'expirymode' in data:
|
||||
mode = data.get('expirymode') or 'none'
|
||||
if mode not in _EXPIRY_MODES:
|
||||
return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES)
|
||||
t.expirymode = mode
|
||||
if 'expirydays' in data:
|
||||
v = data.get('expirydays')
|
||||
if v in (None, ''):
|
||||
t.expirydays = None
|
||||
else:
|
||||
try:
|
||||
t.expirydays = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return "expirydays must be an integer"
|
||||
if t.expirydays < 1:
|
||||
return "expirydays must be >= 1"
|
||||
if 'expiryhour' in data:
|
||||
v = data.get('expiryhour')
|
||||
if v in (None, ''):
|
||||
t.expiryhour = None
|
||||
else:
|
||||
try:
|
||||
t.expiryhour = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return "expiryhour must be an integer"
|
||||
if not (0 <= t.expiryhour <= 23):
|
||||
return "expiryhour must be 0-23"
|
||||
if 'expiryminute' in data:
|
||||
v = data.get('expiryminute')
|
||||
try:
|
||||
t.expiryminute = int(v) if v not in (None, '') else 0
|
||||
except (TypeError, ValueError):
|
||||
return "expiryminute must be an integer"
|
||||
if not (0 <= t.expiryminute <= 59):
|
||||
return "expiryminute must be 0-59"
|
||||
# cross-field consistency
|
||||
mode = t.expirymode or 'none'
|
||||
if mode == 'dailytime' and t.expiryhour is None:
|
||||
t.expiryhour = 8
|
||||
if mode == 'duration' and not t.expirydays:
|
||||
return "duration expiry requires expirydays >= 1"
|
||||
return None
|
||||
|
||||
|
||||
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
|
||||
|
||||
|
||||
def _apply_display_fields(t, data):
|
||||
"""Set shopfloor display-behavior columns on a NotificationType from request
|
||||
data. Only touches fields that are present. Returns an error string, or None."""
|
||||
if 'splitperemployee' in data:
|
||||
t.splitperemployee = bool(data.get('splitperemployee'))
|
||||
if 'showemployeephoto' in data:
|
||||
t.showemployeephoto = bool(data.get('showemployeephoto'))
|
||||
if 'displaystyle' in data:
|
||||
ds = data.get('displaystyle') or 'standard'
|
||||
if ds not in _DISPLAY_STYLES:
|
||||
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
|
||||
t.displaystyle = ds
|
||||
return None
|
||||
|
||||
|
||||
def _config_version():
|
||||
"""Short hash of everything that changes the board's LAYOUT: per-type display
|
||||
config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor
|
||||
kiosks reload when this changes, so type/layout edits and frontend deploys
|
||||
reach pages that are already open."""
|
||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||
parts = [
|
||||
"%s|%s|%s|%d|%d|%s|%s|%s|%d" % (
|
||||
t.notificationtypeid, t.typecolor, t.displaystyle,
|
||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
||||
)
|
||||
for t in types
|
||||
]
|
||||
parts.append(os.environ.get('SHOPFLOOR_BUILD', ''))
|
||||
return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _employee_picture(sso):
|
||||
"""Best-effort Picture blob for an SSO from the HR directory. None on any miss."""
|
||||
if not (sso and str(sso).isdigit()):
|
||||
return None
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
emp = cur.fetchone()
|
||||
conn.close()
|
||||
return emp.get('Picture') if emp else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notification Types
|
||||
@@ -35,6 +188,7 @@ def list_notification_types():
|
||||
|
||||
@notifications_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification_type():
|
||||
"""Create a new notification type."""
|
||||
data = request.get_json()
|
||||
@@ -55,12 +209,57 @@ def create_notification_type():
|
||||
typecolor=data.get('typecolor') or data.get('color', '#17a2b8')
|
||||
)
|
||||
|
||||
err = _apply_expiry_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
err = _apply_display_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
db.session.add(t)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(t.to_dict(), message='Notification type created', http_code=201)
|
||||
|
||||
|
||||
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def update_notification_type(type_id: int):
|
||||
"""Update a notification type, including its auto-expiry rule."""
|
||||
t = NotificationType.query.get(type_id)
|
||||
if not t:
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
if data.get('typename'):
|
||||
dup = NotificationType.query.filter(
|
||||
NotificationType.typename == data['typename'],
|
||||
NotificationType.notificationtypeid != type_id
|
||||
).first()
|
||||
if dup:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Notification type '{data['typename']}' already exists", http_code=409)
|
||||
t.typename = data['typename']
|
||||
if 'typedescription' in data or 'description' in data:
|
||||
t.typedescription = data.get('typedescription') or data.get('description')
|
||||
if 'typecolor' in data or 'color' in data:
|
||||
t.typecolor = data.get('typecolor') or data.get('color')
|
||||
if 'isactive' in data:
|
||||
t.isactive = bool(data['isactive'])
|
||||
|
||||
err = _apply_expiry_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
err = _apply_display_fields(t, data)
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
db.session.commit()
|
||||
return success_response(t.to_dict(), message='Notification type updated')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notifications CRUD
|
||||
# =============================================================================
|
||||
@@ -132,6 +331,7 @@ def get_notification(notification_id: int):
|
||||
|
||||
@notifications_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification():
|
||||
"""Create a new notification."""
|
||||
data = request.get_json()
|
||||
@@ -161,6 +361,13 @@ def create_notification():
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
|
||||
|
||||
# No explicit end time: apply the per-type display window (recognition
|
||||
# clears at the next 8 AM Eastern, recertification runs two weeks).
|
||||
if endtime is None and data.get('notificationtypeid'):
|
||||
ntype = NotificationType.query.get(data['notificationtypeid'])
|
||||
if ntype:
|
||||
endtime = _auto_endtime(ntype, starttime)
|
||||
|
||||
n = Notification(
|
||||
notification=notification_text,
|
||||
notificationtypeid=data.get('notificationtypeid'),
|
||||
@@ -184,6 +391,7 @@ def create_notification():
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.edit')
|
||||
def update_notification(notification_id: int):
|
||||
"""Update a notification."""
|
||||
n = Notification.query.get(notification_id)
|
||||
@@ -250,6 +458,7 @@ def update_notification(notification_id: int):
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.delete')
|
||||
def delete_notification(notification_id: int):
|
||||
"""Delete (soft delete) a notification."""
|
||||
n = Notification.query.get(notification_id)
|
||||
@@ -427,7 +636,7 @@ def get_shopfloor_notifications():
|
||||
Get notifications for shopfloor TV dashboard.
|
||||
|
||||
Returns current and upcoming notifications with isshopfloor=1.
|
||||
Splits multi-employee recognition into separate entries.
|
||||
Splits multi-employee recognition and training into separate entries.
|
||||
|
||||
Query parameters:
|
||||
- businessunit: Filter by business unit ID (null = all units)
|
||||
@@ -487,6 +696,8 @@ def get_shopfloor_notifications():
|
||||
def notification_to_shopfloor(n, employee_override=None):
|
||||
"""Convert notification to shopfloor format."""
|
||||
is_resolved = n.endtime and n.endtime < now
|
||||
ntype = n.notificationtype
|
||||
show_photo = bool(ntype and ntype.showemployeephoto)
|
||||
|
||||
result = {
|
||||
'notificationid': n.notificationid,
|
||||
@@ -498,11 +709,13 @@ def get_shopfloor_notifications():
|
||||
'isactive': n.isactive,
|
||||
'isshopfloor': True,
|
||||
'resolved': is_resolved,
|
||||
'typename': n.notificationtype.typename if n.notificationtype else None,
|
||||
'typecolor': n.notificationtype.typecolor if n.notificationtype else None,
|
||||
'typename': ntype.typename if ntype else None,
|
||||
'typecolor': ntype.typecolor if ntype else None,
|
||||
# Per-type display behavior the dashboard groups/renders by.
|
||||
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
||||
}
|
||||
|
||||
# Employee info
|
||||
# Employee info (photo only when the type wants it)
|
||||
if employee_override:
|
||||
result['employeesso'] = employee_override.get('sso')
|
||||
result['employeename'] = employee_override.get('name')
|
||||
@@ -510,92 +723,36 @@ def get_shopfloor_notifications():
|
||||
else:
|
||||
result['employeesso'] = n.employeesso
|
||||
result['employeename'] = n.employeename
|
||||
result['employeepicture'] = None
|
||||
|
||||
# Try to get picture from wjf_employees
|
||||
if n.employeesso and n.employeesso.isdigit():
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(n.employeesso),))
|
||||
emp = cur.fetchone()
|
||||
if emp and emp.get('Picture'):
|
||||
result['employeepicture'] = emp['Picture']
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
|
||||
|
||||
return result
|
||||
|
||||
# Process current notifications (split multi-employee recognition)
|
||||
current_data = []
|
||||
for n in current_notifications:
|
||||
is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition'
|
||||
def expand(n):
|
||||
"""One shopfloor card per notification, or one per employee when the
|
||||
type is configured to split multi-employee lists."""
|
||||
ntype = n.notificationtype
|
||||
is_split = bool(ntype and ntype.splitperemployee)
|
||||
if not (is_split and n.employeesso and ',' in n.employeesso):
|
||||
return [notification_to_shopfloor(n)]
|
||||
|
||||
if is_recognition and n.employeesso and ',' in n.employeesso:
|
||||
# Split into individual cards for each employee
|
||||
ssos = [s.strip() for s in n.employeesso.split(',')]
|
||||
names = n.employeename.split(', ') if n.employeename else []
|
||||
show_photo = bool(ntype and ntype.showemployeephoto)
|
||||
ssos = [s.strip() for s in n.employeesso.split(',')]
|
||||
names = n.employeename.split(', ') if n.employeename else []
|
||||
return [
|
||||
notification_to_shopfloor(n, {
|
||||
'sso': sso,
|
||||
'name': names[i] if i < len(names) else sso,
|
||||
'picture': _employee_picture(sso) if show_photo else None,
|
||||
})
|
||||
for i, sso in enumerate(ssos)
|
||||
]
|
||||
|
||||
for i, sso in enumerate(ssos):
|
||||
name = names[i] if i < len(names) else sso
|
||||
|
||||
# Look up picture
|
||||
picture = None
|
||||
if sso.isdigit():
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
emp = cur.fetchone()
|
||||
if emp:
|
||||
picture = emp.get('Picture')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
current_data.append(notification_to_shopfloor(n, {
|
||||
'sso': sso,
|
||||
'name': name,
|
||||
'picture': picture
|
||||
}))
|
||||
else:
|
||||
current_data.append(notification_to_shopfloor(n))
|
||||
|
||||
# Process upcoming notifications
|
||||
upcoming_data = []
|
||||
for n in upcoming_notifications:
|
||||
is_recognition = n.notificationtype and n.notificationtype.typecolor == 'recognition'
|
||||
|
||||
if is_recognition and n.employeesso and ',' in n.employeesso:
|
||||
ssos = [s.strip() for s in n.employeesso.split(',')]
|
||||
names = n.employeename.split(', ') if n.employeename else []
|
||||
|
||||
for i, sso in enumerate(ssos):
|
||||
name = names[i] if i < len(names) else sso
|
||||
picture = None
|
||||
if sso.isdigit():
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
|
||||
emp = cur.fetchone()
|
||||
if emp:
|
||||
picture = emp.get('Picture')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
upcoming_data.append(notification_to_shopfloor(n, {
|
||||
'sso': sso,
|
||||
'name': name,
|
||||
'picture': picture
|
||||
}))
|
||||
else:
|
||||
upcoming_data.append(notification_to_shopfloor(n))
|
||||
current_data = [card for n in current_notifications for card in expand(n)]
|
||||
upcoming_data = [card for n in upcoming_notifications for card in expand(n)]
|
||||
|
||||
return success_response({
|
||||
'timestamp': now.isoformat(),
|
||||
'current': current_data,
|
||||
'upcoming': upcoming_data
|
||||
'upcoming': upcoming_data,
|
||||
'configversion': _config_version(),
|
||||
})
|
||||
|
||||
@@ -17,6 +17,25 @@ class NotificationType(db.Model):
|
||||
typecolor = db.Column(db.String(20), default='#17a2b8')
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Auto-expiry rule: when a notification of this type has no explicit end time,
|
||||
# how long it stays up on the shopfloor board.
|
||||
# 'none' -> indefinite (never auto-expires)
|
||||
# 'duration' -> starttime + expirydays days
|
||||
# 'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
|
||||
expirymode = db.Column(db.String(20), default='none')
|
||||
expirydays = db.Column(db.Integer, nullable=True)
|
||||
expiryhour = db.Column(db.SmallInteger, nullable=True)
|
||||
expiryminute = db.Column(db.SmallInteger, nullable=True, default=0)
|
||||
|
||||
# Shopfloor display behavior (data-driven; replaces hardcoded per-type logic).
|
||||
# splitperemployee -> one card per listed employee SSO
|
||||
# showemployeephoto -> resolve + show each employee's photo + name
|
||||
# displaystyle -> 'standard' (rows) | 'carousel' (rotating photo card)
|
||||
# | 'grid' (cycling row of tiles) | 'banner'
|
||||
splitperemployee = db.Column(db.Boolean, default=False)
|
||||
showemployeephoto = db.Column(db.Boolean, default=False)
|
||||
displaystyle = db.Column(db.String(20), default='standard')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationType {self.typename}>"
|
||||
|
||||
@@ -26,7 +45,14 @@ class NotificationType(db.Model):
|
||||
'typename': self.typename,
|
||||
'typedescription': self.typedescription,
|
||||
'typecolor': self.typecolor,
|
||||
'isactive': self.isactive
|
||||
'isactive': self.isactive,
|
||||
'expirymode': self.expirymode or 'none',
|
||||
'expirydays': self.expirydays,
|
||||
'expiryhour': self.expiryhour,
|
||||
'expiryminute': self.expiryminute if self.expiryminute is not None else 0,
|
||||
'splitperemployee': bool(self.splitperemployee),
|
||||
'showemployeephoto': bool(self.showemployeephoto),
|
||||
'displaystyle': self.displaystyle or 'standard'
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +78,11 @@ class Notification(db.Model):
|
||||
link = db.Column(db.String(500), nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
isshopfloor = db.Column(db.Boolean, default=False)
|
||||
employeesso = db.Column(db.String(100), nullable=True)
|
||||
employeename = db.Column(db.String(100), nullable=True)
|
||||
# TEXT (not VARCHAR): recognition/recertification notifications comma-join
|
||||
# every employee's SSO/name into one field, which overflows 100 chars once
|
||||
# ~11 people are listed.
|
||||
employeesso = db.Column(db.Text, nullable=True)
|
||||
employeename = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
notificationtype = db.relationship('NotificationType', backref='notifications')
|
||||
@@ -114,24 +143,25 @@ class Notification(db.Model):
|
||||
|
||||
def to_calendar_event(self):
|
||||
"""Convert to FullCalendar event format."""
|
||||
# Map Bootstrap color names to hex colors
|
||||
color_map = {
|
||||
# Color is data-driven: types store a hex typecolor. Only the legacy
|
||||
# Bootstrap color-name aliases still need translating; hex passes through.
|
||||
color_aliases = {
|
||||
'success': '#04b962',
|
||||
'warning': '#ff8800',
|
||||
'danger': '#f5365c',
|
||||
'info': '#14abef',
|
||||
'primary': '#7934f3',
|
||||
'secondary': '#94614f',
|
||||
'recognition': '#14abef', # Blue for recognition
|
||||
}
|
||||
|
||||
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
|
||||
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex)
|
||||
color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
|
||||
ntype = self.notificationtype
|
||||
raw_color = ntype.typecolor if ntype else '#14abef'
|
||||
color = color_aliases.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
|
||||
show_photo = bool(ntype and getattr(ntype, 'showemployeephoto', False))
|
||||
|
||||
# For recognition notifications, include employee name (or SSO as fallback) in title
|
||||
# Employee-photo types prefix the card with the person's name/SSO.
|
||||
title = self.title
|
||||
if raw_color == 'recognition':
|
||||
if show_photo:
|
||||
employee_display = self.employeename or self.employeesso
|
||||
if employee_display:
|
||||
title = f"{employee_display}: {title}"
|
||||
@@ -147,8 +177,10 @@ class Notification(db.Model):
|
||||
'extendedProps': {
|
||||
'notificationid': self.notificationid,
|
||||
'message': self.notification,
|
||||
'typename': self.notificationtype.typename if self.notificationtype else None,
|
||||
'typename': ntype.typename if ntype else None,
|
||||
'typecolor': raw_color,
|
||||
'showemployeephoto': show_photo,
|
||||
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
||||
'linkurl': self.link,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'employeename': self.employeename,
|
||||
|
||||
@@ -73,23 +73,41 @@ class NotificationsPlugin(BasePlugin):
|
||||
logger.info("Notifications plugin installed")
|
||||
|
||||
def _ensure_notification_types(self) -> None:
|
||||
"""Ensure default notification types exist."""
|
||||
"""Ensure default notification types exist.
|
||||
|
||||
For the special shopfloor types (recognition, training,
|
||||
recertification) the typecolor is a keyword the dashboard maps to a
|
||||
style and the feed uses to split multi-employee cards per employee;
|
||||
generic types use a hex color.
|
||||
"""
|
||||
# (typename, typedescription, typecolor, expirymode, expirydays,
|
||||
# expiryhour, splitperemployee, showemployeephoto, displaystyle)
|
||||
default_types = [
|
||||
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
|
||||
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
|
||||
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
|
||||
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
|
||||
('General', 'General announcement', '#28a745', 'bullhorn'),
|
||||
('Awareness', 'General awareness notification', '#17a2b8', 'none', None, None, False, False, 'standard'),
|
||||
('Change', 'Planned change notification', '#ffc107', 'none', None, None, False, False, 'standard'),
|
||||
('Incident', 'Incident or outage notification', '#dc3545', 'none', None, None, False, False, 'standard'),
|
||||
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'none', None, None, False, False, 'standard'),
|
||||
('General', 'General announcement', '#28a745', 'none', None, None, False, False, 'standard'),
|
||||
('Recognition', 'Employee recognition (clears at 8 AM Eastern)', '#ffc107', 'dailytime', None, 8, True, True, 'carousel'),
|
||||
('Training', 'Training notice (one card per employee)', '#17a2b8', 'none', None, None, True, True, 'carousel'),
|
||||
('Recertification', 'Employees due to retake a training course (shows two weeks)', '#0d6efd', 'duration', 14, None, True, True, 'grid'),
|
||||
]
|
||||
|
||||
for typename, description, color, icon in default_types:
|
||||
for (typename, typedescription, typecolor, expirymode, expirydays,
|
||||
expiryhour, splitperemployee, showemployeephoto, displaystyle) in default_types:
|
||||
existing = NotificationType.query.filter_by(typename=typename).first()
|
||||
if not existing:
|
||||
t = NotificationType(
|
||||
typename=typename,
|
||||
description=description,
|
||||
color=color,
|
||||
icon=icon
|
||||
typedescription=typedescription,
|
||||
typecolor=typecolor,
|
||||
expirymode=expirymode,
|
||||
expirydays=expirydays,
|
||||
expiryhour=expiryhour,
|
||||
expiryminute=0,
|
||||
splitperemployee=splitperemployee,
|
||||
showemployeephoto=showemployeephoto,
|
||||
displaystyle=displaystyle
|
||||
)
|
||||
db.session.add(t)
|
||||
logger.debug(f"Created notification type: {typename}")
|
||||
|
||||
Reference in New Issue
Block a user