From f16defa77a467293dc2b938f7c3aa8f00521815c Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 19 Oct 2026 21:34:12 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D1=8B=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2,=20XLSX=20=D0=B8=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=81=D0=BF=D0=B5=D1=80=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/management_views.py | 49 +++++- partner_programs/serializers/management.py | 33 ++++ partner_programs/services/management.py | 35 ++++- .../services/participants_excel.py | 66 ++++++++ .../tests/test_management_participants.py | 142 ++++++++++++++++++ partner_programs/urls.py | 6 + .../0006_expertjoinednotification_and_more.py | 57 +++++++ project_rates/models.py | 23 +++ project_rates/notifications.py | 86 +++++++++++ project_rates/tasks.py | 42 ++++++ .../tests/test_joined_notifications.py | 133 ++++++++++++++++ project_rates/workspace.py | 3 + project_rates/workspace_urls.py | 3 + templates/email/expert_joined.html | 8 + templates/email/expert_joined.txt | 6 + 15 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 partner_programs/services/participants_excel.py create mode 100644 partner_programs/tests/test_management_participants.py create mode 100644 project_rates/migrations/0006_expertjoinednotification_and_more.py create mode 100644 project_rates/notifications.py create mode 100644 project_rates/tests/test_joined_notifications.py create mode 100644 templates/email/expert_joined.html create mode 100644 templates/email/expert_joined.txt diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 4386b983..4a9789de 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -1,6 +1,7 @@ """Панель организатора поверх существующей модели программ.""" from django.db import transaction +from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import serializers, status @@ -11,7 +12,7 @@ from partner_programs.models import PartnerProgramMaterial from partner_programs.pagination import ProgramAttentionPagination from partner_programs.serializers.management import ( - ManagementListQuery, + ManagementParticipantQuery, ManagementProjectQuery, ManagementSettingsSerializer, ManagementMaterialWrite, @@ -38,6 +39,11 @@ def conflict(code, detail): class ManagementBase(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 + class ManagementView(ManagementBase): def get(self, request, pk): @@ -71,13 +77,48 @@ def put(self, request, pk): class ManagementParticipantsView(ManagementBase): def get(self, request, pk): program = managed_program(pk, request.user) - query = ManagementListQuery(data=request.query_params) + query = ManagementParticipantQuery(data=request.query_params) query.is_valid(raise_exception=True) paginator = ProgramAttentionPagination(query.validated_data) page = paginator.paginate_queryset( - participant_rows(program, query.validated_data["search"]), request, view=self + participant_rows( + program, query.validated_data["search"], query.validated_data + ), + request, + view=self, + ) + response = paginator.get_paginated_response([participant_data(p) for p in page]) + all_rows = participant_rows(program) + response.data.update( + total_count=all_rows.count(), + region_options=list( + all_rows.exclude(participant_region="") + .order_by("participant_region") + .values_list("participant_region", flat=True) + .distinct() + ), + ) + return response + + +class ManagementParticipantsExportView(ManagementBase): + def get(self, request, pk): + from partner_programs.services.participants_excel import export_participants + + program = managed_program(pk, request.user) + query = ManagementParticipantQuery(data=request.query_params) + query.is_valid(raise_exception=True) + rows = participant_rows( + program, query.validated_data["search"], query.validated_data ) - return paginator.get_paginated_response([participant_data(p) for p in page]) + response = HttpResponse( + export_participants(rows), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response[ + "Content-Disposition" + ] = f'attachment; filename="program-{pk}-participants.xlsx"' + return response class ManagementProjectsView(ManagementBase): diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index 02b24407..bfbbf522 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -68,6 +68,39 @@ class ManagementListQuery(StrictFieldsMixin, serializers.Serializer): offset = serializers.IntegerField(min_value=0, default=0) +class ManagementParticipantQuery(ManagementListQuery): + region_scope = serializers.ChoiceField( + choices=("all", "selected", "without_region"), default="all" + ) + region = serializers.CharField(required=False, max_length=255) + activity = serializers.ChoiceField(choices=("all", "period", "never"), default="all") + activity_from = serializers.DateField(required=False) + activity_to = serializers.DateField(required=False) + + def validate(self, attrs): + if attrs["region_scope"] == "selected" and not attrs.get("region"): + raise serializers.ValidationError({"region": "Выберите регион."}) + if attrs["region_scope"] != "selected" and attrs.get("region"): + raise serializers.ValidationError( + {"region": "Регион задан без selected scope."} + ) + start, end = attrs.get("activity_from"), attrs.get("activity_to") + if attrs["activity"] == "period": + if not (start or end): + raise serializers.ValidationError( + {"activity_from": "Укажите начало или конец периода."} + ) + if start and end and start > end: + raise serializers.ValidationError( + {"activity_to": "Конец периода раньше начала."} + ) + elif start or end: + raise serializers.ValidationError( + {"activity": "Даты доступны только для периода."} + ) + return attrs + + class ManagementProjectQuery(ManagementListQuery): submission = serializers.ChoiceField( choices=("all", "submitted", "not_submitted"), default="all" diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index 71b04fa9..0aa22517 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -1,6 +1,10 @@ """Program-scoped чтение для организатора без изменений участников и проектов.""" -from django.db.models import Q +from datetime import datetime, time +from zoneinfo import ZoneInfo + +from django.db.models import Q, Value +from django.db.models.functions import Coalesce, NullIf, Trim from django.shortcuts import get_object_or_404 from rest_framework.exceptions import PermissionDenied @@ -73,12 +77,20 @@ def management_data(program): } -def participant_rows(program, search): +def participant_rows(program, search="", query=None): rows = ( PartnerProgramUserProfile.objects.filter( partner_program=program, user__isnull=False ) .select_related("user") + # The profile's region picker writes to the historical `city` field. + .annotate( + participant_region=Coalesce( + NullIf(Trim("user__city"), Value("")), + NullIf(Trim("user__region"), Value("")), + Value(""), + ) + ) .order_by("datetime_created", "pk") ) if search: @@ -90,6 +102,24 @@ def participant_rows(program, search): | Q(user__patronymic__icontains=part) | Q(user__email__icontains=part) ) + if query: + if query["region_scope"] == "selected": + rows = rows.filter(participant_region=query["region"]) + elif query["region_scope"] == "without_region": + rows = rows.filter(participant_region="") + if query["activity"] == "never": + rows = rows.filter(user__last_activity__isnull=True) + elif query["activity"] == "period": + zone = ZoneInfo("Europe/Moscow") + if query.get("activity_from"): + rows = rows.filter( + user__last_activity__gte=datetime.combine( + query["activity_from"], time.min, zone + ) + ) + if query.get("activity_to"): + end = datetime.combine(query["activity_to"], time.max, zone) + rows = rows.filter(user__last_activity__lte=end) return rows @@ -100,6 +130,7 @@ def participant_data(profile): "name": person_name(profile.user), "email": profile.user.email, "city": profile.user.city, + "region": profile.participant_region or None, "registered_at": profile.datetime_created, "last_activity": profile.user.last_activity, } diff --git a/partner_programs/services/participants_excel.py b/partner_programs/services/participants_excel.py new file mode 100644 index 00000000..52538179 --- /dev/null +++ b/partner_programs/services/participants_excel.py @@ -0,0 +1,66 @@ +"""Export the same filtered queryset as the organizer's participant list.""" + +from io import BytesIO +from zoneinfo import ZoneInfo + +from openpyxl import Workbook +from openpyxl.cell import WriteOnlyCell +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter + +from partner_programs.services.management import person_name + + +def export_participants(rows): + workbook = Workbook(write_only=True) + sheet = workbook.create_sheet("Участники") + sheet.freeze_panes = "C2" + headers = [ + "№", + "Участник", + "Почта", + "Регион", + "В программе с (МСК)", + "Последняя активность (МСК)", + ] + for index, width in enumerate((6, 32, 38, 28, 24, 28), 1): + sheet.column_dimensions[get_column_letter(index)].width = width + header_cells = [] + for title in headers: + cell = WriteOnlyCell(sheet, title) + cell.font = Font(bold=True) + cell.fill = PatternFill("solid", fgColor="EEE8FB") + header_cells.append(cell) + sheet.append(header_cells) + number = 0 + for number, profile in enumerate(rows.iterator(chunk_size=500), 1): + values = [ + number, + person_name(profile.user), + profile.user.email, + profile.participant_region, + profile.datetime_created, + profile.user.last_activity, + ] + cells = [] + for value in values: + if hasattr(value, "tzinfo"): + cell = WriteOnlyCell( + sheet, + value.astimezone(ZoneInfo("Europe/Moscow")).replace(tzinfo=None), + ) + cell.number_format = "dd.mm.yyyy hh:mm" + elif isinstance(value, int): + cell = WriteOnlyCell(sheet, value) + else: + cell = WriteOnlyCell( + sheet, ILLEGAL_CHARACTERS_RE.sub("", str(value or "—")) + ) + cell.data_type = "s" + cells.append(cell) + sheet.append(cells) + sheet.auto_filter.ref = f"A1:F{number + 1}" + stream = BytesIO() + workbook.save(stream) + return stream.getvalue() diff --git a/partner_programs/tests/test_management_participants.py b/partner_programs/tests/test_management_participants.py new file mode 100644 index 00000000..b41c06dc --- /dev/null +++ b/partner_programs/tests/test_management_participants.py @@ -0,0 +1,142 @@ +from datetime import datetime +from io import BytesIO + +from django.test import TestCase +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class ManagementParticipantsTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user() + cls.program = create_partner_program() + cls.program.managers.add(cls.manager) + cls.rows = [] + for name, region, activity in [ + ("Анна", " Москва ", "2026-10-18T21:00:00+00:00"), + ("Анна", "Москва", "2026-10-19T20:59:59+00:00"), + ("Анна", "Москва", "2026-10-19T21:00:00+00:00"), + ("Анна", "Казань", "2026-10-19T12:00:00+00:00"), + ("Иван", " ", None), + ("Иван", None, None), + ]: + user = create_user(first_name=name, region=region) + user.__class__.objects.filter(pk=user.pk).update( + last_activity=datetime.fromisoformat(activity) if activity else None + ) + cls.rows.append(create_program_member(cls.program, user=user)) + create_program_member( + create_partner_program(), user=create_user(region="Чужой регион") + ) + cls.root = f"/programs/{cls.program.pk}/management/participants/" + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def test_intersecting_filters_use_full_inclusive_moscow_days(self): + response = self.client.get( + self.root, + { + "search": "Анна", + "region_scope": "selected", + "region": "Москва", + "activity": "period", + "activity_from": "2026-10-19", + "activity_to": "2026-10-19", + "limit": 1, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 2) + self.assertEqual(response.data["total_count"], 6) + self.assertEqual(response.data["results"][0]["region"], "Москва") + self.assertEqual(response.data["region_options"], ["Казань", "Москва"]) + second = self.client.get( + self.root, {"activity": "period", "activity_to": "2026-10-18"} + ) + self.assertEqual(second.data["count"], 0) + + def test_missing_activity_and_region_are_explicit_filters(self): + response = self.client.get( + self.root, {"region_scope": "without_region", "activity": "never"} + ) + self.assertEqual(response.data["count"], 2) + self.assertTrue( + all( + row["region"] is None and row["last_activity"] is None + for row in response.data["results"] + ) + ) + + def test_invalid_filters_fail_for_list_and_export(self): + for query in [ + {"activity": "period"}, + {"activity": "period", "activity_from": "broken"}, + { + "activity": "period", + "activity_from": "2026-10-20", + "activity_to": "2026-10-19", + }, + {"activity": "never", "activity_from": "2026-10-19"}, + {"region_scope": "selected"}, + {"region_scope": "wrong"}, + {"region": "Москва"}, + ]: + for suffix in ("", "export/"): + with self.subTest(query=query, suffix=suffix): + self.assertEqual( + self.client.get(self.root + suffix, query).status_code, 400 + ) + + def test_export_uses_all_filtered_rows_ignoring_pagination_and_keeps_typed_dates( + self, + ): + response = self.client.get( + self.root + "export/", + {"region_scope": "selected", "region": "Москва", "limit": 1, "offset": 1}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("no-store", response["Cache-Control"]) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.max_row, 4) + self.assertEqual([sheet.cell(row, 1).value for row in range(2, 5)], [1, 2, 3]) + self.assertEqual(sheet.cell(2, 6).value, datetime(2026, 10, 19)) + self.assertEqual(sheet.cell(2, 6).data_type, "d") + + def test_region_matches_profile_picker_city_with_legacy_region_fallback(self): + user = self.rows[0].user + user.city = " Татарстан " + user.region = "Устаревшее значение" + user.save(update_fields=["city", "region"]) + response = self.client.get( + self.root, {"region_scope": "selected", "region": "Татарстан"} + ) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["region"], "Татарстан") + self.assertNotIn("Устаревшее значение", response.data["region_options"]) + + def test_export_neutralizes_user_formulas(self): + user = self.rows[0].user + user.first_name = '=HYPERLINK("bad")' + user.save(update_fields=["first_name"]) + sheet = load_workbook( + BytesIO(self.client.get(self.root + "export/").content) + ).active + self.assertTrue(sheet.cell(2, 2).value.startswith("=")) + self.assertEqual(sheet.cell(2, 2).data_type, "s") + + def test_export_permission_and_empty_result(self): + empty = self.client.get(self.root + "export/", {"search": "Не существует"}) + self.assertEqual(load_workbook(BytesIO(empty.content)).active.max_row, 1) + for user, status in [(create_user(), 403), (None, 401)]: + self.client.force_authenticate(user) + for suffix in ("", "export/"): + self.assertEqual(self.client.get(self.root + suffix).status_code, status) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index d378fe91..2ef2057c 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -42,6 +42,7 @@ ManagementProjectFormView, ManagementCriteriaView, ManagementParticipantsView, + ManagementParticipantsExportView, ManagementProjectsView, ManagementTeamView, ManagementEvaluationView, @@ -121,6 +122,11 @@ name="management-project-form", ), path("/management/", ManagementView.as_view(), name="management"), + path( + "/management/participants/export/", + ManagementParticipantsExportView.as_view(), + name="management-participants-export", + ), path( "/management/participants/", ManagementParticipantsView.as_view(), diff --git a/project_rates/migrations/0006_expertjoinednotification_and_more.py b/project_rates/migrations/0006_expertjoinednotification_and_more.py new file mode 100644 index 00000000..3110a80e --- /dev/null +++ b/project_rates/migrations/0006_expertjoinednotification_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.11 on 2026-10-19 18:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("project_rates", "0005_expertcasescope_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ExpertJoinedNotification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("expert_name", models.CharField(max_length=800)), + ("expert_email", models.EmailField(max_length=254)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("read_at", models.DateTimeField(blank=True, null=True)), + ("emailed_at", models.DateTimeField(blank=True, null=True)), + ( + "invitation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="joined_notifications", + to="project_rates.expertinvitation", + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="expert_joined_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddConstraint( + model_name="expertjoinednotification", + constraint=models.UniqueConstraint( + fields=("invitation", "recipient"), name="unique_expert_joined_recipient" + ), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 6a694a50..6f38bcf6 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -305,3 +305,26 @@ class Meta: ) ] indexes = [models.Index(fields=["email", "status"])] + + +class ExpertJoinedNotification(models.Model): + """One durable organizer receipt for an authenticated invitation acceptance.""" + + invitation = models.ForeignKey( + ExpertInvitation, on_delete=models.CASCADE, related_name="joined_notifications" + ) + recipient = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="expert_joined_notifications" + ) + expert_name = models.CharField(max_length=800) + expert_email = models.EmailField() + created_at = models.DateTimeField(auto_now_add=True) + read_at = models.DateTimeField(null=True, blank=True) + emailed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["invitation", "recipient"], name="unique_expert_joined_recipient" + ) + ] diff --git a/project_rates/notifications.py b/project_rates/notifications.py new file mode 100644 index 00000000..b9c6fdbe --- /dev/null +++ b/project_rates/notifications.py @@ -0,0 +1,86 @@ +"""Organizer receipts: accepting an invitation proves the expert is signed in.""" + +from functools import partial + +from django.db import transaction +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.management import person_name +from project_rates.models import ExpertJoinedNotification + + +def notify_expert_joined(invitation, user): + from project_rates.tasks import send_expert_joined_notification + + for organizer in invitation.partner_program.managers.filter(is_active=True).exclude( + pk=user.pk + ): + notice, created = ExpertJoinedNotification.objects.get_or_create( + invitation=invitation, + recipient=organizer, + defaults={"expert_name": person_name(user), "expert_email": user.email}, + ) + if created: + transaction.on_commit( + partial(send_expert_joined_notification.delay, notice.pk), robust=True + ) + + +def visible_notifications(user): + return ( + ExpertJoinedNotification.objects.filter( + recipient=user, + invitation__partner_program__managers=user, + ) + .select_related("invitation__partner_program") + .order_by("-created_at", "-pk") + ) + + +class ExpertNotificationsView(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) + rows = visible_notifications(request.user) + return Response( + { + "unread_count": rows.filter(read_at__isnull=True).count(), + "results": [ + { + "id": row.pk, + "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 rows[:100] + ], + } + ) + + def post(self, request, notification_id=None): + if notification_id is None: + return Response(status=405) + notice = get_object_or_404( + visible_notifications(request.user), pk=notification_id + ) + if notice.read_at is None: + ExpertJoinedNotification.objects.filter( + pk=notice.pk, read_at__isnull=True + ).update(read_at=timezone.now()) + notice.refresh_from_db(fields=["read_at"]) + return Response({"id": notice.pk, "read_at": notice.read_at}) diff --git a/project_rates/tasks.py b/project_rates/tasks.py index 8e5751af..de0c6f23 100644 --- a/project_rates/tasks.py +++ b/project_rates/tasks.py @@ -45,3 +45,45 @@ def send_expert_invitation(invitation_id, token): except Exception: rows.update(delivery_status="failed") raise + + +@app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def send_expert_joined_notification(notification_id): + from django.db import transaction + from project_rates.models import ExpertJoinedNotification + + # Serializes duplicate deliveries of the same task; acceptance itself is idempotent. + with transaction.atomic(): + notice = ( + ExpertJoinedNotification.objects.select_for_update() + .filter(pk=notification_id) + .first() + ) + if not notice or notice.emailed_at: + return + program = notice.invitation.partner_program + recipient = program.managers.filter( + pk=notice.recipient_id, is_active=True + ).first() + if not recipient or not recipient.email: + return + context = { + "program_name": program.name, + "expert_name": notice.expert_name, + "expert_email": notice.expert_email, + "program_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + f"/office/program/{program.pk}/management/experts", + } + message = EmailMultiAlternatives( + "Эксперт принял приглашение | PROCOLLAB", + render_to_string("email/expert_joined.txt", context), + settings.EMAIL_USER, + [recipient.email], + ) + message.attach_alternative( + render_to_string("email/expert_joined.html", context), "text/html" + ) + if not message.send(): + raise RuntimeError("Expert joined receipt delivery did not complete") + notice.emailed_at = timezone.now() + notice.save(update_fields=["emailed_at"]) diff --git a/project_rates/tests/test_joined_notifications.py b/project_rates/tests/test_joined_notifications.py new file mode 100644 index 00000000..65c526f7 --- /dev/null +++ b/project_rates/tests/test_joined_notifications.py @@ -0,0 +1,133 @@ +from datetime import timedelta + +from django.core import mail +from django.db import transaction +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import create_partner_program, create_user +from project_rates.models import ExpertInvitation, ExpertJoinedNotification +from project_rates.tasks import send_expert_joined_notification +from project_rates.workspace import accept_invitation, digest + + +@override_settings( + EXPERT_WORKSPACE_FRONTEND_URL="https://dev.procollab.ru", + EMAIL_USER="no-reply@example.com", + 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"], +) +class ExpertJoinedNotificationTests(TestCase): + def setUp(self): + self.program = create_partner_program() + self.managers = [create_user(is_active=True), create_user(is_active=True)] + self.program.managers.add(*self.managers) + self.expert = create_user(first_name="Анна", last_name="Эксперт", is_active=True) + self.token = "test-expert-joined-invitation-0123456789" + self.invitation = ExpertInvitation.objects.create( + partner_program=self.program, + email=self.expert.email.lower(), + token_digest=digest(self.token), + expires_at=timezone.now() + timedelta(days=1), + invited_by=self.managers[0], + ) + self.client = APIClient() + self.root = "/evaluation/notifications/" + + def accept(self): + self.client.force_authenticate(self.expert) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/evaluation/invitations/{self.token}/accept/", {}, format="json" + ) + self.assertEqual(response.status_code, 200) + + def test_accept_notifies_each_active_organizer_once_and_preserves_account(self): + inactive = create_user(is_active=False) + self.program.managers.add(inactive) + self.accept() + self.accept() + self.assertEqual(ExpertJoinedNotification.objects.count(), 2) + self.assertEqual(len(mail.outbox), 2) + self.assertEqual({m.to[0] for m in mail.outbox}, {u.email for u in self.managers}) + self.assertIn("Анна Эксперт", mail.outbox[0].body) + self.assertIn( + f"/office/program/{self.program.pk}/management/experts", mail.outbox[0].body + ) + for notice in ExpertJoinedNotification.objects.all(): + send_expert_joined_notification(notice.pk) + self.assertEqual(len(mail.outbox), 2) + + def test_visit_and_sign_in_alone_do_not_notify_or_accept(self): + self.assertEqual( + self.client.get(f"/evaluation/invitations/{self.token}/").status_code, 200 + ) + self.assertEqual( + self.client.post( + "/api/token/", {"email": self.expert.email, "password": "pass"} + ).status_code, + 200, + ) + self.assertFalse(ExpertJoinedNotification.objects.exists()) + + def test_inbox_is_private_read_is_idempotent_and_removed_organizer_loses_access(self): + self.accept() + self.client.force_authenticate(self.managers[0]) + response = self.client.get(self.root) + self.assertEqual(response.data["unread_count"], 1) + self.assertIn("no-store", response["Cache-Control"]) + notice = response.data["results"][0] + read = self.client.post(f"{self.root}{notice['id']}/read/", {}).data + self.assertEqual( + self.client.post(f"{self.root}{notice['id']}/read/", {}).data, read + ) + self.assertEqual(self.client.get(self.root).data["unread_count"], 0) + self.client.force_authenticate(self.managers[1]) + self.assertEqual( + self.client.post(f"{self.root}{notice['id']}/read/", {}).status_code, 404 + ) + self.program.managers.remove(self.managers[0]) + self.client.force_authenticate(self.managers[0]) + self.assertEqual(self.client.get(self.root).data["results"], []) + self.assertEqual( + self.client.post(f"{self.root}{notice['id']}/read/", {}).status_code, 404 + ) + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.root).status_code, 401) + + def test_rollback_does_not_create_notices_or_send_mail(self): + with self.captureOnCommitCallbacks(execute=True): + try: + with transaction.atomic(): + accept_invitation(self.invitation, self.expert) + raise ValueError("rollback") + except ValueError: + pass + self.assertFalse(ExpertJoinedNotification.objects.exists()) + self.assertEqual(len(mail.outbox), 0) + + def test_invited_registration_emits_receipt_and_retains_short_expert_form(self): + self.invitation.email = "new-expert@example.com" + self.invitation.save(update_fields=["email"]) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/evaluation/invitations/{self.token}/register/", + { + "first_name": "Новый", + "last_name": "Эксперт", + "password": "Qx!74Mrv29Zc", + "repeat_password": "Qx!74Mrv29Zc", + "accept_policy": True, + "accept_age": True, + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.invitation.refresh_from_db() + self.assertIsNone(self.invitation.accepted_by.birthday) + self.assertEqual(len(mail.outbox), 2) diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 5da112f6..2860a0ea 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -529,6 +529,9 @@ def accept_invitation(invitation, user): invitation.status = "accepted" invitation.accepted_by = user invitation.save(update_fields=["status", "accepted_by", "updated_at"]) + from project_rates.notifications import notify_expert_joined + + notify_expert_joined(invitation, user) def my_programs(user): diff --git a/project_rates/workspace_urls.py b/project_rates/workspace_urls.py index 4d3fdb86..4f2db0ef 100644 --- a/project_rates/workspace_urls.py +++ b/project_rates/workspace_urls.py @@ -1,4 +1,5 @@ from django.urls import path +from .notifications import ExpertNotificationsView from .workspace_views import ( MyProgramsView, AcceptOwnInvitationView, @@ -7,6 +8,8 @@ ) urlpatterns = [ + path("notifications/", ExpertNotificationsView.as_view()), + path("notifications//read/", ExpertNotificationsView.as_view()), path("programs/", MyProgramsView.as_view()), path( "own-invitations//accept/", AcceptOwnInvitationView.as_view() diff --git a/templates/email/expert_joined.html b/templates/email/expert_joined.html new file mode 100644 index 00000000..aeff973b --- /dev/null +++ b/templates/email/expert_joined.html @@ -0,0 +1,8 @@ + +Эксперт принял приглашение + +

Эксперт принял приглашение

+

{{ expert_name }} ({{ expert_email }}) вошёл на платформу и присоединился к программе «{{ program_name }}» в роли эксперта.

+

Теперь ему можно назначать проекты на оценку.

+

Открыть список экспертов

+ diff --git a/templates/email/expert_joined.txt b/templates/email/expert_joined.txt new file mode 100644 index 00000000..12974af9 --- /dev/null +++ b/templates/email/expert_joined.txt @@ -0,0 +1,6 @@ +Эксперт вошёл на платформу и принял приглашение + +{{ expert_name }} ({{ expert_email }}) присоединился к программе «{{ program_name }}» в роли эксперта. +Теперь ему можно назначать проекты на оценку. + +Открыть список экспертов: {{ program_url }}