Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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",
),
]
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))
)
185 changes: 123 additions & 62 deletions mailing/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -12,13 +13,18 @@
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]
Expand All @@ -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

Expand All @@ -85,61 +70,137 @@ 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(
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="До сдачи итогового решения осталось 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(
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="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}}",
),
),
)
30 changes: 23 additions & 7 deletions mailing/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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}")

Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down
Loading