diff --git a/basket/admin.py b/basket/admin.py
index 6cc9c04b9..0885a5028 100644
--- a/basket/admin.py
+++ b/basket/admin.py
@@ -11,32 +11,22 @@
import sentry_sdk
from basket.base.forms import EmailForm, EmailListForm
-from basket.news.backends.braze import braze
-from basket.news.backends.ctms import (
- CTMSNotFoundByEmailError,
- CTMSNotFoundByEmailIDError,
- ctms,
-)
-from basket.news.newsletters import newsletter_obj
+from basket.news.backends.braze import BrazeUserNotFoundByEmailError, braze
+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
@@ -104,27 +94,55 @@ 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)
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, 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:
contact = None
+ else:
+ # response could be 200 with an empty list
+ if contact:
+ if context["vendor"] == "Braze":
+ 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["dsar_contact"] = contact
- context["dsar_submitted"] = True
+ context["newsletter_names"] = get_newsletter_names(contact)
+ else:
+ contact = None
+
+ if not contact and fallback_to_ctms:
+ context["vendor"] = "CTMS or Braze"
+
+ context["dsar_contact"] = contact
+ context["dsar_submitted"] = True
+
+ if settings.BRAZE_READ_WITH_FALLBACK_ENABLE:
+ try:
+ handler(email, use_braze_backend=True, fallback_to_ctms=True)
+ 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)
+ else:
+ handler(email, use_braze_backend=False)
context["dsar_form"] = form
# adds default django admin context so sidebar shows etc.
@@ -152,20 +170,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 as e:
+ sentry_sdk.capture_exception(e)
+
+ 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)
@@ -173,7 +210,7 @@ def dsar_unsub_view(self, request):
form = EmailListForm()
context = {
- "title": "DSAR: Unsubscribe CTMS Users by Email Address",
+ "title": "DSAR: Unsubscribe Users by Email Address",
"dsar_form": form,
"dsar_output": output,
}
@@ -192,35 +229,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 as e:
+ sentry_sdk.capture_exception(e)
+
+ 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)
@@ -228,7 +272,7 @@ def dsar_delete_view(self, request):
form = EmailListForm()
context = {
- "title": "DSAR: Delete CTMS Data by Email Address",
+ "title": "DSAR: Delete Data by Email Address",
"dsar_form": form,
"dsar_output": output,
}
diff --git a/basket/base/templates/admin/dsar-info.html b/basket/base/templates/admin/dsar-info.html
index 6d655630c..727011b7a 100644
--- a/basket/base/templates/admin/dsar-info.html
+++ b/basket/base/templates/admin/dsar-info.html
@@ -69,53 +69,53 @@
{{ 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 }} |
- {% if dsar_contact.email.first_name %}
+ {% if dsar_contact.first_name or dsar_contact.last_name %}
| 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 %} |
{% endif %}
| Language |
- {{ dsar_contact.email.email_lang }} |
+ {{ dsar_contact.lang }} |
- {% if dsar_contact.email.mailing_country %}
+ {% if dsar_contact.country %}
| Country |
- {{ dsar_contact.email.mailing_country }} |
+ {{ dsar_contact.country }} |
{% endif %}
| FxA ID |
- {{ dsar_contact.fxa.fxa_id }} |
+ {{ dsar_contact.fxa_id }} |
- {% if dsar_contact.fxa.primary_email %}
+ {% if dsar_contact.fxa_primary_email %}
| FxA Primary Email |
- {{ dsar_contact.fxa.primary_email }} |
+ {{ dsar_contact.fxa_primary_email }} |
{% endif %}
| 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 |
@@ -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_rq_utils.py b/basket/base/tests/test_rq_utils.py
index 881dbda40..e36cf5e03 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):
diff --git a/basket/base/tests/test_view_admin_dsar.py b/basket/base/tests/test_view_admin_dsar.py
index 4c7a99810..d1f73b937 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:
@@ -72,11 +74,29 @@ 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):
+ @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:
@@ -85,7 +105,19 @@ 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"]
+
+ @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()
@@ -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,19 @@ 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"]}
+ @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.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):
@@ -130,7 +187,7 @@ 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()
@@ -140,7 +197,22 @@ def test_post_valid_email(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 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)
+ def test_post_valid_email_braze(self):
+ self._create_admin_user()
+ self._login_admin_user()
+ 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_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):
self._create_admin_user()
@@ -166,7 +238,51 @@ 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):
+ @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
+
+ @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:
+ 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"
+
+ @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 or Braze" in response.content
+
+ 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:
@@ -177,6 +293,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):
@@ -198,7 +326,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:
@@ -222,7 +350,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:
@@ -234,6 +382,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()
@@ -246,7 +407,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:
@@ -257,3 +431,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"]}
diff --git a/basket/news/api.py b/basket/news/api.py
index b58e752b7..fc86f7406 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:
+ 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:
+ 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 as e:
+ sentry_sdk.capture_exception(e)
+ 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)
@@ -160,7 +206,36 @@ def lookup_user(request, email: str | None = None, token: uuid.UUID | None = Non
return _invalid_email()
try:
- user_data = get_user_data(email=email, token=token, masked=masked)
+ if settings.BRAZE_READ_WITH_FALLBACK_ENABLE:
+ try:
+ user_data = get_user_data(
+ email=email,
+ token=token,
+ masked=masked,
+ use_braze_backend=True,
+ )
+ except Exception as e:
+ sentry_sdk.capture_exception(e)
+ user_data = get_user_data(
+ email=email,
+ token=token,
+ masked=masked,
+ use_braze_backend=False,
+ )
+ elif settings.BRAZE_ONLY_READ_ENABLE:
+ 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/news/auth.py b/basket/news/auth.py
index c67fc7647..2d66f0f1b 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/backends/braze.py b/basket/news/backends/braze.py
index a072f1857..6dbb56401 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
@@ -8,6 +9,12 @@
import requests
+from basket.base.utils import is_valid_uuid
+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):
@@ -34,6 +41,18 @@ class BrazeInternalServerError(Exception):
pass # 500 error (Braze server error)
+class BrazeUserNotFoundByEmailError(Exception):
+ pass
+
+
+class BrazeUserNotFoundByFxaIdError(Exception):
+ pass
+
+
+class BrazeUserNotFoundByTokenError(Exception):
+ pass
+
+
class BrazeClientError(Exception):
pass # any other error
@@ -43,9 +62,12 @@ class BrazeEndpoint(Enum):
USERS_EXPORT_IDS = "/users/export/ids"
USERS_TRACK = "/users/track"
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 BrazeClient:
+class BrazeInterface:
def __init__(self, base_url, api_key):
urlbits = urlparse(base_url)
if not urlbits.scheme or not urlbits.netloc:
@@ -58,7 +80,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.
@@ -86,10 +108,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(f"Params: {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:
@@ -165,7 +191,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, fxa_id=None):
"""
Export user profile by identifier.
@@ -174,25 +200,35 @@ def export_users(self, email, fields_to_export=None):
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]
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_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)
@@ -218,5 +254,345 @@ 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)
+
+ 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)
+
+ 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 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.
+
+ 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", [])
+
+ results.extend(external_ids)
+ errors.extend(rename_errors)
+
+ return {
+ "braze_collected_response": {
+ "external_ids": results,
+ "rename_errors": errors,
+ }
+ }
+
+
+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,
+ ):
+ """
+ Get a user using the first ID provided.
+
+ @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 not found
+ """
+
+ # 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,
+ [
+ "braze_id",
+ "country",
+ "created_at",
+ "custom_attributes",
+ "email",
+ "email_subscribe",
+ "external_id",
+ "first_name",
+ "language",
+ "last_name",
+ "user_aliases",
+ ],
+ token,
+ fxa_id,
+ )
+
+ if user_response["users"]:
+ user_data = user_response["users"][0]
+ user_email = email or user_data.get("email")
+ subscriptions = []
+
+ 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)
+
+ 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)
+
+ if data.get("fxa_id"):
+ self.interface.add_fxa_id_alias(external_id, data["fxa_id"])
+
+ token = data.get("token")
+ if token and settings.BRAZE_PARALLEL_WRITE_ENABLE:
+ self.interface.migrate_external_id(
+ [
+ {
+ "current_external_id": external_id,
+ "new_external_id": token,
+ }
+ ]
+ )
+ external_id = token
+
+ 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)
+
+ 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):
+ """
+ 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
+ self.update(existing_user, update_data)
+
+ def delete(self, email):
+ """
+ Delete the user matching the email
+
+ @param email: The email of the user
+ @return: deleted user data if successful
+ @raises: BrazeUserNotFoundByEmailError
+ """
+ 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")
+ 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}]
+
+ def from_vendor(self, braze_user_data, subscription_groups):
+ """
+ Converts Braze-formatted data to Basket-formatted data
+ """
+
+ 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"],
+ "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": 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",
+ "optout": braze_user_data.get("email_subscribe") == "unsubscribed",
+ "token": user_attributes.get("basket_token"),
+ "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,
+ "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
+
+ 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 {})
+
+ now = timezone.now().isoformat()
+ 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")
+ )
+
+ if not external_id:
+ raise ValueError("Missing Braze external_id")
+
+ subscription_groups = []
+ 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):
+ subscription_groups.append(
+ {
+ "subscription_group_id": vendor_id,
+ "subscription_state": "subscribed" if is_subscribed else "unsubscribed",
+ }
+ )
+
+ user_attributes = {
+ "external_id": external_id,
+ "email": updated_user_data.get("email"),
+ "update_timestamp": now,
+ "_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": [
+ {
+ "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_id")) or updated_user_data.get("has_fxa", False),
+ "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"),
+ "fxa_deleted": updated_user_data.get("fxa_deleted"),
+ "unsub_reason": updated_user_data.get("unsub_reason"),
+ }
+ ],
+ }
+
+ # 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 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
+ 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]}
+
+ if events:
+ braze_data["events"] = events
+
+ return braze_data
+
-braze = BrazeClient(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/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 000000000..7a8c19737
--- /dev/null
+++ b/basket/news/management/commands/process_braze_external_id_migrator.py
@@ -0,0 +1,120 @@
+import json
+import sys
+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_fxa_alias_chunk = self.strip_for_braze_fxa_alias(chunk)
+ braze_migration_chunk = self.strip_for_braze_migration(chunk)
+ try:
+ 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.035)
+ 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_migration(self, chunk):
+ return [
+ {
+ "current_external_id": item["current_external_id"],
+ "new_external_id": item["new_external_id"],
+ }
+ 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:])
+
+ 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", ""),
+ "fxa_id": getattr(row, "fxa_id", ""),
+ }
+ for row in df.itertuples(index=False)
+ ]
diff --git a/basket/news/management/commands/process_fxa_queue.py b/basket/news/management/commands/process_fxa_queue.py
index c6343fa50..524894694 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,
@@ -55,6 +56,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)
@@ -100,7 +102,33 @@ def handle(self, *args, **options):
continue
try:
- FXA_EVENT_TYPES[event_type].delay(event)
+ if settings.BRAZE_PARALLEL_WRITE_ENABLE:
+ pre_generated_token = generate_token()
+ pre_generated_email_id = generate_token()
+ 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,
+ use_braze_backend=True,
+ )
+ else:
+ 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/management/commands/push_message_to_queue.py b/basket/news/management/commands/push_message_to_queue.py
new file mode 100644
index 000000000..88d6b8431
--- /dev/null
+++ b/basket/news/management/commands/push_message_to_queue.py
@@ -0,0 +1,34 @@
+import json
+
+from django.conf import settings
+from django.core.management import BaseCommand
+
+import boto3
+
+
+class Command(BaseCommand):
+ def add_arguments(self, parser):
+ parser.add_argument("-b", "--body", type=str, default="{}", help="JSON body to process")
+ parser.add_argument(
+ "-e",
+ "--event",
+ type=str,
+ default="event",
+ help="Payload event",
+ )
+
+ def handle(self, *args, **options):
+ message = json.loads(options.get("body"))
+ event = options.get("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({"Message": json.dumps({"event": event, **message})}))
diff --git a/basket/news/newsletters.py b/basket/news/newsletters.py
index d7445caa3..4f4c1fe15 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())
diff --git a/basket/news/tasks.py b/basket/news/tasks.py
index 459a9c41a..dd341081d 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, braze_tx
from basket.news.backends.ctms import (
CTMSNotFoundByAltIDError,
CTMSUniqueIDConflictError,
@@ -44,7 +44,13 @@ def fxa_source_url(metrics):
@rq_task
-def fxa_email_changed(data):
+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"]
@@ -54,25 +60,38 @@ def fxa_email_changed(data):
# message older than our last update for this UID
return
- # Update CTMS
- user_data = get_user_data(fxa_id=fxa_id, extra_fields=["id", "email_id"])
+ # 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:
- 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 = {
"email": email,
- "token": generate_token(),
+ "token": pre_generated_token or generate_token(),
"fxa_id": fxa_id,
"fxa_primary_email": email,
}
- ctms_data = data.copy()
- contact = ctms.add(ctms_data)
+ if pre_generated_email_id:
+ data["email_id"] = pre_generated_email_id
+
+ backend_data = data.copy()
+ contact = None
+ if use_braze_backend:
+ 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 +99,34 @@ 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)
- except CTMSNotFoundByAltIDError:
+ if use_braze_backend:
+ braze.update_by_fxa_id(fxa_id, data)
+ else:
+ ctms.update_by_alt_id("fxa_id", fxa_id, data)
+ except (CTMSNotFoundByAltIDError, BrazeUserNotFoundByFxaIdError):
# 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, **kwargs):
+ 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,
+ 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"]):
@@ -129,16 +157,30 @@ 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=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):
+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 = {
@@ -150,11 +192,25 @@ 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=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):
+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):
@@ -171,20 +227,34 @@ def fxa_login(data):
"source_url": fxa_source_url(metrics_context),
"country": data.get("countryCode", ""),
},
+ 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 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 +270,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
@@ -221,6 +304,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)
@@ -229,11 +316,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)
@@ -276,14 +364,28 @@ 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()
+ token = update_data["token"] = pre_generated_token or generate_token()
+
+ if pre_generated_email_id:
+ update_data["email_id"] = 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 +408,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 +431,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 +443,14 @@ def upsert_contact(api_call_type, data, user_data):
return token, False
+@rq_task
+def braze_add_or_update(update_data, user_data=None):
+ if user_data is None:
+ braze.add(update_data)
+ else:
+ braze.update(user_data, update_data)
+
+
@rq_task
def ctms_add_or_update(update_data, user_data=None):
"""
@@ -359,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.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):
@@ -386,7 +502,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 +516,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,19 +534,25 @@ 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
-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
+ if use_braze_backend:
+ braze.update_by_token(token, {"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
@@ -455,7 +584,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
@@ -468,15 +597,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
diff --git a/basket/news/tests/__init__.py b/basket/news/tests/__init__.py
index 7739842d0..9c85f05d8 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
@@ -25,3 +25,28 @@ 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(self, *expected_args, **expected_kwargs):
+ """Assert that mock was called with at least the specified args/kwargs"""
+ assert self.called, f"{self} was not called"
+
+ actual_args, actual_kwargs = self.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]}")
+
+
+Mock.assert_called_with_subset = assert_called_with_subset
diff --git a/basket/news/tests/test_braze.py b/basket/news/tests/test_braze.py
index b15f45870..ea3bd6c24 100644
--- a/basket/news/tests/test_braze.py
+++ b/basket/news/tests/test_braze.py
@@ -1,5 +1,7 @@
+from collections import namedtuple
from unittest import mock
+from django.test.utils import override_settings
from django.utils import timezone
import pytest
@@ -7,28 +9,48 @@
from freezegun import freeze_time
from basket.news.backends import braze
+from basket.news.backends.braze import Braze
@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_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.BrazeInterface, "_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", "")
+ 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):
@@ -139,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"],
}
@@ -149,6 +171,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"
@@ -170,12 +214,30 @@ 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
+
+
+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", "http://test.com/users/alias/new", json={})
+ braze_client.add_fxa_id_alias(external_id, fxa_id)
assert m.last_request.json() == expected
@@ -228,3 +290,656 @@ 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",
+ "fxa_id": "fxa_123",
+ "has_fxa": True,
+ "fxa_deleted": None,
+ "unsub_reason": "unsub",
+}
+
+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",
+ "user_aliases": [{"alias_name": "fxa_123", "alias_label": "fxa_id"}],
+ "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,
+ "fxa_deleted": None,
+ "unsub_reason": "unsub",
+ }
+ ]
+ },
+}
+
+mock_braze_user_subscription_groups = [
+ {"id": "234c9b4a-1785-4cd5-b839-5dbc134982eb", "status": "Subscribed"},
+ {"id": "78fe6671-9f94-48bd-aaf3-7e873536c3e6", "status": "Unsubscribed"},
+]
+
+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(mock_newsletter_languages, mock_newsletters, 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": {"$time": "2022-01-02"},
+ "fxa_deleted": None,
+ "has_fxa": True,
+ "updated_at": {
+ "$time": dt.isoformat(),
+ },
+ "unsub_reason": "unsub",
+ }
+ ],
+ }
+ ]
+ }
+ 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(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", "unsub_reason": "unsub"}
+ expected = {
+ "attributes": [
+ {
+ "_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"},
+ ],
+ "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,
+ "fxa_deleted": None,
+ "mailing_country": None,
+ "updated_at": {
+ "$time": dt.isoformat(),
+ },
+ "unsub_reason": "unsub",
+ }
+ ],
+ }
+ ]
+ }
+ with freeze_time(dt):
+ 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", "unsub_reason": "unsub"}
+ 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,
+ "fxa_deleted": None,
+ "mailing_country": None,
+ "updated_at": {
+ "$time": dt.isoformat(),
+ },
+ "unsub_reason": "unsub",
+ }
+ ],
+ }
+ ]
+ }
+ 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,
+)
+@mock.patch(
+ "basket.news.newsletters.newsletter_languages",
+ return_value=["en"],
+)
+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,
+ "unsub_reason": "unsub",
+ }
+ 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": {
+ "$time": "2022-01-02",
+ },
+ "has_fxa": True,
+ "fxa_deleted": True,
+ "updated_at": {
+ "$time": dt.isoformat(),
+ },
+ "unsub_reason": "unsub",
+ }
+ ],
+ }
+ ]
+ }
+ 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_events(mock_newsletters, braze_client):
+ braze_instance = Braze(braze_client)
+ dt = timezone.now()
+ events = [
+ {
+ "name": "test event",
+ "time": dt,
+ "external_id": "123",
+ }
+ ]
+ 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": {"$time": "2022-01-02"},
+ "fxa_deleted": None,
+ "has_fxa": True,
+ "updated_at": {
+ "$time": dt.isoformat(),
+ },
+ "unsub_reason": "unsub",
+ }
+ ],
+ }
+ ],
+ "events": events,
+ }
+ with freeze_time(dt):
+ assert braze_instance.to_vendor(basket_user_data=mock_basket_user_data, update_data=None, events=events) == expected
+
+
+@mock.patch(
+ "basket.news.newsletters._newsletters",
+ return_value=mock_newsletters,
+)
+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:
+ 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
+
+ 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",
+ ],
+ "user_aliases": [],
+ }
+ 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",
+ "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={})
+ expected = {"email": {"email_id": new_user["email_id"]}}
+ with freeze_time():
+ response = braze_instance.add(new_user)
+ assert response == expected
+ assert m.last_request.json() == braze_instance.to_vendor(None, new_user)
+
+
+@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", "http://test.com/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",
+ 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",
+ 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,
+)
+@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, update_data)
+ 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", "http://test.com/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"]
+ 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"]}]})
+ 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
+
+
+@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"
+
+
+@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
new file mode 100644
index 000000000..122b7c1b3
--- /dev/null
+++ b/basket/news/tests/test_process_braze_external_id_migrator.py
@@ -0,0 +1,226 @@
+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"},
+ ]
+ )
+
+
+@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)
+ 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.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)
+ # 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):
+ 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.interface.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.interface.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.interface.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.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)
+
+
+@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.035)
diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py
index 6c94f711a..0bc2550ac 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()
@@ -560,51 +576,51 @@ 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.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"])
-@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."""
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"])
@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."""
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"])
-@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")
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"])
-@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")
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"])
diff --git a/basket/news/tests/test_update_user_task.py b/basket/news/tests/test_update_user_task.py
index 40116803f..7981cc403 100644
--- a/basket/news/tests/test_update_user_task.py
+++ b/basket/news/tests/test_update_user_task.py
@@ -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)
+ 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)
- self.upsert_user.delay.assert_called_with(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.
- self.upsert_user.delay.assert_called_with(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.
- self.upsert_user.delay.assert_called_with(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)
- self.upsert_user.delay.assert_called_with(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)
- self.upsert_user.delay.assert_called_with(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)
- self.upsert_contact.assert_called_with(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)
- self.upsert_user.delay.assert_called_with(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_users.py b/basket/news/tests/test_users.py
index 0d55421ef..0c870d3a3 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/tests/test_views.py b/basket/news/tests/test_views.py
index 03bd2b859..824bdeed2 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",
diff --git a/basket/news/utils.py b/basket/news/utils.py
index 7a5c13d24..7c52b7dd0 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,
@@ -166,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
@@ -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,20 @@ 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,
+ )
except CTMSNotFoundByAltIDError:
return None
except requests.exceptions.HTTPError as exc:
@@ -318,14 +327,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"] = backend_user.get("has_fxa", False) if use_braze_backend else bool(backend_user.get("fxa_id"))
if masked:
# mask all emails
@@ -337,7 +346,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 +359,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 f8cc7a7e8..c4751bd82 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,
@@ -129,48 +130,113 @@ 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")
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,
+ pre_generated_token=None,
+ pre_generated_email_id=None,
+ ):
+ if extra_metrics_tags is None:
+ extra_metrics_tags = []
try:
- token = tasks.upsert_contact(SUBSCRIBE, new_user_data, None)[0]
- except Exception:
- metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact"])
- sentry_sdk.capture_exception()
+ user_data = get_user_data(
+ email=email,
+ fxa_id=uid,
+ use_braze_backend=use_braze_backend,
+ )
+ except Exception as e:
+ metrics.incr("news.views.fxa_callback", tags=["status:error", "error:user_data", *extra_metrics_tags])
+ sentry_sdk.capture_exception(e)
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,
+ pre_generated_token=pre_generated_token,
+ pre_generated_email_id=pre_generated_email_id,
+ )[0]
+ except Exception as e:
+ metrics.incr("news.views.fxa_callback", tags=["status:error", "error:upsert_contact", *extra_metrics_tags])
+ sentry_sdk.capture_exception(e)
+ 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:
+ pre_generated_token = generate_token()
+ pre_generated_email_id = generate_token()
+ try:
+ handler(
+ email,
+ uid,
+ use_braze_backend=True,
+ 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 as e:
+ sentry_sdk.capture_exception(e)
+
+ return handler(
+ email,
+ uid,
+ 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(
+ 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,
+ )
@require_POST
@@ -185,7 +251,29 @@ 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,
+ )
+ 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,
+ )
+
return HttpResponseJSON({"status": "ok"})
@@ -274,86 +362,153 @@ 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)
-
- 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"
+ def handler(
+ request,
+ 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,
+ ):
+ 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,
+ )
- authorized = False
- if optin or sync:
- if is_authorized(request, email):
- authorized = True
+ email = data.pop("email", None)
+ token = data.pop("token", None)
- if optin and not authorized:
- # for backward compat we just ignore the optin if
- # no valid API key is sent.
- optin = False
+ 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
+
+ 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"
- return update_user_task(request, SUBSCRIBE, data=data, optin=optin, sync=sync)
+ 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 in both CTMS and Braze 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(
+ request,
+ 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 as e:
+ sentry_sdk.capture_exception(e)
+
+ return handler(
+ request,
+ use_braze_backend=False,
+ should_send_tx_messages=True,
+ rate_limit_increment=True,
+ pre_generated_token=pre_generated_token,
+ pre_generated_email_id=pre_generated_email_id,
+ )
+ elif settings.BRAZE_ONLY_WRITE_ENABLE:
+ return handler(
+ request,
+ use_braze_backend=True,
+ should_send_tx_messages=True,
+ rate_limit_increment=True,
+ extra_metrics_tags=["backend:braze"],
+ )
+ else:
+ return handler(
+ request,
+ use_braze_backend=False,
+ should_send_tx_messages=True,
+ rate_limit_increment=True,
+ )
def invalid_email_response():
@@ -387,7 +542,47 @@ 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 as e:
+ sentry_sdk.capture_exception(e)
+
+ return update_user_task(
+ request,
+ UNSUBSCRIBE,
+ data,
+ use_braze_backend=False,
+ should_send_tx_messages=True,
+ rate_limit_increment=True,
+ )
+ 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,
+ )
@require_POST
@@ -399,7 +594,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(
@@ -425,10 +626,59 @@ 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,
+ 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,
+ )
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 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)
+ else:
+ return get_user(token, masked=masked, use_braze_backend=False)
@require_POST
@@ -452,7 +702,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 as e:
+ sentry_sdk.capture_exception(e)
+ 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)
@@ -490,7 +765,29 @@ 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"],
+ 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"],
+ use_braze_backend=True,
+ )
+ else:
+ tasks.update_custom_unsub.delay(
+ request.POST["token"],
+ request.POST["reason"],
+ use_braze_backend=False,
+ )
return HttpResponseJSON({"status": "ok"})
@@ -589,7 +886,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 as e:
+ sentry_sdk.capture_exception(e)
+ 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)
@@ -616,12 +942,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"))
@@ -696,7 +1037,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()
@@ -707,15 +1048,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(
{
@@ -727,7 +1075,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)
@@ -743,8 +1096,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 6133ff12e..e2b578966 100644
--- a/basket/settings.py
+++ b/basket/settings.py
@@ -211,7 +211,11 @@ def path(*args):
# Send confirmation messages
SEND_CONFIRM_MESSAGES = config("SEND_CONFIRM_MESSAGES", parser=bool, default="false")
+# 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.
@@ -220,7 +224,11 @@ 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")
+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()
@@ -429,6 +437,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="") or None
# stage or production
# https://github.com/mozilla/PyFxA/blob/main/fxa/constants.py
diff --git a/docs/index.rst b/docs/index.rst
index 858beac91..1d85b6f56 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 ab39994bc..837e4c82c 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.
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 71576b65b..53c03b57e 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -4,292 +4,361 @@ annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \
--hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# pydantic
-anyio==4.9.0 \
- --hash=sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028 \
- --hash=sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c
+anyio==4.11.0 \
+ --hash=sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc \
+ --hash=sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4
# via watchfiles
apscheduler==3.11.0 \
--hash=sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133 \
--hash=sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da
- # via -r prod.txt
-asgiref==3.8.1 \
- --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
- --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
+ # via -r requirements/prod.txt
+asgiref==3.10.0 \
+ --hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \
+ --hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# django
# django-cors-headers
boto3==1.38.30 \
--hash=sha256:17af769544b5743843bcc732709b43226de19f1ebff2c324a3440bbecbddb893 \
--hash=sha256:949df0a0edd360f4ad60f1492622eecf98a359a2f72b1e236193d9b320c5dc8c
- # via -r prod.txt
-botocore==1.38.30 \
- --hash=sha256:530e40a6e91c8a096cab17fcc590d0c7227c8347f71a867576163a44d027a714 \
- --hash=sha256:7836c5041c5f249431dbd5471c61db17d4053f72a1d6e3b2197c07ca0839588b
+ # via -r requirements/prod.txt
+botocore==1.38.46 \
+ --hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
+ --hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# boto3
# s3transfer
-certifi==2025.4.26 \
- --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \
- --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3
+cachetools==6.2.2 \
+ --hash=sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace \
+ --hash=sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6
# via
- # -r prod.txt
+ # -r requirements/prod.txt
+ # google-auth
+certifi==2025.11.12 \
+ --hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
+ --hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
+ # 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 prod.txt
+ # -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 prod.txt
+ # -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 prod.txt
+ # -r requirements/prod.txt
# granian
# rq
contextlib2==21.6.0 \
--hash=sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f \
--hash=sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869
- # via -r 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 -r requirements/prod.txt
+coverage[toml]==7.11.3 \
+ --hash=sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63 \
+ --hash=sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa \
+ --hash=sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd \
+ --hash=sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060 \
+ --hash=sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5 \
+ --hash=sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704 \
+ --hash=sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507 \
+ --hash=sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5 \
+ --hash=sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5 \
+ --hash=sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5 \
+ --hash=sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b \
+ --hash=sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c \
+ --hash=sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0 \
+ --hash=sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2 \
+ --hash=sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f \
+ --hash=sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75 \
+ --hash=sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc \
+ --hash=sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1 \
+ --hash=sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64 \
+ --hash=sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7 \
+ --hash=sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001 \
+ --hash=sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237 \
+ --hash=sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31 \
+ --hash=sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131 \
+ --hash=sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe \
+ --hash=sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80 \
+ --hash=sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b \
+ --hash=sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e \
+ --hash=sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f \
+ --hash=sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c \
+ --hash=sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d \
+ --hash=sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e \
+ --hash=sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7 \
+ --hash=sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240 \
+ --hash=sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1 \
+ --hash=sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f \
+ --hash=sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb \
+ --hash=sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc \
+ --hash=sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f \
+ --hash=sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e \
+ --hash=sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926 \
+ --hash=sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428 \
+ --hash=sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2 \
+ --hash=sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832 \
+ --hash=sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de \
+ --hash=sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a \
+ --hash=sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36 \
+ --hash=sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7 \
+ --hash=sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87 \
+ --hash=sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5 \
+ --hash=sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a \
+ --hash=sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06 \
+ --hash=sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3 \
+ --hash=sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c \
+ --hash=sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb \
+ --hash=sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83 \
+ --hash=sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac \
+ --hash=sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9 \
+ --hash=sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2 \
+ --hash=sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd \
+ --hash=sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76 \
+ --hash=sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200 \
+ --hash=sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655 \
+ --hash=sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71 \
+ --hash=sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055 \
+ --hash=sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902 \
+ --hash=sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac \
+ --hash=sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044 \
+ --hash=sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094 \
+ --hash=sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4 \
+ --hash=sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944 \
+ --hash=sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362 \
+ --hash=sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86 \
+ --hash=sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a \
+ --hash=sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8 \
+ --hash=sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7 \
+ --hash=sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55 \
+ --hash=sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297 \
+ --hash=sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739 \
+ --hash=sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e \
+ --hash=sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46 \
+ --hash=sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405 \
+ --hash=sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1 \
+ --hash=sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e \
+ --hash=sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd \
+ --hash=sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820 \
+ --hash=sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e \
+ --hash=sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df \
+ --hash=sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428 \
+ --hash=sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7 \
+ --hash=sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c \
+ --hash=sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203
# via pytest-cov
-crontab==1.0.4 \
- --hash=sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92
+crontab==1.0.5 \
+ --hash=sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# rq-scheduler
cryptography==44.0.3 \
--hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \
@@ -330,26 +399,26 @@ cryptography==44.0.3 \
--hash=sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4 \
--hash=sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# josepy
# 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 prod.txt
+ # -r requirements/prod.txt
# markus
dj-database-url==3.0.0 \
--hash=sha256:749a7a42d88d6c741c1d2f4ab24c2ae0d5cd12f00f2d1d55ff9f5fadabe8a2c3 \
--hash=sha256:cbb84b2e3f372460b1e43692bf9fdc0c32e78930ee101db470cba56105fca1e5
- # via -r prod.txt
+ # via -r requirements/prod.txt
django==5.2.2 \
--hash=sha256:85852e517f84435e9b13421379cd6c43ef5b48a9c8b391d29a26f7900967e952 \
--hash=sha256:997ef2162d04ead6869551b22cde4e06da1f94cf595f4af3f3d3afeae1f3f6fe
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# dj-database-url
# django-allow-cidr
# django-cors-headers
@@ -360,51 +429,127 @@ django==5.2.2 \
django-allow-cidr==0.8.0 \
--hash=sha256:724ce76b7b4a25c641ddcd33777e2e95da622dd2c11937f0d76d0cd3d54b1622 \
--hash=sha256:d6f80230621dd5b19ec1665b85abf8218b02556ff7cf0ddda41e2607267a3277
- # via -r prod.txt
+ # via -r requirements/prod.txt
django-cache-url==3.4.5 \
--hash=sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c \
--hash=sha256:eb9fb194717524348c95cad9905b70b647452741c1d9e481fac6d2125f0ad917
- # via -r prod.txt
+ # via -r requirements/prod.txt
django-cors-headers==4.7.0 \
--hash=sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b \
--hash=sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070
- # via -r prod.txt
+ # via -r requirements/prod.txt
django-mozilla-product-details==1.0.3 \
--hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \
--hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36
- # via -r prod.txt
-django-ninja==1.4.3 \
- --hash=sha256:e46d477ca60c228d2a5eb3cc912094928ea830d364501f966661eeada67cb038 \
- --hash=sha256:f3204137a059437b95677049474220611f1cf9efedba9213556474b75168fa01
- # via -r prod.txt
+ # via -r requirements/prod.txt
+django-ninja==1.4.5 \
+ --hash=sha256:aa1a2ee2b22c5f1c2f4bfbc004386be7074cbfaf133680c2b359a31221965503 \
+ --hash=sha256:d779702ddc6e17b10739049ddb075a6a1e6c6270bdc04e0b0429f6adbf670373
+ # via -r requirements/prod.txt
django-ratelimit==4.1.0 \
--hash=sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b \
--hash=sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92
- # via -r prod.txt
+ # via -r requirements/prod.txt
django-watchman==1.3.0 \
--hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \
--hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb
- # via -r prod.txt
-dnspython==2.7.0 \
- --hash=sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86 \
- --hash=sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1
+ # via -r requirements/prod.txt
+dnspython==2.8.0 \
+ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
+ --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# email-validator
email-validator==2.2.0 \
--hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \
--hash=sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7
- # via -r prod.txt
+ # via -r requirements/prod.txt
everett==3.4.0 \
--hash=sha256:f403c4a41764a6301fb31e2558d6e9718999f0eab9e260d986b894fa2e6b6871 \
--hash=sha256:f8c29c7300702f47b7323b75348e2b86647246694fda7ad410c2a2bfaa980ff7
- # via -r prod.txt
-freezegun==1.5.2 \
- --hash=sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b \
- --hash=sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181
+ # via -r requirements/prod.txt
+freezegun==1.5.5 \
+ --hash=sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a \
+ --hash=sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# rq-scheduler
+google-api-core==2.28.1 \
+ --hash=sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8 \
+ --hash=sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c
+ # via
+ # -r requirements/prod.txt
+ # google-cloud-core
+ # google-cloud-storage
+google-auth==2.43.0 \
+ --hash=sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483 \
+ --hash=sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16
+ # via
+ # -r requirements/prod.txt
+ # google-api-core
+ # google-cloud-core
+ # google-cloud-storage
+google-cloud-core==2.5.0 \
+ --hash=sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc \
+ --hash=sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963
+ # via
+ # -r requirements/prod.txt
+ # google-cloud-storage
+google-cloud-storage==3.4.1 \
+ --hash=sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268 \
+ --hash=sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189
+ # via -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.72.0 \
+ --hash=sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038 \
+ --hash=sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5
+ # via
+ # -r requirements/prod.txt
+ # google-api-core
granian==2.3.2 \
--hash=sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3 \
--hash=sha256:0209cb0e981165cfa930e9d01dec96de5c832c69f0e902f1f8f11c1ff1f744a5 \
@@ -486,12 +631,12 @@ granian==2.3.2 \
--hash=sha256:f7360f4e70a4186e4e4fe67912b1675ceb30199107545ea1790090e5a548ef46 \
--hash=sha256:f7d844277f6eec7f87ca615c283026f3d0b29cdbc61c92c103d2a708936e6e1c \
--hash=sha256:fed8bdfc284ff00e9c530f7a5018d5d6281737fef9fcdd4aa5d69cac68f3d374
- # via -r prod.txt
+ # via -r requirements/prod.txt
hawkauthlib==2.0.0 \
--hash=sha256:935878d3a75832aa76f78ddee13491f1466cbd69a8e7e4248902763cf9953ba9 \
--hash=sha256:effd64a2572e3c0d9090b55ad2180b36ad50e7760bea225cb6ce2248f421510d
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# pyfxa
hiredis==3.2.1 \
--hash=sha256:0079ef1e03930b364556b78548e67236ab3def4e07e674f6adfc52944aa972dd \
@@ -603,41 +748,41 @@ hiredis==3.2.1 \
--hash=sha256:f9ad63cd9065820a43fb1efb8ed5ae85bb78f03ef5eb53f6bde47914708f5718 \
--hash=sha256:fec453a038c262e18d7de4919220b2916e0b17d1eadd12e7a800f09f78f84f39 \
--hash=sha256:ffd982c419f48e3a57f592678c72474429465bb4bfc96472ec805f5d836523f0
- # via -r prod.txt
-idna==3.10 \
- --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
- --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
+ # via -r requirements/prod.txt
+idna==3.11 \
+ --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
+ --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# anyio
# email-validator
# requests
-iniconfig==2.1.0 \
- --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \
- --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
+iniconfig==2.3.0 \
+ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
+ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
ipaddress==1.0.23 \
--hash=sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc \
--hash=sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2
- # via -r prod.txt
+ # via -r requirements/prod.txt
jmespath==1.0.1 \
--hash=sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980 \
--hash=sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe
# via
- # -r prod.txt
+ # -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 prod.txt
+ # -r requirements/prod.txt
# mozilla-django-oidc
legacy-cgi==2.6.3 \
--hash=sha256:4c119d6cb8e9d8b6ad7cc0ddad880552c62df4029622835d06dfd18f438a8154 \
--hash=sha256:6df2ea5ae14c71ef6f097f8b6372b44f6685283dc018535a75c924564183cdab
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# webob
lxml==5.4.0 \
--hash=sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5 \
@@ -772,18 +917,18 @@ lxml==5.4.0 \
--hash=sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c \
--hash=sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e \
--hash=sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539
- # via -r prod.txt
+ # via -r requirements/prod.txt
markus[datadog]==5.1.0 \
--hash=sha256:424172efdccc35172b8aadfdcd753412c3ed2b5651c3b3bc9e0b7e7f2e97da52 \
--hash=sha256:a4ec2d6bb1dcf471638be11a10cb5708de8cc3092ade9cf3b38bb2f651ede33a
- # via -r prod.txt
+ # via -r requirements/prod.txt
mozilla-django-oidc==4.0.1 \
--hash=sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca \
--hash=sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918
- # via -r prod.txt
+ # via -r requirements/prod.txt
msgpack-python==0.5.6 \
--hash=sha256:378cc8a6d3545b532dfd149da715abae4fda2a3adb6d74e525d0d5e51f46909b
- # via -r prod.txt
+ # via -r requirements/prod.txt
mysqlclient==2.2.7 \
--hash=sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076 \
--hash=sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5 \
@@ -793,162 +938,400 @@ mysqlclient==2.2.7 \
--hash=sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585 \
--hash=sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069 \
--hash=sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687
- # via -r prod.txt
-oauthlib==3.2.2 \
- --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \
- --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918
+ # 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.3.1 \
+ --hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \
+ --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# requests-oauthlib
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via 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/prod.txt
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
-pycparser==2.22 \
- --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
- --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
+proto-plus==1.26.1 \
+ --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \
+ --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012
# via
- # -r prod.txt
+ # -r requirements/prod.txt
+ # google-api-core
+protobuf==6.33.1 \
+ --hash=sha256:023af8449482fa884d88b4563d85e83accab54138ae098924a985bcbb734a213 \
+ --hash=sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53 \
+ --hash=sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1 \
+ --hash=sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed \
+ --hash=sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b \
+ --hash=sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa \
+ --hash=sha256:df051de4fd7e5e4371334e234c62ba43763f15ab605579e04c7008c05735cd82 \
+ --hash=sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178 \
+ --hash=sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b \
+ --hash=sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490
+ # 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/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.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.4 \
+ --hash=sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac \
+ --hash=sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e
# via
- # -r prod.txt
+ # -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.5 \
+ --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \
+ --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \
+ --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \
+ --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \
+ --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \
+ --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \
+ --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \
+ --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \
+ --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \
+ --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \
+ --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \
+ --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \
+ --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \
+ --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \
+ --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \
+ --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \
+ --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \
+ --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \
+ --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \
+ --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \
+ --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \
+ --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \
+ --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \
+ --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \
+ --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \
+ --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \
+ --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \
+ --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \
+ --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \
+ --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \
+ --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \
+ --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \
+ --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \
+ --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \
+ --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \
+ --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \
+ --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \
+ --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \
+ --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \
+ --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \
+ --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \
+ --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \
+ --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \
+ --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \
+ --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \
+ --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \
+ --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \
+ --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \
+ --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \
+ --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \
+ --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \
+ --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \
+ --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \
+ --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \
+ --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \
+ --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \
+ --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \
+ --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \
+ --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \
+ --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \
+ --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \
+ --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \
+ --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \
+ --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \
+ --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \
+ --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \
+ --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \
+ --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \
+ --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \
+ --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \
+ --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \
+ --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \
+ --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \
+ --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \
+ --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \
+ --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \
+ --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \
+ --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \
+ --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \
+ --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \
+ --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \
+ --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \
+ --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \
+ --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \
+ --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \
+ --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \
+ --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \
+ --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \
+ --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \
+ --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \
+ --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \
+ --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \
+ --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \
+ --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \
+ --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \
+ --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \
+ --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \
+ --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \
+ --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \
+ --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \
+ --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \
+ --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \
+ --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \
+ --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \
+ --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \
+ --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \
+ --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \
+ --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \
+ --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \
+ --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \
+ --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \
+ --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \
+ --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \
+ --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \
+ --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \
+ --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \
+ --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \
+ --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \
+ --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \
+ --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \
+ --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# pydantic
pyfxa==0.8.1 \
--hash=sha256:df3c575b314e8d67275fc8404294731a5cd39a75e36639fd8c5f8c76c1ee1a4c \
--hash=sha256:f12798fc5f3c9848c1de8048f333b7bdb3b0658daac506c843a4ebfc8df0efb8
- # via -r prod.txt
-pygments==2.19.1 \
- --hash=sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f \
- --hash=sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c
+ # via -r requirements/prod.txt
+pygments==2.19.2 \
+ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
+ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
# via pytest
pyjwt==2.10.1 \
--hash=sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953 \
--hash=sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# pyfxa
pyopenssl==25.1.0 \
--hash=sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab \
--hash=sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b
- # via -r prod.txt
+ # via -r requirements/prod.txt
pysilverpop==0.2.6 \
- --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1
- # via -r prod.txt
+ --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 \
+ --hash=sha256:cc92fb2e27486f99af4e41ebba811c39cc24d6634d1fdaa7f33639489492f346
+ # via -r requirements/prod.txt
pytest==8.4.0 \
--hash=sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6 \
--hash=sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e
# via
- # -r dev.in
+ # -r requirements/dev.in
# pytest-cov
# pytest-datadir
# pytest-django
@@ -956,27 +1339,34 @@ pytest==8.4.0 \
pytest-cov==6.1.1 \
--hash=sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a \
--hash=sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde
- # via -r dev.in
+ # via -r requirements/dev.in
pytest-datadir==1.7.1 \
--hash=sha256:12372417ff2cec4db8aecaf6b6fac119db91515f17e81c7926220e342148e3b4 \
--hash=sha256:367b4cd34b6ca3151317db310ab688ef9a28a9ec15e1e7d6696f4737b5f14bd8
- # via -r dev.in
+ # via -r requirements/dev.in
pytest-django==4.11.1 \
--hash=sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10 \
--hash=sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991
- # via -r dev.in
+ # via -r requirements/dev.in
pytest-mock==3.14.1 \
--hash=sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e \
--hash=sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0
- # via -r dev.in
+ # via -r requirements/dev.in
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via
- # -r prod.txt
+ # -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 \
@@ -1031,20 +1421,22 @@ pyyaml==6.0.2 \
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
- # via -r prod.txt
+ # via -r requirements/prod.txt
redis==6.2.0 \
--hash=sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e \
--hash=sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977
# via
- # -r prod.txt
+ # -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 prod.txt
+ # -r requirements/prod.txt
# datadog
# django-mozilla-product-details
+ # google-api-core
+ # google-cloud-storage
# mozilla-django-oidc
# pyfxa
# pysilverpop
@@ -1053,23 +1445,29 @@ requests==2.32.3 \
requests-mock==1.12.1 \
--hash=sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563 \
--hash=sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401
- # via -r dev.in
+ # via -r requirements/dev.in
requests-oauthlib==2.0.0 \
--hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \
--hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# pysilverpop
rq==2.3.3 \
--hash=sha256:20c41c977b6f27c852a41bd855893717402bae7b8d9607dca21fe9dd55453e22 \
--hash=sha256:2202c4409c4c527ac4bee409867d6c02515dd110030499eb0de54c7374aee0ce
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# rq-scheduler
rq-scheduler==0.14.0 \
--hash=sha256:2d5a14a1ab217f8693184ebaa1fe03838edcbc70b4f76572721c0b33058cd023 \
--hash=sha256:d4ec221a3d8c11b3ff55e041f09d9af1e17f3253db737b6b97e86ab20fc3dc0d
- # via -r prod.txt
+ # 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 \
@@ -1089,25 +1487,25 @@ ruff==0.11.12 \
--hash=sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c \
--hash=sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6 \
--hash=sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a
- # via -r dev.in
-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 prod.txt
+ # -r requirements/prod.txt
# boto3
sentry-processor==0.0.1 \
--hash=sha256:fd7a30fb57aaf05c01cd04cf7d949c628376b2b55d7a0aaa222efe58a8f122bc
- # via -r prod.txt
+ # via -r requirements/prod.txt
sentry-sdk==2.29.1 \
--hash=sha256:8d4a0206b95fa5fe85e5e7517ed662e3888374bdc342c00e435e10e6d831aa6d \
--hash=sha256:90862fe0616ded4572da6c9dadb363121a1ae49a49e21c418f0634e9d10b4c19
- # via -r prod.txt
+ # via -r requirements/prod.txt
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# pysilverpop
# python-dateutil
sniffio==1.3.1 \
@@ -1118,52 +1516,58 @@ sqlparse==0.5.3 \
--hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \
--hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca
# via
- # -r prod.txt
+ # -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 prod.txt
+ # -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 prod.txt
+ # -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
- # -r prod.txt
+ # -r requirements/prod.txt
# apscheduler
ua-parser==1.0.1 \
--hash=sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea \
--hash=sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# user-agents
ua-parser-builtins==0.18.0.post1 \
--hash=sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d
# via
- # -r prod.txt
+ # -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 prod.txt
+ # -r requirements/prod.txt
# botocore
# requests
# sentry-sdk
user-agents==2.2.0 \
--hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7 \
--hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26
- # via -r prod.txt
+ # via -r requirements/prod.txt
uv==0.9.5 \
--hash=sha256:02db727beb94a2137508cee5a785c3465d150954ca9abdff2d8157c76dea163e \
--hash=sha256:0316493044035098666d6e99c14bd61b352555d9717d57269f4ce531855330fa \
@@ -1184,7 +1588,7 @@ uv==0.9.5 \
--hash=sha256:c966e3a4fe4de3b0a6279d0a835c79f9cddbb3693f52d140910cbbed177c5742 \
--hash=sha256:d8835d2c034421ac2235fb658bb4f669a301a0f1eb00a8430148dd8461b65641 \
--hash=sha256:f8eb34ebebac4b45334ce7082cca99293b71fb32b164651f1727c8a640e5b387
- # via -r dev.in
+ # via -r requirements/dev.in
watchfiles==1.0.5 \
--hash=sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827 \
--hash=sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f \
@@ -1257,14 +1661,14 @@ watchfiles==1.0.5 \
--hash=sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965 \
--hash=sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21 \
--hash=sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57
- # via -r dev.in
+ # via -r requirements/dev.in
webob==1.8.9 \
--hash=sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9 \
--hash=sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589
# via
- # -r prod.txt
+ # -r requirements/prod.txt
# hawkauthlib
whitenoise==6.9.0 \
--hash=sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609 \
--hash=sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df
- # via -r prod.txt
+ # via -r requirements/prod.txt
diff --git a/requirements/prod.in b/requirements/prod.in
index 605dd4b26..137b2f43a 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
@@ -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 6f3f2044e..db0c32a60 100644
--- a/requirements/prod.txt
+++ b/requirements/prod.txt
@@ -8,9 +8,9 @@ apscheduler==3.11.0 \
--hash=sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133 \
--hash=sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da
# via -r requirements/prod.in
-asgiref==3.8.1 \
- --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
- --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
+asgiref==3.10.0 \
+ --hash=sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734 \
+ --hash=sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e
# via
# django
# django-cors-headers
@@ -18,184 +18,226 @@ boto3==1.38.30 \
--hash=sha256:17af769544b5743843bcc732709b43226de19f1ebff2c324a3440bbecbddb893 \
--hash=sha256:949df0a0edd360f4ad60f1492622eecf98a359a2f72b1e236193d9b320c5dc8c
# via -r requirements/prod.in
-botocore==1.38.30 \
- --hash=sha256:530e40a6e91c8a096cab17fcc590d0c7227c8347f71a867576163a44d027a714 \
- --hash=sha256:7836c5041c5f249431dbd5471c61db17d4053f72a1d6e3b2197c07ca0839588b
+botocore==1.38.46 \
+ --hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
+ --hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
# via
# boto3
# s3transfer
-certifi==2025.4.26 \
- --hash=sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6 \
- --hash=sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3
+cachetools==6.2.2 \
+ --hash=sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace \
+ --hash=sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6
+ # via google-auth
+certifi==2025.11.12 \
+ --hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
+ --hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
# via
# 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 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 requests
-click==8.2.1 \
- --hash=sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202 \
- --hash=sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b
+click==8.3.0 \
+ --hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \
+ --hash=sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4
# via
# granian
# rq
@@ -203,8 +245,8 @@ contextlib2==21.6.0 \
--hash=sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f \
--hash=sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869
# via -r requirements/prod.in
-crontab==1.0.4 \
- --hash=sha256:715b0e5e105bc62c9683cbb93c1cc5821e07a3e28d17404576d22dba7a896c92
+crontab==1.0.5 \
+ --hash=sha256:f80e01b4f07219763a9869f926dd17147278e7965a928089bca6d3dc80ae46d5
# via rq-scheduler
cryptography==44.0.3 \
--hash=sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259 \
@@ -250,9 +292,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 markus
dj-database-url==3.0.0 \
--hash=sha256:749a7a42d88d6c741c1d2f4ab24c2ae0d5cd12f00f2d1d55ff9f5fadabe8a2c3 \
@@ -286,9 +328,9 @@ django-mozilla-product-details==1.0.3 \
--hash=sha256:1d139ba01f4484f3bb43b72864ce33f249835405449e0dc940217cfa42ce5b46 \
--hash=sha256:a4aba6a68b296dffe8c1afb95d236cdbd402bd855cd49eef4d8a1a610105fd36
# via -r requirements/prod.in
-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.in
django-ratelimit==4.1.0 \
--hash=sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b \
@@ -298,9 +340,9 @@ django-watchman==1.3.0 \
--hash=sha256:33b5fc734d689b83cb96fc17beda624ae2955f4cede0856897d990c363eac962 \
--hash=sha256:5f04300bd7fbdd63b8a883b2730ed1e4d9b0f9991133b33a1281134b81f466eb
# via -r requirements/prod.in
-dnspython==2.7.0 \
- --hash=sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86 \
- --hash=sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1
+dnspython==2.8.0 \
+ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
+ --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
# via email-validator
email-validator==2.2.0 \
--hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \
@@ -310,10 +352,77 @@ everett==3.4.0 \
--hash=sha256:f403c4a41764a6301fb31e2558d6e9718999f0eab9e260d986b894fa2e6b6871 \
--hash=sha256:f8c29c7300702f47b7323b75348e2b86647246694fda7ad410c2a2bfaa980ff7
# via -r requirements/prod.in
-freezegun==1.5.2 \
- --hash=sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b \
- --hash=sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181
+freezegun==1.5.5 \
+ --hash=sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a \
+ --hash=sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2
# via rq-scheduler
+google-api-core==2.28.1 \
+ --hash=sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8 \
+ --hash=sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c
+ # via
+ # google-cloud-core
+ # google-cloud-storage
+google-auth==2.43.0 \
+ --hash=sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483 \
+ --hash=sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16
+ # via
+ # google-api-core
+ # google-cloud-core
+ # google-cloud-storage
+google-cloud-core==2.5.0 \
+ --hash=sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc \
+ --hash=sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963
+ # via google-cloud-storage
+google-cloud-storage==3.4.1 \
+ --hash=sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268 \
+ --hash=sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189
+ # via -r requirements/prod.in
+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
+ # google-cloud-storage
+ # google-resumable-media
+google-resumable-media==2.7.2 \
+ --hash=sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa \
+ --hash=sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0
+ # via google-cloud-storage
+googleapis-common-protos==1.72.0 \
+ --hash=sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038 \
+ --hash=sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5
+ # via google-api-core
granian==2.3.2 \
--hash=sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3 \
--hash=sha256:0209cb0e981165cfa930e9d01dec96de5c832c69f0e902f1f8f11c1ff1f744a5 \
@@ -511,9 +620,9 @@ hiredis==3.2.1 \
--hash=sha256:fec453a038c262e18d7de4919220b2916e0b17d1eadd12e7a800f09f78f84f39 \
--hash=sha256:ffd982c419f48e3a57f592678c72474429465bb4bfc96472ec805f5d836523f0
# via -r requirements/prod.in
-idna==3.10 \
- --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
- --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
+idna==3.11 \
+ --hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
+ --hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via
# email-validator
# requests
@@ -527,14 +636,16 @@ jmespath==1.0.1 \
# via
# boto3
# botocore
-josepy==2.0.0 \
- --hash=sha256:e7d7acd2fe77435cda76092abe4950bb47b597243a8fb733088615fa6de9ec40 \
- --hash=sha256:eb50ec564b1b186b860c7738769274b97b19b5b831239669c0f3d5c86b62a4c0
+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.in
+ # via
+ # -r requirements/prod.in
+ # webob
lxml==5.4.0 \
--hash=sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5 \
--hash=sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b \
@@ -690,118 +801,347 @@ mysqlclient==2.2.7 \
--hash=sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069 \
--hash=sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687
# via -r requirements/prod.in
-oauthlib==3.2.2 \
- --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \
- --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918
+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 pandas
+oauthlib==3.3.1 \
+ --hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \
+ --hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1
# via requests-oauthlib
-pycparser==2.22 \
- --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
- --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
+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/prod.in
+proto-plus==1.26.1 \
+ --hash=sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66 \
+ --hash=sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012
+ # via google-api-core
+protobuf==6.33.1 \
+ --hash=sha256:023af8449482fa884d88b4563d85e83accab54138ae098924a985bcbb734a213 \
+ --hash=sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53 \
+ --hash=sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1 \
+ --hash=sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed \
+ --hash=sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b \
+ --hash=sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa \
+ --hash=sha256:df051de4fd7e5e4371334e234c62ba43763f15ab605579e04c7008c05735cd82 \
+ --hash=sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178 \
+ --hash=sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b \
+ --hash=sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490
+ # via
+ # 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/prod.in
+pyasn1==0.6.1 \
+ --hash=sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629 \
+ --hash=sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034
+ # via
+ # pyasn1-modules
+ # rsa
+pyasn1-modules==0.4.2 \
+ --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
+ --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
+ # via google-auth
+pycparser==2.23 \
+ --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
+ --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
# via cffi
-pydantic==2.11.5 \
- --hash=sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a \
- --hash=sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7
+pydantic==2.12.4 \
+ --hash=sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac \
+ --hash=sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e
# via 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.5 \
+ --hash=sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90 \
+ --hash=sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740 \
+ --hash=sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504 \
+ --hash=sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84 \
+ --hash=sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33 \
+ --hash=sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c \
+ --hash=sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0 \
+ --hash=sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e \
+ --hash=sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0 \
+ --hash=sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a \
+ --hash=sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34 \
+ --hash=sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2 \
+ --hash=sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3 \
+ --hash=sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815 \
+ --hash=sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14 \
+ --hash=sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba \
+ --hash=sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375 \
+ --hash=sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf \
+ --hash=sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963 \
+ --hash=sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1 \
+ --hash=sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808 \
+ --hash=sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553 \
+ --hash=sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1 \
+ --hash=sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2 \
+ --hash=sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5 \
+ --hash=sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470 \
+ --hash=sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2 \
+ --hash=sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b \
+ --hash=sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660 \
+ --hash=sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c \
+ --hash=sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093 \
+ --hash=sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5 \
+ --hash=sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594 \
+ --hash=sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008 \
+ --hash=sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a \
+ --hash=sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a \
+ --hash=sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd \
+ --hash=sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284 \
+ --hash=sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586 \
+ --hash=sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869 \
+ --hash=sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294 \
+ --hash=sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f \
+ --hash=sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66 \
+ --hash=sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51 \
+ --hash=sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc \
+ --hash=sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97 \
+ --hash=sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a \
+ --hash=sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d \
+ --hash=sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9 \
+ --hash=sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c \
+ --hash=sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07 \
+ --hash=sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36 \
+ --hash=sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e \
+ --hash=sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05 \
+ --hash=sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e \
+ --hash=sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941 \
+ --hash=sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3 \
+ --hash=sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612 \
+ --hash=sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3 \
+ --hash=sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b \
+ --hash=sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe \
+ --hash=sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146 \
+ --hash=sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11 \
+ --hash=sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60 \
+ --hash=sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd \
+ --hash=sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b \
+ --hash=sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c \
+ --hash=sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a \
+ --hash=sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460 \
+ --hash=sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1 \
+ --hash=sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf \
+ --hash=sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf \
+ --hash=sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858 \
+ --hash=sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2 \
+ --hash=sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9 \
+ --hash=sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2 \
+ --hash=sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3 \
+ --hash=sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6 \
+ --hash=sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770 \
+ --hash=sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d \
+ --hash=sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc \
+ --hash=sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23 \
+ --hash=sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26 \
+ --hash=sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa \
+ --hash=sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8 \
+ --hash=sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d \
+ --hash=sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3 \
+ --hash=sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d \
+ --hash=sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034 \
+ --hash=sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9 \
+ --hash=sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1 \
+ --hash=sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56 \
+ --hash=sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b \
+ --hash=sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c \
+ --hash=sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a \
+ --hash=sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e \
+ --hash=sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9 \
+ --hash=sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5 \
+ --hash=sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a \
+ --hash=sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556 \
+ --hash=sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e \
+ --hash=sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49 \
+ --hash=sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2 \
+ --hash=sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9 \
+ --hash=sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b \
+ --hash=sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc \
+ --hash=sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb \
+ --hash=sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0 \
+ --hash=sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8 \
+ --hash=sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82 \
+ --hash=sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69 \
+ --hash=sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b \
+ --hash=sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c \
+ --hash=sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75 \
+ --hash=sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5 \
+ --hash=sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f \
+ --hash=sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad \
+ --hash=sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b \
+ --hash=sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7 \
+ --hash=sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425 \
+ --hash=sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52
# via pydantic
pyfxa==0.8.1 \
--hash=sha256:df3c575b314e8d67275fc8404294731a5cd39a75e36639fd8c5f8c76c1ee1a4c \
@@ -816,7 +1156,8 @@ pyopenssl==25.1.0 \
--hash=sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b
# via -r requirements/prod.in
pysilverpop==0.2.6 \
- --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1
+ --hash=sha256:27d08fd7823ece74a21e70ae9becded12d25c480bcdba9e8bd37e02ecb0f53e1 \
+ --hash=sha256:cc92fb2e27486f99af4e41ebba811c39cc24d6634d1fdaa7f33639489492f346
# via -r requirements/prod.in
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
@@ -824,7 +1165,12 @@ python-dateutil==2.9.0.post0 \
# via
# botocore
# freezegun
+ # pandas
# rq-scheduler
+pytz==2025.2 \
+ --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
+ --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
+ # via pandas
pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \
@@ -886,12 +1232,14 @@ redis==6.2.0 \
# via
# -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
# datadog
# django-mozilla-product-details
+ # google-api-core
+ # google-cloud-storage
# mozilla-django-oidc
# pyfxa
# pysilverpop
@@ -910,9 +1258,13 @@ rq-scheduler==0.14.0 \
--hash=sha256:2d5a14a1ab217f8693184ebaa1fe03838edcbc70b4f76572721c0b33058cd023 \
--hash=sha256:d4ec221a3d8c11b3ff55e041f09d9af1e17f3253db737b6b97e86ab20fc3dc0d
# via -r requirements/prod.in
-s3transfer==0.13.0 \
- --hash=sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be \
- --hash=sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177
+rsa==4.9.1 \
+ --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 \
+ --hash=sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75
+ # via google-auth
+s3transfer==0.13.1 \
+ --hash=sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724 \
+ --hash=sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf
# via boto3
sentry-processor==0.0.1 \
--hash=sha256:fd7a30fb57aaf05c01cd04cf7d949c628376b2b55d7a0aaa222efe58a8f122bc
@@ -931,19 +1283,22 @@ sqlparse==0.5.3 \
--hash=sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272 \
--hash=sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca
# via django
-typing-extensions==4.14.0 \
- --hash=sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4 \
- --hash=sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af
+typing-extensions==4.15.0 \
+ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
+ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via
# dj-database-url
# pydantic
# pydantic-core
- # pyopenssl
# 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 pydantic
+tzdata==2025.2 \
+ --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \
+ --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9
+ # via pandas
tzlocal==5.3.1 \
--hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
--hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
@@ -955,9 +1310,9 @@ ua-parser==1.0.1 \
ua-parser-builtins==0.18.0.post1 \
--hash=sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d
# via ua-parser
-urllib3==2.4.0 \
- --hash=sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466 \
- --hash=sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813
+urllib3==2.5.0 \
+ --hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
+ --hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
# via
# botocore
# requests