Skip to content

Commit 9210b5e

Browse files
aapelivclaude
andcommitted
Backend: Hide moderator badge behind a feature flag
Add a generic `hidden` flag to badges that excludes a badge from the GetBadges catalog, from every user's badge list, and from add/remove notifications, while still awarding and storing it. The moderator badge is marked hidden. Honoring the flag is gated on the `hide_moderator_badge` GrowthBook global gate (off by default), so the badge stays visible until the flag is turned on and can be rolled back instantly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 324cbf9 commit 9210b5e

11 files changed

Lines changed: 147 additions & 34 deletions

File tree

app/backend/resources/badges.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
{
1919
"id": "moderator",
2020
"color": "#c74f5b",
21-
"admin_editable": false
21+
"admin_editable": false,
22+
"hidden": true
2223
},
2324
{
2425
"id": "volunteer",

app/backend/src/couchers/experimentation.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,28 @@ def check_gate(context: CouchersContext, gate_name: str) -> bool:
188188
return _get_growthbook(context).is_on(gate_name)
189189

190190

191+
def check_global_gate(gate_name: str) -> bool:
192+
"""
193+
Check if a feature gate is enabled globally, independent of any user.
194+
195+
Use this for gates that toggle behavior in contexts where there is no authenticated user
196+
(background jobs, or open/anonymous endpoints where reading the user id would fail). Evaluated
197+
against a stable synthetic identity so every caller gets the same answer - intended for on/off
198+
rollouts rather than per-user percentage experiments.
199+
200+
Returns False if experimentation is disabled, True if EXPERIMENTATION_PASS_ALL_GATES is set.
201+
"""
202+
_check_initialized()
203+
if config["EXPERIMENTATION_PASS_ALL_GATES"]:
204+
return True
205+
if not config["EXPERIMENTATION_ENABLED"]:
206+
return False
207+
with _state_lock:
208+
features = _state["features"]
209+
saved_groups = _state["savedGroups"]
210+
return GrowthBook(attributes={"id": "global"}, features=features, savedGroups=saved_groups).is_on(gate_name)
211+
212+
191213
def get_feature_value[T](context: CouchersContext, feature_name: str, default: T) -> T:
192214
"""
193215
Get the value of a feature for the user in this context.

app/backend/src/couchers/helpers/badges.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from couchers.models.notifications import NotificationTopicAction
88
from couchers.notifications.notify import notify
99
from couchers.proto import notification_data_pb2
10-
from couchers.resources import get_badge_dict
10+
from couchers.resources import get_badge_dict, hidden_badges_active
1111

1212

1313
def user_add_badge(session: Session, user_id: int, badge_id: str, do_notify: bool = True) -> None:
@@ -19,7 +19,7 @@ def user_add_badge(session: Session, user_id: int, badge_id: str, do_notify: boo
1919
return
2020
session.add(UserBadge(user_id=user_id, badge_id=badge_id))
2121
session.flush()
22-
if do_notify:
22+
if do_notify and not (badge.hidden and hidden_badges_active()):
2323
context = make_background_user_context(user_id=user_id)
2424
notify(
2525
session,
@@ -39,16 +39,17 @@ def user_remove_badge(session: Session, user_id: int, badge_id: str) -> None:
3939
badge = get_badge_dict()[badge_id]
4040
session.execute(delete(UserBadge).where(UserBadge.user_id == user_id, UserBadge.badge_id == badge.id))
4141
session.flush()
42-
context = make_background_user_context(user_id=user_id)
43-
notify(
44-
session,
45-
user_id=user_id,
46-
topic_action=NotificationTopicAction.badge__remove,
47-
key=badge.id,
48-
data=notification_data_pb2.BadgeRemove(
49-
badge_id=badge.id,
50-
badge_name=context.localization.localize_string(f"badges.{badge.id}_name"),
51-
badge_description=context.localization.localize_string(f"badges.{badge.id}_description"),
52-
),
53-
)
42+
if not (badge.hidden and hidden_badges_active()):
43+
context = make_background_user_context(user_id=user_id)
44+
notify(
45+
session,
46+
user_id=user_id,
47+
topic_action=NotificationTopicAction.badge__remove,
48+
key=badge.id,
49+
data=notification_data_pb2.BadgeRemove(
50+
badge_id=badge.id,
51+
badge_name=context.localization.localize_string(f"badges.{badge.id}_name"),
52+
badge_description=context.localization.localize_string(f"badges.{badge.id}_description"),
53+
),
54+
)
5455
session.commit()

app/backend/src/couchers/resources.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
import json
33
import logging
4+
from collections.abc import Iterable
45
from dataclasses import dataclass
56
from pathlib import Path
67
from typing import Any, cast
@@ -11,6 +12,7 @@
1112

1213
from couchers.config import config
1314
from couchers.db import session_scope
15+
from couchers.experimentation import check_global_gate
1416
from couchers.models import Language, Region, TimezoneArea
1517

1618
logger = logging.getLogger(__name__)
@@ -77,6 +79,9 @@ class Badge:
7779
id: str
7880
color: str
7981
admin_editable: bool
82+
# hidden badges are still awarded and stored, but never shown to clients (no catalog entry,
83+
# not listed on user profiles, and no add/remove notification)
84+
hidden: bool = False
8085

8186

8287
@functools.cache
@@ -88,6 +93,27 @@ def get_badge_dict() -> dict[str, Badge]:
8893
return {badge.id: badge for badge in badges}
8994

9095

96+
# gate controlling the rollout of hiding hidden badges; off by default so badges stay visible
97+
HIDE_MODERATOR_BADGE_GATE = "hide_moderator_badge"
98+
99+
100+
def hidden_badges_active() -> bool:
101+
"""Whether badges marked hidden should actually be hidden, per the rollout gate."""
102+
return check_global_gate(HIDE_MODERATOR_BADGE_GATE)
103+
104+
105+
def filter_hidden_badge_ids(badge_ids: Iterable[str]) -> list[str]:
106+
"""
107+
Drop hidden badge ids from a list of badge ids, preserving order.
108+
109+
No-op while the rollout gate is off, so hidden badges stay visible until the flag is turned on.
110+
"""
111+
if not hidden_badges_active():
112+
return list(badge_ids)
113+
badge_dict = get_badge_dict()
114+
return [badge_id for badge_id in badge_ids if badge_id in badge_dict and not badge_dict[badge_id].hidden]
115+
116+
91117
@functools.cache
92118
def get_static_badge_dict() -> dict[str, list[int]]:
93119
"""

app/backend/src/couchers/servicers/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
from couchers.notifications.notify import notify
4848
from couchers.proto import admin_pb2, admin_pb2_grpc, api_pb2, notification_data_pb2
4949
from couchers.proto.internal import jobs_pb2
50-
from couchers.resources import get_badge_dict
50+
from couchers.resources import filter_hidden_badge_ids, get_badge_dict
5151
from couchers.servicers.api import user_model_to_pb
5252
from couchers.servicers.auth import create_session
5353
from couchers.servicers.events import generate_event_delete_notifications
@@ -148,7 +148,7 @@ def _user_to_details(session: Session, user: User) -> admin_pb2.UserDetails:
148148
deleted=user.deleted_at is not None,
149149
shadowed=user.shadowed_at is not None,
150150
do_not_email=user.do_not_email,
151-
badges=[badge.badge_id for badge in user.badges],
151+
badges=filter_hidden_badge_ids(badge.badge_id for badge in user.badges),
152152
**get_strong_verification_fields(session, user),
153153
has_passport_sex_gender_exception=user.has_passport_sex_gender_exception,
154154
pending_mod_notes_count=user.mod_notes.where(ModNote.is_pending).count(),

app/backend/src/couchers/servicers/api.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from couchers.proto import api_pb2, api_pb2_grpc, media_pb2, notification_data_pb2, requests_pb2
5353
from couchers.rate_limits.check import process_rate_limits_and_check_abort
5454
from couchers.rate_limits.definitions import RATE_LIMIT_HOURS
55-
from couchers.resources import get_badge_dict, language_is_allowed, region_is_allowed
55+
from couchers.resources import filter_hidden_badge_ids, get_badge_dict, language_is_allowed, region_is_allowed
5656
from couchers.servicers.blocking import is_not_visible
5757
from couchers.sql import (
5858
moderation_state_column_visible,
@@ -1159,9 +1159,11 @@ def user_model_to_pb(
11591159
avatar_url=avatar_upload.full_url if avatar_upload else None,
11601160
avatar_thumbnail_url=avatar_upload.thumbnail_url if avatar_upload else None,
11611161
profile_gallery_id=db_user.profile_gallery_id,
1162-
badges=session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1163-
.scalars()
1164-
.all(),
1162+
badges=filter_hidden_badge_ids(
1163+
session.execute(select(UserBadge.badge_id).where(UserBadge.user_id == db_user.id).order_by(UserBadge.id))
1164+
.scalars()
1165+
.all()
1166+
),
11651167
**get_strong_verification_fields(session, db_user),
11661168
**response_rate_to_pb(response_rate), # type: ignore[arg-type]
11671169
)

app/backend/src/couchers/servicers/public.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from couchers.models.uploads import get_avatar_upload
2626
from couchers.proto import api_pb2, public_pb2, public_pb2_grpc
2727
from couchers.proto.google.api import httpbody_pb2
28-
from couchers.resources import get_static_badge_dict
28+
from couchers.resources import filter_hidden_badge_ids, get_static_badge_dict
2929
from couchers.servicers.api import fluency2api, hostingstatus2api, meetupstatus2api, user_model_to_pb
3030
from couchers.servicers.gis import _statement_to_geojson_response
3131
from couchers.utils import Timestamp_from_datetime, not_none, now
@@ -222,7 +222,7 @@ def GetPublicUser(
222222
joined=Timestamp_from_datetime(user.display_joined),
223223
hosting_status=hostingstatus2api[user.hosting_status],
224224
meetup_status=meetupstatus2api[user.meetup_status],
225-
badges=[badge.badge_id for badge in user.badges],
225+
badges=filter_hidden_badge_ids(badge.badge_id for badge in user.badges),
226226
)
227227
)
228228

@@ -256,7 +256,7 @@ def GetPublicUser(
256256
regions_lived=[region.code for region in user.regions_lived],
257257
avatar_url=avatar_upload.full_url if avatar_upload else None,
258258
avatar_thumbnail_url=avatar_upload.thumbnail_url if avatar_upload else None,
259-
badges=[badge.badge_id for badge in user.badges],
259+
badges=filter_hidden_badge_ids(badge.badge_id for badge in user.badges),
260260
)
261261
)
262262
raise RuntimeError(user.public_visibility)

app/backend/src/couchers/servicers/resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
get_language_dict,
1212
get_region_dict,
1313
get_terms_of_service,
14+
hidden_badges_active,
1415
)
1516

1617
logger = logging.getLogger(__name__)
@@ -64,6 +65,7 @@ def GetLanguages(
6465
def GetBadges(
6566
self, request: empty_pb2.Empty, context: CouchersContext, session: Session
6667
) -> resources_pb2.GetBadgesRes:
68+
hide_hidden = hidden_badges_active()
6769
return resources_pb2.GetBadgesRes(
6870
badges=[
6971
resources_pb2.Badge(
@@ -73,5 +75,6 @@ def GetBadges(
7375
color=badge.color,
7476
)
7577
for badge in get_badge_dict().values()
78+
if not (hide_hidden and badge.hidden)
7679
]
7780
)

app/backend/src/tests/test_api.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from google.protobuf import empty_pb2, wrappers_pb2
66
from sqlalchemy import func, select, update
77

8+
from couchers.config import config
89
from couchers.db import session_scope
910
from couchers.helpers.badges import user_add_badge
1011
from couchers.jobs.handlers import update_badges
@@ -1573,6 +1574,68 @@ def test_badges(db):
15731574
assert res2.user_ids == [2]
15741575

15751576

1577+
def test_hidden_badge_is_awarded_but_not_shown(db):
1578+
"""
1579+
With the rollout gate on (tests pass all gates), the moderator badge is awarded to superusers
1580+
and stored, but not shown to clients or notified.
1581+
"""
1582+
# create dummies first so the superuser isn't a static founder/board_member (user ids 1, 2)
1583+
generate_user(last_donated=None)
1584+
generate_user(last_donated=None)
1585+
superuser, token = generate_user(is_superuser=True, last_donated=None)
1586+
1587+
update_badges(empty_pb2.Empty())
1588+
1589+
with session_scope() as session:
1590+
# the badge row is still created
1591+
assert (
1592+
session.execute(
1593+
select(func.count())
1594+
.select_from(UserBadge)
1595+
.where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1596+
).scalar()
1597+
== 1
1598+
)
1599+
# but the user gets no notification about it
1600+
assert (
1601+
session.execute(
1602+
select(func.count()).select_from(Notification).where(Notification.user_id == superuser.id)
1603+
).scalar()
1604+
== 0
1605+
)
1606+
1607+
# and it does not appear on the user's profile
1608+
with api_session(token) as api:
1609+
assert "moderator" not in api.GetUser(api_pb2.GetUserReq(user=superuser.username)).badges
1610+
1611+
1612+
def test_hidden_badge_is_shown_when_rollout_gate_off(db, monkeypatch):
1613+
"""With the rollout gate off, the moderator badge behaves as before: shown and notified."""
1614+
# disable experimentation so the global gate evaluates to off (rollback / pre-rollout state)
1615+
monkeypatch.setitem(config, "EXPERIMENTATION_PASS_ALL_GATES", False)
1616+
monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", False)
1617+
1618+
# create dummies first so the superuser isn't a static founder/board_member (user ids 1, 2)
1619+
generate_user(last_donated=None)
1620+
generate_user(last_donated=None)
1621+
superuser, token = generate_user(is_superuser=True, last_donated=None)
1622+
1623+
update_badges(empty_pb2.Empty())
1624+
1625+
with session_scope() as session:
1626+
# the badge add is notified
1627+
assert (
1628+
session.execute(
1629+
select(func.count()).select_from(Notification).where(Notification.user_id == superuser.id)
1630+
).scalar()
1631+
== 1
1632+
)
1633+
1634+
# and it appears on the user's profile
1635+
with api_session(token) as api:
1636+
assert "moderator" in api.GetUser(api_pb2.GetUserReq(user=superuser.username)).badges
1637+
1638+
15761639
def test_user_add_badge_is_idempotent(db):
15771640
"""Test that adding a badge a user already has is a no-op and doesn't send a duplicate notification."""
15781641
user, _ = generate_user()

app/backend/src/tests/test_resources.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,11 @@ def test_GetBadges(db):
5050
badges = api.GetBadges(empty_pb2.Empty()).badges
5151
badges_dict = {b.id: b for b in badges}
5252

53-
# Check that all expected badges are present
53+
# Check that all expected badges are present (the hidden "moderator" badge is excluded)
5454
expected_badge_ids = {
5555
"founder",
5656
"board_member",
5757
"past_board_member",
58-
"moderator",
5958
"volunteer",
6059
"past_volunteer",
6160
"donor",
@@ -72,13 +71,6 @@ def test_GetBadges(db):
7271
assert founder.description == "This user is one of the two founders of Couchers.org"
7372
assert founder.color == "#e47701"
7473

75-
# Check another badge to ensure translations are working
76-
moderator = badges_dict["moderator"]
77-
assert moderator.id == "moderator"
78-
assert moderator.name == "Moderator"
79-
assert moderator.description == "This user is a moderator of Couchers.org"
80-
assert moderator.color == "#c74f5b"
81-
8274
# Check strong_verification badge
8375
strong_verification = badges_dict["strong_verification"]
8476
assert strong_verification.id == "strong_verification"

0 commit comments

Comments
 (0)