Skip to content

Commit d9e4a41

Browse files
committed
Задокументирован модуль Mailing и расширены тесты
1 parent 0eccd3d commit d9e4a41

6 files changed

Lines changed: 490 additions & 103 deletions

File tree

docs/modules/mailing.md

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,139 @@
11
# Mailing
22

3-
TODO
3+
## Назначение
4+
5+
Модуль `mailing` отвечает за email-рассылки и шаблоны писем: хранение схем
6+
старой админской формы рассылки, подготовку данных письма, отправку сообщений
7+
через email backend и автоматические сценарии рассылок по партнерским
8+
программам.
9+
10+
## Статус модуля
11+
12+
Модуль используется в рабочих сценариях, но не подключен как публичный API.
13+
Основные активные точки использования:
14+
15+
- celery-задача `run_program_mailings`;
16+
- старая форма рассылки из админки партнерских программ;
17+
- общие helper-функции отправки писем, которые используют другие модули.
18+
19+
## Основные возможности
20+
21+
- хранение схемы письма в `MailingSchema`;
22+
- рендеринг старой админской формы рассылки;
23+
- подготовка данных письма из формы или typed dataclass;
24+
- массовая отправка писем по строковому шаблону;
25+
- массовая отправка писем по Django template;
26+
- группировка писем батчами;
27+
- сценарные рассылки участникам партнерских программ;
28+
- логирование результата сценарных рассылок в `MailingScenarioLog`.
29+
30+
## Архитектура
31+
32+
- `mailing/models.py` - модели схем писем и логов сценарных рассылок.
33+
- `mailing/utils.py` - подготовка данных письма и низкоуровневые функции
34+
отправки.
35+
- `mailing/scenarios.py` - декларативное описание сценариев рассылки по
36+
программам.
37+
- `mailing/tasks.py` - celery-задача запуска сценариев.
38+
- `mailing/rendering.py` - подстановка базовых placeholders в темы и тексты.
39+
- `mailing/views.py` - старые views для формы рассылки; сейчас не подключены в
40+
публичный URLConf.
41+
- `mailing/urls.py` - старые routes формы рассылки, не подключенные в
42+
`procollab/urls.py`.
43+
- `mailing/tests/` - regression-тесты моделей, rendering/helpers и сценариев.
44+
45+
## Ключевые сущности
46+
47+
- `MailingSchema` - схема шаблона письма и HTML-шаблон для старой формы
48+
рассылки.
49+
- `MailingScenarioLog` - лог отправки сценарного письма конкретному участнику
50+
программы за конкретную дату.
51+
- `Scenario` - dataclass с кодом сценария, триггером, правилом выбора
52+
получателей, шаблоном и builder-контекстом.
53+
- `EmailDataToPrepare` - typed input для подготовки данных письма из кода.
54+
55+
## API и внешние точки входа
56+
57+
Публичных endpoints модуля `mailing` сейчас нет: `mailing.urls` не подключен в
58+
корневой `procollab/urls.py`.
59+
60+
Связанные внешние точки:
61+
62+
- `/anymail/` - webhook routes библиотеки Anymail;
63+
- админка партнерской программы вызывает `MailingTemplateRender` напрямую через
64+
custom admin view;
65+
- celery beat запускает `mailing.tasks.run_program_mailings` каждый день в
66+
10:00.
67+
68+
## Основные сценарии
69+
70+
### 1. Сценарная рассылка по партнерским программам
71+
72+
`run_program_mailings()` проходит по сценариям из `SCENARIOS`.
73+
74+
Для каждого сценария:
75+
76+
- вычисляется целевая дата;
77+
- выбираются программы по дате регистрации, окончанию регистрации или дедлайну
78+
подачи проекта;
79+
- выбираются получатели по правилу сценария;
80+
- создаются `MailingScenarioLog` в статусе `pending`;
81+
- письмо отправляется через `send_mass_mail_from_template`;
82+
- статус лога меняется на `sent` или `failed` по `anymail_status`.
83+
84+
Повторная отправка за ту же дату не дублирует письма со статусом `pending` или
85+
`sent`.
86+
87+
### 2. Старая админская рассылка
88+
89+
`MailingTemplateRender` строит контекст формы:
90+
91+
- доступные `MailingSchema`;
92+
- выбранные и невыбранные пользователи;
93+
- поля шаблона из JSON-схемы.
94+
95+
Сейчас этот renderer используется из админки партнерских программ.
96+
97+
### 3. Отправка письма из других модулей
98+
99+
Другие модули могут подготовить `EmailDataToPrepare`, получить данные через
100+
`prepare_mail_data()` и отправить письмо через `send_mass_mail()`.
101+
102+
Такой flow сейчас использует `vacancy.tasks.send_email`, который также
103+
переиспользуется партнерскими программами и оценками проектов.
104+
105+
## Связи с другими модулями
106+
107+
- `partner_programs` - сценарные рассылки выбирают программы и участников через
108+
selectors; админка программ использует старый renderer формы рассылки.
109+
- `vacancy` - задачи вакансий используют mailing helpers для email-уведомлений.
110+
- `project_rates` - переиспользует общий notification flow через
111+
`vacancy.tasks.send_email`.
112+
- `users` - получатели писем.
113+
- `anymail` / Unisender Go - фактическая отправка писем в production.
114+
115+
## Ограничения и риски
116+
117+
- `mailing/urls.py` содержит старые routes, но они не подключены наружу.
118+
- Если старые routes будут снова подключены, для них нужно отдельно проверить
119+
permissions и безопасность массовой отправки.
120+
- В `mailing/urls.py` есть историческая опечатка `template_fileds`; менять ее
121+
без проверки старого UI не стоит.
122+
- `vacancy.tasks.send_email` фактически является общим helper для уведомлений,
123+
но находится в модуле вакансий.
124+
- `MailingScenarioLog` пока не зарегистрирован в Django admin.
125+
126+
## Тесты
127+
128+
Текущие regression-тесты проверяют:
129+
130+
- строковое представление `MailingSchema` и `MailingScenarioLog`;
131+
- подстановку placeholders в subject и template values;
132+
- контекст старого renderer формы рассылки;
133+
- подготовку данных письма из `EmailDataToPrepare`;
134+
- группировку писем батчами;
135+
- рендеринг и отправку писем по строковому шаблону;
136+
- отправку писем по Django template с `status_callback`;
137+
- выбор участников с неактивными аккаунтами для сценариев программ;
138+
- успешную сценарную рассылку без повторной отправки;
139+
- перевод сценарного лога в `failed` при ошибочном `anymail_status`.

mailing/tests/__init__.py

Whitespace-only changes.

mailing/tests/helpers.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from datetime import datetime, time, timedelta
2+
3+
from django.utils import timezone
4+
5+
from mailing.models import MailingSchema
6+
from partner_programs.models import PartnerProgram, PartnerProgramUserProfile
7+
from users.models import CustomUser
8+
9+
10+
def aware_datetime(dt_date, hour: int = 12):
11+
return timezone.make_aware(
12+
datetime.combine(dt_date, time(hour=hour)),
13+
timezone.get_current_timezone(),
14+
)
15+
16+
17+
def create_user(email: str = "mailing-user@example.com", **overrides) -> CustomUser:
18+
defaults = {
19+
"email": email,
20+
"password": "test-password-12345",
21+
"first_name": "Test",
22+
"last_name": "User",
23+
"birthday": "2000-01-01",
24+
"is_active": True,
25+
}
26+
defaults.update(overrides)
27+
return CustomUser.objects.create_user(**defaults)
28+
29+
30+
def create_program(**overrides) -> PartnerProgram:
31+
today = timezone.localdate()
32+
defaults = {
33+
"name": "Mailing Program",
34+
"tag": "mailing-program",
35+
"city": "Moscow",
36+
"datetime_registration_ends": aware_datetime(today + timedelta(days=10)),
37+
"datetime_started": aware_datetime(today - timedelta(days=10)),
38+
"datetime_finished": aware_datetime(today + timedelta(days=40)),
39+
}
40+
defaults.update(overrides)
41+
return PartnerProgram.objects.create(**defaults)
42+
43+
44+
def register_program_user(
45+
user: CustomUser,
46+
program: PartnerProgram,
47+
registered_on,
48+
) -> PartnerProgramUserProfile:
49+
profile = PartnerProgramUserProfile.objects.create(
50+
user=user,
51+
partner_program=program,
52+
partner_program_data={},
53+
)
54+
PartnerProgramUserProfile.objects.filter(id=profile.id).update(
55+
datetime_created=aware_datetime(registered_on)
56+
)
57+
profile.refresh_from_db()
58+
return profile
59+
60+
61+
def create_mailing_schema(**overrides) -> MailingSchema:
62+
defaults = {
63+
"name": "Default mailing schema",
64+
"schema": {
65+
"title": {"title": "Title", "default": "Default title"},
66+
"text": {"title": "Text"},
67+
"button_text": {"title": "Button text", "default": "Open"},
68+
},
69+
"template": "<h1>{{ title }}</h1><p>{{ text }}</p>{{ user.email }}",
70+
}
71+
defaults.update(overrides)
72+
return MailingSchema.objects.create(**defaults)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from django.test import TestCase
2+
from django.utils import timezone
3+
4+
from mailing.models import MailingScenarioLog
5+
from mailing.rendering import render_subject, render_template_value
6+
from mailing.views import MailingTemplateRender
7+
8+
from .helpers import create_mailing_schema, create_program, create_user
9+
10+
11+
class MailingModelsTests(TestCase):
12+
def test_mailing_schema_string_representation(self):
13+
schema = create_mailing_schema(name="Program reminder")
14+
15+
self.assertEqual(str(schema), "MailingSchema<Program reminder>")
16+
17+
def test_mailing_scenario_log_string_representation(self):
18+
program = create_program()
19+
user = create_user()
20+
log = MailingScenarioLog.objects.create(
21+
scenario_code="program_registration_plus_3_inactive_account",
22+
program=program,
23+
user=user,
24+
scheduled_for=timezone.localdate(),
25+
status=MailingScenarioLog.Status.PENDING,
26+
)
27+
28+
self.assertIn("program_registration_plus_3_inactive_account", str(log))
29+
self.assertIn(f"program={program.id}", str(log))
30+
self.assertIn(f"user={user.id}", str(log))
31+
self.assertIn("status=pending", str(log))
32+
33+
34+
class MailingRenderingTests(TestCase):
35+
def test_render_subject_replaces_program_name(self):
36+
program = create_program(name="Case Cup")
37+
38+
subject = render_subject("{program_name}: reminder", program)
39+
40+
self.assertEqual(subject, "Case Cup: reminder")
41+
42+
def test_render_template_value_replaces_known_placeholders(self):
43+
program = create_program(name="Case Cup")
44+
user = create_user()
45+
46+
value = render_template_value(
47+
"/program/{program_id}/users/{user_id}/{program_name}",
48+
program,
49+
user,
50+
)
51+
52+
self.assertEqual(value, f"/program/{program.id}/users/{user.id}/Case Cup")
53+
54+
def test_template_render_context_contains_schema_users_and_fields(self):
55+
schema = create_mailing_schema(
56+
name="Participant email",
57+
schema={
58+
"title": {"title": "Title", "default": "Default title"},
59+
"text": {"title": "Text"},
60+
},
61+
)
62+
picked_user = create_user(email="picked@example.com")
63+
unpicked_user = create_user(email="unpicked@example.com")
64+
65+
context = MailingTemplateRender._get_context(
66+
schema.id,
67+
picked_users=[picked_user],
68+
unpicked_users=[unpicked_user],
69+
)
70+
71+
selected_schema = context["schemas"][0]
72+
self.assertEqual(selected_schema["id"], schema.id)
73+
self.assertTrue(selected_schema["selected"])
74+
self.assertEqual(context["picked_users"][0]["id"], picked_user.id)
75+
self.assertTrue(context["picked_users"][0]["picked"])
76+
self.assertEqual(context["unpicked_users"][0]["id"], unpicked_user.id)
77+
self.assertFalse(context["unpicked_users"][0]["picked"])
78+
self.assertEqual(
79+
context["template_fields"],
80+
[
81+
{"key": "title", "title": "Title", "default": "Default title"},
82+
{"key": "text", "title": "Text", "default": ""},
83+
],
84+
)

0 commit comments

Comments
 (0)