Skip to content

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
email 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 (system or admin) and scope.
  • AccountNotification: one row per recipient, linking an account (receiver_id) to a notification, with is_read. Deleting a notification cascades to these rows.
  • NotificationChannel: inapp, push, email, sms, discord. Only inapp, email and sms are handled.
  • dispatch_account_notifications in msflib.notifications.service.notification is the programmatic entry point. It persists the notification and recipient rows if inapp is among the channels, then calls the email and SMS handlers for the other channels. If inapp is not requested, nothing is stored and the function returns None.
  • NotificationHandlerFactory maps a channel to a handler. Pass your own via handler_factory= to change or add channels; any object with a get_handler(channel, settings, session) method works.
  • Actions: NotificationAction and AccountNotificationAction in msflib.notifications.actions are standard model actions. AccountNotificationAction adds save_for_recipients, mark_one and mark_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_notification against a settings class without them.
  • Twilio credentials are not configured. One of the three values is empty. sms_notification logs an error and returns None.
  • Twilio package not installed. The twilio import failed at module load. sms_notification logs an error and returns None; nothing is raised to the caller.
  • Email is not sent: no provided configuration for email variables. CORE.EMAILS_ENABLED is false. It is true by default because the SMTP defaults are non-empty (localhost:1025, noreply@example.com). Set EMAILS_ENABLED=false in 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 discord or push channel does nothing. Account notifications have no handler for them. For workspace Discord delivery see workspace_notifications.
  • dispatch_account_notifications returns None. The channels did not include inapp, so nothing was persisted. The admin POST route declares a Notification response, so a request without inapp will fail response validation; always include inapp when 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