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 }}):

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

Raw Data:

{{ dsar_contact_pretty }}
{% elif dsar_submitted %}

Not Found:

-
User not found in CTMS
+
User not found in {{ vendor }}
{% endif %} diff --git a/basket/base/tests/test_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
Primary Email{{ dsar_contact.email.primary_email }}{{ dsar_contact.email }}
Basket Token{{ dsar_contact.email.basket_token }}{{ dsar_contact.token }}
Name{{ dsar_contact.email.first_name }} {{ dsar_contact.email.last_name }}{% if dsar_contact.first_name %}{{ dsar_contact.first_name }}{% endif %}{% if dsar_contact.last_name %} {{ dsar_contact.last_name }}{% endif %}
Language{{ dsar_contact.email.email_lang }}{{ dsar_contact.lang }}
Country{{ dsar_contact.email.mailing_country }}{{ dsar_contact.country }}
FxA ID{{ dsar_contact.fxa.fxa_id }}{{ dsar_contact.fxa_id }}
FxA Primary Email{{ dsar_contact.fxa.primary_email }}{{ dsar_contact.fxa_primary_email }}
MoFo Relevant?{{ dsar_contact.mofo.mofo_relevant|yesno }}{{ dsar_contact.mofo_relevant|yesno }}
Double Opt In?{{ dsar_contact.email.double_opt_in|yesno }}{{ dsar_contact.optin|yesno }}
Opt Out of All Email?{{ dsar_contact.email.has_opted_out_of_email|yesno }}{{ dsar_contact.optout|yesno }}
Subscriptions