Skip to content

Commit e27a04d

Browse files
authored
Merge branch 'develop' into na/spring-blog-post
2 parents 23a9ac6 + 17c1a30 commit e27a04d

133 files changed

Lines changed: 5239 additions & 1400 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/backend.dev.env

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,26 @@ DATABASE_CONNECTION_STRING=postgresql://postgres:203d805f4b62c0a1b2f1f6b82d4583d
1313

1414
ADD_DUMMY_DATA=1
1515

16-
ENABLE_DONATIONS=0
1716
STRIPE_API_KEY=sk_test_...
1817
STRIPE_WEBHOOK_SECRET=whsec_...
1918
STRIPE_RECURRING_PRODUCT_ID=price_...
2019

21-
ENABLE_STRONG_VERIFICATION=0
2220
IRIS_ID_PUBKEY=public-key-...
2321
IRIS_ID_SECRET=secret-...
2422
# corresponds to private key e6c2fbf3756b387bc09a458a7b85935718ef3eb1c2777ef41d335c9f6c0ab272
2523
VERIFICATION_DATA_PUBLIC_KEY=dd740a2b2a35bf05041a28257ea439b30f76f056f3698000b71e6470cd82275f
2624

27-
ENABLE_POSTAL_VERIFICATION=0
2825
MYPOSTCARD_API_KEY=...
2926
MYPOSTCARD_USERNAME=...
3027
MYPOSTCARD_PASSWORD=...
3128
MYPOSTCARD_PRODUCT_CODE=J9GCU
3229
MYPOSTCARD_CAMPAIGN_ID=295
3330

34-
ENABLE_SMS=0
3531
SMS_SENDER_ID=invalid
3632

3733
NOTIFICATION_EMAIL_SENDER=Couchers.org
3834
NOTIFICATION_EMAIL_ADDRESS=notify@couchers.org.invalid
3935
NOTIFICATION_PREFIX='[DEV] '
40-
ENABLE_NOTIFICATION_TRANSLATIONS=1
41-
ENABLE_EMAIL_ICS_ATTACHMENTS=1
4236

4337
REPORTS_EMAIL_RECIPIENT=reports@couchers.org.invalid
4438
CONTRIBUTOR_FORM_EMAIL_RECIPIENT=forms@couchers.org.invalid
@@ -70,7 +64,6 @@ LISTMONK_API_USERNAME=...
7064
LISTMONK_API_KEY=...
7165
LISTMONK_LIST_ID=3
7266

73-
RECAPTHCA_ENABLED=0
7467
RECAPTHCA_PROJECT_ID=...
7568
RECAPTHCA_API_KEY=...
7669
RECAPTHCA_SITE_KEY=...
@@ -89,6 +82,7 @@ EXPERIMENTATION_ENABLED=0
8982
EXPERIMENTATION_PASS_ALL_GATES=1
9083
GROWTHBOOK_API_HOST=https://gbapi.couchershq.org
9184
GROWTHBOOK_CLIENT_KEY=sdk-cGmljeXu1BCzHffE
85+
GROWTHBOOK_CACHE_PATH=growthbook-features-cache.json
9286

9387
# Moderation auto-approval deadline in seconds (0 to disable)
9488
MODERATION_AUTO_APPROVE_DEADLINE_SECONDS=10

app/backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ htmlcov/
1919

2020
# test artifacts
2121
test_artifacts/
22+
23+
# GrowthBook feature-flag disk cache (GROWTHBOOK_CACHE_PATH)
24+
growthbook-features-cache.json

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/config.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,21 @@
4040
("GEOLITE2_ASN_MMDB_FILE_LOCATION", str, ""),
4141
# Whether to try adding dummy data
4242
("ADD_DUMMY_DATA", bool),
43-
# Donations
44-
("ENABLE_DONATIONS", bool),
43+
# Donations (gated at runtime by the `donations_enabled` feature flag)
4544
("STRIPE_API_KEY", str),
4645
("STRIPE_WEBHOOK_SECRET", str),
4746
("STRIPE_RECURRING_PRODUCT_ID", str),
48-
# Strong verification through Iris ID
49-
("ENABLE_STRONG_VERIFICATION", bool),
47+
# Strong verification through Iris ID (gated at runtime by the `strong_verification_enabled` feature flag)
5048
("IRIS_ID_PUBKEY", str),
5149
("IRIS_ID_SECRET", str),
5250
("VERIFICATION_DATA_PUBLIC_KEY", bytes),
53-
# Postal verification
54-
("ENABLE_POSTAL_VERIFICATION", bool),
55-
# MyPostcard API credentials
51+
# Postal verification (MyPostcard API; gated at runtime by the `postal_verification_enabled` feature flag)
5652
("MYPOSTCARD_API_KEY", str),
5753
("MYPOSTCARD_USERNAME", str),
5854
("MYPOSTCARD_PASSWORD", str),
5955
("MYPOSTCARD_PRODUCT_CODE", str),
6056
("MYPOSTCARD_CAMPAIGN_ID", str),
61-
# SMS
62-
("ENABLE_SMS", bool),
57+
# SMS (gated at runtime by the `sms_enabled` feature flag)
6358
("SMS_SENDER_ID", str),
6459
# Email
6560
("ENABLE_EMAIL", bool),
@@ -69,8 +64,6 @@
6964
("NOTIFICATION_EMAIL_ADDRESS", str),
7065
# An optional prefix for email subject, e.g. [STAGING]
7166
("NOTIFICATION_PREFIX", str, ""),
72-
("ENABLE_NOTIFICATION_TRANSLATIONS", bool),
73-
("ENABLE_EMAIL_ICS_ATTACHMENTS", bool),
7467
# Address to send emails about reported users
7568
("REPORTS_EMAIL_RECIPIENT", str),
7669
# Address to send contributor forms when users sign up/fill the form
@@ -108,8 +101,7 @@
108101
("LISTMONK_API_USERNAME", str),
109102
("LISTMONK_API_KEY", str),
110103
("LISTMONK_LIST_ID", int),
111-
# Google recaptcha antibot
112-
("RECAPTHCA_ENABLED", bool),
104+
# Google recaptcha antibot (gated at runtime by the `recaptcha_enabled` feature flag)
113105
("RECAPTHCA_PROJECT_ID", str),
114106
("RECAPTHCA_API_KEY", str),
115107
("RECAPTHCA_SITE_KEY", str),
@@ -122,6 +114,9 @@
122114
# GrowthBook SDK configuration
123115
("GROWTHBOOK_API_HOST", str, "https://cdn.growthbook.io"),
124116
("GROWTHBOOK_CLIENT_KEY", str, ""),
117+
# Disk path for the last-known-good feature payload, used as a cold-start fallback when GrowthBook
118+
# is unreachable. Required when experimentation is enabled so we never start on in-code defaults.
119+
("GROWTHBOOK_CACHE_PATH", str, ""),
125120
# Moderation auto-approval deadline in seconds (0 to disable auto-approval)
126121
("MODERATION_AUTO_APPROVE_DEADLINE_SECONDS", int),
127122
# User ID of the bot user for automated moderation actions
@@ -147,32 +142,35 @@ def check_config(cfg: dict[str, Any]) -> None:
147142
raise Exception("Production site must be over HTTPS")
148143
if not cfg["ENABLE_EMAIL"]:
149144
raise Exception("Production site must have email enabled")
150-
if not cfg["ENABLE_SMS"]:
151-
raise Exception("Production site must have SMS enabled")
152145
if cfg["IN_TEST"]:
153146
raise Exception("IN_TEST while not DEV")
154147

155-
if cfg["ENABLE_DONATIONS"]:
148+
# Donations are gated at runtime by the `donations_enabled` feature flag, which can be flipped on
149+
# remotely at any time, so prod must always have Stripe credentials present so the feature can run.
156150
if not cfg["STRIPE_API_KEY"] or not cfg["STRIPE_WEBHOOK_SECRET"] or not cfg["STRIPE_RECURRING_PRODUCT_ID"]:
157-
raise Exception("No Stripe API key/recurring donation ID but donations enabled")
151+
raise Exception("Stripe credentials must be configured in production")
158152

159-
if cfg["ENABLE_STRONG_VERIFICATION"]:
153+
# The following features are gated at runtime by feature flags (`strong_verification_enabled`,
154+
# `postal_verification_enabled`, `recaptcha_enabled`), which can be flipped on remotely at any
155+
# time, so prod must always have their credentials present.
160156
if not cfg["IRIS_ID_PUBKEY"] or not cfg["IRIS_ID_SECRET"] or not cfg["VERIFICATION_DATA_PUBLIC_KEY"]:
161-
raise Exception("No Iris ID pubkey/secret or verification data pubkey but strong verification enabled")
162-
163-
if cfg["ENABLE_POSTAL_VERIFICATION"]:
157+
raise Exception("Iris ID credentials must be configured in production")
164158
if (
165159
not cfg["MYPOSTCARD_API_KEY"]
166160
or not cfg["MYPOSTCARD_USERNAME"]
167161
or not cfg["MYPOSTCARD_PASSWORD"]
168162
or not cfg["MYPOSTCARD_PRODUCT_CODE"]
169163
or not cfg["MYPOSTCARD_CAMPAIGN_ID"]
170164
):
171-
raise Exception("MyPostcard API credentials not configured but postal verification enabled")
165+
raise Exception("MyPostcard API credentials must be configured in production")
166+
if not cfg["RECAPTHCA_PROJECT_ID"] or not cfg["RECAPTHCA_API_KEY"] or not cfg["RECAPTHCA_SITE_KEY"]:
167+
raise Exception("reCAPTCHA credentials must be configured in production")
172168

173169
if cfg["EXPERIMENTATION_ENABLED"]:
174170
if not cfg["GROWTHBOOK_CLIENT_KEY"]:
175171
raise Exception("No GrowthBook client key but experimentation enabled")
172+
if not cfg["GROWTHBOOK_CACHE_PATH"]:
173+
raise Exception("No GrowthBook cache path but experimentation enabled")
176174

177175

178176
def make_config() -> dict[str, Any]:

app/backend/src/couchers/constants.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,6 @@
103103

104104
GHOST_USERNAME = "ghost"
105105

106-
# Donation drive start date - set to None to disable donation drive banner
107-
# When set, users who haven't donated since this date will see a donation banner
108-
DONATION_DRIVE_START: datetime | None = None
109-
110-
DONATION_GOAL_USD = 5000
111-
# exclude big donations from Aapeli + Itsi that we're hoping to do without :)
112-
DONATION_OFFSET_USD = 2000
113-
114106
# Photo gallery limits
115107
GALLERY_MAX_PHOTOS_NOT_VERIFIED = 2
116108
GALLERY_MAX_PHOTOS_VERIFIED = 5

app/backend/src/couchers/context.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import grpc
44

5+
from couchers import experimentation
56
from couchers.i18n import LocalizationContext
67

78
if TYPE_CHECKING:
@@ -82,6 +83,7 @@ def __init__(
8283
self.__is_interactive = is_interactive
8384
self.__logged_in = self._user_id is not None
8485
self.__cookies: list[str] = []
86+
self.__response_headers: list[tuple[str, str]] = []
8587
self._growthbook: GrowthBook | None = None
8688

8789
if self.__is_interactive:
@@ -138,8 +140,15 @@ def set_cookies(self, cookies: list[str]) -> None:
138140
self.__verify_interactive()
139141
self.__cookies += cookies
140142

143+
def set_response_headers(self, headers: list[tuple[str, str]]) -> None:
144+
"""
145+
Sets extra HTTP response headers (forwarded by Envoy as gRPC initial metadata)
146+
"""
147+
self.__verify_interactive()
148+
self.__response_headers += headers
149+
141150
def _send_cookies(self) -> None:
142-
data = tuple([("set-cookie", cookie) for cookie in self.__cookies])
151+
data = tuple([("set-cookie", cookie) for cookie in self.__cookies]) + tuple(self.__response_headers)
143152
self._grpc_context.send_initial_metadata(data) # type: ignore[union-attr]
144153

145154
@property
@@ -179,6 +188,30 @@ def token(self) -> str:
179188
def localization(self) -> LocalizationContext:
180189
return self.__localization
181190

191+
# Feature-flag evaluation methods mirror the OpenFeature evaluation API, evaluating for this
192+
# context's user. The gating lives in experimentation; we just pass our cached per-request
193+
# evaluator. The in-code default is honored even for flags not yet set up in GrowthBook.
194+
def get_boolean_value(self, flag_key: str, default: bool) -> bool:
195+
return experimentation._boolean_value(flag_key, default, self._get_growthbook)
196+
197+
def get_string_value(self, flag_key: str, default: str) -> str:
198+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
199+
200+
def get_integer_value(self, flag_key: str, default: int) -> int:
201+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
202+
203+
def get_float_value(self, flag_key: str, default: float) -> float:
204+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
205+
206+
def get_object_value[T](self, flag_key: str, default: T) -> T:
207+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
208+
209+
def _get_growthbook(self) -> GrowthBook:
210+
if self._growthbook is None:
211+
# _user_id is None when logged out: evaluate anonymously, falling through to defaults.
212+
self._growthbook = experimentation._create_evaluator(self._user_id)
213+
return self._growthbook
214+
182215

183216
def make_interactive_context(
184217
grpc_context: grpc.ServicerContext,

app/backend/src/couchers/db.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from contextlib import contextmanager
77
from os import getpid
88
from threading import get_ident
9+
from typing import TYPE_CHECKING
910

1011
from alembic import command
1112
from alembic.config import Config
@@ -19,7 +20,6 @@
1920

2021
from couchers.config import config
2122
from couchers.constants import SERVER_THREADS, WORKER_THREADS
22-
from couchers.context import CouchersContext
2323
from couchers.models import (
2424
Cluster,
2525
ClusterRole,
@@ -33,6 +33,9 @@
3333
)
3434
from couchers.sql import where_users_column_visible
3535

36+
if TYPE_CHECKING:
37+
from couchers.context import CouchersContext
38+
3639
# Register psycopg (psycopg3) as the default driver for postgresql:// URLs
3740
# This must happen before any engine is created
3841
registry.register("postgresql", "sqlalchemy.dialects.postgresql.psycopg", "PGDialect_psycopg")

0 commit comments

Comments
 (0)