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