PRINTER CARD is now supplies at or below 5 percent, a new printers_dashboardpercent setting. The report and the card want different scopes: the report lists anything the thresholds call low, which is right for planning an order, while the dashboard is asking what to walk out and change today - and a cartridge at 18 percent is not that. Lowest first. PRINTER LOCATION WAS ALWAYS EMPTY. The lookup went through db.session.get on locationid and produced nothing even where a location is set; the printers list has always read it through the asset relationship, so the card does too now. WARRANTY ROWS are identified the way the floor identifies them: the PC's hostname and the MACHINE it drives, reusing the same lookup behind the warranty page's machine column so the board and the report cannot disagree about which bay a PC belongs to. No dates - expired or expiring is the whole decision when scanning a board, and the exact day belongs on the report you order from. NOTIFICATIONS are stacked: the type in full on one line, the message beneath, trimmed to 100 characters with the rest on hover. Inline, the type was truncated to make room for prose that was then truncated anyway, and neither read. A tooltip is omitted when the text was not trimmed, because one repeating what is already on screen is noise. Two general additions: layout: 'stacked' on a card, and map.detailtooltip.
253 lines
9.8 KiB
Python
253 lines
9.8 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',
|
|
'viewall': '/notifications',
|
|
'title': 'Active notifications',
|
|
'endpoint': '/api/notifications/dashboard/active',
|
|
'render': 'list',
|
|
'severity': 'info',
|
|
'empty': 'hide',
|
|
'position': 60,
|
|
# Stacked, not inline: the type is a label and the message is
|
|
# prose. On one line the type gets truncated to make room for
|
|
# text that then gets truncated anyway, and neither is
|
|
# readable.
|
|
'layout': 'stacked',
|
|
'map': {
|
|
'title': 'typename',
|
|
'detail': 'message',
|
|
'detailtooltip': 'fullmessage',
|
|
'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'),
|
|
]
|