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"] |
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 |
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.
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, 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.