Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
5
plugins/notifications/__init__.py
Normal file
5
plugins/notifications/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications plugin package."""
|
||||
|
||||
from .plugin import NotificationsPlugin
|
||||
|
||||
__all__ = ['NotificationsPlugin']
|
||||
5
plugins/notifications/api/__init__.py
Normal file
5
plugins/notifications/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications plugin API."""
|
||||
|
||||
from .routes import notifications_bp
|
||||
|
||||
__all__ = ['notifications_bp']
|
||||
759
plugins/notifications/api/routes.py
Normal file
759
plugins/notifications/api/routes.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""Notifications plugin API endpoints - adapted to existing schema."""
|
||||
|
||||
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
|
||||
|
||||
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Notification, NotificationType
|
||||
|
||||
from shopdb.api import require_permission
|
||||
|
||||
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):
|
||||
"""Resolved display photo URL for an SSO, via the shared employees-plugin
|
||||
resolver so kiosk cards match EmployeeDetail in both directory modes
|
||||
(self-hosted upload URL or external HR URL). None on any miss."""
|
||||
try:
|
||||
from plugins.employees.api.routes import resolve_employee_photo_url
|
||||
return resolve_employee_photo_url(sso)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notification Types
|
||||
# =============================================================================
|
||||
|
||||
@notifications_bp.route('/types', methods=['GET'])
|
||||
def list_notification_types():
|
||||
"""List all notification types."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = NotificationType.query
|
||||
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(NotificationType.isactive == True)
|
||||
|
||||
query = query.order_by(NotificationType.typename)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [t.to_dict() for t in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
if not data or not data.get('typename'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')
|
||||
|
||||
if NotificationType.query.filter_by(typename=data['typename']).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"Notification type '{data['typename']}' already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
t = NotificationType(
|
||||
typename=data['typename'],
|
||||
typedescription=data.get('typedescription') or data.get('description'),
|
||||
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 = db.session.get(NotificationType, 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
|
||||
# =============================================================================
|
||||
|
||||
@notifications_bp.route('', methods=['GET'])
|
||||
def list_notifications():
|
||||
"""
|
||||
List all notifications with filtering and pagination.
|
||||
|
||||
Query parameters:
|
||||
- page, per_page: Pagination
|
||||
- active: Filter by active status (default: true)
|
||||
- type_id: Filter by notification type ID
|
||||
- current: Filter to currently active notifications only
|
||||
- search: Search in notification text
|
||||
"""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Notification.query
|
||||
|
||||
# Active filter
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(Notification.isactive == True)
|
||||
|
||||
# Type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Notification.notificationtypeid == int(type_id))
|
||||
|
||||
# Exact-match lookup for idempotent import. Notifications have no strong
|
||||
# natural key; ticketnumber is the best available when a ticket is set.
|
||||
if exactticket := request.args.get('ticketnumber'):
|
||||
query = query.filter(Notification.ticketnumber == exactticket)
|
||||
|
||||
# Current filter (active based on dates)
|
||||
if request.args.get('current', 'false').lower() == 'true':
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
query = query.filter(
|
||||
Notification.starttime <= now,
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime >= now
|
||||
)
|
||||
)
|
||||
|
||||
# Search filter
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(
|
||||
Notification.notification.ilike(f'%{search}%')
|
||||
)
|
||||
|
||||
# Sorting by start time (newest first)
|
||||
query = query.order_by(Notification.starttime.desc())
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [n.to_dict() for n in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
|
||||
def get_notification(notification_id: int):
|
||||
"""Get a single notification."""
|
||||
n = db.session.get(Notification, notification_id)
|
||||
|
||||
if not n:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Notification with ID {notification_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
return success_response(n.to_dict())
|
||||
|
||||
|
||||
@notifications_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification():
|
||||
"""Create a new notification."""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Validate required fields
|
||||
notification_text = data.get('notification') or data.get('message')
|
||||
if not notification_text:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required')
|
||||
|
||||
# Parse dates
|
||||
starttime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if data.get('starttime') or data.get('startdate'):
|
||||
try:
|
||||
date_str = data.get('starttime') or data.get('startdate')
|
||||
starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
|
||||
|
||||
endtime = None
|
||||
if data.get('endtime') or data.get('enddate'):
|
||||
try:
|
||||
date_str = data.get('endtime') or data.get('enddate')
|
||||
endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
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 = db.session.get(NotificationType, data['notificationtypeid'])
|
||||
if ntype:
|
||||
endtime = _auto_endtime(ntype, starttime)
|
||||
|
||||
n = Notification(
|
||||
notification=notification_text,
|
||||
notificationtypeid=data.get('notificationtypeid'),
|
||||
businessunitid=data.get('businessunitid'),
|
||||
appid=data.get('appid'),
|
||||
starttime=starttime,
|
||||
endtime=endtime,
|
||||
ticketnumber=data.get('ticketnumber'),
|
||||
link=data.get('link') or data.get('linkurl'),
|
||||
isactive=True,
|
||||
isshopfloor=data.get('isshopfloor', False),
|
||||
employeesso=data.get('employeesso'),
|
||||
employeename=data.get('employeename')
|
||||
)
|
||||
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
return success_response(n.to_dict(), message='Notification created', http_code=201)
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.edit')
|
||||
def update_notification(notification_id: int):
|
||||
"""Update a notification."""
|
||||
n = db.session.get(Notification, notification_id)
|
||||
|
||||
if not n:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Notification with ID {notification_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Update text content
|
||||
if 'notification' in data or 'message' in data:
|
||||
n.notification = data.get('notification') or data.get('message')
|
||||
|
||||
# Update simple fields
|
||||
if 'notificationtypeid' in data:
|
||||
n.notificationtypeid = data['notificationtypeid']
|
||||
if 'businessunitid' in data:
|
||||
n.businessunitid = data['businessunitid']
|
||||
if 'appid' in data:
|
||||
n.appid = data['appid']
|
||||
if 'ticketnumber' in data:
|
||||
n.ticketnumber = data['ticketnumber']
|
||||
if 'link' in data or 'linkurl' in data:
|
||||
n.link = data.get('link') or data.get('linkurl')
|
||||
if 'isactive' in data:
|
||||
n.isactive = data['isactive']
|
||||
if 'isshopfloor' in data:
|
||||
n.isshopfloor = data['isshopfloor']
|
||||
if 'employeesso' in data:
|
||||
n.employeesso = data['employeesso']
|
||||
if 'employeename' in data:
|
||||
n.employeename = data['employeename']
|
||||
|
||||
# Parse and update dates
|
||||
if 'starttime' in data or 'startdate' in data:
|
||||
date_str = data.get('starttime') or data.get('startdate')
|
||||
if date_str:
|
||||
try:
|
||||
n.starttime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
|
||||
else:
|
||||
n.starttime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if 'endtime' in data or 'enddate' in data:
|
||||
date_str = data.get('endtime') or data.get('enddate')
|
||||
if date_str:
|
||||
try:
|
||||
n.endtime = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
|
||||
else:
|
||||
n.endtime = None
|
||||
|
||||
db.session.commit()
|
||||
return success_response(n.to_dict(), message='Notification updated')
|
||||
|
||||
|
||||
@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 = db.session.get(Notification, notification_id)
|
||||
|
||||
if not n:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Notification with ID {notification_id} not found',
|
||||
http_code=404
|
||||
)
|
||||
|
||||
n.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
return success_response(message='Notification deleted')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Special Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@notifications_bp.route('/active', methods=['GET'])
|
||||
def get_active_notifications():
|
||||
"""
|
||||
Get currently active notifications for display.
|
||||
"""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
from datetime import timedelta
|
||||
lookahead = now + timedelta(days=10)
|
||||
|
||||
notifications = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
db.or_(
|
||||
Notification.starttime.is_(None),
|
||||
Notification.starttime <= lookahead
|
||||
),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime >= now
|
||||
)
|
||||
).order_by(Notification.starttime.asc()).all()
|
||||
|
||||
data = [n.to_dict() for n in notifications]
|
||||
|
||||
return success_response({
|
||||
'notifications': data,
|
||||
'total': len(data)
|
||||
})
|
||||
|
||||
|
||||
@notifications_bp.route('/calendar', methods=['GET'])
|
||||
def get_calendar_events():
|
||||
"""
|
||||
Get notifications in FullCalendar event format.
|
||||
|
||||
Query parameters:
|
||||
- start: Start date (ISO format)
|
||||
- end: End date (ISO format)
|
||||
"""
|
||||
query = Notification.query.filter(Notification.isactive == True)
|
||||
|
||||
# Date range filter
|
||||
if start := request.args.get('start'):
|
||||
try:
|
||||
start_date = datetime.fromisoformat(start.replace('Z', '+00:00'))
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Notification.endtime >= start_date,
|
||||
Notification.endtime.is_(None)
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end := request.args.get('end'):
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
|
||||
query = query.filter(Notification.starttime <= end_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
notifications = query.order_by(Notification.starttime).all()
|
||||
|
||||
events = [n.to_calendar_event() for n in notifications]
|
||||
|
||||
return success_response(events)
|
||||
|
||||
|
||||
@notifications_bp.route('/dashboard/summary', methods=['GET'])
|
||||
def dashboard_summary():
|
||||
"""Get notifications dashboard summary."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Total active notifications
|
||||
total_active = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
db.or_(
|
||||
Notification.starttime.is_(None),
|
||||
Notification.starttime <= now
|
||||
),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime >= now
|
||||
)
|
||||
).count()
|
||||
|
||||
# By type
|
||||
by_type = db.session.query(
|
||||
NotificationType.typename,
|
||||
NotificationType.typecolor,
|
||||
db.func.count(Notification.notificationid)
|
||||
).join(Notification
|
||||
).filter(
|
||||
Notification.isactive == True
|
||||
).group_by(NotificationType.typename, NotificationType.typecolor
|
||||
).all()
|
||||
|
||||
return success_response({
|
||||
'active': total_active,
|
||||
'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
|
||||
})
|
||||
|
||||
|
||||
@notifications_bp.route('/employee/<sso>', methods=['GET'])
|
||||
def get_employee_recognitions(sso):
|
||||
"""
|
||||
Get recognitions for a specific employee by SSO.
|
||||
|
||||
Returns all recognition-type notifications where the employee is mentioned.
|
||||
"""
|
||||
if not sso or not sso.isdigit():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required')
|
||||
|
||||
# Find recognition type(s)
|
||||
recognition_types = NotificationType.query.filter(
|
||||
db.or_(
|
||||
NotificationType.typecolor == 'recognition',
|
||||
NotificationType.typename.ilike('%recognition%')
|
||||
)
|
||||
).all()
|
||||
|
||||
recognition_type_ids = [rt.notificationtypeid for rt in recognition_types]
|
||||
|
||||
# Find notifications where this employee is mentioned
|
||||
# Check both exact match and comma-separated list
|
||||
query = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
db.or_(
|
||||
Notification.employeesso == sso,
|
||||
Notification.employeesso.like(f'{sso},%'),
|
||||
Notification.employeesso.like(f'%,{sso}'),
|
||||
Notification.employeesso.like(f'%,{sso},%')
|
||||
)
|
||||
)
|
||||
|
||||
# Optionally filter to recognition types only
|
||||
if recognition_type_ids:
|
||||
query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids))
|
||||
|
||||
query = query.order_by(Notification.starttime.desc())
|
||||
|
||||
notifications = query.all()
|
||||
data = [n.to_dict() for n in notifications]
|
||||
|
||||
return success_response({
|
||||
'recognitions': data,
|
||||
'total': len(data)
|
||||
})
|
||||
|
||||
|
||||
@notifications_bp.route('/shopfloor', methods=['GET'])
|
||||
def get_shopfloor_notifications():
|
||||
"""
|
||||
Get notifications for shopfloor TV dashboard.
|
||||
|
||||
Returns current and upcoming notifications with isshopfloor=1.
|
||||
Splits multi-employee recognition and training into separate entries.
|
||||
|
||||
Query parameters:
|
||||
- businessunit: Filter by business unit ID (null = all units)
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
business_unit = request.args.get('businessunit')
|
||||
|
||||
# Base query for shopfloor notifications
|
||||
base_query = Notification.query.filter(Notification.isshopfloor == True)
|
||||
|
||||
# Business unit filter
|
||||
if business_unit and business_unit.isdigit():
|
||||
# Specific BU: show that BU's notifications AND null (all units)
|
||||
base_query = base_query.filter(
|
||||
db.or_(
|
||||
Notification.businessunitid == int(business_unit),
|
||||
Notification.businessunitid.is_(None)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# All units: only show notifications with NULL businessunitid
|
||||
base_query = base_query.filter(Notification.businessunitid.is_(None))
|
||||
|
||||
# Current notifications (active now or ended within 30 minutes)
|
||||
thirty_min_ago = now - timedelta(minutes=30)
|
||||
current_query = base_query.filter(
|
||||
db.or_(
|
||||
# Active and currently showing
|
||||
db.and_(
|
||||
Notification.isactive == True,
|
||||
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
|
||||
db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
|
||||
),
|
||||
# Recently ended (within 30 min) - show as resolved
|
||||
db.and_(
|
||||
Notification.endtime.isnot(None),
|
||||
Notification.endtime >= thirty_min_ago,
|
||||
Notification.endtime < now
|
||||
)
|
||||
)
|
||||
).order_by(Notification.notificationid.desc())
|
||||
|
||||
current_notifications = current_query.all()
|
||||
|
||||
# Upcoming notifications (starts within next 5 days)
|
||||
five_days = now + timedelta(days=5)
|
||||
upcoming_query = base_query.filter(
|
||||
Notification.isactive == True,
|
||||
Notification.starttime > now,
|
||||
Notification.starttime <= five_days
|
||||
).order_by(Notification.starttime)
|
||||
|
||||
upcoming_notifications = upcoming_query.all()
|
||||
|
||||
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,
|
||||
'notification': n.notification,
|
||||
'starttime': n.starttime.isoformat() if n.starttime else None,
|
||||
'endtime': n.endtime.isoformat() if n.endtime else None,
|
||||
'ticketnumber': n.ticketnumber,
|
||||
'link': n.link,
|
||||
'isactive': n.isactive,
|
||||
'isshopfloor': True,
|
||||
'resolved': is_resolved,
|
||||
'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 (photo only when the type wants it)
|
||||
if employee_override:
|
||||
result['employeesso'] = employee_override.get('sso')
|
||||
result['employeename'] = employee_override.get('name')
|
||||
result['employeepicture'] = employee_override.get('picture')
|
||||
else:
|
||||
result['employeesso'] = n.employeesso
|
||||
result['employeename'] = n.employeename
|
||||
result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None
|
||||
|
||||
return result
|
||||
|
||||
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)]
|
||||
|
||||
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)
|
||||
]
|
||||
|
||||
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,
|
||||
'configversion': _config_version(),
|
||||
})
|
||||
12
plugins/notifications/manifest.json
Normal file
12
plugins/notifications/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "notifications",
|
||||
"version": "1.0.0",
|
||||
"description": "Notifications and announcements management plugin",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/notifications",
|
||||
"provides": {
|
||||
"features": ["notifications", "announcements", "calendar_events"]
|
||||
}
|
||||
}
|
||||
14
plugins/notifications/migrations/env.py
Normal file
14
plugins/notifications/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the notifications plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which
|
||||
filters the metadata to this plugin's tables and drives Alembic against the
|
||||
per-plugin version table alembic_version_notifications. See ADR-008 for the
|
||||
ownership cutover between the core chain and per-plugin chains.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'notifications'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/notifications/migrations/script.py.mako
Normal file
24
plugins/notifications/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,28 @@
|
||||
"""notifications plugin anchor (ownership cutover).
|
||||
|
||||
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
|
||||
already created every table this plugin owns at the cutover point, so there is
|
||||
nothing to build here. This revision gives the plugin's own chain a base that
|
||||
`flask plugin upgrade-all` can stamp into alembic_version_notifications. From this
|
||||
anchor forward, new notifications schema changes land as 000N revisions in this
|
||||
directory, never in the core chain. See ADR-008.
|
||||
"""
|
||||
from alembic import op # noqa: F401
|
||||
import sqlalchemy as sa # noqa: F401
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'notifications0001anchor'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# No-op: the core chain owns every table that exists at the cutover.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# No-op: this anchor never unwinds core-owned tables.
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Index notifications.businessunitid (shopfloor list route filters on it).
|
||||
|
||||
businessunitid is a soft-ref column the shopfloor feed filters by; it had no
|
||||
index. Add ix_notifications_businessunitid (matches the model's index=True).
|
||||
|
||||
Idempotent; downgrade drops it.
|
||||
|
||||
Revision ID: notifications0002buidx
|
||||
Revises: notifications0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'notifications0002buidx'
|
||||
down_revision = 'notifications0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_INDEX = 'ix_notifications_businessunitid'
|
||||
|
||||
|
||||
def _index_names(insp, table):
|
||||
return {i['name'] for i in insp.get_indexes(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'notifications' not in insp.get_table_names():
|
||||
return
|
||||
if _INDEX not in _index_names(insp, 'notifications'):
|
||||
op.create_index(_INDEX, 'notifications', ['businessunitid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'notifications' not in insp.get_table_names():
|
||||
return
|
||||
if _INDEX in _index_names(insp, 'notifications'):
|
||||
op.drop_index(_INDEX, table_name='notifications')
|
||||
5
plugins/notifications/models/__init__.py
Normal file
5
plugins/notifications/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Notifications plugin models."""
|
||||
|
||||
from .notification import Notification, NotificationType
|
||||
|
||||
__all__ = ['Notification', 'NotificationType']
|
||||
195
plugins/notifications/models/notification.py
Normal file
195
plugins/notifications/models/notification.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Notifications plugin models - adapted to existing database schema."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class NotificationType(db.Model):
|
||||
"""
|
||||
Notification type classification.
|
||||
Matches existing notificationtypes table.
|
||||
"""
|
||||
__tablename__ = 'notificationtypes'
|
||||
|
||||
notificationtypeid = db.Column(db.Integer, primary_key=True)
|
||||
typename = db.Column(db.String(50), nullable=False)
|
||||
typedescription = db.Column(db.Text)
|
||||
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}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'typename': self.typename,
|
||||
'typedescription': self.typedescription,
|
||||
'typecolor': self.typecolor,
|
||||
'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'
|
||||
}
|
||||
|
||||
|
||||
class Notification(db.Model):
|
||||
"""
|
||||
Notification/announcement model.
|
||||
Matches existing notifications table schema.
|
||||
"""
|
||||
__tablename__ = 'notifications'
|
||||
|
||||
notificationid = db.Column(db.Integer, primary_key=True)
|
||||
notificationtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('notificationtypes.notificationtypeid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(db.Integer, nullable=True, index=True)
|
||||
appid = db.Column(db.Integer, nullable=True)
|
||||
notification = db.Column(db.Text, nullable=False, comment='The message content')
|
||||
starttime = db.Column(db.DateTime, nullable=True)
|
||||
endtime = db.Column(db.DateTime, nullable=True)
|
||||
ticketnumber = db.Column(db.String(50), nullable=True)
|
||||
link = db.Column(db.String(500), nullable=True)
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
isshopfloor = db.Column(db.Boolean, default=False)
|
||||
# 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')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Notification {self.notificationid}>"
|
||||
|
||||
@property
|
||||
def is_current(self):
|
||||
"""Check if notification is currently active based on dates."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if not self.isactive:
|
||||
return False
|
||||
if self.starttime and now < self.starttime:
|
||||
return False
|
||||
if self.endtime and now > self.endtime:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""Get title - first line or first 100 chars of notification."""
|
||||
if not self.notification:
|
||||
return ''
|
||||
lines = self.notification.split('\n')
|
||||
return lines[0][:100] if lines else self.notification[:100]
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary with related data."""
|
||||
result = {
|
||||
'notificationid': self.notificationid,
|
||||
'notificationtypeid': self.notificationtypeid,
|
||||
'businessunitid': self.businessunitid,
|
||||
'appid': self.appid,
|
||||
'notification': self.notification,
|
||||
'title': self.title,
|
||||
'message': self.notification,
|
||||
'starttime': self.starttime.isoformat() if self.starttime else None,
|
||||
'endtime': self.endtime.isoformat() if self.endtime else None,
|
||||
'startdate': self.starttime.isoformat() if self.starttime else None,
|
||||
'enddate': self.endtime.isoformat() if self.endtime else None,
|
||||
'ticketnumber': self.ticketnumber,
|
||||
'link': self.link,
|
||||
'linkurl': self.link,
|
||||
'isactive': bool(self.isactive) if self.isactive is not None else True,
|
||||
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'iscurrent': self.is_current
|
||||
}
|
||||
|
||||
# Add type info
|
||||
if self.notificationtype:
|
||||
result['typename'] = self.notificationtype.typename
|
||||
result['typecolor'] = self.notificationtype.typecolor
|
||||
|
||||
return result
|
||||
|
||||
def to_calendar_event(self):
|
||||
"""Convert to FullCalendar event format."""
|
||||
# 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',
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
# Employee-photo types prefix the card with the person's name/SSO. A
|
||||
# multi-person recognition/training lists many people, so summarize as
|
||||
# "First Person +N" to keep the calendar title short (the detail popup
|
||||
# still shows the full employeename).
|
||||
title = self.title
|
||||
if show_photo:
|
||||
employee_display = self.employeename or self.employeesso
|
||||
if employee_display:
|
||||
people = [p.strip() for p in employee_display.split(',') if p.strip()]
|
||||
if len(people) > 1:
|
||||
employee_display = f"{people[0]} +{len(people) - 1}"
|
||||
title = f"{employee_display}: {title}"
|
||||
|
||||
return {
|
||||
'id': self.notificationid,
|
||||
'title': title,
|
||||
'start': self.starttime.isoformat() if self.starttime else None,
|
||||
'end': self.endtime.isoformat() if self.endtime else None,
|
||||
'allDay': True,
|
||||
'backgroundColor': color,
|
||||
'borderColor': color,
|
||||
'extendedProps': {
|
||||
'notificationid': self.notificationid,
|
||||
'message': self.notification,
|
||||
'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,
|
||||
'employeesso': self.employeesso,
|
||||
}
|
||||
}
|
||||
231
plugins/notifications/plugin.py
Normal file
231
plugins/notifications/plugin.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Notifications plugin main class."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
import click
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import Notification, NotificationType
|
||||
from .api import notifications_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationsPlugin(BasePlugin):
|
||||
"""
|
||||
Notifications plugin - manages announcements and notifications.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating and managing notifications/announcements
|
||||
- Displaying banner notifications
|
||||
- Calendar view of notifications
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
"""Load plugin manifest from JSON file."""
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
"""Return plugin metadata."""
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'notifications'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get(
|
||||
'description',
|
||||
'Notifications and announcements management'
|
||||
),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
"""Return Flask Blueprint with API routes."""
|
||||
return notifications_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Notification, NotificationType]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
logger.info(f"Notifications plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
self._ensure_notification_types()
|
||||
logger.info("Notifications plugin installed")
|
||||
|
||||
def _ensure_notification_types(self) -> None:
|
||||
"""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', '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, 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,
|
||||
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}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def on_uninstall(self, app: Flask) -> None:
|
||||
"""Called when plugin is uninstalled."""
|
||||
logger.info("Notifications plugin uninstalled")
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('notifications')
|
||||
def notifications_cli():
|
||||
"""Notifications plugin commands."""
|
||||
pass
|
||||
|
||||
@notifications_cli.command('list-types')
|
||||
def list_types():
|
||||
"""List all notification types."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
types = NotificationType.query.filter_by(isactive=True).all()
|
||||
if not types:
|
||||
click.echo('No notification types found.')
|
||||
return
|
||||
|
||||
click.echo('Notification Types:')
|
||||
for t in types:
|
||||
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
|
||||
|
||||
@notifications_cli.command('stats')
|
||||
def stats():
|
||||
"""Show notification statistics."""
|
||||
from flask import current_app
|
||||
from datetime import datetime, timezone
|
||||
|
||||
with current_app.app_context():
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
total = Notification.query.filter(
|
||||
Notification.isactive == True
|
||||
).count()
|
||||
|
||||
active = Notification.query.filter(
|
||||
Notification.isactive == True,
|
||||
Notification.startdate <= now,
|
||||
db.or_(
|
||||
Notification.enddate.is_(None),
|
||||
Notification.enddate >= now
|
||||
)
|
||||
).count()
|
||||
|
||||
click.echo(f"Total notifications: {total}")
|
||||
click.echo(f"Currently active: {active}")
|
||||
|
||||
@notifications_cli.command('create')
|
||||
@click.option('--title', required=True, help='Notification title')
|
||||
@click.option('--message', required=True, help='Notification message')
|
||||
@click.option('--type', 'type_name', default='General', help='Notification type')
|
||||
def create_notification(title, message, type_name):
|
||||
"""Create a new notification."""
|
||||
from flask import current_app
|
||||
|
||||
with current_app.app_context():
|
||||
ntype = NotificationType.query.filter_by(typename=type_name).first()
|
||||
if not ntype:
|
||||
click.echo(f"Error: Notification type '{type_name}' not found.")
|
||||
return
|
||||
|
||||
n = Notification(
|
||||
title=title,
|
||||
message=message,
|
||||
notificationtypeid=ntype.notificationtypeid
|
||||
)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(f"Created notification #{n.notificationid}: {title}")
|
||||
|
||||
return [notifications_cli]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Return dashboard widget definitions."""
|
||||
return [
|
||||
{
|
||||
'name': 'Active Notifications',
|
||||
'component': 'NotificationsWidget',
|
||||
'endpoint': '/api/notifications/dashboard/summary',
|
||||
'size': 'small',
|
||||
'position': 1,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
{
|
||||
'name': 'Notifications',
|
||||
'icon': 'bell',
|
||||
'route': '/notifications',
|
||||
'position': 5,
|
||||
},
|
||||
{
|
||||
'name': 'Calendar',
|
||||
'icon': 'calendar',
|
||||
'route': '/calendar',
|
||||
'position': 6,
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Return the RBAC permissions this plugin owns."""
|
||||
return [
|
||||
('notifications.view', 'View notifications', 'notifications'),
|
||||
('notifications.create', 'Create notifications', 'notifications'),
|
||||
('notifications.edit', 'Edit notifications', 'notifications'),
|
||||
('notifications.delete', 'Delete notifications', 'notifications'),
|
||||
]
|
||||
Reference in New Issue
Block a user