Skip to content

Commit e9edbfa

Browse files
aapelivclaude
andcommitted
Backend: thread CouchersContext into urls helpers
Replace the implicit reliance on config["BASE_URL"] in couchers.urls with an explicit context: every base_url-derived helper now takes a CouchersContext as its first positional arg and builds from a new context.base_url property (currently just config["BASE_URL"]). This threads context through the servicer, notification render, email, tasks and postal layers so that a later per-request / per-recipient BASE_URL override becomes a small change to one property. The media/console helpers are unchanged as they are not base_url-derived. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent af56f58 commit e9edbfa

30 files changed

Lines changed: 697 additions & 588 deletions

app/backend/src/couchers/context.py

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

55
from couchers import experimentation
6+
from couchers.config import config
67
from couchers.i18n import LocalizationContext
78

89
if TYPE_CHECKING:
@@ -180,6 +181,11 @@ def token(self) -> str:
180181
def localization(self) -> LocalizationContext:
181182
return self.__localization
182183

184+
@property
185+
def base_url(self) -> str:
186+
# Single choke point for the frontend base URL of links built (via couchers.urls) for this context.
187+
return cast(str, config["BASE_URL"])
188+
183189
# Feature-flag evaluation methods mirror the OpenFeature evaluation API, evaluating for this
184190
# context's user. The gating lives in experimentation; we just pass our cached per-request
185191
# evaluator. The in-code default is honored even for flags not yet set up in GrowthBook.

app/backend/src/couchers/email/calendar_events.py

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,31 @@
66

77
from couchers import urls
88
from couchers.config import config
9+
from couchers.context import CouchersContext
910
from couchers.email.rendering import get_emails_i18next
10-
from couchers.i18n import LocalizationContext
1111
from couchers.proto.internal.jobs_pb2 import EmailAttachment
1212
from couchers.proto.requests_pb2 import HostRequest
1313

1414
HOST_REQUEST_ICS_FILENAME = "host_request.ics"
1515

1616

1717
def create_host_request_attachment(
18-
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
18+
host_request: HostRequest, other_name: str, hosting: bool, context: CouchersContext
1919
) -> EmailAttachment:
20-
ics = create_host_request_ics(host_request, other_name, hosting, loc_context)
20+
ics = create_host_request_ics(host_request, other_name, hosting, context)
2121
return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
2222

2323

24-
def create_host_request_ics(
25-
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
26-
) -> str:
27-
event = create_host_request_event(host_request, other_name, hosting, loc_context)
24+
def create_host_request_ics(host_request: HostRequest, other_name: str, hosting: bool, context: CouchersContext) -> str:
25+
event = create_host_request_event(host_request, other_name, hosting, context)
2826

2927
# METHOD:PUBLISH means this is part of a stream of calendar event information.
3028
# It allows for later cancellation, and doesn't expose accept/decline functionality.
31-
return event_to_ics(event, "PUBLISH", loc_context)
29+
return event_to_ics(event, "PUBLISH", context)
3230

3331

3432
def create_host_request_event(
35-
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext, sequence: int = 0
33+
host_request: HostRequest, other_name: str, hosting: bool, context: CouchersContext, sequence: int = 0
3634
) -> Event:
3735
"""Creates an ics event for a host request."""
3836

@@ -44,11 +42,11 @@ def create_host_request_event(
4442

4543
if hosting:
4644
event.name = get_emails_i18next().localize(
47-
"calendar_events.host_requests.title_host", loc_context.locale, {"name": other_name}
45+
"calendar_events.host_requests.title_host", context.localization.locale, {"name": other_name}
4846
)
4947
else:
5048
event.name = get_emails_i18next().localize(
51-
"calendar_events.host_requests.title_surfer", loc_context.locale, {"name": other_name}
49+
"calendar_events.host_requests.title_surfer", context.localization.locale, {"name": other_name}
5250
)
5351

5452
# Our to_date is inclusive, iCalendar's DTEND is exclusive (for full-day events)
@@ -58,7 +56,7 @@ def create_host_request_event(
5856
event.make_all_day()
5957

6058
event.location = host_request.hosting_city
61-
event.url = urls.host_request(host_request_id=str(host_request.host_request_id))
59+
event.url = urls.host_request(context, host_request_id=str(host_request.host_request_id))
6260

6361
# Google Calendar™ will hide the URL if there is a location, so also include it in the description
6462
event.description = event.url
@@ -67,30 +65,30 @@ def create_host_request_event(
6765

6866

6967
def create_host_request_cancellation_attachment(
70-
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
68+
host_request: HostRequest, other_name: str, hosting: bool, context: CouchersContext
7169
) -> EmailAttachment:
72-
ics = create_host_request_cancellation_ics(host_request, other_name, hosting, loc_context)
70+
ics = create_host_request_cancellation_ics(host_request, other_name, hosting, context)
7371
return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
7472

7573

7674
def create_host_request_cancellation_ics(
77-
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
75+
host_request: HostRequest, other_name: str, hosting: bool, context: CouchersContext
7876
) -> str:
79-
event = create_host_request_event(host_request, other_name, hosting, loc_context, sequence=1)
77+
event = create_host_request_event(host_request, other_name, hosting, context, sequence=1)
8078
event.name = get_emails_i18next().localize(
81-
"calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
79+
"calendar_events.title_cancelled", context.localization.locale, {"title": event.name}
8280
)
8381
event.status = "CANCELLED"
8482

8583
# METHOD:PUBLISH means this is part of a stream of calendar event information.
8684
# Gmail™ will immediately remove the event from the user's calendar.
8785
# METHOD:CANCEL might leave the event in cancelled state or not work.
88-
return event_to_ics(event, "PUBLISH", loc_context)
86+
return event_to_ics(event, "PUBLISH", context)
8987

9088

91-
def event_to_ics(event: Event, method: str | None, loc_context: LocalizationContext) -> str:
89+
def event_to_ics(event: Event, method: str | None, context: CouchersContext) -> str:
9290
# PRODID is mandatory and generally follows "-//[Organization]//[Product Name]//[Language]"
93-
calendar = Calendar(creator=f"-//Couchers.org//Couchers//{loc_context.locale.upper()}")
91+
calendar = Calendar(creator=f"-//Couchers.org//Couchers//{context.localization.locale.upper()}")
9492
if method:
9593
calendar.method = method
9694
calendar.events.add(event)

app/backend/src/couchers/email/dump_emails.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pathlib import Path
1212

1313
import couchers.email.emails
14+
from couchers.context import CouchersContext, make_logged_out_context
1415
from couchers.email.emails import EmailBase
1516
from couchers.email.rendering import (
1617
EmailFooter,
@@ -43,7 +44,7 @@ def parse(args: list[str]) -> CommandLineArgs:
4344

4445
def main() -> None:
4546
args = CommandLineArgs.parse(sys.argv[1:])
46-
loc_context = LocalizationContext(locale=args.locale, timezone=UTC)
47+
context = make_logged_out_context(LocalizationContext(locale=args.locale, timezone=UTC))
4748

4849
footer = EmailFooter(
4950
timezone_name="UTC",
@@ -59,14 +60,14 @@ def main() -> None:
5960
for _, klass in inspect.getmembers(couchers.email.emails, lambda o: inspect.isclass(o) and o.__base__ == EmailBase):
6061
email_class: type[EmailBase] = klass
6162
if filter_regex.fullmatch(email_class.__name__):
62-
dump_email(email_class.dummy_data(), footer, loc_context, args.outdir)
63+
dump_email(email_class.dummy_data(), footer, context, args.outdir)
6364

6465

65-
def dump_email(email: EmailBase, footer: EmailFooter, loc_context: LocalizationContext, outdir: Path) -> None:
66+
def dump_email(email: EmailBase, footer: EmailFooter, context: CouchersContext, outdir: Path) -> None:
6667
"""Dumps an email's subject and plaintext+html body to a file."""
67-
subject_line = email.get_subject_line(loc_context)
68-
preview_line = email.get_preview_line(loc_context)
69-
blocks = email.get_body_blocks(loc_context)
68+
subject_line = email.get_subject_line(context)
69+
preview_line = email.get_preview_line(context)
70+
blocks = email.get_body_blocks(context)
7071

7172
outdir.mkdir(exist_ok=True)
7273

@@ -76,13 +77,13 @@ def dump_email(email: EmailBase, footer: EmailFooter, loc_context: LocalizationC
7677
html_path = outdir / f"{email.__class__.__name__}.html"
7778
print(f" Rendering html to {html_path}...")
7879
html = render_html_body(
79-
subject=subject_line, preview=preview_line, blocks=blocks, footer=footer, loc_context=loc_context
80+
subject=subject_line, preview=preview_line, blocks=blocks, footer=footer, loc_context=context.localization
8081
)
8182
html_path.write_text(html)
8283

8384
plaintext_path = outdir / f"{email.__class__.__name__}.txt"
8485
print(f" Rendering plaintext to {plaintext_path}...")
85-
plaintext = render_plaintext_body(blocks=blocks, footer=footer, loc_context=loc_context)
86+
plaintext = render_plaintext_body(blocks=blocks, footer=footer, loc_context=context.localization)
8687
plaintext_path.write_text(plaintext)
8788

8889

0 commit comments

Comments
 (0)