Skip to content

Commit c803f4a

Browse files
authored
Merge pull request #8757 from Couchers-org/backend/feature/hide-moderator-badge
Backend: Hide moderator badge behind a feature flag
2 parents a95378c + 185934e commit c803f4a

7 files changed

Lines changed: 133 additions & 20 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+
"flag": "show_moderator_badge"
2223
},
2324
{
2425
"id": "volunteer",

app/backend/src/couchers/context.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import grpc
44

55
from couchers import experimentation
6-
from couchers.config import config
76
from couchers.i18n import LocalizationContext
87

98
if TYPE_CHECKING:
@@ -181,29 +180,23 @@ def token(self) -> str:
181180
def localization(self) -> LocalizationContext:
182181
return self.__localization
183182

184-
# Feature-flag evaluation methods mirror the OpenFeature evaluation API. The in-code default is
185-
# honored even for flags not yet set up in GrowthBook, since get_feature_value falls back to it.
183+
# Feature-flag evaluation methods mirror the OpenFeature evaluation API, evaluating for this
184+
# context's user. The gating lives in experimentation; we just pass our cached per-request
185+
# evaluator. The in-code default is honored even for flags not yet set up in GrowthBook.
186186
def get_boolean_value(self, flag_key: str, default: bool) -> bool:
187-
if config["EXPERIMENTATION_PASS_ALL_GATES"]:
188-
return True
189-
return self._get_feature_value(flag_key, default)
187+
return experimentation._boolean_value(flag_key, default, self._get_growthbook)
190188

191189
def get_string_value(self, flag_key: str, default: str) -> str:
192-
return self._get_feature_value(flag_key, default)
190+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
193191

194192
def get_integer_value(self, flag_key: str, default: int) -> int:
195-
return self._get_feature_value(flag_key, default)
193+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
196194

197195
def get_float_value(self, flag_key: str, default: float) -> float:
198-
return self._get_feature_value(flag_key, default)
196+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
199197

200198
def get_object_value[T](self, flag_key: str, default: T) -> T:
201-
return self._get_feature_value(flag_key, default)
202-
203-
def _get_feature_value[T](self, flag_key: str, default: T) -> T:
204-
if not config["EXPERIMENTATION_ENABLED"]:
205-
return default
206-
return self._get_growthbook().get_feature_value(flag_key, default) # type: ignore[no-any-return]
199+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
207200

208201
def _get_growthbook(self) -> GrowthBook:
209202
if self._growthbook is None:

app/backend/src/couchers/experimentation.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,23 @@
33
44
Uses GrowthBook under the hood, but abstracts the implementation details.
55
6-
Don't evaluate flags by calling into this module directly - go through the CouchersContext methods
7-
(context.get_boolean_value, get_string_value, etc.), which own the per-request evaluator cache and
8-
the enabled / pass-all-gates gating. The underscore-prefixed helpers here are internal to that
9-
wiring; setup_experimentation() is the only public entry point, called once at process startup.
6+
Two ways to evaluate a flag:
7+
- Per-user/request: use the CouchersContext methods (context.get_boolean_value, get_string_value,
8+
etc.), which evaluate for the context's user and own the per-request evaluator cache.
9+
- Global (no user/request): use the module-level get_global_boolean_value / get_global_string_value
10+
/ ... below. Use these ONLY when there is genuinely no user to evaluate for and no way to thread
11+
one through - per-user evaluation is impossible here, not merely that you don't expect the value
12+
to vary per user. Whenever a user is (or could reasonably be) available, use the context: only the
13+
per-user path can do percentage rollouts, experiments, and feature-usage tracking.
14+
15+
Both paths share the enabled / pass-all-gates gating helpers here. setup_experimentation() is called
16+
once at process startup.
1017
"""
1118

1219
import json
1320
import logging
1421
import threading
22+
from collections.abc import Callable
1523
from typing import Any
1624

1725
import urllib3
@@ -180,3 +188,49 @@ def on_feature_usage(key: str, result: FeatureResult, *args: Any, **kwargs: Any)
180188
on_experiment_viewed=on_experiment_viewed,
181189
on_feature_usage=on_feature_usage,
182190
)
191+
192+
193+
def _global_evaluator() -> GrowthBook:
194+
"""Build an anonymous evaluator for flag evaluation with no user/request context."""
195+
return _create_evaluator(None)
196+
197+
198+
# These two helpers are the single home of the gating logic, shared by the global functions below
199+
# and by CouchersContext (which passes its own cached per-request evaluator). get_evaluator is only
200+
# invoked once gating passes, so it stays lazy.
201+
def _feature_value[T](flag_key: str, default: T, get_evaluator: Callable[[], GrowthBook]) -> T:
202+
if not config["EXPERIMENTATION_ENABLED"]:
203+
return default
204+
return get_evaluator().get_feature_value(flag_key, default) # type: ignore[no-any-return]
205+
206+
207+
def _boolean_value(flag_key: str, default: bool, get_evaluator: Callable[[], GrowthBook]) -> bool:
208+
if config["EXPERIMENTATION_PASS_ALL_GATES"]:
209+
return True
210+
return _feature_value(flag_key, default, get_evaluator)
211+
212+
213+
# Global (no-user) flag evaluation. Use these ONLY when there is genuinely no user to evaluate for and
214+
# no way to thread one through - per-user evaluation is impossible here, not merely that you don't
215+
# expect the value to vary per user. If a user is (or could reasonably be) available, use the
216+
# CouchersContext methods instead: only the per-user path does percentage rollouts, experiments, and
217+
# feature-usage tracking. With no user to bucket, rollouts and experiments are skipped and flags fall
218+
# through to their in-code defaults unless a rule forces a value globally.
219+
def get_global_boolean_value(flag_key: str, default: bool) -> bool:
220+
return _boolean_value(flag_key, default, _global_evaluator)
221+
222+
223+
def get_global_string_value(flag_key: str, default: str) -> str:
224+
return _feature_value(flag_key, default, _global_evaluator)
225+
226+
227+
def get_global_integer_value(flag_key: str, default: int) -> int:
228+
return _feature_value(flag_key, default, _global_evaluator)
229+
230+
231+
def get_global_float_value(flag_key: str, default: float) -> float:
232+
return _feature_value(flag_key, default, _global_evaluator)
233+
234+
235+
def get_global_object_value[T](flag_key: str, default: T) -> T:
236+
return _feature_value(flag_key, default, _global_evaluator)

app/backend/src/couchers/jobs/handlers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
update,
3030
)
3131

32+
from couchers import experimentation
3233
from couchers.config import config
3334
from couchers.constants import (
3435
ACTIVENESS_PROBE_EXPIRY_TIME,
@@ -838,6 +839,9 @@ def update_badges(payload: empty_pb2.Empty) -> None:
838839

839840
def update_badge(badge_id: str, members: Sequence[int]) -> None:
840841
badge = get_badge_dict()[badge_id]
842+
# this batch job has no per-user context to evaluate the gate against, so it's global
843+
if badge.flag is not None and not experimentation.get_global_boolean_value(badge.flag, default=True):
844+
members = []
841845
user_ids = session.execute(select(UserBadge.user_id).where(UserBadge.badge_id == badge.id)).scalars().all()
842846
# in case the user ids don't exist in the db
843847
actual_members = session.execute(select(User.id).where(User.id.in_(members))).scalars().all()

app/backend/src/couchers/resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class Badge:
7777
id: str
7878
color: str
7979
admin_editable: bool
80+
# if set, the badge is only awarded while this feature flag is on (the flag defaults to on, so
81+
# the badge keeps being awarded until the flag is turned off)
82+
flag: str | None = None
8083

8184

8285
@functools.cache

app/backend/src/tests/test_bg_jobs.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy.sql import delete, func
1111

1212
import couchers.jobs.worker
13+
from couchers import experimentation
1314
from couchers.config import config
1415
from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL
1516
from couchers.crypto import urlsafe_secure_token
@@ -1342,6 +1343,50 @@ def test_update_badges(db, push_collector: PushCollector):
13421343
assert push.content.body == "The Verified Phone badge was added to your profile."
13431344

13441345

1346+
def test_update_badges_awards_moderator_to_superuser(db):
1347+
"""The show_moderator_badge flag defaults on, so superusers are awarded the moderator badge."""
1348+
superuser, _ = generate_user(is_superuser=True, last_donated=None)
1349+
1350+
update_badges(empty_pb2.Empty())
1351+
1352+
with session_scope() as session:
1353+
assert (
1354+
session.execute(
1355+
select(func.count())
1356+
.select_from(UserBadge)
1357+
.where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1358+
).scalar()
1359+
== 1
1360+
)
1361+
1362+
1363+
def test_update_badges_skips_moderator_when_flag_off(db, monkeypatch):
1364+
"""With show_moderator_badge forced off, superusers are not awarded the moderator badge."""
1365+
# force show_moderator_badge off for everyone (force rule with no coverage applies globally)
1366+
monkeypatch.setattr(experimentation, "_initialized", True)
1367+
monkeypatch.setattr(
1368+
experimentation,
1369+
"_state",
1370+
{"features": {"show_moderator_badge": {"defaultValue": True, "rules": [{"force": False}]}}, "savedGroups": {}},
1371+
)
1372+
monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", True)
1373+
monkeypatch.setitem(config, "EXPERIMENTATION_PASS_ALL_GATES", False)
1374+
1375+
superuser, _ = generate_user(is_superuser=True, last_donated=None)
1376+
1377+
update_badges(empty_pb2.Empty())
1378+
1379+
with session_scope() as session:
1380+
assert (
1381+
session.execute(
1382+
select(func.count())
1383+
.select_from(UserBadge)
1384+
.where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1385+
).scalar()
1386+
== 0
1387+
)
1388+
1389+
13451390
def test_send_request_notifications_blocked_users_no_notification(db, moderator):
13461391
"""
13471392
Regression test: send_request_notifications should not send notifications

app/backend/src/tests/test_experimentation.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,16 @@ def test_record_feature_usage_none_value(db):
9696
rows = _get_usage(session, 1)
9797
assert len(rows) == 1
9898
assert rows[0].value is None
99+
100+
101+
def test_global_evaluation_excluded_from_rollout_gets_feature_default(experimentation_snapshot):
102+
# global (no-user) evaluation can't bucket into a rollout, so it gets the feature default
103+
assert experimentation.get_global_string_value("rollout_flag", "fallback") == "control"
104+
105+
106+
def test_global_evaluation_gets_global_force_on_flag(experimentation_snapshot):
107+
assert experimentation.get_global_boolean_value("global_flag", default=False) is True
108+
109+
110+
def test_global_evaluation_unknown_feature_returns_in_code_default(experimentation_snapshot):
111+
assert experimentation.get_global_string_value("does_not_exist", "my_default") == "my_default"

0 commit comments

Comments
 (0)