diff --git a/core/notifications.py b/core/notifications.py new file mode 100644 index 00000000..cff6522f --- /dev/null +++ b/core/notifications.py @@ -0,0 +1,98 @@ +"""Общая лента, сохраняющая совместимость прежнего API уведомлений экспертов.""" + +import re + +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.services.registration_notifications import ( + visible_request_notifications, +) +from project_rates.notifications import ( + visible_notifications as visible_expert_notifications, +) + + +def unread_count(user): + return ( + visible_expert_notifications(user).filter(read_at__isnull=True).count() + + visible_request_notifications(user).filter(read_at__isnull=True).count() + ) + + +class NotificationsView(APIView): + permission_classes = [IsAuthenticated] + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + def get(self, request, notification_id=None): + if notification_id is not None: + return Response(status=405) + experts = visible_expert_notifications(request.user) + connections = visible_request_notifications(request.user) + results = [ + { + "id": f"expert_joined:{row.pk}", + "kind": "expert_joined", + "text": ( + f"Эксперт {row.expert_name} ({row.expert_email}) вошел на платформу " + f"и принял приглашение в программу «{row.invitation.partner_program.name}»." + ), + "program_id": row.invitation.partner_program_id, + "created_at": row.created_at, + "read_at": row.read_at, + } + for row in experts[:100] + ] + results.extend( + { + "id": f"registration_connection:{row.pk}", + "kind": "registration_connection", + "text": ( + f"Запрос на подключение сайта для программы " + f"«{row.request.registration.program.name}». Автор: {row.request.requester_name}." + ), + "program_id": row.request.registration.program_id, + "registration_id": row.request.registration_id, + "created_at": row.request.created_at, + "read_at": row.read_at, + } + for row in connections[:100] + ) + results.sort(key=lambda item: (item["created_at"], item["id"]), reverse=True) + return Response( + {"unread_count": unread_count(request.user), "results": results[:100]} + ) + + def post(self, request, notification_id=None): + if notification_id is None: + return Response(status=405) + match = re.fullmatch( + r"(expert_joined|registration_connection):([1-9][0-9]{0,18})", notification_id + ) + if not match or int(match[2]) > 2**63 - 1: + raise Http404 + kind, pk = match[1], int(match[2]) + rows = ( + visible_expert_notifications(request.user) + if kind == "expert_joined" + else visible_request_notifications(request.user) + ) + notice = get_object_or_404(rows, pk=pk) + if notice.read_at is None: + rows.filter(pk=pk, read_at__isnull=True).update(read_at=timezone.now()) + notice.refresh_from_db(fields=["read_at"]) + return Response( + { + "id": notification_id, + "read_at": notice.read_at, + "unread_count": unread_count(request.user), + } + ) diff --git a/core/urls.py b/core/urls.py index 86599c86..5d3474b7 100644 --- a/core/urls.py +++ b/core/urls.py @@ -4,10 +4,13 @@ SkillsNestedView, SkillsInlineView, ) +from core.notifications import NotificationsView app_name = "core" urlpatterns = [ + path("notifications/", NotificationsView.as_view()), + path("notifications//read/", NotificationsView.as_view()), path("skills/nested/", SkillsNestedView.as_view()), path("skills/inline/", SkillsInlineView.as_view()), ] diff --git a/partner_programs/migrations/0027_registration_request_notifications.py b/partner_programs/migrations/0027_registration_request_notifications.py new file mode 100644 index 00000000..58695642 --- /dev/null +++ b/partner_programs/migrations/0027_registration_request_notifications.py @@ -0,0 +1,126 @@ +# Generated by Django 4.2.11 on 2026-10-20 12:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0026_interface_copy"), + ] + + operations = [ + migrations.CreateModel( + name="RegistrationConnectionRequest", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("revision", models.PositiveIntegerField()), + ("requester_name", models.CharField(max_length=800)), + ("requester_email", models.EmailField(max_length=254)), + ("website_url", models.URLField()), + ("comment", models.TextField(blank=True)), + ("contact", models.CharField(blank=True, max_length=500)), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ( + "registration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="connection_requests", + to="partner_programs.programregistration", + ), + ), + ( + "requested_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="registration_connection_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="RegistrationConnectionNotification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("read_at", models.DateTimeField(blank=True, null=True)), + ("emailed_at", models.DateTimeField(blank=True, null=True)), + ( + "delivery_status", + models.CharField( + choices=[ + ("queued", "queued"), + ("sent", "sent"), + ("failed", "failed"), + ("cancelled", "cancelled"), + ], + default="queued", + max_length=12, + ), + ), + ("attempts", models.PositiveSmallIntegerField(default=0)), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="registration_connection_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="partner_programs.registrationconnectionrequest", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="registrationconnectionrequest", + constraint=models.UniqueConstraint( + fields=("registration", "revision"), + name="unique_connection_request_revision", + ), + ), + migrations.AddIndex( + model_name="registrationconnectionnotification", + index=models.Index( + fields=["delivery_status", "id"], name="partner_pro_deliver_9389f1_idx" + ), + ), + migrations.AddConstraint( + model_name="registrationconnectionnotification", + constraint=models.UniqueConstraint( + fields=("request", "recipient"), + name="unique_connection_request_recipient", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 53571857..56890b52 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -9,6 +9,8 @@ from partner_programs.registration_models import ( # noqa: F401 ProgramRegistration, PendingProgramRegistration, + RegistrationConnectionRequest, + RegistrationConnectionNotification, ) User = get_user_model() diff --git a/partner_programs/registration_models.py b/partner_programs/registration_models.py index e99bc55f..e82d60e6 100644 --- a/partner_programs/registration_models.py +++ b/partner_programs/registration_models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.utils import timezone class ProgramRegistration(models.Model): @@ -26,9 +27,7 @@ class ProgramRegistration(models.Model): external_url = models.URLField(blank=True) connection_website_url = models.URLField("Сайт мероприятия", blank=True) connection_comment = models.TextField(blank=True) - connection_contact = models.CharField( - "Контакт для связи", max_length=500, blank=True - ) + connection_contact = models.CharField("Контакт для связи", max_length=500, blank=True) requested_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL ) @@ -63,3 +62,62 @@ class Meta: fields=["program", "user"], name="unique_pending_program_registration" ) ] + + +class RegistrationConnectionRequest(models.Model): + """Снимок запроса; изменения анкеты не переписывают отправленные детали.""" + + registration = models.ForeignKey( + ProgramRegistration, on_delete=models.CASCADE, related_name="connection_requests" + ) + revision = models.PositiveIntegerField() + requested_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + on_delete=models.SET_NULL, + related_name="registration_connection_requests", + ) + requester_name = models.CharField(max_length=800) + requester_email = models.EmailField() + website_url = models.URLField() + comment = models.TextField(blank=True) + contact = models.CharField(max_length=500, blank=True) + created_at = models.DateTimeField(default=timezone.now, editable=False) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["registration", "revision"], + name="unique_connection_request_revision", + ) + ] + + +class RegistrationConnectionNotification(models.Model): + request = models.ForeignKey( + RegistrationConnectionRequest, + on_delete=models.CASCADE, + related_name="notifications", + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="registration_connection_notifications", + ) + read_at = models.DateTimeField(null=True, blank=True) + emailed_at = models.DateTimeField(null=True, blank=True) + delivery_status = models.CharField( + max_length=12, + default="queued", + choices=[(value, value) for value in ("queued", "sent", "failed", "cancelled")], + ) + attempts = models.PositiveSmallIntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["request", "recipient"], + name="unique_connection_request_recipient", + ) + ] + indexes = [models.Index(fields=["delivery_status", "id"])] diff --git a/partner_programs/registration_views.py b/partner_programs/registration_views.py index a864ac2f..31351d90 100644 --- a/partner_programs/registration_views.py +++ b/partner_programs/registration_views.py @@ -138,6 +138,15 @@ def post(self, request, pk): program = managed_program(pk, request.user, lock=True) config = service.locked_config(program) if config.external_status != "connected": + duplicate = ( + config.external_status == "requested" + and config.connection_website_url == data["url"] + and config.connection_comment == data["comment"] + and config.connection_contact == data["contact"] + and config.connection_requests.exists() + ) + if duplicate: + return Response(service.settings_data(program)) config.connection_website_url = data["url"] config.connection_comment = data["comment"] config.connection_contact = data["contact"] @@ -146,6 +155,9 @@ def post(self, request, pk): config.external_status = "requested" config.revision += 1 config.save() + from partner_programs.services.registration_notifications import record_request + + record_request(config, request.user) return Response(service.settings_data(program)) diff --git a/partner_programs/services/registration_notifications.py b/partner_programs/services/registration_notifications.py new file mode 100644 index 00000000..493e6992 --- /dev/null +++ b/partner_programs/services/registration_notifications.py @@ -0,0 +1,167 @@ +"""Личные уведомления администраторов и восстанавливаемая очередь писем.""" + +import logging +from functools import partial + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.db import transaction +from django.db.models import Q +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils import timezone + +from partner_programs.models import ( + RegistrationConnectionRequest, + RegistrationConnectionNotification, +) +from partner_programs.services.management import person_name +from users.models import CustomUser + +logger = logging.getLogger(__name__) +MAX_ATTEMPTS = 4 + + +def is_administrator(user): + return user.is_active and (user.is_staff or user.is_superuser) + + +def fanout_request(event): + ids = [] + administrators = CustomUser.objects.filter(is_active=True).filter( + Q(is_staff=True) | Q(is_superuser=True) + ) + for recipient in administrators: + notice, _ = RegistrationConnectionNotification.objects.get_or_create( + request=event, recipient=recipient + ) + ids.append(notice.pk) + return ids + + +def queue_notifications(ids): + from partner_programs.tasks import send_registration_connection_notification + + for notification_id in ids: + try: + send_registration_connection_notification.delay(notification_id) + except Exception: + # Запись остается queued; ее подберет периодическая задача. + logger.exception( + "Unable to enqueue registration notification %s", notification_id + ) + + +def record_request(config, user): + event = RegistrationConnectionRequest.objects.create( + registration=config, + revision=config.revision, + requested_by=user, + requester_name=person_name(user), + requester_email=user.email, + website_url=config.connection_website_url, + comment=config.connection_comment, + contact=config.connection_contact, + created_at=config.requested_at, + ) + ids = fanout_request(event) + transaction.on_commit(partial(queue_notifications, ids), robust=True) + return event + + +def visible_request_notifications(user): + rows = RegistrationConnectionNotification.objects.none() + if is_administrator(user): + rows = RegistrationConnectionNotification.objects.filter(recipient=user) + return rows.select_related("request__registration__program").order_by( + "-request__created_at", "-pk" + ) + + +def deliver_notification(notification_id): + error = None + # Блокировка и emailed_at защищают от параллельных повторов одной задачи. + with transaction.atomic(): + notice = ( + RegistrationConnectionNotification.objects.select_for_update(of=("self",)) + .select_related("request__registration__program", "recipient") + .filter(pk=notification_id) + .first() + ) + if ( + not notice + or notice.emailed_at + or notice.attempts >= MAX_ATTEMPTS + or notice.delivery_status == "cancelled" + ): + return + recipient = notice.recipient + if not is_administrator(recipient): + notice.delivery_status = "cancelled" + notice.save(update_fields=["delivery_status"]) + return + if not recipient.email: + notice.delivery_status = "failed" + notice.attempts = MAX_ATTEMPTS + notice.save(update_fields=["delivery_status", "attempts"]) + return + event = notice.request + context = { + "program_name": event.registration.program.name, + "website_url": event.website_url, + "requester_name": event.requester_name, + "requester_email": event.requester_email, + "comment": event.comment, + "contact": event.contact, + "created_at": event.created_at, + "admin_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + reverse( + "admin:partner_programs_programregistration_change", + args=[event.registration_id], + ), + } + notice.attempts += 1 + try: + message = EmailMultiAlternatives( + "Запрос на подключение сайта мероприятия | PROCOLLAB", + render_to_string("email/registration_connection_request.txt", context), + settings.EMAIL_USER, + [recipient.email], + ) + message.attach_alternative( + render_to_string("email/registration_connection_request.html", context), + "text/html", + ) + if not message.send(): + raise RuntimeError("Registration request email delivery did not complete") + except Exception as exc: + error = exc + notice.delivery_status = ( + "failed" if notice.attempts >= MAX_ATTEMPTS else "queued" + ) + else: + notice.delivery_status = "sent" + notice.emailed_at = timezone.now() + notice.save(update_fields=["attempts", "delivery_status", "emailed_at"]) + # Состояние не откатывается при запуске autoretry Celery. + if error is not None: + raise error + + +def dispatch_pending_notifications(): + # Запрос без получателей остается событием до появления активного администратора. + for event in RegistrationConnectionRequest.objects.filter( + notifications__isnull=True + ).order_by("pk")[:100]: + fanout_request(event) + ids = list( + RegistrationConnectionNotification.objects.filter( + delivery_status="queued", + emailed_at__isnull=True, + attempts__lt=MAX_ATTEMPTS, + ) + .order_by("pk") + .values_list("pk", flat=True)[:100] + ) + queue_notifications(ids) + return len(ids) diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index 7e23c6f0..417d562d 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -6,6 +6,20 @@ logger = logging.getLogger(__name__) +@app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def send_registration_connection_notification(notification_id): + from partner_programs.services.registration_notifications import deliver_notification + + deliver_notification(notification_id) + + +@app.task +def dispatch_registration_connection_notifications(): + from partner_programs.services.registration_notifications import dispatch_pending_notifications + + return dispatch_pending_notifications() + + @app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) def send_program_registration_confirmation(pending_id, generation): from django.conf import settings diff --git a/partner_programs/tests/test_registration_notifications.py b/partner_programs/tests/test_registration_notifications.py new file mode 100644 index 00000000..57621e9c --- /dev/null +++ b/partner_programs/tests/test_registration_notifications.py @@ -0,0 +1,395 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch + +from django.core import mail +from django.db import connections, transaction +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from rest_framework.test import APIClient + +from partner_programs.models import ( + ProgramRegistration, + RegistrationConnectionRequest, + RegistrationConnectionNotification, +) +from partner_programs.services.registration_notifications import ( + deliver_notification, + dispatch_pending_notifications, +) +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.models import ExpertInvitation, ExpertJoinedNotification +from django.utils import timezone +from datetime import timedelta + + +SETTINGS = { + "EXPERT_WORKSPACE_FRONTEND_URL": "https://dev.procollab.ru", + "EMAIL_USER": "no-reply@example.test", + "EMAIL_BACKEND": "django.core.mail.backends.locmem.EmailBackend", + "CELERY_TASK_ALWAYS_EAGER": True, + "CELERY_TASK_EAGER_PROPAGATES": True, + "CELERY_BROKER_URL": "memory://", + "CELERY_RESULT_BACKEND": "cache+memory://", + "PASSWORD_HASHERS": ["django.contrib.auth.hashers.MD5PasswordHasher"], +} + + +@override_settings(**SETTINGS) +class RegistrationRequestNotificationTests(TestCase): + def setUp(self): + self.program = create_partner_program(name="Тестовый чемпионат") + self.manager = create_user(is_active=True, first_name="Анна", last_name="Иванова") + self.admin = create_user(is_active=True, is_staff=True) + self.superuser = create_user(is_active=True, is_superuser=True) + self.program.managers.add(self.manager) + self.config = ProgramRegistration.objects.create(program=self.program) + self.url = f"/programs/{self.program.pk}/management/registration/connection/" + self.inbox = "/core/notifications/" + self.client = APIClient() + self.payload = { + "url": "https://event.example.test", + "comment": "Нужна интеграция", + "contact": "@organizer", + } + + def submit(self, data=None, execute=True): + self.client.force_authenticate(self.manager) + with self.captureOnCommitCallbacks(execute=execute): + response = self.client.post(self.url, data or self.payload, format="json") + self.assertEqual(response.status_code, 200, response.data) + return response + + def test_request_notifies_all_active_system_administrators_after_commit(self): + create_user(is_active=False, is_staff=True) + create_user(is_active=True, user_type=0) + self.client.force_authenticate(self.manager) + with self.captureOnCommitCallbacks(execute=False) as callbacks: + response = self.client.post(self.url, self.payload, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + self.assertEqual(len(callbacks), 1) + self.assertEqual(RegistrationConnectionRequest.objects.count(), 1) + self.assertEqual(RegistrationConnectionNotification.objects.count(), 2) + callbacks[0]() + self.assertEqual( + {message.to[0] for message in mail.outbox}, + {self.admin.email, self.superuser.email}, + ) + body = mail.outbox[0].body + for text in ( + self.program.name, + self.payload["url"], + "Анна Иванова", + self.manager.email, + "Нужна интеграция", + "@organizer", + f"https://dev.procollab.ru/admin/partner_programs/programregistration/{self.config.pk}/change/", + ): + self.assertIn(text, body) + self.config.refresh_from_db() + self.assertEqual(self.config.external_status, "requested") + self.assertEqual(self.config.external_url, "") + + def test_duplicate_request_and_task_do_not_send_again_but_changes_create_new_snapshot( + self, + ): + self.submit() + self.config.refresh_from_db() + revision, requested_at = self.config.revision, self.config.requested_at + self.submit() + for notice in RegistrationConnectionNotification.objects.all(): + deliver_notification(notice.pk) + self.config.refresh_from_db() + self.assertEqual( + (self.config.revision, self.config.requested_at), (revision, requested_at) + ) + self.assertEqual(len(mail.outbox), 2) + self.submit({**self.payload, "comment": "Другой комментарий"}) + self.assertEqual(len(mail.outbox), 4) + self.assertEqual( + list( + RegistrationConnectionRequest.objects.order_by("pk").values_list( + "comment", flat=True + ) + ), + ["Нужна интеграция", "Другой комментарий"], + ) + + def test_invalid_foreign_and_connected_requests_do_not_emit(self): + self.client.force_authenticate(self.manager) + self.assertEqual( + self.client.post(self.url, {"url": "invalid"}, format="json").status_code, 400 + ) + self.client.force_authenticate(create_user(is_active=True)) + self.assertEqual( + self.client.post(self.url, self.payload, format="json").status_code, 403 + ) + self.config.external_status = "connected" + self.config.external_url = "https://form.example.test" + self.config.save() + self.submit() + self.assertFalse(RegistrationConnectionRequest.objects.exists()) + self.assertFalse(RegistrationConnectionNotification.objects.exists()) + self.assertEqual(len(mail.outbox), 0) + + def test_rollback_does_not_leave_event_or_dispatch_mail(self): + with self.captureOnCommitCallbacks(execute=True): + try: + with transaction.atomic(): + self.submit(execute=False) + raise ValueError("rollback") + except ValueError: + pass + self.assertFalse(RegistrationConnectionRequest.objects.exists()) + self.assertFalse(RegistrationConnectionNotification.objects.exists()) + self.assertEqual(len(mail.outbox), 0) + + def test_private_inbox_read_and_role_revocation(self): + self.submit() + self.client.force_authenticate(self.admin) + response = self.client.get(self.inbox) + self.assertEqual(response.data["unread_count"], 1) + self.assertEqual(response["Cache-Control"], "private, no-store") + notice = response.data["results"][0] + self.assertEqual(notice["kind"], "registration_connection") + self.assertEqual(notice["registration_id"], self.config.pk) + self.assertEqual(notice["program_id"], self.program.pk) + path = f"{self.inbox}{notice['id']}/read/" + read = self.client.post(path, {}).data + self.assertEqual(read["unread_count"], 0) + self.assertEqual(self.client.post(path, {}).data, read) + for other in (self.superuser, self.manager): + self.client.force_authenticate(other) + self.assertEqual(self.client.post(path, {}).status_code, 404) + self.client.force_authenticate(self.manager) + self.assertEqual(self.client.get(self.inbox).data["results"], []) + self.admin.is_staff = False + self.admin.save(update_fields=["is_staff"]) + self.client.force_authenticate(self.admin) + self.assertEqual(self.client.get(self.inbox).data["results"], []) + self.assertEqual(self.client.post(path, {}).status_code, 404) + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.inbox).status_code, 401) + self.assertEqual(self.client.post(path, {}).status_code, 401) + + def test_unified_inbox_retains_expert_notices_and_legacy_api(self): + self.submit() + self.program.managers.add(self.admin) + invitation = ExpertInvitation.objects.create( + partner_program=self.program, + email="expert@example.test", + token_digest="f" * 64, + expires_at=timezone.now() + timedelta(days=1), + ) + old = ExpertJoinedNotification.objects.create( + invitation=invitation, + recipient=self.admin, + expert_name="Эксперт", + expert_email="expert@example.test", + ) + self.client.force_authenticate(self.admin) + response = self.client.get(self.inbox).data + self.assertEqual(response["unread_count"], 2) + self.assertEqual( + {row["kind"] for row in response["results"]}, + {"expert_joined", "registration_connection"}, + ) + self.assertEqual(len({row["id"] for row in response["results"]}), 2) + self.assertEqual( + self.client.post(f"{self.inbox}expert_joined:{old.pk}/read/", {}).data[ + "unread_count" + ], + 1, + ) + legacy = self.client.get("/evaluation/notifications/").data + self.assertEqual(len(legacy["results"]), 1) + self.assertEqual(legacy["results"][0]["id"], old.pk) + self.assertEqual(legacy["unread_count"], 0) + + def test_bad_notification_keys_are_not_found(self): + self.client.force_authenticate(self.admin) + for key in ( + "other:1", + "expert_joined:0", + "registration_connection:-1", + "expert_joined:" + "9" * 20, + ): + with self.subTest(key=key): + self.assertEqual( + self.client.post(f"{self.inbox}{key}/read/", {}).status_code, 404 + ) + + def test_broker_failure_preserves_in_app_notice_and_recovers(self): + with patch( + "partner_programs.tasks.send_registration_connection_notification.delay", + side_effect=RuntimeError("broker down"), + ): + self.submit() + self.assertEqual(len(mail.outbox), 0) + self.assertEqual( + RegistrationConnectionNotification.objects.filter( + delivery_status="queued" + ).count(), + 2, + ) + self.client.force_authenticate(self.admin) + self.assertEqual(self.client.get(self.inbox).data["unread_count"], 1) + dispatch_pending_notifications() + self.assertEqual(len(mail.outbox), 2) + dispatch_pending_notifications() + self.assertEqual(len(mail.outbox), 2) + + def test_smtp_failure_attempts_are_persisted_and_bounded(self): + self.submit(execute=False) + notice = RegistrationConnectionNotification.objects.get(recipient=self.admin) + with patch( + "django.core.mail.EmailMultiAlternatives.send", + side_effect=RuntimeError("SMTP down"), + ): + for attempt in range(1, 5): + with self.assertRaises(RuntimeError): + deliver_notification(notice.pk) + notice.refresh_from_db() + self.assertEqual(notice.attempts, attempt) + self.assertEqual(notice.delivery_status, "failed") + deliver_notification(notice.pk) + self.assertEqual(len(mail.outbox), 0) + + def test_transient_delivery_failure_recovers_without_resending_sent_notifications( + self, + ): + self.submit(execute=False) + notice = RegistrationConnectionNotification.objects.get(recipient=self.admin) + with patch("django.core.mail.EmailMultiAlternatives.send", return_value=0): + with self.assertRaises(RuntimeError): + deliver_notification(notice.pk) + dispatch_pending_notifications() + self.assertEqual(len(mail.outbox), 2) + notice.refresh_from_db() + self.assertEqual(notice.attempts, 2) + self.assertEqual(notice.delivery_status, "sent") + + def test_pending_delivery_stops_after_administrator_rights_are_removed(self): + self.submit(execute=False) + notice = RegistrationConnectionNotification.objects.get(recipient=self.admin) + self.admin.is_staff = False + self.admin.save(update_fields=["is_staff"]) + deliver_notification(notice.pk) + notice.refresh_from_db() + self.assertEqual(notice.delivery_status, "cancelled") + self.assertEqual(len(mail.outbox), 0) + + def test_missing_administrators_are_recovered_without_backfilling_legacy_requests( + self, + ): + for user in (self.admin, self.superuser): + user.is_active = False + user.save(update_fields=["is_active"]) + self.submit() + self.assertEqual(RegistrationConnectionRequest.objects.count(), 1) + self.assertFalse(RegistrationConnectionNotification.objects.exists()) + ProgramRegistration.objects.create( + program=create_partner_program(), external_status="requested" + ) + self.admin.is_active = True + self.admin.save(update_fields=["is_active"]) + dispatch_pending_notifications() + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(RegistrationConnectionRequest.objects.count(), 1) + + def test_optional_details_html_escaping_and_user_names_are_preserved(self): + self.submit( + { + "url": "https://event.example.test", + "comment": "", + "contact": "", + } + ) + html = mail.outbox[0].alternatives[0][0] + self.assertNotIn("