diff --git a/core/admin.py b/core/admin.py index ca4c6db3..3c86328c 100644 --- a/core/admin.py +++ b/core/admin.py @@ -15,7 +15,8 @@ class SkillToObjectInline(GenericStackedInline): model = SkillToObject - extra = 1 + extra = 0 + autocomplete_fields = ("skill",) verbose_name = "Навык" verbose_name_plural = "Навыки" @@ -49,6 +50,10 @@ class SkillAdmin(admin.ModelAdmin): "id", "name", ) + search_fields = ( + "name", + "category__name", + ) @admin.register(SkillCategory) diff --git a/mailing/constants.py b/mailing/constants.py index cad1c27b..47dac264 100644 --- a/mailing/constants.py +++ b/mailing/constants.py @@ -10,3 +10,7 @@ def get_default_mailing_schema() -> dict[str, dict[str, str]]: MAILING_USERS_BATCH_SIZE = 100 + +FAILED_ANYMAIL_STATUSES = frozenset( + {"rejected", "failed", "invalid", "bounced", "unknown"} +) diff --git a/mailing/migrations/0008_mailing_scenario_log.py b/mailing/migrations/0008_mailing_scenario_log.py new file mode 100644 index 00000000..dc951911 --- /dev/null +++ b/mailing/migrations/0008_mailing_scenario_log.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("partner_programs", "0015_partnerprogram_publish_projects_after_finish"), + ("mailing", "0007_alter_mailingschema_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="MailingScenarioLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("scenario_code", models.CharField(max_length=128)), + ("scheduled_for", models.DateField()), + ("status", models.CharField(choices=[("pending", "Pending"), ("sent", "Sent"), ("failed", "Failed")], default="pending", max_length=16)), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("error", models.TextField(blank=True, null=True)), + ("datetime_created", models.DateTimeField(auto_now_add=True)), + ("datetime_updated", models.DateTimeField(auto_now=True)), + ( + "program", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="mailing_scenario_logs", + to="partner_programs.partnerprogram", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="mailing_scenario_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Лог сценария рассылки", + "verbose_name_plural": "Логи сценариев рассылки", + "unique_together": {("scenario_code", "program", "user", "scheduled_for")}, + }, + ), + migrations.AddIndex( + model_name="mailingscenariolog", + index=models.Index(fields=["scenario_code", "scheduled_for"], name="mailing_ma_scenari_73b1f9_idx"), + ), + migrations.AddIndex( + model_name="mailingscenariolog", + index=models.Index(fields=["program", "scheduled_for"], name="mailing_ma_program_b9dcf9_idx"), + ), + migrations.AddIndex( + model_name="mailingscenariolog", + index=models.Index(fields=["user", "scheduled_for"], name="mailing_ma_user_id_0e2a92_idx"), + ), + ] diff --git a/mailing/migrations/0009_rename_mailing_ma_scenari_73b1f9_idx_mailing_mai_scenari_eed98a_idx_and_more.py b/mailing/migrations/0009_rename_mailing_ma_scenari_73b1f9_idx_mailing_mai_scenari_eed98a_idx_and_more.py new file mode 100644 index 00000000..0eb2a012 --- /dev/null +++ b/mailing/migrations/0009_rename_mailing_ma_scenari_73b1f9_idx_mailing_mai_scenari_eed98a_idx_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2026-02-09 09:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("mailing", "0008_mailing_scenario_log"), + ] + + operations = [ + migrations.RenameIndex( + model_name="mailingscenariolog", + new_name="mailing_mai_scenari_eed98a_idx", + old_name="mailing_ma_scenari_73b1f9_idx", + ), + migrations.RenameIndex( + model_name="mailingscenariolog", + new_name="mailing_mai_program_63bc97_idx", + old_name="mailing_ma_program_b9dcf9_idx", + ), + migrations.RenameIndex( + model_name="mailingscenariolog", + new_name="mailing_mai_user_id_333e66_idx", + old_name="mailing_ma_user_id_0e2a92_idx", + ), + ] diff --git a/mailing/models.py b/mailing/models.py index 858e3e8b..c4839b45 100644 --- a/mailing/models.py +++ b/mailing/models.py @@ -1,6 +1,7 @@ import os import uuid +from django.conf import settings from django.db import models from .constants import get_default_mailing_schema @@ -22,3 +23,47 @@ class Meta: def __str__(self): return f"MailingSchema<{self.name}>" + + +class MailingScenarioLog(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + SENT = "sent", "Sent" + FAILED = "failed", "Failed" + + scenario_code = models.CharField(max_length=128) + program = models.ForeignKey( + "partner_programs.PartnerProgram", + on_delete=models.CASCADE, + related_name="mailing_scenario_logs", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="mailing_scenario_logs", + ) + scheduled_for = models.DateField() + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.PENDING + ) + sent_at = models.DateTimeField(null=True, blank=True) + error = models.TextField(null=True, blank=True) + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Лог сценария рассылки" + verbose_name_plural = "Логи сценариев рассылки" + unique_together = ("scenario_code", "program", "user", "scheduled_for") + indexes = [ + models.Index(fields=["scenario_code", "scheduled_for"]), + models.Index(fields=["program", "scheduled_for"]), + models.Index(fields=["user", "scheduled_for"]), + ] + + def __str__(self): + return ( + f"MailingScenarioLog<{self.scenario_code}> " + f"program={self.program_id} user={self.user_id} " + f"date={self.scheduled_for} status={self.status}" + ) diff --git a/mailing/rendering.py b/mailing/rendering.py new file mode 100644 index 00000000..e7c728b2 --- /dev/null +++ b/mailing/rendering.py @@ -0,0 +1,18 @@ +from partner_programs.models import PartnerProgram +from users.models import CustomUser + + +def render_subject(subject: str, program: PartnerProgram) -> str: + return subject.replace("{program_name}", program.name) + + +def render_template_value( + value: str, + program: PartnerProgram, + user: CustomUser, +) -> str: + return ( + value.replace("{program_name}", program.name) + .replace("{program_id}", str(program.id)) + .replace("{user_id}", str(user.id)) + ) diff --git a/mailing/scenarios.py b/mailing/scenarios.py new file mode 100644 index 00000000..5702f976 --- /dev/null +++ b/mailing/scenarios.py @@ -0,0 +1,206 @@ +from dataclasses import dataclass +from datetime import date +from enum import Enum +from typing import Callable + +from mailing.rendering import render_template_value +from partner_programs.models import PartnerProgram +from users.models import CustomUser + +FRONTEND_BASE_URL = "https://app.procollab.ru" + + +class TriggerType(Enum): + PROGRAM_SUBMISSION_DEADLINE = "program_submission_deadline" + PROGRAM_REGISTRATION_DATE = "program_registration_date" + PROGRAM_REGISTRATION_END = "program_registration_end" + + +class RecipientRule(Enum): + ALL_PARTICIPANTS = "all_participants" + NO_PROJECT_IN_PROGRAM = "no_project_in_program" + NO_PROJECT_IN_PROGRAM_REGISTERED_ON_DATE = "no_project_in_program_registered_on_date" + PROJECT_NOT_SUBMITTED = "project_not_submitted" + INACTIVE_ACCOUNT_IN_PROGRAM = "inactive_account_in_program" + INACTIVE_ACCOUNT_IN_PROGRAM_REGISTERED_ON_DATE = ( + "inactive_account_in_program_registered_on_date" + ) + + +ContextBuilder = Callable[[PartnerProgram, CustomUser, date], dict] + + +@dataclass(frozen=True) +class Scenario: + code: str + trigger: TriggerType + offset_days: int + template_name: str + subject: str + recipient_rule: RecipientRule + context_builder: ContextBuilder + + +def _build_context( + *, + preview_text: str, + title: str, + text: str, + button_text: str | None = None, + button_link: str | None = None, +) -> ContextBuilder: + def _builder(program: PartnerProgram, user: CustomUser, _ref_date: date) -> dict: + context = { + "preview_text": render_template_value(preview_text, program, user), + "title": render_template_value(title, program, user), + "text": render_template_value(text, program, user), + } + if button_text is not None: + context["button_text"] = render_template_value(button_text, program, user) + if button_link is not None: + context["button_link"] = render_template_value(button_link, program, user) + return context + + return _builder + + +SCENARIOS: tuple[Scenario, ...] = ( + Scenario( + code="program_submission_deadline_minus_10_no_project", + trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, + offset_days=10, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM, + context_builder=_build_context( + preview_text="Кейс-чемпионат уже стартовал", + title="Время начинать!", + text=( + "Кейс-чемпионат уже стартовал. Скорее заходите на платформу, " + "создавайте проект и подключайте команду к работе.\n\n" + "Вас ждет много интересного ⚡" + ), + button_text="Создать проект", + button_link=f"{FRONTEND_BASE_URL}/office/projects", + ), + ), + Scenario( + code="program_registration_plus_5_no_project", + trigger=TriggerType.PROGRAM_REGISTRATION_DATE, + offset_days=5, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM_REGISTERED_ON_DATE, + context_builder=_build_context( + preview_text="Сделать первый шаг", + title="Сделать первый шаг", + text=( + "Когда непонятно с чего начать — стоит начать с самого простого. " + "Например, зайти на платформу, создать проект или вступить в уже " + "созданный лидером вашей команды.\n\n" + "И вот, первый шаг уже сделан!" + ), + button_text="Зайти на платформу", + button_link=f"{FRONTEND_BASE_URL}/office/projects", + ), + ), + Scenario( + code="program_registration_plus_3_inactive_account", + trigger=TriggerType.PROGRAM_REGISTRATION_DATE, + offset_days=3, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.INACTIVE_ACCOUNT_IN_PROGRAM_REGISTERED_ON_DATE, + context_builder=_build_context( + preview_text="Поздравляем!", + title="Поздравляем!", + text=( + "Вы зарегистрировались на {program_name}. " + "Заходите на платформу, чтобы оформить свой профиль участника " + "и вступить в закрытую группу программы.\n\n" + "Увидимся на платформе ⚡" + ), + button_text="Оформить профиль", + button_link=f"{FRONTEND_BASE_URL}/office/profile/{{user_id}}/", + ), + ), + Scenario( + code="program_registration_end_plus_3_inactive_account", + trigger=TriggerType.PROGRAM_REGISTRATION_END, + offset_days=3, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.INACTIVE_ACCOUNT_IN_PROGRAM, + context_builder=_build_context( + preview_text="Без вас совсем не то", + title="Без вас совсем не то", + text=( + "Мы так обрадовались, увидев вашу регистрацию, но, кажется, " + "вы еще не заходили на платформу.\n\n" + "Скорее заходите на procollab, чтобы стать активным участником " + "программы и забрать максимум полезного для себя ⚡" + ), + button_text="Зайти на платформу", + button_link=f"{FRONTEND_BASE_URL}/office/profile/{{user_id}}/", + ), + ), + Scenario( + code="program_submission_deadline_minus_9_project_not_submitted", + trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, + offset_days=9, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED, + context_builder=_build_context( + preview_text="Кейс-задания опубликованы", + title="Кейс-задания опубликованы", + text=( + "Заходите на платформу, чтобы познакомиться с кейсами первого этапа " + "кейс-чемпионата. Кейсы загружены в материалы закрытой группы.\n\n" + "Приступайте к работе уже сегодня, чтобы успеть подготовить итоговое " + "решение в срок ⚡" + ), + button_text="Познакомиться с кейсом", + button_link=f"{FRONTEND_BASE_URL}/office/program/{{program_id}}", + ), + ), + Scenario( + code="program_submission_deadline_minus_3_project_not_submitted", + trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, + offset_days=3, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED, + context_builder=_build_context( + preview_text="До сдачи итогового решения осталось 3 дня", + title="До сдачи итогового решения осталось 3 дня", + text=( + "Работа в самом разгаре, и мы запускаем обратный отсчет. " + "Осталось всего 3 дня, чтобы доработать проект, оформить презентацию " + "и загрузить итоговое решение на платформу." + ), + button_text="Загрузить решение", + button_link=f"{FRONTEND_BASE_URL}/office/projects", + ), + ), + Scenario( + code="program_submission_deadline_minus_1_project_not_submitted", + trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, + offset_days=1, + template_name="email/generic-template-0.html", + subject="{program_name}: важное сообщение", + recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED, + context_builder=_build_context( + preview_text="1 день до сдачи итогового решения", + title="1 день до сдачи итогового решения", + text=( + "День X совсем скоро. Осталось только внести последние штрихи и " + "загрузить итоговое решение на платформу.\n\n" + "По любым техническим вопросам всегда на связи @procollab_support\n\n" + "Удачи!" + ), + button_text="Загрузить решение", + button_link=f"{FRONTEND_BASE_URL}/office/program/{{program_id}}", + ), + ), +) diff --git a/mailing/tasks.py b/mailing/tasks.py new file mode 100644 index 00000000..6e9f91f5 --- /dev/null +++ b/mailing/tasks.py @@ -0,0 +1,281 @@ +import logging +from datetime import timedelta + +from django.utils import timezone + +from mailing.constants import FAILED_ANYMAIL_STATUSES +from mailing.models import MailingScenarioLog +from mailing.rendering import render_subject +from mailing.scenarios import RecipientRule, SCENARIOS, TriggerType +from mailing.utils import send_mass_mail_from_template +from partner_programs.selectors import ( + program_participants, + program_participants_with_inactive_account, + program_participants_with_inactive_account_registered_on, + program_participants_with_unsubmitted_project, + program_participants_without_project_registered_on, + program_participants_without_project, + programs_with_registration_end_on, + programs_with_registrations_on, + programs_with_submission_deadline_on, +) +from procollab.celery import app + +logger = logging.getLogger(__name__) + + +def _get_programs_for_scenario(scenario, target_date): + match scenario.trigger: + case TriggerType.PROGRAM_SUBMISSION_DEADLINE: + return programs_with_submission_deadline_on(target_date) + case TriggerType.PROGRAM_REGISTRATION_DATE: + return programs_with_registrations_on(target_date) + case TriggerType.PROGRAM_REGISTRATION_END: + return programs_with_registration_end_on(target_date) + case _: + raise ValueError(f"Unsupported trigger: {scenario.trigger}") + + +def _get_recipients(scenario, program, target_date): + match scenario.recipient_rule: + case RecipientRule.ALL_PARTICIPANTS: + return program_participants(program.id) + case RecipientRule.NO_PROJECT_IN_PROGRAM: + return program_participants_without_project(program.id) + case RecipientRule.NO_PROJECT_IN_PROGRAM_REGISTERED_ON_DATE: + return program_participants_without_project_registered_on( + program.id, target_date + ) + case RecipientRule.PROJECT_NOT_SUBMITTED: + return program_participants_with_unsubmitted_project(program.id) + case RecipientRule.INACTIVE_ACCOUNT_IN_PROGRAM: + return program_participants_with_inactive_account( + program.id, program.datetime_started + ) + case RecipientRule.INACTIVE_ACCOUNT_IN_PROGRAM_REGISTERED_ON_DATE: + return program_participants_with_inactive_account_registered_on( + program.id, + target_date, + program.datetime_started, + ) + case _: + raise ValueError(f"Unsupported recipient rule: {scenario.recipient_rule}") + + +def _deadline_date(program): + deadline = program.datetime_project_submission_ends or program.datetime_registration_ends + return timezone.localtime(deadline).date() + + +def _send_scenario_for_program(scenario, program, scheduled_for, target_date): + recipients = _get_recipients(scenario, program, target_date) + if not recipients.exists(): + return 0 + + pending_or_sent_ids = MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status__in=[ + MailingScenarioLog.Status.PENDING, + MailingScenarioLog.Status.SENT, + ], + ).values_list("user_id", flat=True) + + recipients_to_send = list(recipients.exclude(id__in=pending_or_sent_ids)) + user_ids = [user.id for user in recipients_to_send] + if not user_ids: + return 0 + + logger.info( + "Scenario %s program=%s scheduled_for=%s recipients=%s", + scenario.code, + program.id, + scheduled_for, + len(user_ids), + ) + + MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.FAILED, + user_id__in=user_ids, + ).update(status=MailingScenarioLog.Status.PENDING, error="", sent_at=None) + + logs = [ + MailingScenarioLog( + scenario_code=scenario.code, + program=program, + user_id=user_id, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.PENDING, + ) + for user_id in user_ids + ] + MailingScenarioLog.objects.bulk_create(logs, ignore_conflicts=True) + + reference_date = ( + _deadline_date(program) + if scenario.trigger == TriggerType.PROGRAM_SUBMISSION_DEADLINE + else target_date + ) + + def context_builder(user): + return scenario.context_builder(program, user, reference_date) + + sent_count = 0 + failed_count = 0 + + def _normalize_status(status_value): + if status_value is None: + return set() + if isinstance(status_value, dict): + statuses = set() + for value in status_value.values(): + if isinstance(value, (set, list, tuple)): + statuses.update(str(item) for item in value) + else: + statuses.add(str(value)) + return {status.lower() for status in statuses} + if isinstance(status_value, (set, list, tuple)): + return {str(item).lower() for item in status_value} + return {str(status_value).lower()} + + def status_callback(user, msg): + nonlocal sent_count, failed_count + status = getattr(msg, "anymail_status", None) + message_id = getattr(status, "message_id", None) if status else None + status_set = _normalize_status(getattr(status, "status", None)) + status_str = ",".join(sorted(status_set)) if status_set else "unknown" + is_failed = not status_set or bool(status_set & FAILED_ANYMAIL_STATUSES) + + if not message_id: + failed_count += 1 + MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.PENDING, + user_id=user.id, + ).update( + status=MailingScenarioLog.Status.FAILED, + error="anymail_status missing", + ) + logger.warning( + "Scenario %s user=%s anymail_status missing", + scenario.code, + user.id, + ) + return + + if is_failed: + failed_count += 1 + MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.PENDING, + user_id=user.id, + ).update( + status=MailingScenarioLog.Status.FAILED, + error=f"anymail_status={status_str} anymail_id={message_id}", + ) + logger.error( + "Scenario %s user=%s anymail_id=%s status=%s", + scenario.code, + user.id, + message_id, + status_str, + ) + return + + sent_count += 1 + MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.PENDING, + user_id=user.id, + ).update( + status=MailingScenarioLog.Status.SENT, + sent_at=timezone.now(), + error="", + ) + logger.info( + "Scenario %s user=%s anymail_id=%s status=%s", + scenario.code, + user.id, + message_id, + status_str, + ) + + try: + num_sent = send_mass_mail_from_template( + recipients_to_send, + render_subject(scenario.subject, program), + scenario.template_name, + context_builder=context_builder, + status_callback=status_callback, + ) + except Exception as exc: + MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.PENDING, + user_id__in=user_ids, + ).update(status=MailingScenarioLog.Status.FAILED, error=str(exc)) + logger.exception( + "Scenario %s failed for program %s", scenario.code, program.id + ) + return 0 + + pending_qs = MailingScenarioLog.objects.filter( + scenario_code=scenario.code, + program=program, + scheduled_for=scheduled_for, + status=MailingScenarioLog.Status.PENDING, + user_id__in=user_ids, + ) + pending_count = pending_qs.count() + if pending_count: + pending_qs.update( + status=MailingScenarioLog.Status.FAILED, + error="anymail_status missing", + ) + failed_count += pending_count + logger.warning( + "Scenario %s program=%s pending left after send: %s", + scenario.code, + program.id, + pending_count, + ) + + logger.info( + "Scenario %s program=%s send_messages=%s sent=%s failed=%s", + scenario.code, + program.id, + num_sent, + sent_count, + failed_count, + ) + return sent_count + + +@app.task +def run_program_mailings() -> int: + today = timezone.localdate() + total_sent = 0 + for scenario in SCENARIOS: + if scenario.trigger == TriggerType.PROGRAM_SUBMISSION_DEADLINE: + target_date = today + timedelta(days=scenario.offset_days) + else: + target_date = today - timedelta(days=scenario.offset_days) + programs = _get_programs_for_scenario(scenario, target_date) + for program in programs: + total_sent += _send_scenario_for_program( + scenario, program, today, target_date + ) + logger.info("Program mailings sent: %s", total_sent) + return total_sent diff --git a/mailing/tests.py b/mailing/tests.py index e69de29b..b724d217 100644 --- a/mailing/tests.py +++ b/mailing/tests.py @@ -0,0 +1,269 @@ +from datetime import datetime, time, timedelta +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from mailing.models import MailingScenarioLog +from mailing.tasks import run_program_mailings +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.selectors import ( + program_participants_with_inactive_account, + program_participants_with_inactive_account_registered_on, +) +from users.models import CustomUser + + +class _SentStatus: + def __init__(self, message_id: str): + self.message_id = message_id + self.status = "sent" + + +class _SentMessage: + def __init__(self, user_id: int): + self.anymail_status = _SentStatus(f"msg-{user_id}") + + +def _fake_send_mass_mail_from_template( + users, + subject, + template_name, + context_builder=None, + status_callback=None, +): + for user in users: + if status_callback: + status_callback(user, _SentMessage(user.id)) + return len(users) + + +class ProgramInactiveAccountSelectorsTests(TestCase): + def setUp(self): + self.today = timezone.localdate() + + def _dt(self, dt_date): + return timezone.make_aware( + datetime.combine(dt_date, time(hour=12)), + timezone.get_current_timezone(), + ) + + def _create_user(self, email: str): + return CustomUser.objects.create_user( + email=email, + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday="2000-01-01", + is_active=True, + ) + + def _create_program(self): + return PartnerProgram.objects.create( + name="FinFor", + tag="finfor", + city="Moscow", + datetime_registration_ends=self._dt(self.today + timedelta(days=10)), + datetime_started=self._dt(self.today - timedelta(days=10)), + datetime_finished=self._dt(self.today + timedelta(days=40)), + ) + + def _register_user(self, user: CustomUser, program: PartnerProgram, registered_on): + profile = PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + partner_program_data={}, + ) + PartnerProgramUserProfile.objects.filter(id=profile.id).update( + datetime_created=self._dt(registered_on) + ) + + def test_participants_with_inactive_account(self): + program = self._create_program() + + inactive_no_activity = self._create_user("inactive-no-activity@example.com") + inactive_old_login = self._create_user("inactive-old-login@example.com") + active_recent_activity = self._create_user("active-recent@example.com") + + self._register_user(inactive_no_activity, program, self.today - timedelta(days=4)) + self._register_user(inactive_old_login, program, self.today - timedelta(days=4)) + self._register_user(active_recent_activity, program, self.today - timedelta(days=4)) + + CustomUser.objects.filter(id=inactive_old_login.id).update( + last_login=self._dt(self.today - timedelta(days=15)) + ) + CustomUser.objects.filter(id=active_recent_activity.id).update( + last_activity=self._dt(self.today - timedelta(days=1)) + ) + + recipients = program_participants_with_inactive_account( + program.id, program.datetime_started + ) + recipient_ids = set(recipients.values_list("id", flat=True)) + + self.assertIn(inactive_no_activity.id, recipient_ids) + self.assertIn(inactive_old_login.id, recipient_ids) + self.assertNotIn(active_recent_activity.id, recipient_ids) + + def test_participants_with_inactive_account_registered_on_date(self): + program = self._create_program() + target_date = self.today - timedelta(days=3) + + registered_on_target = self._create_user("registered-on-target@example.com") + registered_other_day = self._create_user("registered-other-day@example.com") + + self._register_user(registered_on_target, program, target_date) + self._register_user(registered_other_day, program, self.today - timedelta(days=2)) + + recipients = program_participants_with_inactive_account_registered_on( + program.id, target_date, program.datetime_started + ) + recipient_ids = set(recipients.values_list("id", flat=True)) + + self.assertIn(registered_on_target.id, recipient_ids) + self.assertNotIn(registered_other_day.id, recipient_ids) + + +class ProgramInactiveAccountScenariosTests(TestCase): + def setUp(self): + self.today = timezone.localdate() + + def _dt(self, dt_date): + return timezone.make_aware( + datetime.combine(dt_date, time(hour=12)), + timezone.get_current_timezone(), + ) + + def _create_user(self, email: str): + return CustomUser.objects.create_user( + email=email, + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday="2000-01-01", + is_active=True, + ) + + def _register_user(self, user: CustomUser, program: PartnerProgram, registered_on): + profile = PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + partner_program_data={}, + ) + PartnerProgramUserProfile.objects.filter(id=profile.id).update( + datetime_created=self._dt(registered_on) + ) + + @patch( + "mailing.tasks.send_mass_mail_from_template", + side_effect=_fake_send_mass_mail_from_template, + ) + def test_registration_plus_3_inactive_account_scenario(self, send_mail_mock): + target_registration_date = self.today - timedelta(days=3) + + program = PartnerProgram.objects.create( + name="FinFor", + tag="finfor", + city="Moscow", + datetime_registration_ends=self._dt(self.today + timedelta(days=20)), + datetime_started=self._dt(self.today - timedelta(days=15)), + datetime_finished=self._dt(self.today + timedelta(days=40)), + ) + + inactive_user = self._create_user("inactive-user@example.com") + active_user = self._create_user("active-user@example.com") + registered_other_day_user = self._create_user("other-day-user@example.com") + + self._register_user(inactive_user, program, target_registration_date) + self._register_user(active_user, program, target_registration_date) + self._register_user( + registered_other_day_user, + program, + self.today - timedelta(days=2), + ) + + CustomUser.objects.filter(id=active_user.id).update( + last_activity=self._dt(self.today - timedelta(days=1)) + ) + + sent_count = run_program_mailings() + self.assertEqual(sent_count, 1) + + sent_logs = MailingScenarioLog.objects.filter( + scenario_code="program_registration_plus_3_inactive_account", + program=program, + scheduled_for=self.today, + status=MailingScenarioLog.Status.SENT, + ) + self.assertEqual(sent_logs.count(), 1) + self.assertEqual(sent_logs.first().user_id, inactive_user.id) + self.assertEqual(send_mail_mock.call_count, 1) + + second_run_sent_count = run_program_mailings() + self.assertEqual(second_run_sent_count, 0) + self.assertEqual(send_mail_mock.call_count, 1) + + all_logs = MailingScenarioLog.objects.filter( + scenario_code="program_registration_plus_3_inactive_account", + program=program, + scheduled_for=self.today, + ) + self.assertEqual(all_logs.count(), 1) + self.assertEqual( + all_logs.first().status, + MailingScenarioLog.Status.SENT, + ) + + @patch( + "mailing.tasks.send_mass_mail_from_template", + side_effect=_fake_send_mass_mail_from_template, + ) + def test_registration_end_plus_3_inactive_account_scenario(self, send_mail_mock): + target_registration_end_date = self.today - timedelta(days=3) + + program = PartnerProgram.objects.create( + name="FinFor", + tag="finfor", + city="Moscow", + datetime_registration_ends=self._dt(target_registration_end_date), + datetime_started=self._dt(self.today - timedelta(days=15)), + datetime_finished=self._dt(self.today + timedelta(days=20)), + ) + + inactive_user = self._create_user("inactive-end-user@example.com") + active_user = self._create_user("active-end-user@example.com") + + self._register_user(inactive_user, program, self.today - timedelta(days=10)) + self._register_user(active_user, program, self.today - timedelta(days=10)) + + CustomUser.objects.filter(id=active_user.id).update( + last_login=self._dt(self.today - timedelta(days=1)) + ) + + sent_count = run_program_mailings() + self.assertEqual(sent_count, 1) + + sent_logs = MailingScenarioLog.objects.filter( + scenario_code="program_registration_end_plus_3_inactive_account", + program=program, + scheduled_for=self.today, + status=MailingScenarioLog.Status.SENT, + ) + self.assertEqual(sent_logs.count(), 1) + self.assertEqual(sent_logs.first().user_id, inactive_user.id) + self.assertEqual(send_mail_mock.call_count, 1) + + second_run_sent_count = run_program_mailings() + self.assertEqual(second_run_sent_count, 0) + self.assertEqual(send_mail_mock.call_count, 1) + + all_logs = MailingScenarioLog.objects.filter( + scenario_code="program_registration_end_plus_3_inactive_account", + program=program, + scheduled_for=self.today, + ) + self.assertEqual(all_logs.count(), 1) + self.assertEqual( + all_logs.first().status, + MailingScenarioLog.Status.SENT, + ) diff --git a/mailing/utils.py b/mailing/utils.py index e7ead6a0..5346af55 100644 --- a/mailing/utils.py +++ b/mailing/utils.py @@ -1,5 +1,5 @@ from functools import singledispatch -from typing import Dict, List, Union, Annotated +from typing import Dict, List, Union, Annotated, Callable from procollab import settings from .constants import MAILING_USERS_BATCH_SIZE @@ -11,6 +11,7 @@ from django.core import mail from django.core.mail import EmailMultiAlternatives from django.template import Context, Template +from django.template.loader import get_template from .typing import MailDataDict, EmailDataToPrepare @@ -124,3 +125,49 @@ def send_mass_mail( for group in grouped_messages: num_sent += send_group_messages(group) return num_sent + + +def send_mass_mail_from_template( + users: django.db.models.QuerySet | List[User], + subject: str, + template_name: str, + template_context: Union[ + MailDataDict, + list, + dict, + ] = None, + context_builder=None, + status_callback: Callable[[User, EmailMultiAlternatives], None] | None = None, + connection=None, +) -> Annotated[int, "Количество отосланных сообщений"]: + """ + Send emails using a template file from Django template loaders. + Allows optional per-user context via context_builder(user) -> dict. + """ + if template_context is None: + template_context = {} + + template = get_template(template_name) + message_pairs: list[tuple[User, EmailMultiAlternatives]] = [] + for user in users: + context = dict(template_context) + if context_builder is not None: + context.update(context_builder(user)) + context["user"] = user + html_msg = template.render(context) + plain_msg = template.render(context) + msg = EmailMultiAlternatives( + subject, plain_msg, settings.EMAIL_USER, [user.email] + ) + msg.attach_alternative(html_msg, "text/html") + message_pairs.append((user, msg)) + + grouped_messages = create_message_groups(message_pairs) + num_sent: int = 0 + for group in grouped_messages: + messages = [msg for _, msg in group] + num_sent += send_group_messages(messages) + if status_callback is not None: + for user, msg in group: + status_callback(user, msg) + return num_sent diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 28a7f4f2..75390aea 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -81,6 +81,7 @@ class Meta: "projects_availability", "publish_projects_after_finish", "max_project_rates", + "is_distributed_evaluation", "draft", ( "datetime_started", diff --git a/partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py b/partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py new file mode 100644 index 00000000..8516a66a --- /dev/null +++ b/partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2026-02-12 06:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner_programs', '0015_partnerprogram_publish_projects_after_finish'), + ] + + operations = [ + migrations.AddField( + model_name='partnerprogram', + name='is_distributed_evaluation', + field=models.BooleanField(default=False, help_text='Если включено, проекты для оценки доступны только назначенным экспертам', verbose_name='Распределенное оценивание'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 75054e1f..b1cf5679 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -87,6 +87,13 @@ class PartnerProgram(models.Model): verbose_name="Максимальное количество оценок проектов", help_text="Ограничение на число экспертов, которые могут оценить один проект в программе", ) + is_distributed_evaluation = models.BooleanField( + default=False, + verbose_name="Распределенное оценивание", + help_text=( + "Если включено, проекты для оценки доступны только назначенным экспертам" + ), + ) data_schema = models.JSONField( verbose_name="Схема данных в формате JSON", help_text="Ключи - имена полей, значения - тип поля ввода", diff --git a/partner_programs/selectors.py b/partner_programs/selectors.py new file mode 100644 index 00000000..6a8b9c62 --- /dev/null +++ b/partner_programs/selectors.py @@ -0,0 +1,141 @@ +from datetime import datetime, timezone as dt_timezone + +from django.contrib.auth import get_user_model +from django.db.models import DateTimeField, Exists, OuterRef, Q, Value +from django.db.models.functions import Coalesce, Greatest + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import Collaborator + +User = get_user_model() +MIN_ACTIVITY_DATETIME = datetime(1970, 1, 1, tzinfo=dt_timezone.utc) + + +def programs_with_submission_deadline_on(target_date): + return PartnerProgram.objects.filter( + Q(datetime_project_submission_ends__date=target_date) + | Q( + datetime_project_submission_ends__isnull=True, + datetime_registration_ends__date=target_date, + ) + ) + + +def programs_with_registrations_on(target_date): + return PartnerProgram.objects.filter( + partner_program_profiles__datetime_created__date=target_date + ).distinct() + + +def programs_with_registration_end_on(target_date): + return PartnerProgram.objects.filter(datetime_registration_ends__date=target_date) + + +def _participant_profiles(program_id: int): + return PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, user__isnull=False + ) + + +def _inactive_program_users(user_ids, program_started_at): + effective_last_seen = Greatest( + Coalesce( + "last_login", + Value(MIN_ACTIVITY_DATETIME, output_field=DateTimeField()), + ), + Coalesce( + "last_activity", + Value(MIN_ACTIVITY_DATETIME, output_field=DateTimeField()), + ), + ) + return ( + User.objects.filter(id__in=user_ids) + .annotate(effective_last_seen=effective_last_seen) + .filter(effective_last_seen__lt=program_started_at) + .distinct() + ) + + +def program_participants(program_id: int): + user_ids = _participant_profiles(program_id).values_list("user_id", flat=True) + return User.objects.filter(id__in=user_ids).distinct() + + +def program_participants_without_project(program_id: int): + profiles = _participant_profiles(program_id) + leader_exists = Exists( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__leader_id=OuterRef("user_id"), + ) + ) + collab_exists = Exists( + Collaborator.objects.filter( + user_id=OuterRef("user_id"), + project__program_links__partner_program_id=program_id, + ) + ) + eligible_ids = ( + profiles.annotate(is_leader=leader_exists, is_collab=collab_exists) + .filter(is_leader=False, is_collab=False) + .values_list("user_id", flat=True) + ) + return User.objects.filter(id__in=eligible_ids).distinct() + + +def program_participants_without_project_registered_on(program_id: int, target_date): + profiles = _participant_profiles(program_id).filter( + datetime_created__date=target_date + ) + leader_exists = Exists( + PartnerProgramProject.objects.filter( + partner_program_id=program_id, + project__leader_id=OuterRef("user_id"), + ) + ) + collab_exists = Exists( + Collaborator.objects.filter( + user_id=OuterRef("user_id"), + project__program_links__partner_program_id=program_id, + ) + ) + eligible_ids = ( + profiles.annotate(is_leader=leader_exists, is_collab=collab_exists) + .filter(is_leader=False, is_collab=False) + .values_list("user_id", flat=True) + ) + return User.objects.filter(id__in=eligible_ids).distinct() + + +def program_participants_with_unsubmitted_project(program_id: int): + participant_ids = _participant_profiles(program_id).values_list( + "user_id", flat=True + ) + leader_ids = PartnerProgramProject.objects.filter( + partner_program_id=program_id, submitted=False + ).values_list("project__leader_id", flat=True) + collab_ids = Collaborator.objects.filter( + project__program_links__partner_program_id=program_id, + project__program_links__submitted=False, + ).values_list("user_id", flat=True) + return User.objects.filter(id__in=participant_ids).filter( + Q(id__in=leader_ids) | Q(id__in=collab_ids) + ).distinct() + + +def program_participants_with_inactive_account(program_id: int, program_started_at): + participant_ids = _participant_profiles(program_id).values_list("user_id", flat=True) + return _inactive_program_users(participant_ids, program_started_at) + + +def program_participants_with_inactive_account_registered_on( + program_id: int, target_date, program_started_at +): + participant_ids = _participant_profiles(program_id).filter( + datetime_created__date=target_date + ).values_list("user_id", flat=True) + return _inactive_program_users(participant_ids, program_started_at) diff --git a/partner_programs/tests.py b/partner_programs/tests.py index 0fb33174..eb1ea43e 100644 --- a/partner_programs/tests.py +++ b/partner_programs/tests.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate from partner_programs.models import ( PartnerProgram, @@ -10,6 +11,7 @@ ) from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer from partner_programs.services import publish_finished_program_projects +from partner_programs.views import PartnerProgramProjectSubmitView from projects.models import Project @@ -237,6 +239,86 @@ def test_publish_after_flag_enabled_post_finish(self): self.assertTrue(project.is_public) +class PartnerProgramProjectSubmitViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = PartnerProgramProjectSubmitView.as_view() + self.now = timezone.now() + self.user = get_user_model().objects.create_user( + email="leader@example.com", + password="pass", + first_name="Leader", + last_name="User", + birthday="1990-01-01", + ) + + def create_program(self, **overrides): + defaults = { + "name": "Program", + "tag": "program_tag", + "description": "Program description", + "city": "Moscow", + "data_schema": {}, + "draft": False, + "projects_availability": "all_users", + "datetime_registration_ends": self.now + timezone.timedelta(days=10), + "datetime_started": self.now - timezone.timedelta(days=1), + "datetime_finished": self.now + timezone.timedelta(days=30), + "is_competitive": True, + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + def create_project_link(self, program): + project = Project.objects.create( + leader=self.user, + draft=False, + is_public=False, + name="Project", + ) + return PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + def test_submit_blocked_after_deadline(self): + program = self.create_program( + datetime_project_submission_ends=self.now - timezone.timedelta(days=1) + ) + link = self.create_project_link(program) + + request = self.factory.post( + f"partner-program-projects/{link.pk}/submit/" + ) + force_authenticate(request, user=self.user) + response = self.view(request, pk=link.pk) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data.get("detail"), + "Срок подачи проектов в программу завершён.", + ) + link.refresh_from_db() + self.assertFalse(link.submitted) + + def test_submit_allowed_before_deadline(self): + program = self.create_program( + datetime_project_submission_ends=self.now + timezone.timedelta(days=1) + ) + link = self.create_project_link(program) + + request = self.factory.post( + f"partner-program-projects/{link.pk}/submit/" + ) + force_authenticate(request, user=self.user) + response = self.view(request, pk=link.pk) + + self.assertEqual(response.status_code, 200) + link.refresh_from_db() + self.assertTrue(link.submitted) + self.assertIsNotNone(link.datetime_submitted) + + class PartnerProgramFieldValueUpdateSerializerValidTests(TestCase): def setUp(self): now = timezone.now() diff --git a/partner_programs/views.py b/partner_programs/views.py index fa6a7728..5f16c713 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -514,6 +514,12 @@ def post(self, request, pk, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + if not program_project.partner_program.is_project_submission_open(): + return Response( + {"detail": "Срок подачи проектов в программу завершён."}, + status=status.HTTP_400_BAD_REQUEST, + ) + program_project.submitted = True program_project.datetime_submitted = now() program_project.save() diff --git a/procollab/celery.py b/procollab/celery.py index a2b9a531..c44d0af1 100644 --- a/procollab/celery.py +++ b/procollab/celery.py @@ -18,6 +18,10 @@ # "schedule": crontab(minute=0, hour=0), "schedule": crontab(minute="*"), }, + "program_scenarios_mailings": { + "task": "mailing.tasks.run_program_mailings", + "schedule": crontab(minute=0, hour=10), + }, "publish_finished_program_projects": { "task": "partner_programs.tasks.publish_finished_program_projects_task", "schedule": crontab(minute=0, hour=6), diff --git a/procollab/settings.py b/procollab/settings.py index 5a0316b3..2a736412 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -171,7 +171,7 @@ "users.permissions.CustomIsAuthenticated", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication", + "users.authentication.ActivityTrackingJWTAuthentication", "rest_framework.authentication.BasicAuthentication", # "rest_framework.authentication.SessionAuthentication",S ], @@ -331,6 +331,8 @@ "TOKEN_OBTAIN_SERIALIZER": "users.serializers.CustomObtainPairSerializer", } +JWT_LAST_ACTIVITY_THROTTLE_SECONDS = 15 * 60 + if DEBUG: SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] = timedelta(weeks=2) diff --git a/project_rates/admin.py b/project_rates/admin.py index 1ecb1455..ba22e4b3 100644 --- a/project_rates/admin.py +++ b/project_rates/admin.py @@ -1,5 +1,16 @@ -from django.contrib import admin -from .models import Criteria, ProjectScore +from django import forms +from django.contrib import admin, messages +from django.db.models import Count +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.core.exceptions import ValidationError as DjangoValidationError +from django.shortcuts import redirect +from django.urls import reverse + +from partner_programs.models import PartnerProgramProject +from projects.models import Project +from users.models import Expert + +from .models import Criteria, ProjectExpertAssignment, ProjectScore # Register your models here. @@ -28,3 +39,237 @@ def get_criteria_name(self, obj): def get_project_name(self, obj): return obj.project.name + + +class ProjectExpertAssignmentBulkAddForm(forms.ModelForm): + partner_program = forms.ModelChoiceField( + queryset=ProjectExpertAssignment._meta.get_field("partner_program") + .remote_field.model.objects.all() + .order_by("name"), + label="Программа", + ) + expert = forms.ModelChoiceField(queryset=Expert.objects.none(), label="Эксперт") + projects = forms.ModelMultipleChoiceField( + queryset=Project.objects.none(), + label="Проекты", + widget=FilteredSelectMultiple("Проекты", is_stacked=False), + ) + + class Meta: + model = ProjectExpertAssignment + fields = ("partner_program", "expert") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["expert"].queryset = Expert.objects.select_related("user").order_by( + "user__last_name", "user__first_name" + ) + self.fields["projects"].queryset = ( + Project.objects.filter(program_links__isnull=False, draft=False) + .distinct() + .order_by("name") + ) + + program_id = None + if self.is_bound: + program_id = self.data.get("partner_program") + else: + program_id = self.initial.get("partner_program") + + if program_id: + self.fields["expert"].queryset = ( + Expert.objects.filter(programs__id=program_id) + .select_related("user") + .order_by("user__last_name", "user__first_name") + ) + self.fields["projects"].queryset = ( + Project.objects.filter( + program_links__partner_program_id=program_id, + draft=False, + ) + .distinct() + .order_by("name") + ) + + def clean(self): + cleaned_data = super().clean() + program = cleaned_data.get("partner_program") + expert = cleaned_data.get("expert") + projects = cleaned_data.get("projects") + + if not program or not expert or not projects: + return cleaned_data + + if not expert.programs.filter(id=program.id).exists(): + self.add_error("expert", "Эксперт не состоит в выбранной программе.") + + linked_project_ids = set( + PartnerProgramProject.objects.filter( + partner_program=program, + project_id__in=projects.values_list("id", flat=True), + ).values_list("project_id", flat=True) + ) + if len(linked_project_ids) != len(projects): + self.add_error("projects", "Выбраны проекты, не привязанные к программе.") + + selected_project_ids = list(projects.values_list("id", flat=True)) + existing_ids = set( + ProjectExpertAssignment.objects.filter( + partner_program=program, + expert=expert, + project_id__in=selected_project_ids, + ).values_list("project_id", flat=True) + ) + + blocked_by_limit_ids = set() + if program.max_project_rates: + blocked_by_limit_ids = set( + ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id__in=selected_project_ids, + ) + .values("project_id") + .annotate(total=Count("id")) + .filter(total__gte=program.max_project_rates) + .values_list("project_id", flat=True) + ) + + actionable = [ + project_id + for project_id in selected_project_ids + if project_id not in existing_ids and project_id not in blocked_by_limit_ids + ] + if not actionable: + raise forms.ValidationError( + "Нет проектов для нового назначения: все уже назначены или достигли лимита." + ) + + return cleaned_data + + +@admin.register(ProjectExpertAssignment) +class ProjectExpertAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "partner_program", + "project", + "expert", + "datetime_created", + ) + list_filter = ("partner_program",) + search_fields = ( + "project__name", + "partner_program__name", + "expert__user__first_name", + "expert__user__last_name", + "expert__user__email", + ) + + def get_form(self, request, obj=None, **kwargs): + if obj is None: + kwargs["form"] = ProjectExpertAssignmentBulkAddForm + return super().get_form(request, obj, **kwargs) + + def get_fields(self, request, obj=None): + if obj is None: + return ("partner_program", "expert", "projects") + return ("partner_program", "project", "expert") + + def save_model(self, request, obj, form, change): + if change: + return super().save_model(request, obj, form, change) + + program = form.cleaned_data["partner_program"] + expert = form.cleaned_data["expert"] + projects = list(form.cleaned_data["projects"]) + selected_project_ids = [project.id for project in projects] + + existing_ids = set( + ProjectExpertAssignment.objects.filter( + partner_program=program, + expert=expert, + project_id__in=selected_project_ids, + ).values_list("project_id", flat=True) + ) + + blocked_by_limit_ids = set() + if program.max_project_rates: + blocked_by_limit_ids = set( + ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id__in=selected_project_ids, + ) + .values("project_id") + .annotate(total=Count("id")) + .filter(total__gte=program.max_project_rates) + .values_list("project_id", flat=True) + ) + + actionable_projects = [ + project + for project in projects + if project.id not in existing_ids and project.id not in blocked_by_limit_ids + ] + + primary_project = actionable_projects[0] + obj.partner_program = program + obj.expert = expert + obj.project = primary_project + super().save_model(request, obj, form, change) + + created = 1 + skipped = len(existing_ids) + failed = len(blocked_by_limit_ids) + + for project in actionable_projects[1:]: + try: + ProjectExpertAssignment.objects.create( + partner_program=program, + project=project, + expert=expert, + ) + created += 1 + except DjangoValidationError: + failed += 1 + + self.message_user( + request, + ( + f"Назначения обработаны. Создано: {created}, " + f"уже существовало: {skipped}, с ошибкой: {failed}." + ), + level=messages.SUCCESS if failed == 0 else messages.WARNING, + ) + + def delete_view(self, request, object_id, extra_context=None): + obj = self.get_object(request, object_id) + if obj and obj.has_scores(): + self.message_user( + request, + "Нельзя удалить назначение: эксперт уже оценил этот проект.", + level=messages.ERROR, + ) + return redirect( + reverse( + "admin:project_rates_projectexpertassignment_change", + args=[object_id], + ) + ) + return super().delete_view(request, object_id, extra_context=extra_context) + + def delete_queryset(self, request, queryset): + blocked = 0 + for obj in queryset: + if obj.has_scores(): + blocked += 1 + continue + obj.delete() + if blocked: + self.message_user( + request, + ( + "Часть назначений не удалена, потому что по ним уже выставлены оценки: " + f"{blocked}" + ), + level=messages.WARNING, + ) diff --git a/project_rates/migrations/0003_projectexpertassignment.py b/project_rates/migrations/0003_projectexpertassignment.py new file mode 100644 index 00000000..50b6e832 --- /dev/null +++ b/project_rates/migrations/0003_projectexpertassignment.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2026-02-12 06:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0060_alter_userachievement_year'), + ('partner_programs', '0016_partnerprogram_is_distributed_evaluation'), + ('projects', '0032_hide_program_projects'), + ('project_rates', '0002_remove_projectscore_comment'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectExpertAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime_created', models.DateTimeField(auto_now_add=True)), + ('datetime_updated', models.DateTimeField(auto_now=True)), + ('expert', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_assignments', to='users.expert')), + ('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_expert_assignments', to='partner_programs.partnerprogram')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expert_assignments', to='projects.project')), + ], + options={ + 'verbose_name': 'Назначение проекта эксперту', + 'verbose_name_plural': 'Назначения проектов экспертам', + 'indexes': [models.Index(fields=['partner_program', 'project'], name='project_rat_partner_85f175_idx'), models.Index(fields=['partner_program', 'expert'], name='project_rat_partner_aae584_idx')], + 'unique_together': {('partner_program', 'project', 'expert')}, + }, + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index f2743779..e086cd28 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -1,8 +1,9 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from .constants import VERBOSE_TYPES -from partner_programs.models import PartnerProgram +from partner_programs.models import PartnerProgram, PartnerProgramProject from projects.models import Project from .validators import ProjectScoreValidator @@ -93,3 +94,88 @@ class Meta: verbose_name = "Оценка проекта" verbose_name_plural = "Оценки проектов" unique_together = ("criteria", "user", "project") + + +class ProjectExpertAssignment(models.Model): + partner_program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="project_expert_assignments", + ) + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="expert_assignments", + ) + expert = models.ForeignKey( + "users.Expert", + on_delete=models.CASCADE, + related_name="project_assignments", + ) + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Назначение проекта эксперту" + verbose_name_plural = "Назначения проектов экспертам" + unique_together = ("partner_program", "project", "expert") + indexes = [ + models.Index(fields=["partner_program", "project"]), + models.Index(fields=["partner_program", "expert"]), + ] + + def __str__(self): + return ( + f"Assignment<{self.id}> program={self.partner_program_id} " + f"project={self.project_id} expert={self.expert_id}" + ) + + def has_scores(self) -> bool: + return ProjectScore.objects.filter( + project_id=self.project_id, + user_id=self.expert.user_id, + criteria__partner_program_id=self.partner_program_id, + ).exists() + + def clean(self): + errors = {} + + if self.expert_id and self.partner_program_id and not self.expert.programs.filter( + id=self.partner_program_id + ).exists(): + errors["expert"] = "Эксперт не состоит в указанной программе." + + if self.project_id and self.partner_program_id and not PartnerProgramProject.objects.filter( + partner_program_id=self.partner_program_id, + project_id=self.project_id, + ).exists(): + errors["project"] = "Проект не привязан к указанной программе." + + if self.partner_program_id and self.project_id: + max_rates = self.partner_program.max_project_rates + if max_rates: + assignments_qs = ProjectExpertAssignment.objects.filter( + partner_program_id=self.partner_program_id, + project_id=self.project_id, + ) + if self.pk: + assignments_qs = assignments_qs.exclude(pk=self.pk) + if assignments_qs.count() >= max_rates: + errors["partner_program"] = ( + "Достигнуто максимальное количество назначенных экспертов " + "для этого проекта в программе." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + if self.has_scores(): + raise ValidationError( + "Нельзя удалить назначение: эксперт уже оценил этот проект." + ) + return super().delete(*args, **kwargs) diff --git a/project_rates/tests.py b/project_rates/tests.py new file mode 100644 index 00000000..59e7ea12 --- /dev/null +++ b/project_rates/tests.py @@ -0,0 +1,301 @@ +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from projects.models import Project +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from users.models import CustomUser + + +class DistributedEvaluationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + now = timezone.now() + + self.expert_user = CustomUser.objects.create_user( + email="expert@example.com", + password="pass", + first_name="Expert", + last_name="User", + birthday="1990-01-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.other_expert_user = CustomUser.objects.create_user( + email="expert2@example.com", + password="pass", + first_name="Second", + last_name="Expert", + birthday="1991-01-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.leader = CustomUser.objects.create_user( + email="leader@example.com", + password="pass", + first_name="Leader", + last_name="User", + birthday="1992-01-01", + user_type=CustomUser.MEMBER, + is_active=True, + ) + + self.program = PartnerProgram.objects.create( + name="Program", + tag="program_tag", + description="Program description", + city="Moscow", + data_schema={}, + draft=False, + projects_availability="all_users", + datetime_registration_ends=now + timezone.timedelta(days=10), + datetime_started=now - timezone.timedelta(days=1), + datetime_finished=now + timezone.timedelta(days=30), + max_project_rates=2, + ) + self.expert_user.expert.programs.add(self.program) + self.other_expert_user.expert.programs.add(self.program) + + self.project_1 = Project.objects.create( + leader=self.leader, + draft=False, + is_public=False, + name="Project 1", + ) + self.project_2 = Project.objects.create( + leader=self.leader, + draft=False, + is_public=False, + name="Project 2", + ) + PartnerProgramProject.objects.create( + partner_program=self.program, + project=self.project_1, + ) + PartnerProgramProject.objects.create( + partner_program=self.program, + project=self.project_2, + ) + + self.criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + + def _projects_url(self) -> str: + return f"/rate-project/{self.program.id}" + + def _rate_url(self, project_id: int) -> str: + return f"/rate-project/rate/{project_id}" + + def test_list_projects_without_distribution_returns_all_program_projects(self): + self.client.force_authenticate(self.expert_user) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {self.project_1.id, self.project_2.id}) + + def test_list_projects_with_distribution_returns_only_assigned_projects(self): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_2, + expert=self.other_expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = [item["id"] for item in response.data["results"]] + self.assertListEqual(returned_ids, [self.project_1.id]) + + @patch("project_rates.views.send_email.delay") + def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["error"], "you are not assigned to rate this project" + ) + self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) + + @patch("project_rates.views.send_email.delay") + def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + ProjectScore.objects.filter( + project=self.project_1, + user=self.expert_user, + criteria=self.criteria, + value="8", + ).exists() + ) + mock_delay.assert_called_once() + + +class ProjectExpertAssignmentModelTests(TestCase): + def setUp(self): + now = timezone.now() + self.program = PartnerProgram.objects.create( + name="Program", + tag="program_tag", + description="Program description", + city="Moscow", + data_schema={}, + draft=False, + projects_availability="all_users", + datetime_registration_ends=now + timezone.timedelta(days=10), + datetime_started=now - timezone.timedelta(days=1), + datetime_finished=now + timezone.timedelta(days=30), + max_project_rates=1, + ) + + self.leader = CustomUser.objects.create_user( + email="leader2@example.com", + password="pass", + first_name="Leader", + last_name="Two", + birthday="1993-01-01", + user_type=CustomUser.MEMBER, + is_active=True, + ) + self.project = Project.objects.create( + leader=self.leader, + draft=False, + is_public=False, + name="Project", + ) + PartnerProgramProject.objects.create( + partner_program=self.program, + project=self.project, + ) + + self.expert_1_user = CustomUser.objects.create_user( + email="model-expert-1@example.com", + password="pass", + first_name="Model", + last_name="Expert1", + birthday="1990-02-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.expert_2_user = CustomUser.objects.create_user( + email="model-expert-2@example.com", + password="pass", + first_name="Model", + last_name="Expert2", + birthday="1990-03-01", + user_type=CustomUser.EXPERT, + is_active=True, + ) + self.expert_1_user.expert.programs.add(self.program) + self.expert_2_user.expert.programs.add(self.program) + + def test_assignment_requires_expert_in_program(self): + self.expert_1_user.expert.programs.remove(self.program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_requires_project_link_to_program(self): + other_program = PartnerProgram.objects.create( + name="Other Program", + tag="other_program_tag", + description="Program description", + city="Moscow", + data_schema={}, + draft=False, + projects_availability="all_users", + datetime_registration_ends=timezone.now() + timezone.timedelta(days=10), + datetime_started=timezone.now() - timezone.timedelta(days=1), + datetime_finished=timezone.now() + timezone.timedelta(days=30), + ) + self.expert_1_user.expert.programs.add(other_program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=other_program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_respects_max_project_rates_limit(self): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_2_user.expert, + ) + + def test_assignment_cannot_be_deleted_after_scoring(self): + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + ProjectScore.objects.create( + criteria=criteria, + user=self.expert_1_user, + project=self.project, + value="7", + ) + + with self.assertRaises(ValidationError): + assignment.delete() diff --git a/project_rates/views.py b/project_rates/views.py index 46309be3..9e6f94d7 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -13,7 +13,7 @@ from partner_programs.utils import filter_program_projects_by_field_name from projects.models import Project from projects.filters import ProjectFilter -from project_rates.models import Criteria, ProjectScore +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore from project_rates.pagination import RateProjectsPagination from project_rates.serializers import ( ProjectScoreCreateSerializer, @@ -64,6 +64,13 @@ def get_needed_data(self) -> tuple[dict, list[int], PartnerProgram]: ).exists(): raise ValueError("Project is not linked to the program") + if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id=project_id, + expert__user_id=user_id, + ).exists(): + raise ValueError("you are not assigned to rate this project") + return data, criteria_to_get, program def create(self, request, *args, **kwargs) -> Response: @@ -177,8 +184,15 @@ def get_queryset(self) -> QuerySet[Project]: to_attr="_program_scores", ) + projects_qs = Project.objects.filter(draft=False, id__in=project_ids) + if program.is_distributed_evaluation: + projects_qs = projects_qs.filter( + expert_assignments__partner_program=program, + expert_assignments__expert__user=self.request.user, + ) + return ( - Project.objects.filter(draft=False, id__in=project_ids) + projects_qs .annotate( rated_count=Count( "scores__user", @@ -187,6 +201,7 @@ def get_queryset(self) -> QuerySet[Project]: ) ) .prefetch_related(scores_prefetch) + .distinct() ) def get_serializer_context(self): diff --git a/projects/serializers.py b/projects/serializers.py index c6b5a175..0d1af8ac 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -107,7 +107,11 @@ class Meta: ] def get_can_submit(self, obj): - return obj.partner_program.is_competitive and not obj.submitted + return ( + obj.partner_program.is_competitive + and not obj.submitted + and obj.partner_program.is_project_submission_open() + ) def get_program_fields(self, obj): fields_qs = obj.partner_program.fields.all() diff --git a/templates/email/generic-template-0.html b/templates/email/generic-template-0.html index 6ac1b260..bc3f89ff 100644 --- a/templates/email/generic-template-0.html +++ b/templates/email/generic-template-0.html @@ -261,7 +261,7 @@