forked from retoor/devplacepy
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:
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -192,6 +192,13 @@ SYSTEM_PROMPT = (
|
||||
"everything) to restore the default look and behaviour. To hide saved customizations WITHOUT "
|
||||
"deleting them (the same toggles on the user's profile page), call customize_set_enabled with "
|
||||
"category='global' or 'pagetype' and enabled=false; enabled=true brings them back.\n\n"
|
||||
"NOTIFICATION PREFERENCES\n"
|
||||
"The signed-in user controls, per notification type, whether they are notified in-app and/or by "
|
||||
"push - the two channels are independent. Read the current settings with notification_list, then "
|
||||
"change one with notification_set (notification_type, channel='in_app' or 'push', value=true/false). "
|
||||
"notification_reset clears every override back to the platform defaults and is confirmation-gated "
|
||||
"(confirm=true). These mirror the Notifications tab on the user's profile page and are only available "
|
||||
"to signed-in users.\n\n"
|
||||
"USER-DEFINED TOOLS (VIBE TOOLS)\n"
|
||||
"The user can invent new tools for you by describing them ('when I say X, do Y'). When they do, "
|
||||
"call tool_create with a short trigger-oriented description (so you know when to use it), the "
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import NotificationController
|
||||
|
||||
__all__ = ["NotificationController"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import (
|
||||
NOTIFICATION_CHANNELS,
|
||||
get_notification_prefs,
|
||||
reset_notification_prefs,
|
||||
set_notification_pref,
|
||||
)
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.notification")
|
||||
|
||||
|
||||
class NotificationController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "notification_list":
|
||||
return self._list()
|
||||
if name == "notification_set":
|
||||
return self._set(arguments)
|
||||
if name == "notification_reset":
|
||||
return self._reset()
|
||||
raise ToolInputError(f"Unknown notification tool: {name}")
|
||||
|
||||
def _require_user(self) -> None:
|
||||
if self._owner_kind != "user":
|
||||
raise ToolInputError(
|
||||
"Notification preferences are only available for signed-in users."
|
||||
)
|
||||
|
||||
def _channel(self, arguments: dict[str, Any]) -> str:
|
||||
channel = str(arguments.get("channel", "")).strip().lower()
|
||||
if channel not in NOTIFICATION_CHANNELS:
|
||||
raise ToolInputError("'channel' must be 'in_app' or 'push'.")
|
||||
return channel
|
||||
|
||||
def _value(self, arguments: dict[str, Any]) -> bool:
|
||||
raw = arguments.get("value")
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
return str(raw).strip().lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _list(self) -> str:
|
||||
self._require_user()
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"notifications": get_notification_prefs(self._owner_id),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _set(self, arguments: dict[str, Any]) -> str:
|
||||
self._require_user()
|
||||
notification_type = str(arguments.get("notification_type", "")).strip()
|
||||
if not notification_type:
|
||||
raise ToolInputError("'notification_type' is required.")
|
||||
channel = self._channel(arguments)
|
||||
enabled = self._value(arguments)
|
||||
try:
|
||||
set_notification_pref(
|
||||
self._owner_id, notification_type, channel, enabled
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ToolInputError(str(exc))
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"notification_type": notification_type,
|
||||
"channel": channel,
|
||||
"enabled": enabled,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _reset(self) -> str:
|
||||
self._require_user()
|
||||
removed = reset_notification_prefs(self._owner_id)
|
||||
return json.dumps(
|
||||
{"status": "success", "removed": removed},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from .actions.cost_actions import COST_ACTIONS
|
||||
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
|
||||
from .actions.docs_actions import DOCS_ACTIONS
|
||||
from .actions.fetch_actions import FETCH_ACTIONS
|
||||
from .actions.notification_actions import NOTIFICATION_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
@@ -32,6 +33,7 @@ CATALOG = Catalog(
|
||||
+ CONTAINER_ACTIONS
|
||||
+ CUSTOMIZATION_ACTIONS
|
||||
+ BEHAVIOR_ACTIONS
|
||||
+ NOTIFICATION_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user