msflib-notifications¶
Purpose¶
msflib-notifications gives accounts an in-app notification inbox and gives admins a way to broadcast a notification to selected accounts or all of them. A notification can be fanned out over several channels at once: in-app, email and SMS have built-in handlers. Workspace-level notifications are a separate package, workspace_notifications, that builds on this one.
It does not provide push delivery or a Discord integration. push and discord exist in the NotificationChannel enum, but account notifications have no handler for them and skip them with a log warning.
Install¶
[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-account = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/account", rev = "account-v0.2.2" }
msflib-notifications = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/notifications", rev = "notifications-v0.2.1" }
The package declares sms (Twilio), email (the emails library) and all extras.
Packaging (#279)
On this branch those extras do not change what ends up installed: msflib core already depends on emails and twilio unconditionally, and service/email.py in core imports emails at module level. Treat both channels as available whenever core is installed.
The code still guards the Twilio import, so a missing twilio makes SMS return None instead of raising (see Troubleshooting).
msflib-notifications also depends on msflib-tenancy. The notification tables need the account table. Tenancy tables are only needed if you use tenant resolution elsewhere; import msflib.tenancy.models.tenant if you do.
Wiring into a host app¶
Two router factories are exported from msflib.notifications.router. Both take your dependency callables as keyword arguments. See Mounting routes for the general pattern and auth for how get_current_account and RoleCheck are produced.
from fastapi import FastAPI
from msflib.account.actions import AccountAction
from msflib.account.models import AccountCreate, AccountUpdate
from msflib.account.models.account import Account
from msflib.api.deps import get_keystore_factory, get_session_factory
from msflib.auth.deps import get_account_dependencies
from msflib.notifications.router import (
account_notification_router,
admin_account_notification_router,
)
app = FastAPI()
get_session = get_session_factory(engine) # engine and settings come from your host
get_keystore = get_keystore_factory()
deps = get_account_dependencies(
AccountModel=Account,
oauth_token_url="/token",
secret_key=settings.scope("CORE").SECRET_KEY,
session_dep=get_session,
keystore_dep=get_keystore,
)
app.include_router(
account_notification_router(
get_session=get_session,
get_current_account=deps.get_current_account,
prefix="/notifications",
)
)
app.include_router(
admin_account_notification_router(
get_session=get_session,
get_current_account=deps.get_current_account,
role_check=deps.RoleCheck,
settings=settings,
account_action=AccountAction[Account, AccountCreate, AccountUpdate](settings=settings),
prefix="/admin/notifications",
)
)
role_check is called as role_check([AccountRole.admin, AccountRole.root]) and must return a FastAPI dependency; deps.RoleCheck is what the module's own tests pass. account_action is effectively required: the default bare AccountAction() fails when the route resolves receivers (500, TypeError: AccountAction must be instantiated with concrete generic types).
Routes added:
| Router | Method and path | Effect |
|---|---|---|
| account | GET / |
List the caller's notifications (offset, limit) |
| account | GET /{notification_id} |
One notification, looked up by the inbox row id (id in the list response); 404 if it is not the caller's |
| account | PATCH /{notification_id}/mark |
Set read state; ?status=true\|false, omitted toggles. Here the path value is the notification_id of the inbox row, not its id |
| account | PATCH /mark-all |
Same, for all of the caller's notifications |
| account | DELETE /{notification_id} |
Delete the caller's copy, by inbox row id (id) |
| admin | POST / |
Send; body is {"data": <NotificationCreate>} (the route uses validation_types={"data": ...}, so the payload is wrapped and a flat body gives 422), query account_receiver_ids (omit for all accounts). Returns 201 |
| admin | GET /all, GET /{notification_id} |
Admin notifications sent by the calling admin |
| admin | DELETE /{notification_id} |
Delete one of the caller's sent notifications and its inbox rows |
The admin POST returns 400 if channels is empty. admin_account_notification_router also accepts account_action (a custom AccountAction) and notification_create_type (a subclass of NotificationCreate to validate the body with).
The admin list, get and delete routes only match notifications with scope == "account" (ACCOUNT_SCOPE). Extension packages that store their own notifications in the same notification table use a different scope, so these routes never expose or delete them.
Configuration¶
NotificationSettings has the NOTIFICATIONS namespace and one setting.
| Key | Default | Notes |
|---|---|---|
DASHBOARD_PATH |
/dashboard |
Appended to CORE.CLIENT_HOST to build the link in notification emails |
Compose it into your settings class, for example class AppSettings(NotificationSettings, AccountSettings, CoreSettings, SettingsBase). With subclassed settings the environment variable is the flat name DASHBOARD_PATH. If you instead declare the namespace as a field (NOTIFICATIONS: NotificationSettings = NotificationSettings()), NOTIFICATIONS__DASHBOARD_PATH also works.
Behaviour may change (#280)
The NAMESPACE__KEY environment variable style only works for settings composed as a field, not for subclassed hosts. This may change.
Email and SMS delivery read settings that this module does not define:
| Channel | Settings read | Where they come from |
|---|---|---|
CORE.EMAILS_ENABLED, CORE.SMTP_*, CORE.EMAILS_FROM_EMAIL, CORE.EMAILS_FROM_NAME, CORE.PROJECT_NAME, CORE.CLIENT_HOST, CORE.EMAIL_TEMPLATES_DIR |
CoreSettings |
|
| sms | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER read as plain attributes of the settings object |
You must declare them (see below) |
Known issue (#274)
No settings class in the repository declares the TWILIO_* fields, so SMS is never sent with the stock settings classes. Declare them on your own settings class as shown under Examples.
Key concepts¶
Notification: one row per sent message:title,message,channels,sender_id,notification_type(systemoradmin) andscope.AccountNotification: one row per recipient, linking an account (receiver_id) to a notification, withis_read. Deleting a notification cascades to these rows.NotificationChannel:inapp,push,email,sms,discord. Onlyinapp,emailandsmsare handled.dispatch_account_notificationsinmsflib.notifications.service.notificationis the programmatic entry point. It persists the notification and recipient rows ifinappis among the channels, then calls the email and SMS handlers for the other channels. Ifinappis not requested, nothing is stored and the function returnsNone.NotificationHandlerFactorymaps a channel to a handler. Pass your own viahandler_factory=to change or add channels; any object with aget_handler(channel, settings, session)method works.- Actions:
NotificationActionandAccountNotificationActioninmsflib.notifications.actionsare standard model actions.AccountNotificationActionaddssave_for_recipients,mark_oneandmark_all.
Sender rules: for NotificationType.admin a sender is required when inapp is requested, otherwise ValueError is raised. System notifications may omit it.
Examples¶
Send an in-app notification from your own code. This was run against an in-memory SQLite database:
from msflib.notifications.actions import AccountNotificationAction
from msflib.notifications.models import (
NotificationChannel,
NotificationCreate,
NotificationType,
)
from msflib.notifications.service.notification import dispatch_account_notifications
notification = dispatch_account_notifications(
session=session,
settings=settings,
data=NotificationCreate(
title="Welcome",
message="Hello",
channels=[NotificationChannel.inapp],
),
n_type=NotificationType.admin,
sender=admin_account,
receivers=[user_account],
)
ana = AccountNotificationAction()
rows = ana.get_multi_by_all(session, receiver_id=user_account.id)
ana.mark_one(session, rows[0].notification_id, user_account) # toggles; returns the updated row
# mark_one takes the notification_id of the inbox row, not its own id
Adjust recipients at send time with resolve_receivers, a callable that receives the receiver list and returns the list to use for both the inbox rows and the email/SMS handlers:
dispatch_account_notifications(
session=session,
settings=settings,
data=data,
n_type=NotificationType.system,
receivers=all_accounts,
resolve_receivers=lambda receivers: [a for a in receivers if a.email],
)
Enable SMS by declaring the Twilio settings on your settings class. The credentials are read as flat attributes, so flat environment variable names are used:
class AppSettings(NotificationSettings, CoreSettings, SettingsBase):
TWILIO_ACCOUNT_SID: str = ""
TWILIO_AUTH_TOKEN: str = ""
TWILIO_PHONE_NUMBER: str = ""
sms_notification(to_number, message, settings) then returns the Twilio message SID, or None on any failure. The SMS handler sends to each receiver's phone attribute and skips receivers without one.
Use a custom email template per call by passing template_file in the handler context. NotificationHandlerFactory reads context["template_file"] and defaults to notification.html, which ships with core. The template receives title, message, email and link.
Troubleshooting¶
- SMS is never sent and the log shows "Failed to send SMS ... object has no attribute 'TWILIO_ACCOUNT_SID'". No settings class in the repository defines the three Twilio fields, so with the stock settings classes the attribute lookup raises and the handler logs a warning per receiver. Declare the fields as shown above. This was confirmed by running
sms_notificationagainst a settings class without them. Twilio credentials are not configured.One of the three values is empty.sms_notificationlogs an error and returnsNone.Twilio package not installed.Thetwilioimport failed at module load.sms_notificationlogs an error and returnsNone; nothing is raised to the caller.- Email is not sent:
no provided configuration for email variables.CORE.EMAILS_ENABLEDis false. It is true by default because the SMTP defaults are non-empty (localhost:1025,noreply@example.com). SetEMAILS_ENABLED=falsein hosts with no mail server; with it left on, a refused connection still logs "Email sent ... status_code=None". Per-receiver failures in the email handler are caught and logged, so the dispatch call itself succeeds. - A
discordorpushchannel does nothing. Account notifications have no handler for them. For workspace Discord delivery see workspace_notifications. dispatch_account_notificationsreturnsNone. The channels did not includeinapp, so nothing was persisted. The adminPOSTroute declares aNotificationresponse, so a request withoutinappwill fail response validation; always includeinappwhen calling it.
API reference¶
See the generated API reference for msflib.notifications. The module's README.md in modules/notifications has a short endpoint list.
See also¶
- workspace_notifications: workspace-scoped notifications and Discord delivery
- account and auth: the account model and identity dependencies the routers need
- Mounting routes