Files
shopdb-flask/plugins/notifications/plugin.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00

223 lines
8.5 KiB
Python

"""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,
},
]