msflib-workspace-notifications¶
Purpose¶
msflib-workspace-notifications adds workspace-scoped notifications on top of notifications. Workspace admins send a notification to some or all users of a workspace; recipients get an in-app inbox, and the same message can go out by email, SMS and Discord. It also stores the Discord channels a workspace has registered.
The base Notification table stays free of any workspace dependency. This package links a notification to a workspace through its own WorkspaceNotification table and marks it with scope = "workspace", which keeps it invisible to the account-level notification routes.
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-workspaces = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/workspaces", rev = "workspaces-v0.2.1" }
msflib-notifications = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/notifications", rev = "notifications-v0.2.1" }
msflib-workspace-notifications = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/workspace_notifications", rev = "workspace_notifications-v0.2.1" }
The package declares discord, sms, email and all extras (discord-py, twilio, emails). msflib core already depends on discord-py, twilio and emails unconditionally, so on this branch the extras add nothing when core is installed. The Discord import in service/discord.py is still guarded: if discord cannot be imported, the notifier logs an error and Discord messages are not sent.
Packaging (#279)
The sms and all extras pin twilio ^8.0.0, which conflicts with the ^9.4.6 pin in core and msflib-notifications. Avoid installing those extras and rely on core's twilio (see Troubleshooting).
Wiring into a host app¶
Three router factories live in msflib.workspace_notifications.router. They need the workspace-aware identity dependencies from workspaces (get_current_user, get_current_workspace, and a role check for workspace admins).
from fastapi import FastAPI
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, get_user_dependencies
from msflib.workspaces.actions import UserAction
from msflib.workspaces.models import UserCreate, UserUpdate
from msflib.workspaces.models.user import User
from msflib.workspaces.models.workspace import Workspace
from msflib.workspace_notifications.router import (
admin_discord_channel_router,
admin_user_notification_router,
user_notification_router,
)
app = FastAPI()
get_session = get_session_factory(engine) # engine and settings come from your host
get_keystore = get_keystore_factory()
account_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,
)
ws_deps = get_user_dependencies(
UserModel=User,
WorkspaceModel=Workspace,
session_dep=get_session,
account_dependencies=account_deps,
)
admin_role_check = ws_deps.WorkspaceRoleCheck(roles=["admin", "owner"])
app.include_router(
user_notification_router(
get_session=get_session,
get_current_user=ws_deps.get_current_user,
get_current_workspace=ws_deps.get_current_workspace,
prefix="/{workspace_slug}/user",
)
)
app.include_router(
admin_user_notification_router(
get_session=get_session,
get_current_user=ws_deps.get_current_user,
get_current_workspace=ws_deps.get_current_workspace,
admin_role_check=admin_role_check,
settings=settings,
user_action=UserAction[User, UserCreate, UserUpdate](),
prefix="/{workspace_slug}/admin/notification",
)
)
app.include_router(
admin_discord_channel_router(
get_session=get_session,
get_current_user=ws_deps.get_current_user,
get_current_workspace=ws_deps.get_current_workspace,
admin_role_check=admin_role_check,
user_action=UserAction[User, UserCreate, UserUpdate](),
prefix="/{workspace_slug}/admin/discord",
)
)
This mirrors the module's own test fixture, including user_action: without it the admin POST returns 500 (TypeError: UserAction must be instantiated with concrete generic types), because the default is a bare UserAction(). The {workspace_slug} path parameter is what get_current_workspace resolves; use whatever your workspace dependency expects.
| Router | Method and path | Effect |
|---|---|---|
| user | GET / |
The workspace user's notifications (offset, limit) |
| user | GET /{notification_id} |
One, looked up by notification_id, not by the link row id |
| user | PATCH /{notification_id}/mark, PATCH /mark-all |
Set or toggle read state (?status=) |
| user | DELETE /{notification_id} |
Delete the user's copy |
| 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 user_receiver_ids (omit for all users in the workspace). 201 |
| admin | GET /, GET /{notification_id} |
Admin notifications this admin sent in this workspace |
| admin | DELETE /{notification_id} |
Delete a sent notification and its link rows |
| discord | POST /channel, GET /channel |
Register and list the workspace's Discord channels |
| discord | PUT /channel/{id}, DELETE /channel/{id} |
Update and delete one |
The admin POST returns 400 for an empty channels list, and 400 ("In-app channel is required.") when the dispatch returns nothing because inapp was not among the channels. The caller needs a workspace User row of type admin or owner (created by the workspaces account hook or UserAction.create); without a membership the admin routes return 403 "User not found in specified workspace". admin_user_notification_router also accepts user_action, notification_action and notification_create_type for customization. Default prefixes if you omit prefix: /notifications, /admin/notification, /admin/discord.
Configuration¶
WsNotificationSettings has the WS_NOTIFICATIONS namespace.
| Key | Default | Notes |
|---|---|---|
DISCORD_BOT_TOKEN |
None |
Bot token. Without it the Discord handler is skipped with a warning |
Flat alias: BOT_TOKEN maps to WS_NOTIFICATIONS.DISCORD_BOT_TOKEN (confirmed). The environment variable depends on how you compose settings:
- Subclassed (
class AppSettings(WsNotificationSettings, ...)): use the flat names,DISCORD_BOT_TOKENorBOT_TOKEN.WS_NOTIFICATIONS__DISCORD_BOT_TOKENis not read in this style. - Nested field (
WS_NOTIFICATIONS: WsNotificationSettings = WsNotificationSettings()):WS_NOTIFICATIONS__DISCORD_BOT_TOKENorBOT_TOKEN. The bareDISCORD_BOT_TOKENis not read.
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 use the same settings as notifications, including the TWILIO_* attributes you must declare yourself.
Key concepts¶
WorkspaceNotificationlinks oneNotification(unique) to aworkspace_id.WorkspaceNotificationAction.linksetsnotification.scope = "workspace"(WORKSPACE_SCOPE) and creates the link.get_notificationsandget_notificationquery notifications through that link.UserNotificationis the per-recipient inbox row. Itsreceiver_idpoints at a workspaceuser.id(a membership), not an account id.UserNotificationActionhassave_for_recipients,mark_oneandmark_all.DiscordChannel: a named Discord channel id belonging to a workspace.DiscordChannelAction.store_channelrecords the admin's account asadmin_id. If you passadmin_account, it looks up that account's workspace membership with an allowed type (defaultadminorowner) and raisesValueErrorif none is found. If you passadmin_user, that user is used as given with no type check, so the role check is up to the caller (the router relies onadmin_role_check).dispatch_user_notifications(msflib.workspace_notifications.service.notification) mirrors the account dispatcher. It creates the notification withscope="workspace", links it to the workspace and recipients, then runs the other channels. Email and SMS handlers receive account objects, so it resolves each workspace user'saccount_idto an account before calling them.inapphandling and thesenderrules are the same as in the account version.NotificationHandlerFactoryin this package extends the base factory with adiscordhandler and takes aworkspaceargument. Passhandler_factory=to override.DiscordNotifierwraps adiscord.pybot.create_discord_notifier(settings)returnsNonewhen no token is configured; otherwise it builds the bot and starts it on a daemon thread. Messages go to every channel id registered for the workspace.
Known issue (#274)
The Discord notifier starts a new bot thread on every dispatch that includes the discord channel.
Examples¶
Send an in-app notification to a workspace user. This ran against in-memory SQLite with the default tenant seeded:
from msflib.notifications.models import (
NotificationChannel,
NotificationCreate,
NotificationType,
)
from msflib.workspace_notifications.actions import (
UserNotificationAction,
WorkspaceNotificationAction,
)
from msflib.workspace_notifications.service.notification import dispatch_user_notifications
notification = dispatch_user_notifications(
session=session,
settings=settings,
data=NotificationCreate(
title="Standup", message="10am", channels=[NotificationChannel.inapp]
),
n_type=NotificationType.admin,
workspace=workspace,
receivers=[member_user], # workspace User rows
sender=admin_user, # workspace User row
)
notification.scope # "workspace"
WorkspaceNotificationAction().get_notifications(session, workspace.id)
UserNotificationAction().get_multi_by_all(session, receiver_id=member_user.id)
Register a Discord channel for a workspace from code:
from msflib.workspace_notifications.actions import DiscordChannelAction
from msflib.workspace_notifications.models import DiscordChannelCreate
from msflib.workspaces.actions import UserAction
from msflib.workspaces.models import UserCreate, UserUpdate
from msflib.workspaces.models.user import User
dca = DiscordChannelAction(UserAction[User, UserCreate, UserUpdate]())
dca.store_channel(
session,
data=DiscordChannelCreate(name="ops-alerts", channel_id=123456789012345678),
workspace=workspace,
admin_user=admin_user,
)
Then send with channels=[NotificationChannel.inapp, NotificationChannel.discord] and set DISCORD_BOT_TOKEN (or BOT_TOKEN, which is read in both layouts). The Discord example was not run, because it needs a live bot connection.
Troubleshooting¶
- Discord messages are not delivered and the log says "Discord BOT_TOKEN not configured". Set the token using the environment variable name that matches your settings composition (see Configuration). Dispatch continues without Discord.
- "Workspace is required for Discord notifications". The Discord handler is only built when a workspace is passed.
dispatch_user_notificationsalways passes it; this appears only with a custom caller of the factory. - "Discord bot is not initialized. Message cannot be sent."
discord.pycould not be imported, or the notifier was built without a token. - Discord channel not found. The bot only sends to channels it can see (
bot.get_channel); missing ones are logged at info level and skipped. ValueError: Admin user not found for account in workspace.store_channelwas called with anadmin_accountthat has noadminorownermembership in that workspace, or with neitheradmin_accountnoradmin_user.DiscordChannelActionneeds a concreteUserAction[User, UserCreate, UserUpdate](); a bareUserAction()raisesTypeErrorwhenadmin_accountis used.- SMS and email silently not sent. See the Troubleshooting section of notifications; the same handlers run here.
- Workspace notifications show up nowhere in the account inbox. That is intended: they have
scope="workspace"and useUserNotification, notAccountNotification. - Dependency resolution conflict on
twilio.msflib-workspace-notificationspinstwilio ^8.0.0for itssmsextra while core andmsflib-notificationspin^9.4.6. Avoid installing thesmsorallextra of this package; rely on core'stwilioinstead.
API reference¶
See the generated API reference for msflib.workspace_notifications. The module README is a short stub.
See also¶
- notifications: the base module this package extends
- workspaces:
User,Workspaceand the workspace identity dependencies - Mounting routes