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:
@@ -129,6 +129,7 @@ SOFT_DELETE_TABLES = [
|
||||
"project_forks",
|
||||
"bug_tickets",
|
||||
"bug_comment_authors",
|
||||
"notification_preferences",
|
||||
]
|
||||
|
||||
|
||||
@@ -412,6 +413,28 @@ def init_db():
|
||||
_index(db, "audit_log_links", "idx_audit_links_object", ["object_type", "object_uid"])
|
||||
_index(db, "audit_log_links", "idx_audit_links_relation", ["relation"])
|
||||
|
||||
notification_preferences = get_table("notification_preferences")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("user_uid", ""),
|
||||
("notification_type", ""),
|
||||
("in_app_enabled", 1),
|
||||
("push_enabled", 1),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not notification_preferences.has_column(column):
|
||||
notification_preferences.create_column_by_example(column, example)
|
||||
_index(db, "notification_preferences", "idx_notif_prefs_user", ["user_uid"])
|
||||
_index(
|
||||
db,
|
||||
"notification_preferences",
|
||||
"idx_notif_prefs_user_type",
|
||||
["user_uid", "notification_type"],
|
||||
)
|
||||
|
||||
for table in SOFT_DELETE_TABLES:
|
||||
ensure_soft_delete_columns(table)
|
||||
|
||||
@@ -1352,6 +1375,152 @@ def delete_custom_override(
|
||||
return int(count)
|
||||
|
||||
|
||||
NOTIFICATION_TYPES = [
|
||||
{"key": "comment", "label": "Comments", "description": "Someone comments on your post"},
|
||||
{"key": "reply", "label": "Replies", "description": "Someone replies to your comment"},
|
||||
{"key": "mention", "label": "Mentions", "description": "Someone mentions you with @username"},
|
||||
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
|
||||
{"key": "follow", "label": "Followers", "description": "Someone starts following you"},
|
||||
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
|
||||
{"key": "badge", "label": "Badges", "description": "You earn a badge"},
|
||||
{"key": "level", "label": "Level-ups", "description": "You reach a new level"},
|
||||
{"key": "bug", "label": "Bug tracker", "description": "Updates on bug reports you filed"},
|
||||
]
|
||||
|
||||
NOTIFICATION_CHANNELS = ("in_app", "push")
|
||||
_NOTIFICATION_CHANNEL_COLUMNS = {"in_app": "in_app_enabled", "push": "push_enabled"}
|
||||
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
|
||||
|
||||
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
|
||||
|
||||
|
||||
def _notification_default(notification_type: str, channel: str) -> bool:
|
||||
return get_int_setting(f"notif_default_{notification_type}_{channel}", 1) != 0
|
||||
|
||||
|
||||
def get_notification_default(notification_type: str, channel: str) -> bool:
|
||||
return _notification_default(notification_type, channel)
|
||||
|
||||
|
||||
def set_notification_default(
|
||||
notification_type: str, channel: str, enabled: bool
|
||||
) -> None:
|
||||
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
|
||||
raise ValueError(f"Unknown notification channel: {channel}")
|
||||
set_setting(f"notif_default_{notification_type}_{channel}", "1" if enabled else "0")
|
||||
|
||||
|
||||
def _notification_overrides(user_uid: str) -> dict:
|
||||
sync_local_cache("notif_prefs", _notification_prefs_cache)
|
||||
cached = _notification_prefs_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
overrides: dict = {}
|
||||
if "notification_preferences" in db.tables:
|
||||
for row in db["notification_preferences"].find(
|
||||
user_uid=user_uid, deleted_at=None
|
||||
):
|
||||
overrides[row["notification_type"]] = {
|
||||
"in_app": bool(row.get("in_app_enabled", 1)),
|
||||
"push": bool(row.get("push_enabled", 1)),
|
||||
}
|
||||
_notification_prefs_cache.set(user_uid, overrides)
|
||||
return overrides
|
||||
|
||||
|
||||
def notification_enabled(user_uid: str, notification_type: str, channel: str) -> bool:
|
||||
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
|
||||
return True
|
||||
override = _notification_overrides(user_uid).get(notification_type)
|
||||
if override is not None:
|
||||
return bool(override[channel])
|
||||
return _notification_default(notification_type, channel)
|
||||
|
||||
|
||||
def get_notification_prefs(user_uid: str) -> list:
|
||||
overrides = _notification_overrides(user_uid)
|
||||
result = []
|
||||
for entry in NOTIFICATION_TYPES:
|
||||
key = entry["key"]
|
||||
override = overrides.get(key)
|
||||
in_app = (
|
||||
bool(override["in_app"]) if override else _notification_default(key, "in_app")
|
||||
)
|
||||
push = bool(override["push"]) if override else _notification_default(key, "push")
|
||||
result.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": entry["label"],
|
||||
"description": entry["description"],
|
||||
"in_app": in_app,
|
||||
"push": push,
|
||||
"customized": override is not None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def set_notification_pref(
|
||||
user_uid: str, notification_type: str, channel: str, enabled: bool
|
||||
) -> dict:
|
||||
if notification_type not in _NOTIFICATION_TYPE_KEYS:
|
||||
raise ValueError(f"Unknown notification type: {notification_type}")
|
||||
if channel not in _NOTIFICATION_CHANNEL_COLUMNS:
|
||||
raise ValueError(f"Unknown notification channel: {channel}")
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
table = get_table("notification_preferences")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
|
||||
if existing:
|
||||
values = {
|
||||
"in_app": bool(existing.get("in_app_enabled", 1)),
|
||||
"push": bool(existing.get("push_enabled", 1)),
|
||||
}
|
||||
else:
|
||||
values = {
|
||||
"in_app": _notification_default(notification_type, "in_app"),
|
||||
"push": _notification_default(notification_type, "push"),
|
||||
}
|
||||
values[channel] = enabled
|
||||
if existing:
|
||||
record = {
|
||||
"id": existing["id"],
|
||||
"in_app_enabled": 1 if values["in_app"] else 0,
|
||||
"push_enabled": 1 if values["push"] else 0,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
table.update(record, ["id"])
|
||||
result = {**existing, **record}
|
||||
else:
|
||||
result = {
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"notification_type": notification_type,
|
||||
"in_app_enabled": 1 if values["in_app"] else 0,
|
||||
"push_enabled": 1 if values["push"] else 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
table.insert(result)
|
||||
bump_cache_version("notif_prefs")
|
||||
return result
|
||||
|
||||
|
||||
def reset_notification_prefs(user_uid: str, deleted_by: str | None = None) -> int:
|
||||
if "notification_preferences" not in db.tables:
|
||||
return 0
|
||||
count = soft_delete(
|
||||
"notification_preferences", deleted_by or f"user:{user_uid}", user_uid=user_uid
|
||||
)
|
||||
bump_cache_version("notif_prefs")
|
||||
return int(count)
|
||||
|
||||
|
||||
_stats_cache = TTLCache(ttl=30, max_size=50)
|
||||
|
||||
|
||||
|
||||
@@ -1693,6 +1693,83 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="profile-notification-toggle",
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, bug.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
field(
|
||||
"notification_type",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, bug.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"push",
|
||||
"Either in_app or push.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"1",
|
||||
"1 to deliver this notification on this channel, 0 to suppress it.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/{{ username }}?tab=notifications",
|
||||
"data": {
|
||||
"notification_type": "vote",
|
||||
"channel": "push",
|
||||
"value": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="profile-notification-reset",
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications/reset",
|
||||
title="Reset notification preferences",
|
||||
summary="Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"username",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"bob_test",
|
||||
"Profile owner. Must be yourself unless you are an admin.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/{{ username }}?tab=notifications",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="media-delete",
|
||||
method="POST",
|
||||
@@ -3878,6 +3955,51 @@ four ways to sign requests.
|
||||
"Accepts every field on the admin settings form; only non-empty values are written."
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-notification-default",
|
||||
method="POST",
|
||||
path="/admin/notifications",
|
||||
title="Set a notification default",
|
||||
summary="Set the platform-wide default for one notification type on one channel. Applies to users who have not customized that notification; explicit user choices are unaffected.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"notification_type",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, bug.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"push",
|
||||
"Either in_app or push.",
|
||||
),
|
||||
field(
|
||||
"value",
|
||||
"form",
|
||||
"boolean",
|
||||
False,
|
||||
"1",
|
||||
"1 to enable this notification by default, 0 to disable.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/admin/notifications",
|
||||
"data": {
|
||||
"notification_type": "vote",
|
||||
"channel": "push",
|
||||
"value": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-user-reset-ai-quota",
|
||||
method="POST",
|
||||
|
||||
@@ -212,6 +212,18 @@ class CustomizationToggleForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
|
||||
class NotificationPrefForm(BaseModel):
|
||||
notification_type: str = Field(min_length=1, max_length=40)
|
||||
channel: Literal["in_app", "push"]
|
||||
value: bool = False
|
||||
|
||||
|
||||
class NotificationDefaultForm(BaseModel):
|
||||
notification_type: str = Field(min_length=1, max_length=40)
|
||||
channel: Literal["in_app", "push"]
|
||||
value: bool = False
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
|
||||
from devplacepy.models import (
|
||||
AdminRoleForm,
|
||||
AdminPasswordForm,
|
||||
AdminSettingsForm,
|
||||
NotificationDefaultForm,
|
||||
)
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
@@ -25,6 +30,10 @@ from devplacepy.database import (
|
||||
resolve_object_url,
|
||||
SOFT_DELETE_TABLES,
|
||||
_now_iso,
|
||||
NOTIFICATION_TYPES,
|
||||
NOTIFICATION_CHANNELS,
|
||||
get_notification_default,
|
||||
set_notification_default,
|
||||
)
|
||||
from devplacepy.attachments import delete_attachment, _unlink_attachment_files
|
||||
from devplacepy.project_files import _unlink_blob
|
||||
@@ -44,6 +53,7 @@ from devplacepy.schemas import (
|
||||
AdminUsersOut,
|
||||
AdminNewsOut,
|
||||
AdminSettingsOut,
|
||||
AdminNotificationsOut,
|
||||
AdminMediaOut,
|
||||
AdminTrashOut,
|
||||
GatewayUsageOut,
|
||||
@@ -561,6 +571,91 @@ async def admin_settings(request: Request):
|
||||
)
|
||||
|
||||
|
||||
def _notification_defaults_view() -> list:
|
||||
defaults = []
|
||||
for entry in NOTIFICATION_TYPES:
|
||||
defaults.append(
|
||||
{
|
||||
"key": entry["key"],
|
||||
"label": entry["label"],
|
||||
"description": entry["description"],
|
||||
"in_app": get_notification_default(entry["key"], "in_app"),
|
||||
"push": get_notification_default(entry["key"], "push"),
|
||||
}
|
||||
)
|
||||
return defaults
|
||||
|
||||
|
||||
@router.get("/notifications", response_class=HTMLResponse)
|
||||
async def admin_notifications(request: Request):
|
||||
admin = require_admin(request)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Notifications - Admin",
|
||||
description="Manage default notification settings for all users.",
|
||||
robots="noindex,nofollow",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Notifications", "url": "/admin/notifications"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_notifications.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"notification_defaults": _notification_defaults_view(),
|
||||
"admin_section": "notifications",
|
||||
},
|
||||
model=AdminNotificationsOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notifications")
|
||||
async def admin_set_notification_default(
|
||||
request: Request, data: Annotated[NotificationDefaultForm, Form()]
|
||||
):
|
||||
admin = require_admin(request)
|
||||
valid_types = {entry["key"] for entry in NOTIFICATION_TYPES}
|
||||
if data.notification_type not in valid_types or data.channel not in NOTIFICATION_CHANNELS:
|
||||
from devplacepy.responses import json_error
|
||||
|
||||
return json_error(400, "Unknown notification type or channel")
|
||||
set_notification_default(data.notification_type, data.channel, data.value)
|
||||
logger.info(
|
||||
f"Admin {admin['username']} set default {data.notification_type}/{data.channel} "
|
||||
f"to {'on' if data.value else 'off'}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.notification.default",
|
||||
user=admin,
|
||||
target_type="setting",
|
||||
target_uid=f"notif_default_{data.notification_type}_{data.channel}",
|
||||
old_value=0 if data.value else 1,
|
||||
new_value=1 if data.value else 0,
|
||||
summary=(
|
||||
f"admin {admin['username']} set default {data.channel} "
|
||||
f"{data.notification_type} notifications to {'on' if data.value else 'off'}"
|
||||
),
|
||||
metadata={"type": data.notification_type, "channel": data.channel},
|
||||
)
|
||||
return action_result(
|
||||
request,
|
||||
"/admin/notifications",
|
||||
data={
|
||||
"notification_type": data.notification_type,
|
||||
"channel": data.channel,
|
||||
"value": data.value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-log", response_class=HTMLResponse)
|
||||
async def admin_audit_log(
|
||||
request: Request,
|
||||
|
||||
@@ -47,6 +47,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "notification-settings",
|
||||
"title": "Notification settings",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Maintenance agents - the self-maintaining quality fleet (public)
|
||||
{
|
||||
"slug": "maintenance-agents",
|
||||
|
||||
@@ -8,12 +8,13 @@ from devplacepy.models import MessageForm
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import get_table, db
|
||||
from devplacepy.attachments import get_attachments_batch
|
||||
from devplacepy.templating import clear_unread_cache, clear_messages_cache
|
||||
from devplacepy.templating import clear_messages_cache
|
||||
from devplacepy.utils import (
|
||||
generate_uid,
|
||||
require_user,
|
||||
time_ago,
|
||||
create_mention_notifications,
|
||||
create_notification,
|
||||
)
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
@@ -212,20 +213,13 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
|
||||
link_attachments(data.attachment_uids, "message", msg_uid)
|
||||
|
||||
if user["uid"] != receiver_uid:
|
||||
notifications = get_table("notifications")
|
||||
notifications.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": receiver_uid,
|
||||
"type": "message",
|
||||
"message": f"{user['username']} sent you a message",
|
||||
"related_uid": user["uid"],
|
||||
"target_url": f"/messages?with_uid={user['uid']}",
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
create_notification(
|
||||
receiver_uid,
|
||||
"message",
|
||||
f"{user['username']} sent you a message",
|
||||
user["uid"],
|
||||
f"/messages?with_uid={user['uid']}",
|
||||
)
|
||||
clear_unread_cache(receiver_uid)
|
||||
clear_messages_cache(receiver_uid)
|
||||
|
||||
create_mention_notifications(
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from devplacepy.models import ProfileForm, CustomizationToggleForm
|
||||
from devplacepy.models import (
|
||||
ProfileForm,
|
||||
CustomizationToggleForm,
|
||||
NotificationPrefForm,
|
||||
)
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
db,
|
||||
get_customization_prefs,
|
||||
set_customization_pref,
|
||||
get_notification_prefs,
|
||||
set_notification_pref,
|
||||
reset_notification_prefs,
|
||||
get_user_stars,
|
||||
get_user_rank,
|
||||
get_comment_counts_by_post_uids,
|
||||
@@ -274,6 +281,11 @@ async def profile_page(
|
||||
if can_manage_customization
|
||||
else {"disable_global": False, "disable_pagetype": False}
|
||||
)
|
||||
notification_prefs = (
|
||||
get_notification_prefs(profile_user["uid"])
|
||||
if (tab == "notifications" and can_manage_customization)
|
||||
else []
|
||||
)
|
||||
|
||||
base = site_url(request)
|
||||
robots = "noindex,follow" if posts_count < 2 else "index,follow"
|
||||
@@ -326,6 +338,7 @@ async def profile_page(
|
||||
"can_manage_customization": can_manage_customization,
|
||||
"cust_disable_global": customization_prefs["disable_global"],
|
||||
"cust_disable_pagetype": customization_prefs["disable_pagetype"],
|
||||
"notification_prefs": notification_prefs,
|
||||
"ai_quota": ai_quota,
|
||||
"activities": activities,
|
||||
"rank": rank,
|
||||
@@ -450,3 +463,74 @@ async def set_customization_pagetype(
|
||||
data: Annotated[CustomizationToggleForm, Form()],
|
||||
):
|
||||
return _toggle_customization(request, username, "pagetype", data.value)
|
||||
|
||||
|
||||
@router.post("/{username}/notifications")
|
||||
async def set_notification_preference(
|
||||
request: Request,
|
||||
username: str,
|
||||
data: Annotated[NotificationPrefForm, Form()],
|
||||
):
|
||||
target, denied = _resolve_customization_target(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
try:
|
||||
set_notification_pref(
|
||||
target["uid"], data.notification_type, data.channel, data.value
|
||||
)
|
||||
except ValueError:
|
||||
from devplacepy.responses import json_error
|
||||
|
||||
return json_error(400, "Unknown notification type or channel")
|
||||
logger.info(
|
||||
f"Notification {data.notification_type}/{data.channel} "
|
||||
f"{'enabled' if data.value else 'disabled'} for {target['username']}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"profile.notification.toggle",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
old_value=0 if data.value else 1,
|
||||
new_value=1 if data.value else 0,
|
||||
summary=(
|
||||
f"{'enabled' if data.value else 'disabled'} {data.channel} "
|
||||
f"{data.notification_type} notifications for {target['username']}"
|
||||
),
|
||||
metadata={"type": data.notification_type, "channel": data.channel},
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}?tab=notifications"
|
||||
return action_result(
|
||||
request,
|
||||
url,
|
||||
data={
|
||||
"url": url,
|
||||
"notification_type": data.notification_type,
|
||||
"channel": data.channel,
|
||||
"value": data.value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{username}/notifications/reset")
|
||||
async def reset_notification_preferences(request: Request, username: str):
|
||||
target, denied = _resolve_customization_target(request, username)
|
||||
if denied is not None:
|
||||
return denied
|
||||
removed = reset_notification_prefs(target["uid"])
|
||||
logger.info(
|
||||
f"Notification preferences reset for {target['username']} ({removed} rows)"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"profile.notification.reset",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
summary=f"reset notification preferences for {target['username']}",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}?tab=notifications"
|
||||
return action_result(request, url, data={"url": url})
|
||||
|
||||
@@ -564,6 +564,7 @@ class ProfileOut(_Out):
|
||||
viewer_is_admin: bool = False
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
notification_prefs: list[Any] = []
|
||||
|
||||
|
||||
class MessagesOut(_Out):
|
||||
@@ -620,6 +621,11 @@ class AdminSettingsOut(_Out):
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminNotificationsOut(_Out):
|
||||
notification_defaults: list[Any] = []
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaItemOut(MediaItemOut):
|
||||
deleted_at: Optional[str] = None
|
||||
uploader: Optional[str] = None
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -613,3 +613,108 @@ a.profile-stat-value:hover {
|
||||
.ai-usage-table td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.notif-prefs {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.notif-prefs-intro {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.notif-prefs-intro p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.notif-prefs-admin {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notif-prefs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.notif-prefs-table th,
|
||||
.notif-prefs-table td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.notif-prefs-table th {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.notif-prefs-col {
|
||||
width: 5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-prefs-label {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notif-prefs-desc {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.notif-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notif-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.notif-switch-track {
|
||||
position: relative;
|
||||
width: 2.4rem;
|
||||
height: 1.3rem;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.notif-switch-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.notif-switch input:checked + .notif-switch-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.notif-switch input:checked + .notif-switch-track::after {
|
||||
transform: translateX(1.1rem);
|
||||
}
|
||||
|
||||
.notif-switch input:disabled + .notif-switch-track {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.notif-prefs-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class AdminNotificationDefaults {
|
||||
constructor() {
|
||||
this.root = document.querySelector("[data-admin-notif]");
|
||||
if (!this.root) return;
|
||||
this.root
|
||||
.querySelectorAll("[data-notif-toggle]")
|
||||
.forEach((input) => this.bind(input));
|
||||
}
|
||||
|
||||
bind(input) {
|
||||
input.addEventListener("change", async () => {
|
||||
const desired = input.checked;
|
||||
input.disabled = true;
|
||||
try {
|
||||
await Http.send("/admin/notifications", {
|
||||
notification_type: input.dataset.type,
|
||||
channel: input.dataset.channel,
|
||||
value: desired ? "true" : "false",
|
||||
});
|
||||
this.flash(desired ? "Default enabled" : "Default disabled", "success");
|
||||
} catch (error) {
|
||||
input.checked = !desired;
|
||||
this.flash("Could not save default", "error");
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
flash(message, type) {
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show(message, { type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.AdminNotificationDefaults = AdminNotificationDefaults;
|
||||
export { AdminNotificationDefaults };
|
||||
@@ -20,6 +20,8 @@ import { BookmarkManager } from "./BookmarkManager.js";
|
||||
import { PollManager } from "./PollManager.js";
|
||||
import { ApiKeyManager } from "./ApiKeyManager.js";
|
||||
import { CustomizationToggle } from "./CustomizationToggle.js";
|
||||
import { NotificationPrefs } from "./NotificationPrefs.js";
|
||||
import { AdminNotificationDefaults } from "./AdminNotificationDefaults.js";
|
||||
import { UserAiUsage } from "./UserAiUsage.js";
|
||||
import { Tabs } from "./Tabs.js";
|
||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
@@ -56,6 +58,8 @@ class Application {
|
||||
this.polls = new PollManager();
|
||||
this.apiKey = new ApiKeyManager();
|
||||
this.customizationToggle = new CustomizationToggle();
|
||||
this.notificationPrefs = new NotificationPrefs();
|
||||
this.adminNotificationDefaults = new AdminNotificationDefaults();
|
||||
this.userAiUsage = new UserAiUsage();
|
||||
this.tabs = new Tabs();
|
||||
this.windows = new WindowManager();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
class NotificationPrefs {
|
||||
constructor() {
|
||||
this.root = document.querySelector("[data-notif-prefs]");
|
||||
if (!this.root) return;
|
||||
this.username = this.root.dataset.username;
|
||||
this.root
|
||||
.querySelectorAll("[data-notif-toggle]")
|
||||
.forEach((input) => this.bind(input));
|
||||
const reset = this.root.querySelector("[data-notif-reset]");
|
||||
if (reset) reset.addEventListener("click", () => this.reset(reset));
|
||||
}
|
||||
|
||||
bind(input) {
|
||||
input.addEventListener("change", async () => {
|
||||
const desired = input.checked;
|
||||
input.disabled = true;
|
||||
try {
|
||||
await Http.send(`/profile/${this.username}/notifications`, {
|
||||
notification_type: input.dataset.type,
|
||||
channel: input.dataset.channel,
|
||||
value: desired ? "true" : "false",
|
||||
});
|
||||
this.flash(desired ? "Saved" : "Disabled", "success");
|
||||
} catch (error) {
|
||||
input.checked = !desired;
|
||||
this.flash("Could not save notification setting", "error");
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async reset(button) {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await Http.send(`/profile/${this.username}/notifications/reset`, {});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
this.flash("Could not reset notifications", "error");
|
||||
}
|
||||
}
|
||||
|
||||
flash(message, type) {
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show(message, { type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NotificationPrefs = NotificationPrefs;
|
||||
export { NotificationPrefs };
|
||||
@@ -32,6 +32,9 @@
|
||||
<a href="/admin/audit-log" class="sidebar-link {% if admin_section == 'audit-log' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📋</span> Audit Log
|
||||
</a>
|
||||
<a href="/admin/notifications" class="sidebar-link {% if admin_section == 'notifications' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🔔</span> Notifications
|
||||
</a>
|
||||
<a href="/admin/settings" class="sidebar-link {% if admin_section == 'settings' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🔧</span> Settings
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>Notification defaults</h2>
|
||||
</div>
|
||||
<div class="notif-prefs" data-admin-notif>
|
||||
<div class="notif-prefs-intro">
|
||||
<p>These are the platform-wide defaults applied to every user who has not customized a notification on their own profile. Changing a default does not override a user's explicit choice.</p>
|
||||
</div>
|
||||
<table class="notif-prefs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Notification</th>
|
||||
<th class="notif-prefs-col">In-app</th>
|
||||
<th class="notif-prefs-col">Push</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pref in notification_defaults %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="notif-prefs-label">{{ pref.label }}</span>
|
||||
<span class="notif-prefs-desc">{{ pref.description }}</span>
|
||||
</td>
|
||||
<td class="notif-prefs-col">
|
||||
<label class="notif-switch">
|
||||
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="in_app" {% if pref.in_app %}checked{% endif %}>
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="notif-prefs-col">
|
||||
<label class="notif-switch">
|
||||
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="push" {% if pref.push %}checked{% endif %}>
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% set uname = user.get('username') if user else 'YOUR_USERNAME' %}
|
||||
<div class="docs-content" data-render>
|
||||
# Notification settings
|
||||
|
||||
DevPlace notifies you when things happen that involve you: someone comments on your post, replies to
|
||||
your comment, mentions you, upvotes your work, follows you, sends you a direct message, or when you
|
||||
earn a badge, level up, or get an update on a bug you filed. You decide which of these reach you, and
|
||||
how.
|
||||
|
||||
Each notification type can be delivered on two independent channels:
|
||||
|
||||
- **In-app** - the notification appears on DevPlace (the bell in the top navigation and the
|
||||
`/notifications` page).
|
||||
- **Push** - a native web push notification is sent to the devices where you enabled push.
|
||||
|
||||
The two channels are separate: you can keep a type in-app but silence its push, or the other way
|
||||
around.
|
||||
|
||||
## Where to find it
|
||||
|
||||
Open your profile and choose the **Notifications** tab, or go straight to
|
||||
`/profile/{{ uname }}?tab=notifications`. The tab is private: only you (and administrators) can see or
|
||||
change your settings.
|
||||
|
||||
You get one row per notification type with two checkboxes, **In-app** and **Push**. Tick or untick a
|
||||
box and it saves immediately - there is no separate save button.
|
||||
|
||||
## What each type covers
|
||||
|
||||
| Type | Fires when |
|
||||
|------|------------|
|
||||
| Comments | someone comments on your post |
|
||||
| Replies | someone replies to your comment |
|
||||
| Mentions | someone mentions you with `@username` |
|
||||
| Upvotes | someone `++`'d your post, comment, project, or gist |
|
||||
| Followers | someone starts following you |
|
||||
| Direct messages | someone sends you a message |
|
||||
| Badges | you earn a badge |
|
||||
| Level-ups | you reach a new level |
|
||||
| Bug tracker | there is an update on a bug report you filed |
|
||||
|
||||
## Defaults
|
||||
|
||||
Every type and channel is **on** until you turn it off, so notifications work out of the box. A type
|
||||
you have never changed follows the platform default; once you tick or untick a box, your choice is
|
||||
remembered and is no longer affected by later changes to the default.
|
||||
|
||||
Use **Reset to defaults** at the bottom of the tab to clear all of your choices at once and go back to
|
||||
the platform defaults.
|
||||
|
||||
## Turning push on
|
||||
|
||||
Toggling **Push** for a type only has an effect once you have enabled push notifications on the
|
||||
device, which you do with the bell-with-slash button in the top navigation or on the `/notifications`
|
||||
page. Until then, push has nowhere to be delivered. See
|
||||
[Push notifications](/docs/push.html) for the device-side setup.
|
||||
|
||||
## Ask Devii
|
||||
|
||||
You can change these settings in plain language through the Devii assistant, for example:
|
||||
|
||||
> Turn off push notifications for upvotes.
|
||||
|
||||
> Stop notifying me about new followers entirely.
|
||||
|
||||
Devii reads your current settings, makes the change, and can reset everything back to the defaults
|
||||
(it asks you to confirm a reset first, since that clears all of your choices).
|
||||
</div>
|
||||
|
||||
<div class="devii-doc-cta">
|
||||
{% if is_admin(user) %}
|
||||
<a href="/admin/notifications" class="sidebar-link">Notification defaults (admin)</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -260,6 +260,9 @@
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">📊</span> Activity</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=followers" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}"><span class="icon">👥</span> Followers ({{ followers_count }})</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=following" class="profile-tab {% if current_tab == 'following' %}active{% endif %}"><span class="icon">👤</span> Following ({{ following_count }})</a>
|
||||
{% if is_owner or viewer_is_admin %}
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=notifications" class="profile-tab {% if current_tab == 'notifications' %}active{% endif %}"><span class="icon">🔔</span> Notifications</a>
|
||||
{% endif %}
|
||||
<button class="btn-ghost btn-icon profile-tab-btn">△</button>
|
||||
</div>
|
||||
|
||||
@@ -352,6 +355,51 @@
|
||||
{% if follow_pagination.has_next %}<a class="btn btn-ghost btn-sm" href="/profile/{{ profile_user['username'] }}?tab={{ current_tab }}&page={{ follow_pagination.next_page }}">Next</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif current_tab == 'notifications' %}
|
||||
{% if is_owner or viewer_is_admin %}
|
||||
<div class="notif-prefs" data-notif-prefs data-username="{{ profile_user['username'] }}">
|
||||
<div class="notif-prefs-intro">
|
||||
{% if not is_owner %}<p class="notif-prefs-admin">Editing notification settings for <strong>{{ profile_user['username'] }}</strong>.</p>{% endif %}
|
||||
<p>Choose how you want to be notified for each event. <strong>In-app</strong> shows the notification on DevPlace; <strong>Push</strong> sends it to your subscribed devices.</p>
|
||||
</div>
|
||||
<table class="notif-prefs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Notification</th>
|
||||
<th class="notif-prefs-col">In-app</th>
|
||||
<th class="notif-prefs-col">Push</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pref in notification_prefs %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="notif-prefs-label">{{ pref.label }}</span>
|
||||
<span class="notif-prefs-desc">{{ pref.description }}</span>
|
||||
</td>
|
||||
<td class="notif-prefs-col">
|
||||
<label class="notif-switch">
|
||||
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="in_app" {% if pref.in_app %}checked{% endif %}>
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="notif-prefs-col">
|
||||
<label class="notif-switch">
|
||||
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="push" {% if pref.push %}checked{% endif %}>
|
||||
<span class="notif-switch-track"></span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="notif-prefs-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm" data-notif-reset>Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">Not available.</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% for act in activities %}
|
||||
<div class="post-card activity-card">
|
||||
|
||||
+20
-15
@@ -17,6 +17,7 @@ from devplacepy.database import (
|
||||
get_user_stars,
|
||||
bump_cache_version,
|
||||
sync_local_cache,
|
||||
notification_enabled,
|
||||
)
|
||||
from devplacepy.config import SESSION_MAX_AGE
|
||||
|
||||
@@ -349,20 +350,24 @@ def create_notification(
|
||||
) -> None:
|
||||
from devplacepy.templating import clear_unread_cache
|
||||
|
||||
get_table("notifications").insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"type": notification_type,
|
||||
"message": message,
|
||||
"related_uid": related_uid,
|
||||
"target_url": target_url,
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
clear_unread_cache(user_uid)
|
||||
_schedule_push(user_uid, message, target_url)
|
||||
in_app = notification_enabled(user_uid, notification_type, "in_app")
|
||||
push = notification_enabled(user_uid, notification_type, "push")
|
||||
if in_app:
|
||||
get_table("notifications").insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user_uid,
|
||||
"type": notification_type,
|
||||
"message": message,
|
||||
"related_uid": related_uid,
|
||||
"target_url": target_url,
|
||||
"read": False,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
clear_unread_cache(user_uid)
|
||||
if push:
|
||||
_schedule_push(user_uid, message, target_url)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
@@ -371,7 +376,7 @@ def create_notification(
|
||||
target_type="notification",
|
||||
target_uid=related_uid,
|
||||
summary=f"notification created: {message}",
|
||||
metadata={"type": notification_type},
|
||||
metadata={"type": notification_type, "in_app": in_app, "push": push},
|
||||
links=[
|
||||
audit.recipient(user_uid),
|
||||
audit.link("source", "notification", related_uid),
|
||||
|
||||
Reference in New Issue
Block a user