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/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/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 index 97e81108..5702f976 100644 --- a/mailing/scenarios.py +++ b/mailing/scenarios.py @@ -3,6 +3,7 @@ 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 @@ -12,6 +13,7 @@ class TriggerType(Enum): PROGRAM_SUBMISSION_DEADLINE = "program_submission_deadline" PROGRAM_REGISTRATION_DATE = "program_registration_date" + PROGRAM_REGISTRATION_END = "program_registration_end" class RecipientRule(Enum): @@ -19,6 +21,10 @@ class RecipientRule(Enum): 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] @@ -35,46 +41,25 @@ class Scenario: context_builder: ContextBuilder -def _build_submission_deadline_context(offset_days: int) -> ContextBuilder: - def _builder(program: PartnerProgram, user: CustomUser, deadline_date: date) -> dict: - return { - "preview_text": "Кейс-чемпионат уже стартовал", - "title": "Время начинать!", - "text": ( - "Кейс-чемпионат уже стартовал. Скорее заходите на платформу, " - "создавайте проект и подключайте команду к работе.\n\n" - "Вас ждет много интересного ⚡" - ), - "button_text": "Подать проект", - "button_link": f"{FRONTEND_BASE_URL}/office/program/{program.id}", - } - - return _builder - - -def _build_registration_plus_5_context() -> 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: - return { - "preview_text": "Сделайте первый шаг в программе", - "title": "Сделать первый шаг", - "text": ( - "Когда непонятно с чего начать — стоит начать с самого простого. " - "На раз-два-три: зайти на платформу — создать проект — " - "пригласить команду.\n\n" - "И вот, первый шаг уже сделан" - ), - } - - return _builder - - -def _build_project_not_submitted_context(title: str, text: str) -> ContextBuilder: - def _builder(program: PartnerProgram, user: CustomUser, _ref_date: date) -> dict: - return { - "preview_text": title, - "title": title, - "text": text, + 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 @@ -85,32 +70,98 @@ def _builder(program: PartnerProgram, user: CustomUser, _ref_date: date) -> dict trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, offset_days=10, template_name="email/generic-template-0.html", - subject="Время начинать!", + subject="{program_name}: важное сообщение", recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM, - context_builder=_build_submission_deadline_context(10), + 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="Сделать первый шаг", + subject="{program_name}: важное сообщение", recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM_REGISTERED_ON_DATE, - context_builder=_build_registration_plus_5_context(), + 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="Кейс-задания опубликованы", + subject="{program_name}: важное сообщение", recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED, - context_builder=_build_project_not_submitted_context( - "Кейс-задания опубликованы", - "Заходите на платформу, чтобы познакомиться с кейсами первого этапа " - "кейс-чемпионата. Кейсы загружены в материалы закрытой группы.\n\n" - "Приступайте к работе уже сегодня, чтобы успеть подготовить итоговое " - "решение в срок ⚡", + context_builder=_build_context( + preview_text="Кейс-задания опубликованы", + title="Кейс-задания опубликованы", + text=( + "Заходите на платформу, чтобы познакомиться с кейсами первого этапа " + "кейс-чемпионата. Кейсы загружены в материалы закрытой группы.\n\n" + "Приступайте к работе уже сегодня, чтобы успеть подготовить итоговое " + "решение в срок ⚡" + ), + button_text="Познакомиться с кейсом", + button_link=f"{FRONTEND_BASE_URL}/office/program/{{program_id}}", ), ), Scenario( @@ -118,13 +169,18 @@ def _builder(program: PartnerProgram, user: CustomUser, _ref_date: date) -> dict trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, offset_days=3, template_name="email/generic-template-0.html", - subject="До сдачи итогового решения осталось 3 дня", + subject="{program_name}: важное сообщение", recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED, - context_builder=_build_project_not_submitted_context( - "До сдачи итогового решения осталось 3 дня", - "Работа в самом разгаре, и мы запускаем обратный отсчет. " - "Осталось всего 3 дня, чтобы доработать проект, оформить презентацию " - "и загрузить итоговое решение на платформу.", + context_builder=_build_context( + preview_text="До сдачи итогового решения осталось 3 дня", + title="До сдачи итогового решения осталось 3 дня", + text=( + "Работа в самом разгаре, и мы запускаем обратный отсчет. " + "Осталось всего 3 дня, чтобы доработать проект, оформить презентацию " + "и загрузить итоговое решение на платформу." + ), + button_text="Загрузить решение", + button_link=f"{FRONTEND_BASE_URL}/office/projects", ), ), Scenario( @@ -132,14 +188,19 @@ def _builder(program: PartnerProgram, user: CustomUser, _ref_date: date) -> dict trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE, offset_days=1, template_name="email/generic-template-0.html", - subject="1 день до сдачи итогового решения", + subject="{program_name}: важное сообщение", recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED, - context_builder=_build_project_not_submitted_context( - "1 день до сдачи итогового решения", - "День X совсем скоро. Осталось только внести последние штрихи и " - "загрузить итоговое решение на платформу.\n\n" - "По любым техническим вопросам всегда на связи @procollab_support\n\n" - "Удачи!", + 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 index 1ce7e8ac..6e9f91f5 100644 --- a/mailing/tasks.py +++ b/mailing/tasks.py @@ -5,13 +5,17 @@ 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, ) @@ -26,22 +30,34 @@ def _get_programs_for_scenario(scenario, target_date): 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_id: int, target_date): +def _get_recipients(scenario, program, target_date): match scenario.recipient_rule: case RecipientRule.ALL_PARTICIPANTS: - return program_participants(program_id) + return program_participants(program.id) case RecipientRule.NO_PROJECT_IN_PROGRAM: - return program_participants_without_project(program_id) + 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 + program.id, target_date ) case RecipientRule.PROJECT_NOT_SUBMITTED: - return program_participants_with_unsubmitted_project(program_id) + 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}") @@ -52,7 +68,7 @@ def _deadline_date(program): def _send_scenario_for_program(scenario, program, scheduled_for, target_date): - recipients = _get_recipients(scenario, program.id, target_date) + recipients = _get_recipients(scenario, program, target_date) if not recipients.exists(): return 0 @@ -197,7 +213,7 @@ def status_callback(user, msg): try: num_sent = send_mass_mail_from_template( recipients_to_send, - scenario.subject, + render_subject(scenario.subject, program), scenario.template_name, context_builder=context_builder, status_callback=status_callback, 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/partner_programs/selectors.py b/partner_programs/selectors.py index 22b79f86..6a8b9c62 100644 --- a/partner_programs/selectors.py +++ b/partner_programs/selectors.py @@ -1,5 +1,8 @@ +from datetime import datetime, timezone as dt_timezone + from django.contrib.auth import get_user_model -from django.db.models import Exists, OuterRef, Q +from django.db.models import DateTimeField, Exists, OuterRef, Q, Value +from django.db.models.functions import Coalesce, Greatest from partner_programs.models import ( PartnerProgram, @@ -9,6 +12,7 @@ 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): @@ -27,12 +31,35 @@ def programs_with_registrations_on(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() @@ -98,3 +125,17 @@ def program_participants_with_unsubmitted_project(program_id: int): 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/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/users/admin.py b/users/admin.py index 3df9eba7..adf3c16b 100644 --- a/users/admin.py +++ b/users/admin.py @@ -32,7 +32,19 @@ UserWorkExperience, ) -admin.site.register(Permission) + +@admin.register(Permission) +class PermissionAdmin(admin.ModelAdmin): + list_display = ("id", "content_type", "codename", "name") + list_display_links = ("id", "name") + search_fields = ( + "=id", + "name", + "codename", + "content_type__app_label", + "content_type__model", + ) + ordering = ("content_type__app_label", "content_type__model", "codename") class UserEducationInline(admin.TabularInline): @@ -124,7 +136,7 @@ class CustomUserAdmin(admin.ModelAdmin): ), ( "Важные даты", - {"fields": ("last_login", "date_joined")}, + {"fields": ("last_login", "last_activity", "date_joined")}, ), ( "Студенты мосполитеха", @@ -141,6 +153,7 @@ class CustomUserAdmin(admin.ModelAdmin): "is_active", "dataset_migration_applied", "v2_speciality", + "last_activity", "datetime_created", ) list_display_links = ( @@ -162,6 +175,10 @@ class CustomUserAdmin(admin.ModelAdmin): "city", "v2_speciality__name", ) + raw_id_fields = ( + "groups", + "user_permissions", + ) inlines = [ SkillToObjectInline, @@ -175,6 +192,15 @@ class CustomUserAdmin(admin.ModelAdmin): change_form_template = "users/admin/users_change_form.html" change_list_template = "users/admin/users_change_list.html" + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name == "user_permissions": + kwargs["queryset"] = Permission.objects.select_related("content_type").order_by( + "content_type__app_label", + "content_type__model", + "codename", + ) + return super().formfield_for_manytomany(db_field, request, **kwargs) + def save_model(self, request, obj, form, change): # if user_type changed, then delete all related fields if change: diff --git a/users/authentication.py b/users/authentication.py new file mode 100644 index 00000000..567fca45 --- /dev/null +++ b/users/authentication.py @@ -0,0 +1,72 @@ +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.utils import timezone +from rest_framework_simplejwt.authentication import JWTAuthentication + +DEFAULT_LAST_ACTIVITY_THROTTLE_SECONDS = 15 * 60 +LAST_ACTIVITY_CACHE_KEY = "users:last_activity:update:{user_id}" +logger = logging.getLogger(__name__) + + +def get_last_activity_cache_key(user_id: int) -> str: + return LAST_ACTIVITY_CACHE_KEY.format(user_id=user_id) + + +class ActivityTrackingJWTAuthentication(JWTAuthentication): + """ + JWT authentication with lightweight user activity tracking. + + `last_activity` is updated at most once per throttle window per user. + """ + + def authenticate(self, request): + auth_result = super().authenticate(request) + if auth_result is None: + return None + + user, token = auth_result + self._touch_last_activity(user.id) + return user, token + + def _touch_last_activity(self, user_id: int) -> None: + raw_throttle = getattr( + settings, + "JWT_LAST_ACTIVITY_THROTTLE_SECONDS", + DEFAULT_LAST_ACTIVITY_THROTTLE_SECONDS, + ) + try: + throttle_seconds = int(raw_throttle) + except (TypeError, ValueError): + throttle_seconds = DEFAULT_LAST_ACTIVITY_THROTTLE_SECONDS + + if throttle_seconds < 0: + throttle_seconds = DEFAULT_LAST_ACTIVITY_THROTTLE_SECONDS + + should_update = True + if throttle_seconds > 0: + cache_key = get_last_activity_cache_key(user_id) + try: + should_update = cache.add(cache_key, "1", timeout=throttle_seconds) + except Exception: + logger.warning( + "Failed to update activity throttle cache for user_id=%s", + user_id, + exc_info=True, + ) + should_update = True + + if not should_update: + return + + user_model = get_user_model() + try: + user_model.objects.filter(id=user_id).update(last_activity=timezone.now()) + except Exception: + logger.warning( + "Failed to update last_activity for user_id=%s", + user_id, + exc_info=True, + ) diff --git a/users/migrations/0059_customuser_last_activity.py b/users/migrations/0059_customuser_last_activity.py new file mode 100644 index 00000000..bf854a18 --- /dev/null +++ b/users/migrations/0059_customuser_last_activity.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.24 on 2026-02-09 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0058_userachievement_files_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="customuser", + name="last_activity", + field=models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Последняя активность", + ), + ), + ] diff --git a/users/migrations/0060_alter_userachievement_year.py b/users/migrations/0060_alter_userachievement_year.py new file mode 100644 index 00000000..d4e89356 --- /dev/null +++ b/users/migrations/0060_alter_userachievement_year.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2026-02-09 09:39 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0059_customuser_last_activity"), + ] + + operations = [ + migrations.AlterField( + model_name="userachievement", + name="year", + field=models.PositiveSmallIntegerField( + blank=True, + db_index=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1900), + django.core.validators.MaxValueValidator(2026), + ], + verbose_name="Год достижения", + ), + ), + ] diff --git a/users/models.py b/users/models.py index 815ee521..02065c35 100644 --- a/users/models.py +++ b/users/models.py @@ -149,6 +149,12 @@ class CustomUser(AbstractUser): blank=True, verbose_name="Дата верификации", ) + last_activity = models.DateTimeField( + null=True, + blank=True, + db_index=True, + verbose_name="Последняя активность", + ) datetime_updated = models.DateTimeField(auto_now=True) datetime_created = models.DateTimeField(auto_now_add=True) # TODO need to be removed in future. diff --git a/users/serializers.py b/users/serializers.py index 881356f9..4bc8ee55 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.forms.models import model_to_dict +from django.utils import timezone from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.validators import UniqueValidator @@ -997,6 +998,11 @@ class Meta: class CustomObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + data = super().validate(attrs) + CustomUser.objects.filter(id=self.user.id).update(last_login=timezone.now()) + return data + @classmethod def get_token(cls, user): token = super().get_token(user) diff --git a/users/tests_auth_activity.py b/users/tests_auth_activity.py new file mode 100644 index 00000000..57a398e7 --- /dev/null +++ b/users/tests_auth_activity.py @@ -0,0 +1,107 @@ +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.core.cache import cache +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient, APITestCase + +from users.authentication import get_last_activity_cache_key +from users.models import CustomUser + + +class JwtActivityTrackingTests(APITestCase): + def setUp(self): + self.email = "activity_test@example.com" + self.password = "very_strong_password" + self.user = CustomUser.objects.create_user( + email=self.email, + password=self.password, + first_name="Иван", + last_name="Иванов", + birthday="2000-01-01", + is_active=True, + ) + + def _obtain_access_token(self) -> str: + response = self.client.post( + reverse("token_obtain_pair"), + {"email": self.email, "password": self.password}, + format="json", + ) + self.assertEqual(response.status_code, 200) + return response.data["access"] + + def test_token_obtain_pair_updates_last_login(self): + old_login = timezone.now() - timedelta(days=1) + CustomUser.objects.filter(id=self.user.id).update(last_login=old_login) + + response = self.client.post( + reverse("token_obtain_pair"), + {"email": self.email, "password": self.password}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertIsNotNone(self.user.last_login) + self.assertGreater(self.user.last_login, old_login) + + def test_last_activity_updates_with_throttle(self): + cache.delete(get_last_activity_cache_key(self.user.id)) + CustomUser.objects.filter(id=self.user.id).update(last_activity=None) + + access = self._obtain_access_token() + api_client = APIClient() + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") + + first_response = api_client.get("/auth/specialists/") + self.assertEqual(first_response.status_code, 200) + self.user.refresh_from_db() + first_activity = self.user.last_activity + self.assertIsNotNone(first_activity) + + second_response = api_client.get("/auth/specialists/") + self.assertEqual(second_response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.last_activity, first_activity) + + # Simulate throttle window end for deterministic testing. + old_activity = first_activity - timedelta(hours=1) + CustomUser.objects.filter(id=self.user.id).update(last_activity=old_activity) + cache.delete(get_last_activity_cache_key(self.user.id)) + + third_response = api_client.get("/auth/specialists/") + self.assertEqual(third_response.status_code, 200) + self.user.refresh_from_db() + self.assertGreater(self.user.last_activity, old_activity) + + @patch("users.authentication.cache.add", side_effect=Exception("cache is down")) + def test_last_activity_cache_failure_does_not_break_auth(self, _cache_add_mock): + CustomUser.objects.filter(id=self.user.id).update(last_activity=None) + access = self._obtain_access_token() + + api_client = APIClient() + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") + + response = api_client.get("/auth/specialists/") + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertIsNotNone(self.user.last_activity) + + @patch("users.authentication.get_user_model") + def test_last_activity_db_failure_does_not_break_auth(self, get_user_model_mock): + fake_qs = MagicMock() + fake_qs.update.side_effect = Exception("db is down") + fake_model = MagicMock() + fake_model.objects.filter.return_value = fake_qs + get_user_model_mock.return_value = fake_model + + access = self._obtain_access_token() + api_client = APIClient() + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") + + response = api_client.get("/auth/specialists/") + self.assertEqual(response.status_code, 200) + fake_model.objects.filter.assert_called_once_with(id=self.user.id) + fake_qs.update.assert_called_once()