Skip to content

Commit 3a6e346

Browse files
authored
Merge pull request #605 from PROCOLLAB-github/dev
Dev
2 parents b317701 + bcf7616 commit 3a6e346

32 files changed

Lines changed: 2191 additions & 13 deletions

core/admin.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515

1616
class SkillToObjectInline(GenericStackedInline):
1717
model = SkillToObject
18-
extra = 1
18+
extra = 0
19+
autocomplete_fields = ("skill",)
1920
verbose_name = "Навык"
2021
verbose_name_plural = "Навыки"
2122

@@ -49,6 +50,10 @@ class SkillAdmin(admin.ModelAdmin):
4950
"id",
5051
"name",
5152
)
53+
search_fields = (
54+
"name",
55+
"category__name",
56+
)
5257

5358

5459
@admin.register(SkillCategory)

mailing/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ def get_default_mailing_schema() -> dict[str, dict[str, str]]:
1010

1111

1212
MAILING_USERS_BATCH_SIZE = 100
13+
14+
FAILED_ANYMAIL_STATUSES = frozenset(
15+
{"rejected", "failed", "invalid", "bounced", "unknown"}
16+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from django.conf import settings
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [
7+
("partner_programs", "0015_partnerprogram_publish_projects_after_finish"),
8+
("mailing", "0007_alter_mailingschema_options_and_more"),
9+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="MailingScenarioLog",
15+
fields=[
16+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
17+
("scenario_code", models.CharField(max_length=128)),
18+
("scheduled_for", models.DateField()),
19+
("status", models.CharField(choices=[("pending", "Pending"), ("sent", "Sent"), ("failed", "Failed")], default="pending", max_length=16)),
20+
("sent_at", models.DateTimeField(blank=True, null=True)),
21+
("error", models.TextField(blank=True, null=True)),
22+
("datetime_created", models.DateTimeField(auto_now_add=True)),
23+
("datetime_updated", models.DateTimeField(auto_now=True)),
24+
(
25+
"program",
26+
models.ForeignKey(
27+
on_delete=models.deletion.CASCADE,
28+
related_name="mailing_scenario_logs",
29+
to="partner_programs.partnerprogram",
30+
),
31+
),
32+
(
33+
"user",
34+
models.ForeignKey(
35+
on_delete=models.deletion.CASCADE,
36+
related_name="mailing_scenario_logs",
37+
to=settings.AUTH_USER_MODEL,
38+
),
39+
),
40+
],
41+
options={
42+
"verbose_name": "Лог сценария рассылки",
43+
"verbose_name_plural": "Логи сценариев рассылки",
44+
"unique_together": {("scenario_code", "program", "user", "scheduled_for")},
45+
},
46+
),
47+
migrations.AddIndex(
48+
model_name="mailingscenariolog",
49+
index=models.Index(fields=["scenario_code", "scheduled_for"], name="mailing_ma_scenari_73b1f9_idx"),
50+
),
51+
migrations.AddIndex(
52+
model_name="mailingscenariolog",
53+
index=models.Index(fields=["program", "scheduled_for"], name="mailing_ma_program_b9dcf9_idx"),
54+
),
55+
migrations.AddIndex(
56+
model_name="mailingscenariolog",
57+
index=models.Index(fields=["user", "scheduled_for"], name="mailing_ma_user_id_0e2a92_idx"),
58+
),
59+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.11 on 2026-02-09 09:39
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("mailing", "0008_mailing_scenario_log"),
10+
]
11+
12+
operations = [
13+
migrations.RenameIndex(
14+
model_name="mailingscenariolog",
15+
new_name="mailing_mai_scenari_eed98a_idx",
16+
old_name="mailing_ma_scenari_73b1f9_idx",
17+
),
18+
migrations.RenameIndex(
19+
model_name="mailingscenariolog",
20+
new_name="mailing_mai_program_63bc97_idx",
21+
old_name="mailing_ma_program_b9dcf9_idx",
22+
),
23+
migrations.RenameIndex(
24+
model_name="mailingscenariolog",
25+
new_name="mailing_mai_user_id_333e66_idx",
26+
old_name="mailing_ma_user_id_0e2a92_idx",
27+
),
28+
]

mailing/models.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import uuid
33

4+
from django.conf import settings
45
from django.db import models
56
from .constants import get_default_mailing_schema
67

@@ -22,3 +23,47 @@ class Meta:
2223

2324
def __str__(self):
2425
return f"MailingSchema<{self.name}>"
26+
27+
28+
class MailingScenarioLog(models.Model):
29+
class Status(models.TextChoices):
30+
PENDING = "pending", "Pending"
31+
SENT = "sent", "Sent"
32+
FAILED = "failed", "Failed"
33+
34+
scenario_code = models.CharField(max_length=128)
35+
program = models.ForeignKey(
36+
"partner_programs.PartnerProgram",
37+
on_delete=models.CASCADE,
38+
related_name="mailing_scenario_logs",
39+
)
40+
user = models.ForeignKey(
41+
settings.AUTH_USER_MODEL,
42+
on_delete=models.CASCADE,
43+
related_name="mailing_scenario_logs",
44+
)
45+
scheduled_for = models.DateField()
46+
status = models.CharField(
47+
max_length=16, choices=Status.choices, default=Status.PENDING
48+
)
49+
sent_at = models.DateTimeField(null=True, blank=True)
50+
error = models.TextField(null=True, blank=True)
51+
datetime_created = models.DateTimeField(auto_now_add=True)
52+
datetime_updated = models.DateTimeField(auto_now=True)
53+
54+
class Meta:
55+
verbose_name = "Лог сценария рассылки"
56+
verbose_name_plural = "Логи сценариев рассылки"
57+
unique_together = ("scenario_code", "program", "user", "scheduled_for")
58+
indexes = [
59+
models.Index(fields=["scenario_code", "scheduled_for"]),
60+
models.Index(fields=["program", "scheduled_for"]),
61+
models.Index(fields=["user", "scheduled_for"]),
62+
]
63+
64+
def __str__(self):
65+
return (
66+
f"MailingScenarioLog<{self.scenario_code}> "
67+
f"program={self.program_id} user={self.user_id} "
68+
f"date={self.scheduled_for} status={self.status}"
69+
)

mailing/rendering.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from partner_programs.models import PartnerProgram
2+
from users.models import CustomUser
3+
4+
5+
def render_subject(subject: str, program: PartnerProgram) -> str:
6+
return subject.replace("{program_name}", program.name)
7+
8+
9+
def render_template_value(
10+
value: str,
11+
program: PartnerProgram,
12+
user: CustomUser,
13+
) -> str:
14+
return (
15+
value.replace("{program_name}", program.name)
16+
.replace("{program_id}", str(program.id))
17+
.replace("{user_id}", str(user.id))
18+
)

mailing/scenarios.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
from dataclasses import dataclass
2+
from datetime import date
3+
from enum import Enum
4+
from typing import Callable
5+
6+
from mailing.rendering import render_template_value
7+
from partner_programs.models import PartnerProgram
8+
from users.models import CustomUser
9+
10+
FRONTEND_BASE_URL = "https://app.procollab.ru"
11+
12+
13+
class TriggerType(Enum):
14+
PROGRAM_SUBMISSION_DEADLINE = "program_submission_deadline"
15+
PROGRAM_REGISTRATION_DATE = "program_registration_date"
16+
PROGRAM_REGISTRATION_END = "program_registration_end"
17+
18+
19+
class RecipientRule(Enum):
20+
ALL_PARTICIPANTS = "all_participants"
21+
NO_PROJECT_IN_PROGRAM = "no_project_in_program"
22+
NO_PROJECT_IN_PROGRAM_REGISTERED_ON_DATE = "no_project_in_program_registered_on_date"
23+
PROJECT_NOT_SUBMITTED = "project_not_submitted"
24+
INACTIVE_ACCOUNT_IN_PROGRAM = "inactive_account_in_program"
25+
INACTIVE_ACCOUNT_IN_PROGRAM_REGISTERED_ON_DATE = (
26+
"inactive_account_in_program_registered_on_date"
27+
)
28+
29+
30+
ContextBuilder = Callable[[PartnerProgram, CustomUser, date], dict]
31+
32+
33+
@dataclass(frozen=True)
34+
class Scenario:
35+
code: str
36+
trigger: TriggerType
37+
offset_days: int
38+
template_name: str
39+
subject: str
40+
recipient_rule: RecipientRule
41+
context_builder: ContextBuilder
42+
43+
44+
def _build_context(
45+
*,
46+
preview_text: str,
47+
title: str,
48+
text: str,
49+
button_text: str | None = None,
50+
button_link: str | None = None,
51+
) -> ContextBuilder:
52+
def _builder(program: PartnerProgram, user: CustomUser, _ref_date: date) -> dict:
53+
context = {
54+
"preview_text": render_template_value(preview_text, program, user),
55+
"title": render_template_value(title, program, user),
56+
"text": render_template_value(text, program, user),
57+
}
58+
if button_text is not None:
59+
context["button_text"] = render_template_value(button_text, program, user)
60+
if button_link is not None:
61+
context["button_link"] = render_template_value(button_link, program, user)
62+
return context
63+
64+
return _builder
65+
66+
67+
SCENARIOS: tuple[Scenario, ...] = (
68+
Scenario(
69+
code="program_submission_deadline_minus_10_no_project",
70+
trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE,
71+
offset_days=10,
72+
template_name="email/generic-template-0.html",
73+
subject="{program_name}: важное сообщение",
74+
recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM,
75+
context_builder=_build_context(
76+
preview_text="Кейс-чемпионат уже стартовал",
77+
title="Время начинать!",
78+
text=(
79+
"Кейс-чемпионат уже стартовал. Скорее заходите на платформу, "
80+
"создавайте проект и подключайте команду к работе.\n\n"
81+
"Вас ждет много интересного ⚡"
82+
),
83+
button_text="Создать проект",
84+
button_link=f"{FRONTEND_BASE_URL}/office/projects",
85+
),
86+
),
87+
Scenario(
88+
code="program_registration_plus_5_no_project",
89+
trigger=TriggerType.PROGRAM_REGISTRATION_DATE,
90+
offset_days=5,
91+
template_name="email/generic-template-0.html",
92+
subject="{program_name}: важное сообщение",
93+
recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM_REGISTERED_ON_DATE,
94+
context_builder=_build_context(
95+
preview_text="Сделать первый шаг",
96+
title="Сделать первый шаг",
97+
text=(
98+
"Когда непонятно с чего начать — стоит начать с самого простого. "
99+
"Например, зайти на платформу, создать проект или вступить в уже "
100+
"созданный лидером вашей команды.\n\n"
101+
"И вот, первый шаг уже сделан!"
102+
),
103+
button_text="Зайти на платформу",
104+
button_link=f"{FRONTEND_BASE_URL}/office/projects",
105+
),
106+
),
107+
Scenario(
108+
code="program_registration_plus_3_inactive_account",
109+
trigger=TriggerType.PROGRAM_REGISTRATION_DATE,
110+
offset_days=3,
111+
template_name="email/generic-template-0.html",
112+
subject="{program_name}: важное сообщение",
113+
recipient_rule=RecipientRule.INACTIVE_ACCOUNT_IN_PROGRAM_REGISTERED_ON_DATE,
114+
context_builder=_build_context(
115+
preview_text="Поздравляем!",
116+
title="Поздравляем!",
117+
text=(
118+
"Вы зарегистрировались на {program_name}. "
119+
"Заходите на платформу, чтобы оформить свой профиль участника "
120+
"и вступить в закрытую группу программы.\n\n"
121+
"Увидимся на платформе ⚡"
122+
),
123+
button_text="Оформить профиль",
124+
button_link=f"{FRONTEND_BASE_URL}/office/profile/{{user_id}}/",
125+
),
126+
),
127+
Scenario(
128+
code="program_registration_end_plus_3_inactive_account",
129+
trigger=TriggerType.PROGRAM_REGISTRATION_END,
130+
offset_days=3,
131+
template_name="email/generic-template-0.html",
132+
subject="{program_name}: важное сообщение",
133+
recipient_rule=RecipientRule.INACTIVE_ACCOUNT_IN_PROGRAM,
134+
context_builder=_build_context(
135+
preview_text="Без вас совсем не то",
136+
title="Без вас совсем не то",
137+
text=(
138+
"Мы так обрадовались, увидев вашу регистрацию, но, кажется, "
139+
"вы еще не заходили на платформу.\n\n"
140+
"Скорее заходите на procollab, чтобы стать активным участником "
141+
"программы и забрать максимум полезного для себя ⚡"
142+
),
143+
button_text="Зайти на платформу",
144+
button_link=f"{FRONTEND_BASE_URL}/office/profile/{{user_id}}/",
145+
),
146+
),
147+
Scenario(
148+
code="program_submission_deadline_minus_9_project_not_submitted",
149+
trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE,
150+
offset_days=9,
151+
template_name="email/generic-template-0.html",
152+
subject="{program_name}: важное сообщение",
153+
recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED,
154+
context_builder=_build_context(
155+
preview_text="Кейс-задания опубликованы",
156+
title="Кейс-задания опубликованы",
157+
text=(
158+
"Заходите на платформу, чтобы познакомиться с кейсами первого этапа "
159+
"кейс-чемпионата. Кейсы загружены в материалы закрытой группы.\n\n"
160+
"Приступайте к работе уже сегодня, чтобы успеть подготовить итоговое "
161+
"решение в срок ⚡"
162+
),
163+
button_text="Познакомиться с кейсом",
164+
button_link=f"{FRONTEND_BASE_URL}/office/program/{{program_id}}",
165+
),
166+
),
167+
Scenario(
168+
code="program_submission_deadline_minus_3_project_not_submitted",
169+
trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE,
170+
offset_days=3,
171+
template_name="email/generic-template-0.html",
172+
subject="{program_name}: важное сообщение",
173+
recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED,
174+
context_builder=_build_context(
175+
preview_text="До сдачи итогового решения осталось 3 дня",
176+
title="До сдачи итогового решения осталось 3 дня",
177+
text=(
178+
"Работа в самом разгаре, и мы запускаем обратный отсчет. "
179+
"Осталось всего 3 дня, чтобы доработать проект, оформить презентацию "
180+
"и загрузить итоговое решение на платформу."
181+
),
182+
button_text="Загрузить решение",
183+
button_link=f"{FRONTEND_BASE_URL}/office/projects",
184+
),
185+
),
186+
Scenario(
187+
code="program_submission_deadline_minus_1_project_not_submitted",
188+
trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE,
189+
offset_days=1,
190+
template_name="email/generic-template-0.html",
191+
subject="{program_name}: важное сообщение",
192+
recipient_rule=RecipientRule.PROJECT_NOT_SUBMITTED,
193+
context_builder=_build_context(
194+
preview_text="1 день до сдачи итогового решения",
195+
title="1 день до сдачи итогового решения",
196+
text=(
197+
"День X совсем скоро. Осталось только внести последние штрихи и "
198+
"загрузить итоговое решение на платформу.\n\n"
199+
"По любым техническим вопросам всегда на связи @procollab_support\n\n"
200+
"Удачи!"
201+
),
202+
button_text="Загрузить решение",
203+
button_link=f"{FRONTEND_BASE_URL}/office/program/{{program_id}}",
204+
),
205+
),
206+
)

0 commit comments

Comments
 (0)