diff --git a/core/notifications.py b/core/notifications.py index cff6522f..f05564ef 100644 --- a/core/notifications.py +++ b/core/notifications.py @@ -12,6 +12,7 @@ from partner_programs.services.registration_notifications import ( visible_request_notifications, ) +from partner_programs.services.attention_actions import visible_reminders from project_rates.notifications import ( visible_notifications as visible_expert_notifications, ) @@ -21,6 +22,7 @@ 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() + + visible_reminders(user).filter(read_at__isnull=True).count() ) @@ -37,6 +39,7 @@ def get(self, request, notification_id=None): return Response(status=405) experts = visible_expert_notifications(request.user) connections = visible_request_notifications(request.user) + reminders = visible_reminders(request.user) results = [ { "id": f"expert_joined:{row.pk}", @@ -66,6 +69,18 @@ def get(self, request, notification_id=None): } for row in connections[:100] ) + results.extend( + { + "id": f"program_attention:{row.pk}", + "kind": "program_attention", + "text": row.text, + "program_id": row.batch.program_id, + "category": row.batch.category, + "created_at": row.created_at, + "read_at": row.read_at, + } + for row in reminders[:100] + ) results.sort(key=lambda item: (item["created_at"], item["id"]), reverse=True) return Response( {"unread_count": unread_count(request.user), "results": results[:100]} @@ -75,15 +90,20 @@ 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 + r"(expert_joined|registration_connection|program_attention):([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) + visible_reminders(request.user) + if kind == "program_attention" + else ( + 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: diff --git a/partner_programs/attention_models.py b/partner_programs/attention_models.py new file mode 100644 index 00000000..adcbc688 --- /dev/null +++ b/partner_programs/attention_models.py @@ -0,0 +1,55 @@ +"""Личные напоминания организатора и восстанавливаемая очередь писем.""" + +from django.conf import settings +from django.db import models +from django.utils import timezone + + +class ProgramAttentionReminderBatch(models.Model): + id = models.UUIDField(primary_key=True, editable=False) + program = models.ForeignKey( + "partner_programs.PartnerProgram", on_delete=models.CASCADE + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + category = models.CharField(max_length=48) + created_at = models.DateTimeField(default=timezone.now, editable=False) + result = models.JSONField(default=dict) + + +class ProgramAttentionReminder(models.Model): + batch = models.ForeignKey( + ProgramAttentionReminderBatch, + on_delete=models.CASCADE, + related_name="notifications", + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="attention_reminders", + ) + text = models.TextField() + action_path = models.CharField(max_length=300) + source_ids = models.JSONField(default=list) + assignment_ids = models.JSONField(default=list) + created_at = models.DateTimeField(default=timezone.now, editable=False) + 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=[(v, v) for v in ("queued", "sent", "failed", "cancelled")], + ) + attempts = models.PositiveSmallIntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["batch", "recipient"], name="unique_attention_batch_recipient" + ) + ] + indexes = [ + models.Index(fields=["delivery_status", "id"]), + models.Index(fields=["recipient", "-created_at"]), + ] diff --git a/partner_programs/attention_views.py b/partner_programs/attention_views.py new file mode 100644 index 00000000..b2c38b8e --- /dev/null +++ b/partner_programs/attention_views.py @@ -0,0 +1,55 @@ +from django.http import Http404, HttpResponse +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.serializers.attention_actions import ( + CATEGORIES, + AttentionActionSerializer, + AttentionConfirmationSerializer, +) +from partner_programs.services.attention_actions import ( + confirm_reminder, + export_attention, + preview_reminder, +) +from partner_programs.services.management import managed_program + + +class AttentionActionView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk, category, action): + program = managed_program(pk, request.user) + if category not in CATEGORIES or action not in ( + "reminder-preview", + "reminders", + "export", + ): + raise Http404 + serializer = ( + AttentionConfirmationSerializer(data=request.data) + if action == "reminders" + else AttentionActionSerializer(data=request.data) + ) + serializer.is_valid(raise_exception=True) + if action == "reminders": + response = Response( + confirm_reminder( + program, category, request.user, serializer.validated_data["token"] + ) + ) + elif action == "reminder-preview": + response = Response( + preview_reminder( + program, category, request.user, serializer.validated_data + ) + ) + else: + response = HttpResponse( + export_attention(program, category, serializer.validated_data), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = 'attachment; filename="attention.xlsx"' + response["Cache-Control"] = "private, no-store" + return response diff --git a/partner_programs/migrations/0028_programattentionreminderbatch_and_more.py b/partner_programs/migrations/0028_programattentionreminderbatch_and_more.py new file mode 100644 index 00000000..a7cd26d6 --- /dev/null +++ b/partner_programs/migrations/0028_programattentionreminderbatch_and_more.py @@ -0,0 +1,123 @@ +# Generated by Django 4.2.11 on 2026-10-20 21:31 + +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", "0027_registration_request_notifications"), + ] + + operations = [ + migrations.CreateModel( + name="ProgramAttentionReminderBatch", + fields=[ + ( + "id", + models.UUIDField(editable=False, primary_key=True, serialize=False), + ), + ("category", models.CharField(max_length=48)), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ("result", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="partner_programs.partnerprogram", + ), + ), + ], + ), + migrations.CreateModel( + name="ProgramAttentionReminder", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("action_path", models.CharField(max_length=300)), + ("source_ids", models.JSONField(default=list)), + ("assignment_ids", models.JSONField(default=list)), + ( + "created_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ("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)), + ( + "batch", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="partner_programs.programattentionreminderbatch", + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attention_reminders", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["delivery_status", "id"], + name="partner_pro_deliver_0b22ac_idx", + ), + models.Index( + fields=["recipient", "-created_at"], + name="partner_pro_recipie_e24c88_idx", + ), + ], + }, + ), + migrations.AddConstraint( + model_name="programattentionreminder", + constraint=models.UniqueConstraint( + fields=("batch", "recipient"), name="unique_attention_batch_recipient" + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 56890b52..1c451ed9 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -6,6 +6,10 @@ from files.models import UserFile from partner_programs.constants import get_default_data_schema from projects.models import Project +from partner_programs.attention_models import ( # noqa: F401 + ProgramAttentionReminder, + ProgramAttentionReminderBatch, +) from partner_programs.registration_models import ( # noqa: F401 ProgramRegistration, PendingProgramRegistration, diff --git a/partner_programs/serializers/attention_actions.py b/partner_programs/serializers/attention_actions.py new file mode 100644 index 00000000..32c0600d --- /dev/null +++ b/partner_programs/serializers/attention_actions.py @@ -0,0 +1,42 @@ +from rest_framework import serializers + +MAX_SELECTION = 10000 +CATEGORIES = ( + "participants-without-visit", + "participants-without-team", + "projects-awaiting-evaluation", +) + + +class AttentionSelectionSerializer(serializers.Serializer): + mode = serializers.ChoiceField(choices=("selected", "all")) + ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + max_length=MAX_SELECTION, + default=list, + ) + excluded_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + max_length=MAX_SELECTION, + default=list, + ) + + def validate(self, data): + if data["mode"] == "all" and data["ids"]: + raise serializers.ValidationError( + "Для всех найденных используйте только исключения." + ) + if data["mode"] == "selected" and (not data["ids"] or data["excluded_ids"]): + raise serializers.ValidationError("Выберите хотя бы одну строку.") + return data + + +class AttentionActionSerializer(serializers.Serializer): + search = serializers.CharField( + max_length=200, allow_blank=True, default="", trim_whitespace=True + ) + selection = AttentionSelectionSerializer() + + +class AttentionConfirmationSerializer(serializers.Serializer): + token = serializers.CharField(max_length=1000000) diff --git a/partner_programs/services/attention_actions.py b/partner_programs/services/attention_actions.py new file mode 100644 index 00000000..abf836ef --- /dev/null +++ b/partner_programs/services/attention_actions.py @@ -0,0 +1,466 @@ +"""Program-scoped selection, recipient preview and idempotent reminder outbox.""" + +import io +import logging +import uuid +from functools import partial + +from django.conf import settings +from django.core import signing +from django.core.mail import EmailMultiAlternatives +from django.db import transaction +from django.db.models import F, Value +from django.db.models.functions import Concat +from django.template.loader import render_to_string +from django.utils import timezone +from openpyxl import Workbook +from rest_framework.exceptions import ValidationError + +from partner_programs.models import ( + ProgramAttentionReminder, + ProgramAttentionReminderBatch, +) +from partner_programs.serializers.attention import ProgramAttentionProjectSerializer +from partner_programs.serializers.attention_actions import MAX_SELECTION +from partner_programs.services.analytics import ( + participants_without_team_rows, + participants_without_visit_rows, + projects_awaiting_evaluation_rows, +) +from partner_programs.services.assignment_analytics import annotated_assignment_queryset +from partner_programs.services.exports import _literal_excel_cell +from partner_programs.services.management import managed_program, person_name +from users.models import CustomUser + +logger = logging.getLogger(__name__) +SALT = "program-attention-reminder-v1" +PREVIEW_TTL = 15 * 60 +MAX_ATTEMPTS = 4 +LABELS = { + "participants-without-visit": "Участники еще не заходили на платформу", + "participants-without-team": "Участники без команды", + "projects-awaiting-evaluation": "Работы ожидают оценивания", +} + + +def selected_rows(program, category, payload): + search, selection = payload["search"], payload["selection"] + if category == "projects-awaiting-evaluation": + rows = projects_awaiting_evaluation_rows(program) + if search: + rows = rows.filter(project__name__icontains=search) + key = "pk" + rows = rows.order_by(F("datetime_submitted").asc(nulls_last=True), "pk") + else: + selector = ( + participants_without_visit_rows + if category == "participants-without-visit" + else participants_without_team_rows + ) + rows = selector(program.pk) + if search: + rows = rows.annotate( + search_name=Concat("user__first_name", Value(" "), "user__last_name") + ).filter(search_name__icontains=search) + key = "user_id" + rows = rows.order_by(F("registered_at").asc(nulls_last=True), "user_id") + if selection["mode"] == "selected": + rows = rows.filter(**{f"{key}__in": selection["ids"]}) + else: + rows = rows.exclude(**{f"{key}__in": selection["excluded_ids"]}) + if rows.count() > MAX_SELECTION: + raise ValidationError( + { + "detail": f"За один раз можно выбрать до {MAX_SELECTION} строк. Уточните поиск." + } + ) + return list(rows) + + +def source_id(row, category): + return row.pk if category == "projects-awaiting-evaluation" else row["user_id"] + + +def recipient_targets(program, category, rows): + """One recipient per real user; no inferred or newly suggested assignments.""" + if category != "projects-awaiting-evaluation": + return [ + { + "user_id": row["user_id"], + "source_ids": [row["user_id"]], + "assignment_ids": [], + } + for row in rows + ] + links = {row.project_id: row.pk for row in rows} + targets = {} + assignments = ( + annotated_assignment_queryset(program.pk) + .filter( + project_id__in=links, + project_submitted=True, + is_completed=False, + expert__programs=program.pk, + ) + .order_by("pk") + .values("pk", "project_id", "expert__user_id") + ) + for row in assignments: + uid = row["expert__user_id"] + target = targets.setdefault( + uid, {"user_id": uid, "source_ids": [], "assignment_ids": []} + ) + sid = links[row["project_id"]] + if sid not in target["source_ids"]: + target["source_ids"].append(sid) + target["assignment_ids"].append(row["pk"]) + return list(targets.values()) + + +def message_for(program, category, recipient, rows): + greeting = f"{person_name(recipient)}, здравствуйте!" + if category == "participants-without-visit": + text = f"Вы зарегистрированы в программе «{program.name}». Зайдите на PROCOLLAB, чтобы начать участие." + path = f"/office/program/{program.pk}" + elif category == "participants-without-team": + text = ( + f"Вы участвуете в программе «{program.name}», но пока не состоите в команде. " + "Создайте проект или присоединитесь к команде." + ) + path = f"/office/program/{program.pk}/members" + else: + names = "; ".join(f"«{row.project.name}»" for row in rows) + text = ( + f"В программе «{program.name}» у вас остались незавершенные оценки по работам: {names}. " + "Завершите оценивание на PROCOLLAB." + ) + path = f"/office/program/{program.pk}/projects-rating" + return f"{greeting}\n\n{text}", path + + +def preview_reminder(program, category, actor, payload): + rows = selected_rows(program, category, payload) + targets = recipient_targets(program, category, rows) + users = CustomUser.objects.in_bulk([t["user_id"] for t in targets]) + available = [ + t for t in targets if users.get(t["user_id"]) and users[t["user_id"]].email + ] + assigned_sources = {sid for t in targets for sid in t["source_ids"]} + preview_text = "" + if available: + first = available[0] + preview_text, _ = message_for( + program, + category, + users[first["user_id"]], + [r for r in rows if source_id(r, category) in first["source_ids"]], + ) + token = ( + signing.dumps( + { + "request_id": str(uuid.uuid4()), + "program_id": program.pk, + "actor_id": actor.pk, + "category": category, + "source_ids": [source_id(r, category) for r in rows], + "targets": available, + }, + salt=SALT, + compress=True, + ) + if available + else None + ) + return { + "token": token, + "selected_count": len(rows), + "recipient_count": len(available), + "without_assignments_count": len(rows) - len(assigned_sources) + if category == "projects-awaiting-evaluation" + else 0, + "unavailable_count": len(targets) - len(available), + "preview_text": preview_text, + "recipients": [ + {"user_id": t["user_id"], "full_name": person_name(users[t["user_id"]])} + for t in available[:20] + ], + "expires_in": PREVIEW_TTL, + } + + +def fresh_targets(program, category, ids, snapshot): + rows = selected_rows( + program, + category, + {"search": "", "selection": {"mode": "selected", "ids": ids, "excluded_ids": []}}, + ) + current = {t["user_id"]: t for t in recipient_targets(program, category, rows)} + assignment_sources = {} + if category == "projects-awaiting-evaluation": + links = {r.project_id: r.pk for r in rows} + assignment_ids = [aid for t in current.values() for aid in t["assignment_ids"]] + assignment_sources = { + pk: links[pid] + for pk, pid in annotated_assignment_queryset(program.pk) + .filter(pk__in=assignment_ids) + .values_list("pk", "project_id") + } + result = [] + for approved in snapshot: + target = current.get(approved["user_id"]) + if not target: + continue + target["source_ids"] = sorted( + set(target["source_ids"]) & set(approved["source_ids"]) + ) + target["assignment_ids"] = sorted( + set(target["assignment_ids"]) & set(approved["assignment_ids"]) + ) + if target["source_ids"] and ( + category != "projects-awaiting-evaluation" or target["assignment_ids"] + ): + # The project assignment may have been replaced after preview. Retain only + # sources belonging to the approved, still unfinished assignments. + if category == "projects-awaiting-evaluation": + target["source_ids"] = sorted( + {assignment_sources[aid] for aid in target["assignment_ids"]} + ) + result.append(target) + return rows, result + + +def confirm_reminder(program, category, actor, token): + try: + data = signing.loads(token, salt=SALT, max_age=PREVIEW_TTL) + except signing.BadSignature: + raise ValidationError( + {"detail": "Предпросмотр устарел. Проверьте получателей еще раз."} + ) + if (data.get("program_id"), data.get("category"), data.get("actor_id")) != ( + program.pk, + category, + actor.pk, + ): + raise ValidationError( + {"detail": "Предпросмотр относится к другому списку или пользователю."} + ) + with transaction.atomic(): + # Serialize confirmations for the same program; token UUID is the durable + # idempotency key, including when the client loses the HTTP response. + program = managed_program(program.pk, actor, lock=True) + existing = ProgramAttentionReminderBatch.objects.filter( + pk=data["request_id"] + ).first() + if existing: + return existing.result + rows, targets = fresh_targets( + program, category, data["source_ids"], data["targets"] + ) + users = CustomUser.objects.in_bulk([t["user_id"] for t in targets]) + targets = [ + t for t in targets if users.get(t["user_id"]) and users[t["user_id"]].email + ] + result = { + "created_count": len(targets), + "skipped_count": len(data["targets"]) - len(targets), + } + batch = ProgramAttentionReminderBatch.objects.create( + id=data["request_id"], + program=program, + created_by=actor, + category=category, + result=result, + ) + rows_by_id = {source_id(r, category): r for r in rows} + notices = [] + for target in targets: + text, path = message_for( + program, + category, + users[target["user_id"]], + [rows_by_id[sid] for sid in target["source_ids"]], + ) + notices.append( + ProgramAttentionReminder( + batch=batch, + recipient_id=target["user_id"], + text=text, + action_path=path, + source_ids=target["source_ids"], + assignment_ids=target["assignment_ids"], + ) + ) + ProgramAttentionReminder.objects.bulk_create(notices, batch_size=500) + transaction.on_commit(partial(queue_batch, str(batch.pk)), robust=True) + return result + + +def visible_reminders(user): + return ( + ProgramAttentionReminder.objects.filter(recipient=user) + .select_related("batch") + .order_by("-created_at", "-pk") + ) + + +def queue_batch(batch_id): + from partner_programs.tasks import dispatch_attention_reminders + + try: + dispatch_attention_reminders.delay(batch_id) + except Exception: + logger.exception("Unable to enqueue attention reminder batch %s", batch_id) + + +def dispatch_pending_reminders(batch_id=None): + from partner_programs.tasks import send_attention_reminder + + rows = ProgramAttentionReminder.objects.filter( + delivery_status="queued", emailed_at__isnull=True, attempts__lt=MAX_ATTEMPTS + ) + if batch_id: + rows = rows.filter(batch_id=batch_id) + ids = list(rows.order_by("pk").values_list("pk", flat=True)[:100]) + for pk in ids: + try: + send_attention_reminder.delay(pk) + except Exception: + logger.exception("Unable to enqueue attention reminder %s", pk) + return len(ids) + + +def deliver_reminder(pk): + error = None + with transaction.atomic(): + notice = ( + ProgramAttentionReminder.objects.select_for_update(of=("self",)) + .select_related("batch__program", "recipient") + .filter(pk=pk) + .first() + ) + if ( + not notice + or notice.emailed_at + or notice.attempts >= MAX_ATTEMPTS + or notice.delivery_status == "cancelled" + ): + return + program, category = notice.batch.program, notice.batch.category + rows, targets = fresh_targets( + program, + category, + notice.source_ids, + [ + { + "user_id": notice.recipient_id, + "source_ids": notice.source_ids, + "assignment_ids": notice.assignment_ids, + } + ], + ) + if not targets or not notice.recipient.email: + notice.delivery_status = "cancelled" + notice.save(update_fields=["delivery_status"]) + return + text, path = message_for( + program, + category, + notice.recipient, + [r for r in rows if source_id(r, category) in targets[0]["source_ids"]], + ) + context = { + "text": text, + "action_url": settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + path, + } + notice.attempts += 1 + try: + message = EmailMultiAlternatives( + "Напоминание по программе | PROCOLLAB", + render_to_string("email/attention_reminder.txt", context), + settings.EMAIL_USER, + [notice.recipient.email], + ) + message.attach_alternative( + render_to_string("email/attention_reminder.html", context), "text/html" + ) + if not message.send(): + raise RuntimeError("Attention reminder 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"]) + if error is not None: + raise error + + +def export_attention(program, category, payload): + rows = selected_rows(program, category, payload) + book = Workbook(write_only=True) + sheet = book.create_sheet("Требует внимания") + + def append(values): + sheet.append([_literal_excel_cell(sheet, value) for value in values]) + + if category == "projects-awaiting-evaluation": + users = CustomUser.objects.in_bulk( + [r.project.leader_id for r in rows if r.project.leader_id] + ) + append( + [ + "ID работы в программе", + "Проект", + "Руководитель", + "Email руководителя", + "Дата сдачи", + "Причина", + "Назначений", + "Выполнено", + ] + ) + serializer = ProgramAttentionProjectSerializer() + for row in rows: + leader = users.get(row.project.leader_id) + append( + [ + row.pk, + row.project.name, + person_name(leader) if leader else "", + leader.email if leader else "", + row.datetime_submitted.isoformat() if row.datetime_submitted else "", + serializer.get_reason_label(row), + row.assignments_total, + row.assignments_completed, + ] + ) + else: + users = CustomUser.objects.in_bulk([r["user_id"] for r in rows]) + append( + [ + "ID участника", + "Участник", + "Email", + "Город", + "Дата регистрации", + "Категория", + ] + ) + for row in rows: + user = users[row["user_id"]] + append( + [ + user.pk, + person_name(user), + user.email, + row["user__city"] or "", + row["registered_at"].isoformat() if row["registered_at"] else "", + LABELS[category], + ] + ) + output = io.BytesIO() + book.save(output) + return output.getvalue() diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index 417d562d..a2080858 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -15,7 +15,9 @@ def send_registration_connection_notification(notification_id): @app.task def dispatch_registration_connection_notifications(): - from partner_programs.services.registration_notifications import dispatch_pending_notifications + from partner_programs.services.registration_notifications import ( + dispatch_pending_notifications, + ) return dispatch_pending_notifications() @@ -62,6 +64,20 @@ def send_program_registration_confirmation(pending_id, generation): raise +@app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def send_attention_reminder(notification_id): + from partner_programs.services.attention_actions import deliver_reminder + + deliver_reminder(notification_id) + + +@app.task +def dispatch_attention_reminders(batch_id=None): + from partner_programs.services.attention_actions import dispatch_pending_reminders + + return dispatch_pending_reminders(batch_id) + + @app.task def publish_finished_program_projects_task() -> int: updated_count = publish_finished_program_projects() diff --git a/partner_programs/tests/test_attention_actions.py b/partner_programs/tests/test_attention_actions.py new file mode 100644 index 00000000..255376c5 --- /dev/null +++ b/partner_programs/tests/test_attention_actions.py @@ -0,0 +1,378 @@ +import io +from concurrent.futures import ThreadPoolExecutor +from unittest import skipUnless +from unittest.mock import patch + +from django.core import mail, signing +from django.db import close_old_connections, connection, transaction +from django.test import TransactionTestCase, override_settings +from openpyxl import load_workbook + +from partner_programs.models import ( + ProgramAttentionReminder, + ProgramAttentionReminderBatch, +) +from partner_programs.services.attention_actions import ( + confirm_reminder, + deliver_reminder, + dispatch_pending_reminders, + preview_reminder, +) +from partner_programs.tests.test_attention_analytics_api import AttentionAnalyticsTestCase +from partner_programs.tests.helpers import ( + create_user, + create_partner_program, + create_program_member, +) +from project_rates.tests.helpers import create_rate_expert + +VISITS = "participants-without-visit" +TEAMS = "participants-without-team" +WORKS = "projects-awaiting-evaluation" + + +@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") +class AttentionActionsTests(AttentionAnalyticsTestCase): + def post(self, action, payload, category=TEAMS, program=None): + pk = (program or self.program).pk + return self.client.post( + f"/programs/{pk}/manager-overview/attention/{category}/{action}/", + payload, + format="json", + ) + + def selection(self, ids=None, search="", excluded=None): + return { + "search": search, + "selection": { + "mode": "all" if ids is None else "selected", + "ids": ids or [], + "excluded_ids": excluded or [], + }, + } + + def preview(self, payload=None, category=TEAMS): + response = self.post("reminder-preview", payload or self.selection(), category) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def confirm(self, preview, category=TEAMS): + response = self.post("reminders", {"token": preview["token"]}, category) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def test_access_for_all_actions_is_program_scoped(self): + member = self.participant().user + for user in (None, member): + self.client.force_authenticate(user) + for action in ("reminder-preview", "reminders", "export"): + with self.subTest(user=user, action=action): + response = self.post(action, self.selection()) + self.assertIn(response.status_code, (401, 403)) + self.client.force_authenticate(self.manager) + self.assertEqual( + self.post("export", self.selection(), program=self.other_program).status_code, + 403, + ) + self.assertEqual(ProgramAttentionReminder.objects.count(), 0) + + def test_preview_is_read_only_and_all_respects_search_and_exclusions_beyond_page( + self, + ): + ids = [self.participant(first_name="Target").user_id for _ in range(28)] + self.participant(first_name="Other") + self.participant(program=self.other_program, first_name="Target") + result = self.preview(self.selection(search="Target", excluded=[ids[0]])) + self.assertEqual(result["selected_count"], 27) + self.assertEqual(result["recipient_count"], 27) + self.assertEqual(ProgramAttentionReminder.objects.count(), 0) + self.assertEqual(ProgramAttentionReminderBatch.objects.count(), 0) + + def test_never_visited_uses_both_activity_fields_and_not_email_activation(self): + unseen = self.participant(is_active=False).user + visited = self.participant(last_login=self.program.datetime_started).user + active = self.participant(last_activity=self.program.datetime_started).user + result = self.preview(self.selection([unseen.pk, visited.pk, active.pk]), VISITS) + self.assertEqual(result["recipient_count"], 1) + self.confirm(result, VISITS) + self.assertEqual(ProgramAttentionReminder.objects.get().recipient_id, unseen.pk) + + def test_confirmation_is_idempotent_and_queues_after_commit(self): + self.participant() + preview = self.preview() + with patch("partner_programs.services.attention_actions.queue_batch") as queue: + with self.captureOnCommitCallbacks(execute=True): + result = self.confirm(preview) + self.assertEqual(queue.call_count, 0) + self.assertEqual(queue.call_count, 1) + self.assertEqual(self.confirm(preview), result) + self.assertEqual(queue.call_count, 1) + self.assertEqual(result["created_count"], 1) + self.assertEqual(ProgramAttentionReminder.objects.count(), 1) + + def test_token_tampering_expiry_and_cross_actor_rejected(self): + self.participant() + preview = self.preview() + self.assertEqual( + self.post("reminders", {"token": preview["token"] + "tamper"}).status_code, + 400, + ) + with patch( + "partner_programs.services.attention_actions.signing.loads", + side_effect=signing.SignatureExpired, + ): + self.assertEqual( + self.post("reminders", {"token": preview["token"]}).status_code, 400 + ) + other = create_user(prefix="other-manager") + self.program.managers.add(other) + self.client.force_authenticate(other) + self.assertEqual( + self.post("reminders", {"token": preview["token"]}).status_code, 400 + ) + self.assertEqual(ProgramAttentionReminder.objects.count(), 0) + + def test_preview_cannot_be_used_for_another_category_or_program(self): + self.participant() + preview = self.preview() + self.other_program.managers.add(self.manager) + self.assertEqual( + self.post("reminders", {"token": preview["token"]}, VISITS).status_code, 400 + ) + self.assertEqual( + self.post( + "reminders", {"token": preview["token"]}, program=self.other_program + ).status_code, + 400, + ) + + def test_participants_resolved_since_preview_are_skipped(self): + user = self.participant().user + preview = self.preview(category=VISITS) + user.last_login = self.program.datetime_started + user.save(update_fields=["last_login"]) + result = self.confirm(preview, VISITS) + self.assertEqual(result, {"created_count": 0, "skipped_count": 1}) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_experts_receive_one_message_with_only_own_unfinished_works(self): + first_work, second_work = ( + self.work(name="Work A"), + self.work(name="Work B"), + ) + self.work(name="No expert") + other = create_rate_expert(program=self.program) + self.assignment(first_work) + self.assignment(second_work) + self.assignment(first_work, expert=other) + self.score(first_work, expert=other) + preview = self.preview(category=WORKS) + self.assertEqual(preview["selected_count"], 3) + self.assertEqual(preview["recipient_count"], 1) + self.assertEqual(preview["without_assignments_count"], 1) + self.confirm(preview, WORKS) + notice = ProgramAttentionReminder.objects.get() + self.assertEqual(notice.recipient_id, self.expert.pk) + self.assertIn("Work A", notice.text) + self.assertIn("Work B", notice.text) + self.assertNotIn("No expert", notice.text) + + def test_open_mode_without_real_assignments_has_export_only(self): + self.open_mode() + self.work() + preview = self.preview(category=WORKS) + self.assertIsNone(preview["token"]) + self.assertEqual(preview["recipient_count"], 0) + self.assertEqual(self.post("export", self.selection(), WORKS).status_code, 200) + + def test_completed_and_new_assignments_after_preview_are_not_sent(self): + first_work, second_work = self.work(name="Complete later"), self.work(name="Keep") + self.assignment(first_work) + self.assignment(second_work) + preview = self.preview(category=WORKS) + self.score(first_work) + other = create_rate_expert(program=self.program) + self.assignment(second_work, expert=other) + self.confirm(preview, WORKS) + notice = ProgramAttentionReminder.objects.get() + self.assertEqual(notice.recipient_id, self.expert.pk) + self.assertIn("Keep", notice.text) + self.assertNotIn("Complete later", notice.text) + + def test_reassigned_work_does_not_leak_into_message_for_retained_assignment(self): + first_work, second_work = self.work(name="Removed assignment"), self.work( + name="Retained assignment" + ) + first = self.assignment(first_work) + self.assignment(second_work) + preview = self.preview(category=WORKS) + first.delete() + other = create_rate_expert(program=self.program) + self.assignment(first_work, expert=other) + self.confirm(preview, WORKS) + notice = ProgramAttentionReminder.objects.get() + self.assertNotIn("Removed assignment", notice.text) + self.assertIn("Retained assignment", notice.text) + + def test_notification_feed_and_read_are_recipient_owned(self): + user = self.participant().user + self.confirm(self.preview()) + notice = ProgramAttentionReminder.objects.get() + self.client.force_authenticate(user) + response = self.client.get("/core/notifications/") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["unread_count"], 1) + self.assertEqual(response.data["results"][0]["kind"], "program_attention") + url = f"/core/notifications/program_attention:{notice.pk}/read/" + self.assertEqual(self.client.post(url).status_code, 200) + self.assertEqual(self.client.post(url).data["unread_count"], 0) + self.client.force_authenticate(self.manager) + self.assertEqual(self.client.post(url).status_code, 404) + + def test_private_email_is_escaped_and_retries_are_idempotent(self): + user = self.participant(first_name="").user + self.confirm(self.preview()) + notice = ProgramAttentionReminder.objects.get() + deliver_reminder(notice.pk) + deliver_reminder(notice.pk) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [user.email]) + self.assertEqual(mail.outbox[0].cc, []) + self.assertNotIn("