feat: add notification preference system with per-type per-channel toggles and admin defaults

Implement configurable notification preferences across in-app and push channels, including a new `notification_preferences` table with soft-delete support, per-user toggle endpoints, admin defaults management, and canonical type definitions. The change introduces `NOTIFICATION_TYPES` and `NOTIFICATION_CHANNELS` constants, `NotificationPrefForm`/`NotificationDefaultForm` models, `notification_enabled` resolution logic, and UI integration via the profile notifications tab and admin panel.
This commit is contained in:
2026-06-13 10:09:48 +00:00
parent 1a26428952
commit 5e4f0b1f3f
27 changed files with 1330 additions and 33 deletions
@@ -50,6 +50,7 @@ CONFIRM_REQUIRED = {
"admin_reset_all_ai_quota",
"admin_reset_guest_ai_quota",
"admin_reset_user_ai_quota",
"notification_reset",
}
CONDITIONAL_CONFIRM = {
@@ -96,6 +97,8 @@ _DEVII_MECHANIC_EVENTS = {
"customize_set_css": "devii.customization.css.set",
"customize_set_js": "devii.customization.js.set",
"customize_reset": "devii.customization.reset",
"notification_set": "devii.notification.set",
"notification_reset": "devii.notification.reset",
}
_DEVII_CONTAINER_EVENTS = {
@@ -128,6 +131,11 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
"confirm=true."
)
if name == "notification_reset":
return ToolInputError(
"Resetting clears every notification preference and restores the platform defaults; it "
"cannot be undone. Ask the user to confirm, then call again with confirm=true."
)
if name == "project_set_private":
value = str(arguments.get("value", "")).strip()
return ToolInputError(
@@ -250,6 +258,9 @@ class Dispatcher:
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
from ..notification import NotificationController
self._notification = NotificationController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._behavior = behavior
self._read_files: set[tuple[str, str]] = set()
@@ -396,6 +407,9 @@ class Dispatcher:
if action.handler == "customization":
return await self._customization.dispatch(action.name, arguments)
if action.handler == "notification":
return await self._notification.dispatch(action.name, arguments)
if action.handler == "behavior":
if self._behavior is None:
return error_result(
@@ -0,0 +1,80 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from .spec import Action, Param
def arg(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
TYPES = (
"One of: comment, reply, mention, vote, follow, message, badge, level, bug."
)
NOTIFICATION_ACTIONS: tuple[Action, ...] = (
Action(
name="notification_list",
method="LOCAL",
path="",
summary="List the user's notification preferences (in-app and push per type)",
description=(
"Returns every notification type with the user's current in-app and push setting and "
"whether it has been customized. Use this before changing a setting."
),
handler="notification",
requires_auth=True,
read_only=True,
),
Action(
name="notification_set",
method="LOCAL",
path="",
summary="Enable or disable one notification type on one channel",
description=(
"Sets whether the user receives a given notification type on a given channel. The two "
"channels are independent: in-app (shown on DevPlace) and push (sent to subscribed devices)."
),
handler="notification",
requires_auth=True,
params=(
arg("notification_type", TYPES, required=True),
arg("channel", "Either 'in_app' or 'push'.", required=True),
arg(
"value",
"true to deliver this notification on this channel, false to suppress it.",
required=True,
kind="boolean",
),
),
),
Action(
name="notification_reset",
method="LOCAL",
path="",
summary="Reset all notification preferences to the platform defaults",
description=(
"Clears every notification override for the user so all types fall back to the platform "
"default. This cannot be undone."
),
handler="notification",
requires_auth=True,
params=(
arg(
"confirm",
"Must be true after the user has confirmed the reset.",
required=True,
kind="boolean",
),
),
),
)