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("