Notifications: a new "thread" type notifies every other commenter on a
post whenever anyone comments on it, disregarding reply hierarchy -
excluding the actor and whoever already got a comment/reply
notification for that same event, so no one is double-notified.
Implemented via a background-deferred fan-out mirroring the existing
mention-notification pattern.
SEO: discussion_forum_posting() now embeds up to 20 of a post's
comments as nested schema.org Comment entities (not just an aggregate
count), and a new /topics hub plus /topics/{topic} pages give the
feed's topic filter real, independently crawlable/indexable URLs -
/feed?topic=X was never indexable since its canonical strips the
query string back to bare /feed. Both are wired end to end (schemas,
Devii actions, docs API, sitemap, locustfile load-test coverage).
Quiz player: the auto-advance to the next question used to hide the
just-answered slide in the same tick as rendering the grade, so on
any multi-question quiz the Correct/Not correct feedback was never
actually visible before the view moved on. Delayed via setTimeout,
with the pending timer cleared on manual navigation and on
disconnect so it can't race or fire on a removed component.
Also includes other local changes already in progress in this
working tree before this session (messaging, push delivery,
deepsearch jobs, game economy, quiz builder) - verified by the full
suite passing (3467 tests) but not authored or individually reviewed
in this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
24 KiB
This file documents notifications and gamification (XP/levels/badges/leaderboard), both implemented in devplacepy/utils.py. Claude Code loads it automatically whenever a file under devplacepy/utils/ is read or edited.
Notification system
Notifications are created server-side in the route handlers and stored in the notifications table. Unread counts are cached per-process in _unread_cache (notifications) and _messages_cache (messages), both 10s TTL in templating.py. Mutating routes call clear_unread_cache(uid) / clear_messages_cache(uid) to invalidate.
Live counter badges
The top-nav bell and Messages link carry data-counter="notifications" / data-counter="messages" with a child <span data-counter-badge hidden>. CounterManager.js polls GET /notifications/counts (returns {"notifications", "messages"}, zeros for guests) every 30s (via the shared Poller, pauseHidden) and on tab focus, then sets each badge's text and toggles its hidden attribute. Server-rendered counts seed the initial state, so the badges are correct on first paint and self-heal without a reload. Absolute badges use .nav-badge; inline badges (next to text links) add .nav-badge-inline.
Notification types and trigger points
| Type | Trigger | Location | Condition |
|---|---|---|---|
comment |
Top-level comment on post | comments.py |
post["user_uid"] != user["uid"] |
reply |
Reply to a comment | comments.py |
parent["user_uid"] != user["uid"] |
thread |
Any comment on a post, for every other user who has also commented on that post | content.py create_comment_record -> utils.py create_thread_notifications |
Disregards comment hierarchy (parent/child) - fires for every distinct commenter on the post except the actor and whoever already got comment/reply for this same event |
vote |
Upvote on post or comment | votes.py |
value == 1 AND voter != owner |
follow |
Follow another user | follow.py |
Always (self-follow blocked upstream) |
message |
Send a message | messages.py |
Always (different user) |
mention |
@username in content |
utils.py create_mention_notifications |
Mentioned user != actor |
level |
Reach a new level | utils.py award_xp |
new_level > current_level |
badge |
Earn any badge | utils.py notify_badge |
First grant only (award_badge returns True) |
issue |
Issue filed/replied/status | issues.py, services/gitea, services/jobs/issue_create_service.py |
Reporter or admins |
battle |
Opinion War lead change / result / fight-ready | services/opinionwar/store.py (lead change + result), services/opinionwar/service.py (cooldown sweep) |
Fighters of that war; lead change excludes the actor |
The moderation type carries both faces of the safety layer: the acknowledgement (with the published response window) sent to a reporter the moment they file, and the statement of reasons sent to a user whose content or account was actioned. Both are ordinary create_notification calls through the single funnel, so a user can turn the channel off per preference like any other type - what they cannot turn off is the decision itself.
level/badge notifications have related_uid == user_uid (self), so the notifications template renders them with the recipient's own avatar; the template is type-agnostic (driven by message/actor), so no per-type handling is needed.
thread is the flat, hierarchy-agnostic counterpart to comment/reply: after those two fire (or don't - e.g. the actor is the post owner), create_comment_record queries the post's distinct non-deleted commenters and fans out one thread notification to each, excluding the actor and anyone already notified as the post owner (comment) or the reply parent (comment/reply) for this exact event - so a given comment never double-notifies the same recipient under two types. Only target_type == "post" fires it, matching the existing comment/reply restriction (project/gist/news comments do not notify at all today). Like create_mention_notifications, the fan-out (utils.py create_thread_notifications / _deliver_thread_notifications) is deferred through background.submit, never inlined on the request path.
Auto-mark-read on content view
Beyond the explicit clears (/notifications/open/{uid}, POST /notifications/mark-read/{uid}, POST /notifications/mark-all-read), a notification is automatically marked read when the user opens the page where its referenced content is visible. The single primitive is database.mark_notifications_read_by_target(user_uid, target_url): it stamps read = 1 on every unread notification of that user whose stored target_url equals target_url OR begins with target_url + "#" (so a post-page key clears its #comment-{uid} variants), then clear_unread_cache(user_uid). It returns the count and short-circuits (no write, no cache bump) when nothing matches, so a detail view by a user with no relevant unread notifications costs only one indexed read. There is no target_type/target_uid column - the target_url string (produced by resolve_object_url) is the only content key, so callers build the page key with that same generator (or the identical literal the create-site used) to guarantee a match with zero drift.
Call sites (each only when a real user is present and the resource resolved, placed after the 404/canonical-redirect guards):
| GET handler | page key | clears |
|---|---|---|
posts.py::view_post |
resolve_object_url("post", uid) |
comment, reply, thread, vote, mention (post + its comments) |
projects/index.py::project_detail |
resolve_object_url("project", uid) |
vote, mention |
gists.py::gist_detail |
resolve_object_url("gist", uid) |
vote, mention |
news.py::news_detail_page |
resolve_object_url("news", uid) |
comment, vote, mention |
messages.py::messages_page |
/messages?with_uid={with_uid} |
message, DM mention (beside the existing mark_conversation_read) |
profile/index.py::profile_page |
/profile/{username} |
follow (follower's profile), badge/level (own profile) |
issues/index.py::issue_detail |
/issues?highlight={number} |
issue (the create-sites store the ?highlight= URL, not the /issues/{number} detail path) |
devii.py::devii_page |
/devii |
reminder |
game/index.py::game_home |
/game |
harvest_stolen |
Deliberately excluded: GET /notifications (the list must stay readable) and /feed (not a content page). Reuse mark_notifications_read_by_target for any future surface; never hand-roll a per-handler UPDATE notifications.
Time-grouped display
Notifications are grouped by time period in notifications.py _group_label():
- Same day - Today
- Previous day - Yesterday
- Within 7 days - This week
- Older - Older
The template uses notification_groups (list of {label, entries} dicts). Jinja2 note: avoid .items as a dict key - it clashes with Python's dict.items() method.
Vote notification messages
f"{user['username']} ++'d your post"
f"{user['username']} ++'d your comment"
Per-user, per-channel preferences
Every notification type is independently toggleable on three channels - in-app, push and telegram - per user, with admin-set platform defaults. This is enforced at the single funnel, so there is no per-call-site work: a new create_notification(user_uid, type, ...) call is automatically subject to preferences.
- Canonical types =
database.NOTIFICATION_TYPES(the nine listed above;key/label/description). This list drives the profile tab and the admin page; it is the single source of truth. Thetesttype is intentionally absent, so it always delivers (the resolver defaults unknown types to on). - Channels are data-driven. Adding a channel is a constant change, never scattered edits:
NOTIFICATION_CHANNELS(the tuple),_NOTIFICATION_CHANNEL_COLUMNS(channel -> table column), and_NOTIFICATION_CHANNEL_DEFAULTS(channel -> per-type fallback when no admin default row exists).in_app/pushdefault1(on);telegramdefaults0(off for every type). The four helpers (_notification_overrides,get_notification_prefs,set_notification_pref,_notification_default) iterate these maps generically, so a new channel needs only the constant entry + thenotification_preferencescolumn ininit_db. - Resolution (
database.notification_enabled(user_uid, type, channel)): a livenotification_preferencesrow for(user_uid, type)wins; else the global defaultget_int_setting("notif_default_{type}_{channel}", _NOTIFICATION_CHANNEL_DEFAULTS[channel]); else the channel fallback. So a user who never touched a setting inherits the admin default (or the channel fallback when no default row exists); a user who customized keeps their choice. - Storage: table
notification_preferences(uid,user_uid,notification_type,in_app_enabled,push_enabled,telegram_enabled, timestamps, soft-delete columns), inSOFT_DELETE_TABLES, columns+indexes ensured ininit_db. Cached under the"notif_prefs"cache-version name (sync_local_cache/bump_cache_version), keyed byuser_uid. Helpers:get_notification_prefs(user_uid)(full resolved list for the UI/JSON, one boolean per channel keyed by channel name),set_notification_pref(user_uid, type, channel, enabled)(upsert one channel, leaving the others at their resolved value; revives a soft-deleted row),reset_notification_prefs(user_uid)(soft-delete the user's overrides -> fall back to defaults),get_notification_default/set_notification_default(thenotif_default_*site_settingskeys; defaults are NOT seeded so an untouched type reads its channel fallback:1for in_app/push,0for telegram). - Enforcement (
create_notificationsingle funnel) is inutils.create_notification: the in-appnotifications.insert+clear_unread_cacherun only whennotification_enabled(..., "in_app"),_schedule_pushruns only whennotification_enabled(..., "push"), and_schedule_telegramruns only whennotification_enabled(..., "telegram"). Thenotification.createaudit row is unconditional and records which channels fired (metadata.in_app/push/telegram). Because the DM path inmessages.pynow routes throughcreate_notification(typemessage) instead of inserting directly, direct messages are gated like everything else and gained a (default-on) push channel.create_notificationis a thin wrapper that defers the whole body (preference reads + insert + push + telegram + audit) to the background task queue viabackground.submit(_deliver_notification, ...), so it never blocks the request (inline under tests); call it directly, never wrap it inbackground.submit. Seedevplacepy/services/CLAUDE.md-> Background task queue. - Telegram channel (cross-worker delivery).
_schedule_telegram(user_uid, message)callsservices/telegram/store.enqueue_outbox, which no-ops when the user is not paired (link_for_useris None) and otherwise inserts a row into thetelegram_outboxqueue (uid,user_uid,chat_id,text,statuspending|sent|failed,attempts, timestamps; transient, NOT inSOFT_DELETE_TABLES, columns+index ensured ininit_db). This is needed because_deliver_notificationruns on the background queue of any worker, but the Telegram bot worker subprocess (andTelegramService.send_markdown) lives only on the service-lock owner.services/telegram/outbox_service.pyTelegramOutboxService(lock-ownerBaseService, default-enabled, ~2s interval, registered inmain.pyafterTelegramService) drains pending rows on the lock owner: if the Telegram service is missing/disabled/not worker_alive()it returns (rows wait, nothing is lost), else it sends each viasend_markdown,mark_outbox_senton success ormark_outbox_failed(retries up toMAX_OUTBOX_ATTEMPTS=3, thenfailed), andprune_outboxclears old sent/failed rows. The DB is the cross-worker bridge here exactly as inNotificationRelayService/MessageRelay; a dedicated service (not folded into the 1s toast relay) keeps the blocking 30s-per-sendsend_markdownoff the toast path. Telegram defaults off, so absent any pairing the platform sends nothing until a paired user opts a type in. The profile Telegram column is disabled until the user pairs Telegram. - Live pairing update (pub/sub). When a user completes pairing from their phone, the bridge (
services/telegram/bridge.py_handle_unpaired) runs on the service-lock owner - exactly where every/pubsub/wssubscriber converges - so right after thetelegram.pair.successaudit it calls_publish_pairing(user_uid, True)->services.pubsub.publish(f"user.{uid}.telegram", {"paired": True})(best-effort, lazy import). No new table, no cross-worker hop. The topic is already authorized for that user byservices/pubsub/policy.py_own_namespace(user.{uid}.*). Frontend:TelegramPairingandNotificationPrefs(both built withthis.pubsubinApplication.js, afterPubSubClient) readdocument.body.dataset.userUidand subscribe touser.{uid}.telegram(theLiveNotifications/CounterManagerpattern); on{paired:true}the pairing panel flips to Connected (setPaired/hideCode/setMessage) and the notifications Telegram column enables live (dropdisabled+notif-switch-disabled, hide the hint). Cross-device unpair live update is intentionally not wired (HTTP unpair runs on any worker; same-page unpair updates locally). - UI: profile Notifications tab (
/profile/{username}?tab=notifications, owner-or-admin,POST /profile/{username}/notifications+/reset) and admin global defaults at/admin/notifications. Devii toolsnotification_list/notification_set/notification_reset(handler="notification", owner-scoped,set/resetinCONFIRM_REQUIRED). Reuse this canonical-list + resolver + override-table + single-funnel pattern for future per-user per-channel preferences.
Live delivery (toasts and unread counts)
- Live toasts ride the
in_appchannel (no new channel).services/notification_relay.pyNotificationRelayServiceis a lock-ownerBaseService(registered inmain.py, default-enabled, 1s interval) modeled onMessageRelay: it primes a watermark toMAX(notifications.id)on first tick, then each tick selectsnotifications WHERE id > watermarkand publishes{uid, type, message, target_url}to the per-recipient pub/sub topicuser.{user_uid}.notifications. It runs only on the service lock owner, which is exactly where every pub/sub WS subscriber converges (non-owner/pubsub/wscloses4013), so the in-processservices.pubsub.publishreaches the recipient's browser. Because thenotificationsrow exists only when thein_appchannel is enabled (the insert above), a live toast fires exactly for the notifications a user has enabled - the relay needs no preference lookup of its own. The watermark always advances (boot-priming + every-tick) so a newly-connected subscriber never gets a backlog flood. Frontend:base.htmlexposes the viewer uid as<body data-user-uid>,static/js/LiveNotifications.js(app.liveNotifications, constructed withthis.pubsub/this.toast) subscribes to that topic and callsapp.toast.show(message, {type:"info", ms, url}). The toast click reproduces a bell-item click exactly: it targetsGET /notifications/open/{uid}(which marks the row read and redirects to itstarget_url), not the baretarget_url- the relay publishesuidfor this.AppToastgained a generic click action via_action(options):options.onClick(a function) oroptions.url(navigate); either adds the.dp-toast-linkpointer cursor, so any caller can attach a click action. DMs toast too (typemessageis not excluded). Reuse this relay-on-the-lock-owner pattern for any future "live mirror of a DB-persisted, per-user event." - Unread-count badges ride the same relay. The same
NotificationRelayServicetick, after publishing the toast rows, also publishes the recipient's fresh unread counts{notifications, messages}touser.{uid}.counts(computed with a direct DB query on the lock owner, never the per-worker_unread_cache, which could be stale there). Because a new DM also inserts amessage-type notification row throughpersist_message -> create_notification, this one publish point covers both new notifications and new messages, so the header badge bumps instantly.static/js/CounterManager.js(app.counters, constructed withthis.pubsub) subscribes touser.{uid}.countsand applies the pushed counts; its HTTP poll of/notifications/countsstays as a 60s reconciliation fallback (covers decrements on read and any missed frame). Read events still clear the per-worker cache as before.
Gamification (XP, levels, badges, leaderboard)
The progression engine lives in utils.py and is wired into the existing content-creation, vote, and follow hooks. Do NOT scatter XP/badge logic - go through these helpers.
XP and levels (utils.py)
award_xp(user_uid, amount)- adds XP (clamped to>= 0), recomputes and storeslevel, invalidates the per-process user cache (clear_user_cache), and on level-up fires alevelnotification plus any level-milestone badge. Returns{"xp", "level", "leveled_up"}.level_for_xp(xp)->1 + max(0, xp) // LEVEL_XP(LEVEL_XP = 100).levelis stored on the user (not derived in the template) soprofile.html'sxp % 100progress bar keeps working.- XP amounts are constants in
utils.py:XP_POST=10,XP_COMMENT=2,XP_PROJECT=15,XP_GIST=5,XP_UPVOTE=5,XP_FOLLOW=5. Awards for received upvotes/followers go to the content owner / followed user and live inside the existingowner_uid != user["uid"]guards (votes.py,follow.py).
Badges (utils.py)
award_badge(user_uid, name)is idempotent (returnsTrueonly on first grant) - reuse it; never insert intobadgesdirectly.check_milestone_badges(user_uid)recomputes source-derived count thresholds (counts of existing tables, so always accurate, no tracking needed) and awards + notifies any newly crossed badge. Call it after content creation and after an upvote/follow that changes the recipient's totals. The thresholds are the data-driven_COUNT_MILESTONESlist(badge, metric_key, threshold)over metric keysposts/comments/projects/gists/stars/followers/following/streak, resolved lazily by_milestone_metrics(user_uid)(a memoized resolver that only queries a metric when an unheld badge needs it - badges already held are skipped before any query). To add a source-derived milestone, add a row to_COUNT_MILESTONESand the badge toBADGE_CATALOG. Streak badges additionally emit areward.streak.milestoneaudit event.award_rewards(user_uid, amount, first_badge=None)is the single entry point for the create/upvote/follow reward sequence: it awards the optional first-time badge, callsaward_xp, thencheck_milestone_badges. Use it instead of calling the three separately (posts/projects/gists/comments/follow/votes all go through it) so milestone checks are never skipped.award_rewardsdefers its whole body to the background task queue (background.submit(_apply_rewards, ...)), so EVERY XP award on the platform leaves the request path - the badge/XP/milestone writes (and the reward-triggered level/badge notifications nested insideaward_xp/check_milestone_badges) all run on the consumer. In tests the queue runs inline, so XP is awarded synchronously and assertions hold. Never re-wrap anaward_rewardscall inbackground.submit- it self-defers. Seedevplacepy/services/CLAUDE.md-> Background task queue.- Badge catalog + display metadata is
BADGE_CATALOGinutils.py(55 badges), exposed to templates as thebadge_info(name)global. Each entry hasicon,description, and agroup(one ofBADGE_GROUPS: First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels, Milestones). Unknown names fall back to a default icon and the name as description.LEVEL_BADGESmaps levels 5/10/25/50/100 to Level badges, awarded insideaward_xpon level-up.
Activity tracking and feature-first-use badges
- For achievements that are not derivable from an existing table (first time a user uses a feature, "read N docs", number of DeepSearches, etc.), there is a generic per-user activity counter.
database.record_activity(user_uid, action) -> intupserts auser_activitycounter (unique(user_uid, action), raw SQLON CONFLICTinsidewith db:) and returns the new count;database.record_unique_activity(user_uid, action, target) -> int | Nonerecords a distinct(user_uid, action, target)inuser_activity_seen(returns the new distinct count, orNoneif the target was already seen) for "N distinct things" achievements. Both tables + their unique indexes are ensured ininit_db(). utils.ACHIEVEMENTSmapsaction -> [(threshold, badge_name), ...](threshold 1 = first use).utils.UNIQUE_ACTIONSis the set of actions counted distinctly (currentlydocs.read).utils.track_action(user_uid, action, target=None)is the single public hook: it returns immediately if the action is unknown, elsebackground.submits_apply_achievements, which records the activity (counter or unique), then awards + notifies every crossed threshold viaaward_badge/notify_badge. It is fully deferred (off the request path) and idempotent, so call it inline at the success point of any feature.- Wired actions and their hook sites:
vote/bookmark(content.py),reaction(routers/reactions.py),follow(routers/follow.py, follower side),message(services/messaging/persist.py),docs.read(routers/docs/views.py, unique per slug, authenticated only),fork/zip(routers/projects/index.py),zip/project_file(routers/projects/files.py),seo(routers/tools/seo.py),deepsearch(routers/tools/deepsearch.py),container(services/containers/api.create_instance, user actor),upload(routers/uploads.py),issue(routers/issues/create.py),poll(routers/polls.py),profile(routers/profile/index.py, when a field is filled),devii(services/devii/session.py_run_turn, user owner on the non-docs channel only). To add a feature badge: add the badge toBADGE_CATALOG, anACHIEVEMENTSentry, and atrack_action(...)call at the feature's success point. - Profile showcase:
utils.build_achievements(held_names)returns the full catalog grouped byBADGE_GROUPSwith anearnedflag per badge andearned/totalper group. The profile route passes it asachievements(+badge_earned/badge_total) andprofile.htmlrenders a collapsible Achievements showcase (.profile-achievements, styles inprofile.css) above the tabs, so every badge - earned and locked - is discoverable.
Rank and leaderboard (database.py)
_ranked_authors()builds (and 60s-caches in_authors_cache) the full list of authors with positive total stars, ordered desc, enriched viaget_users_by_uids.get_top_authors(limit),get_leaderboard(limit, offset)(adds a 1-basedrank), andget_user_rank(user_uid)all slice/scan this one list - keep them consistent.update_target_stars()clears_authors_cacheso a vote is reflected on the leaderboard and profile rank immediately (also keeps integration tests deterministic).- The leaderboard page (
/leaderboard,routers/leaderboard.py) shows the top 50 only - no pagination (TOP_LIMIT = 50). It is public (get_current_user), highlights the current user's row (.leaderboard-row-self), and is listed insitemap.xml.
Backfill
_backfill_gamification() runs at the end of init_db(). It computes XP from prior activity (same amounts as above) for users still at the default xp=0, sets level, then runs check_milestone_badges per user. Guarded on xp=0 so it is idempotent across restarts.