Skip to content
Merged

Dev #605

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
663cf20
Сдача проекта блокируется после дедлайну, изменения учтены в can_submit
Toksi86 Jan 26, 2026
76b71e7
Merge pull request #597 from PROCOLLAB-github/fix/project-submit-dead…
Toksi86 Jan 26, 2026
4d394c1
Добавлена рассылка писем с напомимнанием об окончании срока подачи пр…
Toksi86 Jan 30, 2026
468bf9d
Merge pull request #598 from PROCOLLAB-github/feature/auto_sending_email
Toksi86 Jan 30, 2026
2068442
Добавлено логирование id задач по отправки писем
Toksi86 Feb 2, 2026
d0857a9
Merge pull request #599 from PROCOLLAB-github/feature/auto_sending_email
Toksi86 Feb 2, 2026
d8b4eba
Обновил логи для избежаниях ложных sent статусов
Toksi86 Feb 2, 2026
670e18d
Merge pull request #600 from PROCOLLAB-github/feature/auto_sending_email
Toksi86 Feb 2, 2026
800cd7c
Устраён баг с линивым QuerySet, добавлные точные per-user статусы по …
Toksi86 Feb 2, 2026
3b57c50
Merge pull request #601 from PROCOLLAB-github/feature/auto_sending_email
Toksi86 Feb 2, 2026
a2fefc3
Добавлены новые сценарии рассылок, обновлён шаблон письма, перенесено…
Toksi86 Feb 5, 2026
7c89b73
Merge pull request #602 from PROCOLLAB-github/feature/auto_sending_email
Toksi86 Feb 5, 2026
1048cd4
Автмоатическая генерация миграций с актуальными данными
Toksi86 Feb 10, 2026
5421d1f
Оптимизированы дублирующиеся SQL-запросы при получении профиля пользо…
Toksi86 Feb 10, 2026
0f38434
Добавлены трекинг активности через JWT и обновление last_login при по…
Toksi86 Feb 10, 2026
a60fba3
Расширены сценарии рассылки
Toksi86 Feb 11, 2026
416f2b7
Best-effort фикс, чтобы трекинг активности не ломал JWT-аутентификацию
Toksi86 Feb 11, 2026
ba4630a
Расширены тесты, убран личшний N+1 запрос в БД
Toksi86 Feb 11, 2026
4de1068
Изменены тексты писем для сценариев рассылки согласно шаблону
Toksi86 Feb 11, 2026
87eee11
Убраны дублирующиеся функции
Toksi86 Feb 11, 2026
f62c4f3
Исправлена стилистическая ошибка
Toksi86 Feb 11, 2026
dd1cd4e
Merge pull request #603 from PROCOLLAB-github/feature/auto_sending_email
Toksi86 Feb 11, 2026
92fb824
Добалвена модель связи экспертов с программами и распределённое оцени…
Toksi86 Feb 13, 2026
cd80564
Код привдён в соответствие со стилистическим оформлением
Toksi86 Feb 13, 2026
bcf7616
Merge pull request #604 from PROCOLLAB-github/feature_project_list_is…
Toksi86 Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

class SkillToObjectInline(GenericStackedInline):
model = SkillToObject
extra = 1
extra = 0
autocomplete_fields = ("skill",)
verbose_name = "Навык"
verbose_name_plural = "Навыки"

Expand Down Expand Up @@ -49,6 +50,10 @@ class SkillAdmin(admin.ModelAdmin):
"id",
"name",
)
search_fields = (
"name",
"category__name",
)


@admin.register(SkillCategory)
Expand Down
4 changes: 4 additions & 0 deletions mailing/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
59 changes: 59 additions & 0 deletions mailing/migrations/0008_mailing_scenario_log.py
Original file line number Diff line number Diff line change
@@ -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"),
),
]
Original file line number Diff line number Diff line change
@@ -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",
),
]
45 changes: 45 additions & 0 deletions mailing/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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}"
)
18 changes: 18 additions & 0 deletions mailing/rendering.py
Original file line number Diff line number Diff line change
@@ -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))
)
206 changes: 206 additions & 0 deletions mailing/scenarios.py
Original file line number Diff line number Diff line change
@@ -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}}",
),
),
)
Loading