diff --git a/notifications/migrations/0004_program_attention_reminder_type.py b/notifications/migrations/0004_program_attention_reminder_type.py
new file mode 100644
index 00000000..2a1f9896
--- /dev/null
+++ b/notifications/migrations/0004_program_attention_reminder_type.py
@@ -0,0 +1,45 @@
+# Generated by Django 4.2.11 on 2026-10-21 10:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("notifications", "0003_notification_image_url"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="notification",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("program_attention_reminder", "Напоминание по программе"),
+ ("project_invite_created", "Приглашение в проект"),
+ ("project_invite_accepted", "Приглашение в проект принято"),
+ ("project_invite_declined", "Приглашение в проект отклонено"),
+ ("project_invite_revoked", "Приглашение в проект отозвано"),
+ ("vacancy_response_created", "Новый отклик на вакансию"),
+ ("vacancy_response_accepted", "Отклик принят"),
+ ("vacancy_response_declined", "Отклик отклонён"),
+ ("program_news_published", "Новая новость программы"),
+ ("program_material_published", "Новый материал программы"),
+ ("course_access_opened", "Открыт доступ к курсу"),
+ ("team_invite_created", "Приглашение в команду"),
+ ("team_invite_accepted", "Приглашение в команду принято"),
+ ("team_invite_declined", "Приглашение в команду отклонено"),
+ ("team_invite_revoked", "Приглашение в команду отозвано"),
+ ("application_submitted", "Заявка отправлена"),
+ ("application_status_changed", "Статус заявки изменён"),
+ ("submission_submitted", "Решение отправлено"),
+ ("submission_status_changed", "Статус решения изменён"),
+ ("expert_assignment_created", "Назначена экспертиза"),
+ ("expert_assignment_revoked", "Экспертиза отозвана"),
+ ("evaluation_submitted", "Оценка отправлена"),
+ ("news_comment_created", "Новый комментарий"),
+ ],
+ max_length=64,
+ ),
+ ),
+ ]
diff --git a/notifications/models.py b/notifications/models.py
index 80d5f4e6..ed50a041 100644
--- a/notifications/models.py
+++ b/notifications/models.py
@@ -4,6 +4,10 @@
class Notification(models.Model):
class Type(models.TextChoices):
+ PROGRAM_ATTENTION_REMINDER = (
+ "program_attention_reminder",
+ "Напоминание по программе",
+ )
PROJECT_INVITE_CREATED = "project_invite_created", "Приглашение в проект"
PROJECT_INVITE_ACCEPTED = (
"project_invite_accepted",
@@ -48,6 +52,7 @@ class Category(models.TextChoices):
SYSTEM = "system", "Система"
TYPE_CATEGORY = {
+ Type.PROGRAM_ATTENTION_REMINDER: Category.PROGRAM,
Type.PROJECT_INVITE_CREATED: Category.PROJECT,
Type.PROJECT_INVITE_ACCEPTED: Category.PROJECT,
Type.PROJECT_INVITE_DECLINED: Category.PROJECT,
diff --git a/notifications/selectors.py b/notifications/selectors.py
index 167ece6e..a4c44133 100644
--- a/notifications/selectors.py
+++ b/notifications/selectors.py
@@ -4,6 +4,7 @@
ANGULAR_NOTIFICATION_TYPES = (
+ Notification.Type.PROGRAM_ATTENTION_REMINDER,
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.PROJECT_INVITE_ACCEPTED,
Notification.Type.PROJECT_INVITE_DECLINED,
diff --git a/notifications/tests/test_notification_visibility.py b/notifications/tests/test_notification_visibility.py
index 54536f8d..e3b0d439 100644
--- a/notifications/tests/test_notification_visibility.py
+++ b/notifications/tests/test_notification_visibility.py
@@ -23,6 +23,7 @@ def test_angular_surface_contains_only_production_types(self):
self.assertEqual(
set(ANGULAR_NOTIFICATION_TYPES),
{
+ Notification.Type.PROGRAM_ATTENTION_REMINDER,
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.PROJECT_INVITE_ACCEPTED,
Notification.Type.PROJECT_INVITE_DECLINED,
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..4459c18d
--- /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.analytics_actions_support 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/0026_program_attention_reminders.py b/partner_programs/migrations/0026_program_attention_reminders.py
new file mode 100644
index 00000000..774a67d6
--- /dev/null
+++ b/partner_programs/migrations/0026_program_attention_reminders.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", "0025_partnerprogramuserprofile_welcome_acknowledged_at"),
+ ]
+
+ 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 8a4939b1..cb2b4bd0 100644
--- a/partner_programs/models.py
+++ b/partner_programs/models.py
@@ -1415,3 +1415,9 @@ def save(self, *args, **kwargs):
return super().save(*args, **kwargs)
self.full_clean()
super().save(*args, **kwargs)
+
+
+from .attention_models import ( # noqa: E402,F401
+ ProgramAttentionReminder,
+ ProgramAttentionReminderBatch,
+)
diff --git a/partner_programs/report_views.py b/partner_programs/report_views.py
new file mode 100644
index 00000000..4169d825
--- /dev/null
+++ b/partner_programs/report_views.py
@@ -0,0 +1,26 @@
+from django.db import connection, transaction
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.views import APIView
+
+from core.utils import build_xlsx_download_response
+from partner_programs.services.analytics_actions_support import managed_program
+from partner_programs.services.program_report import build_program_report
+
+
+class ProgramReportView(APIView):
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, pk):
+ with transaction.atomic():
+ if connection.vendor == "postgresql":
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
+ )
+ program = managed_program(pk, request.user)
+ data = build_program_report(program)
+ response = build_xlsx_download_response(
+ data, base_name=f"Программа - {program.name}"
+ )
+ response["Cache-Control"] = "private, no-store"
+ return response
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/analytics_actions_support.py b/partner_programs/services/analytics_actions_support.py
new file mode 100644
index 00000000..d0c713fd
--- /dev/null
+++ b/partner_programs/services/analytics_actions_support.py
@@ -0,0 +1,45 @@
+"""Shared, read-only helpers for production analytics actions only."""
+from django.db.models import Value
+from django.db.models.functions import Coalesce, NullIf, Trim
+from django.shortcuts import get_object_or_404
+from rest_framework.exceptions import PermissionDenied
+from partner_programs.models import PartnerProgram, PartnerProgramUserProfile
+from partner_programs.permissions import is_program_manager
+
+
+def managed_program(pk, user, *, lock=False):
+ rows = (
+ PartnerProgram.objects.select_for_update()
+ if lock
+ else PartnerProgram.objects.all()
+ )
+ program = get_object_or_404(rows, pk=pk)
+ if not user.is_authenticated or not (
+ user.is_staff or user.is_superuser or is_program_manager(user, program)
+ ):
+ raise PermissionDenied("У вас нет доступа к управлению этой программой.")
+ return program
+
+
+def person_name(user):
+ return (
+ " ".join(filter(None, (user.first_name, user.last_name, user.patronymic))).strip()
+ or "Без имени"
+ )
+
+
+def participant_rows(program):
+ return (
+ PartnerProgramUserProfile.objects.filter(
+ partner_program=program, user__isnull=False
+ )
+ .select_related("user")
+ .annotate(
+ participant_region=Coalesce(
+ NullIf(Trim("user__city"), Value("")),
+ NullIf(Trim("user__region"), Value("")),
+ Value(""),
+ )
+ )
+ .order_by("datetime_created", "pk")
+ )
diff --git a/partner_programs/services/attention_actions.py b/partner_programs/services/attention_actions.py
new file mode 100644
index 00000000..6dadedb5
--- /dev/null
+++ b/partner_programs/services/attention_actions.py
@@ -0,0 +1,488 @@
+"""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.project_analytics_attention import (
+ ProjectAnalyticsAwaitingProjectSerializer as ProgramAttentionProjectSerializer,
+)
+from partner_programs.serializers.attention_actions import MAX_SELECTION
+from partner_programs.services.project_analytics import (
+ participants_without_team_rows,
+ participants_without_visit_rows,
+ projects_awaiting_evaluation_rows,
+)
+from partner_programs.services.project_assignment_analytics import (
+ annotated_assignment_queryset,
+)
+from partner_programs.services.exports import _literal_excel_cell
+from partner_programs.services.analytics_actions_support import (
+ managed_program,
+ person_name,
+)
+from users.models import CustomUser
+from notifications.models import Notification
+from notifications.services import create_notification
+
+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)
+ for notice in notices:
+ create_notification(
+ recipient_id=notice.recipient_id,
+ actor_id=None,
+ notification_type=Notification.Type.PROGRAM_ATTENTION_REMINDER,
+ title="Напоминание по программе",
+ message=notice.text,
+ action_url=notice.action_path,
+ event_key=f"program_attention:{batch.pk}:{notice.recipient_id}",
+ )
+ 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": getattr(
+ settings, "PROGRAM_ATTENTION_FRONTEND_URL", "https://app.procollab.ru"
+ ).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/services/program_report.py b/partner_programs/services/program_report.py
new file mode 100644
index 00000000..c1cf454a
--- /dev/null
+++ b/partner_programs/services/program_report.py
@@ -0,0 +1,397 @@
+"""A single, program-scoped workbook with stable IDs between its sheets."""
+
+from collections import Counter
+from datetime import datetime
+from decimal import Decimal, InvalidOperation
+from io import BytesIO
+import json
+from zoneinfo import ZoneInfo
+
+from django.db.models import Prefetch
+from django.utils import timezone
+from openpyxl import Workbook
+from openpyxl.cell import WriteOnlyCell
+from openpyxl.styles import Font, PatternFill
+from openpyxl.utils import get_column_letter
+
+from partner_programs.models import PartnerProgramFieldValue, PartnerProgramProject
+from partner_programs.services.project_analytics import (
+ build_project_analytics as build_program_manager_analytics,
+)
+from partner_programs.services.project_assignment_analytics import build_assignments
+from partner_programs.services.project_case_analytics import project_case_links
+from partner_programs.services.case_fields import get_program_case_field
+from partner_programs.services.exports import _literal_excel_cell
+from partner_programs.services.analytics_actions_support import (
+ participant_rows,
+ person_name,
+)
+from project_rates.models import Criteria, ProjectScore
+from projects.models import Collaborator
+
+ASSIGNMENT_LABELS = {
+ "completed": "Выполнено",
+ "pending": "Ожидает оценки",
+ "in_progress": "В работе",
+ "not_ready": "Решение еще не сдано",
+}
+
+
+def text_value(value):
+ if isinstance(value, (dict, list)):
+ return json.dumps(value, ensure_ascii=False)
+ return value
+
+
+def score_value(value, kind):
+ if value is not None and value != "" and kind in ("int", "float"):
+ try:
+ number = Decimal(str(value))
+ if number.is_finite():
+ return number
+ except InvalidOperation:
+ pass
+ return value
+
+
+class Report:
+ def __init__(self):
+ self.book = Workbook(write_only=True)
+
+ def sheet(self, name, headers):
+ sheet = self.book.create_sheet(name)
+ sheet.freeze_panes = "A2"
+ for index, title in enumerate(headers, 1):
+ sheet.column_dimensions[get_column_letter(index)].width = (
+ 30 if "ID" not in title else 16
+ )
+ cells = []
+ for title in headers:
+ cell = _literal_excel_cell(sheet, title)
+ cell.font = Font(bold=True)
+ cell.fill = PatternFill("solid", fgColor="E8E0FA")
+ cells.append(cell)
+ sheet.append(cells)
+ return sheet
+
+ def append(self, sheet, values):
+ cells = []
+ for value in values:
+ if isinstance(value, datetime):
+ value = value.astimezone(ZoneInfo("Europe/Moscow")).replace(tzinfo=None)
+ cell = WriteOnlyCell(sheet, value)
+ cell.number_format = "dd.mm.yyyy hh:mm"
+ elif isinstance(value, Decimal):
+ cell = WriteOnlyCell(sheet, value)
+ else:
+ cell = _literal_excel_cell(sheet, text_value(value))
+ cells.append(cell)
+ sheet.append(cells)
+
+ def finish(self):
+ output = BytesIO()
+ self.book.save(output)
+ return output.getvalue()
+
+
+def build_program_report(program):
+ report = Report()
+ overview = build_program_manager_analytics(program)
+ summary = report.sheet("Сводка", ["Показатель", "Значение"])
+ for row in [
+ ["ID программы", program.pk],
+ ["Программа", program.name],
+ ["Сформировано (МСК)", timezone.now()],
+ ["Описание", program.description],
+ ["Начало программы (МСК)", program.datetime_started],
+ ["Окончание программы (МСК)", program.datetime_finished],
+ ["Окончание регистрации (МСК)", program.datetime_registration_ends],
+ ["Окончание приема решений (МСК)", program.get_project_submission_deadline()],
+ ["Окончание оценивания (МСК)", program.datetime_evaluation_ends],
+ ["Участники", overview["summary"]["participants"]["total"]],
+ ["Проекты", overview["summary"]["projects"]["total"]],
+ ["Сдано", overview["solution_funnel"]["submitted"]],
+ ["Оценено проектов", overview["solution_funnel"]["evaluated"]],
+ ["Заходили на платформу", overview["participant_funnel"]["visited_participants"]],
+ ["Вступили в команду", overview["participant_funnel"]["with_team"]],
+ [
+ "Формат оценивания",
+ "Распределенное" if program.is_distributed_evaluation else "Открытое",
+ ],
+ [
+ "Связи между листами",
+ "ID участника = ID пользователя. Проекты, команды, назначения и оценки связаны по ID проекта.",
+ ],
+ ]:
+ report.append(summary, row)
+
+ people = report.sheet(
+ "Участники",
+ [
+ "ID участника",
+ "Участник",
+ "Почта",
+ "Регион",
+ "Дата регистрации (МСК)",
+ "Последняя активность (МСК)",
+ "Последний вход (МСК)",
+ ],
+ )
+ answers = report.sheet(
+ "Анкеты",
+ ["ID регистрации", "ID участника", "Участник", "ID поля", "Поле анкеты", "Ответ"],
+ )
+ seen = set()
+ for profile in participant_rows(program).iterator(chunk_size=500):
+ user = profile.user
+ if user.pk not in seen:
+ report.append(
+ people,
+ [
+ user.pk,
+ person_name(user),
+ user.email,
+ profile.participant_region,
+ profile.datetime_created,
+ user.last_activity,
+ user.last_login,
+ ],
+ )
+ seen.add(user.pk)
+ schema = (
+ getattr(profile, "registration_schema", program.data_schema)
+ if isinstance(
+ getattr(profile, "registration_schema", program.data_schema), dict
+ )
+ else {}
+ )
+ fields = {
+ field["id"]: field.get("label", field["id"])
+ for field in schema.get("fields", [])
+ if isinstance(field, dict) and "id" in field
+ }
+ data = (
+ profile.partner_program_data
+ if isinstance(profile.partner_program_data, dict)
+ else {}
+ )
+ for key, value in data.items():
+ report.append(
+ answers,
+ [
+ profile.pk,
+ user.pk,
+ person_name(user),
+ key,
+ fields.get(key, key),
+ value,
+ ],
+ )
+
+ fields = list(program.fields.order_by("pk"))
+ projects = report.sheet(
+ "Проекты",
+ [
+ "ID проекта",
+ "ID связи с программой",
+ "Проект",
+ "Описание",
+ "Кейс",
+ "Регион",
+ "ID лидера",
+ "Лидер",
+ "Размер команды",
+ "Презентация",
+ "Сдача решения",
+ "Добавлен (МСК)",
+ "Сдан (МСК)",
+ ]
+ + [f"{field.label} [ID {field.pk}]" for field in fields],
+ )
+ teams = report.sheet(
+ "Команды", ["ID проекта", "Проект", "ID участника", "Участник", "Роль"]
+ )
+ case_field = get_program_case_field(program)
+ links = (
+ project_case_links(program, field=case_field)
+ .select_related("project", "project__leader")
+ .prefetch_related(
+ Prefetch(
+ "project__collaborator_set",
+ queryset=Collaborator.objects.select_related("user"),
+ to_attr="report_collaborators",
+ ),
+ Prefetch(
+ "field_values",
+ queryset=PartnerProgramFieldValue.objects.filter(
+ field__partner_program=program
+ ),
+ to_attr="report_values",
+ ),
+ )
+ .order_by("pk")
+ )
+ for link in links.iterator(chunk_size=500):
+ project = link.project
+ team = {}
+ if project.leader_id:
+ team[project.leader_id] = (project.leader, "Лидер")
+ for member in project.report_collaborators:
+ team.setdefault(member.user_id, (member.user, "Участник"))
+ values = {value.field_id: value.value_text for value in link.report_values}
+ report.append(
+ projects,
+ [
+ project.pk,
+ link.pk,
+ project.name,
+ project.description,
+ link.case_name or "Без выбранного кейса",
+ project.region,
+ project.leader_id,
+ person_name(project.leader) if project.leader_id else "",
+ len(team),
+ project.presentation_address,
+ ("Сдано" if link.submitted else "Не сдано")
+ if program.is_competitive
+ else "Не требуется",
+ link.datetime_created,
+ link.datetime_submitted if link.submitted else None,
+ ]
+ + [values.get(field.pk, "") for field in fields],
+ )
+ for user, role in team.values():
+ report.append(
+ teams, [project.pk, project.name, user.pk, person_name(user), role]
+ )
+
+ cases = report.sheet("Кейсы", ["Кейс", "Проектов", "Сдано", "Участников"])
+ for case in overview["cases"]["items"]:
+ report.append(
+ cases,
+ [
+ case["name"],
+ case["projects_total"],
+ case["submitted"],
+ case["participants_total"],
+ ],
+ )
+ case = overview["cases"]["without_case"]
+ report.append(
+ cases,
+ [
+ "Без выбранного кейса",
+ case["projects_total"],
+ case["submitted"],
+ case["participants_total"],
+ ],
+ )
+
+ assignment_data = build_assignments(program.pk)
+ total = Counter(row["expert"]["expert_id"] for row in assignment_data)
+ completed = Counter(
+ row["expert"]["expert_id"]
+ for row in assignment_data
+ if row["status"] == "completed"
+ )
+ experts = report.sheet(
+ "Эксперты",
+ ["ID эксперта", "ID пользователя", "Эксперт", "Почта", "Назначений", "Выполнено"],
+ )
+ for expert in program.experts.select_related("user").order_by("pk"):
+ report.append(
+ experts,
+ [
+ expert.pk,
+ expert.user_id,
+ person_name(expert.user),
+ expert.user.email,
+ total[expert.pk],
+ completed[expert.pk],
+ ],
+ )
+ assignments = report.sheet(
+ "Назначения",
+ [
+ "ID назначения",
+ "ID проекта",
+ "Проект",
+ "ID эксперта",
+ "ID пользователя",
+ "Эксперт",
+ "Статус",
+ "Назначено (МСК)",
+ "Сдано (МСК)",
+ ],
+ )
+ for row in assignment_data:
+ report.append(
+ assignments,
+ [
+ row["assignment_id"],
+ row["project"]["id"],
+ row["project"]["name"],
+ row["expert"]["expert_id"],
+ row["expert"]["user_id"],
+ row["expert"]["full_name"],
+ ASSIGNMENT_LABELS[row["status"]],
+ row["assigned_at"],
+ row["project_submitted_at"],
+ ],
+ )
+ criteria = report.sheet(
+ "Критерии", ["ID критерия", "Критерий", "Описание", "Тип", "Минимум", "Максимум"]
+ )
+ for criterion in Criteria.objects.filter(partner_program=program).order_by("pk"):
+ report.append(
+ criteria,
+ [
+ criterion.pk,
+ criterion.name,
+ criterion.description,
+ criterion.type,
+ criterion.min_value,
+ criterion.max_value,
+ ],
+ )
+ scores = report.sheet(
+ "Оценки",
+ [
+ "ID оценки",
+ "ID проекта",
+ "Проект",
+ "ID пользователя эксперта",
+ "Эксперт",
+ "ID критерия",
+ "Критерий",
+ "Тип",
+ "Значение",
+ "Исходное значение",
+ ],
+ )
+ for score in (
+ ProjectScore.objects.filter(
+ criteria__partner_program=program,
+ project_id__in=PartnerProgramProject.objects.filter(
+ partner_program=program
+ ).values("project_id"),
+ )
+ .select_related("criteria", "project", "user")
+ .order_by("project_id", "user_id", "criteria_id")
+ ):
+ report.append(
+ scores,
+ [
+ score.pk,
+ score.project_id,
+ score.project.name,
+ score.user_id,
+ person_name(score.user),
+ score.criteria_id,
+ score.criteria.name,
+ score.criteria.type,
+ score_value(score.value, score.criteria.type),
+ score.value,
+ ],
+ )
+ return report.finish()
diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py
index 39baf11c..300f5452 100644
--- a/partner_programs/tasks.py
+++ b/partner_programs/tasks.py
@@ -11,3 +11,17 @@ def publish_finished_program_projects_task() -> int:
updated_count = publish_finished_program_projects()
logger.info("Published %s program projects after finish", updated_count)
return updated_count
+
+
+@app.task
+def send_attention_reminder(pk):
+ from partner_programs.services.attention_actions import deliver_reminder
+
+ deliver_reminder(pk)
+
+
+@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)
diff --git a/partner_programs/tests/test_attention_actions.py b/partner_programs/tests/test_attention_actions.py
new file mode 100644
index 00000000..721fe397
--- /dev/null
+++ b/partner_programs/tests/test_attention_actions.py
@@ -0,0 +1,399 @@
+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 TestCase, 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_project_analytics_attention_api import (
+ ProjectAnalyticsAttentionFixture,
+)
+from notifications.models import Notification
+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"
+
+
+class AttentionAnalyticsTestCase(ProjectAnalyticsAttentionFixture, TestCase):
+ pass
+
+
+@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}/project-analytics/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.set_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)
+
+ @override_settings(NEXTGEN_SURFACE_ENABLED=False)
+ def test_notification_feed_and_read_are_recipient_owned(self):
+ user = self.participant().user
+ preview = self.preview()
+ self.confirm(preview)
+ self.confirm(preview)
+ self.assertEqual(
+ Notification.objects.filter(type="program_attention_reminder").count(), 1
+ )
+ notice = Notification.objects.get(type="program_attention_reminder")
+ self.client.force_authenticate(user)
+ response = self.client.get("/notifications/")
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data["unread_count"], 1)
+ self.assertEqual(
+ response.data["results"][0]["type"], "program_attention_reminder"
+ )
+ url = f"/notifications/{notice.pk}/read/"
+ self.assertEqual(self.client.post(url).status_code, 200)
+ self.assertEqual(
+ self.client.get("/notifications/unread-count/").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("