From 90eea2c4823adce3cd06aa164378738bf88a85fa Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:34:44 -0300 Subject: [PATCH 001/137] Add Braze class stub --- basket/news/backends/braze.py | 38 +++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index a072f185..004e2557 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -45,7 +45,7 @@ class BrazeEndpoint(Enum): USERS_DELETE = "/users/delete" -class BrazeClient: +class BrazeInterface: def __init__(self, base_url, api_key): urlbits = urlparse(base_url) if not urlbits.scheme or not urlbits.netloc: @@ -219,4 +219,38 @@ def send_campaign(self, email, campaign_id): return self._request(BrazeEndpoint.CAMPAIGNS_TRIGGER_SEND, data) -braze = BrazeClient(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY) +class Braze: + """Basket interface to Braze""" + + def __init__(self, interface): + self.interface = interface + + def get( + self, + email_id=None, + token=None, + email=None, + fxa_id=None, + ): + raise NotImplementedError + + def add(self, data): + raise NotImplementedError + + def update(self, existing_data, update_data): + raise NotImplementedError + + def update_by_fxa_id(self, fxa_id, update_data): + raise NotImplementedError + + def delete(self, email): + raise NotImplementedError + + def from_vendor(self): + raise NotImplementedError + + def to_vendor(self): + raise NotImplementedError + + +braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) From cfd7efe956541b2acdd161c9d7ffecd57ce42e29 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:11:10 -0300 Subject: [PATCH 002/137] Update views.fxa_callback with use_braze_backend option --- basket/news/views.py | 113 ++++++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 28 deletions(-) diff --git a/basket/news/views.py b/basket/news/views.py index f8cc7a7e..71c93135 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -136,41 +136,98 @@ def fxa_callback(request): email = user_profile.get("email") uid = user_profile.get("uid") - try: - user_data = get_user_data(email=email, fxa_id=uid) - except Exception: - metrics.incr("news.views.fxa_callback", tags=["status:error", "error:user_data"]) - sentry_sdk.capture_exception() - return HttpResponseRedirect(error_url) - - if user_data: - token = user_data["token"] - else: - new_user_data = { - "email": email, - "optin": True, - "newsletters": [settings.FXA_REGISTER_NEWSLETTER], - "source_url": f"{settings.FXA_REGISTER_SOURCE_URL}?utm_source=basket-fxa-oauth", - } - locale = user_profile.get("locale") - if locale: - new_user_data["fxa_lang"] = locale - lang = get_best_language(get_accept_languages(locale)) - if lang not in newsletter_languages(): - lang = "other" - new_user_data["lang"] = lang + def handler( + email, + uid, + use_braze_backend=False, + should_send_tx_messages=True, + extra_metrics_tags=None, + ): + if extra_metrics_tags is None: + extra_metrics_tags = [] try: - token = tasks.upsert_contact(SUBSCRIBE, new_user_data, None)[0] + user_data = get_user_data( + email=email, + fxa_id=uid, + use_braze_backend=use_braze_backend, + ) except Exception: - metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact"]) + metrics.incr("news.views.fxa_callback", tags=["status:error", "error:user_data", *extra_metrics_tags]) sentry_sdk.capture_exception() return HttpResponseRedirect(error_url) - metrics.incr("news.views.fxa_callback", tags=["status:success"]) - redirect_to = f"https://{settings.FXA_EMAIL_PREFS_DOMAIN}/newsletter/existing/{token}/?fxa=1" - return HttpResponseRedirect(redirect_to) + if user_data: + token = user_data["token"] + else: + new_user_data = { + "email": email, + "optin": True, + "newsletters": [settings.FXA_REGISTER_NEWSLETTER], + "source_url": f"{settings.FXA_REGISTER_SOURCE_URL}?utm_source=basket-fxa-oauth", + } + locale = user_profile.get("locale") + if locale: + new_user_data["fxa_lang"] = locale + lang = get_best_language(get_accept_languages(locale)) + if lang not in newsletter_languages(): + lang = "other" + + new_user_data["lang"] = lang + + try: + token = tasks.upsert_contact( + SUBSCRIBE, + new_user_data, + None, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + )[0] + except Exception: + metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact", *extra_metrics_tags]) + sentry_sdk.capture_exception() + return HttpResponseRedirect(error_url) + + metrics.incr("news.views.fxa_callback", tags=["status:success", *extra_metrics_tags]) + redirect_to = f"https://{settings.FXA_EMAIL_PREFS_DOMAIN}/newsletter/existing/{token}/?fxa=1" + return HttpResponseRedirect(redirect_to) + + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + try: + handler( + email, + uid, + use_braze_backend=True, + should_send_tx_messages=False, + extra_metrics_tags=["backend:braze"], + ) + except Exception: + sentry_sdk.capture_exception() + + return handler( + email, + uid, + use_braze_backend=False, + should_send_tx_messages=True, + extra_metrics_tags=["backend:ctms"], + ) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + return handler( + email, + uid, + use_braze_backend=True, + should_send_tx_messages=True, + extra_metrics_tags=["backend:braze"], + ) + else: + return handler( + email, + uid, + use_braze_backend=False, + should_send_tx_messages=True, + extra_metrics_tags=["backend:ctms"], + ) @require_POST From 6107ab6218158612e76c676d0856a78036acf74a Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:20:28 -0300 Subject: [PATCH 003/137] Update views.confirm with use_braze_backend option --- basket/news/views.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/basket/news/views.py b/basket/news/views.py index 71c93135..0f556ee7 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -242,7 +242,31 @@ def confirm(request, token): increment=True, ): raise Ratelimited() - tasks.confirm_user.delay(token) + + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + tasks.confirm_user.delay( + token, + use_braze_backend=True, + extra_metrics_tags=["backend:braze"], + ) + tasks.confirm_user.delay( + token, + use_braze_backend=False, + extra_metrics_tags=["backend:ctms"], + ) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + tasks.confirm_user.delay( + token, + use_braze_backend=True, + extra_metrics_tags=["backend:braze"], + ) + else: + tasks.confirm_user.delay( + token, + use_braze_backend=False, + extra_metrics_tags=["backend:ctms"], + ) + return HttpResponseJSON({"status": "ok"}) From d1a4de14ade8aa1b0e115bdbc3e42bf343ab6461 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:15:16 -0300 Subject: [PATCH 004/137] add vendor_id_to_slug function (based on existing slug_to_vendor_id) --- basket/news/newsletters.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/basket/news/newsletters.py b/basket/news/newsletters.py index d7445caa..5cb5a065 100644 --- a/basket/news/newsletters.py +++ b/basket/news/newsletters.py @@ -155,6 +155,11 @@ def slug_to_vendor_id(slug): return _newsletters()["by_name"][slug].vendor_id +def vendor_id_to_slug(vendor_id): + """Given a newsletter's slug, return its vendor_id""" + return _newsletters()["by_vendor_id"][vendor_id].slug + + def newsletter_fields(): """Get a list of all the newsletter backend-specific fields""" return list(_newsletters()["by_vendor_id"].keys()) From 9209e61fce37c97268e2ea95d7929669d2d377e9 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:17:41 -0300 Subject: [PATCH 005/137] add from_vendor to braze class --- basket/news/backends/braze.py | 52 +++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 004e2557..0da93c61 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -8,6 +8,8 @@ import requests +from basket.news.newsletters import vendor_id_to_slug + # Braze errors: https://www.braze.com/docs/api/errors/ class BrazeBadRequestError(Exception): @@ -246,8 +248,54 @@ def update_by_fxa_id(self, fxa_id, update_data): def delete(self, email): raise NotImplementedError - def from_vendor(self): - raise NotImplementedError + def from_vendor(self, braze_user_data, subscription_groups): + """ + Converts Braze-formatted data to Basket-formatted data + """ + custom_attributes = braze_user_data.get("custom_attributes", {}) + + user_attributes = custom_attributes.get("user_attributes_v1", [{}])[0] + newsletters_v1 = custom_attributes.get("newsletters_v1", []) + waitlists_v1 = custom_attributes.get("waitlists_v1", []) + + braze_subscription_ids = [ + subscription["subscription_group_id"] for subscription in subscription_groups if subscription["subscription_status"] == "subscribed" + ] + newsletters = [vendor_id_to_slug(vendor_id) for vendor_id in braze_subscription_ids] + + basket_user_data = { + "email": braze_user_data["email"], + "email_id": braze_user_data["external_id"], + "id": braze_user_data["braze_id"], + "first_name": braze_user_data.get("first_name"), + "last_name": braze_user_data.get("last_name"), + "country": braze_user_data.get("country") or user_attributes.get("mailing_country"), + "lang": braze_user_data.get("language") or user_attributes.get("email_lang", "en"), + "newsletters": newsletters, + "newsletters_v1": newsletters_v1, # We need to record newsletters_v1 here so we can continue to update it in Braze after CTMS is removed + "created_date": user_attributes.get("created_at"), + "last_modified_date": user_attributes.get("updated_at"), + "optin": braze_user_data.get("email_subscribe") == "opted_in", + "optout": braze_user_data.get("email_subscribe") == "unsubscribed", + "token": user_attributes.get("basket_token") or braze_user_data["external_id"], + # missing fxa fields: fxa_deleted, fxa_id, fxa_lang, fxa_primary_email, fxa_service + } + + if user_attributes.get(["has_fxa"]) == "true" and user_attributes.get(["fxa_created_at"]): + basket_user_data["fxa_create_date"] = user_attributes["fxa_created_at"] + + if waitlists_v1: + for waitlist in waitlists_v1: + # Legacy waitlist format. For backward compatibility. + # This logic was ported over from the CTMS from_vendor method. + name = waitlist.get(["waitlist_name"]) + if name == "guardian-vpn-waitlist": + basket_user_data["fpn_country"] = waitlist.get(["waitlist_geo"]) + basket_user_data["fpn_platform"] = waitlist.get["waitlist_platform"] + if name.startswith("relay") and name.endswith("-waitlist"): + basket_user_data["relay_country"] = waitlist.get(["waitlist_geo"]) + + return basket_user_data def to_vendor(self): raise NotImplementedError From 2c37c843a9881196b8c3da653310359e3da49586 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:24:28 -0300 Subject: [PATCH 006/137] fix comment --- basket/news/newsletters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/newsletters.py b/basket/news/newsletters.py index 5cb5a065..9fdd7ed0 100644 --- a/basket/news/newsletters.py +++ b/basket/news/newsletters.py @@ -156,7 +156,7 @@ def slug_to_vendor_id(slug): def vendor_id_to_slug(vendor_id): - """Given a newsletter's slug, return its vendor_id""" + """Given a newsletter's vendor_id, return its slug""" return _newsletters()["by_vendor_id"][vendor_id].slug From 4d58807365579ad101e9e044f6aff8303915dc56 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:09:04 -0300 Subject: [PATCH 007/137] Update views and tasks to handle either CTMS or Braze backend --- basket/news/backends/braze.py | 3 + basket/news/tasks.py | 103 +++++++-- basket/news/utils.py | 37 ++- basket/news/views.py | 418 ++++++++++++++++++++++++++++------ basket/settings.py | 5 + 5 files changed, 460 insertions(+), 106 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 004e2557..0393a735 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -243,6 +243,9 @@ def update(self, existing_data, update_data): def update_by_fxa_id(self, fxa_id, update_data): raise NotImplementedError + def update_by_token(self, token, update_data): + raise NotImplementedError + def delete(self, email): raise NotImplementedError diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 459a9c41..97fd8f28 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -175,16 +175,26 @@ def fxa_login(data): @rq_task -def update_user_meta(token, data): +def update_user_meta(token, data, use_braze_backend=False): """Update a user's metadata, not newsletters""" - try: - ctms.update_by_alt_id("token", token, data) - except CTMSNotFoundByAltIDError: - raise + if use_braze_backend: + braze.update_by_token(token, data) + else: + try: + ctms.update_by_alt_id("token", token, data) + except CTMSNotFoundByAltIDError: + raise @rq_task -def upsert_user(api_call_type, data): +def upsert_user( + api_call_type, + data, + use_braze_backend=False, + should_send_tx_messages=True, + pre_generated_token=None, + pre_generated_email_id=None, +): """ Update or insert (upsert) a contact record @@ -200,11 +210,24 @@ def upsert_user(api_call_type, data): token=data.get("token"), email=data.get("email"), extra_fields=["id", "email_id"], + use_braze_backend=use_braze_backend, ), + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, ) -def upsert_contact(api_call_type, data, user_data): +def upsert_contact( + api_call_type, + data, + user_data, + use_braze_backend=False, + should_send_tx_messages=True, + pre_generated_token=None, + pre_generated_email_id=None, +): """ Update or insert (upsert) a contact record @@ -229,11 +252,12 @@ def upsert_contact(api_call_type, data, user_data): braze_txs = newsletters_set & braze_msg_ids if braze_txs: braze_msgs = [t for t in braze_txs if t in braze_msg_ids] - send_tx_messages( - data["email"], - data.get("lang", "en-US"), - braze_msgs, - ) + if should_send_tx_messages: + send_tx_messages( + data["email"], + data.get("lang", "en-US"), + braze_msgs, + ) newsletters_set -= set(braze_msgs) newsletters = list(newsletters_set) @@ -277,13 +301,21 @@ def upsert_contact(api_call_type, data, user_data): if user_data is None: # no user found. create new one. - token = update_data["token"] = generate_token() + token = update_data["token"] = pre_generated_token or generate_token() + update_data["email_id"] = update_data["email_id"] or pre_generated_email_id + if settings.MAINTENANCE_MODE: - ctms_add_or_update.delay(update_data) + if use_braze_backend: + braze_add_or_update.delay(update_data) + else: + ctms_add_or_update.delay(update_data) else: - new_user = ctms.add(update_data) + if use_braze_backend: + new_user = braze.add(update_data) + else: + new_user = ctms.add(update_data) - if send_confirm and settings.SEND_CONFIRM_MESSAGES: + if send_confirm and settings.SEND_CONFIRM_MESSAGES and should_send_tx_messages: send_confirm_message.delay( data["email"], token, @@ -306,12 +338,18 @@ def upsert_contact(api_call_type, data, user_data): if user_data and user_data.get("token"): token = user_data["token"] else: - token = update_data["token"] = generate_token() + token = update_data["token"] = pre_generated_token or generate_token() if settings.MAINTENANCE_MODE: - ctms_add_or_update.delay(update_data, user_data) + if use_braze_backend: + braze_add_or_update.delay(update_data, user_data) + else: + ctms_add_or_update.delay(update_data, user_data) else: - ctms.update(user_data, update_data) + if use_braze_backend: + braze.update(user_data, update_data) + else: + ctms.update(user_data, update_data) # In the rare case the user hasn't confirmed their email and is subscribing to the same newsletter, send the confirmation again. # We catch this by checking if the user `optin` is `False` and if the `update_data["newsletters"]` is empty. @@ -323,7 +361,7 @@ def upsert_contact(api_call_type, data, user_data): send_fx_confirm = all(n.firefox_confirm for n in newsletter_objs) send_confirm = "fx" if send_fx_confirm else "moz" - if send_confirm and settings.SEND_CONFIRM_MESSAGES: + if send_confirm and settings.SEND_CONFIRM_MESSAGES and should_send_tx_messages: send_confirm_message.delay( data["email"], token, @@ -335,6 +373,11 @@ def upsert_contact(api_call_type, data, user_data): return token, False +@rq_task +def braze_add_or_update(update_data, user_data=None): + raise NotImplementedError + + @rq_task def ctms_add_or_update(update_data, user_data=None): """ @@ -386,7 +429,7 @@ def send_confirm_message(email, token, lang, message_type, email_id): @rq_task -def confirm_user(token): +def confirm_user(token, use_braze_backend=False, extra_metrics_tags=None): """ Confirm any pending subscriptions for the user with this token. @@ -400,10 +443,17 @@ def confirm_user(token): :raises: BasketError for fatal errors, NewsletterException for retryable errors. """ - user_data = get_user_data(token=token, extra_fields=["email_id"]) + if extra_metrics_tags is None: + extra_metrics_tags = [] + + user_data = get_user_data( + token=token, + extra_fields=["email_id"], + use_braze_backend=use_braze_backend, + ) if user_data is None: - metrics.incr("news.tasks.confirm_user.confirm_user_not_found") + metrics.incr("news.tasks.confirm_user.confirm_user_not_found", tags=extra_metrics_tags) return if user_data["optin"]: @@ -411,9 +461,12 @@ def confirm_user(token): return if not ("email" in user_data and user_data["email"]): - raise BasketError("token has no email in CTMS") + raise BasketError(f"token has no email in {'Braze' if use_braze_backend else 'CTMS'}") - ctms.update(user_data, {"optin": True}) + if use_braze_backend: + braze.update(user_data, {"optin": True}) + else: + ctms.update(user_data, {"optin": True}) @rq_task diff --git a/basket/news/utils.py b/basket/news/utils.py index 7a5c13d2..d6db2d1f 100644 --- a/basket/news/utils.py +++ b/basket/news/utils.py @@ -21,6 +21,7 @@ # Get error codes from basket-client so users see the same definitions from basket import errors, metrics +from basket.news.backends.braze import braze from basket.news.backends.common import NewsletterException from basket.news.backends.ctms import ( CTMSError, @@ -238,6 +239,7 @@ def get_user_data( fxa_id=None, extra_fields=None, masked=False, + use_braze_backend=False, ): """ Return a dictionary of the user's data. @@ -283,13 +285,21 @@ def get_user_data( if extra_fields is None: extra_fields = [] - ctms_user = None + backend_user = None try: - ctms_user = ctms.get( - token=token, - email=email, - fxa_id=fxa_id, - ) + if use_braze_backend: + backend_user = braze.get( + token=token, + email=email, + fxa_id=fxa_id, + ) + else: + backend_user = ctms.get( + token=token, + email=email, + fxa_id=fxa_id, + ) + # TODO: handle analogous Braze errors here except CTMSNotFoundByAltIDError: return None except requests.exceptions.HTTPError as exc: @@ -318,14 +328,14 @@ def get_user_data( status_code=400, ) from exc - if not ctms_user: + if not backend_user: return None # Only return fields in `ALLOWED_USER_FIELDS` or in the `extra_fields` arg. allowed = set(ALLOWED_USER_FIELDS + extra_fields) - user = {fn: ctms_user[fn] for fn in allowed if fn in ctms_user} + user = {fn: backend_user[fn] for fn in allowed if fn in backend_user} - user["has_fxa"] = bool(ctms_user.get("fxa_id")) + user["has_fxa"] = bool(backend_user.get("fxa_id")) if masked: # mask all emails @@ -337,7 +347,7 @@ def get_user_data( return user -def get_user(token=None, email=None, masked=True): +def get_user(token=None, email=None, masked=True, use_braze_backend=False): if settings.MAINTENANCE_MODE and not settings.MAINTENANCE_READ_ONLY: # can't return user data during maintenance return HttpResponseJSON( @@ -350,7 +360,12 @@ def get_user(token=None, email=None, masked=True): ) try: - user_data = get_user_data(token, email, masked=masked) + user_data = get_user_data( + token, + email, + masked=masked, + use_braze_backend=use_braze_backend, + ) status_code = 200 except NewsletterException as e: return newsletter_exception_response(e) diff --git a/basket/news/views.py b/basket/news/views.py index 0f556ee7..cb495f2a 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -39,6 +39,7 @@ HttpResponseJSON, NewsletterException, email_is_blocked, + generate_token, get_accept_languages, get_best_language, get_best_request_lang, @@ -143,6 +144,7 @@ def handler( use_braze_backend=False, should_send_tx_messages=True, extra_metrics_tags=None, + pre_generated_token=None, ): if extra_metrics_tags is None: extra_metrics_tags = [] @@ -183,6 +185,7 @@ def handler( None, use_braze_backend=use_braze_backend, should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, )[0] except Exception: metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact", *extra_metrics_tags]) @@ -194,6 +197,7 @@ def handler( return HttpResponseRedirect(redirect_to) if settings.BRAZE_PARALLEL_WRITE_ENABLE: + pre_generated_token = generate_token() try: handler( email, @@ -201,6 +205,7 @@ def handler( use_braze_backend=True, should_send_tx_messages=False, extra_metrics_tags=["backend:braze"], + pre_generated_token=pre_generated_token, ) except Exception: sentry_sdk.capture_exception() @@ -211,6 +216,7 @@ def handler( use_braze_backend=False, should_send_tx_messages=True, extra_metrics_tags=["backend:ctms"], + pre_generated_token=pre_generated_token, ) elif settings.BRAZE_ONLY_WRITE_ENABLE: return handler( @@ -370,71 +376,147 @@ def subscribe(request): email = data.pop("email", None) token = data.pop("token", None) - if not (email or token): - return HttpResponseJSON( - { - "status": "error", - "desc": "email or token is required", - "code": errors.BASKET_USAGE_ERROR, - }, - 401, - ) - - # If we don't have an email, we must have a token after the above check. - if not email: - # Validate we have a UUID token. - if not is_token(token): - return invalid_token_response() - # Get the user's email from the token. - try: - user_data = get_user_data(token=token) - if user_data: - email = user_data.get("email") - except NewsletterException as e: - return newsletter_exception_response(e) - - email = process_email(email) - if not email: - return invalid_token_response() if token else invalid_email_response() - data["email"] = email - - if email_is_blocked(email): - metrics.incr("news.views.subscribe", tags=["info:email_blocked"]) - # don't let on there's a problem - return HttpResponseJSON({"status": "ok"}) - - optin = data.pop("optin", "N").upper() == "Y" - sync = data.pop("sync", "N").upper() == "Y" - - authorized = False - if optin or sync: - if is_authorized(request, email): - authorized = True - - if optin and not authorized: - # for backward compat we just ignore the optin if - # no valid API key is sent. - optin = False + def handler( + email, + token, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=None, + pre_generated_token=None, + pre_generated_email_id=None, + ): + if extra_metrics_tags is None: + extra_metrics_tags = [] - if sync: - if not authorized: + if not (email or token): return HttpResponseJSON( { "status": "error", - "desc": "Using subscribe with sync=Y, you need to pass a valid `api-key` or FxA OAuth Authorization.", - "code": errors.BASKET_AUTH_ERROR, + "desc": "email or token is required", + "code": errors.BASKET_USAGE_ERROR, }, 401, ) - # NOTE this is not a typo; Referrer is misspelled in the HTTP spec - # https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.36 - if not data.get("source_url") and request.headers.get("Referer"): - # try to get it from referrer - metrics.incr("news.views.subscribe", tags=["info:use_referrer"]) - data["source_url"] = request.headers["referer"] + # If we don't have an email, we must have a token after the above check. + if not email: + # Validate we have a UUID token. + if not is_token(token): + return invalid_token_response() + # Get the user's email from the token. + try: + user_data = get_user_data(token=token, use_braze_backend=use_braze_backend) + if user_data: + email = user_data.get("email") + except NewsletterException as e: + return newsletter_exception_response(e) + + email = process_email(email) + if not email: + return invalid_token_response() if token else invalid_email_response() + data["email"] = email - return update_user_task(request, SUBSCRIBE, data=data, optin=optin, sync=sync) + if email_is_blocked(email): + metrics.incr("news.views.subscribe", tags=["info:email_blocked", *extra_metrics_tags]) + # don't let on there's a problem + return HttpResponseJSON({"status": "ok"}) + + optin = data.pop("optin", "N").upper() == "Y" + sync = data.pop("sync", "N").upper() == "Y" + + authorized = False + if optin or sync: + if is_authorized(request, email): + authorized = True + + if optin and not authorized: + # for backward compat we just ignore the optin if + # no valid API key is sent. + optin = False + + if sync: + if not authorized: + return HttpResponseJSON( + { + "status": "error", + "desc": "Using subscribe with sync=Y, you need to pass a valid `api-key` or FxA OAuth Authorization.", + "code": errors.BASKET_AUTH_ERROR, + }, + 401, + ) + + # NOTE this is not a typo; Referrer is misspelled in the HTTP spec + # https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.36 + if not data.get("source_url") and request.headers.get("Referer"): + # try to get it from referrer + metrics.incr("news.views.subscribe", tags=["info:use_referrer", *extra_metrics_tags]) + data["source_url"] = request.headers["referer"] + + return update_user_task( + request, + SUBSCRIBE, + data=data, + optin=optin, + sync=sync, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + rate_limit_increment=rate_limit_increment, + extra_metrics_tags=extra_metrics_tags, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) + + # We are doing parallel writes and want the token/email_id + # to be same so we eagerly generate them now. + pre_generated_token = generate_token() + pre_generated_email_id = generate_token() + + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + try: + handler( + email, + token, + use_braze_backend=True, + should_send_tx_messages=False, + rate_limit_increment=False, + extra_metrics_tags=["backend:braze"], + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) + except Exception: + sentry_sdk.capture_exception() + + return handler( + email, + token, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:ctms"], + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + return handler( + email, + token, + use_braze_backend=True, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:braze"], + # After the external_id migration we can stop passing in email_id here. + pre_generated_email_id=pre_generated_email_id, + ) + else: + return handler( + email, + token, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:ctms"], + ) def invalid_email_response(): @@ -468,7 +550,49 @@ def unsubscribe(request, token): data["optout"] = True data["newsletters"] = ",".join(newsletter_slugs()) - return update_user_task(request, UNSUBSCRIBE, data) + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + try: + update_user_task( + request, + UNSUBSCRIBE, + data, + use_braze_backend=True, + should_send_tx_messages=False, + rate_limit_increment=False, + extra_metrics_tags=["backend:braze"], + ) + except Exception: + sentry_sdk.capture_exception() + + return update_user_task( + request, + UNSUBSCRIBE, + data, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:ctms"], + ) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + return update_user_task( + request, + UNSUBSCRIBE, + data, + use_braze_backend=True, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:braze"], + ) + else: + return update_user_task( + request, + UNSUBSCRIBE, + data, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:ctms"], + ) @require_POST @@ -480,7 +604,13 @@ def user_meta(request, token): if form.is_valid(): # don't send empty values data = {k: v for k, v in form.cleaned_data.items() if v} - tasks.update_user_meta.delay(token, data) + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + tasks.update_user_meta.delay(token, data, use_braze_backend=True) + tasks.update_user_meta.delay(token, data, use_braze_backend=False) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + tasks.update_user_meta.delay(token, data, use_braze_backend=True) + else: + tasks.update_user_meta.delay(token, data, use_braze_backend=False) return HttpResponseJSON({"status": "ok"}) return HttpResponseJSON( @@ -506,10 +636,61 @@ def user(request, token): return invalid_email_response() data["email"] = email - return update_user_task(request, SET, data) + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + pre_generated_token = generate_token() + update_user_task( + request, + SET, + data, + use_braze_backend=True, + should_send_tx_messages=False, + rate_limit_increment=False, + extra_metrics_tags=["backend:braze"], + pre_generated_token=pre_generated_token, + ) + return update_user_task( + request, + SET, + data, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:ctms"], + pre_generated_token=pre_generated_token, + ) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + return update_user_task( + request, + SET, + data, + use_braze_backend=True, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:braze"], + ) + else: + return update_user_task( + request, + SET, + data, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=["backend:ctms"], + ) masked = not has_valid_api_key(request) - return get_user(token, masked=masked) + + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: + try: + return get_user(token, masked=masked, use_braze_backend=True) + except Exception: + sentry_sdk.capture_exception() + return get_user(token, masked=masked, use_braze_backend=False) + elif settings.BRAZE_ONLY_READ_ENABLE: + return get_user(token, masked=masked, use_braze_backend=True) + else: + return get_user(token, masked=masked, use_braze_backend=False) @require_POST @@ -533,7 +714,32 @@ def send_recovery_message(request): return HttpResponseJSON({"status": "ok"}) try: - user_data = get_user_data(email=email, extra_fields=["email_id"]) + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: + try: + user_data = get_user_data( + email=email, + extra_fields=["email_id"], + use_braze_backend=True, + ) + except Exception: + sentry_sdk.capture_exception() + user_data = get_user_data( + email=email, + extra_fields=["email_id"], + use_braze_backend=False, + ) + elif settings.BRAZE_ONLY_READ_ENABLE: + user_data = get_user_data( + email=email, + extra_fields=["email_id"], + use_braze_backend=True, + ) + else: + user_data = get_user_data( + email=email, + extra_fields=["email_id"], + use_braze_backend=False, + ) except NewsletterException as e: return newsletter_exception_response(e) @@ -556,6 +762,7 @@ def send_recovery_message(request): # Custom update methods +# TODO confirm if this endpoint is still needed. @csrf_exempt def custom_unsub_reason(request): """Update the reason field for the user, which logs why the user @@ -670,7 +877,36 @@ def lookup_user(request): return invalid_email_response() try: - user_data = get_user_data(token=token, email=email, masked=not authorized) + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: + try: + user_data = get_user_data( + token=token, + email=email, + masked=not authorized, + use_braze_backend=True, + ) + except Exception: + sentry_sdk.capture_exception() + user_data = get_user_data( + token=token, + email=email, + masked=not authorized, + use_braze_backend=False, + ) + elif settings.BRAZE_ONLY_READ_ENABLE: + user_data = get_user_data( + token=token, + email=email, + masked=not authorized, + use_braze_backend=True, + ) + else: + user_data = get_user_data( + token=token, + email=email, + masked=not authorized, + use_braze_backend=False, + ) except NewsletterException as e: return newsletter_exception_response(e) @@ -697,12 +933,27 @@ def list_newsletters(request): return render(request, "news/newsletters.html", {"newsletters": active_newsletters}) -def update_user_task(request, api_call_type, data=None, optin=False, sync=False): +def update_user_task( + request, + api_call_type, + data=None, + optin=False, + sync=False, + use_braze_backend=False, + should_send_tx_messages=True, + rate_limit_increment=True, + extra_metrics_tags=None, + pre_generated_token=None, + pre_generated_email_id=None, +): """Call the update_user task async with the right parameters. If sync==True, be sure to include the token in the response. Otherwise, basket can just do everything in the background. """ + if extra_metrics_tags is None: + extra_metrics_tags = [] + data = data or request.POST.dict() newsletters = parse_newsletters_csv(data.get("newsletters")) @@ -777,7 +1028,7 @@ def update_user_task(request, api_call_type, data=None, optin=False, sync=False) group="basket.news.views.update_user_task.subscribe", key=lambda x, y: f"{data['newsletters']}-{email}", rate=settings.EMAIL_SUBSCRIBE_RATE_LIMIT, - increment=True, + increment=rate_limit_increment, ): raise Ratelimited() @@ -788,15 +1039,22 @@ def update_user_task(request, api_call_type, data=None, optin=False, sync=False) group="basket.news.views.update_user_task.set", key=lambda x, y: f"{data['newsletters']}-{token}", rate=settings.EMAIL_SUBSCRIBE_RATE_LIMIT, - increment=True, + increment=rate_limit_increment, ): raise Ratelimited() if sync: - metrics.incr("news.views.subscribe.sync") + metrics.incr("news.views.subscribe.sync", tags=extra_metrics_tags) if settings.MAINTENANCE_MODE and not settings.MAINTENANCE_READ_ONLY: # save what we can - tasks.upsert_user.delay(api_call_type, data) + tasks.upsert_user.delay( + api_call_type, + data, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) # have to error since we can't return a token return HttpResponseJSON( { @@ -808,7 +1066,12 @@ def update_user_task(request, api_call_type, data=None, optin=False, sync=False) ) try: - user_data = get_user_data(email=email, token=token, extra_fields=["email_id"]) + user_data = get_user_data( + email=email, + token=token, + extra_fields=["email_id"], + use_braze_backend=use_braze_backend, + ) except NewsletterException as e: return newsletter_exception_response(e) @@ -824,8 +1087,23 @@ def update_user_task(request, api_call_type, data=None, optin=False, sync=False) 400, ) - token, created = tasks.upsert_contact(api_call_type, data, user_data) + token, created = tasks.upsert_contact( + api_call_type, + data, + user_data, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) return HttpResponseJSON({"status": "ok", "token": token, "created": created}) else: - tasks.upsert_user.delay(api_call_type, data) + tasks.upsert_user.delay( + api_call_type, + data, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) return HttpResponseJSON({"status": "ok"}) diff --git a/basket/settings.py b/basket/settings.py index 6133ff12..beb42ca6 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -222,6 +222,11 @@ def path(*args): BRAZE_DELETE_USER_ENABLE = config("BRAZE_DELETE_USER_ENABLE", parser=bool, default="false") +BRAZE_PARALLEL_WRITE_ENABLE = config("BRAZE_PARALLEL_WRITE_ENABLE", parser=bool, default="false") +BRAZE_ONLY_WRITE_ENABLE = config("BRAZE_ONLY_WRITE_ENABLE", parser=bool, default="false") +BRAZE_READ_WITH_FALLBACK_ENABLE = config("BRAZE_READ_WITH_FALLBACK_ENABLE", parser=bool, default="false") +BRAZE_ONLY_READ_ENABLE = config("BRAZE_ONLY_READ_ENABLE", parser=bool, default="false") + # Mozilla CTMS CTMS_ENV = config("CTMS_ENV", default="").lower() CTMS_ENABLED = config("CTMS_ENABLED", parser=bool, default="false") From 60a8f25f4fe46db21103e70b30bc3aecacf34f85 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:25:21 -0300 Subject: [PATCH 008/137] Improve comment about token/email pre-generation --- basket/news/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/views.py b/basket/news/views.py index cb495f2a..2a6b7fe8 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -468,7 +468,7 @@ def handler( ) # We are doing parallel writes and want the token/email_id - # to be same so we eagerly generate them now. + # to be same in both CTMS and Braze so we eagerly generate them now. pre_generated_token = generate_token() pre_generated_email_id = generate_token() From 97dd6004e1985902695c46fe4cf52528a4934acc Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:34:34 -0300 Subject: [PATCH 009/137] remove function --- basket/news/newsletters.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/basket/news/newsletters.py b/basket/news/newsletters.py index 9fdd7ed0..d7445caa 100644 --- a/basket/news/newsletters.py +++ b/basket/news/newsletters.py @@ -155,11 +155,6 @@ def slug_to_vendor_id(slug): return _newsletters()["by_name"][slug].vendor_id -def vendor_id_to_slug(vendor_id): - """Given a newsletter's vendor_id, return its slug""" - return _newsletters()["by_vendor_id"][vendor_id].slug - - def newsletter_fields(): """Get a list of all the newsletter backend-specific fields""" return list(_newsletters()["by_vendor_id"].keys()) From e40a9d80d4f84fa68cf346fe28d7454c705a1e4c Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:36:06 -0300 Subject: [PATCH 010/137] implement braze.get, modify from_vendor accordingly --- basket/news/backends/braze.py | 62 ++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index e7483c77..22b2e952 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -8,8 +8,6 @@ import requests -from basket.news.newsletters import vendor_id_to_slug - # Braze errors: https://www.braze.com/docs/api/errors/ class BrazeBadRequestError(Exception): @@ -45,6 +43,7 @@ class BrazeEndpoint(Enum): USERS_EXPORT_IDS = "/users/export/ids" USERS_TRACK = "/users/track" USERS_DELETE = "/users/delete" + SUBSCRIPTION_USER_STATUS = "/subscription/user/status" class BrazeInterface: @@ -60,7 +59,7 @@ def __init__(self, base_url, api_key): self.active = bool(self.api_key) - def _request(self, endpoint, data=None): + def _request(self, endpoint, data=None, method="POST", params=None): """ Make a request to the Braze API. @@ -88,10 +87,14 @@ def _request(self, endpoint, data=None): try: if settings.DEBUG: - print(f"POST {url}") # noqa: T201 + print(f"{method} {url}") # noqa: T201 print(f"Headers: {headers}") # noqa: T201 + print(params) # noqa: T201 print(json.dumps(data, indent=2)) # noqa: T201 - response = requests.post(url, headers=headers, data=json.dumps(data)) + if method == "GET": + response = requests.get(url, headers=headers, params=params, data=json.dumps(data)) + else: + response = requests.post(url, headers=headers, data=json.dumps(data)) response.raise_for_status() return response.json() except requests.exceptions.HTTPError as exc: @@ -167,7 +170,7 @@ def track_user(self, email, event=None, user_data=None): return self._request(BrazeEndpoint.USERS_TRACK, data) - def export_users(self, email, fields_to_export=None): + def export_users(self, email, fields_to_export=None, external_id=None): """ Export user profile by identifier. @@ -181,6 +184,9 @@ def export_users(self, email, fields_to_export=None): "email_address": email, } + if external_id: + data["external_ids"] = [external_id] + if fields_to_export: data["fields_to_export"] = fields_to_export @@ -220,6 +226,17 @@ def send_campaign(self, email, campaign_id): return self._request(BrazeEndpoint.CAMPAIGNS_TRIGGER_SEND, data) + def get_user_subscriptions(self, external_id, email): + """ + Get user's subscription groups and their status. + + https://www.braze.com/docs/api/endpoints/subscription_groups/get_list_user_subscription_groups/ + + """ + params = {"external_id": external_id, "email": email} + + return self._request(BrazeEndpoint.SUBSCRIPTION_USER_STATUS, None, "GET", params) + class Braze: """Basket interface to Braze""" @@ -234,7 +251,32 @@ def get( email=None, fxa_id=None, ): - raise NotImplementedError + user_response = self.interface.export_users( + email, + [ + "braze_id", + "country", + "created_at", + "custom_attributes", + "email", + "email_subscribe", + "external_id", + "first_name", + "language", + "last_name", + ], + token, + ) + + if user_response["users"]: + user_data = user_response["users"][0] + + subscription_response = self.interface.get_user_subscriptions(user_data["external_id"], email) + subscriptions = subscription_response.get("users", [{}])[0].get("subscription_groups", []) + + return self.from_vendor(user_data, subscriptions) + + return None def add(self, data): raise NotImplementedError @@ -261,10 +303,8 @@ def from_vendor(self, braze_user_data, subscription_groups): newsletters_v1 = custom_attributes.get("newsletters_v1", []) waitlists_v1 = custom_attributes.get("waitlists_v1", []) - braze_subscription_ids = [ - subscription["subscription_group_id"] for subscription in subscription_groups if subscription["subscription_status"] == "subscribed" - ] - newsletters = [vendor_id_to_slug(vendor_id) for vendor_id in braze_subscription_ids] + # TODO: query basket for vendor_id and slug instead + newsletters = [subscription["name"] for subscription in subscription_groups if subscription["status"] == "Subscribed"] basket_user_data = { "email": braze_user_data["email"], From fffb45ab4e0635f9172be2bd9ea2dbe4f252b2d7 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:37:00 -0300 Subject: [PATCH 011/137] address comments, fix typos, remove newsletter_v1 and waitlist_v1 (deprecated) --- basket/news/backends/braze.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 22b2e952..d219c571 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -297,11 +297,8 @@ def from_vendor(self, braze_user_data, subscription_groups): """ Converts Braze-formatted data to Basket-formatted data """ - custom_attributes = braze_user_data.get("custom_attributes", {}) - user_attributes = custom_attributes.get("user_attributes_v1", [{}])[0] - newsletters_v1 = custom_attributes.get("newsletters_v1", []) - waitlists_v1 = custom_attributes.get("waitlists_v1", []) + user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] # TODO: query basket for vendor_id and slug instead newsletters = [subscription["name"] for subscription in subscription_groups if subscription["status"] == "Subscribed"] @@ -315,29 +312,17 @@ def from_vendor(self, braze_user_data, subscription_groups): "country": braze_user_data.get("country") or user_attributes.get("mailing_country"), "lang": braze_user_data.get("language") or user_attributes.get("email_lang", "en"), "newsletters": newsletters, - "newsletters_v1": newsletters_v1, # We need to record newsletters_v1 here so we can continue to update it in Braze after CTMS is removed "created_date": user_attributes.get("created_at"), "last_modified_date": user_attributes.get("updated_at"), "optin": braze_user_data.get("email_subscribe") == "opted_in", "optout": braze_user_data.get("email_subscribe") == "unsubscribed", - "token": user_attributes.get("basket_token") or braze_user_data["external_id"], + "token": user_attributes.get("basket_token"), # missing fxa fields: fxa_deleted, fxa_id, fxa_lang, fxa_primary_email, fxa_service } - if user_attributes.get(["has_fxa"]) == "true" and user_attributes.get(["fxa_created_at"]): + if user_attributes.get("has_fxa") == "true" and user_attributes.get("fxa_created_at"): basket_user_data["fxa_create_date"] = user_attributes["fxa_created_at"] - if waitlists_v1: - for waitlist in waitlists_v1: - # Legacy waitlist format. For backward compatibility. - # This logic was ported over from the CTMS from_vendor method. - name = waitlist.get(["waitlist_name"]) - if name == "guardian-vpn-waitlist": - basket_user_data["fpn_country"] = waitlist.get(["waitlist_geo"]) - basket_user_data["fpn_platform"] = waitlist.get["waitlist_platform"] - if name.startswith("relay") and name.endswith("-waitlist"): - basket_user_data["relay_country"] = waitlist.get(["waitlist_geo"]) - return basket_user_data def to_vendor(self): From 007c628cef15f0e466bea1062ea671841b1e907f Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:56:13 -0300 Subject: [PATCH 012/137] use boolean value --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index d219c571..c537e441 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -320,7 +320,7 @@ def from_vendor(self, braze_user_data, subscription_groups): # missing fxa fields: fxa_deleted, fxa_id, fxa_lang, fxa_primary_email, fxa_service } - if user_attributes.get("has_fxa") == "true" and user_attributes.get("fxa_created_at"): + if user_attributes.get("has_fxa") and user_attributes.get("fxa_created_at"): basket_user_data["fxa_create_date"] = user_attributes["fxa_created_at"] return basket_user_data From 496f529dd3d0e5c294f0d754225ba3170990f288 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:39:50 -0300 Subject: [PATCH 013/137] add fxa fields --- basket/news/backends/braze.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index c537e441..c8245a16 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -317,12 +317,13 @@ def from_vendor(self, braze_user_data, subscription_groups): "optin": braze_user_data.get("email_subscribe") == "opted_in", "optout": braze_user_data.get("email_subscribe") == "unsubscribed", "token": user_attributes.get("basket_token"), - # missing fxa fields: fxa_deleted, fxa_id, fxa_lang, fxa_primary_email, fxa_service + "fxa_service": user_attributes.get("fxa_first_service"), + "fxa_lang": user_attributes.get("fxa_lang"), + "fxa_primary_email": user_attributes.get("fxa_primary_email"), + "fxa_create_date": user_attributes.get("fxa_created_at") if user_attributes.get("has_fxa") else None, + # TODO: missing field: fxa_id } - if user_attributes.get("has_fxa") and user_attributes.get("fxa_created_at"): - basket_user_data["fxa_create_date"] = user_attributes["fxa_created_at"] - return basket_user_data def to_vendor(self): From e458fee8d04cc1e8af6612841427d9fa69e19e8e Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:58:17 -0300 Subject: [PATCH 014/137] fix newsletters field --- basket/news/backends/braze.py | 8 +++++--- basket/news/newsletters.py | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index c8245a16..3dd06ba4 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -8,6 +8,8 @@ import requests +from basket.news.newsletters import vendor_id_to_slug + # Braze errors: https://www.braze.com/docs/api/errors/ class BrazeBadRequestError(Exception): @@ -300,8 +302,8 @@ def from_vendor(self, braze_user_data, subscription_groups): user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] - # TODO: query basket for vendor_id and slug instead - newsletters = [subscription["name"] for subscription in subscription_groups if subscription["status"] == "Subscribed"] + subscription_ids = [subscription["id"] for subscription in subscription_groups if subscription["status"] == "Subscribed"] + newsletter_slugs = list(filter(None, map(vendor_id_to_slug, subscription_ids))) basket_user_data = { "email": braze_user_data["email"], @@ -311,7 +313,7 @@ def from_vendor(self, braze_user_data, subscription_groups): "last_name": braze_user_data.get("last_name"), "country": braze_user_data.get("country") or user_attributes.get("mailing_country"), "lang": braze_user_data.get("language") or user_attributes.get("email_lang", "en"), - "newsletters": newsletters, + "newsletters": newsletter_slugs, "created_date": user_attributes.get("created_at"), "last_modified_date": user_attributes.get("updated_at"), "optin": braze_user_data.get("email_subscribe") == "opted_in", diff --git a/basket/news/newsletters.py b/basket/news/newsletters.py index d7445caa..4f4c1fe1 100644 --- a/basket/news/newsletters.py +++ b/basket/news/newsletters.py @@ -155,6 +155,14 @@ def slug_to_vendor_id(slug): return _newsletters()["by_name"][slug].vendor_id +def vendor_id_to_slug(vendor_id): + """Given a newsletter's vendor_id, return its slug""" + try: + return _newsletters()["by_vendor_id"][vendor_id].slug + except KeyError: + return None + + def newsletter_fields(): """Get a list of all the newsletter backend-specific fields""" return list(_newsletters()["by_vendor_id"].keys()) From 31bfbacad346437b3827b3afd321516707d58822 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:01:52 -0300 Subject: [PATCH 015/137] Improve log --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 3dd06ba4..162163b2 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -91,7 +91,7 @@ def _request(self, endpoint, data=None, method="POST", params=None): if settings.DEBUG: print(f"{method} {url}") # noqa: T201 print(f"Headers: {headers}") # noqa: T201 - print(params) # noqa: T201 + print(f"Params: {params}") # noqa: T201 print(json.dumps(data, indent=2)) # noqa: T201 if method == "GET": response = requests.get(url, headers=headers, params=params, data=json.dumps(data)) From 55647bcc0358dae5acffc7ce20e5e6705ed021d4 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:12:46 -0300 Subject: [PATCH 016/137] fix bug --- basket/news/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 97fd8f28..50e1ca61 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -302,7 +302,7 @@ def upsert_contact( if user_data is None: # no user found. create new one. token = update_data["token"] = pre_generated_token or generate_token() - update_data["email_id"] = update_data["email_id"] or pre_generated_email_id + update_data["email_id"] = update_data.get("email_id") or pre_generated_email_id if settings.MAINTENANCE_MODE: if use_braze_backend: From 4a526f5024886914da357574860e1f6701b4d26b Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:13:17 -0300 Subject: [PATCH 017/137] remove unnecessary return --- basket/news/backends/braze.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 162163b2..83587896 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -278,8 +278,6 @@ def get( return self.from_vendor(user_data, subscriptions) - return None - def add(self, data): raise NotImplementedError From 427339fa13203c1ecccdc7cb9226b6636ea08677 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:13:08 -0300 Subject: [PATCH 018/137] add first draft of to_vendor --- basket/news/backends/braze.py | 65 ++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 83587896..caa7fe60 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -8,7 +8,8 @@ import requests -from basket.news.newsletters import vendor_id_to_slug +from basket.base.utils import is_valid_uuid +from basket.news.newsletters import slug_to_vendor_id, vendor_id_to_slug # Braze errors: https://www.braze.com/docs/api/errors/ @@ -305,7 +306,7 @@ def from_vendor(self, braze_user_data, subscription_groups): basket_user_data = { "email": braze_user_data["email"], - "email_id": braze_user_data["external_id"], + "email_id": braze_user_data["external_id"], # TODO: conditional on migration status config (could be basket token instead) "id": braze_user_data["braze_id"], "first_name": braze_user_data.get("first_name"), "last_name": braze_user_data.get("last_name"), @@ -326,8 +327,64 @@ def from_vendor(self, braze_user_data, subscription_groups): return basket_user_data - def to_vendor(self): - raise NotImplementedError + def to_vendor(self, basket_user_data, update_data, update_existing_only=True, events=None): + now = timezone.now().isoformat() + + country = update_data.get("country") or basket_user_data.get("country") # todo process + language = update_data.get("lang") or basket_user_data.get("lang", "en") # todo process + optin = update_data["optin"] if "optin" in update_data else basket_user_data.get("optin") + optout = update_data["optout"] if "optout" in update_data else basket_user_data.get("optout") + email_id = update_data.get("email_id") or basket_user_data.get("email_id") + token = update_data.get("token") or basket_user_data.get("token") + + subscription_groups = [] + if isinstance(update_data.get("newsletters"), dict): + for slug, is_subscribed in update_data["newsletters"].items(): + vendor_id = slug_to_vendor_id(slug) + if is_valid_uuid(vendor_id): + subscription_groups.append( + { + "subscription_group_id": vendor_id, + "subscription_state": "subscribed" if is_subscribed else "unsubscribed", + } + ) + + braze_data = { + "attributes": [ + { + "external_id": email_id, # TODO: conditional on migration status config (could be basket token instead) + "email": basket_user_data.get("email"), + "first_name": basket_user_data.get("first_name"), + "last_name": basket_user_data.get("last_name"), + "country": country, + "language": language, + "update_timestamp": now, + "_update_existing_only": update_existing_only, + "email_subscribe": "opted_in" if optin else "unsubscribed" if optout else "subscribed", + "subscription_groups": subscription_groups, + "user_attributes_v1": [ + { + "basket_token": token, + "created_at": {"$time": basket_user_data.get("created_date", now)}, + "email_lang": language, + "mailing_country": country, + "updated_at": {"$time": now}, + "has_fxa": bool(basket_user_data.get("fxa_create_date")), + "fxa_created_at": basket_user_data.get("fxa_create_date"), + "fxa_first_service": basket_user_data.get("fxa_service"), + "fxa_lang": basket_user_data.get("fxa_lang"), + "fxa_primary_email": basket_user_data.get("fxa_primary_email"), + # TODO: missing field: fxa_id + } + ], + } + ] + } + + if events: + braze_data["events"] = events + + return braze_data braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) From 352bb34dc743c4c721de854bb7d4875f3303178d Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:22:00 -0300 Subject: [PATCH 019/137] add country and language processing --- basket/news/backends/braze.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index caa7fe60..626fd00a 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -9,6 +9,7 @@ import requests from basket.base.utils import is_valid_uuid +from basket.news.backends.ctms import process_country, process_lang from basket.news.newsletters import slug_to_vendor_id, vendor_id_to_slug @@ -330,8 +331,8 @@ def from_vendor(self, braze_user_data, subscription_groups): def to_vendor(self, basket_user_data, update_data, update_existing_only=True, events=None): now = timezone.now().isoformat() - country = update_data.get("country") or basket_user_data.get("country") # todo process - language = update_data.get("lang") or basket_user_data.get("lang", "en") # todo process + country = process_country(update_data.get("country") or basket_user_data.get("country")) + language = process_lang(update_data.get("lang") or basket_user_data.get("lang")) optin = update_data["optin"] if "optin" in update_data else basket_user_data.get("optin") optout = update_data["optout"] if "optout" in update_data else basket_user_data.get("optout") email_id = update_data.get("email_id") or basket_user_data.get("email_id") From 22df65fea043ffcc4b51b4818c6d18dcbf393f6d Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Mon, 20 Oct 2025 08:45:18 -0700 Subject: [PATCH 020/137] Create command to push to fxa queue --- .../management/commands/process_fxa_queue.py | 1 + .../commands/push_message_to_queue.py | 54 +++++++++++++++++++ basket/settings.py | 1 + 3 files changed, 56 insertions(+) create mode 100644 basket/news/management/commands/push_message_to_queue.py diff --git a/basket/news/management/commands/process_fxa_queue.py b/basket/news/management/commands/process_fxa_queue.py index c6343fa5..ceca604f 100644 --- a/basket/news/management/commands/process_fxa_queue.py +++ b/basket/news/management/commands/process_fxa_queue.py @@ -55,6 +55,7 @@ def handle(self, *args, **options): region_name=settings.FXA_EVENTS_QUEUE_REGION, aws_access_key_id=settings.FXA_EVENTS_ACCESS_KEY_ID, aws_secret_access_key=settings.FXA_EVENTS_SECRET_ACCESS_KEY, + endpoint_url=settings.FXA_EVENTS_ENDPOINT_URL, ) queue = sqs.Queue(settings.FXA_EVENTS_QUEUE_URL) diff --git a/basket/news/management/commands/push_message_to_queue.py b/basket/news/management/commands/push_message_to_queue.py new file mode 100644 index 00000000..d51e2166 --- /dev/null +++ b/basket/news/management/commands/push_message_to_queue.py @@ -0,0 +1,54 @@ +import json +import logging + +from django.conf import settings +from django.core.management import BaseCommand + +import boto3 + +from basket.news.tasks import ( + fxa_delete, + fxa_email_changed, + fxa_login, + fxa_newsletters_update, + fxa_verified, +) + +FXA_EVENT_TYPES = { + "delete": fxa_delete, + "login": fxa_login, + "newsletters-update": fxa_newsletters_update, + "primaryEmailChanged": fxa_email_changed, + "verified": fxa_verified, +} +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument("-m", "--message", type=str, default="{}", help="JSON message to process") + parser.add_argument( + "-e", + "--event", + type=str, + default="event", + help="Payload event", + ) + + def handle(self, *args, **options): + message = options.get("message") + event = options.get("event") + + print(event) + + sqs = boto3.resource( + "sqs", + region_name=settings.FXA_EVENTS_QUEUE_REGION, + aws_access_key_id=settings.FXA_EVENTS_ACCESS_KEY_ID, + aws_secret_access_key=settings.FXA_EVENTS_SECRET_ACCESS_KEY, + endpoint_url=settings.FXA_EVENTS_ENDPOINT_URL, + ) + + queue = sqs.Queue(settings.FXA_EVENTS_QUEUE_URL) + + queue.send_message(MessageBody=json.dumps({"event": event, "Message": message})) diff --git a/basket/settings.py b/basket/settings.py index c478c157..31fe6ef4 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -427,6 +427,7 @@ def before_send(event, hint): FXA_EVENTS_QUEUE_URL = config("FXA_EVENTS_QUEUE_URL", default="") FXA_EVENTS_QUEUE_WAIT_TIME = config("FXA_EVENTS_QUEUE_WAIT_TIME", parser=int, default="10") FXA_EVENTS_SNITCH_ID = config("FXA_EVENTS_SNITCH_ID", default="") +FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default="") # stage or production # https://github.com/mozilla/PyFxA/blob/main/fxa/constants.py From 1bb3ef0cd35039865f134dc981319c480f80d397 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:27:00 -0300 Subject: [PATCH 021/137] refactor to_vendor, implement braze add --- basket/news/backends/braze.py | 60 ++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 626fd00a..c85f79c1 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -241,6 +241,13 @@ def get_user_subscriptions(self, external_id, email): return self._request(BrazeEndpoint.SUBSCRIPTION_USER_STATUS, None, "GET", params) + def save_user(self, braze_user_data): + """ + Creates a new user or updates attributes for an existing user in Braze. + https://www.braze.com/docs/api/endpoints/user_data/post_user_track/ + """ + return self._request(BrazeEndpoint.USERS_TRACK, braze_user_data) + class Braze: """Basket interface to Braze""" @@ -281,7 +288,14 @@ def get( return self.from_vendor(user_data, subscriptions) def add(self, data): - raise NotImplementedError + custom_attributes = {"_update_existing_only": False} + + # If we don't have an `email_id`, we need to submit the user alias. + if not data.get("email_id"): + custom_attributes["user_alias"] = {"alias_name": data.email, "alias_label": "email"} + + braze_user_data = self.to_vendor(None, data, custom_attributes) + self.interface.save_user(braze_user_data) def update(self, existing_data, update_data): raise NotImplementedError @@ -328,15 +342,12 @@ def from_vendor(self, braze_user_data, subscription_groups): return basket_user_data - def to_vendor(self, basket_user_data, update_data, update_existing_only=True, events=None): - now = timezone.now().isoformat() + def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=None, events=None): + updated_user_data = (basket_user_data or {}) | (update_data or {}) - country = process_country(update_data.get("country") or basket_user_data.get("country")) - language = process_lang(update_data.get("lang") or basket_user_data.get("lang")) - optin = update_data["optin"] if "optin" in update_data else basket_user_data.get("optin") - optout = update_data["optout"] if "optout" in update_data else basket_user_data.get("optout") - email_id = update_data.get("email_id") or basket_user_data.get("email_id") - token = update_data.get("token") or basket_user_data.get("token") + now = timezone.now().isoformat() + country = process_country(updated_user_data.get("country")) + language = process_lang(updated_user_data.get("lang")) subscription_groups = [] if isinstance(update_data.get("newsletters"), dict): @@ -353,32 +364,37 @@ def to_vendor(self, basket_user_data, update_data, update_existing_only=True, ev braze_data = { "attributes": [ { - "external_id": email_id, # TODO: conditional on migration status config (could be basket token instead) - "email": basket_user_data.get("email"), - "first_name": basket_user_data.get("first_name"), - "last_name": basket_user_data.get("last_name"), + "external_id": updated_user_data.get("email_id"), # TODO: conditional on migration status config (could be basket token instead) + "email": updated_user_data.get("email"), + "first_name": updated_user_data.get("first_name"), + "last_name": updated_user_data.get("last_name"), "country": country, "language": language, "update_timestamp": now, - "_update_existing_only": update_existing_only, - "email_subscribe": "opted_in" if optin else "unsubscribed" if optout else "subscribed", + "_update_existing_only": True, + "email_subscribe": "opted_in" + if updated_user_data.get("optin") + else "unsubscribed" + if updated_user_data.get("optout") + else "subscribed", "subscription_groups": subscription_groups, "user_attributes_v1": [ { - "basket_token": token, - "created_at": {"$time": basket_user_data.get("created_date", now)}, + "basket_token": updated_user_data.get("token"), + "created_at": {"$time": updated_user_data.get("created_date", now)}, "email_lang": language, "mailing_country": country, "updated_at": {"$time": now}, - "has_fxa": bool(basket_user_data.get("fxa_create_date")), - "fxa_created_at": basket_user_data.get("fxa_create_date"), - "fxa_first_service": basket_user_data.get("fxa_service"), - "fxa_lang": basket_user_data.get("fxa_lang"), - "fxa_primary_email": basket_user_data.get("fxa_primary_email"), + "has_fxa": bool(updated_user_data.get("fxa_create_date")), + "fxa_created_at": updated_user_data.get("fxa_create_date"), + "fxa_first_service": updated_user_data.get("fxa_service"), + "fxa_lang": updated_user_data.get("fxa_lang"), + "fxa_primary_email": updated_user_data.get("fxa_primary_email"), # TODO: missing field: fxa_id } ], } + | (custom_attributes or {}) ] } From e7453747a95c218900c6b84a4b8ee6164d2aa298 Mon Sep 17 00:00:00 2001 From: stephendherrera Date: Mon, 20 Oct 2025 14:16:18 -0600 Subject: [PATCH 022/137] Migration script for UUID. --- basket/news/backends/braze.py | 38 + .../process_braze_external_id_migrator.py | 97 +++ basket/news/tests/test_braze.py | 19 + ...test_process_braze_external_id_migrator.py | 189 +++++ requirements/dev.in | 3 + requirements/dev.txt | 572 ++++++++++--- requirements/prod.in | 3 + requirements/prod.txt | 791 ++++++++++++++++-- 8 files changed, 1557 insertions(+), 155 deletions(-) create mode 100644 basket/news/management/commands/process_braze_external_id_migrator.py create mode 100644 basket/news/tests/test_process_braze_external_id_migrator.py diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index a072f185..d532dd8d 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -43,6 +43,7 @@ class BrazeEndpoint(Enum): USERS_EXPORT_IDS = "/users/export/ids" USERS_TRACK = "/users/track" USERS_DELETE = "/users/delete" + USERS_MIGRATE_EXTERNAL_ID = "/users/external_ids/rename" class BrazeClient: @@ -218,5 +219,42 @@ def send_campaign(self, email, campaign_id): return self._request(BrazeEndpoint.CAMPAIGNS_TRIGGER_SEND, data) + def migrate_external_id(self, migrations): + """ + Migrate a user's external_id to a new value. 50 rename objects per request is the hard Braze limit. + + If the migrations list has more than 50 objects, method will send multiple requests, each with <=50 objects. + + https://www.braze.com/docs/api/endpoints/user_data/external_id_migration/post_external_ids_rename/#prerequisites + + """ + + if not (isinstance(migrations, list) and all(isinstance(item, dict) for item in migrations)): + raise BrazeClientError("migrations must be a list of dictionaries") + + results = [] + errors = [] + + for i in range(0, len(migrations), 50): + chunk = migrations[i : i + 50] + rename_request = {"external_id_renames": chunk} + rename_response = self._request(BrazeEndpoint.USERS_MIGRATE_EXTERNAL_ID, rename_request) + + external_ids = rename_response.get("external_ids", []) + rename_errors = rename_response.get("rename_errors", []) + + if not external_ids: + raise BrazeNotFoundError("No external_ids found for migration.") + + results.extend(external_ids) + errors.extend(rename_errors) + + return { + "braze_collected_response": { + "external_ids": results, + "rename_errors": errors, + } + } + braze = BrazeClient(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY) diff --git a/basket/news/management/commands/process_braze_external_id_migrator.py b/basket/news/management/commands/process_braze_external_id_migrator.py new file mode 100644 index 00000000..1440c322 --- /dev/null +++ b/basket/news/management/commands/process_braze_external_id_migrator.py @@ -0,0 +1,97 @@ +import json +import time + +from django.core.management.base import BaseCommand, CommandError + +import pandas as pd +from google.cloud import storage + +from basket.news.backends.braze import braze + + +class Command(BaseCommand): + help = "Migrator utility to fetch external_ids from a Parquet file in GCS and migrate them to updated UUIDs." + + def add_arguments(self, parser): + parser.add_argument("--project", type=str, required=False, help="Project ID") + parser.add_argument("--bucket", type=str, required=True, help="GCS Storage Bucket") + parser.add_argument("--prefix", type=str, required=True, help="GCS Storage Prefix") + parser.add_argument("--file", type=str, required=True, help="Name of file to migrate") + parser.add_argument( + "--start_timestamp", + type=str, + required=False, + help="create_timestamp to start from", + ) + parser.add_argument( + "--chunk_size", + type=int, + required=False, + default=50, + help="Number of records per migration batch, 50 max", + ) + + def handle(self, **options): + project = options.get("project") + bucket = options["bucket"] + prefix = options["prefix"] + file_name = options["file"] + start_timestamp = options.get("start_timestamp") + chunk_size = options["chunk_size"] + try: + self.process_and_migrate_parquet_file(project, bucket, prefix, file_name, start_timestamp, chunk_size) + except Exception as err: + raise CommandError(f"Error processing Parquet file: {str(err)}") from err + + def process_and_migrate_parquet_file(self, project, bucket, prefix, file_name, start_timestamp, chunk_size): + client = storage.Client(project=project) + blob = client.bucket(bucket).blob(f"{prefix}/{file_name}") + if not blob.exists(): + raise CommandError(f"File '{file_name}' not found in bucket '{bucket}' with prefix '{prefix}'") + df = self.read_parquet_blob(blob) + if start_timestamp and "create_timestamp" in df.columns: + df = df[df["create_timestamp"] >= start_timestamp] + migrations = self.build_migrations(df) + + for i in range(0, len(migrations), chunk_size): + chunk = migrations[i : i + chunk_size] + braze_chunk = self.strip_for_braze(chunk) + try: + braze.migrate_external_id(braze_chunk) + time.sleep(0.07) + except Exception as e: + failure = { + "current_external_id": self.mask(chunk[0]["current_external_id"]), + "new_external_id": self.mask(chunk[0]["new_external_id"]), + "create_timestamp": str(chunk[0].get("create_timestamp", "")), + "reason": str(e), + } + self.stdout.write(self.style.ERROR(json.dumps(failure, indent=2))) + raise CommandError("Migration failed. Process terminated error.") from None + + def strip_for_braze(self, chunk): + return [ + { + "current_external_id": item["current_external_id"], + "new_external_id": item["new_external_id"], + } + for item in chunk + ] + + def mask(self, external_id): + parts = str(external_id).split("-") + return "-".join(["***"] * 3 + parts[3:]) + + def read_parquet_blob(self, blob): + data = blob.download_as_bytes() + return pd.read_parquet(pd.io.common.BytesIO(data)) + + def build_migrations(self, df): + return [ + { + "current_external_id": row.email_id, + "new_external_id": row.basket_token, + "create_timestamp": getattr(row, "create_timestamp", ""), + } + for row in df.itertuples(index=False) + ] diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index b15f4587..734276a8 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -14,6 +14,25 @@ def braze_client(): return braze.BrazeClient("http://test.com", "test_api_key") +def test_migrate_external_id_success(braze_client): + migrations = [ + {"current_external_id": "old_id_1", "new_external_id": "new_id_1"}, + {"current_external_id": "old_id_2", "new_external_id": "new_id_2"}, + ] + mock_response = { + "external_ids": ["new_id_1", "new_id_2"], + "rename_errors": [], + } + with mock.patch.object(braze.BrazeClient, "_request", return_value=mock_response): + result = braze_client.migrate_external_id(migrations) + assert result == { + "braze_collected_response": { + "external_ids": ["new_id_1", "new_id_2"], + "rename_errors": [], + } + } + + def test_braze_client_no_api_key(): with pytest.warns(UserWarning, match="Braze API key is not configured"): braze_client = braze.BrazeClient("http://test.com", "") diff --git a/basket/news/tests/test_process_braze_external_id_migrator.py b/basket/news/tests/test_process_braze_external_id_migrator.py new file mode 100644 index 00000000..672e79eb --- /dev/null +++ b/basket/news/tests/test_process_braze_external_id_migrator.py @@ -0,0 +1,189 @@ +import io +from unittest import mock + +from django.core.management.base import CommandError + +import pandas as pd +import pytest + +from basket.news.management.commands.process_braze_external_id_migrator import Command + + +@pytest.fixture +def sample_df(): + return pd.DataFrame( + [ + {"email_id": "id1", "basket_token": "token1", "create_timestamp": "2024-01-01T00:00:00"}, + {"email_id": "id2", "basket_token": "token2", "create_timestamp": "2024-02-01T00:00:00"}, + ] + ) + + +def parquet_bytes(df): + buf = io.BytesIO() + df.to_parquet(buf, index=False) + buf.seek(0) + return buf.read() + + +@pytest.fixture(autouse=True) +def mock_braze(): + with mock.patch("basket.news.management.commands.process_braze_external_id_migrator.braze") as braze_mock: + yield braze_mock + + +@pytest.fixture(autouse=True) +def mock_storage_client(): + with mock.patch("basket.news.management.commands.process_braze_external_id_migrator.storage.Client") as storage_mock: + yield storage_mock + + +def test_successful_migration(mock_storage_client, mock_braze, sample_df): + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(sample_df) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + mock_braze.migrate_external_id.return_value = {"braze_collected_response": {"external_ids": ["id1", "id2"], "rename_errors": []}} + + cmd = Command() + cmd.stdout = mock.Mock() + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 + ) + expected_chunk = [ + {"current_external_id": "id1", "new_external_id": "token1"}, + {"current_external_id": "id2", "new_external_id": "token2"}, + ] + mock_braze.migrate_external_id.assert_called_once_with(expected_chunk) + + +def test_file_not_found(mock_storage_client, mock_braze): + mock_blob = mock.Mock() + mock_blob.exists.return_value = False + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + cmd = Command() + with pytest.raises(CommandError) as exc: + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 + ) + assert "not found" in str(exc.value) + + +def test_migration_failure(mock_storage_client, mock_braze, sample_df): + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(sample_df) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + mock_braze.migrate_external_id.side_effect = Exception("fail!") + cmd = Command() + cmd.stdout = mock.Mock() + cmd.style = mock.Mock() + cmd.style.ERROR = lambda x: x + with pytest.raises(CommandError) as exc: + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 + ) + assert "Migration failed" in str(exc.value) + assert any("fail!" in str(call_arg[0][0]) for call_arg in cmd.stdout.write.call_args_list) + + +def test_start_timestamp_filtering(mock_storage_client, mock_braze): + df = pd.DataFrame( + [ + {"email_id": "id1", "basket_token": "token1", "create_timestamp": "2023-01-01T00:00:00"}, + {"email_id": "id2", "basket_token": "token2", "create_timestamp": "2024-02-01T00:00:00"}, + ] + ) + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(df) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + cmd = Command() + cmd.stdout = mock.Mock() + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp="2024-01-01T00:00:00", chunk_size=2 + ) + expected_chunk = [{"current_external_id": "id2", "new_external_id": "token2"}] + mock_braze.migrate_external_id.assert_called_once_with(expected_chunk) + + +def test_empty_parquet_file(mock_storage_client, mock_braze): + empty_df = pd.DataFrame(columns=["email_id", "basket_token", "create_timestamp"]) + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(empty_df) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + cmd = Command() + cmd.stdout = mock.Mock() + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 + ) + mock_braze.migrate_external_id.assert_not_called() + + +def test_chunking_behavior(mock_storage_client, mock_braze): + df = pd.DataFrame([{"email_id": f"id{i}", "basket_token": f"token{i}", "create_timestamp": f"2024-01-01T00:00:0{i}"} for i in range(5)]) + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(df) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + cmd = Command() + cmd.stdout = mock.Mock() + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 + ) + # Should be called 3 times: 2, 2, 1 + assert mock_braze.migrate_external_id.call_count == 3 + all_calls = [call.args[0] for call in mock_braze.migrate_external_id.call_args_list] + assert all(len(chunk) <= 2 for chunk in all_calls) + + +@mock.patch("basket.news.management.commands.process_braze_external_id_migrator.time.sleep") +def test_rate_limit_sleep_between_chunks(mock_sleep, sample_df, mock_storage_client, mock_braze): + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(sample_df) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + cmd = Command() + cmd.stdout = mock.Mock() + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=1 + ) + + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(0.07) diff --git a/requirements/dev.in b/requirements/dev.in index 076efa75..0acdf487 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -9,3 +9,6 @@ requests-mock==1.12.1 ruff==0.11.12 uv==0.7.11 watchfiles==1.0.5 # required for `granian --reload` +google-cloud-storage==3.4.1 +pandas==2.3.3 +pyarrow==21.0.0 diff --git a/requirements/dev.txt b/requirements/dev.txt index 0e4678fb..3e344d57 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,15 +1,17 @@ # This file was autogenerated by uv via the following command: -# just compile-requirements +# uv pip compile --generate-hashes --no-strip-extras requirements/dev.in -o requirements/dev.txt annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 # via # -r requirements/prod.txt # pydantic -anyio==4.9.0 \ - --hash=sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028 \ - --hash=sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c - # via watchfiles +anyio==4.11.0 \ + --hash=sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc \ + --hash=sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4 + # via + # -r requirements/prod.txt + # watchfiles apscheduler==3.11.0 \ --hash=sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133 \ --hash=sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da @@ -32,6 +34,12 @@ botocore==1.38.30 \ # -r requirements/prod.txt # boto3 # s3transfer +cachetools==6.2.1 \ + --hash=sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701 \ + --hash=sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201 + # via + # -r requirements/prod.txt + # google-auth certifi==2025.4.26 \ --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 @@ -217,75 +225,102 @@ contextlib2==21.6.0 \ --hash=sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f \ --hash=sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869 # via -r requirements/prod.txt -coverage[toml]==7.8.2 \ - --hash=sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7 \ - --hash=sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be \ - --hash=sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404 \ - --hash=sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11 \ - --hash=sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5 \ - --hash=sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d \ - --hash=sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347 \ - --hash=sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36 \ - --hash=sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3 \ - --hash=sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3 \ - --hash=sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b \ - --hash=sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e \ - --hash=sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85 \ - --hash=sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279 \ - --hash=sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d \ - --hash=sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a \ - --hash=sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3 \ - --hash=sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7 \ - --hash=sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57 \ - --hash=sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8 \ - --hash=sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625 \ - --hash=sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b \ - --hash=sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740 \ - --hash=sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a \ - --hash=sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be \ - --hash=sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257 \ - --hash=sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622 \ - --hash=sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6 \ - --hash=sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879 \ - --hash=sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a \ - --hash=sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a \ - --hash=sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a \ - --hash=sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050 \ - --hash=sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0 \ - --hash=sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32 \ - --hash=sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1 \ - --hash=sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48 \ - --hash=sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f \ - --hash=sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008 \ - --hash=sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223 \ - --hash=sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2 \ - --hash=sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53 \ - --hash=sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975 \ - --hash=sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7 \ - --hash=sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199 \ - --hash=sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f \ - --hash=sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7 \ - --hash=sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27 \ - --hash=sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c \ - --hash=sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca \ - --hash=sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787 \ - --hash=sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9 \ - --hash=sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a \ - --hash=sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8 \ - --hash=sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20 \ - --hash=sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d \ - --hash=sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99 \ - --hash=sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108 \ - --hash=sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7 \ - --hash=sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c \ - --hash=sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb \ - --hash=sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46 \ - --hash=sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca \ - --hash=sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d \ - --hash=sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837 \ - --hash=sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54 \ - --hash=sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3 - # via pytest-cov +coverage[toml]==7.11.0 \ + --hash=sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5 \ + --hash=sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e \ + --hash=sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab \ + --hash=sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a \ + --hash=sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82 \ + --hash=sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050 \ + --hash=sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893 \ + --hash=sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de \ + --hash=sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969 \ + --hash=sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601 \ + --hash=sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de \ + --hash=sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05 \ + --hash=sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3 \ + --hash=sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad \ + --hash=sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841 \ + --hash=sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847 \ + --hash=sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52 \ + --hash=sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11 \ + --hash=sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68 \ + --hash=sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73 \ + --hash=sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415 \ + --hash=sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745 \ + --hash=sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b \ + --hash=sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785 \ + --hash=sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4 \ + --hash=sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48 \ + --hash=sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2 \ + --hash=sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b \ + --hash=sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040 \ + --hash=sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0 \ + --hash=sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866 \ + --hash=sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1 \ + --hash=sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf \ + --hash=sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b \ + --hash=sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d \ + --hash=sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115 \ + --hash=sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2 \ + --hash=sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9 \ + --hash=sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43 \ + --hash=sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc \ + --hash=sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa \ + --hash=sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1 \ + --hash=sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088 \ + --hash=sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c \ + --hash=sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777 \ + --hash=sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623 \ + --hash=sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268 \ + --hash=sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186 \ + --hash=sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e \ + --hash=sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007 \ + --hash=sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7 \ + --hash=sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547 \ + --hash=sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3 \ + --hash=sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc \ + --hash=sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37 \ + --hash=sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721 \ + --hash=sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552 \ + --hash=sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca \ + --hash=sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497 \ + --hash=sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46 \ + --hash=sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2 \ + --hash=sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0 \ + --hash=sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44 \ + --hash=sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1 \ + --hash=sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4 \ + --hash=sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479 \ + --hash=sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e \ + --hash=sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2 \ + --hash=sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e \ + --hash=sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef \ + --hash=sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0 \ + --hash=sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad \ + --hash=sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075 \ + --hash=sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f \ + --hash=sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d \ + --hash=sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca \ + --hash=sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd \ + --hash=sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287 \ + --hash=sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f \ + --hash=sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0 \ + --hash=sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996 \ + --hash=sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d \ + --hash=sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd \ + --hash=sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31 \ + --hash=sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740 \ + --hash=sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591 \ + --hash=sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be \ + --hash=sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f \ + --hash=sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d \ + --hash=sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836 \ + --hash=sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d \ + --hash=sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c + # via + # -r requirements/prod.txt + # pytest-cov crontab==1.0.4 \ --hash=sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92 # via @@ -405,6 +440,84 @@ freezegun==1.5.2 \ # via # -r requirements/prod.txt # rq-scheduler +google-api-core==2.26.0 \ + --hash=sha256:2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed \ + --hash=sha256:e6e6d78bd6cf757f4aee41dcc85b07f485fbb069d5daa3afb126defba1e91a62 + # via + # -r requirements/prod.txt + # google-cloud-core + # google-cloud-storage +google-auth==2.41.1 \ + --hash=sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d \ + --hash=sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2 + # via + # -r requirements/prod.txt + # google-api-core + # google-cloud-core + # google-cloud-storage +google-cloud-core==2.4.3 \ + --hash=sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53 \ + --hash=sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e + # via + # -r requirements/prod.txt + # google-cloud-storage +google-cloud-storage==3.4.1 \ + --hash=sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268 \ + --hash=sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +google-crc32c==1.7.1 \ + --hash=sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db \ + --hash=sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337 \ + --hash=sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c \ + --hash=sha256:1f2b3522222746fff0e04a9bd0a23ea003ba3cccc8cf21385c564deb1f223242 \ + --hash=sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e \ + --hash=sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472 \ + --hash=sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194 \ + --hash=sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3 \ + --hash=sha256:3bda0fcb632d390e3ea8b6b07bf6b4f4a66c9d02dcd6fbf7ba00a197c143f582 \ + --hash=sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d \ + --hash=sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6 \ + --hash=sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82 \ + --hash=sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06 \ + --hash=sha256:713121af19f1a617054c41f952294764e0c5443d5a5d9034b2cd60f5dd7e0349 \ + --hash=sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a \ + --hash=sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d \ + --hash=sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48 \ + --hash=sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb \ + --hash=sha256:9fc196f0b8d8bd2789352c6a522db03f89e83a0ed6b64315923c396d7a932315 \ + --hash=sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589 \ + --hash=sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76 \ + --hash=sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65 \ + --hash=sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6 \ + --hash=sha256:bb5e35dcd8552f76eed9461a23de1030920a3c953c1982f324be8f97946e7127 \ + --hash=sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53 \ + --hash=sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603 \ + --hash=sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35 \ + --hash=sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9 \ + --hash=sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638 \ + --hash=sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9 \ + --hash=sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77 \ + --hash=sha256:f2226b6a8da04f1d9e61d3e357f2460b9551c5e6950071437e122c958a18ae14 \ + --hash=sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b \ + --hash=sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb + # via + # -r requirements/prod.txt + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.2 \ + --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \ + --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0 + # via + # -r requirements/prod.txt + # google-cloud-storage +googleapis-common-protos==1.71.0 \ + --hash=sha256:1aec01e574e29da63c80ba9f7bbf1ccfaacf1da877f23609fe236ca7c72a2e2e \ + --hash=sha256:59034a1d849dc4d18971997a72ac56246570afdd17f9369a0ff68218d50ab78c + # via + # -r requirements/prod.txt + # google-api-core granian==2.3.2 \ --hash=sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3 \ --hash=sha256:0209cb0e981165cfa930e9d01dec96de5c832c69f0e902f1f8f11c1ff1f744a5 \ @@ -612,10 +725,12 @@ idna==3.10 \ # anyio # email-validator # requests -iniconfig==2.1.0 \ - --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ - --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 - # via pytest +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via + # -r requirements/prod.txt + # pytest ipaddress==1.0.23 \ --hash=sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc \ --hash=sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2 @@ -636,7 +751,9 @@ josepy==2.0.0 \ legacy-cgi==2.6.3 \ --hash=sha256:4c119d6cb8e9d8b6ad7cc0ddad880552c62df4029622835d06dfd18f438a8154 \ --hash=sha256:6df2ea5ae14c71ef6f097f8b6372b44f6685283dc018535a75c924564183cdab - # via -r requirements/prod.txt + # via + # -r requirements/prod.txt + # webob lxml==5.4.0 \ --hash=sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5 \ --hash=sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b \ @@ -792,6 +909,84 @@ mysqlclient==2.2.7 \ --hash=sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069 \ --hash=sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687 # via -r requirements/prod.txt +numpy==2.3.4 \ + --hash=sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64 \ + --hash=sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e \ + --hash=sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0 \ + --hash=sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365 \ + --hash=sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d \ + --hash=sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c \ + --hash=sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52 \ + --hash=sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36 \ + --hash=sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec \ + --hash=sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f \ + --hash=sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197 \ + --hash=sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7 \ + --hash=sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9 \ + --hash=sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37 \ + --hash=sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a \ + --hash=sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db \ + --hash=sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c \ + --hash=sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7 \ + --hash=sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d \ + --hash=sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e \ + --hash=sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f \ + --hash=sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a \ + --hash=sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16 \ + --hash=sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e \ + --hash=sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868 \ + --hash=sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05 \ + --hash=sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e \ + --hash=sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff \ + --hash=sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f \ + --hash=sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7 \ + --hash=sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f \ + --hash=sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e \ + --hash=sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562 \ + --hash=sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6 \ + --hash=sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0 \ + --hash=sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26 \ + --hash=sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0 \ + --hash=sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d \ + --hash=sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879 \ + --hash=sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef \ + --hash=sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29 \ + --hash=sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252 \ + --hash=sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847 \ + --hash=sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6 \ + --hash=sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32 \ + --hash=sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0 \ + --hash=sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3 \ + --hash=sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b \ + --hash=sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3 \ + --hash=sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc \ + --hash=sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc \ + --hash=sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda \ + --hash=sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a \ + --hash=sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40 \ + --hash=sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032 \ + --hash=sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7 \ + --hash=sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966 \ + --hash=sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9 \ + --hash=sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346 \ + --hash=sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2 \ + --hash=sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a \ + --hash=sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786 \ + --hash=sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f \ + --hash=sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc \ + --hash=sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb \ + --hash=sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646 \ + --hash=sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd \ + --hash=sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1 \ + --hash=sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11 \ + --hash=sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667 \ + --hash=sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996 \ + --hash=sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953 \ + --hash=sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b \ + --hash=sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb + # via + # -r requirements/prod.txt + # pandas oauthlib==3.2.2 \ --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 @@ -801,11 +996,156 @@ oauthlib==3.2.2 \ packaging==25.0 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f - # via pytest + # via + # -r requirements/prod.txt + # pytest +pandas==2.3.3 \ + --hash=sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7 \ + --hash=sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593 \ + --hash=sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5 \ + --hash=sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791 \ + --hash=sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73 \ + --hash=sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec \ + --hash=sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4 \ + --hash=sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5 \ + --hash=sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac \ + --hash=sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084 \ + --hash=sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c \ + --hash=sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87 \ + --hash=sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35 \ + --hash=sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250 \ + --hash=sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c \ + --hash=sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826 \ + --hash=sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9 \ + --hash=sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713 \ + --hash=sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1 \ + --hash=sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523 \ + --hash=sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3 \ + --hash=sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78 \ + --hash=sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53 \ + --hash=sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c \ + --hash=sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21 \ + --hash=sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5 \ + --hash=sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff \ + --hash=sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45 \ + --hash=sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110 \ + --hash=sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493 \ + --hash=sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b \ + --hash=sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450 \ + --hash=sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86 \ + --hash=sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8 \ + --hash=sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98 \ + --hash=sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89 \ + --hash=sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66 \ + --hash=sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b \ + --hash=sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8 \ + --hash=sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29 \ + --hash=sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6 \ + --hash=sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc \ + --hash=sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2 \ + --hash=sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788 \ + --hash=sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa \ + --hash=sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151 \ + --hash=sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838 \ + --hash=sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b \ + --hash=sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a \ + --hash=sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d \ + --hash=sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908 \ + --hash=sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0 \ + --hash=sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b \ + --hash=sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c \ + --hash=sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee + # via + # -r requirements/dev.in + # -r requirements/prod.txt pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - # via pytest + # via + # -r requirements/prod.txt + # pytest +proto-plus==1.26.1 \ + --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \ + --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012 + # via + # -r requirements/prod.txt + # google-api-core +protobuf==6.33.0 \ + --hash=sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954 \ + --hash=sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995 \ + --hash=sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef \ + --hash=sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455 \ + --hash=sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee \ + --hash=sha256:c963e86c3655af3a917962c9619e1a6b9670540351d7af9439d06064e3317cc9 \ + --hash=sha256:cd33a8e38ea3e39df66e1bbc462b076d6e5ba3a4ebbde58219d777223a7873d3 \ + --hash=sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035 \ + --hash=sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90 \ + --hash=sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298 + # via + # -r requirements/prod.txt + # google-api-core + # googleapis-common-protos + # proto-plus +pyarrow==21.0.0 \ + --hash=sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4 \ + --hash=sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623 \ + --hash=sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7 \ + --hash=sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636 \ + --hash=sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7 \ + --hash=sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1 \ + --hash=sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10 \ + --hash=sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51 \ + --hash=sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd \ + --hash=sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8 \ + --hash=sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d \ + --hash=sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569 \ + --hash=sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e \ + --hash=sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc \ + --hash=sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6 \ + --hash=sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c \ + --hash=sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82 \ + --hash=sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79 \ + --hash=sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6 \ + --hash=sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10 \ + --hash=sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61 \ + --hash=sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d \ + --hash=sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb \ + --hash=sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e \ + --hash=sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e \ + --hash=sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594 \ + --hash=sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634 \ + --hash=sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da \ + --hash=sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3 \ + --hash=sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876 \ + --hash=sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e \ + --hash=sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a \ + --hash=sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b \ + --hash=sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f \ + --hash=sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18 \ + --hash=sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe \ + --hash=sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99 \ + --hash=sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26 \ + --hash=sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d \ + --hash=sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a \ + --hash=sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd \ + --hash=sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503 \ + --hash=sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +pyasn1==0.6.1 \ + --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ + --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 + # via + # -r requirements/prod.txt + # pyasn1-modules + # rsa +pyasn1-modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ + --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 + # via + # -r requirements/prod.txt + # google-auth pycparser==2.22 \ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc @@ -925,10 +1265,12 @@ pyfxa==0.8.1 \ --hash=sha256:df3c575b314e8d67275fc8404294731a5cd39a75e36639fd8c5f8c76c1ee1a4c \ --hash=sha256:f12798fc5f3c9848c1de8048f333b7bdb3b0658daac506c843a4ebfc8df0efb8 # via -r requirements/prod.txt -pygments==2.19.1 \ - --hash=sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f \ - --hash=sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c - # via pytest +pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b + # via + # -r requirements/prod.txt + # pytest pyjwt==2.10.1 \ --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \ --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb @@ -947,6 +1289,7 @@ pytest==8.4.0 \ --hash=sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e # via # -r requirements/dev.in + # -r requirements/prod.txt # pytest-cov # pytest-datadir # pytest-django @@ -954,19 +1297,27 @@ pytest==8.4.0 \ pytest-cov==6.1.1 \ --hash=sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a \ --hash=sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt pytest-datadir==1.7.1 \ --hash=sha256:12372417ff2cec4db8aecaf6b6fac119db91515f17e81c7926220e342148e3b4 \ --hash=sha256:367b4cd34b6ca3151317db310ab688ef9a28a9ec15e1e7d6696f4737b5f14bd8 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt pytest-django==4.11.1 \ --hash=sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10 \ --hash=sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt pytest-mock==3.14.1 \ --hash=sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e \ --hash=sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -974,7 +1325,14 @@ python-dateutil==2.9.0.post0 \ # -r requirements/prod.txt # botocore # freezegun + # pandas # rq-scheduler +pytz==2025.2 \ + --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ + --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 + # via + # -r requirements/prod.txt + # pandas pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ @@ -1043,6 +1401,8 @@ requests==2.32.3 \ # -r requirements/prod.txt # datadog # django-mozilla-product-details + # google-api-core + # google-cloud-storage # mozilla-django-oidc # pyfxa # pysilverpop @@ -1051,7 +1411,9 @@ requests==2.32.3 \ requests-mock==1.12.1 \ --hash=sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563 \ --hash=sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt requests-oauthlib==2.0.0 \ --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \ --hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9 @@ -1068,6 +1430,12 @@ rq-scheduler==0.14.0 \ --hash=sha256:2d5a14a1ab217f8693184ebaa1fe03838edcbc70b4f76572721c0b33058cd023 \ --hash=sha256:d4ec221a3d8c11b3ff55e041f09d9af1e17f3253db737b6b97e86ab20fc3dc0d # via -r requirements/prod.txt +rsa==4.9.1 \ + --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ + --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 + # via + # -r requirements/prod.txt + # google-auth ruff==0.11.12 \ --hash=sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd \ --hash=sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02 \ @@ -1087,7 +1455,9 @@ ruff==0.11.12 \ --hash=sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c \ --hash=sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6 \ --hash=sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt s3transfer==0.13.0 \ --hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \ --hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177 @@ -1111,7 +1481,9 @@ six==1.17.0 \ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc - # via anyio + # via + # -r requirements/prod.txt + # anyio sqlparse==0.5.3 \ --hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \ --hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca @@ -1123,11 +1495,9 @@ typing-extensions==4.14.0 \ --hash=sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af # via # -r requirements/prod.txt - # anyio # dj-database-url # pydantic # pydantic-core - # pyopenssl # typing-inspection typing-inspection==0.4.1 \ --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ @@ -1135,6 +1505,12 @@ typing-inspection==0.4.1 \ # via # -r requirements/prod.txt # pydantic +tzdata==2025.2 \ + --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ + --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 + # via + # -r requirements/prod.txt + # pandas tzlocal==5.3.1 \ --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \ --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d @@ -1183,7 +1559,9 @@ uv==0.7.11 \ --hash=sha256:f82490ddeb4c0e074f49f5d40c5e2ff863db13e8d3ea894401137f01177f5a65 \ --hash=sha256:fa3bb9394a96d315d60cd2453a7d17d891693e14f31840bff2cdc96b6552c48f \ --hash=sha256:fa506c6492f3993756de785c01a7f2d9615b9c7ccfd8ac56c5b8b08b2db74768 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt watchfiles==1.0.5 \ --hash=sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827 \ --hash=sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f \ @@ -1256,7 +1634,9 @@ watchfiles==1.0.5 \ --hash=sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965 \ --hash=sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21 \ --hash=sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57 - # via -r requirements/dev.in + # via + # -r requirements/dev.in + # -r requirements/prod.txt webob==1.8.9 \ --hash=sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9 \ --hash=sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589 diff --git a/requirements/prod.in b/requirements/prod.in index 605dd4b2..cb312d12 100644 --- a/requirements/prod.in +++ b/requirements/prod.in @@ -33,3 +33,6 @@ sentry-processor==0.0.1 sentry-sdk==2.29.1 user-agents==2.2.0 whitenoise==6.9.0 +google-cloud-storage==3.4.1 +pandas==2.3.3 +pyarrow==21.0.0 diff --git a/requirements/prod.txt b/requirements/prod.txt index 6f3f2044..46b7bddf 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,33 +1,50 @@ # This file was autogenerated by uv via the following command: -# just compile-requirements +# uv pip compile --generate-hashes --no-strip-extras requirements/dev.in -o requirements/prod.txt annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 - # via pydantic + # via + # -r requirements/prod.txt + # pydantic +anyio==4.11.0 \ + --hash=sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc \ + --hash=sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4 + # via + # -r requirements/prod.txt + # watchfiles apscheduler==3.11.0 \ --hash=sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133 \ --hash=sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da - # via -r requirements/prod.in + # via -r requirements/prod.txt asgiref==3.8.1 \ --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 # via + # -r requirements/prod.txt # django # django-cors-headers boto3==1.38.30 \ --hash=sha256:17af769544b5743843bcc732709b43226de19f1ebff2c324a3440bbecbddb893 \ --hash=sha256:949df0a0edd360f4ad60f1492622eecf98a359a2f72b1e236193d9b320c5dc8c - # via -r requirements/prod.in + # via -r requirements/prod.txt botocore==1.38.30 \ --hash=sha256:530e40a6e91c8a096cab17fcc590d0c7227c8347f71a867576163a44d027a714 \ --hash=sha256:7836c5041c5f249431dbd5471c61db17d4053f72a1d6e3b2197c07ca0839588b # via + # -r requirements/prod.txt # boto3 # s3transfer +cachetools==6.2.1 \ + --hash=sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701 \ + --hash=sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201 + # via + # -r requirements/prod.txt + # google-auth certifi==2025.4.26 \ --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 # via + # -r requirements/prod.txt # requests # sentry-sdk cffi==1.17.1 \ @@ -98,7 +115,9 @@ cffi==1.17.1 \ --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b - # via cryptography + # via + # -r requirements/prod.txt + # cryptography charset-normalizer==3.4.2 \ --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ @@ -192,20 +211,121 @@ charset-normalizer==3.4.2 \ --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f - # via requests + # via + # -r requirements/prod.txt + # requests click==8.2.1 \ --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b # via + # -r requirements/prod.txt # granian # rq contextlib2==21.6.0 \ --hash=sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f \ --hash=sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869 - # via -r requirements/prod.in + # via -r requirements/prod.txt +coverage[toml]==7.11.0 \ + --hash=sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5 \ + --hash=sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e \ + --hash=sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab \ + --hash=sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a \ + --hash=sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82 \ + --hash=sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050 \ + --hash=sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893 \ + --hash=sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de \ + --hash=sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969 \ + --hash=sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601 \ + --hash=sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de \ + --hash=sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05 \ + --hash=sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3 \ + --hash=sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad \ + --hash=sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841 \ + --hash=sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847 \ + --hash=sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52 \ + --hash=sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11 \ + --hash=sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68 \ + --hash=sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73 \ + --hash=sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415 \ + --hash=sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745 \ + --hash=sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b \ + --hash=sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785 \ + --hash=sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4 \ + --hash=sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48 \ + --hash=sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2 \ + --hash=sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b \ + --hash=sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040 \ + --hash=sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0 \ + --hash=sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866 \ + --hash=sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1 \ + --hash=sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf \ + --hash=sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b \ + --hash=sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d \ + --hash=sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115 \ + --hash=sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2 \ + --hash=sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9 \ + --hash=sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43 \ + --hash=sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc \ + --hash=sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa \ + --hash=sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1 \ + --hash=sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088 \ + --hash=sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c \ + --hash=sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777 \ + --hash=sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623 \ + --hash=sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268 \ + --hash=sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186 \ + --hash=sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e \ + --hash=sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007 \ + --hash=sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7 \ + --hash=sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547 \ + --hash=sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3 \ + --hash=sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc \ + --hash=sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37 \ + --hash=sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721 \ + --hash=sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552 \ + --hash=sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca \ + --hash=sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497 \ + --hash=sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46 \ + --hash=sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2 \ + --hash=sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0 \ + --hash=sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44 \ + --hash=sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1 \ + --hash=sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4 \ + --hash=sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479 \ + --hash=sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e \ + --hash=sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2 \ + --hash=sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e \ + --hash=sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef \ + --hash=sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0 \ + --hash=sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad \ + --hash=sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075 \ + --hash=sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f \ + --hash=sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d \ + --hash=sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca \ + --hash=sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd \ + --hash=sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287 \ + --hash=sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f \ + --hash=sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0 \ + --hash=sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996 \ + --hash=sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d \ + --hash=sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd \ + --hash=sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31 \ + --hash=sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740 \ + --hash=sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591 \ + --hash=sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be \ + --hash=sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f \ + --hash=sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d \ + --hash=sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836 \ + --hash=sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d \ + --hash=sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c + # via + # -r requirements/prod.txt + # pytest-cov crontab==1.0.4 \ --hash=sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92 - # via rq-scheduler + # via + # -r requirements/prod.txt + # rq-scheduler cryptography==44.0.3 \ --hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \ --hash=sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43 \ @@ -245,7 +365,7 @@ cryptography==44.0.3 \ --hash=sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4 \ --hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053 # via - # -r requirements/prod.in + # -r requirements/prod.txt # josepy # mozilla-django-oidc # pyfxa @@ -253,16 +373,18 @@ cryptography==44.0.3 \ datadog==0.51.0 \ --hash=sha256:3279534f831ae0b4ae2d8ce42ef038b4ab38e667d7ed6ff7437982d7a0cf5250 \ --hash=sha256:a9764f091c96af4e0996d4400b168fc5fba380f911d6d672c9dcd4773e29ea3f - # via markus + # via + # -r requirements/prod.txt + # markus dj-database-url==3.0.0 \ --hash=sha256:749a7a42d88d6c741c1d2f4ab24c2ae0d5cd12f00f2d1d55ff9f5fadabe8a2c3 \ --hash=sha256:cbb84b2e3f372460b1e43692bf9fdc0c32e78930ee101db470cba56105fca1e5 - # via -r requirements/prod.in + # via -r requirements/prod.txt django==5.2.2 \ --hash=sha256:85852e517f84435e9b13421379cd6c43ef5b48a9c8b391d29a26f7900967e952 \ --hash=sha256:997ef2162d04ead6869551b22cde4e06da1f94cf595f4af3f3d3afeae1f3f6fe # via - # -r requirements/prod.in + # -r requirements/prod.txt # dj-database-url # django-allow-cidr # django-cors-headers @@ -273,47 +395,129 @@ django==5.2.2 \ django-allow-cidr==0.8.0 \ --hash=sha256:724ce76b7b4a25c641ddcd33777e2e95da622dd2c11937f0d76d0cd3d54b1622 \ --hash=sha256:d6f80230621dd5b19ec1665b85abf8218b02556ff7cf0ddda41e2607267a3277 - # via -r requirements/prod.in + # via -r requirements/prod.txt django-cache-url==3.4.5 \ --hash=sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c \ --hash=sha256:eb9fb194717524348c95cad9905b70b647452741c1d9e481fac6d2125f0ad917 - # via -r requirements/prod.in + # via -r requirements/prod.txt django-cors-headers==4.7.0 \ --hash=sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b \ --hash=sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070 - # via -r requirements/prod.in + # via -r requirements/prod.txt django-mozilla-product-details==1.0.3 \ --hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \ --hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36 - # via -r requirements/prod.in + # via -r requirements/prod.txt django-ninja==1.4.3 \ --hash=sha256:e46d477ca60c228d2a5eb3cc912094928ea830d364501f966661eeada67cb038 \ --hash=sha256:f3204137a059437b95677049474220611f1cf9efedba9213556474b75168fa01 - # via -r requirements/prod.in + # via -r requirements/prod.txt django-ratelimit==4.1.0 \ --hash=sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b \ --hash=sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92 - # via -r requirements/prod.in + # via -r requirements/prod.txt django-watchman==1.3.0 \ --hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \ --hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb - # via -r requirements/prod.in + # via -r requirements/prod.txt dnspython==2.7.0 \ --hash=sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86 \ --hash=sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1 - # via email-validator + # via + # -r requirements/prod.txt + # email-validator email-validator==2.2.0 \ --hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \ --hash=sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7 - # via -r requirements/prod.in + # via -r requirements/prod.txt everett==3.4.0 \ --hash=sha256:f403c4a41764a6301fb31e2558d6e9718999f0eab9e260d986b894fa2e6b6871 \ --hash=sha256:f8c29c7300702f47b7323b75348e2b86647246694fda7ad410c2a2bfaa980ff7 - # via -r requirements/prod.in + # via -r requirements/prod.txt freezegun==1.5.2 \ --hash=sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b \ --hash=sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181 - # via rq-scheduler + # via + # -r requirements/prod.txt + # rq-scheduler +google-api-core==2.26.0 \ + --hash=sha256:2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed \ + --hash=sha256:e6e6d78bd6cf757f4aee41dcc85b07f485fbb069d5daa3afb126defba1e91a62 + # via + # -r requirements/prod.txt + # google-cloud-core + # google-cloud-storage +google-auth==2.41.1 \ + --hash=sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d \ + --hash=sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2 + # via + # -r requirements/prod.txt + # google-api-core + # google-cloud-core + # google-cloud-storage +google-cloud-core==2.4.3 \ + --hash=sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53 \ + --hash=sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e + # via + # -r requirements/prod.txt + # google-cloud-storage +google-cloud-storage==3.4.1 \ + --hash=sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268 \ + --hash=sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +google-crc32c==1.7.1 \ + --hash=sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db \ + --hash=sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337 \ + --hash=sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c \ + --hash=sha256:1f2b3522222746fff0e04a9bd0a23ea003ba3cccc8cf21385c564deb1f223242 \ + --hash=sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e \ + --hash=sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472 \ + --hash=sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194 \ + --hash=sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3 \ + --hash=sha256:3bda0fcb632d390e3ea8b6b07bf6b4f4a66c9d02dcd6fbf7ba00a197c143f582 \ + --hash=sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d \ + --hash=sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6 \ + --hash=sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82 \ + --hash=sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06 \ + --hash=sha256:713121af19f1a617054c41f952294764e0c5443d5a5d9034b2cd60f5dd7e0349 \ + --hash=sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a \ + --hash=sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d \ + --hash=sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48 \ + --hash=sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb \ + --hash=sha256:9fc196f0b8d8bd2789352c6a522db03f89e83a0ed6b64315923c396d7a932315 \ + --hash=sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589 \ + --hash=sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76 \ + --hash=sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65 \ + --hash=sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6 \ + --hash=sha256:bb5e35dcd8552f76eed9461a23de1030920a3c953c1982f324be8f97946e7127 \ + --hash=sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53 \ + --hash=sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603 \ + --hash=sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35 \ + --hash=sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9 \ + --hash=sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638 \ + --hash=sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9 \ + --hash=sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77 \ + --hash=sha256:f2226b6a8da04f1d9e61d3e357f2460b9551c5e6950071437e122c958a18ae14 \ + --hash=sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b \ + --hash=sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb + # via + # -r requirements/prod.txt + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.2 \ + --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \ + --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0 + # via + # -r requirements/prod.txt + # google-cloud-storage +googleapis-common-protos==1.71.0 \ + --hash=sha256:1aec01e574e29da63c80ba9f7bbf1ccfaacf1da877f23609fe236ca7c72a2e2e \ + --hash=sha256:59034a1d849dc4d18971997a72ac56246570afdd17f9369a0ff68218d50ab78c + # via + # -r requirements/prod.txt + # google-api-core granian==2.3.2 \ --hash=sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3 \ --hash=sha256:0209cb0e981165cfa930e9d01dec96de5c832c69f0e902f1f8f11c1ff1f744a5 \ @@ -395,11 +599,13 @@ granian==2.3.2 \ --hash=sha256:f7360f4e70a4186e4e4fe67912b1675ceb30199107545ea1790090e5a548ef46 \ --hash=sha256:f7d844277f6eec7f87ca615c283026f3d0b29cdbc61c92c103d2a708936e6e1c \ --hash=sha256:fed8bdfc284ff00e9c530f7a5018d5d6281737fef9fcdd4aa5d69cac68f3d374 - # via -r requirements/prod.in + # via -r requirements/prod.txt hawkauthlib==2.0.0 \ --hash=sha256:935878d3a75832aa76f78ddee13491f1466cbd69a8e7e4248902763cf9953ba9 \ --hash=sha256:effd64a2572e3c0d9090b55ad2180b36ad50e7760bea225cb6ce2248f421510d - # via pyfxa + # via + # -r requirements/prod.txt + # pyfxa hiredis==3.2.1 \ --hash=sha256:0079ef1e03930b364556b78548e67236ab3def4e07e674f6adfc52944aa972dd \ --hash=sha256:01dd8ea88bf8363751857ca2eb8f13faad0c7d57a6369663d4d1160f225ab449 \ @@ -510,31 +716,44 @@ hiredis==3.2.1 \ --hash=sha256:f9ad63cd9065820a43fb1efb8ed5ae85bb78f03ef5eb53f6bde47914708f5718 \ --hash=sha256:fec453a038c262e18d7de4919220b2916e0b17d1eadd12e7a800f09f78f84f39 \ --hash=sha256:ffd982c419f48e3a57f592678c72474429465bb4bfc96472ec805f5d836523f0 - # via -r requirements/prod.in + # via -r requirements/prod.txt idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 # via + # -r requirements/prod.txt + # anyio # email-validator # requests +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via + # -r requirements/prod.txt + # pytest ipaddress==1.0.23 \ --hash=sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc \ --hash=sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2 - # via -r requirements/prod.in + # via -r requirements/prod.txt jmespath==1.0.1 \ --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe # via + # -r requirements/prod.txt # boto3 # botocore josepy==2.0.0 \ --hash=sha256:e7d7acd2fe77435cda76092abe4950bb47b597243a8fb733088615fa6de9ec40 \ --hash=sha256:eb50ec564b1b186b860c7738769274b97b19b5b831239669c0f3d5c86b62a4c0 - # via mozilla-django-oidc + # via + # -r requirements/prod.txt + # mozilla-django-oidc legacy-cgi==2.6.3 \ --hash=sha256:4c119d6cb8e9d8b6ad7cc0ddad880552c62df4029622835d06dfd18f438a8154 \ --hash=sha256:6df2ea5ae14c71ef6f097f8b6372b44f6685283dc018535a75c924564183cdab - # via -r requirements/prod.in + # via + # -r requirements/prod.txt + # webob lxml==5.4.0 \ --hash=sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5 \ --hash=sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b \ @@ -668,18 +887,18 @@ lxml==5.4.0 \ --hash=sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c \ --hash=sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e \ --hash=sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539 - # via -r requirements/prod.in + # via -r requirements/prod.txt markus[datadog]==5.1.0 \ --hash=sha256:424172efdccc35172b8aadfdcd753412c3ed2b5651c3b3bc9e0b7e7f2e97da52 \ --hash=sha256:a4ec2d6bb1dcf471638be11a10cb5708de8cc3092ade9cf3b38bb2f651ede33a - # via -r requirements/prod.in + # via -r requirements/prod.txt mozilla-django-oidc==4.0.1 \ --hash=sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca \ --hash=sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918 - # via -r requirements/prod.in + # via -r requirements/prod.txt msgpack-python==0.5.6 \ --hash=sha256:378cc8a6d3545b532dfd149da715abae4fda2a3adb6d74e525d0d5e51f46909b - # via -r requirements/prod.in + # via -r requirements/prod.txt mysqlclient==2.2.7 \ --hash=sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076 \ --hash=sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5 \ @@ -689,19 +908,256 @@ mysqlclient==2.2.7 \ --hash=sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585 \ --hash=sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069 \ --hash=sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687 - # via -r requirements/prod.in + # via -r requirements/prod.txt +numpy==2.3.4 \ + --hash=sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64 \ + --hash=sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e \ + --hash=sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0 \ + --hash=sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365 \ + --hash=sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d \ + --hash=sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c \ + --hash=sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52 \ + --hash=sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36 \ + --hash=sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec \ + --hash=sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f \ + --hash=sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197 \ + --hash=sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7 \ + --hash=sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9 \ + --hash=sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37 \ + --hash=sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a \ + --hash=sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db \ + --hash=sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c \ + --hash=sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7 \ + --hash=sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d \ + --hash=sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e \ + --hash=sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f \ + --hash=sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a \ + --hash=sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16 \ + --hash=sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e \ + --hash=sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868 \ + --hash=sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05 \ + --hash=sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e \ + --hash=sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff \ + --hash=sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f \ + --hash=sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7 \ + --hash=sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f \ + --hash=sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e \ + --hash=sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562 \ + --hash=sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6 \ + --hash=sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0 \ + --hash=sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26 \ + --hash=sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0 \ + --hash=sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d \ + --hash=sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879 \ + --hash=sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef \ + --hash=sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29 \ + --hash=sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252 \ + --hash=sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847 \ + --hash=sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6 \ + --hash=sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32 \ + --hash=sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0 \ + --hash=sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3 \ + --hash=sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b \ + --hash=sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3 \ + --hash=sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc \ + --hash=sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc \ + --hash=sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda \ + --hash=sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a \ + --hash=sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40 \ + --hash=sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032 \ + --hash=sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7 \ + --hash=sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966 \ + --hash=sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9 \ + --hash=sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346 \ + --hash=sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2 \ + --hash=sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a \ + --hash=sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786 \ + --hash=sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f \ + --hash=sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc \ + --hash=sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb \ + --hash=sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646 \ + --hash=sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd \ + --hash=sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1 \ + --hash=sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11 \ + --hash=sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667 \ + --hash=sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996 \ + --hash=sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953 \ + --hash=sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b \ + --hash=sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb + # via + # -r requirements/prod.txt + # pandas oauthlib==3.2.2 \ --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 - # via requests-oauthlib + # via + # -r requirements/prod.txt + # requests-oauthlib +packaging==25.0 \ + --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ + --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f + # via + # -r requirements/prod.txt + # pytest +pandas==2.3.3 \ + --hash=sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7 \ + --hash=sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593 \ + --hash=sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5 \ + --hash=sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791 \ + --hash=sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73 \ + --hash=sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec \ + --hash=sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4 \ + --hash=sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5 \ + --hash=sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac \ + --hash=sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084 \ + --hash=sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c \ + --hash=sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87 \ + --hash=sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35 \ + --hash=sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250 \ + --hash=sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c \ + --hash=sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826 \ + --hash=sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9 \ + --hash=sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713 \ + --hash=sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1 \ + --hash=sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523 \ + --hash=sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3 \ + --hash=sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78 \ + --hash=sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53 \ + --hash=sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c \ + --hash=sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21 \ + --hash=sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5 \ + --hash=sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff \ + --hash=sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45 \ + --hash=sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110 \ + --hash=sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493 \ + --hash=sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b \ + --hash=sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450 \ + --hash=sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86 \ + --hash=sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8 \ + --hash=sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98 \ + --hash=sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89 \ + --hash=sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66 \ + --hash=sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b \ + --hash=sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8 \ + --hash=sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29 \ + --hash=sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6 \ + --hash=sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc \ + --hash=sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2 \ + --hash=sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788 \ + --hash=sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa \ + --hash=sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151 \ + --hash=sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838 \ + --hash=sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b \ + --hash=sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a \ + --hash=sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d \ + --hash=sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908 \ + --hash=sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0 \ + --hash=sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b \ + --hash=sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c \ + --hash=sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee + # via + # -r requirements/dev.in + # -r requirements/prod.txt +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via + # -r requirements/prod.txt + # pytest +proto-plus==1.26.1 \ + --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \ + --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012 + # via + # -r requirements/prod.txt + # google-api-core +protobuf==6.33.0 \ + --hash=sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954 \ + --hash=sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995 \ + --hash=sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef \ + --hash=sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455 \ + --hash=sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee \ + --hash=sha256:c963e86c3655af3a917962c9619e1a6b9670540351d7af9439d06064e3317cc9 \ + --hash=sha256:cd33a8e38ea3e39df66e1bbc462b076d6e5ba3a4ebbde58219d777223a7873d3 \ + --hash=sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035 \ + --hash=sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90 \ + --hash=sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298 + # via + # -r requirements/prod.txt + # google-api-core + # googleapis-common-protos + # proto-plus +pyarrow==21.0.0 \ + --hash=sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4 \ + --hash=sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623 \ + --hash=sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7 \ + --hash=sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636 \ + --hash=sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7 \ + --hash=sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1 \ + --hash=sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10 \ + --hash=sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51 \ + --hash=sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd \ + --hash=sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8 \ + --hash=sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d \ + --hash=sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569 \ + --hash=sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e \ + --hash=sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc \ + --hash=sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6 \ + --hash=sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c \ + --hash=sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82 \ + --hash=sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79 \ + --hash=sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6 \ + --hash=sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10 \ + --hash=sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61 \ + --hash=sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d \ + --hash=sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb \ + --hash=sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e \ + --hash=sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e \ + --hash=sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594 \ + --hash=sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634 \ + --hash=sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da \ + --hash=sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3 \ + --hash=sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876 \ + --hash=sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e \ + --hash=sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a \ + --hash=sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b \ + --hash=sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f \ + --hash=sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18 \ + --hash=sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe \ + --hash=sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99 \ + --hash=sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26 \ + --hash=sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d \ + --hash=sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a \ + --hash=sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd \ + --hash=sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503 \ + --hash=sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +pyasn1==0.6.1 \ + --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ + --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 + # via + # -r requirements/prod.txt + # pyasn1-modules + # rsa +pyasn1-modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ + --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 + # via + # -r requirements/prod.txt + # google-auth pycparser==2.22 \ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc - # via cffi + # via + # -r requirements/prod.txt + # cffi pydantic==2.11.5 \ --hash=sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a \ --hash=sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7 - # via django-ninja + # via + # -r requirements/prod.txt + # django-ninja pydantic-core==2.33.2 \ --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ @@ -802,29 +1258,81 @@ pydantic-core==2.33.2 \ --hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \ --hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \ --hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d - # via pydantic + # via + # -r requirements/prod.txt + # pydantic pyfxa==0.8.1 \ --hash=sha256:df3c575b314e8d67275fc8404294731a5cd39a75e36639fd8c5f8c76c1ee1a4c \ --hash=sha256:f12798fc5f3c9848c1de8048f333b7bdb3b0658daac506c843a4ebfc8df0efb8 - # via -r requirements/prod.in + # via -r requirements/prod.txt +pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b + # via + # -r requirements/prod.txt + # pytest pyjwt==2.10.1 \ --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \ --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb - # via pyfxa + # via + # -r requirements/prod.txt + # pyfxa pyopenssl==25.1.0 \ --hash=sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab \ --hash=sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b - # via -r requirements/prod.in + # via -r requirements/prod.txt pysilverpop==0.2.6 \ --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 - # via -r requirements/prod.in + # via -r requirements/prod.txt +pytest==8.4.0 \ + --hash=sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6 \ + --hash=sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e + # via + # -r requirements/dev.in + # -r requirements/prod.txt + # pytest-cov + # pytest-datadir + # pytest-django + # pytest-mock +pytest-cov==6.1.1 \ + --hash=sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a \ + --hash=sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde + # via + # -r requirements/dev.in + # -r requirements/prod.txt +pytest-datadir==1.7.1 \ + --hash=sha256:12372417ff2cec4db8aecaf6b6fac119db91515f17e81c7926220e342148e3b4 \ + --hash=sha256:367b4cd34b6ca3151317db310ab688ef9a28a9ec15e1e7d6696f4737b5f14bd8 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +pytest-django==4.11.1 \ + --hash=sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10 \ + --hash=sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +pytest-mock==3.14.1 \ + --hash=sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e \ + --hash=sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0 + # via + # -r requirements/dev.in + # -r requirements/prod.txt python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 # via + # -r requirements/prod.txt # botocore # freezegun + # pandas # rq-scheduler +pytz==2025.2 \ + --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ + --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 + # via + # -r requirements/prod.txt + # pandas pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ @@ -879,98 +1387,263 @@ pyyaml==6.0.2 \ --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 - # via -r requirements/prod.in + # via -r requirements/prod.txt redis==6.2.0 \ --hash=sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e \ --hash=sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977 # via - # -r requirements/prod.in + # -r requirements/prod.txt # rq requests==2.32.3 \ --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 # via + # -r requirements/prod.txt # datadog # django-mozilla-product-details + # google-api-core + # google-cloud-storage # mozilla-django-oidc # pyfxa # pysilverpop + # requests-mock # requests-oauthlib +requests-mock==1.12.1 \ + --hash=sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563 \ + --hash=sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401 + # via + # -r requirements/dev.in + # -r requirements/prod.txt requests-oauthlib==2.0.0 \ --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \ --hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9 - # via pysilverpop + # via + # -r requirements/prod.txt + # pysilverpop rq==2.3.3 \ --hash=sha256:20c41c977b6f27c852a41bd855893717402bae7b8d9607dca21fe9dd55453e22 \ --hash=sha256:2202c4409c4c527ac4bee409867d6c02515dd110030499eb0de54c7374aee0ce # via - # -r requirements/prod.in + # -r requirements/prod.txt # rq-scheduler rq-scheduler==0.14.0 \ --hash=sha256:2d5a14a1ab217f8693184ebaa1fe03838edcbc70b4f76572721c0b33058cd023 \ --hash=sha256:d4ec221a3d8c11b3ff55e041f09d9af1e17f3253db737b6b97e86ab20fc3dc0d - # via -r requirements/prod.in + # via -r requirements/prod.txt +rsa==4.9.1 \ + --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ + --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 + # via + # -r requirements/prod.txt + # google-auth +ruff==0.11.12 \ + --hash=sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd \ + --hash=sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02 \ + --hash=sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3 \ + --hash=sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012 \ + --hash=sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603 \ + --hash=sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13 \ + --hash=sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832 \ + --hash=sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5 \ + --hash=sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be \ + --hash=sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7 \ + --hash=sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5 \ + --hash=sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef \ + --hash=sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa \ + --hash=sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a \ + --hash=sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc \ + --hash=sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c \ + --hash=sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6 \ + --hash=sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a + # via + # -r requirements/dev.in + # -r requirements/prod.txt s3transfer==0.13.0 \ --hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \ --hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177 - # via boto3 + # via + # -r requirements/prod.txt + # boto3 sentry-processor==0.0.1 \ --hash=sha256:fd7a30fb57aaf05c01cd04cf7d949c628376b2b55d7a0aaa222efe58a8f122bc - # via -r requirements/prod.in + # via -r requirements/prod.txt sentry-sdk==2.29.1 \ --hash=sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d \ --hash=sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19 - # via -r requirements/prod.in + # via -r requirements/prod.txt six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via + # -r requirements/prod.txt # pysilverpop # python-dateutil +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via + # -r requirements/prod.txt + # anyio sqlparse==0.5.3 \ --hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \ --hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca - # via django + # via + # -r requirements/prod.txt + # django typing-extensions==4.14.0 \ --hash=sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4 \ --hash=sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af # via + # -r requirements/prod.txt # dj-database-url # pydantic # pydantic-core - # pyopenssl # typing-inspection typing-inspection==0.4.1 \ --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ --hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28 - # via pydantic + # via + # -r requirements/prod.txt + # pydantic +tzdata==2025.2 \ + --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ + --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 + # via + # -r requirements/prod.txt + # pandas tzlocal==5.3.1 \ --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \ --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d - # via apscheduler + # via + # -r requirements/prod.txt + # apscheduler ua-parser==1.0.1 \ --hash=sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea \ --hash=sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d - # via user-agents + # via + # -r requirements/prod.txt + # user-agents ua-parser-builtins==0.18.0.post1 \ --hash=sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d - # via ua-parser + # via + # -r requirements/prod.txt + # ua-parser urllib3==2.4.0 \ --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 # via + # -r requirements/prod.txt # botocore # requests # sentry-sdk user-agents==2.2.0 \ --hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7 \ --hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 - # via -r requirements/prod.in + # via -r requirements/prod.txt +uv==0.7.11 \ + --hash=sha256:1950db80e7b5e1549029514cb006b37df1af7418c788acb57c1d46c0f1e8f310 \ + --hash=sha256:2d1115f09cf9feb60a25d0f03558393defa8f6c8118d01649e2bf05a783cc529 \ + --hash=sha256:33daeb9f1e5f49f1f192815c580beb996fd3be131821af2e887af4ec575c2a4f \ + --hash=sha256:363a8aabb3455fd4a15611c8ee4d76ac1a3d587f60250aee7dd4f777a3e02486 \ + --hash=sha256:38adcfd3c3d03c97e98498073df004ac190d21538ecf40752fb3f1b439c6fe5e \ + --hash=sha256:622e897701cb84657bf86d353afebde5e682a6f9fd4e7454aac65fcd899dfa7a \ + --hash=sha256:692ed1bf85b05e03d1ec6bc5e362e879a907ac8c539cb1ed31a34dd520c392ba \ + --hash=sha256:7cce0fd2a9128b5dce6031ebbd15a9f392ed08f6ef53ccce15d47543a5fac5fe \ + --hash=sha256:839cce114504b466da8ebee42f661845d8566679e90b9707629e7b9f3021dcd0 \ + --hash=sha256:949ae795894e92bc3739e5e52b187457644144791917bafed3d58913d7ac07dc \ + --hash=sha256:a4103201781d268e9d46c6e6d93b8d983ee9ec9dbe4c92d3f8181c909bd1deb9 \ + --hash=sha256:a4db2ee6121ad47546d34afefd35d83fdfb41bf03bf03f7c633cb1296411725f \ + --hash=sha256:a7ef8dc76a1aa113667543ad79b37ed995f8dd518109e59f4c6952e987a45e43 \ + --hash=sha256:b2321cafda2d411287f4fb4ef2e6a997c55101355c79088648ef54743f388874 \ + --hash=sha256:b2e39c2d93a779739b937402bc1f57fdfb5c9514acdcc71215af07d6de8422f3 \ + --hash=sha256:f82490ddeb4c0e074f49f5d40c5e2ff863db13e8d3ea894401137f01177f5a65 \ + --hash=sha256:fa3bb9394a96d315d60cd2453a7d17d891693e14f31840bff2cdc96b6552c48f \ + --hash=sha256:fa506c6492f3993756de785c01a7f2d9615b9c7ccfd8ac56c5b8b08b2db74768 + # via + # -r requirements/dev.in + # -r requirements/prod.txt +watchfiles==1.0.5 \ + --hash=sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827 \ + --hash=sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f \ + --hash=sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d \ + --hash=sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234 \ + --hash=sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a \ + --hash=sha256:15ac96dd567ad6c71c71f7b2c658cb22b7734901546cd50a475128ab557593ca \ + --hash=sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5 \ + --hash=sha256:1909e0a9cd95251b15bff4261de5dd7550885bd172e3536824bf1cf6b121e200 \ + --hash=sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417 \ + --hash=sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2 \ + --hash=sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827 \ + --hash=sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed \ + --hash=sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f \ + --hash=sha256:2b7a21715fb12274a71d335cff6c71fe7f676b293d322722fe708a9ec81d91f5 \ + --hash=sha256:2cfb371be97d4db374cba381b9f911dd35bb5f4c58faa7b8b7106c8853e5d225 \ + --hash=sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d \ + --hash=sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246 \ + --hash=sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705 \ + --hash=sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec \ + --hash=sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc \ + --hash=sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663 \ + --hash=sha256:4b6227351e11c57ae997d222e13f5b6f1f0700d84b8c52304e8675d33a808382 \ + --hash=sha256:554389562c29c2c182e3908b149095051f81d28c2fec79ad6c8997d7d63e0009 \ + --hash=sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40 \ + --hash=sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63 \ + --hash=sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614 \ + --hash=sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487 \ + --hash=sha256:832ccc221927c860e7286c55c9b6ebcc0265d5e072f49c7f6456c7798d2b39aa \ + --hash=sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936 \ + --hash=sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6 \ + --hash=sha256:85fbb6102b3296926d0c62cfc9347f6237fb9400aecd0ba6bbda94cae15f2b3b \ + --hash=sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92 \ + --hash=sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563 \ + --hash=sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb \ + --hash=sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d \ + --hash=sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034 \ + --hash=sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418 \ + --hash=sha256:974866e0db748ebf1eccab17862bc0f0303807ed9cda465d1324625b81293a18 \ + --hash=sha256:9848b21ae152fe79c10dd0197304ada8f7b586d3ebc3f27f43c506e5a52a863c \ + --hash=sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249 \ + --hash=sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763 \ + --hash=sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d \ + --hash=sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256 \ + --hash=sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6 \ + --hash=sha256:a3904d88955fda461ea2531fcf6ef73584ca921415d5cfa44457a225f4a42bc1 \ + --hash=sha256:a74add8d7727e6404d5dc4dcd7fac65d4d82f95928bbee0cf5414c900e86773e \ + --hash=sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04 \ + --hash=sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11 \ + --hash=sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2 \ + --hash=sha256:b659576b950865fdad31fa491d31d37cf78b27113a7671d39f919828587b429b \ + --hash=sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096 \ + --hash=sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9 \ + --hash=sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40 \ + --hash=sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01 \ + --hash=sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85 \ + --hash=sha256:c0901429650652d3f0da90bad42bdafc1f9143ff3605633c455c999a2d786cac \ + --hash=sha256:cb1489f25b051a89fae574505cc26360c8e95e227a9500182a7fe0afcc500ce0 \ + --hash=sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff \ + --hash=sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358 \ + --hash=sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25 \ + --hash=sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f \ + --hash=sha256:dfd6ae1c385ab481766b3c61c44aca2b3cd775f6f7c0fa93d979ddec853d29d5 \ + --hash=sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4 \ + --hash=sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9 \ + --hash=sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512 \ + --hash=sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a \ + --hash=sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11 \ + --hash=sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d \ + --hash=sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965 \ + --hash=sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21 \ + --hash=sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57 + # via + # -r requirements/dev.in + # -r requirements/prod.txt webob==1.8.9 \ --hash=sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9 \ --hash=sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589 - # via hawkauthlib + # via + # -r requirements/prod.txt + # hawkauthlib whitenoise==6.9.0 \ --hash=sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609 \ --hash=sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df - # via -r requirements/prod.in + # via -r requirements/prod.txt From 955be7ee76c024906f5c95fc1b4f1e702067f29d Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Mon, 20 Oct 2025 16:00:58 -0700 Subject: [PATCH 023/137] Fix some JSON parsing issues --- basket/news/management/commands/push_message_to_queue.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/basket/news/management/commands/push_message_to_queue.py b/basket/news/management/commands/push_message_to_queue.py index d51e2166..09442d9d 100644 --- a/basket/news/management/commands/push_message_to_queue.py +++ b/basket/news/management/commands/push_message_to_queue.py @@ -26,7 +26,7 @@ class Command(BaseCommand): def add_arguments(self, parser): - parser.add_argument("-m", "--message", type=str, default="{}", help="JSON message to process") + parser.add_argument("-b", "--body", type=str, default="{}", help="JSON body to process") parser.add_argument( "-e", "--event", @@ -36,11 +36,9 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - message = options.get("message") + message = options.get("body") event = options.get("event") - print(event) - sqs = boto3.resource( "sqs", region_name=settings.FXA_EVENTS_QUEUE_REGION, @@ -51,4 +49,4 @@ def handle(self, *args, **options): queue = sqs.Queue(settings.FXA_EVENTS_QUEUE_URL) - queue.send_message(MessageBody=json.dumps({"event": event, "Message": message})) + queue.send_message(MessageBody=json.dumps({"Message": json.dumps({"event": event, "message": message})})) From a13668e5c3af70a89c63e16095d5f7604c538725 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:12:35 -0300 Subject: [PATCH 024/137] Update dsar delete with use_braze_backend option --- basket/admin.py | 63 +++++++++++++++++++---------------- basket/news/backends/braze.py | 32 +++++++++++++++--- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index 6cc9c04b..3d2d22e7 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -11,7 +11,7 @@ import sentry_sdk from basket.base.forms import EmailForm, EmailListForm -from basket.news.backends.braze import braze +from basket.news.backends.braze import BrazeUserNotFoundByEmailError, braze from basket.news.backends.ctms import ( CTMSNotFoundByEmailError, CTMSNotFoundByEmailIDError, @@ -192,35 +192,42 @@ def dsar_delete_view(self, request): emails = form.cleaned_data["emails"] output = [] - # Process the emails. - for email in emails: - try: - data = ctms.delete(email) - except CTMSNotFoundByEmailError: - output.append(f"{email} not found in CTMS") - else: - for contact in data: - email_id = contact["email_id"] - msg = f"DELETED {email} (ctms id: {email_id})." - if contact["fxa_id"]: - msg += " fxa: YES." - if contact["mofo_contact_id"]: - msg += " mofo: YES." - output.append(msg) - - if settings.BRAZE_DELETE_USER_ENABLE: + def handler(emails, use_braze_backend=False): + # Process the emails. + for email in emails: try: - # Fetch braze_ids instead of external_ids so we also delete - # alias-only profiles. - response = braze.export_users(email, ["braze_id"]) - if response and response.get("users"): - braze_ids = [user["braze_id"] for user in response["users"]] - braze.delete_users(braze_ids) - msg = f"DELETED {email} (braze ids: {', '.join(braze_ids)})." + if use_braze_backend: + data = braze.delete(email) + else: + data = ctms.delete(email) + except CTMSNotFoundByEmailError: + output.append(f"{email} not found in CTMS") + except BrazeUserNotFoundByEmailError: + output.append(f"{email} not found in Braze") + else: + for contact in data: + email_id = contact["email_id"] + if use_braze_backend: + msg = f"DELETED {email} from Braze (external_id: {email_id})." + else: + msg = f"DELETED {email} from CTMS (ctms id: {email_id})." + if contact.get("fxa_id"): + msg += " fxa: YES." + if contact.get("mofo_contact_id"): + msg += " mofo: YES." output.append(msg) - except Exception as e: - sentry_sdk.capture_exception() - log.error(f"Braze user deletion error: {e}") + + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + try: + handler(emails, use_braze_backend=True) + except Exception: + sentry_sdk.capture_exception() + + handler(emails, use_braze_backend=False) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + handler(emails, use_braze_backend=True) + else: + handler(emails, use_braze_backend=False) output = "\n".join(output) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index c85f79c1..bc94c864 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -38,6 +38,10 @@ class BrazeInternalServerError(Exception): pass # 500 error (Braze server error) +class BrazeUserNotFoundByEmailError(Exception): + pass + + class BrazeClientError(Exception): pass # any other error @@ -196,15 +200,22 @@ def export_users(self, email, fields_to_export=None, external_id=None): return self._request(BrazeEndpoint.USERS_EXPORT_IDS, data) - def delete_users(self, braze_ids): + def delete_user(self, email): """ - Delete user profile by braze ids. + Delete user profile by email. https://www.braze.com/docs/api/endpoints/user_data/post_user_delete/ """ - data = {"braze_ids": braze_ids} + data = { + "email_addresses": [ + { + "email": email, + "prioritization": ["most_recently_updated"], + }, + ] + } return self._request(BrazeEndpoint.USERS_DELETE, data) @@ -307,7 +318,20 @@ def update_by_token(self, token, update_data): raise NotImplementedError def delete(self, email): - raise NotImplementedError + """ + Delete the user matching the email + + @param email: The email of the user + @return: deleted user data if successful + @raises: BrazeUserNotFoundByEmailError + """ + data = self.get(email=email) + if not data: + raise BrazeUserNotFoundByEmailError + + self.interface.delete_user(email) + # return in list to match CTMS.delete + return [data] def from_vendor(self, braze_user_data, subscription_groups): """ From 1d9aaa16aca9814dd5cd0e7dadf8bb9cebe4699e Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:32:12 -0300 Subject: [PATCH 025/137] Remove unused setting --- basket/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/basket/settings.py b/basket/settings.py index beb42ca6..8b398fd7 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -220,8 +220,6 @@ def path(*args): "firefox-mobile-welcome": "download-firefox-mobile", } -BRAZE_DELETE_USER_ENABLE = config("BRAZE_DELETE_USER_ENABLE", parser=bool, default="false") - BRAZE_PARALLEL_WRITE_ENABLE = config("BRAZE_PARALLEL_WRITE_ENABLE", parser=bool, default="false") BRAZE_ONLY_WRITE_ENABLE = config("BRAZE_ONLY_WRITE_ENABLE", parser=bool, default="false") BRAZE_READ_WITH_FALLBACK_ENABLE = config("BRAZE_READ_WITH_FALLBACK_ENABLE", parser=bool, default="false") From b28a3f6dc4f98ec80a981aae72d4bea082e4fb9e Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:51:51 -0300 Subject: [PATCH 026/137] handle billable attributes --- basket/news/backends/braze.py | 68 +++++++++++++++++------------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index bc94c864..e2eccf62 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -367,7 +367,8 @@ def from_vendor(self, braze_user_data, subscription_groups): return basket_user_data def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=None, events=None): - updated_user_data = (basket_user_data or {}) | (update_data or {}) + existing_user_data = basket_user_data or {} + updated_user_data = existing_user_data | (update_data or {}) now = timezone.now().isoformat() country = process_country(updated_user_data.get("country")) @@ -385,43 +386,42 @@ def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=N } ) - braze_data = { - "attributes": [ + user_attributes = { + "external_id": updated_user_data.get("email_id"), # TODO: conditional on migration status config (could be basket token instead) + "email": updated_user_data.get("email"), + "update_timestamp": now, + "_update_existing_only": True, + "email_subscribe": "opted_in" if updated_user_data.get("optin") else "unsubscribed" if updated_user_data.get("optout") else "subscribed", + "subscription_groups": subscription_groups, + "user_attributes_v1": [ { - "external_id": updated_user_data.get("email_id"), # TODO: conditional on migration status config (could be basket token instead) - "email": updated_user_data.get("email"), - "first_name": updated_user_data.get("first_name"), - "last_name": updated_user_data.get("last_name"), - "country": country, - "language": language, - "update_timestamp": now, - "_update_existing_only": True, - "email_subscribe": "opted_in" - if updated_user_data.get("optin") - else "unsubscribed" - if updated_user_data.get("optout") - else "subscribed", - "subscription_groups": subscription_groups, - "user_attributes_v1": [ - { - "basket_token": updated_user_data.get("token"), - "created_at": {"$time": updated_user_data.get("created_date", now)}, - "email_lang": language, - "mailing_country": country, - "updated_at": {"$time": now}, - "has_fxa": bool(updated_user_data.get("fxa_create_date")), - "fxa_created_at": updated_user_data.get("fxa_create_date"), - "fxa_first_service": updated_user_data.get("fxa_service"), - "fxa_lang": updated_user_data.get("fxa_lang"), - "fxa_primary_email": updated_user_data.get("fxa_primary_email"), - # TODO: missing field: fxa_id - } - ], + "basket_token": updated_user_data.get("token"), + "created_at": {"$time": updated_user_data.get("created_date", now)}, + "email_lang": language, + "mailing_country": country, + "updated_at": {"$time": now}, + "has_fxa": bool(updated_user_data.get("fxa_create_date")), + "fxa_created_at": updated_user_data.get("fxa_create_date"), + "fxa_first_service": updated_user_data.get("fxa_service"), + "fxa_lang": updated_user_data.get("fxa_lang"), + "fxa_primary_email": updated_user_data.get("fxa_primary_email"), + # TODO: missing field: fxa_id } - | (custom_attributes or {}) - ] + ], } + # Country, language, first and last name are billable data points. Only update them when necessary. + if country != existing_user_data.get("country"): + user_attributes["country"] = country + if language != existing_user_data.get("language"): + user_attributes["language"] = language + if (first_name := updated_user_data.get("first_name")) != existing_user_data.get("first_name"): + user_attributes["first_name"] = first_name + if (last_name := updated_user_data.get("last_name")) != existing_user_data.get("last_name"): + user_attributes["last_name"] = last_name + + braze_data = {"attributes": [user_attributes | (custom_attributes or {})]} + if events: braze_data["events"] = events From 99f36fd59ab786e108f4bd3a0eea7e0a249fbd7c Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:53:23 -0300 Subject: [PATCH 027/137] implement braze.update --- basket/news/backends/braze.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index e2eccf62..b6390f3b 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -309,7 +309,8 @@ def add(self, data): self.interface.save_user(braze_user_data) def update(self, existing_data, update_data): - raise NotImplementedError + braze_user_data = self.to_vendor(existing_data, update_data) + self.interface.save_user(braze_user_data) def update_by_fxa_id(self, fxa_id, update_data): raise NotImplementedError From f2e87a2f225809fb54ab4444e9ad0699b5c5ae4b Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:12:06 -0300 Subject: [PATCH 028/137] add braze_add_or_update --- basket/news/tasks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 50e1ca61..2a6448b1 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -375,7 +375,10 @@ def upsert_contact( @rq_task def braze_add_or_update(update_data, user_data=None): - raise NotImplementedError + if user_data is None: + braze.add(update_data) + else: + braze.update(user_data, update_data) @rq_task From 65aab7e9d7a40fa101638ac0812452e82b5d930a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:29:47 -0300 Subject: [PATCH 029/137] fix conditions --- basket/news/backends/braze.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index b6390f3b..0b1db345 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -412,9 +412,9 @@ def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=N } # Country, language, first and last name are billable data points. Only update them when necessary. - if country != existing_user_data.get("country"): + if country != process_country(existing_user_data.get("country")): user_attributes["country"] = country - if language != existing_user_data.get("language"): + if language != process_lang(existing_user_data.get("language")): user_attributes["language"] = language if (first_name := updated_user_data.get("first_name")) != existing_user_data.get("first_name"): user_attributes["first_name"] = first_name From 7cc12b6a1fb892b62ce8bb1d0525b8e00f0f97b9 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:29:41 -0300 Subject: [PATCH 030/137] Update test_view_admin_dsar.py --- basket/base/tests/test_view_admin_dsar.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index 4c7a9981..ffd44548 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -72,9 +72,9 @@ def test_post_valid_emails(self): assert response.status_code == 200 assert mock_ctms.delete.call_count == 3 - assert "DELETED test1@example.com (ctms id: 123)." in response.context["dsar_output"] - assert "DELETED test2@example.com (ctms id: 456). fxa: YES." in response.context["dsar_output"] - assert "DELETED test3@example.com (ctms id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] + assert "DELETED test1@example.com from CTMS (ctms id: 123)." in response.context["dsar_output"] + assert "DELETED test2@example.com from CTMS (ctms id: 456). fxa: YES." in response.context["dsar_output"] + assert "DELETED test3@example.com from CTMS (ctms id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] def test_post_valid_email(self): self._create_admin_user() @@ -85,7 +85,7 @@ def test_post_valid_email(self): assert response.status_code == 200 assert mock_ctms.delete.called - assert "DELETED test@example.com (ctms id: 123)." in response.context["dsar_output"] + assert "DELETED test@example.com from CTMS (ctms id: 123)." in response.context["dsar_output"] def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() From afc50cb49cdf1d6254a935c89c634451ef41a2c3 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 09:56:58 -0300 Subject: [PATCH 031/137] Update test_update_user_task.py --- basket/news/tests/__init__.py | 22 ++++++++++++++++++++++ basket/news/tests/test_update_user_task.py | 18 +++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/basket/news/tests/__init__.py b/basket/news/tests/__init__.py index 7739842d..48d53cc8 100644 --- a/basket/news/tests/__init__.py +++ b/basket/news/tests/__init__.py @@ -25,3 +25,25 @@ def _patch_tasks(self, name): patcher = patch("basket.news.tasks." + name) setattr(self, name, patcher.start()) self.addCleanup(patcher.stop) + + +def assert_called_with_subset(mock_method, *expected_args, **expected_kwargs): + """Assert that mock was called with at least the specified args/kwargs""" + assert mock_method.called, f"{mock_method} was not called" + + actual_args, actual_kwargs = mock_method.call_args + + # Check positional args + if len(expected_args) > len(actual_args): + raise AssertionError(f"Expected at least {len(expected_args)} args, got {len(actual_args)}") + + for i, expected_arg in enumerate(expected_args): + if actual_args[i] != expected_arg: + raise AssertionError(f"Arg {i}: expected {expected_arg}, got {actual_args[i]}") + + # Check keyword args + for key, expected_value in expected_kwargs.items(): + if key not in actual_kwargs: + raise AssertionError(f"Expected keyword arg '{key}' not found") + if actual_kwargs[key] != expected_value: + raise AssertionError(f"Kwarg '{key}': expected {expected_value}, got {actual_kwargs[key]}") diff --git a/basket/news/tests/test_update_user_task.py b/basket/news/tests/test_update_user_task.py index 40116803..649ae845 100644 --- a/basket/news/tests/test_update_user_task.py +++ b/basket/news/tests/test_update_user_task.py @@ -9,7 +9,7 @@ from basket import errors from basket.news import views -from basket.news.tests import TasksPatcherMixin +from basket.news.tests import TasksPatcherMixin, assert_called_with_subset from basket.news.utils import SET, SUBSCRIBE, UNSUBSCRIBE @@ -61,7 +61,7 @@ def test_accept_lang(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) - self.upsert_user.delay.assert_called_with(SUBSCRIBE, after_data) + assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, after_data) @patch("basket.news.utils.get_best_language") @patch("basket.news.utils.newsletter_languages") @@ -75,7 +75,7 @@ def test_accept_lang_header(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) - self.upsert_user.delay.assert_called_with(SUBSCRIBE, after_data) + assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, after_data) @patch("basket.news.utils.get_best_language") @patch("basket.news.utils.newsletter_languages") @@ -92,7 +92,7 @@ def test_lang_overrides_accept_lang(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) # basically asserts that the data['lang'] value wasn't changed. - self.upsert_user.delay.assert_called_with(SUBSCRIBE, data) + assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, data) @patch("basket.news.utils.get_best_language") @patch("basket.news.utils.newsletter_languages") @@ -110,7 +110,7 @@ def test_lang_default_if_not_in_list(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) # basically asserts that the data['lang'] value wasn't changed. - self.upsert_user.delay.assert_called_with(SUBSCRIBE, after_data) + assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, after_data) def test_missing_email(self): """ @@ -131,7 +131,7 @@ def test_success_no_sync(self): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) - self.upsert_user.delay.assert_called_with(SUBSCRIBE, data) + assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, data) self.assertFalse(self.upsert_contact.called) def test_success_with_valid_newsletters(self): @@ -180,7 +180,7 @@ def test_success_with_request_data(self): response = views.update_user_task(request, SUBSCRIBE, sync=False) self.assert_response_ok(response) - self.upsert_user.delay.assert_called_with(SUBSCRIBE, data) + assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, data) @patch("basket.news.views.get_user_data") def test_success_with_sync(self, gud_mock): @@ -196,7 +196,7 @@ def test_success_with_sync(self, gud_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=True) self.assert_response_ok(response, token="mytoken", created=True) - self.upsert_contact.assert_called_with(SUBSCRIBE, data, gud_mock.return_value) + assert_called_with_subset(self.upsert_contact, SUBSCRIBE, data, gud_mock.return_value) @patch("basket.news.views.newsletter_slugs") @patch("basket.news.views.newsletter_private_slugs") @@ -217,7 +217,7 @@ def test_success_with_unsubscribe_private_newsletter( response = views.update_user_task(request, UNSUBSCRIBE, data) self.assert_response_ok(response) - self.upsert_user.delay.assert_called_with(UNSUBSCRIBE, data) + assert_called_with_subset(self.upsert_user.delay, UNSUBSCRIBE, data) mock_api_key.assert_not_called() @patch("basket.news.views.newsletter_and_group_slugs") From 3832c7398bc8d58b5a27671f8613707fe4141d71 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:21:24 -0300 Subject: [PATCH 032/137] Update test_views and test_update_user_task --- basket/news/tests/__init__.py | 11 +++++--- basket/news/tests/test_update_user_task.py | 18 ++++++------- basket/news/tests/test_views.py | 30 +++++++++++----------- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/basket/news/tests/__init__.py b/basket/news/tests/__init__.py index 48d53cc8..9c85f05d 100644 --- a/basket/news/tests/__init__.py +++ b/basket/news/tests/__init__.py @@ -1,5 +1,5 @@ import functools -from unittest.mock import patch +from unittest.mock import Mock, patch from markus.testing import MetricsMock @@ -27,11 +27,11 @@ def _patch_tasks(self, name): self.addCleanup(patcher.stop) -def assert_called_with_subset(mock_method, *expected_args, **expected_kwargs): +def assert_called_with_subset(self, *expected_args, **expected_kwargs): """Assert that mock was called with at least the specified args/kwargs""" - assert mock_method.called, f"{mock_method} was not called" + assert self.called, f"{self} was not called" - actual_args, actual_kwargs = mock_method.call_args + actual_args, actual_kwargs = self.call_args # Check positional args if len(expected_args) > len(actual_args): @@ -47,3 +47,6 @@ def assert_called_with_subset(mock_method, *expected_args, **expected_kwargs): raise AssertionError(f"Expected keyword arg '{key}' not found") if actual_kwargs[key] != expected_value: raise AssertionError(f"Kwarg '{key}': expected {expected_value}, got {actual_kwargs[key]}") + + +Mock.assert_called_with_subset = assert_called_with_subset diff --git a/basket/news/tests/test_update_user_task.py b/basket/news/tests/test_update_user_task.py index 649ae845..7981cc40 100644 --- a/basket/news/tests/test_update_user_task.py +++ b/basket/news/tests/test_update_user_task.py @@ -9,7 +9,7 @@ from basket import errors from basket.news import views -from basket.news.tests import TasksPatcherMixin, assert_called_with_subset +from basket.news.tests import TasksPatcherMixin from basket.news.utils import SET, SUBSCRIBE, UNSUBSCRIBE @@ -61,7 +61,7 @@ def test_accept_lang(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) - assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, after_data) + self.upsert_user.delay.assert_called_with_subset(SUBSCRIBE, after_data) @patch("basket.news.utils.get_best_language") @patch("basket.news.utils.newsletter_languages") @@ -75,7 +75,7 @@ def test_accept_lang_header(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) - assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, after_data) + self.upsert_user.delay.assert_called_with_subset(SUBSCRIBE, after_data) @patch("basket.news.utils.get_best_language") @patch("basket.news.utils.newsletter_languages") @@ -92,7 +92,7 @@ def test_lang_overrides_accept_lang(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) # basically asserts that the data['lang'] value wasn't changed. - assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, data) + self.upsert_user.delay.assert_called_with_subset(SUBSCRIBE, data) @patch("basket.news.utils.get_best_language") @patch("basket.news.utils.newsletter_languages") @@ -110,7 +110,7 @@ def test_lang_default_if_not_in_list(self, nl_mock, get_best_language_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) # basically asserts that the data['lang'] value wasn't changed. - assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, after_data) + self.upsert_user.delay.assert_called_with_subset(SUBSCRIBE, after_data) def test_missing_email(self): """ @@ -131,7 +131,7 @@ def test_success_no_sync(self): response = views.update_user_task(request, SUBSCRIBE, data, sync=False) self.assert_response_ok(response) - assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, data) + self.upsert_user.delay.assert_called_with_subset(SUBSCRIBE, data) self.assertFalse(self.upsert_contact.called) def test_success_with_valid_newsletters(self): @@ -180,7 +180,7 @@ def test_success_with_request_data(self): response = views.update_user_task(request, SUBSCRIBE, sync=False) self.assert_response_ok(response) - assert_called_with_subset(self.upsert_user.delay, SUBSCRIBE, data) + self.upsert_user.delay.assert_called_with_subset(SUBSCRIBE, data) @patch("basket.news.views.get_user_data") def test_success_with_sync(self, gud_mock): @@ -196,7 +196,7 @@ def test_success_with_sync(self, gud_mock): response = views.update_user_task(request, SUBSCRIBE, data, sync=True) self.assert_response_ok(response, token="mytoken", created=True) - assert_called_with_subset(self.upsert_contact, SUBSCRIBE, data, gud_mock.return_value) + self.upsert_contact.assert_called_with_subset(SUBSCRIBE, data, gud_mock.return_value) @patch("basket.news.views.newsletter_slugs") @patch("basket.news.views.newsletter_private_slugs") @@ -217,7 +217,7 @@ def test_success_with_unsubscribe_private_newsletter( response = views.update_user_task(request, UNSUBSCRIBE, data) self.assert_response_ok(response) - assert_called_with_subset(self.upsert_user.delay, UNSUBSCRIBE, data) + self.upsert_user.delay.assert_called_with_subset(UNSUBSCRIBE, data) mock_api_key.assert_not_called() @patch("basket.news.views.newsletter_and_group_slugs") diff --git a/basket/news/tests/test_views.py b/basket/news/tests/test_views.py index 03bd2b85..824bdeed 100644 --- a/basket/news/tests/test_views.py +++ b/basket/news/tests/test_views.py @@ -36,7 +36,7 @@ def test_valid_uppercase_country(self, uum_mock): req = self.rf.post("/", {"country": "GB"}) resp = views.user_meta(req, "the-dudes-token-man") assert resp.status_code == 200 - uum_mock.delay.assert_called_with( + uum_mock.delay.assert_called_with_subset( "the-dudes-token-man", {"country": "gb"}, ) @@ -45,7 +45,7 @@ def test_only_send_given_values(self, uum_mock): req = self.rf.post("/", {"first_name": "The", "last_name": "Dude"}) resp = views.user_meta(req, "the-dudes-token-man") assert resp.status_code == 200 - uum_mock.delay.assert_called_with( + uum_mock.delay.assert_called_with_subset( "the-dudes-token-man", {"first_name": "The", "last_name": "Dude"}, ) @@ -104,7 +104,7 @@ def test_non_ascii_email(self, update_user_mock): data={"email": "dude@黒川.日本", "newsletters": "firefox-os"}, ) views.subscribe(req) - update_user_mock.assert_called_with( + update_user_mock.assert_called_with_subset( req, views.SUBSCRIBE, data={"email": "dude@xn--5rtw95l.xn--wgv71a", "newsletters": "firefox-os"}, @@ -179,7 +179,7 @@ def test_optin_valid_api_key_required(self): response = views.subscribe(request) self.assertEqual(response, self.update_user_task.return_value) - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -299,7 +299,7 @@ def test_no_source_url_referrer(self, metricsmock): self.assertEqual(response, self.update_user_task.return_value) self.process_email.assert_called_with(request_data["email"]) - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -333,7 +333,7 @@ def test_source_url_overrides_referrer(self): self.assertEqual(response, self.update_user_task.return_value) self.process_email.assert_called_with(request_data["email"]) - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -363,7 +363,7 @@ def test_success_with_email(self): self.assertEqual(response, self.update_user_task.return_value) self.process_email.assert_called_with(request_data["email"]) - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -396,7 +396,7 @@ def test_success_with_token(self, get_user_data_mock): self.assertEqual(response, self.update_user_task.return_value) self.is_token.assert_called_with(request_data["token"]) self.process_email.assert_called_with(email) - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -424,7 +424,7 @@ def test_success_sync_optin(self): self.is_authorized.assert_called_with(request, self.process_email.return_value) self.assertEqual(response, self.update_user_task.return_value) self.process_email.assert_called_with("dude@example.com") - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -451,7 +451,7 @@ def test_success_sync_optin_lowercase(self): self.assertEqual(response, self.update_user_task.return_value) self.process_email.assert_called_with("dude@example.com") - self.update_user_task.assert_called_with( + self.update_user_task.assert_called_with_subset( request, SUBSCRIBE, data=update_data, @@ -868,7 +868,7 @@ def test_existing_user(self, metricsmock): fxa_profile_mock.get_profile.assert_called_with("access-token") metricsmock.assert_incr_once("news.views.fxa_callback", tags=["status:success"]) assert resp["location"] == "https://www.mozilla.org/newsletter/existing/the-token/?fxa=1" - self.get_user_data.assert_called_with(email="dude@example.com", fxa_id="abc123") + self.get_user_data.assert_called_with_subset(email="dude@example.com", fxa_id="abc123") @mock_metrics def test_new_user_with_locale(self, metricsmock): @@ -898,8 +898,8 @@ def test_new_user_with_locale(self, metricsmock): fxa_profile_mock.get_profile.assert_called_with("access-token") metricsmock.assert_incr_once("news.views.fxa_callback", tags=["status:success"]) assert resp["location"] == "https://www.mozilla.org/newsletter/existing/the-new-token/?fxa=1" - self.get_user_data.assert_called_with(email="dude@example.com", fxa_id=None) - self.upsert_contact.assert_called_with( + self.get_user_data.assert_called_with_subset(email="dude@example.com", fxa_id=None) + self.upsert_contact.assert_called_with_subset( SUBSCRIBE, { "email": "dude@example.com", @@ -939,8 +939,8 @@ def test_new_user_without_locale(self, metricsmock): fxa_profile_mock.get_profile.assert_called_with("access-token") metricsmock.assert_incr_once("news.views.fxa_callback", tags=["status:success"]) assert resp["location"] == "https://www.mozilla.org/newsletter/existing/the-new-token/?fxa=1" - self.get_user_data.assert_called_with(email="dude@example.com", fxa_id=None) - self.upsert_contact.assert_called_with( + self.get_user_data.assert_called_with_subset(email="dude@example.com", fxa_id=None) + self.upsert_contact.assert_called_with_subset( SUBSCRIBE, { "email": "dude@example.com", From e4ad92bbaffd129045ae4e9a90a12cc3e9015b86 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:28:26 -0300 Subject: [PATCH 033/137] Update test_update_users.py and test_upsert_user.py --- basket/news/tests/test_upsert_user.py | 7 +++++++ basket/news/tests/test_users.py | 2 +- basket/news/views.py | 10 ---------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/basket/news/tests/test_upsert_user.py b/basket/news/tests/test_upsert_user.py index b441d32b..a646ea53 100644 --- a/basket/news/tests/test_upsert_user.py +++ b/basket/news/tests/test_upsert_user.py @@ -51,6 +51,7 @@ def test_update_first_last_names( "first_name": "The", "last_name": "Dude", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -261,6 +262,7 @@ def test_send_confirm(self, get_user_mock, ctms_mock, confirm_mock): "lang": "en", "newsletters": "slug", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -289,6 +291,7 @@ def test_send_fx_confirm(self, get_user_mock, ctms_mock, confirm_mock): "lang": "en", "newsletters": "slug", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -326,6 +329,7 @@ def test_send_moz_confirm(self, get_user_mock, ctms_mock, confirm_mock): "lang": "en", "newsletters": "slug,slug2", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -361,6 +365,7 @@ def test_no_send_confirm_newsletter( "lang": "en", "newsletters": "slug", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -494,6 +499,7 @@ def test_new_subscription_with_ctms_conflict( "lang": "en", "newsletters": "slug", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -527,6 +533,7 @@ def test_new_user_subscribes_to_mofo_newsletter( "lang": "en", "newsletters": "mozilla-foundation", "email": self.email, + "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() diff --git a/basket/news/tests/test_users.py b/basket/news/tests/test_users.py index 0d55421e..0c870d3a 100644 --- a/basket/news/tests/test_users.py +++ b/basket/news/tests/test_users.py @@ -26,7 +26,7 @@ def test_user_set(self, update_user_task): """If request is POST, it should attempt to update the user's info.""" update_user_task.return_value = HttpResponse() resp = self.client.post(self.url, data={"fake": "data"}) - update_user_task.assert_called_with( + update_user_task.assert_called_with_subset( resp.wsgi_request, SET, {"fake": "data", "token": self.token}, diff --git a/basket/news/views.py b/basket/news/views.py index 2a6b7fe8..fa2a98e7 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -215,7 +215,6 @@ def handler( uid, use_braze_backend=False, should_send_tx_messages=True, - extra_metrics_tags=["backend:ctms"], pre_generated_token=pre_generated_token, ) elif settings.BRAZE_ONLY_WRITE_ENABLE: @@ -232,7 +231,6 @@ def handler( uid, use_braze_backend=False, should_send_tx_messages=True, - extra_metrics_tags=["backend:ctms"], ) @@ -258,7 +256,6 @@ def confirm(request, token): tasks.confirm_user.delay( token, use_braze_backend=False, - extra_metrics_tags=["backend:ctms"], ) elif settings.BRAZE_ONLY_WRITE_ENABLE: tasks.confirm_user.delay( @@ -270,7 +267,6 @@ def confirm(request, token): tasks.confirm_user.delay( token, use_braze_backend=False, - extra_metrics_tags=["backend:ctms"], ) return HttpResponseJSON({"status": "ok"}) @@ -493,7 +489,6 @@ def handler( use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, - extra_metrics_tags=["backend:ctms"], pre_generated_token=pre_generated_token, pre_generated_email_id=pre_generated_email_id, ) @@ -515,7 +510,6 @@ def handler( use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, - extra_metrics_tags=["backend:ctms"], ) @@ -571,7 +565,6 @@ def unsubscribe(request, token): use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, - extra_metrics_tags=["backend:ctms"], ) elif settings.BRAZE_ONLY_WRITE_ENABLE: return update_user_task( @@ -591,7 +584,6 @@ def unsubscribe(request, token): use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, - extra_metrics_tags=["backend:ctms"], ) @@ -655,7 +647,6 @@ def user(request, token): use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, - extra_metrics_tags=["backend:ctms"], pre_generated_token=pre_generated_token, ) elif settings.BRAZE_ONLY_WRITE_ENABLE: @@ -676,7 +667,6 @@ def user(request, token): use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, - extra_metrics_tags=["backend:ctms"], ) masked = not has_valid_api_key(request) From 96c7fa22faac4a27ba1a39075d96b30cb3ab0d7d Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:13:05 -0300 Subject: [PATCH 034/137] Update test_braze.py --- basket/news/tests/test_braze.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index b15f4587..c5edabe8 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -11,24 +11,24 @@ @pytest.fixture def braze_client(): - return braze.BrazeClient("http://test.com", "test_api_key") + return braze.BrazeInterface("http://test.com", "test_api_key") def test_braze_client_no_api_key(): with pytest.warns(UserWarning, match="Braze API key is not configured"): - braze_client = braze.BrazeClient("http://test.com", "") + braze_client = braze.BrazeInterface("http://test.com", "") assert braze_client.active is False assert braze_client.track_user("test@test.com") is None def test_braze_client_no_base_url(): with pytest.raises(ValueError): - braze.BrazeClient("", "test_api_key") + braze.BrazeInterface("", "test_api_key") def test_braze_client_invalid_base_url(): with pytest.raises(ValueError): - braze.BrazeClient("test.com", "test_api_key") + braze.BrazeInterface("test.com", "test_api_key") def test_braze_client_headers(braze_client): @@ -170,12 +170,19 @@ def test_braze_send_campaign(braze_client): assert m.last_request.json() == expected -def test_braze_delete_users(braze_client): - braze_ids = ["abc123"] - expected = {"braze_ids": braze_ids} +def test_braze_delete_user(braze_client): + email = "test@example.com" + expected = { + "email_addresses": [ + { + "email": email, + "prioritization": ["most_recently_updated"], + }, + ] + } with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/delete", json={}) - braze_client.delete_users(braze_ids) + braze_client.delete_user(email) assert m.last_request.json() == expected From 2591590d5a4129bc03cf1a4756fa5d5674718f7a Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Wed, 22 Oct 2025 08:56:37 -0700 Subject: [PATCH 035/137] JSON load body --- basket/news/management/commands/push_message_to_queue.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/management/commands/push_message_to_queue.py b/basket/news/management/commands/push_message_to_queue.py index 09442d9d..81287141 100644 --- a/basket/news/management/commands/push_message_to_queue.py +++ b/basket/news/management/commands/push_message_to_queue.py @@ -36,7 +36,7 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - message = options.get("body") + message = json.loads(options.get("body")) event = options.get("event") sqs = boto3.resource( @@ -49,4 +49,4 @@ def handle(self, *args, **options): queue = sqs.Queue(settings.FXA_EVENTS_QUEUE_URL) - queue.send_message(MessageBody=json.dumps({"Message": json.dumps({"event": event, "message": message})})) + queue.send_message(MessageBody=json.dumps({"Message": json.dumps({"event": event, **message})})) From faa0a721fa9d8e179bc88d596a13a86e978eaaf3 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:05:33 -0300 Subject: [PATCH 036/137] Remove google-cloud-storage, pandas, and pyarrow from dev.in They get included by `-r prod.txt` --- requirements/dev.in | 3 --- 1 file changed, 3 deletions(-) diff --git a/requirements/dev.in b/requirements/dev.in index 0acdf487..076efa75 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -9,6 +9,3 @@ requests-mock==1.12.1 ruff==0.11.12 uv==0.7.11 watchfiles==1.0.5 # required for `granian --reload` -google-cloud-storage==3.4.1 -pandas==2.3.3 -pyarrow==21.0.0 From 5ed64f885353745e99f5a81d7691729fd0de85d5 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:07:38 -0300 Subject: [PATCH 037/137] Upgrade django-ninja to 1.4.5 Addresses https://github.com/vitalik/django-ninja/issues/1559 --- requirements/prod.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/prod.in b/requirements/prod.in index cb312d12..137b2f43 100644 --- a/requirements/prod.in +++ b/requirements/prod.in @@ -7,7 +7,7 @@ django-allow-cidr==0.8.0 django-cache-url==3.4.5 django-cors-headers==4.7.0 django-mozilla-product-details==1.0.3 -django-ninja==1.4.3 +django-ninja==1.4.5 django-ratelimit==4.1.0 django-watchman==1.3.0 django==5.2.2 From e6e2d55132af05aaf01243d2e0e5c15aaf7d9404 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:08:40 -0300 Subject: [PATCH 038/137] Compile requirements --- requirements/dev.txt | 768 +++++++++++++------------- requirements/prod.txt | 1214 +++++++++++++++-------------------------- 2 files changed, 840 insertions(+), 1142 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 3e344d57..a08da137 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --generate-hashes --no-strip-extras requirements/dev.in -o requirements/dev.txt +# just compile-requirements annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 @@ -9,16 +9,14 @@ annotated-types==0.7.0 \ anyio==4.11.0 \ --hash=sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc \ --hash=sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4 - # via - # -r requirements/prod.txt - # watchfiles + # via watchfiles apscheduler==3.11.0 \ --hash=sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133 \ --hash=sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da # via -r requirements/prod.txt -asgiref==3.8.1 \ - --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ - --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 +asgiref==3.10.0 \ + --hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \ + --hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e # via # -r requirements/prod.txt # django @@ -27,9 +25,9 @@ boto3==1.38.30 \ --hash=sha256:17af769544b5743843bcc732709b43226de19f1ebff2c324a3440bbecbddb893 \ --hash=sha256:949df0a0edd360f4ad60f1492622eecf98a359a2f72b1e236193d9b320c5dc8c # via -r requirements/prod.txt -botocore==1.38.30 \ - --hash=sha256:530e40a6e91c8a096cab17fcc590d0c7227c8347f71a867576163a44d027a714 \ - --hash=sha256:7836c5041c5f249431dbd5471c61db17d4053f72a1d6e3b2197c07ca0839588b +botocore==1.38.46 \ + --hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \ + --hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b # via # -r requirements/prod.txt # boto3 @@ -40,183 +38,221 @@ cachetools==6.2.1 \ # via # -r requirements/prod.txt # google-auth -certifi==2025.4.26 \ - --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ - --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 +certifi==2025.10.5 \ + --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ + --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 # via # -r requirements/prod.txt # requests # sentry-sdk -cffi==1.17.1 \ - --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ - --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ - --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ - --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ - --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ - --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ - --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ - --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ - --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ - --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ - --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ - --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ - --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ - --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ - --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ - --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ - --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ - --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ - --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ - --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ - --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ - --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ - --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ - --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ - --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ - --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ - --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ - --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ - --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ - --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ - --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ - --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ - --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ - --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ - --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ - --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ - --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ - --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ - --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ - --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ - --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ - --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ - --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ - --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ - --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ - --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf # via # -r requirements/prod.txt # cryptography -charset-normalizer==3.4.2 \ - --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ - --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ - --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ - --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ - --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ - --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ - --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ - --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ - --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ - --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ - --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ - --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ - --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ - --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ - --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ - --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ - --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ - --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ - --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ - --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ - --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ - --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ - --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ - --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ - --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ - --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ - --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ - --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ - --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ - --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ - --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ - --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ - --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ - --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ - --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ - --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ - --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ - --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ - --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ - --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ - --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ - --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ - --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ - --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ - --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ - --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ - --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ - --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ - --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ - --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ - --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ - --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ - --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ - --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ - --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ - --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ - --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ - --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ - --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ - --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ - --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ - --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ - --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ - --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ - --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ - --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ - --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ - --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ - --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ - --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ - --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ - --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ - --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ - --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ - --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ - --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ - --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ - --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ - --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ - --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ - --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ - --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ - --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ - --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ - --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ - --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ - --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ - --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ - --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ - --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ - --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ - --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f +charset-normalizer==3.4.4 \ + --hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \ + --hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \ + --hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \ + --hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \ + --hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \ + --hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \ + --hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \ + --hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \ + --hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \ + --hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \ + --hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \ + --hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \ + --hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \ + --hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \ + --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ + --hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \ + --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ + --hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \ + --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ + --hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \ + --hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \ + --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ + --hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \ + --hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \ + --hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \ + --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ + --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ + --hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \ + --hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \ + --hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \ + --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ + --hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \ + --hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \ + --hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \ + --hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \ + --hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \ + --hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \ + --hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \ + --hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \ + --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ + --hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \ + --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ + --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ + --hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \ + --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ + --hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \ + --hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \ + --hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \ + --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ + --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ + --hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \ + --hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \ + --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ + --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ + --hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \ + --hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \ + --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ + --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ + --hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \ + --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \ + --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ + --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ + --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ + --hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \ + --hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \ + --hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \ + --hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \ + --hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \ + --hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \ + --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ + --hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \ + --hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \ + --hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \ + --hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \ + --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ + --hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \ + --hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \ + --hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \ + --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ + --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ + --hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \ + --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ + --hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \ + --hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \ + --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ + --hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \ + --hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \ + --hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \ + --hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \ + --hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \ + --hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \ + --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ + --hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \ + --hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \ + --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ + --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ + --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ + --hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \ + --hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \ + --hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \ + --hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \ + --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ + --hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \ + --hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \ + --hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \ + --hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \ + --hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \ + --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ + --hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \ + --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \ + --hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \ + --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \ + --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608 # via # -r requirements/prod.txt # requests -click==8.2.1 \ - --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ - --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b +click==8.3.0 \ + --hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \ + --hash=sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4 # via # -r requirements/prod.txt # granian @@ -318,11 +354,9 @@ coverage[toml]==7.11.0 \ --hash=sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836 \ --hash=sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d \ --hash=sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c - # via - # -r requirements/prod.txt - # pytest-cov -crontab==1.0.4 \ - --hash=sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92 + # via pytest-cov +crontab==1.0.5 \ + --hash=sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5 # via # -r requirements/prod.txt # rq-scheduler @@ -370,9 +404,9 @@ cryptography==44.0.3 \ # mozilla-django-oidc # pyfxa # pyopenssl -datadog==0.51.0 \ - --hash=sha256:3279534f831ae0b4ae2d8ce42ef038b4ab38e667d7ed6ff7437982d7a0cf5250 \ - --hash=sha256:a9764f091c96af4e0996d4400b168fc5fba380f911d6d672c9dcd4773e29ea3f +datadog==0.52.1 \ + --hash=sha256:44c6deb563c4522dba206fba2e2bb93d3b04113c40191851ba3a241d82b5fd0b \ + --hash=sha256:b8c92cd761618ee062f114171067e4c400d48c9f0dad16cb285042439d9d5d4e # via # -r requirements/prod.txt # markus @@ -408,9 +442,9 @@ django-mozilla-product-details==1.0.3 \ --hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \ --hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36 # via -r requirements/prod.txt -django-ninja==1.4.3 \ - --hash=sha256:e46d477ca60c228d2a5eb3cc912094928ea830d364501f966661eeada67cb038 \ - --hash=sha256:f3204137a059437b95677049474220611f1cf9efedba9213556474b75168fa01 +django-ninja==1.4.5 \ + --hash=sha256:aa1a2ee2b22c5f1c2f4bfbc004386be7074cbfaf133680c2b359a31221965503 \ + --hash=sha256:d779702ddc6e17b10739049ddb075a6a1e6c6270bdc04e0b0429f6adbf670373 # via -r requirements/prod.txt django-ratelimit==4.1.0 \ --hash=sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b \ @@ -420,9 +454,9 @@ django-watchman==1.3.0 \ --hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \ --hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb # via -r requirements/prod.txt -dnspython==2.7.0 \ - --hash=sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86 \ - --hash=sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1 +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ + --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f # via # -r requirements/prod.txt # email-validator @@ -434,9 +468,9 @@ everett==3.4.0 \ --hash=sha256:f403c4a41764a6301fb31e2558d6e9718999f0eab9e260d986b894fa2e6b6871 \ --hash=sha256:f8c29c7300702f47b7323b75348e2b86647246694fda7ad410c2a2bfaa980ff7 # via -r requirements/prod.txt -freezegun==1.5.2 \ - --hash=sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b \ - --hash=sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181 +freezegun==1.5.5 \ + --hash=sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a \ + --hash=sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2 # via # -r requirements/prod.txt # rq-scheduler @@ -464,9 +498,7 @@ google-cloud-core==2.4.3 \ google-cloud-storage==3.4.1 \ --hash=sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268 \ --hash=sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/prod.txt google-crc32c==1.7.1 \ --hash=sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db \ --hash=sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337 \ @@ -717,9 +749,9 @@ hiredis==3.2.1 \ --hash=sha256:fec453a038c262e18d7de4919220b2916e0b17d1eadd12e7a800f09f78f84f39 \ --hash=sha256:ffd982c419f48e3a57f592678c72474429465bb4bfc96472ec805f5d836523f0 # via -r requirements/prod.txt -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 +idna==3.11 \ + --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ + --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 # via # -r requirements/prod.txt # anyio @@ -728,9 +760,7 @@ idna==3.10 \ iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 - # via - # -r requirements/prod.txt - # pytest + # via pytest ipaddress==1.0.23 \ --hash=sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc \ --hash=sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2 @@ -742,9 +772,9 @@ jmespath==1.0.1 \ # -r requirements/prod.txt # boto3 # botocore -josepy==2.0.0 \ - --hash=sha256:e7d7acd2fe77435cda76092abe4950bb47b597243a8fb733088615fa6de9ec40 \ - --hash=sha256:eb50ec564b1b186b860c7738769274b97b19b5b831239669c0f3d5c86b62a4c0 +josepy==2.2.0 \ + --hash=sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b \ + --hash=sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9 # via # -r requirements/prod.txt # mozilla-django-oidc @@ -987,18 +1017,16 @@ numpy==2.3.4 \ # via # -r requirements/prod.txt # pandas -oauthlib==3.2.2 \ - --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ - --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 +oauthlib==3.3.1 \ + --hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \ + --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 # via # -r requirements/prod.txt # requests-oauthlib packaging==25.0 \ --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f - # via - # -r requirements/prod.txt - # pytest + # via pytest pandas==2.3.3 \ --hash=sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7 \ --hash=sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593 \ @@ -1055,15 +1083,11 @@ pandas==2.3.3 \ --hash=sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b \ --hash=sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c \ --hash=sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/prod.txt pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - # via - # -r requirements/prod.txt - # pytest + # via pytest proto-plus==1.26.1 \ --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \ --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012 @@ -1130,9 +1154,7 @@ pyarrow==21.0.0 \ --hash=sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd \ --hash=sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503 \ --hash=sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/prod.txt pyasn1==0.6.1 \ --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 @@ -1146,118 +1168,136 @@ pyasn1-modules==0.4.2 \ # via # -r requirements/prod.txt # google-auth -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc +pycparser==2.23 \ + --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \ + --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 # via # -r requirements/prod.txt # cffi -pydantic==2.11.5 \ - --hash=sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a \ - --hash=sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7 +pydantic==2.12.3 \ + --hash=sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74 \ + --hash=sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf # via # -r requirements/prod.txt # django-ninja -pydantic-core==2.33.2 \ - --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ - --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ - --hash=sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02 \ - --hash=sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56 \ - --hash=sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4 \ - --hash=sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22 \ - --hash=sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef \ - --hash=sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec \ - --hash=sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d \ - --hash=sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b \ - --hash=sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a \ - --hash=sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f \ - --hash=sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052 \ - --hash=sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab \ - --hash=sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916 \ - --hash=sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c \ - --hash=sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf \ - --hash=sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27 \ - --hash=sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a \ - --hash=sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8 \ - --hash=sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7 \ - --hash=sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612 \ - --hash=sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1 \ - --hash=sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039 \ - --hash=sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca \ - --hash=sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7 \ - --hash=sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a \ - --hash=sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6 \ - --hash=sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782 \ - --hash=sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b \ - --hash=sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7 \ - --hash=sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025 \ - --hash=sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849 \ - --hash=sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7 \ - --hash=sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b \ - --hash=sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa \ - --hash=sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e \ - --hash=sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea \ - --hash=sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac \ - --hash=sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51 \ - --hash=sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e \ - --hash=sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162 \ - --hash=sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65 \ - --hash=sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2 \ - --hash=sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954 \ - --hash=sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b \ - --hash=sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de \ - --hash=sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc \ - --hash=sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64 \ - --hash=sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb \ - --hash=sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9 \ - --hash=sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101 \ - --hash=sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d \ - --hash=sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef \ - --hash=sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3 \ - --hash=sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1 \ - --hash=sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5 \ - --hash=sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88 \ - --hash=sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d \ - --hash=sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290 \ - --hash=sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e \ - --hash=sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d \ - --hash=sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808 \ - --hash=sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc \ - --hash=sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d \ - --hash=sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc \ - --hash=sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e \ - --hash=sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640 \ - --hash=sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30 \ - --hash=sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e \ - --hash=sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9 \ - --hash=sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a \ - --hash=sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9 \ - --hash=sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f \ - --hash=sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb \ - --hash=sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5 \ - --hash=sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab \ - --hash=sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d \ - --hash=sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572 \ - --hash=sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593 \ - --hash=sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29 \ - --hash=sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535 \ - --hash=sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1 \ - --hash=sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f \ - --hash=sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8 \ - --hash=sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf \ - --hash=sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246 \ - --hash=sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9 \ - --hash=sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011 \ - --hash=sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9 \ - --hash=sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a \ - --hash=sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3 \ - --hash=sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6 \ - --hash=sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8 \ - --hash=sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a \ - --hash=sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2 \ - --hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \ - --hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \ - --hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d +pydantic-core==2.41.4 \ + --hash=sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4 \ + --hash=sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03 \ + --hash=sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e \ + --hash=sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57 \ + --hash=sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee \ + --hash=sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def \ + --hash=sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8 \ + --hash=sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89 \ + --hash=sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d \ + --hash=sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706 \ + --hash=sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6 \ + --hash=sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00 \ + --hash=sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c \ + --hash=sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e \ + --hash=sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405 \ + --hash=sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2 \ + --hash=sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80 \ + --hash=sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b \ + --hash=sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999 \ + --hash=sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b \ + --hash=sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af \ + --hash=sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d \ + --hash=sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a \ + --hash=sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2 \ + --hash=sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed \ + --hash=sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb \ + --hash=sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9 \ + --hash=sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d \ + --hash=sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005 \ + --hash=sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5 \ + --hash=sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94 \ + --hash=sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa \ + --hash=sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537 \ + --hash=sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e \ + --hash=sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2 \ + --hash=sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894 \ + --hash=sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa \ + --hash=sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308 \ + --hash=sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e \ + --hash=sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265 \ + --hash=sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae \ + --hash=sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba \ + --hash=sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347 \ + --hash=sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062 \ + --hash=sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1 \ + --hash=sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891 \ + --hash=sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8 \ + --hash=sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d \ + --hash=sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da \ + --hash=sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c \ + --hash=sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db \ + --hash=sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025 \ + --hash=sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514 \ + --hash=sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5 \ + --hash=sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e \ + --hash=sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c \ + --hash=sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2 \ + --hash=sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d \ + --hash=sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac \ + --hash=sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8 \ + --hash=sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431 \ + --hash=sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746 \ + --hash=sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a \ + --hash=sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47 \ + --hash=sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd \ + --hash=sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84 \ + --hash=sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b \ + --hash=sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332 \ + --hash=sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9 \ + --hash=sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12 \ + --hash=sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2 \ + --hash=sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc \ + --hash=sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887 \ + --hash=sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258 \ + --hash=sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e \ + --hash=sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a \ + --hash=sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a \ + --hash=sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f \ + --hash=sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335 \ + --hash=sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f \ + --hash=sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad \ + --hash=sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2 \ + --hash=sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d \ + --hash=sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8 \ + --hash=sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338 \ + --hash=sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4 \ + --hash=sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42 \ + --hash=sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7 \ + --hash=sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf \ + --hash=sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0 \ + --hash=sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2 \ + --hash=sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd \ + --hash=sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff \ + --hash=sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d \ + --hash=sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2 \ + --hash=sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b \ + --hash=sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d \ + --hash=sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02 \ + --hash=sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e \ + --hash=sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166 \ + --hash=sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945 \ + --hash=sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c \ + --hash=sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616 \ + --hash=sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced \ + --hash=sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700 \ + --hash=sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1 \ + --hash=sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827 \ + --hash=sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970 \ + --hash=sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd \ + --hash=sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c \ + --hash=sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4 \ + --hash=sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f \ + --hash=sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab \ + --hash=sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564 \ + --hash=sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8 \ + --hash=sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb \ + --hash=sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554 # via # -r requirements/prod.txt # pydantic @@ -1268,9 +1308,7 @@ pyfxa==0.8.1 \ pygments==2.19.2 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b - # via - # -r requirements/prod.txt - # pytest + # via pytest pyjwt==2.10.1 \ --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \ --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb @@ -1282,14 +1320,14 @@ pyopenssl==25.1.0 \ --hash=sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b # via -r requirements/prod.txt pysilverpop==0.2.6 \ - --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 + --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 \ + --hash=sha256:cc92fb2e27486f99af4e41ebba811c39cc24d6634d1fdaa7f33639489492f346 # via -r requirements/prod.txt pytest==8.4.0 \ --hash=sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6 \ --hash=sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e # via # -r requirements/dev.in - # -r requirements/prod.txt # pytest-cov # pytest-datadir # pytest-django @@ -1297,27 +1335,19 @@ pytest==8.4.0 \ pytest-cov==6.1.1 \ --hash=sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a \ --hash=sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in pytest-datadir==1.7.1 \ --hash=sha256:12372417ff2cec4db8aecaf6b6fac119db91515f17e81c7926220e342148e3b4 \ --hash=sha256:367b4cd34b6ca3151317db310ab688ef9a28a9ec15e1e7d6696f4737b5f14bd8 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in pytest-django==4.11.1 \ --hash=sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10 \ --hash=sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in pytest-mock==3.14.1 \ --hash=sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e \ --hash=sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -1394,9 +1424,9 @@ redis==6.2.0 \ # via # -r requirements/prod.txt # rq -requests==2.32.3 \ - --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ - --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 +requests==2.32.5 \ + --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ + --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf # via # -r requirements/prod.txt # datadog @@ -1411,9 +1441,7 @@ requests==2.32.3 \ requests-mock==1.12.1 \ --hash=sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563 \ --hash=sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in requests-oauthlib==2.0.0 \ --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \ --hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9 @@ -1455,12 +1483,10 @@ ruff==0.11.12 \ --hash=sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c \ --hash=sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6 \ --hash=sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a - # via - # -r requirements/dev.in - # -r requirements/prod.txt -s3transfer==0.13.0 \ - --hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \ - --hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177 + # via -r requirements/dev.in +s3transfer==0.13.1 \ + --hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \ + --hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf # via # -r requirements/prod.txt # boto3 @@ -1481,27 +1507,25 @@ six==1.17.0 \ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc - # via - # -r requirements/prod.txt - # anyio + # via anyio sqlparse==0.5.3 \ --hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \ --hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca # via # -r requirements/prod.txt # django -typing-extensions==4.14.0 \ - --hash=sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4 \ - --hash=sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via # -r requirements/prod.txt # dj-database-url # pydantic # pydantic-core # typing-inspection -typing-inspection==0.4.1 \ - --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ - --hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28 +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 # via # -r requirements/prod.txt # pydantic @@ -1528,9 +1552,9 @@ ua-parser-builtins==0.18.0.post1 \ # via # -r requirements/prod.txt # ua-parser -urllib3==2.4.0 \ - --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ - --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 +urllib3==2.5.0 \ + --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ + --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc # via # -r requirements/prod.txt # botocore @@ -1559,9 +1583,7 @@ uv==0.7.11 \ --hash=sha256:f82490ddeb4c0e074f49f5d40c5e2ff863db13e8d3ea894401137f01177f5a65 \ --hash=sha256:fa3bb9394a96d315d60cd2453a7d17d891693e14f31840bff2cdc96b6552c48f \ --hash=sha256:fa506c6492f3993756de785c01a7f2d9615b9c7ccfd8ac56c5b8b08b2db74768 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in watchfiles==1.0.5 \ --hash=sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827 \ --hash=sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f \ @@ -1634,9 +1656,7 @@ watchfiles==1.0.5 \ --hash=sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965 \ --hash=sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21 \ --hash=sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/dev.in webob==1.8.9 \ --hash=sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9 \ --hash=sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589 diff --git a/requirements/prod.txt b/requirements/prod.txt index 46b7bddf..78e60607 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,331 +1,253 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --generate-hashes --no-strip-extras requirements/dev.in -o requirements/prod.txt +# just compile-requirements annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 - # via - # -r requirements/prod.txt - # pydantic -anyio==4.11.0 \ - --hash=sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc \ - --hash=sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4 - # via - # -r requirements/prod.txt - # watchfiles + # via pydantic apscheduler==3.11.0 \ --hash=sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133 \ --hash=sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da - # via -r requirements/prod.txt -asgiref==3.8.1 \ - --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ - --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 + # via -r requirements/prod.in +asgiref==3.10.0 \ + --hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \ + --hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e # via - # -r requirements/prod.txt # django # django-cors-headers boto3==1.38.30 \ --hash=sha256:17af769544b5743843bcc732709b43226de19f1ebff2c324a3440bbecbddb893 \ --hash=sha256:949df0a0edd360f4ad60f1492622eecf98a359a2f72b1e236193d9b320c5dc8c - # via -r requirements/prod.txt -botocore==1.38.30 \ - --hash=sha256:530e40a6e91c8a096cab17fcc590d0c7227c8347f71a867576163a44d027a714 \ - --hash=sha256:7836c5041c5f249431dbd5471c61db17d4053f72a1d6e3b2197c07ca0839588b + # via -r requirements/prod.in +botocore==1.38.46 \ + --hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \ + --hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b # via - # -r requirements/prod.txt # boto3 # s3transfer cachetools==6.2.1 \ --hash=sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701 \ --hash=sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201 + # via google-auth +certifi==2025.10.5 \ + --hash=sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de \ + --hash=sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43 # via - # -r requirements/prod.txt - # google-auth -certifi==2025.4.26 \ - --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \ - --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3 - # via - # -r requirements/prod.txt # requests # sentry-sdk -cffi==1.17.1 \ - --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ - --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ - --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ - --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ - --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ - --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ - --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ - --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ - --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ - --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ - --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ - --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ - --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ - --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ - --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ - --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ - --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ - --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ - --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ - --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ - --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ - --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ - --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ - --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ - --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ - --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ - --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ - --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ - --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ - --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ - --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ - --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ - --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ - --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ - --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ - --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ - --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ - --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ - --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ - --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ - --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ - --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ - --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ - --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ - --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ - --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ - --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ - --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ - --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ - --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ - --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ - --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ - --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ - --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ - --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ - --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ - --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ - --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ - --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ - --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ - --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ - --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ - --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ - --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ - --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ - --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ - --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b - # via - # -r requirements/prod.txt - # cryptography -charset-normalizer==3.4.2 \ - --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ - --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ - --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ - --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ - --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ - --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ - --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ - --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ - --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ - --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ - --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ - --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ - --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ - --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ - --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ - --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ - --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ - --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ - --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ - --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ - --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ - --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ - --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ - --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ - --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ - --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ - --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ - --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ - --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ - --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ - --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ - --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ - --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ - --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ - --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ - --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ - --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ - --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ - --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ - --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ - --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ - --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ - --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ - --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ - --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ - --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ - --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ - --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ - --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ - --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ - --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ - --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ - --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ - --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ - --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ - --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ - --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ - --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ - --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ - --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ - --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ - --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ - --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ - --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ - --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ - --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ - --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ - --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ - --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ - --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ - --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ - --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ - --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ - --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ - --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ - --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ - --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ - --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ - --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ - --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ - --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ - --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ - --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ - --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ - --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ - --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ - --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ - --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ - --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ - --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ - --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ - --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + # via cryptography +charset-normalizer==3.4.4 \ + --hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \ + --hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \ + --hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \ + --hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \ + --hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \ + --hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \ + --hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \ + --hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \ + --hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \ + --hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \ + --hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \ + --hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \ + --hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \ + --hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \ + --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ + --hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \ + --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ + --hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \ + --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ + --hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \ + --hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \ + --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ + --hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \ + --hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \ + --hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \ + --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ + --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ + --hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \ + --hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \ + --hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \ + --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ + --hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \ + --hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \ + --hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \ + --hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \ + --hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \ + --hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \ + --hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \ + --hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \ + --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ + --hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \ + --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ + --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ + --hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \ + --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ + --hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \ + --hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \ + --hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \ + --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ + --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ + --hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \ + --hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \ + --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ + --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ + --hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \ + --hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \ + --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ + --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ + --hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \ + --hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \ + --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ + --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ + --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ + --hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \ + --hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \ + --hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \ + --hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \ + --hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \ + --hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \ + --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ + --hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \ + --hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \ + --hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \ + --hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \ + --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ + --hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \ + --hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \ + --hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \ + --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ + --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ + --hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \ + --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ + --hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \ + --hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \ + --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ + --hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \ + --hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \ + --hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \ + --hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \ + --hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \ + --hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \ + --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ + --hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \ + --hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \ + --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ + --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ + --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ + --hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \ + --hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \ + --hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \ + --hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \ + --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ + --hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \ + --hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \ + --hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \ + --hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \ + --hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \ + --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ + --hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \ + --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \ + --hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \ + --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \ + --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608 + # via requests +click==8.3.0 \ + --hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \ + --hash=sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4 # via - # -r requirements/prod.txt - # requests -click==8.2.1 \ - --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \ - --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b - # via - # -r requirements/prod.txt # granian # rq contextlib2==21.6.0 \ --hash=sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f \ --hash=sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869 - # via -r requirements/prod.txt -coverage[toml]==7.11.0 \ - --hash=sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5 \ - --hash=sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e \ - --hash=sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab \ - --hash=sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a \ - --hash=sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82 \ - --hash=sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050 \ - --hash=sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893 \ - --hash=sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de \ - --hash=sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969 \ - --hash=sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601 \ - --hash=sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de \ - --hash=sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05 \ - --hash=sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3 \ - --hash=sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad \ - --hash=sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841 \ - --hash=sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847 \ - --hash=sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52 \ - --hash=sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11 \ - --hash=sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68 \ - --hash=sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73 \ - --hash=sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415 \ - --hash=sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745 \ - --hash=sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b \ - --hash=sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785 \ - --hash=sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4 \ - --hash=sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48 \ - --hash=sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2 \ - --hash=sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b \ - --hash=sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040 \ - --hash=sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0 \ - --hash=sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866 \ - --hash=sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1 \ - --hash=sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf \ - --hash=sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b \ - --hash=sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d \ - --hash=sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115 \ - --hash=sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2 \ - --hash=sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9 \ - --hash=sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43 \ - --hash=sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc \ - --hash=sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa \ - --hash=sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1 \ - --hash=sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088 \ - --hash=sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c \ - --hash=sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777 \ - --hash=sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623 \ - --hash=sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268 \ - --hash=sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186 \ - --hash=sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e \ - --hash=sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007 \ - --hash=sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7 \ - --hash=sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547 \ - --hash=sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3 \ - --hash=sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc \ - --hash=sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37 \ - --hash=sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721 \ - --hash=sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552 \ - --hash=sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca \ - --hash=sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497 \ - --hash=sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46 \ - --hash=sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2 \ - --hash=sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0 \ - --hash=sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44 \ - --hash=sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1 \ - --hash=sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4 \ - --hash=sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479 \ - --hash=sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e \ - --hash=sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2 \ - --hash=sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e \ - --hash=sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef \ - --hash=sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0 \ - --hash=sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad \ - --hash=sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075 \ - --hash=sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f \ - --hash=sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d \ - --hash=sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca \ - --hash=sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd \ - --hash=sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287 \ - --hash=sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f \ - --hash=sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0 \ - --hash=sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996 \ - --hash=sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d \ - --hash=sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd \ - --hash=sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31 \ - --hash=sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740 \ - --hash=sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591 \ - --hash=sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be \ - --hash=sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f \ - --hash=sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d \ - --hash=sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836 \ - --hash=sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d \ - --hash=sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c - # via - # -r requirements/prod.txt - # pytest-cov -crontab==1.0.4 \ - --hash=sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92 - # via - # -r requirements/prod.txt - # rq-scheduler + # via -r requirements/prod.in +crontab==1.0.5 \ + --hash=sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5 + # via rq-scheduler cryptography==44.0.3 \ --hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \ --hash=sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43 \ @@ -365,26 +287,24 @@ cryptography==44.0.3 \ --hash=sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4 \ --hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053 # via - # -r requirements/prod.txt + # -r requirements/prod.in # josepy # mozilla-django-oidc # pyfxa # pyopenssl -datadog==0.51.0 \ - --hash=sha256:3279534f831ae0b4ae2d8ce42ef038b4ab38e667d7ed6ff7437982d7a0cf5250 \ - --hash=sha256:a9764f091c96af4e0996d4400b168fc5fba380f911d6d672c9dcd4773e29ea3f - # via - # -r requirements/prod.txt - # markus +datadog==0.52.1 \ + --hash=sha256:44c6deb563c4522dba206fba2e2bb93d3b04113c40191851ba3a241d82b5fd0b \ + --hash=sha256:b8c92cd761618ee062f114171067e4c400d48c9f0dad16cb285042439d9d5d4e + # via markus dj-database-url==3.0.0 \ --hash=sha256:749a7a42d88d6c741c1d2f4ab24c2ae0d5cd12f00f2d1d55ff9f5fadabe8a2c3 \ --hash=sha256:cbb84b2e3f372460b1e43692bf9fdc0c32e78930ee101db470cba56105fca1e5 - # via -r requirements/prod.txt + # via -r requirements/prod.in django==5.2.2 \ --hash=sha256:85852e517f84435e9b13421379cd6c43ef5b48a9c8b391d29a26f7900967e952 \ --hash=sha256:997ef2162d04ead6869551b22cde4e06da1f94cf595f4af3f3d3afeae1f3f6fe # via - # -r requirements/prod.txt + # -r requirements/prod.in # dj-database-url # django-allow-cidr # django-cors-headers @@ -395,78 +315,68 @@ django==5.2.2 \ django-allow-cidr==0.8.0 \ --hash=sha256:724ce76b7b4a25c641ddcd33777e2e95da622dd2c11937f0d76d0cd3d54b1622 \ --hash=sha256:d6f80230621dd5b19ec1665b85abf8218b02556ff7cf0ddda41e2607267a3277 - # via -r requirements/prod.txt + # via -r requirements/prod.in django-cache-url==3.4.5 \ --hash=sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c \ --hash=sha256:eb9fb194717524348c95cad9905b70b647452741c1d9e481fac6d2125f0ad917 - # via -r requirements/prod.txt + # via -r requirements/prod.in django-cors-headers==4.7.0 \ --hash=sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b \ --hash=sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070 - # via -r requirements/prod.txt + # via -r requirements/prod.in django-mozilla-product-details==1.0.3 \ --hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \ --hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36 - # via -r requirements/prod.txt -django-ninja==1.4.3 \ - --hash=sha256:e46d477ca60c228d2a5eb3cc912094928ea830d364501f966661eeada67cb038 \ - --hash=sha256:f3204137a059437b95677049474220611f1cf9efedba9213556474b75168fa01 - # via -r requirements/prod.txt + # via -r requirements/prod.in +django-ninja==1.4.5 \ + --hash=sha256:aa1a2ee2b22c5f1c2f4bfbc004386be7074cbfaf133680c2b359a31221965503 \ + --hash=sha256:d779702ddc6e17b10739049ddb075a6a1e6c6270bdc04e0b0429f6adbf670373 + # via -r requirements/prod.in django-ratelimit==4.1.0 \ --hash=sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b \ --hash=sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92 - # via -r requirements/prod.txt + # via -r requirements/prod.in django-watchman==1.3.0 \ --hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \ --hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb - # via -r requirements/prod.txt -dnspython==2.7.0 \ - --hash=sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86 \ - --hash=sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1 - # via - # -r requirements/prod.txt - # email-validator + # via -r requirements/prod.in +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ + --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f + # via email-validator email-validator==2.2.0 \ --hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \ --hash=sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7 - # via -r requirements/prod.txt + # via -r requirements/prod.in everett==3.4.0 \ --hash=sha256:f403c4a41764a6301fb31e2558d6e9718999f0eab9e260d986b894fa2e6b6871 \ --hash=sha256:f8c29c7300702f47b7323b75348e2b86647246694fda7ad410c2a2bfaa980ff7 - # via -r requirements/prod.txt -freezegun==1.5.2 \ - --hash=sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b \ - --hash=sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181 - # via - # -r requirements/prod.txt - # rq-scheduler + # via -r requirements/prod.in +freezegun==1.5.5 \ + --hash=sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a \ + --hash=sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2 + # via rq-scheduler google-api-core==2.26.0 \ --hash=sha256:2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed \ --hash=sha256:e6e6d78bd6cf757f4aee41dcc85b07f485fbb069d5daa3afb126defba1e91a62 # via - # -r requirements/prod.txt # google-cloud-core # google-cloud-storage google-auth==2.41.1 \ --hash=sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d \ --hash=sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2 # via - # -r requirements/prod.txt # google-api-core # google-cloud-core # google-cloud-storage google-cloud-core==2.4.3 \ --hash=sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53 \ --hash=sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e - # via - # -r requirements/prod.txt - # google-cloud-storage + # via google-cloud-storage google-cloud-storage==3.4.1 \ --hash=sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268 \ --hash=sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/prod.in google-crc32c==1.7.1 \ --hash=sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db \ --hash=sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337 \ @@ -503,21 +413,16 @@ google-crc32c==1.7.1 \ --hash=sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b \ --hash=sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb # via - # -r requirements/prod.txt # google-cloud-storage # google-resumable-media google-resumable-media==2.7.2 \ --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \ --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0 - # via - # -r requirements/prod.txt - # google-cloud-storage + # via google-cloud-storage googleapis-common-protos==1.71.0 \ --hash=sha256:1aec01e574e29da63c80ba9f7bbf1ccfaacf1da877f23609fe236ca7c72a2e2e \ --hash=sha256:59034a1d849dc4d18971997a72ac56246570afdd17f9369a0ff68218d50ab78c - # via - # -r requirements/prod.txt - # google-api-core + # via google-api-core granian==2.3.2 \ --hash=sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3 \ --hash=sha256:0209cb0e981165cfa930e9d01dec96de5c832c69f0e902f1f8f11c1ff1f744a5 \ @@ -599,13 +504,11 @@ granian==2.3.2 \ --hash=sha256:f7360f4e70a4186e4e4fe67912b1675ceb30199107545ea1790090e5a548ef46 \ --hash=sha256:f7d844277f6eec7f87ca615c283026f3d0b29cdbc61c92c103d2a708936e6e1c \ --hash=sha256:fed8bdfc284ff00e9c530f7a5018d5d6281737fef9fcdd4aa5d69cac68f3d374 - # via -r requirements/prod.txt + # via -r requirements/prod.in hawkauthlib==2.0.0 \ --hash=sha256:935878d3a75832aa76f78ddee13491f1466cbd69a8e7e4248902763cf9953ba9 \ --hash=sha256:effd64a2572e3c0d9090b55ad2180b36ad50e7760bea225cb6ce2248f421510d - # via - # -r requirements/prod.txt - # pyfxa + # via pyfxa hiredis==3.2.1 \ --hash=sha256:0079ef1e03930b364556b78548e67236ab3def4e07e674f6adfc52944aa972dd \ --hash=sha256:01dd8ea88bf8363751857ca2eb8f13faad0c7d57a6369663d4d1160f225ab449 \ @@ -716,43 +619,32 @@ hiredis==3.2.1 \ --hash=sha256:f9ad63cd9065820a43fb1efb8ed5ae85bb78f03ef5eb53f6bde47914708f5718 \ --hash=sha256:fec453a038c262e18d7de4919220b2916e0b17d1eadd12e7a800f09f78f84f39 \ --hash=sha256:ffd982c419f48e3a57f592678c72474429465bb4bfc96472ec805f5d836523f0 - # via -r requirements/prod.txt -idna==3.10 \ - --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ - --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 + # via -r requirements/prod.in +idna==3.11 \ + --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \ + --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902 # via - # -r requirements/prod.txt - # anyio # email-validator # requests -iniconfig==2.3.0 \ - --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ - --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 - # via - # -r requirements/prod.txt - # pytest ipaddress==1.0.23 \ --hash=sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc \ --hash=sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2 - # via -r requirements/prod.txt + # via -r requirements/prod.in jmespath==1.0.1 \ --hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \ --hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe # via - # -r requirements/prod.txt # boto3 # botocore -josepy==2.0.0 \ - --hash=sha256:e7d7acd2fe77435cda76092abe4950bb47b597243a8fb733088615fa6de9ec40 \ - --hash=sha256:eb50ec564b1b186b860c7738769274b97b19b5b831239669c0f3d5c86b62a4c0 - # via - # -r requirements/prod.txt - # mozilla-django-oidc +josepy==2.2.0 \ + --hash=sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b \ + --hash=sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9 + # via mozilla-django-oidc legacy-cgi==2.6.3 \ --hash=sha256:4c119d6cb8e9d8b6ad7cc0ddad880552c62df4029622835d06dfd18f438a8154 \ --hash=sha256:6df2ea5ae14c71ef6f097f8b6372b44f6685283dc018535a75c924564183cdab # via - # -r requirements/prod.txt + # -r requirements/prod.in # webob lxml==5.4.0 \ --hash=sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5 \ @@ -887,18 +779,18 @@ lxml==5.4.0 \ --hash=sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c \ --hash=sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e \ --hash=sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539 - # via -r requirements/prod.txt + # via -r requirements/prod.in markus[datadog]==5.1.0 \ --hash=sha256:424172efdccc35172b8aadfdcd753412c3ed2b5651c3b3bc9e0b7e7f2e97da52 \ --hash=sha256:a4ec2d6bb1dcf471638be11a10cb5708de8cc3092ade9cf3b38bb2f651ede33a - # via -r requirements/prod.txt + # via -r requirements/prod.in mozilla-django-oidc==4.0.1 \ --hash=sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca \ --hash=sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918 - # via -r requirements/prod.txt + # via -r requirements/prod.in msgpack-python==0.5.6 \ --hash=sha256:378cc8a6d3545b532dfd149da715abae4fda2a3adb6d74e525d0d5e51f46909b - # via -r requirements/prod.txt + # via -r requirements/prod.in mysqlclient==2.2.7 \ --hash=sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076 \ --hash=sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5 \ @@ -908,7 +800,7 @@ mysqlclient==2.2.7 \ --hash=sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585 \ --hash=sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069 \ --hash=sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687 - # via -r requirements/prod.txt + # via -r requirements/prod.in numpy==2.3.4 \ --hash=sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64 \ --hash=sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e \ @@ -984,21 +876,11 @@ numpy==2.3.4 \ --hash=sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953 \ --hash=sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b \ --hash=sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb - # via - # -r requirements/prod.txt - # pandas -oauthlib==3.2.2 \ - --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ - --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 - # via - # -r requirements/prod.txt - # requests-oauthlib -packaging==25.0 \ - --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ - --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f - # via - # -r requirements/prod.txt - # pytest + # via pandas +oauthlib==3.3.1 \ + --hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \ + --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1 + # via requests-oauthlib pandas==2.3.3 \ --hash=sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7 \ --hash=sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593 \ @@ -1055,21 +937,11 @@ pandas==2.3.3 \ --hash=sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b \ --hash=sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c \ --hash=sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee - # via - # -r requirements/dev.in - # -r requirements/prod.txt -pluggy==1.6.0 \ - --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ - --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - # via - # -r requirements/prod.txt - # pytest + # via -r requirements/prod.in proto-plus==1.26.1 \ --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \ --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012 - # via - # -r requirements/prod.txt - # google-api-core + # via google-api-core protobuf==6.33.0 \ --hash=sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954 \ --hash=sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995 \ @@ -1082,7 +954,6 @@ protobuf==6.33.0 \ --hash=sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90 \ --hash=sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298 # via - # -r requirements/prod.txt # google-api-core # googleapis-common-protos # proto-plus @@ -1130,199 +1001,164 @@ pyarrow==21.0.0 \ --hash=sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd \ --hash=sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503 \ --hash=sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/prod.in pyasn1==0.6.1 \ --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \ --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034 # via - # -r requirements/prod.txt # pyasn1-modules # rsa pyasn1-modules==0.4.2 \ --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 - # via - # -r requirements/prod.txt - # google-auth -pycparser==2.22 \ - --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ - --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc - # via - # -r requirements/prod.txt - # cffi -pydantic==2.11.5 \ - --hash=sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a \ - --hash=sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7 - # via - # -r requirements/prod.txt - # django-ninja -pydantic-core==2.33.2 \ - --hash=sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d \ - --hash=sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac \ - --hash=sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02 \ - --hash=sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56 \ - --hash=sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4 \ - --hash=sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22 \ - --hash=sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef \ - --hash=sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec \ - --hash=sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d \ - --hash=sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b \ - --hash=sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a \ - --hash=sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f \ - --hash=sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052 \ - --hash=sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab \ - --hash=sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916 \ - --hash=sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c \ - --hash=sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf \ - --hash=sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27 \ - --hash=sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a \ - --hash=sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8 \ - --hash=sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7 \ - --hash=sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612 \ - --hash=sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1 \ - --hash=sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039 \ - --hash=sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca \ - --hash=sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7 \ - --hash=sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a \ - --hash=sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6 \ - --hash=sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782 \ - --hash=sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b \ - --hash=sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7 \ - --hash=sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025 \ - --hash=sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849 \ - --hash=sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7 \ - --hash=sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b \ - --hash=sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa \ - --hash=sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e \ - --hash=sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea \ - --hash=sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac \ - --hash=sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51 \ - --hash=sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e \ - --hash=sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162 \ - --hash=sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65 \ - --hash=sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2 \ - --hash=sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954 \ - --hash=sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b \ - --hash=sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de \ - --hash=sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc \ - --hash=sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64 \ - --hash=sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb \ - --hash=sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9 \ - --hash=sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101 \ - --hash=sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d \ - --hash=sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef \ - --hash=sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3 \ - --hash=sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1 \ - --hash=sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5 \ - --hash=sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88 \ - --hash=sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d \ - --hash=sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290 \ - --hash=sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e \ - --hash=sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d \ - --hash=sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808 \ - --hash=sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc \ - --hash=sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d \ - --hash=sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc \ - --hash=sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e \ - --hash=sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640 \ - --hash=sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30 \ - --hash=sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e \ - --hash=sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9 \ - --hash=sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a \ - --hash=sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9 \ - --hash=sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f \ - --hash=sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb \ - --hash=sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5 \ - --hash=sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab \ - --hash=sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d \ - --hash=sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572 \ - --hash=sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593 \ - --hash=sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29 \ - --hash=sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535 \ - --hash=sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1 \ - --hash=sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f \ - --hash=sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8 \ - --hash=sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf \ - --hash=sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246 \ - --hash=sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9 \ - --hash=sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011 \ - --hash=sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9 \ - --hash=sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a \ - --hash=sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3 \ - --hash=sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6 \ - --hash=sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8 \ - --hash=sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a \ - --hash=sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2 \ - --hash=sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c \ - --hash=sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6 \ - --hash=sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d - # via - # -r requirements/prod.txt - # pydantic + # via google-auth +pycparser==2.23 \ + --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \ + --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 + # via cffi +pydantic==2.12.3 \ + --hash=sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74 \ + --hash=sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf + # via django-ninja +pydantic-core==2.41.4 \ + --hash=sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4 \ + --hash=sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03 \ + --hash=sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e \ + --hash=sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57 \ + --hash=sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee \ + --hash=sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def \ + --hash=sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8 \ + --hash=sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89 \ + --hash=sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d \ + --hash=sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706 \ + --hash=sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6 \ + --hash=sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00 \ + --hash=sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c \ + --hash=sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e \ + --hash=sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405 \ + --hash=sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2 \ + --hash=sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80 \ + --hash=sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b \ + --hash=sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999 \ + --hash=sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b \ + --hash=sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af \ + --hash=sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d \ + --hash=sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a \ + --hash=sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2 \ + --hash=sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed \ + --hash=sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb \ + --hash=sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9 \ + --hash=sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d \ + --hash=sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005 \ + --hash=sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5 \ + --hash=sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94 \ + --hash=sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa \ + --hash=sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537 \ + --hash=sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e \ + --hash=sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2 \ + --hash=sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894 \ + --hash=sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa \ + --hash=sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308 \ + --hash=sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e \ + --hash=sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265 \ + --hash=sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae \ + --hash=sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba \ + --hash=sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347 \ + --hash=sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062 \ + --hash=sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1 \ + --hash=sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891 \ + --hash=sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8 \ + --hash=sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d \ + --hash=sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da \ + --hash=sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c \ + --hash=sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db \ + --hash=sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025 \ + --hash=sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514 \ + --hash=sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5 \ + --hash=sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e \ + --hash=sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c \ + --hash=sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2 \ + --hash=sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d \ + --hash=sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac \ + --hash=sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8 \ + --hash=sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431 \ + --hash=sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746 \ + --hash=sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a \ + --hash=sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47 \ + --hash=sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd \ + --hash=sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84 \ + --hash=sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b \ + --hash=sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332 \ + --hash=sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9 \ + --hash=sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12 \ + --hash=sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2 \ + --hash=sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc \ + --hash=sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887 \ + --hash=sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258 \ + --hash=sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e \ + --hash=sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a \ + --hash=sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a \ + --hash=sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f \ + --hash=sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335 \ + --hash=sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f \ + --hash=sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad \ + --hash=sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2 \ + --hash=sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d \ + --hash=sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8 \ + --hash=sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338 \ + --hash=sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4 \ + --hash=sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42 \ + --hash=sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7 \ + --hash=sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf \ + --hash=sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0 \ + --hash=sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2 \ + --hash=sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd \ + --hash=sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff \ + --hash=sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d \ + --hash=sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2 \ + --hash=sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b \ + --hash=sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d \ + --hash=sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02 \ + --hash=sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e \ + --hash=sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166 \ + --hash=sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945 \ + --hash=sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c \ + --hash=sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616 \ + --hash=sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced \ + --hash=sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700 \ + --hash=sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1 \ + --hash=sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827 \ + --hash=sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970 \ + --hash=sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd \ + --hash=sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c \ + --hash=sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4 \ + --hash=sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f \ + --hash=sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab \ + --hash=sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564 \ + --hash=sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8 \ + --hash=sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb \ + --hash=sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554 + # via pydantic pyfxa==0.8.1 \ --hash=sha256:df3c575b314e8d67275fc8404294731a5cd39a75e36639fd8c5f8c76c1ee1a4c \ --hash=sha256:f12798fc5f3c9848c1de8048f333b7bdb3b0658daac506c843a4ebfc8df0efb8 - # via -r requirements/prod.txt -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b - # via - # -r requirements/prod.txt - # pytest + # via -r requirements/prod.in pyjwt==2.10.1 \ --hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \ --hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb - # via - # -r requirements/prod.txt - # pyfxa + # via pyfxa pyopenssl==25.1.0 \ --hash=sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab \ --hash=sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b - # via -r requirements/prod.txt + # via -r requirements/prod.in pysilverpop==0.2.6 \ - --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 - # via -r requirements/prod.txt -pytest==8.4.0 \ - --hash=sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6 \ - --hash=sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e - # via - # -r requirements/dev.in - # -r requirements/prod.txt - # pytest-cov - # pytest-datadir - # pytest-django - # pytest-mock -pytest-cov==6.1.1 \ - --hash=sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a \ - --hash=sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde - # via - # -r requirements/dev.in - # -r requirements/prod.txt -pytest-datadir==1.7.1 \ - --hash=sha256:12372417ff2cec4db8aecaf6b6fac119db91515f17e81c7926220e342148e3b4 \ - --hash=sha256:367b4cd34b6ca3151317db310ab688ef9a28a9ec15e1e7d6696f4737b5f14bd8 - # via - # -r requirements/dev.in - # -r requirements/prod.txt -pytest-django==4.11.1 \ - --hash=sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10 \ - --hash=sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991 - # via - # -r requirements/dev.in - # -r requirements/prod.txt -pytest-mock==3.14.1 \ - --hash=sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e \ - --hash=sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 \ + --hash=sha256:cc92fb2e27486f99af4e41ebba811c39cc24d6634d1fdaa7f33639489492f346 + # via -r requirements/prod.in python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 # via - # -r requirements/prod.txt # botocore # freezegun # pandas @@ -1330,9 +1166,7 @@ python-dateutil==2.9.0.post0 \ pytz==2025.2 \ --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 - # via - # -r requirements/prod.txt - # pandas + # via pandas pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ @@ -1387,18 +1221,17 @@ pyyaml==6.0.2 \ --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 - # via -r requirements/prod.txt + # via -r requirements/prod.in redis==6.2.0 \ --hash=sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e \ --hash=sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977 # via - # -r requirements/prod.txt + # -r requirements/prod.in # rq -requests==2.32.3 \ - --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \ - --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 +requests==2.32.5 \ + --hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \ + --hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf # via - # -r requirements/prod.txt # datadog # django-mozilla-product-details # google-api-core @@ -1406,244 +1239,89 @@ requests==2.32.3 \ # mozilla-django-oidc # pyfxa # pysilverpop - # requests-mock # requests-oauthlib -requests-mock==1.12.1 \ - --hash=sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563 \ - --hash=sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401 - # via - # -r requirements/dev.in - # -r requirements/prod.txt requests-oauthlib==2.0.0 \ --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \ --hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9 - # via - # -r requirements/prod.txt - # pysilverpop + # via pysilverpop rq==2.3.3 \ --hash=sha256:20c41c977b6f27c852a41bd855893717402bae7b8d9607dca21fe9dd55453e22 \ --hash=sha256:2202c4409c4c527ac4bee409867d6c02515dd110030499eb0de54c7374aee0ce # via - # -r requirements/prod.txt + # -r requirements/prod.in # rq-scheduler rq-scheduler==0.14.0 \ --hash=sha256:2d5a14a1ab217f8693184ebaa1fe03838edcbc70b4f76572721c0b33058cd023 \ --hash=sha256:d4ec221a3d8c11b3ff55e041f09d9af1e17f3253db737b6b97e86ab20fc3dc0d - # via -r requirements/prod.txt + # via -r requirements/prod.in rsa==4.9.1 \ --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \ --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75 - # via - # -r requirements/prod.txt - # google-auth -ruff==0.11.12 \ - --hash=sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd \ - --hash=sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02 \ - --hash=sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3 \ - --hash=sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012 \ - --hash=sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603 \ - --hash=sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13 \ - --hash=sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832 \ - --hash=sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5 \ - --hash=sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be \ - --hash=sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7 \ - --hash=sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5 \ - --hash=sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef \ - --hash=sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa \ - --hash=sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a \ - --hash=sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc \ - --hash=sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c \ - --hash=sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6 \ - --hash=sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a - # via - # -r requirements/dev.in - # -r requirements/prod.txt -s3transfer==0.13.0 \ - --hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \ - --hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177 - # via - # -r requirements/prod.txt - # boto3 + # via google-auth +s3transfer==0.13.1 \ + --hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \ + --hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf + # via boto3 sentry-processor==0.0.1 \ --hash=sha256:fd7a30fb57aaf05c01cd04cf7d949c628376b2b55d7a0aaa222efe58a8f122bc - # via -r requirements/prod.txt + # via -r requirements/prod.in sentry-sdk==2.29.1 \ --hash=sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d \ --hash=sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19 - # via -r requirements/prod.txt + # via -r requirements/prod.in six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via - # -r requirements/prod.txt # pysilverpop # python-dateutil -sniffio==1.3.1 \ - --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ - --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc - # via - # -r requirements/prod.txt - # anyio sqlparse==0.5.3 \ --hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \ --hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca + # via django +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via - # -r requirements/prod.txt - # django -typing-extensions==4.14.0 \ - --hash=sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4 \ - --hash=sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af - # via - # -r requirements/prod.txt # dj-database-url # pydantic # pydantic-core # typing-inspection -typing-inspection==0.4.1 \ - --hash=sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51 \ - --hash=sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28 - # via - # -r requirements/prod.txt - # pydantic +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via pydantic tzdata==2025.2 \ --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 - # via - # -r requirements/prod.txt - # pandas + # via pandas tzlocal==5.3.1 \ --hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \ --hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d - # via - # -r requirements/prod.txt - # apscheduler + # via apscheduler ua-parser==1.0.1 \ --hash=sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea \ --hash=sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d - # via - # -r requirements/prod.txt - # user-agents + # via user-agents ua-parser-builtins==0.18.0.post1 \ --hash=sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d + # via ua-parser +urllib3==2.5.0 \ + --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \ + --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc # via - # -r requirements/prod.txt - # ua-parser -urllib3==2.4.0 \ - --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \ - --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813 - # via - # -r requirements/prod.txt # botocore # requests # sentry-sdk user-agents==2.2.0 \ --hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7 \ --hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 - # via -r requirements/prod.txt -uv==0.7.11 \ - --hash=sha256:1950db80e7b5e1549029514cb006b37df1af7418c788acb57c1d46c0f1e8f310 \ - --hash=sha256:2d1115f09cf9feb60a25d0f03558393defa8f6c8118d01649e2bf05a783cc529 \ - --hash=sha256:33daeb9f1e5f49f1f192815c580beb996fd3be131821af2e887af4ec575c2a4f \ - --hash=sha256:363a8aabb3455fd4a15611c8ee4d76ac1a3d587f60250aee7dd4f777a3e02486 \ - --hash=sha256:38adcfd3c3d03c97e98498073df004ac190d21538ecf40752fb3f1b439c6fe5e \ - --hash=sha256:622e897701cb84657bf86d353afebde5e682a6f9fd4e7454aac65fcd899dfa7a \ - --hash=sha256:692ed1bf85b05e03d1ec6bc5e362e879a907ac8c539cb1ed31a34dd520c392ba \ - --hash=sha256:7cce0fd2a9128b5dce6031ebbd15a9f392ed08f6ef53ccce15d47543a5fac5fe \ - --hash=sha256:839cce114504b466da8ebee42f661845d8566679e90b9707629e7b9f3021dcd0 \ - --hash=sha256:949ae795894e92bc3739e5e52b187457644144791917bafed3d58913d7ac07dc \ - --hash=sha256:a4103201781d268e9d46c6e6d93b8d983ee9ec9dbe4c92d3f8181c909bd1deb9 \ - --hash=sha256:a4db2ee6121ad47546d34afefd35d83fdfb41bf03bf03f7c633cb1296411725f \ - --hash=sha256:a7ef8dc76a1aa113667543ad79b37ed995f8dd518109e59f4c6952e987a45e43 \ - --hash=sha256:b2321cafda2d411287f4fb4ef2e6a997c55101355c79088648ef54743f388874 \ - --hash=sha256:b2e39c2d93a779739b937402bc1f57fdfb5c9514acdcc71215af07d6de8422f3 \ - --hash=sha256:f82490ddeb4c0e074f49f5d40c5e2ff863db13e8d3ea894401137f01177f5a65 \ - --hash=sha256:fa3bb9394a96d315d60cd2453a7d17d891693e14f31840bff2cdc96b6552c48f \ - --hash=sha256:fa506c6492f3993756de785c01a7f2d9615b9c7ccfd8ac56c5b8b08b2db74768 - # via - # -r requirements/dev.in - # -r requirements/prod.txt -watchfiles==1.0.5 \ - --hash=sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827 \ - --hash=sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f \ - --hash=sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d \ - --hash=sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234 \ - --hash=sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a \ - --hash=sha256:15ac96dd567ad6c71c71f7b2c658cb22b7734901546cd50a475128ab557593ca \ - --hash=sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5 \ - --hash=sha256:1909e0a9cd95251b15bff4261de5dd7550885bd172e3536824bf1cf6b121e200 \ - --hash=sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417 \ - --hash=sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2 \ - --hash=sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827 \ - --hash=sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed \ - --hash=sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f \ - --hash=sha256:2b7a21715fb12274a71d335cff6c71fe7f676b293d322722fe708a9ec81d91f5 \ - --hash=sha256:2cfb371be97d4db374cba381b9f911dd35bb5f4c58faa7b8b7106c8853e5d225 \ - --hash=sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d \ - --hash=sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246 \ - --hash=sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705 \ - --hash=sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec \ - --hash=sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc \ - --hash=sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663 \ - --hash=sha256:4b6227351e11c57ae997d222e13f5b6f1f0700d84b8c52304e8675d33a808382 \ - --hash=sha256:554389562c29c2c182e3908b149095051f81d28c2fec79ad6c8997d7d63e0009 \ - --hash=sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40 \ - --hash=sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63 \ - --hash=sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614 \ - --hash=sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487 \ - --hash=sha256:832ccc221927c860e7286c55c9b6ebcc0265d5e072f49c7f6456c7798d2b39aa \ - --hash=sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936 \ - --hash=sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6 \ - --hash=sha256:85fbb6102b3296926d0c62cfc9347f6237fb9400aecd0ba6bbda94cae15f2b3b \ - --hash=sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92 \ - --hash=sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563 \ - --hash=sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb \ - --hash=sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d \ - --hash=sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034 \ - --hash=sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418 \ - --hash=sha256:974866e0db748ebf1eccab17862bc0f0303807ed9cda465d1324625b81293a18 \ - --hash=sha256:9848b21ae152fe79c10dd0197304ada8f7b586d3ebc3f27f43c506e5a52a863c \ - --hash=sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249 \ - --hash=sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763 \ - --hash=sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d \ - --hash=sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256 \ - --hash=sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6 \ - --hash=sha256:a3904d88955fda461ea2531fcf6ef73584ca921415d5cfa44457a225f4a42bc1 \ - --hash=sha256:a74add8d7727e6404d5dc4dcd7fac65d4d82f95928bbee0cf5414c900e86773e \ - --hash=sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04 \ - --hash=sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11 \ - --hash=sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2 \ - --hash=sha256:b659576b950865fdad31fa491d31d37cf78b27113a7671d39f919828587b429b \ - --hash=sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096 \ - --hash=sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9 \ - --hash=sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40 \ - --hash=sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01 \ - --hash=sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85 \ - --hash=sha256:c0901429650652d3f0da90bad42bdafc1f9143ff3605633c455c999a2d786cac \ - --hash=sha256:cb1489f25b051a89fae574505cc26360c8e95e227a9500182a7fe0afcc500ce0 \ - --hash=sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff \ - --hash=sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358 \ - --hash=sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25 \ - --hash=sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f \ - --hash=sha256:dfd6ae1c385ab481766b3c61c44aca2b3cd775f6f7c0fa93d979ddec853d29d5 \ - --hash=sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4 \ - --hash=sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9 \ - --hash=sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512 \ - --hash=sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a \ - --hash=sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11 \ - --hash=sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d \ - --hash=sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965 \ - --hash=sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21 \ - --hash=sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57 - # via - # -r requirements/dev.in - # -r requirements/prod.txt + # via -r requirements/prod.in webob==1.8.9 \ --hash=sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9 \ --hash=sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589 - # via - # -r requirements/prod.txt - # hawkauthlib + # via hawkauthlib whitenoise==6.9.0 \ --hash=sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609 \ --hash=sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df - # via -r requirements/prod.txt + # via -r requirements/prod.in From a5f1c8a691bf2627f217eab1bd2c31d608ad9dab Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:55:23 -0300 Subject: [PATCH 039/137] Don't attempt to create new contacts on unsubscribe or set --- basket/news/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 459a9c41..8584e032 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -276,6 +276,10 @@ def upsert_contact(api_call_type, data, user_data): update_data["mofo_relevant"] = True if user_data is None: + if api_call_type != SUBSCRIBE: + # Doesn't make sense to create a new user for UNSUBSCRIBE or SET + return None, False + # no user found. create new one. token = update_data["token"] = generate_token() if settings.MAINTENANCE_MODE: From 3064dc3a6fea638607d22bc7f57a43c22033a8fe Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:59:25 -0300 Subject: [PATCH 040/137] Check that request token matches user's token --- basket/news/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 8584e032..b132b4c5 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -221,6 +221,10 @@ def upsert_contact(api_call_type, data, user_data): newsletters = parse_newsletters_csv(data.get("newsletters")) cur_newsletters = user_data and user_data.get("newsletters") + if user_data and data.get("token") and user_data.get("token") != data["token"]: + # We were passed a token but it doesn't match the user. + return None, None + if api_call_type == SUBSCRIBE: newsletters_set = set(newsletters) From 725092cea94339340427de0e9189cbbdb39cc384 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:05:32 -0300 Subject: [PATCH 041/137] Make test_rq_utils work with non-docker redis --- basket/base/tests/test_rq_utils.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/basket/base/tests/test_rq_utils.py b/basket/base/tests/test_rq_utils.py index 881dbda4..e36cf5e0 100644 --- a/basket/base/tests/test_rq_utils.py +++ b/basket/base/tests/test_rq_utils.py @@ -1,4 +1,5 @@ from unittest.mock import patch +from urllib.parse import urlparse from django.conf import settings from django.test.utils import override_settings @@ -19,6 +20,8 @@ from basket.news.models import FailedTask from basket.news.utils import NewsletterException +default_rq_url = settings.RQ_URL + @pytest.mark.django_db class TestRQUtils: @@ -46,7 +49,7 @@ def test_rq_exponential_backoff_with_debug(self): """ assert rq_exponential_backoff() == [5, 5, 5, 5, 5, 5, 5, 5, 5, 5] - @override_settings(RQ_URL="redis://redis:6379/2") + @override_settings(RQ_URL=default_rq_url) def test_get_redis_connection(self): """ Test that the get_redis_connection function returns a Redis connection with params we expect. @@ -58,7 +61,12 @@ def test_get_redis_connection(self): # Test with no URL argument, but with RQ_URL in the settings. # Note: The RQ_URL being used also sets this back to the "default" for tests that follow. connection = get_redis_connection(force=True) - assert connection.connection_pool.connection_kwargs == {"host": "redis", "port": 6379, "db": 2} + parsed_default_rq_url = urlparse(default_rq_url) + assert connection.connection_pool.connection_kwargs == { + "host": parsed_default_rq_url.hostname, + "port": parsed_default_rq_url.port, + "db": 2, + } @override_settings(REDIS_URL=None, RQ_URL=None) def test_get_redis_connection_none(self): @@ -69,7 +77,7 @@ def test_get_redis_connection_none(self): get_redis_connection(force=True) # Set back to the "default" for tests that follow since the connection is cached in the module. - get_redis_connection("redis://redis:6379/2", force=True) + get_redis_connection(default_rq_url, force=True) @override_settings(RQ_DEFAULT_QUEUE="") def test_get_queue(self): From 12c04c7613f741fd2e75006d51f0429d5ddd7ec2 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:58:45 -0300 Subject: [PATCH 042/137] Update api routes to optionally use Braze as backend --- basket/news/api.py | 84 ++++++++++++++++++++++++++++++++++++++++++++-- basket/settings.py | 1 + 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/basket/news/api.py b/basket/news/api.py index b58e752b..cb58354a 100644 --- a/basket/news/api.py +++ b/basket/news/api.py @@ -5,6 +5,7 @@ from django.http import HttpResponse from django.views.decorators.cache import cache_page, never_cache +import sentry_sdk from ninja import NinjaAPI, Router from ninja.decorators import decorate_view from ninja.errors import Throttled, ValidationError @@ -82,7 +83,27 @@ def confirm_user(request, token: uuid.UUID): if settings.MAINTENANCE_MODE and not settings.MAINTENANCE_READ_ONLY: return _maintenance_error() - tasks.confirm_user.delay(str(token)) + if settings.BRAZE_PARALLEL_WRITE_ENABLE and settings.BRAZE_TOKEN_MIGRATION_COMPLETE: + tasks.confirm_user.delay( + str(token), + use_braze_backend=True, + extra_metrics_tags=["backend:braze"], + ) + tasks.confirm_user.delay( + str(token), + use_braze_backend=False, + ) + elif settings.BRAZE_ONLY_WRITE_ENABLE and settings.BRAZE_TOKEN_MIGRATION_COMPLETE: + tasks.confirm_user.delay( + str(token), + use_braze_backend=True, + extra_metrics_tags=["backend:braze"], + ) + else: + tasks.confirm_user.delay( + str(token), + use_braze_backend=False, + ) return {"status": "ok"} @@ -107,7 +128,32 @@ def recover_user(request, body: RecoverUserSchema): return {"status": "ok"} try: - user_data = get_user_data(email=body.email, extra_fields=["email_id"]) + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: + try: + user_data = get_user_data( + email=body.email, + extra_fields=["email_id"], + use_braze_backend=True, + ) + except Exception: + sentry_sdk.capture_exception() + user_data = get_user_data( + email=body.email, + extra_fields=["email_id"], + use_braze_backend=False, + ) + elif settings.BRAZE_ONLY_READ_ENABLE: + user_data = get_user_data( + email=body.email, + extra_fields=["email_id"], + use_braze_backend=True, + ) + else: + user_data = get_user_data( + email=body.email, + extra_fields=["email_id"], + use_braze_backend=False, + ) except NewsletterException as exc: return _unknown_error(exc) @@ -159,8 +205,40 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non if not email: return _invalid_email() + # We can't do a look up by token until migration is complete. + braze_unable_to_serve = token and not settings.BRAZE_TOKEN_MIGRATION_COMPLETE + try: - user_data = get_user_data(email=email, token=token, masked=masked) + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE and not braze_unable_to_serve: + try: + user_data = get_user_data( + email=email, + token=token, + masked=masked, + use_braze_backend=True, + ) + except Exception: + sentry_sdk.capture_exception() + user_data = get_user_data( + email=email, + token=token, + masked=masked, + use_braze_backend=False, + ) + elif settings.BRAZE_ONLY_READ_ENABLE and not braze_unable_to_serve: + user_data = get_user_data( + email=email, + token=token, + masked=masked, + use_braze_backend=True, + ) + else: + user_data = get_user_data( + email=email, + token=token, + masked=masked, + use_braze_backend=False, + ) except NewsletterException as exc: return _unknown_error(exc) diff --git a/basket/settings.py b/basket/settings.py index 8b398fd7..61cb4dd2 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -224,6 +224,7 @@ def path(*args): BRAZE_ONLY_WRITE_ENABLE = config("BRAZE_ONLY_WRITE_ENABLE", parser=bool, default="false") BRAZE_READ_WITH_FALLBACK_ENABLE = config("BRAZE_READ_WITH_FALLBACK_ENABLE", parser=bool, default="false") BRAZE_ONLY_READ_ENABLE = config("BRAZE_ONLY_READ_ENABLE", parser=bool, default="false") +BRAZE_TOKEN_MIGRATION_COMPLETE = config("BRAZE_TOKEN_MIGRATION_COMPLETE", parser=bool, default="false") # Mozilla CTMS CTMS_ENV = config("CTMS_ENV", default="").lower() From f3726f92c1633e96300d9bff89ef800e30cc3fd1 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:57:59 -0300 Subject: [PATCH 043/137] Update migrator braze interface usage --- .../management/commands/process_braze_external_id_migrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/management/commands/process_braze_external_id_migrator.py b/basket/news/management/commands/process_braze_external_id_migrator.py index 1440c322..162c24d5 100644 --- a/basket/news/management/commands/process_braze_external_id_migrator.py +++ b/basket/news/management/commands/process_braze_external_id_migrator.py @@ -57,7 +57,7 @@ def process_and_migrate_parquet_file(self, project, bucket, prefix, file_name, s chunk = migrations[i : i + chunk_size] braze_chunk = self.strip_for_braze(chunk) try: - braze.migrate_external_id(braze_chunk) + braze.interface.migrate_external_id(braze_chunk) time.sleep(0.07) except Exception as e: failure = { From dc14268e76a5977f80cf040bb1bd3ce8da683818 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:02:02 -0300 Subject: [PATCH 044/137] add interface method tests --- basket/news/tests/test_braze.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index c5edabe8..f924b6e1 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -149,6 +149,28 @@ def test_braze_export_users(braze_client): assert m.last_request.json() == expected +def test_get_user_subscriptions(braze_client): + email = "test@test.com" + external_id = ("fed654",) + params = { + "email": [email], + "external_id": ["fed654"], + } + with requests_mock.mock() as m: + m.register_uri("GET", "http://test.com/subscription/user/status", json={}) + braze_client.get_user_subscriptions(external_id, email) + assert m.last_request.qs == params + + +def test_braze_save_user(braze_client): + data = {"email": "test@test.com", "first_name": "foo", "last_name": "bar"} + expected = data + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + braze_client.save_user(data) + assert m.last_request.json() == expected + + def test_braze_send_campaign(braze_client): email = "test@test.com" campaign_id = "test_campaign_id" From be6f6cbb5d564647b6e81f0c22f9855cb79ea5d7 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:37:14 -0300 Subject: [PATCH 045/137] fix broken conditional --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 0b1db345..7f966d2d 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -376,7 +376,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=N language = process_lang(updated_user_data.get("lang")) subscription_groups = [] - if isinstance(update_data.get("newsletters"), dict): + if update_data and isinstance(update_data.get("newsletters"), dict): for slug, is_subscribed in update_data["newsletters"].items(): vendor_id = slug_to_vendor_id(slug) if is_valid_uuid(vendor_id): From af9c96f1a55e54269b26cc1e00e16e13159f2d19 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:15:07 -0300 Subject: [PATCH 046/137] add tests for to_vendor and from_vendor --- basket/news/tests/test_braze.py | 278 ++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index f924b6e1..549d3b5d 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -1,3 +1,4 @@ +from collections import namedtuple from unittest import mock from django.utils import timezone @@ -7,6 +8,7 @@ from freezegun import freeze_time from basket.news.backends import braze +from basket.news.backends.braze import Braze @pytest.fixture @@ -257,3 +259,279 @@ def test_braze_exception_500(braze_client): m.register_uri("POST", "http://test.com/users/track", status_code=500, json={}) with pytest.raises(braze.BrazeInternalServerError): braze_client.track_user("test@test.com") + + +mock_basket_user_data = { + "email": "test@example.com", + "email_id": "123", + "id": "456", + "first_name": "Test", + "last_name": "User", + "country": "US", + "lang": "en", + "newsletters": ["foo-news"], + "created_date": "2022-01-01", + "last_modified_date": "2022-02-01", + "optin": True, + "optout": False, + "token": "abc", + "fxa_service": "test", + "fxa_lang": "en", + "fxa_primary_email": "test2@example.com", + "fxa_create_date": "2022-01-02", +} + +mock_braze_user_data = { + "email": "test@example.com", + "external_id": "123", + "braze_id": "456", + "first_name": "Test", + "last_name": "User", + "country": "US", + "language": "en", + "email_subscribe": "opted_in", + "custom_attributes": { + "user_attributes_v1": [ + { + "mailing_country": "us", + "email_lang": "en", + "created_at": "2022-01-01", + "updated_at": "2022-02-01", + "basket_token": "abc", + "fxa_first_service": "test", + "fxa_lang": "en", + "fxa_primary_email": "test2@example.com", + "fxa_created_at": "2022-01-02", + "has_fxa": True, + } + ] + }, +} + +mock_braze_user_subscription_groups = [ + {"id": "234c9b4a-1785-4cd5-b839-5dbc134982eb", "status": "Subscribed"}, + {"id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "status": "Not Subscribed"}, +] + +mock_newsletters = { + "by_vendor_id": { + "234c9b4a-1785-4cd5-b839-5dbc134982eb": namedtuple("Newsletter", ["slug"])("foo-news"), + "78fe6671-9f94-48bd-aaf3-7e873536c3e6": namedtuple("Newsletter", ["slug"])("bar-news"), + }, + "by_name": { + "foo-news": namedtuple("Newsletter", ["vendor_id"])("234c9b4a-1785-4cd5-b839-5dbc134982eb"), + "bar-news": namedtuple("Newsletter", ["vendor_id"])("78fe6671-9f94-48bd-aaf3-7e873536c3e6"), + }, +} + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +def test_from_vendor(braze_client): + braze_instance = Braze(braze_client()) + + assert braze_instance.from_vendor(mock_braze_user_data, mock_braze_user_subscription_groups) == mock_basket_user_data + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_to_vendor_with_user_data_and_no_updates(self, braze_client): + braze_instance = Braze(braze_client()) + dt = timezone.now() + expected = { + "attributes": [ + { + "_update_existing_only": True, + "email": "test@example.com", + "external_id": "123", + "email_subscribe": "opted_in", + "subscription_groups": [], + "update_timestamp": dt.isoformat(), + "user_attributes_v1": [ + { + "mailing_country": "us", + "email_lang": "en", + "created_at": { + "$time": "2022-01-01", + }, + "basket_token": "abc", + "fxa_first_service": "test", + "fxa_lang": "en", + "fxa_primary_email": "test2@example.com", + "fxa_created_at": "2022-01-02", + "has_fxa": True, + "updated_at": { + "$time": dt.isoformat(), + }, + } + ], + } + ] + } + with freeze_time(dt): + assert braze_instance.to_vendor(mock_basket_user_data) == expected + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_to_vendor_with_updates_and_no_user_data(self, braze_client): + braze_instance = Braze(braze_client()) + dt = timezone.now() + update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123"} + expected = { + "attributes": [ + { + "_update_existing_only": True, + "email": "test@example.com", + "external_id": "123", + "email_subscribe": "subscribed", + "subscription_groups": [ + {"subscription_group_id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "subscription_state": "subscribed"}, + ], + "update_timestamp": dt.isoformat(), + "user_attributes_v1": [ + { + "email_lang": "en", + "created_at": { + "$time": dt.isoformat(), + }, + "basket_token": "abc", + "fxa_first_service": None, + "fxa_lang": None, + "fxa_primary_email": None, + "fxa_created_at": None, + "has_fxa": False, + "mailing_country": None, + "updated_at": { + "$time": dt.isoformat(), + }, + } + ], + } + ] + } + with freeze_time(dt): + assert braze_instance.to_vendor(None, update_data) == expected + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_to_vendor_with_both_user_data_and_updates(self, braze_client): + braze_instance = Braze(braze_client()) + dt = timezone.now() + update_data = {"newsletters": {"bar-news": True, "foo-news": False}, "first_name": "Foo", "country": "CA", "optin": False} + expected = { + "attributes": [ + { + "_update_existing_only": True, + "email": "test@example.com", + "external_id": "123", + "email_subscribe": "subscribed", + "first_name": "Foo", + "country": "ca", + "subscription_groups": [ + {"subscription_group_id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "subscription_state": "subscribed"}, + {"subscription_group_id": "234c9b4a-1785-4cd5-b839-5dbc134982eb", "subscription_state": "unsubscribed"}, + ], + "update_timestamp": dt.isoformat(), + "user_attributes_v1": [ + { + "mailing_country": "ca", + "email_lang": "en", + "created_at": { + "$time": "2022-01-01", + }, + "basket_token": "abc", + "fxa_first_service": "test", + "fxa_lang": "en", + "fxa_primary_email": "test2@example.com", + "fxa_created_at": "2022-01-02", + "has_fxa": True, + "updated_at": { + "$time": dt.isoformat(), + }, + } + ], + } + ] + } + with freeze_time(dt): + assert braze_instance.to_vendor(mock_basket_user_data, update_data) == expected + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_to_vendor_with_custom_attributes_and_events(self, braze_client): + braze_instance = Braze(braze_client()) + dt = timezone.now() + events = [ + { + "name": "test event", + "time": dt, + "external_id": "123", + } + ] + expected = { + "attributes": [ + { + "_update_existing_only": False, + "email": "test@example.com", + "external_id": "123", + "email_subscribe": "opted_in", + "subscription_groups": [], + "update_timestamp": dt.isoformat(), + "user_attributes_v1": [ + { + "mailing_country": "us", + "email_lang": "en", + "created_at": { + "$time": "2022-01-01", + }, + "basket_token": "abc", + "fxa_first_service": "test", + "fxa_lang": "en", + "fxa_primary_email": "test2@example.com", + "fxa_created_at": "2022-01-02", + "has_fxa": True, + "updated_at": { + "$time": dt.isoformat(), + }, + } + ], + } + ], + "events": events, + } + with freeze_time(dt): + assert ( + braze_instance.to_vendor( + basket_user_data=mock_basket_user_data, update_data=None, custom_attributes={"_update_existing_only": False}, events=events + ) + == expected + ) From b5aad312c856d6fe309af5c436c4636ef61fcc2f Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:15:49 -0300 Subject: [PATCH 047/137] add default just in case --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 7f966d2d..bbca7cda 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -341,7 +341,7 @@ def from_vendor(self, braze_user_data, subscription_groups): user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] - subscription_ids = [subscription["id"] for subscription in subscription_groups if subscription["status"] == "Subscribed"] + subscription_ids = [subscription["id"] for subscription in (subscription_groups or []) if subscription["status"] == "Subscribed"] newsletter_slugs = list(filter(None, map(vendor_id_to_slug, subscription_ids))) basket_user_data = { From 05809306a48dad5dfabc2be0eb9b3fdd2b832d5d Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:20:30 -0300 Subject: [PATCH 048/137] add braze get test --- basket/news/tests/test_braze.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 549d3b5d..b4509efc 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -330,7 +330,7 @@ def test_braze_exception_500(braze_client): return_value=mock_newsletters, ) def test_from_vendor(braze_client): - braze_instance = Braze(braze_client()) + braze_instance = Braze(braze_client) assert braze_instance.from_vendor(mock_braze_user_data, mock_braze_user_subscription_groups) == mock_basket_user_data @@ -344,7 +344,7 @@ def test_from_vendor(braze_client): return_value=["en"], ) def test_to_vendor_with_user_data_and_no_updates(self, braze_client): - braze_instance = Braze(braze_client()) + braze_instance = Braze(braze_client) dt = timezone.now() expected = { "attributes": [ @@ -389,7 +389,7 @@ def test_to_vendor_with_user_data_and_no_updates(self, braze_client): return_value=["en"], ) def test_to_vendor_with_updates_and_no_user_data(self, braze_client): - braze_instance = Braze(braze_client()) + braze_instance = Braze(braze_client) dt = timezone.now() update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123"} expected = { @@ -437,7 +437,7 @@ def test_to_vendor_with_updates_and_no_user_data(self, braze_client): return_value=["en"], ) def test_to_vendor_with_both_user_data_and_updates(self, braze_client): - braze_instance = Braze(braze_client()) + braze_instance = Braze(braze_client) dt = timezone.now() update_data = {"newsletters": {"bar-news": True, "foo-news": False}, "first_name": "Foo", "country": "CA", "optin": False} expected = { @@ -488,7 +488,7 @@ def test_to_vendor_with_both_user_data_and_updates(self, braze_client): return_value=["en"], ) def test_to_vendor_with_custom_attributes_and_events(self, braze_client): - braze_instance = Braze(braze_client()) + braze_instance = Braze(braze_client) dt = timezone.now() events = [ { @@ -535,3 +535,18 @@ def test_to_vendor_with_custom_attributes_and_events(self, braze_client): ) == expected ) + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +def test_braze_get(self, braze_client): + email = mock_braze_user_data["email"] + braze_instance = Braze(braze_client) + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/export/ids", json={"users": [mock_braze_user_data]}) + m.register_uri( + "GET", "http://test.com/subscription/user/status", json={"users": [{"subscription_groups": mock_braze_user_subscription_groups}]} + ) + assert braze_instance.get(email=email) == mock_basket_user_data From 33243a5ef781f65c62a2487db3d1d1b50486208b Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:41:39 -0300 Subject: [PATCH 049/137] add request assertions to braze.get test --- basket/news/tests/test_braze.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index b4509efc..338abc7d 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -550,3 +550,28 @@ def test_braze_get(self, braze_client): "GET", "http://test.com/subscription/user/status", json={"users": [{"subscription_groups": mock_braze_user_subscription_groups}]} ) assert braze_instance.get(email=email) == mock_basket_user_data + + api_requests = m.request_history + assert api_requests[0].url == "http://test.com/users/export/ids" + assert api_requests[0].json() == { + "email_address": email, + "fields_to_export": [ + "braze_id", + "country", + "created_at", + "custom_attributes", + "email", + "email_subscribe", + "external_id", + "first_name", + "language", + "last_name", + ], + "user_aliases": [ + { + "alias_label": "email", + "alias_name": email, + }, + ], + } + assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123&email=test%40example.com" From aab1073ecd943c5e9c733fc9707ea7943907d87b Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Mon, 27 Oct 2025 14:35:00 -0700 Subject: [PATCH 050/137] Update FXA functions --- basket/news/backends/braze.py | 3 ++ basket/news/tasks.py | 60 +++++++++++++++++++++++------------ 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index d376dd3a..7a64145b 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -351,6 +351,9 @@ def update(self, existing_data, update_data): braze_user_data = self.to_vendor(existing_data, update_data) self.interface.save_user(braze_user_data) + def update_by_alt_id(self, alt_id_name, alt_id_value, update_data): + raise NotImplementedError + def update_by_fxa_id(self, fxa_id, update_data): raise NotImplementedError diff --git a/basket/news/tasks.py b/basket/news/tasks.py index dffe2fbc..eae2e855 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -44,7 +44,7 @@ def fxa_source_url(metrics): @rq_task -def fxa_email_changed(data): +def fxa_email_changed(data, use_braze_backend=False): ts = data["ts"] fxa_id = data["uid"] email = data["email"] @@ -55,14 +55,20 @@ def fxa_email_changed(data): return # Update CTMS - user_data = get_user_data(fxa_id=fxa_id, extra_fields=["id", "email_id"]) + user_data = get_user_data(fxa_id=fxa_id, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) if user_data: - ctms.update(user_data, {"fxa_primary_email": email}) + if use_braze_backend: + braze.update(user_data, {"fxa_primary_email": email}) + else: + ctms.update(user_data, {"fxa_primary_email": email}) else: # FxA record not found, try email - user_data = get_user_data(email=email, extra_fields=["id", "email_id"]) + user_data = get_user_data(email=email, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) if user_data: - ctms.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) + if use_braze_backend: + braze.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) + else: + ctms.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) else: # No matching record for Email or FxA ID. Create one. data = { @@ -71,8 +77,13 @@ def fxa_email_changed(data): "fxa_id": fxa_id, "fxa_primary_email": email, } - ctms_data = data.copy() - contact = ctms.add(ctms_data) + backend_data = data.copy() + contact = None + if use_braze_backend: + # This doesn't return the user??? What do we do here? + contact = braze.add(backend_data) + else: + contact = ctms.add(backend_data) if contact: data["email_id"] = contact["email"]["email_id"] metrics.incr("news.tasks.fxa_email_changed.user_not_found") @@ -80,25 +91,28 @@ def fxa_email_changed(data): cache.set(cache_key, ts, 7200) # 2 hr -def fxa_direct_update_contact(fxa_id, data): +def fxa_direct_update_contact(fxa_id, data, use_braze_backend=False): """Set some small data for a contact with an FxA ID Ignore if contact with FxA ID can't be found """ try: - ctms.update_by_alt_id("fxa_id", fxa_id, data) + if use_braze_backend: + braze.update_by_alt_id("fxa_id", fxa_id, data, use_braze_backend) + else: + ctms.update_by_alt_id("fxa_id", fxa_id, data) except CTMSNotFoundByAltIDError: # No associated record found, skip this update. pass @rq_task -def fxa_delete(data): - fxa_direct_update_contact(data["uid"], {"fxa_deleted": True}) +def fxa_delete(data, use_braze_backend=False): + fxa_direct_update_contact(data["uid"], {"fxa_deleted": True}, use_braze_backend) @rq_task -def fxa_verified(data): +def fxa_verified(data, use_braze_backend=False): """Add new FxA users""" # if we're not using the sandbox ignore testing domains if email_is_testing(data["email"]): @@ -129,16 +143,16 @@ def fxa_verified(data): newsletters.append(settings.FXA_REGISTER_NEWSLETTER) new_data["newsletters"] = newsletters - user_data = get_fxa_user_data(fxa_id, email) + user_data = get_fxa_user_data(fxa_id, email, use_braze_backend) # don't overwrite the user's language if already set if not (user_data and user_data.get("lang")): new_data["lang"] = lang - upsert_contact(SUBSCRIBE, new_data, user_data) + upsert_contact(SUBSCRIBE, new_data, user_data, use_braze_backend) @rq_task -def fxa_newsletters_update(data): +def fxa_newsletters_update(data, use_braze_backend=False): email = data["email"] fxa_id = data["uid"] new_data = { @@ -150,11 +164,11 @@ def fxa_newsletters_update(data): "fxa_id": fxa_id, "optin": True, } - upsert_contact(SUBSCRIBE, new_data, get_fxa_user_data(fxa_id, email)) + upsert_contact(SUBSCRIBE, new_data, get_fxa_user_data(fxa_id, email), use_braze_backend) @rq_task -def fxa_login(data): +def fxa_login(data, use_braze_backend=False): email = data["email"] # if we're not using the sandbox ignore testing domains if email_is_testing(email): @@ -171,6 +185,7 @@ def fxa_login(data): "source_url": fxa_source_url(metrics_context), "country": data.get("countryCode", ""), }, + use_braze_backend, ) @@ -519,7 +534,7 @@ def record_common_voice_update(data): ctms.add(new_data) -def get_fxa_user_data(fxa_id, email): +def get_fxa_user_data(fxa_id, email, use_braze_backend=False): """ Return a user data dict, just like `get_user_data` below, but ensure we have a good FxA contact @@ -532,15 +547,18 @@ def get_fxa_user_data(fxa_id, email): """ user_data = None # try getting user data with the fxa_id first - user_data_fxa = get_user_data(fxa_id=fxa_id, extra_fields=["id", "email_id"]) + user_data_fxa = get_user_data(fxa_id=fxa_id, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) if user_data_fxa: user_data = user_data_fxa # If email doesn't match, update FxA primary email field with the new email. if user_data_fxa["email"] != email: - ctms.update(user_data_fxa, {"fxa_primary_email": email}) + if use_braze_backend: + braze.update(user_data_fxa, {"fxa_primary_email": email}) + else: + ctms.update(user_data_fxa, {"fxa_primary_email": email}) # if we still don't have user data try again with email this time if not user_data: - user_data = get_user_data(email=email, extra_fields=["id", "email_id"]) + user_data = get_user_data(email=email, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) return user_data From 519fe7741cda815638abd3b06d9ed2adc620a91a Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Mon, 27 Oct 2025 15:18:26 -0700 Subject: [PATCH 051/137] Add more braze feature flags --- basket/news/management/commands/process_fxa_queue.py | 8 +++++++- basket/news/tasks.py | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/basket/news/management/commands/process_fxa_queue.py b/basket/news/management/commands/process_fxa_queue.py index c6343fa5..896c3941 100644 --- a/basket/news/management/commands/process_fxa_queue.py +++ b/basket/news/management/commands/process_fxa_queue.py @@ -100,7 +100,13 @@ def handle(self, *args, **options): continue try: - FXA_EVENT_TYPES[event_type].delay(event) + if settings.BRAZE.PARALLEL_WRITE_ENABLE: + FXA_EVENT_TYPES[event_type].delay(event, True) + FXA_EVENT_TYPES[event_type].delay(event) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + FXA_EVENT_TYPES[event_type].delay(event, True) + else: + FXA_EVENT_TYPES[event_type].delay(event) except Exception: # something's wrong with the queue. try again. metrics.incr("fxa.events.message", tags=["info:queue_error", f"event:{event_type}"]) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index eae2e855..8bc9594a 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -65,7 +65,7 @@ def fxa_email_changed(data, use_braze_backend=False): # FxA record not found, try email user_data = get_user_data(email=email, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) if user_data: - if use_braze_backend: + if use_braze_backend and settings.BRAZE_FXA_MIGRATION_COMPLETE: braze.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) else: ctms.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) @@ -97,7 +97,7 @@ def fxa_direct_update_contact(fxa_id, data, use_braze_backend=False): Ignore if contact with FxA ID can't be found """ try: - if use_braze_backend: + if use_braze_backend and settings.BRAZE_FXA_MIGRATION_COMPLETE: braze.update_by_alt_id("fxa_id", fxa_id, data, use_braze_backend) else: ctms.update_by_alt_id("fxa_id", fxa_id, data) From 28680826c8a2979ce857a7787f7cf45aa37ade56 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 13:31:10 -0300 Subject: [PATCH 052/137] fix broken syntax --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index bbca7cda..ee06c790 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -303,7 +303,7 @@ def add(self, data): # If we don't have an `email_id`, we need to submit the user alias. if not data.get("email_id"): - custom_attributes["user_alias"] = {"alias_name": data.email, "alias_label": "email"} + custom_attributes["user_alias"] = {"alias_name": data["email"], "alias_label": "email"} braze_user_data = self.to_vendor(None, data, custom_attributes) self.interface.save_user(braze_user_data) From 7e352523046f7c779e5b3496bbe5b6ef99a79a5f Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:49:19 -0300 Subject: [PATCH 053/137] Fix call to track_user in send_tx_message --- basket/news/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index dffe2fbc..95c99359 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -413,7 +413,7 @@ def ctms_add_or_update(update_data, user_data=None): @rq_task def send_tx_message(email, message_id, language, user_data=None): metrics.incr("news.tasks.send_tx_message", tags=[f"message_id:{message_id}", f"language:{language}"]) - braze.track_user(email, event=f"send-{message_id}-{language}", user_data=user_data) + braze.interface.track_user(email, event=f"send-{message_id}-{language}", user_data=user_data) def send_tx_messages(email, lang, message_ids): From bd0fd94f1ed4ac8cb3ed0ed586cf30f2062983b9 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:49:55 -0300 Subject: [PATCH 054/137] fix test params, add tests for braze add and update --- basket/news/tests/test_braze.py | 48 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 338abc7d..743b45fd 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -343,7 +343,7 @@ def test_from_vendor(braze_client): "basket.news.newsletters.newsletter_languages", return_value=["en"], ) -def test_to_vendor_with_user_data_and_no_updates(self, braze_client): +def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() expected = { @@ -388,7 +388,7 @@ def test_to_vendor_with_user_data_and_no_updates(self, braze_client): "basket.news.newsletters.newsletter_languages", return_value=["en"], ) -def test_to_vendor_with_updates_and_no_user_data(self, braze_client): +def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123"} @@ -436,7 +436,7 @@ def test_to_vendor_with_updates_and_no_user_data(self, braze_client): "basket.news.newsletters.newsletter_languages", return_value=["en"], ) -def test_to_vendor_with_both_user_data_and_updates(self, braze_client): +def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() update_data = {"newsletters": {"bar-news": True, "foo-news": False}, "first_name": "Foo", "country": "CA", "optin": False} @@ -487,7 +487,7 @@ def test_to_vendor_with_both_user_data_and_updates(self, braze_client): "basket.news.newsletters.newsletter_languages", return_value=["en"], ) -def test_to_vendor_with_custom_attributes_and_events(self, braze_client): +def test_to_vendor_with_custom_attributes_and_events(mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() events = [ @@ -541,7 +541,7 @@ def test_to_vendor_with_custom_attributes_and_events(self, braze_client): "basket.news.newsletters._newsletters", return_value=mock_newsletters, ) -def test_braze_get(self, braze_client): +def test_braze_get(mock_newsletters, braze_client): email = mock_braze_user_data["email"] braze_instance = Braze(braze_client) with requests_mock.mock() as m: @@ -575,3 +575,41 @@ def test_braze_get(self, braze_client): ], } assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123&email=test%40example.com" + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +def test_braze_add(mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + new_user = { + "email": "test@example.com", + "newsletters": {"foo-news": True}, + "country": "US", + } + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + with freeze_time(): + braze_instance.add(new_user) + assert m.last_request.json() == braze_instance.to_vendor( + None, new_user, {"_update_existing_only": False, "user_alias": {"alias_name": new_user["email"], "alias_label": "email"}} + ) + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_braze_update(mock_newsletter_languages, mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + update_data = {"country": "CA"} + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + with freeze_time(): + braze_instance.update(mock_basket_user_data | {"lang": None}, update_data) + assert m.last_request.json() == braze_instance.to_vendor(mock_basket_user_data | {"lang": None}, update_data) From 78c67127fd1291d0b92741b9571705fab21e9ce5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:02:35 -0300 Subject: [PATCH 055/137] return email id with braze.add --- basket/news/backends/braze.py | 1 + 1 file changed, 1 insertion(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index ee06c790..3086e664 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -307,6 +307,7 @@ def add(self, data): braze_user_data = self.to_vendor(None, data, custom_attributes) self.interface.save_user(braze_user_data) + return {"email": {"email_id": data.get("email_id")}} def update(self, existing_data, update_data): braze_user_data = self.to_vendor(existing_data, update_data) From 89b5485607dbed549775a66fdf4b0bc670887f7d Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:03:36 -0300 Subject: [PATCH 056/137] update braze.add test with missing data fields and return statement --- basket/news/tests/test_braze.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 743b45fd..6faf1fa9 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -585,16 +585,18 @@ def test_braze_add(mock_newsletters, braze_client): braze_instance = Braze(braze_client) new_user = { "email": "test@example.com", + "email_id": "123", + "basket_token": "abc", "newsletters": {"foo-news": True}, "country": "US", } with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/track", json={}) + expected = {"email": {"email_id": new_user["email_id"]}} with freeze_time(): - braze_instance.add(new_user) - assert m.last_request.json() == braze_instance.to_vendor( - None, new_user, {"_update_existing_only": False, "user_alias": {"alias_name": new_user["email"], "alias_label": "email"}} - ) + response = braze_instance.add(new_user) + assert response == expected + assert m.last_request.json() == braze_instance.to_vendor(None, new_user, {"_update_existing_only": False}) @mock.patch( From e9982d91030e1d838818085dcb846a73357d5a5b Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:05:38 -0300 Subject: [PATCH 057/137] remove dictionary override --- basket/news/tests/test_braze.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 6faf1fa9..c9e03e6f 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -613,5 +613,5 @@ def test_braze_update(mock_newsletter_languages, mock_newsletters, braze_client) with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/track", json={}) with freeze_time(): - braze_instance.update(mock_basket_user_data | {"lang": None}, update_data) - assert m.last_request.json() == braze_instance.to_vendor(mock_basket_user_data | {"lang": None}, update_data) + braze_instance.update(mock_basket_user_data, update_data) + assert m.last_request.json() == braze_instance.to_vendor(mock_basket_user_data, update_data) From a72d16ffba72b740368cfd7590c875aa4bd5929a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:27:37 -0300 Subject: [PATCH 058/137] edit braze.delete to avoid grabbing unnecessary data --- basket/news/backends/braze.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 3086e664..1a30428b 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -327,13 +327,14 @@ def delete(self, email): @return: deleted user data if successful @raises: BrazeUserNotFoundByEmailError """ - data = self.get(email=email) - if not data: + data = self.interface.export_users(email=email, fields_to_export=["external_id"]) + if not data["users"]: raise BrazeUserNotFoundByEmailError + email_id = data["users"][0].get("external_id") self.interface.delete_user(email) - # return in list to match CTMS.delete - return [data] + # return in list of email_id to match CTMS.delete + return [{"email_id": email_id}] def from_vendor(self, braze_user_data, subscription_groups): """ From 9a1d8106dd09879cb786df992b85ffee8468f9df Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:30:51 -0300 Subject: [PATCH 059/137] add test for braze.delete --- basket/news/tests/test_braze.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index c9e03e6f..5f793052 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -615,3 +615,18 @@ def test_braze_update(mock_newsletter_languages, mock_newsletters, braze_client) with freeze_time(): braze_instance.update(mock_basket_user_data, update_data) assert m.last_request.json() == braze_instance.to_vendor(mock_basket_user_data, update_data) + + +def test_braze_delete(braze_client): + braze_instance = Braze(braze_client) + email = mock_braze_user_data["email"] + expected = [{"email_id": mock_braze_user_data["external_id"]}] + + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/export/ids", json={"users": [{"external_id": mock_braze_user_data["external_id"]}]}) + m.register_uri("POST", "http://test.com/users/delete", json={}) + response = braze_instance.delete(email) + api_requests = m.request_history + assert api_requests[0].url == "http://test.com/users/export/ids" + assert api_requests[1].url == "http://test.com/users/delete" + assert response == expected From e9f05d726585a28d73a7a3723b397492f3b10a35 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:28:23 -0300 Subject: [PATCH 060/137] Update tests to use braze.interface --- .../tests/test_process_braze_external_id_migrator.py | 12 ++++++------ basket/news/tests/test_tasks.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/basket/news/tests/test_process_braze_external_id_migrator.py b/basket/news/tests/test_process_braze_external_id_migrator.py index 672e79eb..14b04f7d 100644 --- a/basket/news/tests/test_process_braze_external_id_migrator.py +++ b/basket/news/tests/test_process_braze_external_id_migrator.py @@ -48,7 +48,7 @@ def test_successful_migration(mock_storage_client, mock_braze, sample_df): mock_client.bucket.return_value = mock_bucket mock_storage_client.return_value = mock_client - mock_braze.migrate_external_id.return_value = {"braze_collected_response": {"external_ids": ["id1", "id2"], "rename_errors": []}} + mock_braze.interface.migrate_external_id.return_value = {"braze_collected_response": {"external_ids": ["id1", "id2"], "rename_errors": []}} cmd = Command() cmd.stdout = mock.Mock() @@ -59,7 +59,7 @@ def test_successful_migration(mock_storage_client, mock_braze, sample_df): {"current_external_id": "id1", "new_external_id": "token1"}, {"current_external_id": "id2", "new_external_id": "token2"}, ] - mock_braze.migrate_external_id.assert_called_once_with(expected_chunk) + mock_braze.interface.migrate_external_id.assert_called_once_with(expected_chunk) def test_file_not_found(mock_storage_client, mock_braze): @@ -89,7 +89,7 @@ def test_migration_failure(mock_storage_client, mock_braze, sample_df): mock_client.bucket.return_value = mock_bucket mock_storage_client.return_value = mock_client - mock_braze.migrate_external_id.side_effect = Exception("fail!") + mock_braze.interface.migrate_external_id.side_effect = Exception("fail!") cmd = Command() cmd.stdout = mock.Mock() cmd.style = mock.Mock() @@ -124,7 +124,7 @@ def test_start_timestamp_filtering(mock_storage_client, mock_braze): project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp="2024-01-01T00:00:00", chunk_size=2 ) expected_chunk = [{"current_external_id": "id2", "new_external_id": "token2"}] - mock_braze.migrate_external_id.assert_called_once_with(expected_chunk) + mock_braze.interface.migrate_external_id.assert_called_once_with(expected_chunk) def test_empty_parquet_file(mock_storage_client, mock_braze): @@ -143,7 +143,7 @@ def test_empty_parquet_file(mock_storage_client, mock_braze): cmd.process_and_migrate_parquet_file( project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 ) - mock_braze.migrate_external_id.assert_not_called() + mock_braze.interface.migrate_external_id.assert_not_called() def test_chunking_behavior(mock_storage_client, mock_braze): @@ -163,7 +163,7 @@ def test_chunking_behavior(mock_storage_client, mock_braze): project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 ) # Should be called 3 times: 2, 2, 1 - assert mock_braze.migrate_external_id.call_count == 3 + assert mock_braze.interface.migrate_external_id.call_count == 3 all_calls = [call.args[0] for call in mock_braze.migrate_external_id.call_args_list] assert all(len(chunk) <= 2 for chunk in all_calls) diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py index 6c94f711..cb3b10ac 100644 --- a/basket/news/tests/test_tasks.py +++ b/basket/news/tests/test_tasks.py @@ -563,7 +563,7 @@ def test_delete_ctms_not_found_succeeds(self, mock_ctms): @patch("basket.news.tasks.braze") def test_send_tx_message(mock_braze, metricsmock): send_tx_message("test@example.com", "download-foo", "en-US") - mock_braze.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) + mock_braze.interface.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:download-foo", "language:en-US"]) @@ -573,7 +573,7 @@ def test_send_tx_messages(mock_model, mock_braze, metricsmock): """Test multipe message IDs, but only one is a transactional message.""" mock_model.side_effect = [BrazeTxEmailMessage(message_id="download-foo", language="en-US"), None] send_tx_messages("test@example.com", "en-US", ["newsletter", "download-foo"]) - mock_braze.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) + mock_braze.interface.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:download-foo", "language:en-US"]) @@ -584,7 +584,7 @@ def test_send_tx_messages_with_map(mock_model, mock_braze, metricsmock): """Test multipe message IDs, but only one is a transactional message.""" mock_model.side_effect = [BrazeTxEmailMessage(message_id="download-foo", language="en-US"), None] send_tx_messages("test@example.com", "en-US", ["newsletter", "download-foo"]) - mock_braze.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) + mock_braze.interface.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:download-foo", "language:en-US"]) @@ -593,7 +593,7 @@ def test_send_tx_messages_with_map(mock_model, mock_braze, metricsmock): def test_send_confirm_message(mock_get_message, mock_braze, metricsmock): mock_get_message.return_value = BrazeTxEmailMessage(message_id="newsletter-confirm-fx", language="en-US") send_confirm_message("test@example.com", "abc123", "en", "fx", "fed654") - mock_braze.track_user.assert_called_once_with( + mock_braze.interface.track_user.assert_called_once_with( "test@example.com", event="send-newsletter-confirm-fx-en-US", user_data={"basket_token": "abc123", "email_id": "fed654"} ) metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:newsletter-confirm-fx", "language:en-US"]) @@ -604,7 +604,7 @@ def test_send_confirm_message(mock_get_message, mock_braze, metricsmock): def test_send_recovery_message(mock_get_message, mock_braze, metricsmock): mock_get_message.return_value = BrazeTxEmailMessage(message_id="newsletter-confirm-fx", language="en-US") send_recovery_message("test@example.com", "abc123", "en", "fed654") - mock_braze.track_user.assert_called_once_with( + mock_braze.interface.track_user.assert_called_once_with( "test@example.com", event="send-newsletter-confirm-fx-en-US", user_data={"basket_token": "abc123", "email_id": "fed654"} ) metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:newsletter-confirm-fx", "language:en-US"]) From 8dcbb40c3f38de4b79c92112632e2e79dc8195b4 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:29:45 -0300 Subject: [PATCH 061/137] Fix closure issue in subscribe handler --- basket/news/views.py | 45 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/basket/news/views.py b/basket/news/views.py index fa2a98e7..21df60ba 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -357,24 +357,8 @@ def common_voice_goals(request): @require_POST @csrf_exempt def subscribe(request): - data = request.POST.dict() - newsletters = data.get("newsletters", None) - if not newsletters: - return HttpResponseJSON( - { - "status": "error", - "desc": "newsletters is missing", - "code": errors.BASKET_USAGE_ERROR, - }, - 400, - ) - - email = data.pop("email", None) - token = data.pop("token", None) - def handler( - email, - token, + request, use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, @@ -382,6 +366,21 @@ def handler( pre_generated_token=None, pre_generated_email_id=None, ): + data = request.POST.dict() + newsletters = data.get("newsletters", None) + if not newsletters: + return HttpResponseJSON( + { + "status": "error", + "desc": "newsletters is missing", + "code": errors.BASKET_USAGE_ERROR, + }, + 400, + ) + + email = data.pop("email", None) + token = data.pop("token", None) + if extra_metrics_tags is None: extra_metrics_tags = [] @@ -471,8 +470,7 @@ def handler( if settings.BRAZE_PARALLEL_WRITE_ENABLE: try: handler( - email, - token, + request, use_braze_backend=True, should_send_tx_messages=False, rate_limit_increment=False, @@ -484,8 +482,7 @@ def handler( sentry_sdk.capture_exception() return handler( - email, - token, + request, use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, @@ -494,8 +491,7 @@ def handler( ) elif settings.BRAZE_ONLY_WRITE_ENABLE: return handler( - email, - token, + request, use_braze_backend=True, should_send_tx_messages=True, rate_limit_increment=True, @@ -505,8 +501,7 @@ def handler( ) else: return handler( - email, - token, + request, use_braze_backend=False, should_send_tx_messages=True, rate_limit_increment=True, From 7d20c9e0e69ab1ca2f07db9aa217f40ee33b2189 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:05:11 -0300 Subject: [PATCH 062/137] add todo --- basket/news/backends/braze.py | 1 + 1 file changed, 1 insertion(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 1a30428b..80ba0a0d 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -334,6 +334,7 @@ def delete(self, email): email_id = data["users"][0].get("external_id") self.interface.delete_user(email) # return in list of email_id to match CTMS.delete + # TODO also return fxa_id once it's added as an alias return [{"email_id": email_id}] def from_vendor(self, braze_user_data, subscription_groups): From 4c29c19e2c745ef99c34d10d781a5c81c328f63d Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:05:18 -0300 Subject: [PATCH 063/137] fix mock --- basket/news/tests/test_braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 5f793052..ea128d54 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -310,7 +310,7 @@ def test_braze_exception_500(braze_client): mock_braze_user_subscription_groups = [ {"id": "234c9b4a-1785-4cd5-b839-5dbc134982eb", "status": "Subscribed"}, - {"id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "status": "Not Subscribed"}, + {"id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "status": "Unsubscribed"}, ] mock_newsletters = { From 97b9267f9877f2b2d5ec3e48f00f3700bd19c0ae Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Wed, 29 Oct 2025 09:10:19 -0700 Subject: [PATCH 064/137] Add untested fxa alias in braze add --- basket/news/backends/braze.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 7a64145b..994d83c8 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -338,11 +338,15 @@ def get( return self.from_vendor(user_data, subscriptions) def add(self, data): + fxa_id = data.get("fxa_id") custom_attributes = {"_update_existing_only": False} # If we don't have an `email_id`, we need to submit the user alias. if not data.get("email_id"): custom_attributes["user_alias"] = {"alias_name": data.email, "alias_label": "email"} + # If we have an `fxa_id` we need to create an alias for it + if fxa_id: + custom_attributes["user_alias"] = {"alias_name": fxa_id, "alias_label": "fxa"} braze_user_data = self.to_vendor(None, data, custom_attributes) self.interface.save_user(braze_user_data) From b4a1a619fc95fb23b1a0752ddce4cfe5d4a91ec6 Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Wed, 29 Oct 2025 10:42:13 -0700 Subject: [PATCH 065/137] Use none for default endpoint --- .../commands/push_message_to_queue.py | 18 ------------------ basket/settings.py | 2 +- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/basket/news/management/commands/push_message_to_queue.py b/basket/news/management/commands/push_message_to_queue.py index 81287141..88d6b843 100644 --- a/basket/news/management/commands/push_message_to_queue.py +++ b/basket/news/management/commands/push_message_to_queue.py @@ -1,28 +1,10 @@ import json -import logging from django.conf import settings from django.core.management import BaseCommand import boto3 -from basket.news.tasks import ( - fxa_delete, - fxa_email_changed, - fxa_login, - fxa_newsletters_update, - fxa_verified, -) - -FXA_EVENT_TYPES = { - "delete": fxa_delete, - "login": fxa_login, - "newsletters-update": fxa_newsletters_update, - "primaryEmailChanged": fxa_email_changed, - "verified": fxa_verified, -} -log = logging.getLogger(__name__) - class Command(BaseCommand): def add_arguments(self, parser): diff --git a/basket/settings.py b/basket/settings.py index 31fe6ef4..b618c21e 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -427,7 +427,7 @@ def before_send(event, hint): FXA_EVENTS_QUEUE_URL = config("FXA_EVENTS_QUEUE_URL", default="") FXA_EVENTS_QUEUE_WAIT_TIME = config("FXA_EVENTS_QUEUE_WAIT_TIME", parser=int, default="10") FXA_EVENTS_SNITCH_ID = config("FXA_EVENTS_SNITCH_ID", default="") -FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default="") +FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default=None) # stage or production # https://github.com/mozilla/PyFxA/blob/main/fxa/constants.py From 5ac3a4da2454cc310fff546cdc17728d0d89cbd0 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:47:48 -0300 Subject: [PATCH 066/137] default to empty string (None errors out) --- basket/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/settings.py b/basket/settings.py index b5dc06fe..260192af 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -433,7 +433,7 @@ def before_send(event, hint): FXA_EVENTS_QUEUE_URL = config("FXA_EVENTS_QUEUE_URL", default="") FXA_EVENTS_QUEUE_WAIT_TIME = config("FXA_EVENTS_QUEUE_WAIT_TIME", parser=int, default="10") FXA_EVENTS_SNITCH_ID = config("FXA_EVENTS_SNITCH_ID", default="") -FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default=None) +FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default="") or None # stage or production # https://github.com/mozilla/PyFxA/blob/main/fxa/constants.py From 6c7db8fc74553b40cf7d55846ed3a7a694f7eee4 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:21:06 -0300 Subject: [PATCH 067/137] remove email alias, add fxa_id --- basket/news/backends/braze.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 671c1b96..e692e06d 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -325,6 +325,7 @@ def get( "first_name", "language", "last_name", + "user_aliases", ], token, ) @@ -339,11 +340,6 @@ def get( def add(self, data): custom_attributes = {"_update_existing_only": False} - - # If we don't have an `email_id`, we need to submit the user alias. - if not data.get("email_id"): - custom_attributes["user_alias"] = {"alias_name": data["email"], "alias_label": "email"} - braze_user_data = self.to_vendor(None, data, custom_attributes) self.interface.save_user(braze_user_data) return {"email": {"email_id": data.get("email_id")}} @@ -385,6 +381,8 @@ def from_vendor(self, braze_user_data, subscription_groups): """ user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] + user_aliases = braze_user_data.get("user_aliases", []) + fxa_id = next((user_alias for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) subscription_ids = [subscription["id"] for subscription in (subscription_groups or []) if subscription["status"] == "Subscribed"] newsletter_slugs = list(filter(None, map(vendor_id_to_slug, subscription_ids))) @@ -407,7 +405,7 @@ def from_vendor(self, braze_user_data, subscription_groups): "fxa_lang": user_attributes.get("fxa_lang"), "fxa_primary_email": user_attributes.get("fxa_primary_email"), "fxa_create_date": user_attributes.get("fxa_created_at") if user_attributes.get("has_fxa") else None, - # TODO: missing field: fxa_id + "fxa_id": fxa_id, } return basket_user_data @@ -451,7 +449,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=N "fxa_first_service": updated_user_data.get("fxa_service"), "fxa_lang": updated_user_data.get("fxa_lang"), "fxa_primary_email": updated_user_data.get("fxa_primary_email"), - # TODO: missing field: fxa_id + "fxa_id": updated_user_data.get("fxa_id"), } ], } From 61f3ade66840b2e46847f357f532062a47d15207 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:41:59 -0300 Subject: [PATCH 068/137] remove custom attributes from to_vendor, no longer necessary --- basket/news/backends/braze.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index e692e06d..f4ab3651 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -339,8 +339,7 @@ def get( return self.from_vendor(user_data, subscriptions) def add(self, data): - custom_attributes = {"_update_existing_only": False} - braze_user_data = self.to_vendor(None, data, custom_attributes) + braze_user_data = self.to_vendor(None, data) self.interface.save_user(braze_user_data) return {"email": {"email_id": data.get("email_id")}} @@ -410,7 +409,7 @@ def from_vendor(self, braze_user_data, subscription_groups): return basket_user_data - def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=None, events=None): + def to_vendor(self, basket_user_data=None, update_data=None, events=None): existing_user_data = basket_user_data or {} updated_user_data = existing_user_data | (update_data or {}) @@ -434,7 +433,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=N "external_id": updated_user_data.get("email_id"), # TODO: conditional on migration status config (could be basket token instead) "email": updated_user_data.get("email"), "update_timestamp": now, - "_update_existing_only": True, + "_update_existing_only": bool(existing_user_data), "email_subscribe": "opted_in" if updated_user_data.get("optin") else "unsubscribed" if updated_user_data.get("optout") else "subscribed", "subscription_groups": subscription_groups, "user_attributes_v1": [ @@ -464,7 +463,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, custom_attributes=N if (last_name := updated_user_data.get("last_name")) != existing_user_data.get("last_name"): user_attributes["last_name"] = last_name - braze_data = {"attributes": [user_attributes | (custom_attributes or {})]} + braze_data = {"attributes": [user_attributes]} if events: braze_data["events"] = events From 556cec2e0c053f8d8cff31ec30c67e995f2d2478 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:42:05 -0300 Subject: [PATCH 069/137] fix bug --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index f4ab3651..0820d8c1 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -381,7 +381,7 @@ def from_vendor(self, braze_user_data, subscription_groups): user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] user_aliases = braze_user_data.get("user_aliases", []) - fxa_id = next((user_alias for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) + fxa_id = next((user_alias["alias_name"] for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) subscription_ids = [subscription["id"] for subscription in (subscription_groups or []) if subscription["status"] == "Subscribed"] newsletter_slugs = list(filter(None, map(vendor_id_to_slug, subscription_ids))) From ec58f1a696b61df0742be59b6a4535ff6ec260eb Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 29 Oct 2025 16:42:27 -0300 Subject: [PATCH 070/137] update tests --- basket/news/tests/test_braze.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index e8159e94..4f35e1a5 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -298,6 +298,7 @@ def test_braze_exception_500(braze_client): "fxa_lang": "en", "fxa_primary_email": "test2@example.com", "fxa_create_date": "2022-01-02", + "fxa_id": "fxa_123", } mock_braze_user_data = { @@ -309,6 +310,7 @@ def test_braze_exception_500(braze_client): "country": "US", "language": "en", "email_subscribe": "opted_in", + "user_aliases": [{"alias_name": "fxa_123", "alias_label": "fxa_id"}], "custom_attributes": { "user_attributes_v1": [ { @@ -322,6 +324,7 @@ def test_braze_exception_500(braze_client): "fxa_primary_email": "test2@example.com", "fxa_created_at": "2022-01-02", "has_fxa": True, + "fxa_id": "fxa_123", } ] }, @@ -390,6 +393,7 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock "updated_at": { "$time": dt.isoformat(), }, + "fxa_id": "fxa_123", } ], } @@ -414,7 +418,7 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock expected = { "attributes": [ { - "_update_existing_only": True, + "_update_existing_only": False, "email": "test@example.com", "external_id": "123", "email_subscribe": "subscribed", @@ -438,6 +442,7 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock "updated_at": { "$time": dt.isoformat(), }, + "fxa_id": None, } ], } @@ -489,6 +494,7 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "updated_at": { "$time": dt.isoformat(), }, + "fxa_id": "fxa_123", } ], } @@ -506,7 +512,7 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "basket.news.newsletters.newsletter_languages", return_value=["en"], ) -def test_to_vendor_with_custom_attributes_and_events(mock_newsletters, braze_client): +def test_to_vendor_with_events(mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() events = [ @@ -519,7 +525,7 @@ def test_to_vendor_with_custom_attributes_and_events(mock_newsletters, braze_cli expected = { "attributes": [ { - "_update_existing_only": False, + "_update_existing_only": True, "email": "test@example.com", "external_id": "123", "email_subscribe": "opted_in", @@ -541,6 +547,7 @@ def test_to_vendor_with_custom_attributes_and_events(mock_newsletters, braze_cli "updated_at": { "$time": dt.isoformat(), }, + "fxa_id": "fxa_123", } ], } @@ -548,12 +555,7 @@ def test_to_vendor_with_custom_attributes_and_events(mock_newsletters, braze_cli "events": events, } with freeze_time(dt): - assert ( - braze_instance.to_vendor( - basket_user_data=mock_basket_user_data, update_data=None, custom_attributes={"_update_existing_only": False}, events=events - ) - == expected - ) + assert braze_instance.to_vendor(basket_user_data=mock_basket_user_data, update_data=None, events=events) == expected @mock.patch( @@ -585,6 +587,7 @@ def test_braze_get(mock_newsletters, braze_client): "first_name", "language", "last_name", + "user_aliases", ], "user_aliases": [ { @@ -615,7 +618,7 @@ def test_braze_add(mock_newsletters, braze_client): with freeze_time(): response = braze_instance.add(new_user) assert response == expected - assert m.last_request.json() == braze_instance.to_vendor(None, new_user, {"_update_existing_only": False}) + assert m.last_request.json() == braze_instance.to_vendor(None, new_user) @mock.patch( From 0c84bb9de1c7f34d5baddb0876d64fe22959e0b2 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:54:36 -0300 Subject: [PATCH 071/137] Fallback to CTMS when looking up by token and no user found --- basket/news/api.py | 20 +++++++++++++------- basket/news/views.py | 16 +++++++++++++++- basket/settings.py | 3 +-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/basket/news/api.py b/basket/news/api.py index cb58354a..243d8233 100644 --- a/basket/news/api.py +++ b/basket/news/api.py @@ -83,7 +83,7 @@ def confirm_user(request, token: uuid.UUID): if settings.MAINTENANCE_MODE and not settings.MAINTENANCE_READ_ONLY: return _maintenance_error() - if settings.BRAZE_PARALLEL_WRITE_ENABLE and settings.BRAZE_TOKEN_MIGRATION_COMPLETE: + if settings.BRAZE_PARALLEL_WRITE_ENABLE: tasks.confirm_user.delay( str(token), use_braze_backend=True, @@ -93,7 +93,7 @@ def confirm_user(request, token: uuid.UUID): str(token), use_braze_backend=False, ) - elif settings.BRAZE_ONLY_WRITE_ENABLE and settings.BRAZE_TOKEN_MIGRATION_COMPLETE: + elif settings.BRAZE_ONLY_WRITE_ENABLE: tasks.confirm_user.delay( str(token), use_braze_backend=True, @@ -205,11 +205,8 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non if not email: return _invalid_email() - # We can't do a look up by token until migration is complete. - braze_unable_to_serve = token and not settings.BRAZE_TOKEN_MIGRATION_COMPLETE - try: - if settings.BRAZE_READ_WITH_FALLBACK_ENABLE and not braze_unable_to_serve: + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: try: user_data = get_user_data( email=email, @@ -217,6 +214,15 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non masked=masked, use_braze_backend=True, ) + # If token migration isn't complete we might only find the user + # in CTMS when looking up by token. + if not user_data: + user_data = get_user_data( + email=email, + token=token, + masked=masked, + use_braze_backend=False, + ) except Exception: sentry_sdk.capture_exception() user_data = get_user_data( @@ -225,7 +231,7 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non masked=masked, use_braze_backend=False, ) - elif settings.BRAZE_ONLY_READ_ENABLE and not braze_unable_to_serve: + elif settings.BRAZE_ONLY_READ_ENABLE: user_data = get_user_data( email=email, token=token, diff --git a/basket/news/views.py b/basket/news/views.py index 21df60ba..c3749650 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -668,7 +668,12 @@ def user(request, token): if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: try: - return get_user(token, masked=masked, use_braze_backend=True) + response = get_user(token, masked=masked, use_braze_backend=True) + # If token migration isn't complete we might only find the user + # in CTMS when looking up by token. + if response.status_code == 404: + return get_user(token, masked=masked, use_braze_backend=False) + return response except Exception: sentry_sdk.capture_exception() return get_user(token, masked=masked, use_braze_backend=False) @@ -870,6 +875,15 @@ def lookup_user(request): masked=not authorized, use_braze_backend=True, ) + # If token migration isn't complete we might only find the user + # in CTMS when looking up by token. + if not user_data: + user_data = get_user_data( + token=token, + email=email, + masked=not authorized, + use_braze_backend=False, + ) except Exception: sentry_sdk.capture_exception() user_data = get_user_data( diff --git a/basket/settings.py b/basket/settings.py index b5dc06fe..60734ccc 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -224,7 +224,6 @@ def path(*args): BRAZE_ONLY_WRITE_ENABLE = config("BRAZE_ONLY_WRITE_ENABLE", parser=bool, default="false") BRAZE_READ_WITH_FALLBACK_ENABLE = config("BRAZE_READ_WITH_FALLBACK_ENABLE", parser=bool, default="false") BRAZE_ONLY_READ_ENABLE = config("BRAZE_ONLY_READ_ENABLE", parser=bool, default="false") -BRAZE_TOKEN_MIGRATION_COMPLETE = config("BRAZE_TOKEN_MIGRATION_COMPLETE", parser=bool, default="false") # Mozilla CTMS CTMS_ENV = config("CTMS_ENV", default="").lower() @@ -433,7 +432,7 @@ def before_send(event, hint): FXA_EVENTS_QUEUE_URL = config("FXA_EVENTS_QUEUE_URL", default="") FXA_EVENTS_QUEUE_WAIT_TIME = config("FXA_EVENTS_QUEUE_WAIT_TIME", parser=int, default="10") FXA_EVENTS_SNITCH_ID = config("FXA_EVENTS_SNITCH_ID", default="") -FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default=None) +FXA_EVENTS_ENDPOINT_URL = config("FXA_EVENTS_ENDPOINT_URL", default="") or None # stage or production # https://github.com/mozilla/PyFxA/blob/main/fxa/constants.py From a4bd01bfead27c9be25ae73b652cf002a083b909 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:05:36 -0300 Subject: [PATCH 072/137] remove outdated comment --- basket/news/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 01394a14..0867bd63 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -80,7 +80,6 @@ def fxa_email_changed(data, use_braze_backend=False): backend_data = data.copy() contact = None if use_braze_backend: - # This doesn't return the user??? What do we do here? contact = braze.add(backend_data) else: contact = ctms.add(backend_data) From 402ee691db4a9f8afd3feddfdeb5672a73c44475 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:06:45 -0300 Subject: [PATCH 073/137] update external id logic, add fxa id alias call --- basket/news/backends/braze.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 0820d8c1..25706b42 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -53,6 +53,7 @@ class BrazeEndpoint(Enum): USERS_DELETE = "/users/delete" SUBSCRIPTION_USER_STATUS = "/subscription/user/status" USERS_MIGRATE_EXTERNAL_ID = "/users/external_ids/rename" + USERS_ADD_ALIAS = "/users/alias/new" class BrazeInterface: @@ -261,6 +262,14 @@ def save_user(self, braze_user_data): """ return self._request(BrazeEndpoint.USERS_TRACK, braze_user_data) + def add_fxa_id_alias(self, external_id, fxa_id): + """ + Adds the fxa_id user alias to an existing user in Braze. + https://www.braze.com/docs/api/endpoints/user_data/post_user_alias + """ + data = {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": external_id}]} + return self._request(BrazeEndpoint.USERS_ADD_ALIAS, data) + def migrate_external_id(self, migrations): """ Migrate a user's external_id to a new value. 50 rename objects per request is the hard Braze limit. @@ -340,8 +349,13 @@ def get( def add(self, data): braze_user_data = self.to_vendor(None, data) + external_id = braze_user_data["external_id"] self.interface.save_user(braze_user_data) - return {"email": {"email_id": data.get("email_id")}} + + if data.get("fxa_id"): + self.interface.add_fxa_id_alias(external_id, data["fxa_id"]) + + return {"email": {"email_id": external_id}} def update(self, existing_data, update_data): braze_user_data = self.to_vendor(existing_data, update_data) @@ -388,7 +402,7 @@ def from_vendor(self, braze_user_data, subscription_groups): basket_user_data = { "email": braze_user_data["email"], - "email_id": braze_user_data["external_id"], # TODO: conditional on migration status config (could be basket token instead) + "email_id": braze_user_data["external_id"], "id": braze_user_data["braze_id"], "first_name": braze_user_data.get("first_name"), "last_name": braze_user_data.get("last_name"), @@ -417,6 +431,10 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): country = process_country(updated_user_data.get("country")) language = process_lang(updated_user_data.get("lang")) + external_id = ( + updated_user_data.get("token") if not existing_user_data and settings.BRAZE_ONLY_WRITE_ENABLE else updated_user_data.get("email_id") + ) + subscription_groups = [] if update_data and isinstance(update_data.get("newsletters"), dict): for slug, is_subscribed in update_data["newsletters"].items(): @@ -430,7 +448,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): ) user_attributes = { - "external_id": updated_user_data.get("email_id"), # TODO: conditional on migration status config (could be basket token instead) + "external_id": external_id, "email": updated_user_data.get("email"), "update_timestamp": now, "_update_existing_only": bool(existing_user_data), From 089496df915d8ba42996383300cf0f660e9626c4 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:11:32 -0300 Subject: [PATCH 074/137] add alias step to braze.update --- basket/news/backends/braze.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 25706b42..dec18974 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -361,6 +361,9 @@ def update(self, existing_data, update_data): braze_user_data = self.to_vendor(existing_data, update_data) self.interface.save_user(braze_user_data) + if update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: + self.interface.add_fxa_id_alias(braze_user_data.get("external_id"), update_data["fxa_id"]) + def update_by_alt_id(self, alt_id_name, alt_id_value, update_data): raise NotImplementedError From a9911213d2b744510854502916ec8fb8c38ff7fa Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:36:37 -0300 Subject: [PATCH 075/137] add get_fxa_id_from_aliases method, return fxa_id with braze.delete, fix has_fxa and user attributes --- basket/news/backends/braze.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index dec18974..fe86b27e 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -381,15 +381,15 @@ def delete(self, email): @return: deleted user data if successful @raises: BrazeUserNotFoundByEmailError """ - data = self.interface.export_users(email=email, fields_to_export=["external_id"]) + data = self.interface.export_users(email=email, fields_to_export=["external_id", "user_aliases"]) if not data["users"]: raise BrazeUserNotFoundByEmailError email_id = data["users"][0].get("external_id") + fxa_id = self.get_fxa_id_from_aliases(data["users"][0].get("user_aliases")) self.interface.delete_user(email) - # return in list of email_id to match CTMS.delete - # TODO also return fxa_id once it's added as an alias - return [{"email_id": email_id}] + # return in list of {email_id, fxa_id} to match CTMS.delete + return [{"email_id": email_id, "fxa_id": fxa_id}] def from_vendor(self, braze_user_data, subscription_groups): """ @@ -397,8 +397,6 @@ def from_vendor(self, braze_user_data, subscription_groups): """ user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] - user_aliases = braze_user_data.get("user_aliases", []) - fxa_id = next((user_alias["alias_name"] for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) subscription_ids = [subscription["id"] for subscription in (subscription_groups or []) if subscription["status"] == "Subscribed"] newsletter_slugs = list(filter(None, map(vendor_id_to_slug, subscription_ids))) @@ -421,7 +419,8 @@ def from_vendor(self, braze_user_data, subscription_groups): "fxa_lang": user_attributes.get("fxa_lang"), "fxa_primary_email": user_attributes.get("fxa_primary_email"), "fxa_create_date": user_attributes.get("fxa_created_at") if user_attributes.get("has_fxa") else None, - "fxa_id": fxa_id, + "has_fxa": user_attributes.get("has_fxa"), + "fxa_id": self.get_fxa_id_from_aliases(braze_user_data.get("user_aliases")), } return basket_user_data @@ -464,12 +463,11 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): "email_lang": language, "mailing_country": country, "updated_at": {"$time": now}, - "has_fxa": bool(updated_user_data.get("fxa_create_date")), + "has_fxa": updated_user_data.get("has_fxa", bool(updated_user_data.get("fxa_id"))), "fxa_created_at": updated_user_data.get("fxa_create_date"), "fxa_first_service": updated_user_data.get("fxa_service"), "fxa_lang": updated_user_data.get("fxa_lang"), "fxa_primary_email": updated_user_data.get("fxa_primary_email"), - "fxa_id": updated_user_data.get("fxa_id"), } ], } @@ -491,5 +489,9 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): return braze_data + def get_fxa_id_from_aliases(user_aliases): + if user_aliases: + return next((user_alias["alias_name"] for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) + braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) From a1bd545fe4fd0e8ad1f247ed8c6ecd09bc7af948 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:53:17 -0300 Subject: [PATCH 076/137] fix has_fxa for braze users --- basket/news/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/utils.py b/basket/news/utils.py index d6db2d1f..5211907e 100644 --- a/basket/news/utils.py +++ b/basket/news/utils.py @@ -335,7 +335,7 @@ def get_user_data( allowed = set(ALLOWED_USER_FIELDS + extra_fields) user = {fn: backend_user[fn] for fn in allowed if fn in backend_user} - user["has_fxa"] = bool(backend_user.get("fxa_id")) + user["has_fxa"] = backend_user.get("has_fxa", False) if use_braze_backend else bool(backend_user.get("fxa_id")) if masked: # mask all emails From 7d4ef23829f8bd891e0974cc49174bf77f103ae2 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:01:59 -0300 Subject: [PATCH 077/137] handle empty strings --- basket/news/backends/braze.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index fe86b27e..3550ff40 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -430,8 +430,8 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): updated_user_data = existing_user_data | (update_data or {}) now = timezone.now().isoformat() - country = process_country(updated_user_data.get("country")) - language = process_lang(updated_user_data.get("lang")) + country = process_country(updated_user_data.get("country") or None) + language = process_lang(updated_user_data.get("lang") or None) external_id = ( updated_user_data.get("token") if not existing_user_data and settings.BRAZE_ONLY_WRITE_ENABLE else updated_user_data.get("email_id") @@ -473,9 +473,9 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): } # Country, language, first and last name are billable data points. Only update them when necessary. - if country != process_country(existing_user_data.get("country")): + if country != process_country(existing_user_data.get("country") or None): user_attributes["country"] = country - if language != process_lang(existing_user_data.get("language")): + if language != process_lang(existing_user_data.get("language") or None): user_attributes["language"] = language if (first_name := updated_user_data.get("first_name")) != existing_user_data.get("first_name"): user_attributes["first_name"] = first_name From cec851508369bb57a7667d8ea163535fae74d919 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:10:36 -0300 Subject: [PATCH 078/137] remove unnecessary flag --- basket/news/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 0867bd63..36a032e8 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -65,7 +65,7 @@ def fxa_email_changed(data, use_braze_backend=False): # FxA record not found, try email user_data = get_user_data(email=email, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) if user_data: - if use_braze_backend and settings.BRAZE_FXA_MIGRATION_COMPLETE: + if use_braze_backend: braze.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) else: ctms.update(user_data, {"fxa_id": fxa_id, "fxa_primary_email": email}) @@ -96,7 +96,7 @@ def fxa_direct_update_contact(fxa_id, data, use_braze_backend=False): Ignore if contact with FxA ID can't be found """ try: - if use_braze_backend and settings.BRAZE_FXA_MIGRATION_COMPLETE: + if use_braze_backend: braze.update_by_alt_id("fxa_id", fxa_id, data, use_braze_backend) else: ctms.update_by_alt_id("fxa_id", fxa_id, data) From 90b562d573c78a1315e4d7c09e26c9b47ce975b4 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:13:20 -0300 Subject: [PATCH 079/137] fix has_fxa based on fxa_id update --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 3550ff40..5e170242 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -463,7 +463,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): "email_lang": language, "mailing_country": country, "updated_at": {"$time": now}, - "has_fxa": updated_user_data.get("has_fxa", bool(updated_user_data.get("fxa_id"))), + "has_fxa": bool(updated_user_data.get("fxa_id")) or updated_user_data.get("has_fxa", False), "fxa_created_at": updated_user_data.get("fxa_create_date"), "fxa_first_service": updated_user_data.get("fxa_service"), "fxa_lang": updated_user_data.get("fxa_lang"), From 18dd116440027ba4061b777dd72a1fadb51d997a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:22:43 -0300 Subject: [PATCH 080/137] fix typo --- basket/news/management/commands/process_fxa_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/management/commands/process_fxa_queue.py b/basket/news/management/commands/process_fxa_queue.py index ec76f270..ccf54cd6 100644 --- a/basket/news/management/commands/process_fxa_queue.py +++ b/basket/news/management/commands/process_fxa_queue.py @@ -101,7 +101,7 @@ def handle(self, *args, **options): continue try: - if settings.BRAZE.PARALLEL_WRITE_ENABLE: + if settings.BRAZE_PARALLEL_WRITE_ENABLE: FXA_EVENT_TYPES[event_type].delay(event, True) FXA_EVENT_TYPES[event_type].delay(event) elif settings.BRAZE_ONLY_WRITE_ENABLE: From ddaac4e26dfc560958c8a6149101b1d9af7d7136 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:27:39 -0300 Subject: [PATCH 081/137] add fxa_id to braze get and export_users --- basket/news/backends/braze.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 5e170242..070901c4 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -180,7 +180,7 @@ def track_user(self, email, event=None, user_data=None): return self._request(BrazeEndpoint.USERS_TRACK, data) - def export_users(self, email, fields_to_export=None, external_id=None): + def export_users(self, email, fields_to_export=None, external_id=None, fxa_id=None): """ Export user profile by identifier. @@ -200,6 +200,9 @@ def export_users(self, email, fields_to_export=None, external_id=None): if fields_to_export: data["fields_to_export"] = fields_to_export + if fxa_id: + data["user_aliases"].append({"alias_name": fxa_id, "alias_label": "fxa_id"}) + return self._request(BrazeEndpoint.USERS_EXPORT_IDS, data) def delete_user(self, email): @@ -337,6 +340,7 @@ def get( "user_aliases", ], token, + fxa_id, ) if user_response["users"]: From 64d966145413968525ee28acc7c6d8dcc11b6f8f Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:35:02 -0300 Subject: [PATCH 082/137] remove braze.update_by_token --- basket/news/backends/braze.py | 3 --- basket/news/tasks.py | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 070901c4..ba473d9c 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -374,9 +374,6 @@ def update_by_alt_id(self, alt_id_name, alt_id_value, update_data): def update_by_fxa_id(self, fxa_id, update_data): raise NotImplementedError - def update_by_token(self, token, update_data): - raise NotImplementedError - def delete(self, email): """ Delete the user matching the email diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 36a032e8..e8de8dbd 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -191,8 +191,8 @@ def fxa_login(data, use_braze_backend=False): @rq_task def update_user_meta(token, data, use_braze_backend=False): """Update a user's metadata, not newsletters""" - if use_braze_backend: - braze.update_by_token(token, data) + if use_braze_backend and settings.BRAZE_ONLY_WRITE_ENABLE: + braze.update({"email_id": token}, data) else: try: ctms.update_by_alt_id("token", token, data) From 166a53663c872990600bf83df76450e4adbeff4c Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:46:38 -0300 Subject: [PATCH 083/137] remove pre_generated_email_id, that settings flag will only be true post migration --- basket/news/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/basket/news/views.py b/basket/news/views.py index c3749650..67eb8516 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -496,8 +496,6 @@ def handler( should_send_tx_messages=True, rate_limit_increment=True, extra_metrics_tags=["backend:braze"], - # After the external_id migration we can stop passing in email_id here. - pre_generated_email_id=pre_generated_email_id, ) else: return handler( From 3718b14c896f2348d5641e672b82d9dd08634b04 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:59:35 -0300 Subject: [PATCH 084/137] add update_by_fxa_id method, delete braze.update_by_alt_id --- basket/news/backends/braze.py | 12 ++++++++---- basket/news/tasks.py | 6 +++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index ba473d9c..30a99f9b 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -42,6 +42,10 @@ class BrazeUserNotFoundByEmailError(Exception): pass +class BrazeUserNotFoundByFxaIdError(Exception): + pass + + class BrazeClientError(Exception): pass # any other error @@ -368,11 +372,11 @@ def update(self, existing_data, update_data): if update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: self.interface.add_fxa_id_alias(braze_user_data.get("external_id"), update_data["fxa_id"]) - def update_by_alt_id(self, alt_id_name, alt_id_value, update_data): - raise NotImplementedError - def update_by_fxa_id(self, fxa_id, update_data): - raise NotImplementedError + existing_user = self.get(fxa_id=fxa_id) + if not existing_user: + raise BrazeUserNotFoundByFxaIdError + self.update(existing_user, update_data) def delete(self, email): """ diff --git a/basket/news/tasks.py b/basket/news/tasks.py index e8de8dbd..c0f0588b 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -8,7 +8,7 @@ from basket.base.decorators import rq_task from basket.base.exceptions import BasketError from basket.base.utils import email_is_testing -from basket.news.backends.braze import braze +from basket.news.backends.braze import BrazeUserNotFoundByFxaIdError, braze from basket.news.backends.ctms import ( CTMSNotFoundByAltIDError, CTMSUniqueIDConflictError, @@ -97,10 +97,10 @@ def fxa_direct_update_contact(fxa_id, data, use_braze_backend=False): """ try: if use_braze_backend: - braze.update_by_alt_id("fxa_id", fxa_id, data, use_braze_backend) + braze.update_by_fxa_id(fxa_id, data) else: ctms.update_by_alt_id("fxa_id", fxa_id, data) - except CTMSNotFoundByAltIDError: + except (CTMSNotFoundByAltIDError, BrazeUserNotFoundByFxaIdError): # No associated record found, skip this update. pass From a87be70fdde5f63e8f098919143f3e867fdc15d5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:10:33 -0300 Subject: [PATCH 085/137] don't call add_fxa_id_alias if there's no external id --- basket/news/backends/braze.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 30a99f9b..81d1f145 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -360,17 +360,18 @@ def add(self, data): external_id = braze_user_data["external_id"] self.interface.save_user(braze_user_data) - if data.get("fxa_id"): + if external_id and data.get("fxa_id"): self.interface.add_fxa_id_alias(external_id, data["fxa_id"]) return {"email": {"email_id": external_id}} def update(self, existing_data, update_data): braze_user_data = self.to_vendor(existing_data, update_data) + external_id = braze_user_data["external_id"] self.interface.save_user(braze_user_data) - if update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: - self.interface.add_fxa_id_alias(braze_user_data.get("external_id"), update_data["fxa_id"]) + if external_id and update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: + self.interface.add_fxa_id_alias(external_id, update_data["fxa_id"]) def update_by_fxa_id(self, fxa_id, update_data): existing_user = self.get(fxa_id=fxa_id) From cebd9ffcc0e73265ea05543eb9ee63c5955fce02 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:27:49 -0300 Subject: [PATCH 086/137] fix external id --- basket/news/backends/braze.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 81d1f145..72eff313 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -357,7 +357,7 @@ def get( def add(self, data): braze_user_data = self.to_vendor(None, data) - external_id = braze_user_data["external_id"] + external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) if external_id and data.get("fxa_id"): @@ -367,7 +367,7 @@ def add(self, data): def update(self, existing_data, update_data): braze_user_data = self.to_vendor(existing_data, update_data) - external_id = braze_user_data["external_id"] + external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) if external_id and update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: From 0e5625fc9d81915b25953d034831c8e928df5564 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:33:17 -0300 Subject: [PATCH 087/137] fix language comparison --- basket/news/backends/braze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 72eff313..a8a3bc8f 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -481,7 +481,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): # Country, language, first and last name are billable data points. Only update them when necessary. if country != process_country(existing_user_data.get("country") or None): user_attributes["country"] = country - if language != process_lang(existing_user_data.get("language") or None): + if not existing_user_data or language != process_lang(existing_user_data.get("language") or None): user_attributes["language"] = language if (first_name := updated_user_data.get("first_name")) != existing_user_data.get("first_name"): user_attributes["first_name"] = first_name From fe80c65785d2ef30878927a72f3b85f1795be241 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:58:57 -0300 Subject: [PATCH 088/137] fix export users and fxa_id lookup --- basket/news/backends/braze.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index a8a3bc8f..a875a8dc 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -193,10 +193,7 @@ def export_users(self, email, fields_to_export=None, external_id=None, fxa_id=No If alias is not found, returns empty "users" list. """ - data = { - "user_aliases": [{"alias_name": email, "alias_label": "email"}], - "email_address": email, - } + data = {"email_address": email, "user_aliases": []} if external_id: data["external_ids"] = [external_id] @@ -207,6 +204,9 @@ def export_users(self, email, fields_to_export=None, external_id=None, fxa_id=No if fxa_id: data["user_aliases"].append({"alias_name": fxa_id, "alias_label": "fxa_id"}) + if email: + (data["user_aliases"].append({"alias_name": email, "alias_label": "email"}),) + return self._request(BrazeEndpoint.USERS_EXPORT_IDS, data) def delete_user(self, email): @@ -392,7 +392,9 @@ def delete(self, email): raise BrazeUserNotFoundByEmailError email_id = data["users"][0].get("external_id") - fxa_id = self.get_fxa_id_from_aliases(data["users"][0].get("user_aliases")) + user_aliases = data["users"][0].get("user_aliases", []) + fxa_id = next((user_alias["alias_name"] for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) + self.interface.delete_user(email) # return in list of {email_id, fxa_id} to match CTMS.delete return [{"email_id": email_id, "fxa_id": fxa_id}] @@ -403,9 +405,10 @@ def from_vendor(self, braze_user_data, subscription_groups): """ user_attributes = braze_user_data.get("custom_attributes", {}).get("user_attributes_v1", [{}])[0] - + user_aliases = braze_user_data.get("user_aliases", []) subscription_ids = [subscription["id"] for subscription in (subscription_groups or []) if subscription["status"] == "Subscribed"] newsletter_slugs = list(filter(None, map(vendor_id_to_slug, subscription_ids))) + fxa_id = next((user_alias["alias_name"] for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) basket_user_data = { "email": braze_user_data["email"], @@ -426,7 +429,7 @@ def from_vendor(self, braze_user_data, subscription_groups): "fxa_primary_email": user_attributes.get("fxa_primary_email"), "fxa_create_date": user_attributes.get("fxa_created_at") if user_attributes.get("has_fxa") else None, "has_fxa": user_attributes.get("has_fxa"), - "fxa_id": self.get_fxa_id_from_aliases(braze_user_data.get("user_aliases")), + "fxa_id": fxa_id, } return basket_user_data @@ -495,9 +498,5 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): return braze_data - def get_fxa_id_from_aliases(user_aliases): - if user_aliases: - return next((user_alias["alias_name"] for user_alias in user_aliases if user_alias.get("alias_label") == "fxa_id"), None) - braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) From 7265e93b224d436bb9fe19736d7a96f4597fed39 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:20:09 -0300 Subject: [PATCH 089/137] add conditional --- basket/news/backends/braze.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index a875a8dc..cc976d96 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -349,9 +349,12 @@ def get( if user_response["users"]: user_data = user_response["users"][0] + user_email = email or user_data.get("email") + subscriptions = [] - subscription_response = self.interface.get_user_subscriptions(user_data["external_id"], email) - subscriptions = subscription_response.get("users", [{}])[0].get("subscription_groups", []) + if user_data.get("external_id") and user_email: + subscription_response = self.interface.get_user_subscriptions(user_data["external_id"], email) + subscriptions = subscription_response.get("users", [{}])[0].get("subscription_groups", []) return self.from_vendor(user_data, subscriptions) From e6244543f2ed9e6663704a734d48c075fc7a810a Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:03:28 -0300 Subject: [PATCH 090/137] Pre-generate tokens/email_ids for fxa tasks --- .../management/commands/process_fxa_queue.py | 30 +++++++-- basket/news/tasks.py | 64 ++++++++++++++++--- basket/news/views.py | 5 ++ 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/basket/news/management/commands/process_fxa_queue.py b/basket/news/management/commands/process_fxa_queue.py index ccf54cd6..7a128d56 100644 --- a/basket/news/management/commands/process_fxa_queue.py +++ b/basket/news/management/commands/process_fxa_queue.py @@ -18,6 +18,7 @@ fxa_newsletters_update, fxa_verified, ) +from basket.news.utils import generate_token FXA_EVENT_TYPES = { "delete": fxa_delete, @@ -102,12 +103,33 @@ def handle(self, *args, **options): try: if settings.BRAZE_PARALLEL_WRITE_ENABLE: - FXA_EVENT_TYPES[event_type].delay(event, True) - FXA_EVENT_TYPES[event_type].delay(event) + pre_generated_token = generate_token() + pre_generated_email_id = generate_token() + print(pre_generated_email_id) + FXA_EVENT_TYPES[event_type].delay( + event, + use_braze_backend=True, + should_send_tx_messages=False, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) + FXA_EVENT_TYPES[event_type].delay( + event, + use_braze_backend=False, + should_send_tx_messages=True, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) elif settings.BRAZE_ONLY_WRITE_ENABLE: - FXA_EVENT_TYPES[event_type].delay(event, True) + FXA_EVENT_TYPES[event_type].delay( + event, + use_braze_backend=True, + ) else: - FXA_EVENT_TYPES[event_type].delay(event) + FXA_EVENT_TYPES[event_type].delay( + event, + use_braze_backend=False, + ) except Exception: # something's wrong with the queue. try again. metrics.incr("fxa.events.message", tags=["info:queue_error", f"event:{event_type}"]) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index c0f0588b..39cd536f 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -44,7 +44,13 @@ def fxa_source_url(metrics): @rq_task -def fxa_email_changed(data, use_braze_backend=False): +def fxa_email_changed( + data, + use_braze_backend=False, + pre_generated_token=None, + pre_generated_email_id=None, + **kwargs, +): ts = data["ts"] fxa_id = data["uid"] email = data["email"] @@ -72,8 +78,9 @@ def fxa_email_changed(data, use_braze_backend=False): else: # No matching record for Email or FxA ID. Create one. data = { + "email_id": pre_generated_email_id, "email": email, - "token": generate_token(), + "token": pre_generated_token or generate_token(), "fxa_id": fxa_id, "fxa_primary_email": email, } @@ -106,12 +113,18 @@ def fxa_direct_update_contact(fxa_id, data, use_braze_backend=False): @rq_task -def fxa_delete(data, use_braze_backend=False): +def fxa_delete(data, use_braze_backend=False, **kwargs): fxa_direct_update_contact(data["uid"], {"fxa_deleted": True}, use_braze_backend) @rq_task -def fxa_verified(data, use_braze_backend=False): +def fxa_verified( + data, + use_braze_backend=False, + should_send_tx_messages=True, + pre_generated_token=None, + pre_generated_email_id=None, +): """Add new FxA users""" # if we're not using the sandbox ignore testing domains if email_is_testing(data["email"]): @@ -147,11 +160,25 @@ def fxa_verified(data, use_braze_backend=False): if not (user_data and user_data.get("lang")): new_data["lang"] = lang - upsert_contact(SUBSCRIBE, new_data, user_data, use_braze_backend) + upsert_contact( + SUBSCRIBE, + new_data, + user_data, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) @rq_task -def fxa_newsletters_update(data, use_braze_backend=False): +def fxa_newsletters_update( + data, + use_braze_backend=False, + should_send_tx_messages=True, + pre_generated_token=None, + pre_generated_email_id=None, +): email = data["email"] fxa_id = data["uid"] new_data = { @@ -163,11 +190,25 @@ def fxa_newsletters_update(data, use_braze_backend=False): "fxa_id": fxa_id, "optin": True, } - upsert_contact(SUBSCRIBE, new_data, get_fxa_user_data(fxa_id, email), use_braze_backend) + upsert_contact( + SUBSCRIBE, + new_data, + get_fxa_user_data(fxa_id, email), + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, + ) @rq_task -def fxa_login(data, use_braze_backend=False): +def fxa_login( + data, + use_braze_backend=False, + should_send_tx_messages=True, + pre_generated_token=None, + pre_generated_email_id=None, +): email = data["email"] # if we're not using the sandbox ignore testing domains if email_is_testing(email): @@ -184,7 +225,10 @@ def fxa_login(data, use_braze_backend=False): "source_url": fxa_source_url(metrics_context), "country": data.get("countryCode", ""), }, - use_braze_backend, + use_braze_backend=use_braze_backend, + should_send_tx_messages=should_send_tx_messages, + pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, ) @@ -324,7 +368,7 @@ def upsert_contact( # no user found. create new one. token = update_data["token"] = pre_generated_token or generate_token() - update_data["email_id"] = update_data.get("email_id") or pre_generated_email_id + update_data["email_id"] = update_data.get("email_id") or pre_generated_email_id or generate_token() if settings.MAINTENANCE_MODE: if use_braze_backend: diff --git a/basket/news/views.py b/basket/news/views.py index 67eb8516..97c643ec 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -145,6 +145,7 @@ def handler( should_send_tx_messages=True, extra_metrics_tags=None, pre_generated_token=None, + pre_generated_email_id=None, ): if extra_metrics_tags is None: extra_metrics_tags = [] @@ -186,6 +187,7 @@ def handler( use_braze_backend=use_braze_backend, should_send_tx_messages=should_send_tx_messages, pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, )[0] except Exception: metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact", *extra_metrics_tags]) @@ -198,6 +200,7 @@ def handler( if settings.BRAZE_PARALLEL_WRITE_ENABLE: pre_generated_token = generate_token() + pre_generated_email_id = generate_token() try: handler( email, @@ -206,6 +209,7 @@ def handler( should_send_tx_messages=False, extra_metrics_tags=["backend:braze"], pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, ) except Exception: sentry_sdk.capture_exception() @@ -216,6 +220,7 @@ def handler( use_braze_backend=False, should_send_tx_messages=True, pre_generated_token=pre_generated_token, + pre_generated_email_id=pre_generated_email_id, ) elif settings.BRAZE_ONLY_WRITE_ENABLE: return handler( From 2af9512757cf3231e666a430c550b0da9e69bc20 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:25:46 -0300 Subject: [PATCH 091/137] Fix test_tasks and test_upsert_user tests --- basket/news/backends/braze.py | 3 +- basket/news/tasks.py | 8 +++-- basket/news/tests/test_tasks.py | 46 ++++++++++++++++++--------- basket/news/tests/test_upsert_user.py | 7 ---- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index cc976d96..38dd2a56 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -2,6 +2,7 @@ import warnings from enum import Enum from urllib.parse import urljoin, urlparse, urlunparse +from uuid import uuid4 from django.conf import settings from django.utils import timezone @@ -360,7 +361,7 @@ def get( def add(self, data): braze_user_data = self.to_vendor(None, data) - external_id = braze_user_data["attributes"][0]["external_id"] + external_id = braze_user_data["attributes"][0]["external_id"] or str(uuid4()) self.interface.save_user(braze_user_data) if external_id and data.get("fxa_id"): diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 39cd536f..e131e603 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -78,12 +78,14 @@ def fxa_email_changed( else: # No matching record for Email or FxA ID. Create one. data = { - "email_id": pre_generated_email_id, "email": email, "token": pre_generated_token or generate_token(), "fxa_id": fxa_id, "fxa_primary_email": email, } + if pre_generated_email_id: + data["email_id"] = pre_generated_email_id + backend_data = data.copy() contact = None if use_braze_backend: @@ -368,7 +370,9 @@ def upsert_contact( # no user found. create new one. token = update_data["token"] = pre_generated_token or generate_token() - update_data["email_id"] = update_data.get("email_id") or pre_generated_email_id or generate_token() + + if pre_generated_email_id: + update_data["email_id"] = pre_generated_email_id if settings.MAINTENANCE_MODE: if use_braze_backend: diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py index cb3b10ac..dca75595 100644 --- a/basket/news/tests/test_tasks.py +++ b/basket/news/tests/test_tasks.py @@ -78,7 +78,7 @@ def test_success(self, fxa_data_mock, upsert_mock): "service": "sync", } fxa_verified(data) - upsert_mock.assert_called_with( + upsert_mock.assert_called_with_subset( SUBSCRIBE, { "email": data["email"], @@ -103,7 +103,7 @@ def test_with_newsletters(self, fxa_data_mock, upsert_mock): "service": "sync", } fxa_verified(data) - upsert_mock.assert_called_with( + upsert_mock.assert_called_with_subset( SUBSCRIBE, { "email": data["email"], @@ -134,7 +134,7 @@ def test_with_subscribe_and_metrics(self, fxa_data_mock, upsert_mock): "countryCode": "DE", } fxa_verified(data) - upsert_mock.assert_called_with( + upsert_mock.assert_called_with_subset( SUBSCRIBE, { "email": data["email"], @@ -160,7 +160,7 @@ def test_with_createDate(self, fxa_data_mock, upsert_mock): "locale": "en-US,en", } fxa_verified(data) - upsert_mock.assert_called_with( + upsert_mock.assert_called_with_subset( SUBSCRIBE, { "email": data["email"], @@ -218,7 +218,7 @@ def test_fxa_login_task_with_no_utm(self, upsert_mock): def test_fxa_login_task_with_utm_data(self, upsert_mock): data = self.get_data() fxa_login(data) - upsert_mock.delay.assert_called_with( + upsert_mock.delay.assert_called_with_subset( SUBSCRIBE, { "email": "the.dude@example.com", @@ -294,8 +294,8 @@ def test_fxa_id_not_found(self, cache_mock, gud_mock, ctms_mock): fxa_email_changed(data) gud_mock.assert_has_calls( [ - call(fxa_id=data["uid"], extra_fields=["id", "email_id"]), - call(email=data["email"], extra_fields=["id", "email_id"]), + call(fxa_id=data["uid"], extra_fields=["id", "email_id"], use_braze_backend=False), + call(email=data["email"], extra_fields=["id", "email_id"], use_braze_backend=False), ], ) ctms_mock.update.assert_called_with( @@ -316,8 +316,8 @@ def test_fxa_id_nor_email_found(self, cache_mock, gud_mock, ctms_mock): fxa_email_changed(data) gud_mock.assert_has_calls( [ - call(fxa_id=data["uid"], extra_fields=["id", "email_id"]), - call(email=data["email"], extra_fields=["id", "email_id"]), + call(fxa_id=data["uid"], extra_fields=["id", "email_id"], use_braze_backend=False), + call(email=data["email"], extra_fields=["id", "email_id"], use_braze_backend=False), ], ) ctms_mock.update.assert_not_called() @@ -347,8 +347,8 @@ def test_fxa_id_nor_email_found_ctms_add_fails( fxa_email_changed(data) gud_mock.assert_has_calls( [ - call(fxa_id=data["uid"], extra_fields=["id", "email_id"]), - call(email=data["email"], extra_fields=["id", "email_id"]), + call(fxa_id=data["uid"], extra_fields=["id", "email_id"], use_braze_backend=False), + call(email=data["email"], extra_fields=["id", "email_id"], use_braze_backend=False), ], ) ctms_mock.update.assert_not_called() @@ -496,7 +496,11 @@ def test_found_by_fxa_id_email_match(self, mock_gud, mock_ctms): fxa_user_data = get_fxa_user_data("123", "test@example.com") assert user_data == fxa_user_data - mock_gud.assert_called_once_with(fxa_id="123", extra_fields=["id", "email_id"]) + mock_gud.assert_called_once_with( + fxa_id="123", + extra_fields=["id", "email_id"], + use_braze_backend=False, + ) mock_ctms.update.assert_not_called() def test_found_by_fxa_id_email_mismatch(self, mock_gud, mock_ctms): @@ -512,7 +516,11 @@ def test_found_by_fxa_id_email_mismatch(self, mock_gud, mock_ctms): fxa_user_data = get_fxa_user_data("123", "fxa@example.com") assert user_data == fxa_user_data - mock_gud.assert_called_once_with(fxa_id="123", extra_fields=["id", "email_id"]) + mock_gud.assert_called_once_with( + fxa_id="123", + extra_fields=["id", "email_id"], + use_braze_backend=False, + ) mock_ctms.update.assert_called_once_with( user_data, {"fxa_primary_email": "fxa@example.com"}, @@ -531,8 +539,16 @@ def test_miss_by_fxa_id(self, mock_gud, mock_ctms): assert user_data == fxa_user_data assert mock_gud.call_count == 2 - mock_gud.assert_any_call(fxa_id="123", extra_fields=["id", "email_id"]) - mock_gud.assert_called_with(email="test@example.com", extra_fields=["id", "email_id"]) + mock_gud.assert_any_call( + fxa_id="123", + extra_fields=["id", "email_id"], + use_braze_backend=False, + ) + mock_gud.assert_called_with( + email="test@example.com", + extra_fields=["id", "email_id"], + use_braze_backend=False, + ) mock_ctms.update.assert_not_called() diff --git a/basket/news/tests/test_upsert_user.py b/basket/news/tests/test_upsert_user.py index a646ea53..b441d32b 100644 --- a/basket/news/tests/test_upsert_user.py +++ b/basket/news/tests/test_upsert_user.py @@ -51,7 +51,6 @@ def test_update_first_last_names( "first_name": "The", "last_name": "Dude", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -262,7 +261,6 @@ def test_send_confirm(self, get_user_mock, ctms_mock, confirm_mock): "lang": "en", "newsletters": "slug", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -291,7 +289,6 @@ def test_send_fx_confirm(self, get_user_mock, ctms_mock, confirm_mock): "lang": "en", "newsletters": "slug", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -329,7 +326,6 @@ def test_send_moz_confirm(self, get_user_mock, ctms_mock, confirm_mock): "lang": "en", "newsletters": "slug,slug2", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -365,7 +361,6 @@ def test_no_send_confirm_newsletter( "lang": "en", "newsletters": "slug", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -499,7 +494,6 @@ def test_new_subscription_with_ctms_conflict( "lang": "en", "newsletters": "slug", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() @@ -533,7 +527,6 @@ def test_new_user_subscribes_to_mofo_newsletter( "lang": "en", "newsletters": "mozilla-foundation", "email": self.email, - "email_id": None, } upsert_user(SUBSCRIBE, data) update_data = data.copy() From e772ca0003ac07305ccea5834ff807ac2256d70d Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:37:10 -0300 Subject: [PATCH 092/137] Fix braze tests --- basket/news/tests/test_braze.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 4f35e1a5..677cd538 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -299,6 +299,7 @@ def test_braze_exception_500(braze_client): "fxa_primary_email": "test2@example.com", "fxa_create_date": "2022-01-02", "fxa_id": "fxa_123", + "has_fxa": True, } mock_braze_user_data = { @@ -393,7 +394,6 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock "updated_at": { "$time": dt.isoformat(), }, - "fxa_id": "fxa_123", } ], } @@ -421,6 +421,7 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock "_update_existing_only": False, "email": "test@example.com", "external_id": "123", + "language": "en", "email_subscribe": "subscribed", "subscription_groups": [ {"subscription_group_id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "subscription_state": "subscribed"}, @@ -442,7 +443,6 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock "updated_at": { "$time": dt.isoformat(), }, - "fxa_id": None, } ], } @@ -494,7 +494,6 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "updated_at": { "$time": dt.isoformat(), }, - "fxa_id": "fxa_123", } ], } @@ -547,7 +546,6 @@ def test_to_vendor_with_events(mock_newsletters, braze_client): "updated_at": { "$time": dt.isoformat(), }, - "fxa_id": "fxa_123", } ], } @@ -642,7 +640,7 @@ def test_braze_update(mock_newsletter_languages, mock_newsletters, braze_client) def test_braze_delete(braze_client): braze_instance = Braze(braze_client) email = mock_braze_user_data["email"] - expected = [{"email_id": mock_braze_user_data["external_id"]}] + expected = [{"email_id": mock_braze_user_data["external_id"], "fxa_id": None}] with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/export/ids", json={"users": [{"external_id": mock_braze_user_data["external_id"]}]}) From f10ccce957b83bd602f684e76e99b8b14306bb76 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:12:16 -0400 Subject: [PATCH 093/137] Migrate email_id to token after user creation when in parallel mode --- basket/news/backends/braze.py | 11 +++++++++++ basket/news/tests/test_braze.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 38dd2a56..b143e1e8 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -367,6 +367,17 @@ def add(self, data): if external_id and data.get("fxa_id"): self.interface.add_fxa_id_alias(external_id, data["fxa_id"]) + token = data.get("token") + if external_id and token and settings.BRAZE_PARALLEL_WRITE_ENABLE: + self.interface.migrate_external_id( + [ + { + "current_external_id": external_id, + "new_external_id": token, + } + ] + ) + return {"email": {"email_id": external_id}} def update(self, existing_data, update_data): diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 677cd538..acfbac6b 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -1,6 +1,7 @@ from collections import namedtuple from unittest import mock +from django.test.utils import override_settings from django.utils import timezone import pytest @@ -619,6 +620,37 @@ def test_braze_add(mock_newsletters, braze_client): assert m.last_request.json() == braze_instance.to_vendor(None, new_user) +@override_settings(BRAZE_PARALLEL_WRITE_ENABLE=True) +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +def test_braze_add_migrates_external_id(mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + new_user = { + "email": "test@example.com", + "email_id": "123", + "token": "abc", + "newsletters": {"foo-news": True}, + "country": "US", + } + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + m.register_uri( + "POST", + "http://test.com/users/external_ids/rename", + json={ + "message": "success", + "external_ids": ["abc"], + }, + ) + braze_instance.add(new_user) + + # Assert the rename endpoint was called + rename_calls = [call for call in m.request_history if call.url == "http://test.com/users/external_ids/rename"] + assert len(rename_calls) == 1 + + @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, From b5c5c450aafcac01be4497f5205db9caba5ce498 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:38:08 -0400 Subject: [PATCH 094/137] handle missing external id in to_vendor --- basket/news/backends/braze.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index b143e1e8..7f9bb3c0 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -361,14 +361,14 @@ def get( def add(self, data): braze_user_data = self.to_vendor(None, data) - external_id = braze_user_data["attributes"][0]["external_id"] or str(uuid4()) + external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) - if external_id and data.get("fxa_id"): + if data.get("fxa_id"): self.interface.add_fxa_id_alias(external_id, data["fxa_id"]) token = data.get("token") - if external_id and token and settings.BRAZE_PARALLEL_WRITE_ENABLE: + if token and settings.BRAZE_PARALLEL_WRITE_ENABLE: self.interface.migrate_external_id( [ { @@ -461,6 +461,9 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): updated_user_data.get("token") if not existing_user_data and settings.BRAZE_ONLY_WRITE_ENABLE else updated_user_data.get("email_id") ) + if not external_id and not existing_user_data: + external_id = str(uuid4()) + subscription_groups = [] if update_data and isinstance(update_data.get("newsletters"), dict): for slug, is_subscribed in update_data["newsletters"].items(): From 0db5ec2f3748c6ad9c62347e146f8aa20f357fe8 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:59:46 -0400 Subject: [PATCH 095/137] Remove uuid generation in braze.add --- basket/news/backends/braze.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index b143e1e8..f43091b4 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -2,7 +2,6 @@ import warnings from enum import Enum from urllib.parse import urljoin, urlparse, urlunparse -from uuid import uuid4 from django.conf import settings from django.utils import timezone @@ -361,7 +360,7 @@ def get( def add(self, data): braze_user_data = self.to_vendor(None, data) - external_id = braze_user_data["attributes"][0]["external_id"] or str(uuid4()) + external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) if external_id and data.get("fxa_id"): From 8966b40987de1f572c87742c4d2573f6d6a70075 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:22:47 -0400 Subject: [PATCH 096/137] throw error in to_vendor if external_id is missing --- basket/news/backends/braze.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 289dcfbe..b0ec2307 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -8,6 +8,7 @@ import requests +from basket.base.exceptions import BasketError from basket.base.utils import is_valid_uuid from basket.news.backends.ctms import process_country, process_lang from basket.news.newsletters import slug_to_vendor_id, vendor_id_to_slug @@ -384,7 +385,7 @@ def update(self, existing_data, update_data): external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) - if external_id and update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: + if update_data.get("fxa_id") and existing_data.get("fxa_id") != update_data["fxa_id"]: self.interface.add_fxa_id_alias(external_id, update_data["fxa_id"]) def update_by_fxa_id(self, fxa_id, update_data): @@ -460,8 +461,8 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): updated_user_data.get("token") if not existing_user_data and settings.BRAZE_ONLY_WRITE_ENABLE else updated_user_data.get("email_id") ) - if not external_id and not existing_user_data: - external_id = str(uuid4()) + if not external_id: + raise BasketError("Missing Braze external_id") subscription_groups = [] if update_data and isinstance(update_data.get("newsletters"), dict): From e683eed8dbe1c45afe9aea7b1fba079efe07694a Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:41:51 -0400 Subject: [PATCH 097/137] Remove print --- basket/news/management/commands/process_fxa_queue.py | 1 - 1 file changed, 1 deletion(-) diff --git a/basket/news/management/commands/process_fxa_queue.py b/basket/news/management/commands/process_fxa_queue.py index 7a128d56..52489469 100644 --- a/basket/news/management/commands/process_fxa_queue.py +++ b/basket/news/management/commands/process_fxa_queue.py @@ -105,7 +105,6 @@ def handle(self, *args, **options): if settings.BRAZE_PARALLEL_WRITE_ENABLE: pre_generated_token = generate_token() pre_generated_email_id = generate_token() - print(pre_generated_email_id) FXA_EVENT_TYPES[event_type].delay( event, use_braze_backend=True, From 8e1da9758b8bf371bc69be5a1bffcb0fbc623f6c Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:52:08 -0400 Subject: [PATCH 098/137] change error type --- basket/news/backends/braze.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index b0ec2307..4f9fab9f 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -8,7 +8,6 @@ import requests -from basket.base.exceptions import BasketError from basket.base.utils import is_valid_uuid from basket.news.backends.ctms import process_country, process_lang from basket.news.newsletters import slug_to_vendor_id, vendor_id_to_slug @@ -462,7 +461,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): ) if not external_id: - raise BasketError("Missing Braze external_id") + raise ValueError("Missing Braze external_id") subscription_groups = [] if update_data and isinstance(update_data.get("newsletters"), dict): From dabc9c0dd89de02050751b66f10e4a07cef89e66 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:52:47 -0400 Subject: [PATCH 099/137] update failing tests, add external id to_vendor tests --- basket/news/tests/test_braze.py | 64 ++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index acfbac6b..a48337c2 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -404,6 +404,7 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock assert braze_instance.to_vendor(mock_basket_user_data) == expected +@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, @@ -453,6 +454,66 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock assert braze_instance.to_vendor(None, update_data) == expected +@override_settings(BRAZE_ONLY_WRITE_ENABLE=True) +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_to_vendor_with_updates_and_no_user_data_in_braze_only_write(mock_newsletter_languages, mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + dt = timezone.now() + update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123"} + expected = { + "attributes": [ + { + "_update_existing_only": False, + "email": "test@example.com", + "external_id": "abc", + "language": "en", + "email_subscribe": "subscribed", + "subscription_groups": [ + {"subscription_group_id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "subscription_state": "subscribed"}, + ], + "update_timestamp": dt.isoformat(), + "user_attributes_v1": [ + { + "email_lang": "en", + "created_at": { + "$time": dt.isoformat(), + }, + "basket_token": "abc", + "fxa_first_service": None, + "fxa_lang": None, + "fxa_primary_email": None, + "fxa_created_at": None, + "has_fxa": False, + "mailing_country": None, + "updated_at": { + "$time": dt.isoformat(), + }, + } + ], + } + ] + } + with freeze_time(dt): + assert braze_instance.to_vendor(None, update_data) == expected + + +def test_to_vendor_throws_exception_for_missing_external_id(braze_client): + braze_instance = Braze(braze_client) + update_data = { + "newsletters": {"bar-news": True}, + "email": "test@example.com", + } + with pytest.raises(ValueError): + braze_instance.to_vendor(None, update_data) + + @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, @@ -598,6 +659,7 @@ def test_braze_get(mock_newsletters, braze_client): assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123&email=test%40example.com" +@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, @@ -607,7 +669,7 @@ def test_braze_add(mock_newsletters, braze_client): new_user = { "email": "test@example.com", "email_id": "123", - "basket_token": "abc", + "token": "abc", "newsletters": {"foo-news": True}, "country": "US", } From 4ebae40507ed4af46a9de856908d58e0510140f5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:04:15 -0400 Subject: [PATCH 100/137] add fxa_deleted --- basket/news/backends/braze.py | 2 ++ basket/news/tests/test_braze.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 4f9fab9f..42bec2eb 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -444,6 +444,7 @@ def from_vendor(self, braze_user_data, subscription_groups): "fxa_create_date": user_attributes.get("fxa_created_at") if user_attributes.get("has_fxa") else None, "has_fxa": user_attributes.get("has_fxa"), "fxa_id": fxa_id, + "fxa_deleted": user_attributes.get("fxa_deleted"), } return basket_user_data @@ -494,6 +495,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): "fxa_first_service": updated_user_data.get("fxa_service"), "fxa_lang": updated_user_data.get("fxa_lang"), "fxa_primary_email": updated_user_data.get("fxa_primary_email"), + "fxa_deleted": updated_user_data.get("fxa_deleted"), } ], } diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index a48337c2..1939eedc 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -301,6 +301,7 @@ def test_braze_exception_500(braze_client): "fxa_create_date": "2022-01-02", "fxa_id": "fxa_123", "has_fxa": True, + "fxa_deleted": None, } mock_braze_user_data = { @@ -327,6 +328,7 @@ def test_braze_exception_500(braze_client): "fxa_created_at": "2022-01-02", "has_fxa": True, "fxa_id": "fxa_123", + "fxa_deleted": None, } ] }, @@ -391,6 +393,7 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock "fxa_lang": "en", "fxa_primary_email": "test2@example.com", "fxa_created_at": "2022-01-02", + "fxa_deleted": None, "has_fxa": True, "updated_at": { "$time": dt.isoformat(), @@ -441,6 +444,7 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock "fxa_primary_email": None, "fxa_created_at": None, "has_fxa": False, + "fxa_deleted": None, "mailing_country": None, "updated_at": { "$time": dt.isoformat(), @@ -491,6 +495,7 @@ def test_to_vendor_with_updates_and_no_user_data_in_braze_only_write(mock_newsle "fxa_primary_email": None, "fxa_created_at": None, "has_fxa": False, + "fxa_deleted": None, "mailing_country": None, "updated_at": { "$time": dt.isoformat(), @@ -553,6 +558,7 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "fxa_primary_email": "test2@example.com", "fxa_created_at": "2022-01-02", "has_fxa": True, + "fxa_deleted": None, "updated_at": { "$time": dt.isoformat(), }, @@ -604,6 +610,7 @@ def test_to_vendor_with_events(mock_newsletters, braze_client): "fxa_lang": "en", "fxa_primary_email": "test2@example.com", "fxa_created_at": "2022-01-02", + "fxa_deleted": None, "has_fxa": True, "updated_at": { "$time": dt.isoformat(), From 25e196aee0f670e49c85b74b3124632cb6445367 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:50:44 -0400 Subject: [PATCH 101/137] add test for fxa id alias --- basket/news/tests/test_braze.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 1939eedc..adac3460 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -230,6 +230,17 @@ def test_braze_delete_user(braze_client): assert m.last_request.json() == expected +def test_braze_add_fxa_id_alias(braze_client): + external_id = "abc" + fxa_id = "123" + expected = {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": external_id}]} + + with requests_mock.mock() as m: + m.register_uri("POST", "/users/alias/new", json={}) + braze_client.add_fxa_id_alias(external_id, fxa_id) + assert m.last_request.json() == expected + + def test_braze_exception_400(braze_client): with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/track", status_code=400, json={}) From ec69d561c5fec9b200d77c2f1b41b201d87cbd8a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:25:22 -0400 Subject: [PATCH 102/137] add missing time wrapper for fxa_created_at, remove incorrectly mocked fxa_id (should not be saved in user attributes) --- basket/news/backends/braze.py | 2 +- basket/news/tests/test_braze.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 42bec2eb..21bb87fd 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -491,7 +491,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): "mailing_country": country, "updated_at": {"$time": now}, "has_fxa": bool(updated_user_data.get("fxa_id")) or updated_user_data.get("has_fxa", False), - "fxa_created_at": updated_user_data.get("fxa_create_date"), + "fxa_created_at": {"$time": fxa_create_date} if (fxa_create_date := updated_user_data.get("fxa_create_date")) else None, "fxa_first_service": updated_user_data.get("fxa_service"), "fxa_lang": updated_user_data.get("fxa_lang"), "fxa_primary_email": updated_user_data.get("fxa_primary_email"), diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index adac3460..c2040201 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -338,7 +338,6 @@ def test_braze_exception_500(braze_client): "fxa_primary_email": "test2@example.com", "fxa_created_at": "2022-01-02", "has_fxa": True, - "fxa_id": "fxa_123", "fxa_deleted": None, } ] @@ -403,7 +402,7 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock "fxa_first_service": "test", "fxa_lang": "en", "fxa_primary_email": "test2@example.com", - "fxa_created_at": "2022-01-02", + "fxa_created_at": {"$time": "2022-01-02"}, "fxa_deleted": None, "has_fxa": True, "updated_at": { @@ -567,7 +566,9 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "fxa_first_service": "test", "fxa_lang": "en", "fxa_primary_email": "test2@example.com", - "fxa_created_at": "2022-01-02", + "fxa_created_at": { + "$time": "2022-01-02", + }, "has_fxa": True, "fxa_deleted": None, "updated_at": { @@ -620,7 +621,7 @@ def test_to_vendor_with_events(mock_newsletters, braze_client): "fxa_first_service": "test", "fxa_lang": "en", "fxa_primary_email": "test2@example.com", - "fxa_created_at": "2022-01-02", + "fxa_created_at": {"$time": "2022-01-02"}, "fxa_deleted": None, "has_fxa": True, "updated_at": { From cd53521edb30369b1d2298f16f84b930b5cf5cc3 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:31:38 -0400 Subject: [PATCH 103/137] add tests for update_by_fxa_id --- basket/news/tests/test_braze.py | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index c2040201..4dd6e02e 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -763,3 +763,44 @@ def test_braze_delete(braze_client): assert api_requests[0].url == "http://test.com/users/export/ids" assert api_requests[1].url == "http://test.com/users/delete" assert response == expected + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_braze_update_by_fxa_id_for_existing_user(mock_newsletter_languages, mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + fxa_id = mock_basket_user_data["fxa_id"] + update_data = {"fxa_deleted": True} + + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/export/ids", json={"users": [mock_braze_user_data]}) + m.register_uri( + "GET", + "http://test.com/subscription/user/status?external_id=123", + json={"users": [{"subscription_groups": mock_braze_user_subscription_groups}]}, + ) + m.register_uri("POST", "http://test.com/users/track", json={}) + with freeze_time(): + braze_instance.update_by_fxa_id(fxa_id, update_data) + api_requests = m.request_history + assert api_requests[0].url == "http://test.com/users/export/ids" + assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123" + assert api_requests[2].url == "http://test.com/users/track" + assert api_requests[2].json() == braze_instance.to_vendor(mock_basket_user_data, update_data) + + +def test_braze_update_by_fxa_id_user_not_found(braze_client): + braze_instance = Braze(braze_client) + fxa_id = "000_none" + update_data = {"fxa_deleted": True} + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/export/ids", json={"users": []}) + with pytest.raises(braze.BrazeUserNotFoundByFxaIdError): + braze_instance.update_by_fxa_id(fxa_id, update_data) + assert m.last_request.url == "http://test.com/users/export/ids" From ff2b29e990c54b9d73d449948e4b0bd751b1ae7a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:34:41 -0400 Subject: [PATCH 104/137] add fxa_deleted to a to_vendor test --- basket/news/tests/test_braze.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 4dd6e02e..80bd76c0 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -540,7 +540,7 @@ def test_to_vendor_throws_exception_for_missing_external_id(braze_client): def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() - update_data = {"newsletters": {"bar-news": True, "foo-news": False}, "first_name": "Foo", "country": "CA", "optin": False} + update_data = {"newsletters": {"bar-news": True, "foo-news": False}, "first_name": "Foo", "country": "CA", "optin": False, "fxa_deleted": True} expected = { "attributes": [ { @@ -570,7 +570,7 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "$time": "2022-01-02", }, "has_fxa": True, - "fxa_deleted": None, + "fxa_deleted": True, "updated_at": { "$time": dt.isoformat(), }, From 680a303d7d4716b3e9945e7e500e17c5c3738008 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:23:01 -0400 Subject: [PATCH 105/137] add tests for followup alias calls in braze add and update --- basket/news/tests/test_braze.py | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 80bd76c0..4fb2c31a 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -701,6 +701,30 @@ def test_braze_add(mock_newsletters, braze_client): assert m.last_request.json() == braze_instance.to_vendor(None, new_user) +@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +def test_braze_add_with_fxa_id(mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + fxa_id = "fxa123" + new_user = {"email": "test@example.com", "email_id": "123", "token": "abc", "newsletters": {"foo-news": True}, "country": "US", "fxa_id": fxa_id} + + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + m.register_uri("POST", "/users/alias/new", json={}) + expected = {"email": {"email_id": new_user["email_id"]}} + with freeze_time(): + response = braze_instance.add(new_user) + api_requests = m.request_history + assert response == expected + assert api_requests[0].url == "http://test.com/users/track" + assert api_requests[0].json() == braze_instance.to_vendor(None, new_user) + assert api_requests[1].url == "http://test.com/users/alias/new" + assert api_requests[1].json() == {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": "123"}]} + + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=True) @mock.patch( "basket.news.newsletters._newsletters", @@ -750,6 +774,31 @@ def test_braze_update(mock_newsletter_languages, mock_newsletters, braze_client) assert m.last_request.json() == braze_instance.to_vendor(mock_basket_user_data, update_data) +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_braze_update_with_fxa_id_change(mock_newsletter_languages, mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + update_data = {"country": "CA", "fxa_id": "new_fxa_id"} + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + m.register_uri("POST", "/users/alias/new", json={}) + with freeze_time(): + braze_instance.update(mock_basket_user_data, update_data) + api_requests = m.request_history + assert api_requests[0].url == "http://test.com/users/track" + assert api_requests[0].json() == braze_instance.to_vendor(mock_basket_user_data, update_data) + assert api_requests[1].url == "http://test.com/users/alias/new" + assert api_requests[1].json() == { + "user_aliases": [{"alias_name": "new_fxa_id", "alias_label": "fxa_id", "external_id": mock_basket_user_data["email_id"]}] + } + + def test_braze_delete(braze_client): braze_instance = Braze(braze_client) email = mock_braze_user_data["email"] From 39b6503b4109d2384816b4512163b2a14ba47967 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:32:18 -0400 Subject: [PATCH 106/137] return updated external_id if it's been migrated --- basket/news/backends/braze.py | 1 + 1 file changed, 1 insertion(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 21bb87fd..9ac7f2cb 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -376,6 +376,7 @@ def add(self, data): } ] ) + external_id = token return {"email": {"email_id": external_id}} From 836f640ed1a68cc6bbab7e8bcee6f23abbc76998 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:32:46 -0400 Subject: [PATCH 107/137] fix incorrect urls, add test for braze add with id migration --- basket/news/tests/test_braze.py | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 4fb2c31a..71e0119f 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -236,7 +236,7 @@ def test_braze_add_fxa_id_alias(braze_client): expected = {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": external_id}]} with requests_mock.mock() as m: - m.register_uri("POST", "/users/alias/new", json={}) + m.register_uri("POST", "http://test.com/users/alias/new", json={}) braze_client.add_fxa_id_alias(external_id, fxa_id) assert m.last_request.json() == expected @@ -713,7 +713,7 @@ def test_braze_add_with_fxa_id(mock_newsletters, braze_client): with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/track", json={}) - m.register_uri("POST", "/users/alias/new", json={}) + m.register_uri("POST", "http://test.com/users/alias/new", json={}) expected = {"email": {"email_id": new_user["email_id"]}} with freeze_time(): response = braze_instance.add(new_user) @@ -725,6 +725,50 @@ def test_braze_add_with_fxa_id(mock_newsletters, braze_client): assert api_requests[1].json() == {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": "123"}]} +@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) +@override_settings(BRAZE_PARALLEL_WRITE_ENABLE=True) +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +def test_braze_add_with_external_id_migration(mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + new_user = { + "email": "test@example.com", + "email_id": "123", + "token": "abc", + "newsletters": {"foo-news": True}, + "country": "US", + } + + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/track", json={}) + m.register_uri( + "POST", + "http://test.com/users/external_ids/rename", + json={ + "message": "success", + "external_ids": [new_user["token"]], + }, + ) + expected = {"email": {"email_id": new_user["token"]}} + with freeze_time(): + response = braze_instance.add(new_user) + api_requests = m.request_history + assert response == expected + assert api_requests[0].url == "http://test.com/users/track" + assert api_requests[0].json() == braze_instance.to_vendor(None, new_user) + assert api_requests[1].url == "http://test.com/users/external_ids/rename" + assert api_requests[1].json() == { + "external_id_renames": [ + { + "current_external_id": "123", + "new_external_id": "abc", + }, + ], + } + + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=True) @mock.patch( "basket.news.newsletters._newsletters", @@ -787,7 +831,7 @@ def test_braze_update_with_fxa_id_change(mock_newsletter_languages, mock_newslet update_data = {"country": "CA", "fxa_id": "new_fxa_id"} with requests_mock.mock() as m: m.register_uri("POST", "http://test.com/users/track", json={}) - m.register_uri("POST", "/users/alias/new", json={}) + m.register_uri("POST", "http://test.com/users/alias/new", json={}) with freeze_time(): braze_instance.update(mock_basket_user_data, update_data) api_requests = m.request_history From 80d25b8dd1269caad4b18606b13f05ff28b75faf Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:53:08 -0400 Subject: [PATCH 108/137] Remove email alias from export call --- basket/news/backends/braze.py | 3 --- basket/news/tests/test_braze.py | 9 ++------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 9ac7f2cb..2d745ed7 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -204,9 +204,6 @@ def export_users(self, email, fields_to_export=None, external_id=None, fxa_id=No if fxa_id: data["user_aliases"].append({"alias_name": fxa_id, "alias_label": "fxa_id"}) - if email: - (data["user_aliases"].append({"alias_name": email, "alias_label": "email"}),) - return self._request(BrazeEndpoint.USERS_EXPORT_IDS, data) def delete_user(self, email): diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 71e0119f..41c87582 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -161,7 +161,7 @@ def test_braze_track_user_with_event_and_token_and_email_id(braze_client): def test_braze_export_users(braze_client): email = "test@test.com" expected = { - "user_aliases": [{"alias_name": email, "alias_label": "email"}], + "user_aliases": [], "email_address": email, "fields_to_export": ["external_id"], } @@ -668,12 +668,7 @@ def test_braze_get(mock_newsletters, braze_client): "last_name", "user_aliases", ], - "user_aliases": [ - { - "alias_label": "email", - "alias_name": email, - }, - ], + "user_aliases": [], } assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123&email=test%40example.com" From 2d5b43d62d9df32d786afb59ab8abca9f29cdac5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:46:38 -0400 Subject: [PATCH 109/137] implement braze dsar unsubscribe --- basket/admin.py | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index 3d2d22e7..93390ea3 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -152,20 +152,39 @@ def dsar_unsub_view(self, request): "waitlists": "UNSUBSCRIBE", } - # Process the emails. - for email in emails: - contact = ctms.get(email=email) - if contact: - email_id = contact["email_id"] - try: - ctms.interface.patch_by_email_id(email_id, update_data) - except CTMSNotFoundByEmailIDError: - # should never reach here, but best to catch it anyway - output.append(f"{email} not found in CTMS") + def handler(emails, use_braze_backend=False): + # Process the emails. + for email in emails: + if use_braze_backend: + contact = braze.get(email=email) else: - output.append(f"UNSUBSCRIBED {email} (ctms id: {email_id}).") - else: - output.append(f"{email} not found in CTMS") + contact = ctms.get(email=email) + if contact: + email_id = contact["email_id"] + try: + if use_braze_backend: + braze.update(contact, {"optout": True}) + else: + ctms.interface.patch_by_email_id(email_id, update_data) + except CTMSNotFoundByEmailIDError: + # should never reach here, but best to catch it anyway + output.append(f"{email} not found in CTMS") + else: + output.append(f"UNSUBSCRIBED {email} ({'Braze external id:' if use_braze_backend else 'ctms id:'} {email_id}).") + else: + output.append(f"{email} not found in {'Braze' if use_braze_backend else 'CTMS'}") + + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + try: + handler(emails, use_braze_backend=True) + except Exception: + sentry_sdk.capture_exception() + + handler(emails, use_braze_backend=False) + elif settings.BRAZE_ONLY_WRITE_ENABLE: + handler(emails, use_braze_backend=True) + else: + handler(emails, use_braze_backend=False) output = "\n".join(output) From 6228de825d05c5a8f22d465d2cd78ab22c44c5e5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:46:52 -0400 Subject: [PATCH 110/137] add settings overrides to existing CTMS tests --- basket/base/tests/test_view_admin_dsar.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index ffd44548..852412c5 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth.models import Permission, User from django.test import Client +from django.test.utils import override_settings from django.urls import reverse import pytest @@ -59,6 +60,7 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None + @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_emails(self): self._create_admin_user() self._login_admin_user() @@ -76,6 +78,7 @@ def test_post_valid_emails(self): assert "DELETED test2@example.com from CTMS (ctms id: 456). fxa: YES." in response.context["dsar_output"] assert "DELETED test3@example.com from CTMS (ctms id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] + @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_email(self): self._create_admin_user() self._login_admin_user() @@ -87,6 +90,7 @@ def test_post_valid_email(self): assert mock_ctms.delete.called assert "DELETED test@example.com from CTMS (ctms id: 123)." in response.context["dsar_output"] + @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() self._login_admin_user() @@ -198,6 +202,7 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None + @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_emails(self): self._create_admin_user() self._login_admin_user() @@ -222,6 +227,7 @@ def test_post_valid_emails(self): assert "UNSUBSCRIBED test2@example.com (ctms id: 456)." in response.context["dsar_output"] assert "UNSUBSCRIBED test3@example.com (ctms id: 789)." in response.context["dsar_output"] + @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_email(self): self._create_admin_user() self._login_admin_user() @@ -234,6 +240,7 @@ def test_post_valid_email(self): mock_ctms.interface.patch_by_email_id.assert_called_with("123", self.update_data) assert "UNSUBSCRIBED test@example.com (ctms id: 123)." in response.context["dsar_output"] + @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() self._login_admin_user() From 4a883f6991e0fc769792326a9525a6c91089324f Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:54:27 -0400 Subject: [PATCH 111/137] add missing setting overrides --- basket/base/tests/test_view_admin_dsar.py | 6 ++++++ basket/news/tests/test_braze.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index 852412c5..52b55719 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -60,6 +60,7 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_emails(self): self._create_admin_user() @@ -78,6 +79,7 @@ def test_post_valid_emails(self): assert "DELETED test2@example.com from CTMS (ctms id: 456). fxa: YES." in response.context["dsar_output"] assert "DELETED test3@example.com from CTMS (ctms id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_email(self): self._create_admin_user() @@ -90,6 +92,7 @@ def test_post_valid_email(self): assert mock_ctms.delete.called assert "DELETED test@example.com from CTMS (ctms id: 123)." in response.context["dsar_output"] + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() @@ -202,6 +205,7 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_emails(self): self._create_admin_user() @@ -227,6 +231,7 @@ def test_post_valid_emails(self): assert "UNSUBSCRIBED test2@example.com (ctms id: 456)." in response.context["dsar_output"] assert "UNSUBSCRIBED test3@example.com (ctms id: 789)." in response.context["dsar_output"] + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_email(self): self._create_admin_user() @@ -240,6 +245,7 @@ def test_post_valid_email(self): mock_ctms.interface.patch_by_email_id.assert_called_with("123", self.update_data) assert "UNSUBSCRIBED test@example.com (ctms id: 123)." in response.context["dsar_output"] + @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 41c87582..5d0965e0 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -673,6 +673,7 @@ def test_braze_get(mock_newsletters, braze_client): assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123&email=test%40example.com" +@override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", @@ -696,6 +697,7 @@ def test_braze_add(mock_newsletters, braze_client): assert m.last_request.json() == braze_instance.to_vendor(None, new_user) +@override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", From d9db421aef26541eaa527df384ffa7172755242e Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:40:54 -0400 Subject: [PATCH 112/137] Remove unnecessary overrides from test_braze --- basket/news/tests/test_braze.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 41c87582..0cc79228 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -417,7 +417,6 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock assert braze_instance.to_vendor(mock_basket_user_data) == expected -@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, @@ -673,7 +672,6 @@ def test_braze_get(mock_newsletters, braze_client): assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123&email=test%40example.com" -@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, @@ -696,7 +694,6 @@ def test_braze_add(mock_newsletters, braze_client): assert m.last_request.json() == braze_instance.to_vendor(None, new_user) -@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @mock.patch( "basket.news.newsletters._newsletters", return_value=mock_newsletters, @@ -720,7 +717,6 @@ def test_braze_add_with_fxa_id(mock_newsletters, braze_client): assert api_requests[1].json() == {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": "123"}]} -@override_settings(BRAZE_ONLY_WRITE_ENABLE=False) @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=True) @mock.patch( "basket.news.newsletters._newsletters", From bd69bf918006a9ef35730117625d15a38d9cd1b5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:09:12 -0400 Subject: [PATCH 113/137] remove false settings overrides (no longer necessary) --- basket/base/tests/test_view_admin_dsar.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index 52b55719..ffd44548 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -5,7 +5,6 @@ from django.conf import settings from django.contrib.auth.models import Permission, User from django.test import Client -from django.test.utils import override_settings from django.urls import reverse import pytest @@ -60,8 +59,6 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None - @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) - @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_emails(self): self._create_admin_user() self._login_admin_user() @@ -79,8 +76,6 @@ def test_post_valid_emails(self): assert "DELETED test2@example.com from CTMS (ctms id: 456). fxa: YES." in response.context["dsar_output"] assert "DELETED test3@example.com from CTMS (ctms id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] - @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) - @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_email(self): self._create_admin_user() self._login_admin_user() @@ -92,8 +87,6 @@ def test_post_valid_email(self): assert mock_ctms.delete.called assert "DELETED test@example.com from CTMS (ctms id: 123)." in response.context["dsar_output"] - @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) - @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() self._login_admin_user() @@ -205,8 +198,6 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None - @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) - @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_emails(self): self._create_admin_user() self._login_admin_user() @@ -231,8 +222,6 @@ def test_post_valid_emails(self): assert "UNSUBSCRIBED test2@example.com (ctms id: 456)." in response.context["dsar_output"] assert "UNSUBSCRIBED test3@example.com (ctms id: 789)." in response.context["dsar_output"] - @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) - @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_valid_email(self): self._create_admin_user() self._login_admin_user() @@ -245,8 +234,6 @@ def test_post_valid_email(self): mock_ctms.interface.patch_by_email_id.assert_called_with("123", self.update_data) assert "UNSUBSCRIBED test@example.com (ctms id: 123)." in response.context["dsar_output"] - @override_settings(BRAZE_PARALLEL_WRITE_ENABLE=False) - @override_settings(BRAZE_ONLY_WRITE_ENABLE=False) def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() self._login_admin_user() From cf3fe41d5f62f5c3174b8fae7c2a927a8ca67fa5 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:28:02 -0400 Subject: [PATCH 114/137] add delete tests for braze --- basket/base/tests/test_view_admin_dsar.py | 62 +++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index ffd44548..d9891e2b 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -5,11 +5,13 @@ from django.conf import settings from django.contrib.auth.models import Permission, User from django.test import Client +from django.test.utils import override_settings from django.urls import reverse import pytest from basket.base.forms import EmailForm, EmailListForm +from basket.news.backends.braze import BrazeUserNotFoundByEmailError from basket.news.backends.ctms import CTMSNotFoundByEmailError TEST_DATA_DIR = Path(__file__).resolve().parent.joinpath("data") @@ -59,7 +61,7 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None - def test_post_valid_emails(self): + def test_post_valid_emails_ctms(self): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["delete"]) as mock_ctms: @@ -76,7 +78,25 @@ def test_post_valid_emails(self): assert "DELETED test2@example.com from CTMS (ctms id: 456). fxa: YES." in response.context["dsar_output"] assert "DELETED test3@example.com from CTMS (ctms id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] - def test_post_valid_email(self): + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_valid_emails_braze(self): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["delete"]) as mock_braze: + mock_braze.delete.side_effect = [ + [{"email_id": "123", "fxa_id": "", "mofo_contact_id": ""}], + [{"email_id": "456", "fxa_id": "string", "mofo_contact_id": ""}], + [{"email_id": "789", "fxa_id": "string", "mofo_contact_id": "string"}], + ] + response = self.client.post(self.url, {"emails": "test1@example.com\ntest2@example.com\ntest3@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.delete.call_count == 3 + assert "DELETED test1@example.com from Braze (external_id: 123)." in response.context["dsar_output"] + assert "DELETED test2@example.com from Braze (external_id: 456). fxa: YES." in response.context["dsar_output"] + assert "DELETED test3@example.com from Braze (external_id: 789). fxa: YES. mofo: YES." in response.context["dsar_output"] + + def test_post_valid_email_ctms(self): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["delete"]) as mock_ctms: @@ -87,6 +107,18 @@ def test_post_valid_email(self): assert mock_ctms.delete.called assert "DELETED test@example.com from CTMS (ctms id: 123)." in response.context["dsar_output"] + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_valid_email_braze(self): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["delete"]) as mock_braze: + mock_braze.delete.return_value = [{"email_id": "123", "fxa_id": "", "mofo_contact_id": ""}] + response = self.client.post(self.url, {"emails": "test@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.delete.called + assert "DELETED test@example.com from Braze (external_id: 123)." in response.context["dsar_output"] + def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() self._login_admin_user() @@ -98,7 +130,19 @@ def test_post_unknown_ctms_user(self, mocker): assert mock_ctms.delete.called assert "unknown@example.com not found in CTMS" in response.context["dsar_output"] - def test_post_invalid_email(self, mocker): + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_unknown_braze_user(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["delete"]) as mock_braze: + mock_braze.delete.side_effect = BrazeUserNotFoundByEmailError("unknown@example.com") + response = self.client.post(self.url, {"emails": "unknown@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.delete.called + assert "unknown@example.com not found in Braze" in response.context["dsar_output"] + + def test_post_invalid_email_ctms(self, mocker): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["delete"]) as mock_ctms: @@ -110,6 +154,18 @@ def test_post_invalid_email(self, mocker): assert response.context["dsar_output"] is None assert response.context["dsar_form"].errors == {"emails": ["Invalid email: invalid@email"]} + def test_post_invalid_email_braze(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["delete"]) as mock_braze: + mock_braze.delete.side_effect = BrazeUserNotFoundByEmailError + response = self.client.post(self.url, {"emails": "invalid@email"}, follow=True) + + assert response.status_code == 200 + assert not mock_braze.delete.called + assert response.context["dsar_output"] is None + assert response.context["dsar_form"].errors == {"emails": ["Invalid email: invalid@email"]} + @pytest.mark.django_db class TestAdminDSARInfoView(DSARViewTestBase): From e2b553bae712b10b9a0ccf1cacaeab91ece54787 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:30:15 -0400 Subject: [PATCH 115/137] Add fxa_id alias to migration command --- basket/news/backends/braze.py | 19 ++++++++-- .../process_braze_external_id_migrator.py | 29 +++++++++++++-- ...test_process_braze_external_id_migrator.py | 37 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 2d745ed7..9ec99eb7 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -274,6 +274,22 @@ def add_fxa_id_alias(self, external_id, fxa_id): data = {"user_aliases": [{"alias_name": fxa_id, "alias_label": "fxa_id", "external_id": external_id}]} return self._request(BrazeEndpoint.USERS_ADD_ALIAS, data) + def add_aliases(self, alias_operations): + """ + @param alias_operations: List of user alias objects (schema below) + + { + "external_id" : (optional, string), + "alias_name" : (required, string), + "alias_label" : (required, string) + } + + Add up to 50 user aliases in Braze. + https://www.braze.com/docs/api/endpoints/user_data/post_user_alias + """ + data = {"user_aliases": alias_operations} + return self._request(BrazeEndpoint.USERS_ADD_ALIAS, data) + def migrate_external_id(self, migrations): """ Migrate a user's external_id to a new value. 50 rename objects per request is the hard Braze limit. @@ -298,9 +314,6 @@ def migrate_external_id(self, migrations): external_ids = rename_response.get("external_ids", []) rename_errors = rename_response.get("rename_errors", []) - if not external_ids: - raise BrazeNotFoundError("No external_ids found for migration.") - results.extend(external_ids) errors.extend(rename_errors) diff --git a/basket/news/management/commands/process_braze_external_id_migrator.py b/basket/news/management/commands/process_braze_external_id_migrator.py index 162c24d5..3700a18d 100644 --- a/basket/news/management/commands/process_braze_external_id_migrator.py +++ b/basket/news/management/commands/process_braze_external_id_migrator.py @@ -1,4 +1,5 @@ import json +import sys import time from django.core.management.base import BaseCommand, CommandError @@ -55,9 +56,19 @@ def process_and_migrate_parquet_file(self, project, bucket, prefix, file_name, s for i in range(0, len(migrations), chunk_size): chunk = migrations[i : i + chunk_size] - braze_chunk = self.strip_for_braze(chunk) + braze_fxa_alias_chunk = self.strip_for_braze_fxa_alias(chunk) + braze_migration_chunk = self.strip_for_braze_migration(chunk) try: - braze.interface.migrate_external_id(braze_chunk) + if braze_fxa_alias_chunk: + braze.interface.add_aliases(braze_fxa_alias_chunk) + + migrate_response = braze.interface.migrate_external_id(braze_migration_chunk) + + if not migrate_response["braze_collected_response"]["external_ids"]: + # If no external_ids are migrated we assume we are done. + self.stdout.write(self.style.SUCCESS(f"Migration complete. Ended on email_id {chunk[-1]['current_external_id']}.")) + sys.exit(0) + time.sleep(0.07) except Exception as e: failure = { @@ -69,7 +80,7 @@ def process_and_migrate_parquet_file(self, project, bucket, prefix, file_name, s self.stdout.write(self.style.ERROR(json.dumps(failure, indent=2))) raise CommandError("Migration failed. Process terminated error.") from None - def strip_for_braze(self, chunk): + def strip_for_braze_migration(self, chunk): return [ { "current_external_id": item["current_external_id"], @@ -78,6 +89,17 @@ def strip_for_braze(self, chunk): for item in chunk ] + def strip_for_braze_fxa_alias(self, chunk): + return [ + { + "external_id": item["current_external_id"], + "alias_name": "fxa_id", + "alias_label": item["fxa_id"], + } + for item in chunk + if item.get("fxa_id") + ] + def mask(self, external_id): parts = str(external_id).split("-") return "-".join(["***"] * 3 + parts[3:]) @@ -92,6 +114,7 @@ def build_migrations(self, df): "current_external_id": row.email_id, "new_external_id": row.basket_token, "create_timestamp": getattr(row, "create_timestamp", ""), + "fxa_id": getattr(row, "fxa_id", ""), } for row in df.itertuples(index=False) ] diff --git a/basket/news/tests/test_process_braze_external_id_migrator.py b/basket/news/tests/test_process_braze_external_id_migrator.py index 14b04f7d..df30823d 100644 --- a/basket/news/tests/test_process_braze_external_id_migrator.py +++ b/basket/news/tests/test_process_braze_external_id_migrator.py @@ -19,6 +19,16 @@ def sample_df(): ) +@pytest.fixture +def sample_df_with_fxa(): + return pd.DataFrame( + [ + {"email_id": "id1", "basket_token": "token1", "fxa_id": "fxa1", "create_timestamp": "2024-01-01T00:00:00"}, + {"email_id": "id2", "basket_token": "token2", "fxa_id": "fxa2", "create_timestamp": "2024-02-01T00:00:00"}, + ] + ) + + def parquet_bytes(df): buf = io.BytesIO() df.to_parquet(buf, index=False) @@ -60,6 +70,33 @@ def test_successful_migration(mock_storage_client, mock_braze, sample_df): {"current_external_id": "id2", "new_external_id": "token2"}, ] mock_braze.interface.migrate_external_id.assert_called_once_with(expected_chunk) + # No fxa_ids in chunk so no calls should be made to add_aliases + mock_braze.interface.add_aliases.assert_not_called() + + +def test_successful_migration_with_fxa(mock_storage_client, mock_braze, sample_df_with_fxa): + mock_blob = mock.Mock() + mock_blob.exists.return_value = True + mock_blob.download_as_bytes.return_value = parquet_bytes(sample_df_with_fxa) + mock_bucket = mock.Mock() + mock_bucket.blob.return_value = mock_blob + mock_client = mock.Mock() + mock_client.bucket.return_value = mock_bucket + mock_storage_client.return_value = mock_client + + mock_braze.interface.migrate_external_id.return_value = {"braze_collected_response": {"external_ids": ["id1", "id2"], "rename_errors": []}} + + cmd = Command() + cmd.stdout = mock.Mock() + cmd.process_and_migrate_parquet_file( + project="proj", bucket="bucket", prefix="prefix", file_name="file.parquet", start_timestamp=None, chunk_size=2 + ) + expected_chunk = [ + {"current_external_id": "id1", "new_external_id": "token1"}, + {"current_external_id": "id2", "new_external_id": "token2"}, + ] + mock_braze.interface.migrate_external_id.assert_called_once_with(expected_chunk) + mock_braze.interface.add_aliases.assert_called_once() def test_file_not_found(mock_storage_client, mock_braze): From 74bb4ce54298a602ae1d0fffca1d693ee4b63efc Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:39:30 -0400 Subject: [PATCH 116/137] Halve sleep time in migration script to reflect new 2000/min limit --- .../management/commands/process_braze_external_id_migrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/management/commands/process_braze_external_id_migrator.py b/basket/news/management/commands/process_braze_external_id_migrator.py index 3700a18d..7a8c1973 100644 --- a/basket/news/management/commands/process_braze_external_id_migrator.py +++ b/basket/news/management/commands/process_braze_external_id_migrator.py @@ -69,7 +69,7 @@ def process_and_migrate_parquet_file(self, project, bucket, prefix, file_name, s self.stdout.write(self.style.SUCCESS(f"Migration complete. Ended on email_id {chunk[-1]['current_external_id']}.")) sys.exit(0) - time.sleep(0.07) + time.sleep(0.035) except Exception as e: failure = { "current_external_id": self.mask(chunk[0]["current_external_id"]), From 9716fb1bb3d230eb938dcab31fe9b6ec76f8f2c3 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:46:05 -0400 Subject: [PATCH 117/137] Remove unnecessary TODO --- basket/news/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/basket/news/utils.py b/basket/news/utils.py index 5211907e..7a65fd96 100644 --- a/basket/news/utils.py +++ b/basket/news/utils.py @@ -299,7 +299,6 @@ def get_user_data( email=email, fxa_id=fxa_id, ) - # TODO: handle analogous Braze errors here except CTMSNotFoundByAltIDError: return None except requests.exceptions.HTTPError as exc: From b333442e5be8bcbfce652fec2d3eaf89d763d32a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:50:28 -0400 Subject: [PATCH 118/137] add DSAR unsub braze tests --- basket/base/tests/test_view_admin_dsar.py | 80 +++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index d9891e2b..f72aec64 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -154,6 +154,7 @@ def test_post_invalid_email_ctms(self, mocker): assert response.context["dsar_output"] is None assert response.context["dsar_form"].errors == {"emails": ["Invalid email: invalid@email"]} + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) def test_post_invalid_email_braze(self, mocker): self._create_admin_user() self._login_admin_user() @@ -186,7 +187,19 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailForm) assert "dsar_contact" not in response.context - def test_post_valid_email(self): + def test_post_valid_email_ctms(self): + self._create_admin_user() + self._login_admin_user() + user_data = self._get_test_data() + with patch("basket.admin.ctms", spec_set=["interface"]) as mock_ctms: + mock_ctms.interface.get_by_alternate_id.return_value = user_data + response = self.client.post(self.url, {"email": "test@example.com"}, follow=True) + + assert response.status_code == 200 + mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") + assert response.context["dsar_contact"]["email"]["basket_token"] == "0723e863-cff2-4f74-b492-82b861732d19" + + def test_post_valid_email_braze(self): self._create_admin_user() self._login_admin_user() user_data = self._get_test_data() @@ -254,7 +267,7 @@ def test_get(self): assert isinstance(response.context["dsar_form"], EmailListForm) assert response.context["dsar_output"] is None - def test_post_valid_emails(self): + def test_post_valid_emails_ctms(self): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["get", "interface"]) as mock_ctms: @@ -278,7 +291,27 @@ def test_post_valid_emails(self): assert "UNSUBSCRIBED test2@example.com (ctms id: 456)." in response.context["dsar_output"] assert "UNSUBSCRIBED test3@example.com (ctms id: 789)." in response.context["dsar_output"] - def test_post_valid_email(self): + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_valid_emails_braze(self): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["get", "update"]) as mock_braze: + mock_users = [ + {"email_id": "123", "fxa_id": "", "mofo_contact_id": ""}, + {"email_id": "456", "fxa_id": "string", "mofo_contact_id": ""}, + {"email_id": "789", "fxa_id": "string", "mofo_contact_id": "string"}, + ] + mock_braze.get.side_effect = mock_users + response = self.client.post(self.url, {"emails": "test1@example.com\ntest2@example.com\ntest3@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.get.call_count == 3 + mock_braze.update.assert_has_calls([call(user, {"optout": True}) for user in mock_users]) + assert "UNSUBSCRIBED test1@example.com (Braze external id: 123)." in response.context["dsar_output"] + assert "UNSUBSCRIBED test2@example.com (Braze external id: 456)." in response.context["dsar_output"] + assert "UNSUBSCRIBED test3@example.com (Braze external id: 789)." in response.context["dsar_output"] + + def test_post_valid_email_ctms(self): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["get", "interface"]) as mock_ctms: @@ -290,6 +323,19 @@ def test_post_valid_email(self): mock_ctms.interface.patch_by_email_id.assert_called_with("123", self.update_data) assert "UNSUBSCRIBED test@example.com (ctms id: 123)." in response.context["dsar_output"] + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_valid_email_braze(self): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["get", "update"]) as mock_braze: + mock_braze.get.return_value = {"email_id": "123", "fxa_id": "", "mofo_contact_id": ""} + response = self.client.post(self.url, {"emails": "test@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.get.called + mock_braze.update.assert_called_with({"email_id": "123", "fxa_id": "", "mofo_contact_id": ""}, {"optout": True}) + assert "UNSUBSCRIBED test@example.com (Braze external id: 123)." in response.context["dsar_output"] + def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() self._login_admin_user() @@ -302,7 +348,20 @@ def test_post_unknown_ctms_user(self, mocker): assert not mock_ctms.interface.patch_by_email_id.called assert "unknown@example.com not found in CTMS" in response.context["dsar_output"] - def test_post_invalid_email(self, mocker): + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_unknown_braze_user(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["get", "update"]) as mock_braze: + mock_braze.get.return_value = None + response = self.client.post(self.url, {"emails": "unknown@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.get.called + assert not mock_braze.update.called + assert "unknown@example.com not found in Braze" in response.context["dsar_output"] + + def test_post_invalid_email_ctms(self, mocker): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["get", "interface"]) as mock_ctms: @@ -313,3 +372,16 @@ def test_post_invalid_email(self, mocker): assert not mock_ctms.interface.patch_by_email_id.called assert response.context["dsar_output"] is None assert response.context["dsar_form"].errors == {"emails": ["Invalid email: invalid@email"]} + + @override_settings(BRAZE_ONLY_WRITE_ENABLE=True) + def test_post_invalid_email_braze(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.ctms", spec_set=["get", "update"]) as mock_braze: + response = self.client.post(self.url, {"emails": "invalid@email"}, follow=True) + + assert response.status_code == 200 + assert not mock_braze.get.called + assert not mock_braze.update.called + assert response.context["dsar_output"] is None + assert response.context["dsar_form"].errors == {"emails": ["Invalid email: invalid@email"]} From 45fcb22ac74aacfa603c516b3b06660b9a41ecbd Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:51:23 -0400 Subject: [PATCH 119/137] remove title reference to ctms --- basket/admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index 93390ea3..d9bc9fd3 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -104,7 +104,7 @@ def get_app_list(self, request, app_label=None): def dsar_info_view(self, request): form = EmailForm() context = { - "title": "DSAR: Fetch CTMS User Info by Email Address", + "title": "DSAR: Fetch User Info by Email Address", } if request.method == "POST": form = EmailForm(request.POST) @@ -192,7 +192,7 @@ def handler(emails, use_braze_backend=False): form = EmailListForm() context = { - "title": "DSAR: Unsubscribe CTMS Users by Email Address", + "title": "DSAR: Unsubscribe Users by Email Address", "dsar_form": form, "dsar_output": output, } @@ -254,7 +254,7 @@ def handler(emails, use_braze_backend=False): form = EmailListForm() context = { - "title": "DSAR: Delete CTMS Data by Email Address", + "title": "DSAR: Delete Data by Email Address", "dsar_form": form, "dsar_output": output, } From 4fa000a3fc988e3967f90538f04104171ab5e25a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:21:44 -0400 Subject: [PATCH 120/137] modify DSAR get info to handle both braze and ctms formats --- basket/admin.py | 78 +++++++++++++--------- basket/base/templates/admin/dsar-info.html | 28 ++++---- basket/base/tests/test_view_admin_dsar.py | 4 +- 3 files changed, 61 insertions(+), 49 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index d9bc9fd3..205df864 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -12,31 +12,21 @@ from basket.base.forms import EmailForm, EmailListForm from basket.news.backends.braze import BrazeUserNotFoundByEmailError, braze -from basket.news.backends.ctms import ( - CTMSNotFoundByEmailError, - CTMSNotFoundByEmailIDError, - ctms, -) -from basket.news.newsletters import newsletter_obj +from basket.news.backends.ctms import CTMSNotFoundByEmailError, CTMSNotFoundByEmailIDError, ctms, from_vendor +from basket.news.newsletters import slug_to_vendor_id log = logging.getLogger(__name__) -def get_newsletter_names(ctms_contact): +def get_newsletter_names(contact): names = [] - newsletters = ctms_contact["newsletters"] - for nl in newsletters: - if not nl["subscribed"]: - continue - - nl_slug = nl["name"] - nl_obj = newsletter_obj(nl_slug) - if nl_obj: - nl_name = nl_obj.title - else: - nl_name = "" - names.append(f"{nl_name} (id: {nl_slug})") - + newsletters = contact["newsletters"] + for newsletter_slug in newsletters: + try: + newsletter_id = slug_to_vendor_id(newsletter_slug) + names.append(f"{newsletter_slug} (id: {newsletter_id})") + except KeyError: + pass return names @@ -110,21 +100,43 @@ def dsar_info_view(self, request): form = EmailForm(request.POST) if form.is_valid(): email = form.cleaned_data["email"] - try: - contact = ctms.interface.get_by_alternate_id(primary_email=email) - except CTMSNotFoundByEmailError: - contact = None - else: - # response could be 200 with an empty list - if contact: - contact = contact[0] - context["dsar_contact_pretty"] = json.dumps(contact, indent=2, sort_keys=True) - context["newsletter_names"] = get_newsletter_names(contact) - else: + + def handler(email, use_braze_backend=False): + try: + if use_braze_backend: + contact = braze.get(email=email) + else: + contact = ctms.interface.get_by_alternate_id(primary_email=email) + except CTMSNotFoundByEmailError: contact = None + else: + # response could be 200 with an empty list + if contact: + if use_braze_backend: + context["dsar_contact_pretty"] = json.dumps(contact, indent=2, sort_keys=True) + else: + raw_contact = contact[0] + contact = from_vendor(raw_contact) + context["dsar_contact_pretty"] = json.dumps(raw_contact, indent=2, sort_keys=True) + + context["newsletter_names"] = get_newsletter_names(contact) + else: + contact = None + + context["dsar_contact"] = contact + context["dsar_submitted"] = True + context["vendor"] = "Braze" if use_braze_backend else "CTMS" - context["dsar_contact"] = contact - context["dsar_submitted"] = True + if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: + try: + handler(email, use_braze_backend=True) + except Exception: + sentry_sdk.capture_exception() + handler(email, use_braze_backend=False) + elif settings.BRAZE_ONLY_READ_ENABLE: + handler(email, use_braze_backend=True) + else: + handler(email, use_braze_backend=False) context["dsar_form"] = form # adds default django admin context so sidebar shows etc. diff --git a/basket/base/templates/admin/dsar-info.html b/basket/base/templates/admin/dsar-info.html index 6d655630..dbd6d58b 100644 --- a/basket/base/templates/admin/dsar-info.html +++ b/basket/base/templates/admin/dsar-info.html @@ -73,49 +73,49 @@

User Info:

- + - + - {% if dsar_contact.email.first_name %} + {% if dsar_contact.first_name or dsar_contact.last_name %} - + {% endif %} - + - {% if dsar_contact.email.mailing_country %} + {% if dsar_contact.country %} - + {% endif %} - + - {% if dsar_contact.fxa.primary_email %} + {% if dsar_contact.fxa_primary_email %} - + {% endif %} - + - + - + @@ -141,7 +141,7 @@

Raw Data:

{{ dsar_contact_pretty }}
{% elif dsar_submitted %}

Not Found:

-
User not found in CTMS
+
User not found in {{ vendor }}
{% endif %} diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index f72aec64..fbbd0c10 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -197,7 +197,7 @@ def test_post_valid_email_ctms(self): assert response.status_code == 200 mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") - assert response.context["dsar_contact"]["email"]["basket_token"] == "0723e863-cff2-4f74-b492-82b861732d19" + assert response.context["dsar_contact"]["token"] == "0723e863-cff2-4f74-b492-82b861732d19" def test_post_valid_email_braze(self): self._create_admin_user() @@ -209,7 +209,7 @@ def test_post_valid_email_braze(self): assert response.status_code == 200 mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") - assert response.context["dsar_contact"]["email"]["basket_token"] == "0723e863-cff2-4f74-b492-82b861732d19" + assert response.context["dsar_contact"]["token"] == "0723e863-cff2-4f74-b492-82b861732d19" def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() From 4d25b028ae4983bbd8b7d8296b2f474302420a66 Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Thu, 6 Nov 2025 16:11:53 -0800 Subject: [PATCH 121/137] Update unsub command --- basket/news/backends/braze.py | 1 + basket/news/tasks.py | 16 ++++++++++------ basket/news/views.py | 6 +++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 2d745ed7..54fc42ef 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -494,6 +494,7 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): "fxa_lang": updated_user_data.get("fxa_lang"), "fxa_primary_email": updated_user_data.get("fxa_primary_email"), "fxa_deleted": updated_user_data.get("fxa_deleted"), + "unsub_reason": updated_user_data.get("unsub_reason"), } ], } diff --git a/basket/news/tasks.py b/basket/news/tasks.py index e131e603..7812b373 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -543,13 +543,17 @@ def confirm_user(token, use_braze_backend=False, extra_metrics_tags=None): @rq_task -def update_custom_unsub(token, reason): +def update_custom_unsub(token, reason, use_braze_backend=False): """Record a user's custom unsubscribe reason.""" - try: - ctms.update_by_alt_id("token", token, {"reason": reason}) - except CTMSNotFoundByAltIDError: - # No record found for that token, nothing to do. - pass + user_data = get_user_data(token=token, extra_fields=["email_id"], use_braze_backend=use_braze_backend) + if use_braze_backend: + braze.update(user_data, {"unsub_reason": reason}) + else: + try: + ctms.update_by_alt_id("token", token, {"reason": reason}) + except CTMSNotFoundByAltIDError: + # No record found for that token, nothing to do. + pass @rq_task diff --git a/basket/news/views.py b/basket/news/views.py index 97c643ec..d3424774 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -771,7 +771,11 @@ def custom_unsub_reason(request): 400, ) - tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"]) + if settings.BRAZE_PARALLEL_WRITE_ENABLE: + tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], True) + tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], False) + if settings.BRAZE_ONLY_WRITE_ENABLE: + tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], True) return HttpResponseJSON({"status": "ok"}) From b30b36291f3e49ee2cf64b053f1a5db8f7aeb4d6 Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Thu, 6 Nov 2025 16:14:05 -0800 Subject: [PATCH 122/137] Add to from_vendor --- basket/news/backends/braze.py | 1 + 1 file changed, 1 insertion(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 54fc42ef..1f10fbc2 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -443,6 +443,7 @@ def from_vendor(self, braze_user_data, subscription_groups): "has_fxa": user_attributes.get("has_fxa"), "fxa_id": fxa_id, "fxa_deleted": user_attributes.get("fxa_deleted"), + "unsub_reason": user_attributes.get("unsub_reason"), } return basket_user_data From b749e6d2092bd3e7a985ef0c54320dc20e7b6b16 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:34:32 -0400 Subject: [PATCH 123/137] include vendor information --- basket/base/templates/admin/dsar-info.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/base/templates/admin/dsar-info.html b/basket/base/templates/admin/dsar-info.html index dbd6d58b..727011b7 100644 --- a/basket/base/templates/admin/dsar-info.html +++ b/basket/base/templates/admin/dsar-info.html @@ -69,7 +69,7 @@

{{ dsar_form.email.label_tag }}

{% if dsar_contact %} -

User Info:

+

User Info (from {{ vendor }}):

Primary Email{{ dsar_contact.email.primary_email }}{{ dsar_contact.email }}
Basket Token{{ dsar_contact.email.basket_token }}{{ dsar_contact.token }}
Name{{ dsar_contact.email.first_name }} {{ dsar_contact.email.last_name }}{% if dsar_contact.first_name %}{{ dsar_contact.first_name }}{% endif %}{% if dsar_contact.last_name %} {{ dsar_contact.last_name }}{% endif %}
Language{{ dsar_contact.email.email_lang }}{{ dsar_contact.lang }}
Country{{ dsar_contact.email.mailing_country }}{{ dsar_contact.country }}
FxA ID{{ dsar_contact.fxa.fxa_id }}{{ dsar_contact.fxa_id }}
FxA Primary Email{{ dsar_contact.fxa.primary_email }}{{ dsar_contact.fxa_primary_email }}
MoFo Relevant?{{ dsar_contact.mofo.mofo_relevant|yesno }}{{ dsar_contact.mofo_relevant|yesno }}
Double Opt In?{{ dsar_contact.email.double_opt_in|yesno }}{{ dsar_contact.optin|yesno }}
Opt Out of All Email?{{ dsar_contact.email.has_opted_out_of_email|yesno }}{{ dsar_contact.optout|yesno }}
Subscriptions
From 2d3324acad295ab0d93baad39aa9623e86e60fb8 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:52:52 -0400 Subject: [PATCH 124/137] add braze tests for dsar info view --- basket/base/tests/test_view_admin_dsar.py | 40 +++++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index fbbd0c10..d91211e2 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -199,17 +199,18 @@ def test_post_valid_email_ctms(self): mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") assert response.context["dsar_contact"]["token"] == "0723e863-cff2-4f74-b492-82b861732d19" + @override_settings(BRAZE_ONLY_READ_ENABLE=True) def test_post_valid_email_braze(self): self._create_admin_user() self._login_admin_user() - user_data = self._get_test_data() - with patch("basket.admin.ctms", spec_set=["interface"]) as mock_ctms: - mock_ctms.interface.get_by_alternate_id.return_value = user_data + mock_user_data = {"email": "test@example.com", "token": "abc", "country": "us", "lang": "en", "newsletters": "foo-news", "email_id": "123"} + with patch("basket.admin.braze", spec_set=["get"]) as mock_braze: + mock_braze.get.return_value = mock_user_data response = self.client.post(self.url, {"email": "test@example.com"}, follow=True) - assert response.status_code == 200 - mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") - assert response.context["dsar_contact"]["token"] == "0723e863-cff2-4f74-b492-82b861732d19" + assert response.status_code == 200 + mock_braze.get.assert_called_with(email="test@example.com") + assert response.context["dsar_contact"]["token"] == mock_user_data["token"] def test_post_unknown_ctms_user(self, mocker): self._create_admin_user() @@ -223,6 +224,19 @@ def test_post_unknown_ctms_user(self, mocker): assert mock_ctms.interface.get_by_alternate_id.called assert b"User not found in CTMS" in response.content + @override_settings(BRAZE_ONLY_READ_ENABLE=True) + def test_post_unknown_braze_user(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["get"]) as mock_braze: + # it may throw this error + mock_braze.get.return_value = None + response = self.client.post(self.url, {"email": "unknown@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.get.called + assert b"User not found in Braze" in response.content + def test_post_unknown_ctms_user_empty_list(self, mocker): self._create_admin_user() self._login_admin_user() @@ -235,7 +249,7 @@ def test_post_unknown_ctms_user_empty_list(self, mocker): assert mock_ctms.interface.get_by_alternate_id.called assert b"User not found in CTMS" in response.content - def test_post_invalid_email(self, mocker): + def test_post_invalid_email_ctms(self, mocker): self._create_admin_user() self._login_admin_user() with patch("basket.admin.ctms", spec_set=["interface"]) as mock_ctms: @@ -246,6 +260,18 @@ def test_post_invalid_email(self, mocker): assert "dsar_contact" not in response.context assert response.context["dsar_form"].errors == {"email": ["Enter a valid email address."]} + @override_settings(BRAZE_ONLY_READ_ENABLE=True) + def test_post_invalid_email_braze(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.braze", spec_set=["get"]) as mock_braze: + response = self.client.post(self.url, {"email": "invalid@email"}, follow=True) + + assert response.status_code == 200 + assert not mock_braze.get.called + assert "dsar_contact" not in response.context + assert response.context["dsar_form"].errors == {"email": ["Enter a valid email address."]} + @pytest.mark.django_db class TestAdminDSARUnsubView(DSARViewTestBase): From a86e534d633b28b3307fcb709953ad5ffed4a87a Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:31:06 -0400 Subject: [PATCH 125/137] add ctms fallback on user not found, update tests --- basket/admin.py | 14 +++++-- basket/base/tests/test_view_admin_dsar.py | 47 +++++++++++++++++++---- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index 205df864..9fc1739b 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -101,10 +101,14 @@ def dsar_info_view(self, request): if form.is_valid(): email = form.cleaned_data["email"] - def handler(email, use_braze_backend=False): + def handler(email, use_braze_backend=False, fallback_to_ctms=False): + context["vendor"] = "Braze" if use_braze_backend else "CTMS" try: if use_braze_backend: contact = braze.get(email=email) + if not contact and fallback_to_ctms: + context["vendor"] = "CTMS" + contact = ctms.interface.get_by_alternate_id(primary_email=email) else: contact = ctms.interface.get_by_alternate_id(primary_email=email) except CTMSNotFoundByEmailError: @@ -112,7 +116,7 @@ def handler(email, use_braze_backend=False): else: # response could be 200 with an empty list if contact: - if use_braze_backend: + if context["vendor"] == "Braze": context["dsar_contact_pretty"] = json.dumps(contact, indent=2, sort_keys=True) else: raw_contact = contact[0] @@ -123,13 +127,15 @@ def handler(email, use_braze_backend=False): else: contact = None + if not contact and fallback_to_ctms: + context["vendor"] = "CTMS and Braze" + context["dsar_contact"] = contact context["dsar_submitted"] = True - context["vendor"] = "Braze" if use_braze_backend else "CTMS" if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: try: - handler(email, use_braze_backend=True) + handler(email, use_braze_backend=True, fallback_to_ctms=True) except Exception: sentry_sdk.capture_exception() handler(email, use_braze_backend=False) diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index d91211e2..61dd5ae9 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -197,6 +197,7 @@ def test_post_valid_email_ctms(self): assert response.status_code == 200 mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") + assert b"User Info (from CTMS)" in response.content assert response.context["dsar_contact"]["token"] == "0723e863-cff2-4f74-b492-82b861732d19" @override_settings(BRAZE_ONLY_READ_ENABLE=True) @@ -210,6 +211,7 @@ def test_post_valid_email_braze(self): assert response.status_code == 200 mock_braze.get.assert_called_with(email="test@example.com") + assert b"User Info (from Braze)" in response.content assert response.context["dsar_contact"]["token"] == mock_user_data["token"] def test_post_unknown_ctms_user(self, mocker): @@ -224,6 +226,18 @@ def test_post_unknown_ctms_user(self, mocker): assert mock_ctms.interface.get_by_alternate_id.called assert b"User not found in CTMS" in response.content + def test_post_unknown_ctms_user_empty_list(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.ctms", spec_set=["interface"]) as mock_ctms: + # it may also return an empty list + mock_ctms.interface.get_by_alternate_id.return_value = [] + response = self.client.post(self.url, {"email": "unknown@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_ctms.interface.get_by_alternate_id.called + assert b"User not found in CTMS" in response.content + @override_settings(BRAZE_ONLY_READ_ENABLE=True) def test_post_unknown_braze_user(self, mocker): self._create_admin_user() @@ -237,17 +251,36 @@ def test_post_unknown_braze_user(self, mocker): assert mock_braze.get.called assert b"User not found in Braze" in response.content - def test_post_unknown_ctms_user_empty_list(self, mocker): + @override_settings(BRAZE_READ_WITH_FALLBACK_ENABLE=True) + def test_post_unknown_braze_user_found_in_ctms_fallback(self): self._create_admin_user() self._login_admin_user() + user_data = self._get_test_data() with patch("basket.admin.ctms", spec_set=["interface"]) as mock_ctms: - # it may also return an empty list - mock_ctms.interface.get_by_alternate_id.return_value = [] - response = self.client.post(self.url, {"email": "unknown@example.com"}, follow=True) + with patch("basket.admin.braze", spec_set=["get"]) as mock_braze: + mock_braze.get.return_value = None + mock_ctms.interface.get_by_alternate_id.return_value = user_data + response = self.client.post(self.url, {"email": "test@example.com"}, follow=True) + assert response.status_code == 200 + mock_braze.get.assert_called_with(email="test@example.com") + mock_ctms.interface.get_by_alternate_id.assert_called_with(primary_email="test@example.com") + assert b"User Info (from CTMS)" in response.content + assert response.context["dsar_contact"]["token"] == "0723e863-cff2-4f74-b492-82b861732d19" - assert response.status_code == 200 - assert mock_ctms.interface.get_by_alternate_id.called - assert b"User not found in CTMS" in response.content + @override_settings(BRAZE_READ_WITH_FALLBACK_ENABLE=True) + def test_post_unknown_braze_not_found_in_ctms_fallback(self, mocker): + self._create_admin_user() + self._login_admin_user() + with patch("basket.admin.ctms", spec_set=["interface"]) as mock_ctms: + with patch("basket.admin.braze", spec_set=["get"]) as mock_braze: + mock_braze.get.return_value = None + mock_ctms.interface.get_by_alternate_id.side_effect = CTMSNotFoundByEmailError("unknown@example.com") + response = self.client.post(self.url, {"email": "unknown@example.com"}, follow=True) + + assert response.status_code == 200 + assert mock_braze.get.called + assert mock_ctms.interface.get_by_alternate_id.called + assert b"User not found in CTMS and Braze" in response.content def test_post_invalid_email_ctms(self, mocker): self._create_admin_user() From 2bb65b581c88497f47d611ae0693613968ff4631 Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Fri, 7 Nov 2025 16:03:08 -0800 Subject: [PATCH 126/137] Fix small logic issues --- basket/news/tasks.py | 2 +- basket/news/views.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 7812b373..3187db15 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -545,8 +545,8 @@ def confirm_user(token, use_braze_backend=False, extra_metrics_tags=None): @rq_task def update_custom_unsub(token, reason, use_braze_backend=False): """Record a user's custom unsubscribe reason.""" - user_data = get_user_data(token=token, extra_fields=["email_id"], use_braze_backend=use_braze_backend) if use_braze_backend: + user_data = get_user_data(token=token, extra_fields=["email_id"], use_braze_backend=use_braze_backend) braze.update(user_data, {"unsub_reason": reason}) else: try: diff --git a/basket/news/views.py b/basket/news/views.py index d3424774..03987c98 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -774,8 +774,10 @@ def custom_unsub_reason(request): if settings.BRAZE_PARALLEL_WRITE_ENABLE: tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], True) tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], False) - if settings.BRAZE_ONLY_WRITE_ENABLE: + elif settings.BRAZE_ONLY_WRITE_ENABLE: tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], True) + else: + tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], False) return HttpResponseJSON({"status": "ok"}) From 729bf455a9af0f72f6189c24c8768fcd9bc5778b Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Mon, 10 Nov 2025 11:13:03 -0800 Subject: [PATCH 127/137] Fixtests --- basket/news/tests/test_braze.py | 20 +++++++++++++++++--- basket/news/views.py | 24 ++++++++++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index 41c87582..c713b9f0 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -313,6 +313,7 @@ def test_braze_exception_500(braze_client): "fxa_id": "fxa_123", "has_fxa": True, "fxa_deleted": None, + "unsub_reason": "unsub", } mock_braze_user_data = { @@ -339,6 +340,7 @@ def test_braze_exception_500(braze_client): "fxa_created_at": "2022-01-02", "has_fxa": True, "fxa_deleted": None, + "unsub_reason": "unsub", } ] }, @@ -408,6 +410,7 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock "updated_at": { "$time": dt.isoformat(), }, + "unsub_reason": "unsub", } ], } @@ -429,7 +432,7 @@ def test_to_vendor_with_user_data_and_no_updates(mock_newsletter_languages, mock def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() - update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123"} + update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123", "unsub_reason": "unsub"} expected = { "attributes": [ { @@ -459,6 +462,7 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock "updated_at": { "$time": dt.isoformat(), }, + "unsub_reason": "unsub", } ], } @@ -480,7 +484,7 @@ def test_to_vendor_with_updates_and_no_user_data(mock_newsletter_languages, mock def test_to_vendor_with_updates_and_no_user_data_in_braze_only_write(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() - update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123"} + update_data = {"newsletters": {"bar-news": True}, "email": "test@example.com", "token": "abc", "email_id": "123", "unsub_reason": "unsub"} expected = { "attributes": [ { @@ -510,6 +514,7 @@ def test_to_vendor_with_updates_and_no_user_data_in_braze_only_write(mock_newsle "updated_at": { "$time": dt.isoformat(), }, + "unsub_reason": "unsub", } ], } @@ -540,7 +545,14 @@ def test_to_vendor_throws_exception_for_missing_external_id(braze_client): def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mock_newsletters, braze_client): braze_instance = Braze(braze_client) dt = timezone.now() - update_data = {"newsletters": {"bar-news": True, "foo-news": False}, "first_name": "Foo", "country": "CA", "optin": False, "fxa_deleted": True} + update_data = { + "newsletters": {"bar-news": True, "foo-news": False}, + "first_name": "Foo", + "country": "CA", + "optin": False, + "fxa_deleted": True, + "unsub_reason": "unsub", + } expected = { "attributes": [ { @@ -574,6 +586,7 @@ def test_to_vendor_with_both_user_data_and_updates(mock_newsletter_languages, mo "updated_at": { "$time": dt.isoformat(), }, + "unsub_reason": "unsub", } ], } @@ -627,6 +640,7 @@ def test_to_vendor_with_events(mock_newsletters, braze_client): "updated_at": { "$time": dt.isoformat(), }, + "unsub_reason": "unsub", } ], } diff --git a/basket/news/views.py b/basket/news/views.py index 03987c98..8af3034c 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -772,12 +772,28 @@ def custom_unsub_reason(request): ) if settings.BRAZE_PARALLEL_WRITE_ENABLE: - tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], True) - tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], False) + tasks.update_custom_unsub.delay( + request.POST["token"], + request.POST["reason"], + use_braze_backend=True, + ) + tasks.update_custom_unsub.delay( + request.POST["token"], + request.POST["reason"], + use_braze_backend=False, + ) elif settings.BRAZE_ONLY_WRITE_ENABLE: - tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], True) + tasks.update_custom_unsub.delay( + request.POST["token"], + request.POST["reason"], + use_braze_backend=True, + ) else: - tasks.update_custom_unsub.delay(request.POST["token"], request.POST["reason"], False) + tasks.update_custom_unsub.delay( + request.POST["token"], + request.POST["reason"], + use_braze_backend=False, + ) return HttpResponseJSON({"status": "ok"}) From 2ce89df7a06261ab892d736f37a3cf9df9f7bf9b Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:13:32 -0400 Subject: [PATCH 128/137] Add Braze-CTMS shim to support token/fxa_id lookups prior to migrations --- basket/news/backends/braze.py | 27 +++++++++++- basket/news/tasks.py | 7 ++-- basket/news/tests/test_braze.py | 41 +++++++++++++++++++ ...test_process_braze_external_id_migrator.py | 2 +- basket/news/views.py | 1 - basket/settings.py | 1 + 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index ce7ff15c..4da0a17a 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -1,4 +1,5 @@ import json +import logging import warnings from enum import Enum from urllib.parse import urljoin, urlparse, urlunparse @@ -9,9 +10,11 @@ import requests from basket.base.utils import is_valid_uuid -from basket.news.backends.ctms import process_country, process_lang +from basket.news.backends.ctms import ctms, process_country, process_lang from basket.news.newsletters import slug_to_vendor_id, vendor_id_to_slug +log = logging.getLogger(__name__) + # Braze errors: https://www.braze.com/docs/api/errors/ class BrazeBadRequestError(Exception): @@ -46,6 +49,10 @@ class BrazeUserNotFoundByFxaIdError(Exception): pass +class BrazeUserNotFoundByTokenError(Exception): + pass + + class BrazeClientError(Exception): pass # any other error @@ -338,6 +345,18 @@ def get( email=None, fxa_id=None, ): + # If we only have a token or fxa_id and the Braze migrations for them haven't been + # completed we won't be able to look up the user. We add a temporary shim here which + # will fetch the email from CTMS. This shim can be disabled/removed after the migrations + # are complete. + if not email and settings.BRAZE_CTMS_SHIM_ENABLE: + try: + ctms_response = ctms.get(token=token, fxa_id=fxa_id) + if ctms_response: + email = ctms_response.get("email") + except Exception: + log.warn("Unable to fetch email from CTMS in braze.get shim") + user_response = self.interface.export_users( email, [ @@ -404,6 +423,12 @@ def update_by_fxa_id(self, fxa_id, update_data): raise BrazeUserNotFoundByFxaIdError self.update(existing_user, update_data) + def update_by_token(self, token, update_data): + existing_user = self.get(token=token) + if not existing_user: + raise BrazeUserNotFoundByTokenError + self.update(existing_user, update_data) + def delete(self, email): """ Delete the user matching the email diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 3187db15..f0b396cf 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -237,8 +237,8 @@ def fxa_login( @rq_task def update_user_meta(token, data, use_braze_backend=False): """Update a user's metadata, not newsletters""" - if use_braze_backend and settings.BRAZE_ONLY_WRITE_ENABLE: - braze.update({"email_id": token}, data) + if use_braze_backend: + braze.update_by_token(token, data) else: try: ctms.update_by_alt_id("token", token, data) @@ -546,8 +546,7 @@ def confirm_user(token, use_braze_backend=False, extra_metrics_tags=None): def update_custom_unsub(token, reason, use_braze_backend=False): """Record a user's custom unsubscribe reason.""" if use_braze_backend: - user_data = get_user_data(token=token, extra_fields=["email_id"], use_braze_backend=use_braze_backend) - braze.update(user_data, {"unsub_reason": reason}) + braze.update_by_token(token, {"unsub_reason": reason}) else: try: ctms.update_by_alt_id("token", token, {"reason": reason}) diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py index c732690c..ea3bd6c2 100644 --- a/basket/news/tests/test_braze.py +++ b/basket/news/tests/test_braze.py @@ -902,3 +902,44 @@ def test_braze_update_by_fxa_id_user_not_found(braze_client): with pytest.raises(braze.BrazeUserNotFoundByFxaIdError): braze_instance.update_by_fxa_id(fxa_id, update_data) assert m.last_request.url == "http://test.com/users/export/ids" + + +@mock.patch( + "basket.news.newsletters._newsletters", + return_value=mock_newsletters, +) +@mock.patch( + "basket.news.newsletters.newsletter_languages", + return_value=["en"], +) +def test_braze_update_by_token_for_existing_user(mock_newsletter_languages, mock_newsletters, braze_client): + braze_instance = Braze(braze_client) + token = mock_basket_user_data["token"] + update_data = {"first_name": "Edmund"} + + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/export/ids", json={"users": [mock_braze_user_data]}) + m.register_uri( + "GET", + "http://test.com/subscription/user/status?external_id=123", + json={"users": [{"subscription_groups": mock_braze_user_subscription_groups}]}, + ) + m.register_uri("POST", "http://test.com/users/track", json={}) + with freeze_time(): + braze_instance.update_by_token(token, update_data) + api_requests = m.request_history + assert api_requests[0].url == "http://test.com/users/export/ids" + assert api_requests[1].url == "http://test.com/subscription/user/status?external_id=123" + assert api_requests[2].url == "http://test.com/users/track" + assert api_requests[2].json() == braze_instance.to_vendor(mock_basket_user_data, update_data) + + +def test_braze_update_by_token_user_not_found(braze_client): + braze_instance = Braze(braze_client) + token = "000_none" + update_data = {"first_name": "Edmund"} + with requests_mock.mock() as m: + m.register_uri("POST", "http://test.com/users/export/ids", json={"users": []}) + with pytest.raises(braze.BrazeUserNotFoundByTokenError): + braze_instance.update_by_token(token, update_data) + assert m.last_request.url == "http://test.com/users/export/ids" diff --git a/basket/news/tests/test_process_braze_external_id_migrator.py b/basket/news/tests/test_process_braze_external_id_migrator.py index df30823d..122b7c1b 100644 --- a/basket/news/tests/test_process_braze_external_id_migrator.py +++ b/basket/news/tests/test_process_braze_external_id_migrator.py @@ -223,4 +223,4 @@ def test_rate_limit_sleep_between_chunks(mock_sleep, sample_df, mock_storage_cli ) assert mock_sleep.call_count == 2 - mock_sleep.assert_called_with(0.07) + mock_sleep.assert_called_with(0.035) diff --git a/basket/news/views.py b/basket/news/views.py index 8af3034c..faefc45b 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -755,7 +755,6 @@ def send_recovery_message(request): # Custom update methods -# TODO confirm if this endpoint is still needed. @csrf_exempt def custom_unsub_reason(request): """Update the reason field for the user, which logs why the user diff --git a/basket/settings.py b/basket/settings.py index 60734ccc..3baacf8b 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -224,6 +224,7 @@ def path(*args): BRAZE_ONLY_WRITE_ENABLE = config("BRAZE_ONLY_WRITE_ENABLE", parser=bool, default="false") BRAZE_READ_WITH_FALLBACK_ENABLE = config("BRAZE_READ_WITH_FALLBACK_ENABLE", parser=bool, default="false") BRAZE_ONLY_READ_ENABLE = config("BRAZE_ONLY_READ_ENABLE", parser=bool, default="false") +BRAZE_CTMS_SHIM_ENABLE = config("BRAZE_CTMS_SHIM_ENABLE", parser=bool, default="false") # Mozilla CTMS CTMS_ENV = config("CTMS_ENV", default="").lower() From 6670d0cd4218fdfe5d22711293ef07b169355923 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:27:29 -0400 Subject: [PATCH 129/137] Remove unnecessary fallbacks --- basket/news/api.py | 9 --------- basket/news/views.py | 16 +--------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/basket/news/api.py b/basket/news/api.py index 243d8233..b80c9f66 100644 --- a/basket/news/api.py +++ b/basket/news/api.py @@ -214,15 +214,6 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non masked=masked, use_braze_backend=True, ) - # If token migration isn't complete we might only find the user - # in CTMS when looking up by token. - if not user_data: - user_data = get_user_data( - email=email, - token=token, - masked=masked, - use_braze_backend=False, - ) except Exception: sentry_sdk.capture_exception() user_data = get_user_data( diff --git a/basket/news/views.py b/basket/news/views.py index faefc45b..3fcbc349 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -671,12 +671,7 @@ def user(request, token): if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: try: - response = get_user(token, masked=masked, use_braze_backend=True) - # If token migration isn't complete we might only find the user - # in CTMS when looking up by token. - if response.status_code == 404: - return get_user(token, masked=masked, use_braze_backend=False) - return response + return get_user(token, masked=masked, use_braze_backend=True) except Exception: sentry_sdk.capture_exception() return get_user(token, masked=masked, use_braze_backend=False) @@ -899,15 +894,6 @@ def lookup_user(request): masked=not authorized, use_braze_backend=True, ) - # If token migration isn't complete we might only find the user - # in CTMS when looking up by token. - if not user_data: - user_data = get_user_data( - token=token, - email=email, - masked=not authorized, - use_braze_backend=False, - ) except Exception: sentry_sdk.capture_exception() user_data = get_user_data( From 24f7d346c529a3859aa3c6f5ceac9f606bf00caf Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:20:01 -0400 Subject: [PATCH 130/137] Create a second braze instance to be used for tx emails only --- basket/news/backends/braze.py | 1 + basket/news/tasks.py | 4 ++-- basket/news/tests/test_tasks.py | 10 +++++----- basket/settings.py | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 4da0a17a..e6aa697b 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -556,4 +556,5 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): return braze_data +braze_tx = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_TRANSACTIONAL_API_KEY)) braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index f0b396cf..67ca273e 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -8,7 +8,7 @@ from basket.base.decorators import rq_task from basket.base.exceptions import BasketError from basket.base.utils import email_is_testing -from basket.news.backends.braze import BrazeUserNotFoundByFxaIdError, braze +from basket.news.backends.braze import BrazeUserNotFoundByFxaIdError, braze, braze_tx from basket.news.backends.ctms import ( CTMSNotFoundByAltIDError, CTMSUniqueIDConflictError, @@ -475,7 +475,7 @@ def ctms_add_or_update(update_data, user_data=None): @rq_task def send_tx_message(email, message_id, language, user_data=None): metrics.incr("news.tasks.send_tx_message", tags=[f"message_id:{message_id}", f"language:{language}"]) - braze.interface.track_user(email, event=f"send-{message_id}-{language}", user_data=user_data) + braze_tx.interface.track_user(email, event=f"send-{message_id}-{language}", user_data=user_data) def send_tx_messages(email, lang, message_ids): diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py index dca75595..0bc2550a 100644 --- a/basket/news/tests/test_tasks.py +++ b/basket/news/tests/test_tasks.py @@ -576,14 +576,14 @@ def test_delete_ctms_not_found_succeeds(self, mock_ctms): ) -@patch("basket.news.tasks.braze") +@patch("basket.news.tasks.braze_tx") def test_send_tx_message(mock_braze, metricsmock): send_tx_message("test@example.com", "download-foo", "en-US") mock_braze.interface.track_user.assert_called_once_with("test@example.com", event="send-download-foo-en-US", user_data=None) metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:download-foo", "language:en-US"]) -@patch("basket.news.tasks.braze") +@patch("basket.news.tasks.braze_tx") @patch("basket.news.models.BrazeTxEmailMessage.objects.get_message") def test_send_tx_messages(mock_model, mock_braze, metricsmock): """Test multipe message IDs, but only one is a transactional message.""" @@ -594,7 +594,7 @@ def test_send_tx_messages(mock_model, mock_braze, metricsmock): @override_settings(BRAZE_MESSAGE_ID_MAP={"download-zzz": "download-foo"}) -@patch("basket.news.tasks.braze") +@patch("basket.news.tasks.braze_tx") @patch("basket.news.models.BrazeTxEmailMessage.objects.get_message") def test_send_tx_messages_with_map(mock_model, mock_braze, metricsmock): """Test multipe message IDs, but only one is a transactional message.""" @@ -604,7 +604,7 @@ def test_send_tx_messages_with_map(mock_model, mock_braze, metricsmock): metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:download-foo", "language:en-US"]) -@patch("basket.news.tasks.braze") +@patch("basket.news.tasks.braze_tx") @patch("basket.news.models.BrazeTxEmailMessage.objects.get_message") def test_send_confirm_message(mock_get_message, mock_braze, metricsmock): mock_get_message.return_value = BrazeTxEmailMessage(message_id="newsletter-confirm-fx", language="en-US") @@ -615,7 +615,7 @@ def test_send_confirm_message(mock_get_message, mock_braze, metricsmock): metricsmock.assert_incr_once("news.tasks.send_tx_message", tags=["message_id:newsletter-confirm-fx", "language:en-US"]) -@patch("basket.news.tasks.braze") +@patch("basket.news.tasks.braze_tx") @patch("basket.news.models.BrazeTxEmailMessage.objects.get_message") def test_send_recovery_message(mock_get_message, mock_braze, metricsmock): mock_get_message.return_value = BrazeTxEmailMessage(message_id="newsletter-confirm-fx", language="en-US") diff --git a/basket/settings.py b/basket/settings.py index 3baacf8b..0ab77505 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -211,6 +211,7 @@ def path(*args): # Send confirmation messages SEND_CONFIRM_MESSAGES = config("SEND_CONFIRM_MESSAGES", parser=bool, default="false") +BRAZE_TRANSACTIONAL_API_KEY = config("BRAZE_TRANSACTIONAL_API_KEY", default="") BRAZE_API_KEY = config("BRAZE_API_KEY", default="") BRAZE_BASE_API_URL = config("BRAZE_BASE_API_URL", default="https://rest.iad-05.braze.com") # Map of Braze message IDs to the actual message IDs. From 23bc15dcfa363f19f7f558b2a2ae839098979e4c Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:06:00 -0400 Subject: [PATCH 131/137] Rename braze api key settings --- basket/news/backends/braze.py | 4 ++-- basket/settings.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index e6aa697b..14fa578d 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -556,5 +556,5 @@ def to_vendor(self, basket_user_data=None, update_data=None, events=None): return braze_data -braze_tx = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_TRANSACTIONAL_API_KEY)) -braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) +braze_tx = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_API_KEY)) +braze = Braze(BrazeInterface(settings.BRAZE_BASE_API_URL, settings.BRAZE_NEWSLETTER_API_KEY)) diff --git a/basket/settings.py b/basket/settings.py index 0ab77505..e2b57896 100644 --- a/basket/settings.py +++ b/basket/settings.py @@ -211,8 +211,11 @@ def path(*args): # Send confirmation messages SEND_CONFIRM_MESSAGES = config("SEND_CONFIRM_MESSAGES", parser=bool, default="false") -BRAZE_TRANSACTIONAL_API_KEY = config("BRAZE_TRANSACTIONAL_API_KEY", default="") +# Used for transactional emails BRAZE_API_KEY = config("BRAZE_API_KEY", default="") +# Used for everything else +BRAZE_NEWSLETTER_API_KEY = config("BRAZE_NEWSLETTER_API_KEY", default="") + BRAZE_BASE_API_URL = config("BRAZE_BASE_API_URL", default="https://rest.iad-05.braze.com") # Map of Braze message IDs to the actual message IDs. # This is intended for older messages that are hard to change. From 37308a2134aa52e2e9255da8e9434f28b1fa629e Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:23:54 -0400 Subject: [PATCH 132/137] Tweak user not found text in admin --- basket/admin.py | 2 +- basket/base/tests/test_view_admin_dsar.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index 9fc1739b..8f36e335 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -128,7 +128,7 @@ def handler(email, use_braze_backend=False, fallback_to_ctms=False): contact = None if not contact and fallback_to_ctms: - context["vendor"] = "CTMS and Braze" + context["vendor"] = "CTMS or Braze" context["dsar_contact"] = contact context["dsar_submitted"] = True diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py index 61dd5ae9..d1f73b93 100644 --- a/basket/base/tests/test_view_admin_dsar.py +++ b/basket/base/tests/test_view_admin_dsar.py @@ -280,7 +280,7 @@ def test_post_unknown_braze_not_found_in_ctms_fallback(self, mocker): assert response.status_code == 200 assert mock_braze.get.called assert mock_ctms.interface.get_by_alternate_id.called - assert b"User not found in CTMS and Braze" in response.content + assert b"User not found in CTMS or Braze" in response.content def test_post_invalid_email_ctms(self, mocker): self._create_admin_user() From 9ca0f03c6e35af3e3aa3b68b61fa30a3579c9b3d Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:29:48 -0400 Subject: [PATCH 133/137] Add docstrings to Braze methods --- basket/news/backends/braze.py | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index 14fa578d..ddf8e5f8 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -345,6 +345,16 @@ def get( email=None, fxa_id=None, ): + """ + Get a user using the first ID provided. + + @param email_id: CTMS email ID + @param token: basket_token + @param email: email address + @param fxa_id: external ID from FxA + @return: dict, or None if disabled + """ + # If we only have a token or fxa_id and the Braze migrations for them haven't been # completed we won't be able to look up the user. We add a temporary shim here which # will fetch the email from CTMS. This shim can be disabled/removed after the migrations @@ -388,6 +398,11 @@ def get( return self.from_vendor(user_data, subscriptions) def add(self, data): + """ + Create a user record. + + @param data: user data to add as a new user. + """ braze_user_data = self.to_vendor(None, data) external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) @@ -410,6 +425,12 @@ def add(self, data): return {"email": {"email_id": external_id}} def update(self, existing_data, update_data): + """ + Update data in an existing user record. + + @param existing_data: current user record + @param update_data: dict of new data + """ braze_user_data = self.to_vendor(existing_data, update_data) external_id = braze_user_data["attributes"][0]["external_id"] self.interface.save_user(braze_user_data) @@ -418,12 +439,26 @@ def update(self, existing_data, update_data): self.interface.add_fxa_id_alias(external_id, update_data["fxa_id"]) def update_by_fxa_id(self, fxa_id, update_data): + """ + Update data in an existing user record by fxa_id. + + @param fxa_id: external ID from FxA + @param update_data: dict of new data + @raises BrazeUserNotFoundByFxaIdError: when no record found + """ existing_user = self.get(fxa_id=fxa_id) if not existing_user: raise BrazeUserNotFoundByFxaIdError self.update(existing_user, update_data) def update_by_token(self, token, update_data): + """ + Update data in an existing user record by basket_token. + + @param token: basket_token + @param update_data: dict of new data + @raises BrazeUserNotFoundByTokenError: when no record found + """ existing_user = self.get(token=token) if not existing_user: raise BrazeUserNotFoundByTokenError @@ -487,6 +522,9 @@ def from_vendor(self, braze_user_data, subscription_groups): return basket_user_data def to_vendor(self, basket_user_data=None, update_data=None, events=None): + """ + Converts Basket-formatted data to Braze-formatted data + """ existing_user_data = basket_user_data or {} updated_user_data = existing_user_data | (update_data or {}) From cc34154a12c9cf7aea9e8d9a6cb5493ff6745663 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:40:23 -0400 Subject: [PATCH 134/137] Update ctms specific comment --- basket/news/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/basket/news/tasks.py b/basket/news/tasks.py index 67ca273e..dd341081 100644 --- a/basket/news/tasks.py +++ b/basket/news/tasks.py @@ -60,7 +60,7 @@ def fxa_email_changed( # message older than our last update for this UID return - # Update CTMS + # Update backend user_data = get_user_data(fxa_id=fxa_id, extra_fields=["id", "email_id"], use_braze_backend=use_braze_backend) if user_data: if use_braze_backend: From 9e51e71b6c07c4f55e982ecd5d7fac482e3ff6f6 Mon Sep 17 00:00:00 2001 From: Jacob Penny <808988+jacobpenny@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:20:34 -0400 Subject: [PATCH 135/137] Remove cTms references in docs --- docs/index.rst | 5 +++-- docs/newsletter_api.rst | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 858beac9..1d85b6f5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,8 +8,9 @@ About Basket -------------------- A Python web service, basket, provides an API for all of our subscribing needs. -Basket interfaces into whatever email provider we are using, currently ConTact -Management System (CTMS) (formerly Salesforce Marketing Cloud, formerly ExactTarget). +Basket interfaces into whatever email provider we are using, currently Braze +(formerly ConTact Management System (CTMS), formerly Salesforce Marketing Cloud, +formerly ExactTarget). Contents -------- diff --git a/docs/newsletter_api.rst b/docs/newsletter_api.rst index ab39994b..837e4c82 100644 --- a/docs/newsletter_api.rst +++ b/docs/newsletter_api.rst @@ -209,8 +209,8 @@ The following URLs are available (assuming "/news" is app url): On success, response is a bunch of data about the user:: { - 'status': 'ok', # no errors talking to CTMS - 'status': 'error', # errors talking to CTMS, see next field + 'status': 'ok', # no errors talking to backend + 'status': 'error', # errors talking to backend, see next field 'desc': 'error message' # details if status is error 'email': 'email@address', 'country': country code, @@ -245,7 +245,7 @@ The following URLs are available (assuming "/news" is app url): sent to the email, containing a link to the existing subscriptions page with their token in it, so they can use it to manage their subscriptions. - If the user is known in CTMS, the message will be sent in their preferred + If the user is known, the message will be sent in their preferred language. If the email provided is not known, a 404 status is returned. From 921ace765a1cbf510c019e707365787cd8926b04 Mon Sep 17 00:00:00 2001 From: clara-campos <64791123+clara-campos@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:30:46 -0400 Subject: [PATCH 136/137] Adjust comment --- basket/news/backends/braze.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/basket/news/backends/braze.py b/basket/news/backends/braze.py index ddf8e5f8..6dbb5640 100644 --- a/basket/news/backends/braze.py +++ b/basket/news/backends/braze.py @@ -348,11 +348,11 @@ def get( """ Get a user using the first ID provided. - @param email_id: CTMS email ID + @param email_id: external ID from Braze @param token: basket_token @param email: email address @param fxa_id: external ID from FxA - @return: dict, or None if disabled + @return: dict, or None if not found """ # If we only have a token or fxa_id and the Braze migrations for them haven't been From a52a55eb4c1b44f2d48e13d80f5bd65d8978c304 Mon Sep 17 00:00:00 2001 From: Matthew Semeniuk Date: Fri, 14 Nov 2025 09:25:50 -0800 Subject: [PATCH 137/137] Add missing sentry error parameters --- basket/admin.py | 12 ++++++------ basket/news/api.py | 8 ++++---- basket/news/auth.py | 4 ++-- basket/news/utils.py | 8 ++++---- basket/news/views.py | 36 ++++++++++++++++++------------------ 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/basket/admin.py b/basket/admin.py index 8f36e335..0885a502 100644 --- a/basket/admin.py +++ b/basket/admin.py @@ -136,8 +136,8 @@ def handler(email, use_braze_backend=False, fallback_to_ctms=False): if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: try: handler(email, use_braze_backend=True, fallback_to_ctms=True) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) handler(email, use_braze_backend=False) elif settings.BRAZE_ONLY_READ_ENABLE: handler(email, use_braze_backend=True) @@ -195,8 +195,8 @@ def handler(emails, use_braze_backend=False): if settings.BRAZE_PARALLEL_WRITE_ENABLE: try: handler(emails, use_braze_backend=True) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) handler(emails, use_braze_backend=False) elif settings.BRAZE_ONLY_WRITE_ENABLE: @@ -257,8 +257,8 @@ def handler(emails, use_braze_backend=False): if settings.BRAZE_PARALLEL_WRITE_ENABLE: try: handler(emails, use_braze_backend=True) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) handler(emails, use_braze_backend=False) elif settings.BRAZE_ONLY_WRITE_ENABLE: diff --git a/basket/news/api.py b/basket/news/api.py index b80c9f66..fc86f740 100644 --- a/basket/news/api.py +++ b/basket/news/api.py @@ -135,8 +135,8 @@ def recover_user(request, body: RecoverUserSchema): extra_fields=["email_id"], use_braze_backend=True, ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) user_data = get_user_data( email=body.email, extra_fields=["email_id"], @@ -214,8 +214,8 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non masked=masked, use_braze_backend=True, ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) user_data = get_user_data( email=email, token=token, diff --git a/basket/news/auth.py b/basket/news/auth.py index c67fc764..2d66f0f1 100644 --- a/basket/news/auth.py +++ b/basket/news/auth.py @@ -41,9 +41,9 @@ def authenticate(self, request, token): try: oauth.verify_token(token, scope=["basket", "profile:email"]) fxa_email = profile.get_email(token) - except fxa.errors.Error: + except fxa.errors.Error as e: # Unable to validate token or find email. - sentry_sdk.capture_exception() + sentry_sdk.capture_exception(e) return None if email == fxa_email: diff --git a/basket/news/utils.py b/basket/news/utils.py index 7a65fd96..7c52b7dd 100644 --- a/basket/news/utils.py +++ b/basket/news/utils.py @@ -167,16 +167,16 @@ def has_valid_fxa_oauth(request, email): # This will raise an exception if things are not as they should be. try: oauth.verify_token(token, scope=["basket", "profile:email"]) - except fxa.errors.Error: + except fxa.errors.Error as e: # security failure or server problem. can't validate. return invalid - sentry_sdk.capture_exception() + sentry_sdk.capture_exception(e) return False try: fxa_email = profile.get_email(token) - except fxa.errors.Error: + except fxa.errors.Error as e: # security failure or server problem. can't validate. return invalid - sentry_sdk.capture_exception() + sentry_sdk.capture_exception(e) return False return email == fxa_email diff --git a/basket/news/views.py b/basket/news/views.py index 3fcbc349..c4751bd8 100644 --- a/basket/news/views.py +++ b/basket/news/views.py @@ -130,9 +130,9 @@ def fxa_callback(request): try: access_token = fxa_oauth.trade_code(code, ttl=settings.FXA_OAUTH_TOKEN_TTL)["access_token"] user_profile = fxa_profile.get_profile(access_token) - except Exception: + except Exception as e: metrics.incr("news.views.fxa_callback", tags=["status:error", "error:fxa_comm"]) - sentry_sdk.capture_exception() + sentry_sdk.capture_exception(e) return HttpResponseRedirect(error_url) email = user_profile.get("email") @@ -156,9 +156,9 @@ def handler( fxa_id=uid, use_braze_backend=use_braze_backend, ) - except Exception: + except Exception as e: metrics.incr("news.views.fxa_callback", tags=["status:error", "error:user_data", *extra_metrics_tags]) - sentry_sdk.capture_exception() + sentry_sdk.capture_exception(e) return HttpResponseRedirect(error_url) if user_data: @@ -189,9 +189,9 @@ def handler( pre_generated_token=pre_generated_token, pre_generated_email_id=pre_generated_email_id, )[0] - except Exception: + except Exception as e: metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact", *extra_metrics_tags]) - sentry_sdk.capture_exception() + sentry_sdk.capture_exception(e) return HttpResponseRedirect(error_url) metrics.incr("news.views.fxa_callback", tags=["status:success", *extra_metrics_tags]) @@ -211,8 +211,8 @@ def handler( pre_generated_token=pre_generated_token, pre_generated_email_id=pre_generated_email_id, ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) return handler( email, @@ -483,8 +483,8 @@ def handler( pre_generated_token=pre_generated_token, pre_generated_email_id=pre_generated_email_id, ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) return handler( request, @@ -553,8 +553,8 @@ def unsubscribe(request, token): rate_limit_increment=False, extra_metrics_tags=["backend:braze"], ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) return update_user_task( request, @@ -672,8 +672,8 @@ def user(request, token): if settings.BRAZE_READ_WITH_FALLBACK_ENABLE: try: return get_user(token, masked=masked, use_braze_backend=True) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) return get_user(token, masked=masked, use_braze_backend=False) elif settings.BRAZE_ONLY_READ_ENABLE: return get_user(token, masked=masked, use_braze_backend=True) @@ -709,8 +709,8 @@ def send_recovery_message(request): extra_fields=["email_id"], use_braze_backend=True, ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) user_data = get_user_data( email=email, extra_fields=["email_id"], @@ -894,8 +894,8 @@ def lookup_user(request): masked=not authorized, use_braze_backend=True, ) - except Exception: - sentry_sdk.capture_exception() + except Exception as e: + sentry_sdk.capture_exception(e) user_data = get_user_data( token=token, email=email,
Primary Email