Files
shopdb-flask/plugins/notifications/plugin.py
cproudlock 5eb84873e8
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
dashboard: convert the last dead widgets, and delete the one that had nothing
Three plugins still declared widgets naming Vue components nobody wrote.
Converting them honestly meant three different answers, not one.

notifications gets a real card: the active notifications themselves, not a
count. "4 active" tells an admin nothing; knowing WHICH message the shop is
looking at is the point, and it is how a stale one gets noticed and taken down.

machines gets machines out of service - anything not In Use, excluding
Inventory, because a spare on a shelf is stock rather than a problem. Someone is
supposed to be chasing each of those and today they are visible only to whoever
thinks to filter the list by status.

network gets NOTHING, and its declaration is deleted rather than converted.
Network devices carry no live status - no polling, no reachability check,
nothing that can be wrong - so the only possible card is a count of how many
exist, which is precisely the always-true number this dashboard exists to get
away from. A comment records that, so the next person does not re-add it. If
reachability is ever collected, that is the card.

Also adds a contract test over every declared card: no component names, a valid
renderer and severity, and - the one that matters - the endpoint must be a REAL
route. A declaration pointing at a route nobody wrote is exactly how the old
widgets rotted unnoticed for months, and now it fails the build instead.
2026-08-11 14:27:29 -04:00

248 lines
9.6 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]:
"""Dashboard card: what the floor is being shown right now.
The items, not a count. "4 active notifications" tells an admin
nothing; knowing WHICH message is on the boards is the point, and it is
how a stale one gets noticed and taken down.
Replaces a declaration naming a component nobody wrote.
"""
return [
{
'id': 'notifications-active',
'title': 'Active notifications',
'endpoint': '/api/notifications/dashboard/active',
'render': 'list',
'severity': 'info',
'empty': 'hide',
'position': 60,
'map': {
'title': 'typename',
'detail': 'message',
'meta': [{'key': 'endtime', 'label': 'until',
'format': 'date'}],
'link': '/notifications/{notificationid}',
},
},
]
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'),
]