Skip to content

Commit 468bf9d

Browse files
authored
Merge pull request #598 from PROCOLLAB-github/feature/auto_sending_email
Добавлена рассылка писем об окончании срока подачи проектов на программу
2 parents 76b71e7 + 4d394c1 commit 468bf9d

7 files changed

Lines changed: 396 additions & 0 deletions

File tree

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+
]

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/scenarios.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from dataclasses import dataclass
2+
from datetime import date
3+
from enum import Enum
4+
from typing import Callable
5+
6+
from partner_programs.models import PartnerProgram
7+
from users.models import CustomUser
8+
9+
FRONTEND_BASE_URL = "https://app.procollab.ru"
10+
11+
12+
class TriggerType(Enum):
13+
PROGRAM_SUBMISSION_DEADLINE = "program_submission_deadline"
14+
15+
16+
class RecipientRule(Enum):
17+
ALL_PARTICIPANTS = "all_participants"
18+
NO_PROJECT_IN_PROGRAM = "no_project_in_program"
19+
20+
21+
ContextBuilder = Callable[[PartnerProgram, CustomUser, date], dict]
22+
23+
24+
@dataclass(frozen=True)
25+
class Scenario:
26+
code: str
27+
trigger: TriggerType
28+
offset_days: int
29+
template_name: str
30+
subject: str
31+
recipient_rule: RecipientRule
32+
context_builder: ContextBuilder
33+
34+
35+
def _build_submission_deadline_context(offset_days: int) -> ContextBuilder:
36+
def _builder(program: PartnerProgram, user: CustomUser, deadline_date: date) -> dict:
37+
deadline_str = deadline_date.strftime("%d.%m.%Y")
38+
return {
39+
"preview_text": f"До окончания подачи проектов осталось {offset_days} дней",
40+
"title": "Пора подать проект",
41+
"text": (
42+
f"До окончания подачи проектов в программе «{program.name}» "
43+
f"осталось {offset_days} дней. "
44+
f"Пожалуйста, подайте проект и сформируйте команду до {deadline_str}."
45+
),
46+
"button_text": "Подать проект",
47+
"button_link": f"{FRONTEND_BASE_URL}/office/program/{program.id}",
48+
}
49+
50+
return _builder
51+
52+
53+
SCENARIOS: tuple[Scenario, ...] = (
54+
Scenario(
55+
code="program_submission_deadline_minus_10_no_project",
56+
trigger=TriggerType.PROGRAM_SUBMISSION_DEADLINE,
57+
offset_days=10,
58+
template_name="email/generic-template-0.html",
59+
subject="Procollab | Подача проекта",
60+
recipient_rule=RecipientRule.NO_PROJECT_IN_PROGRAM,
61+
context_builder=_build_submission_deadline_context(10),
62+
),
63+
)

mailing/tasks.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import logging
2+
from datetime import timedelta
3+
4+
from django.utils import timezone
5+
6+
from mailing.models import MailingScenarioLog
7+
from mailing.scenarios import RecipientRule, SCENARIOS, TriggerType
8+
from mailing.utils import send_mass_mail_from_template
9+
from partner_programs.selectors import (
10+
program_participants,
11+
program_participants_without_project,
12+
programs_with_submission_deadline_on,
13+
)
14+
from procollab.celery import app
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def _get_programs_for_scenario(scenario, target_date):
20+
match scenario.trigger:
21+
case TriggerType.PROGRAM_SUBMISSION_DEADLINE:
22+
return programs_with_submission_deadline_on(target_date)
23+
case _:
24+
raise ValueError(f"Unsupported trigger: {scenario.trigger}")
25+
26+
27+
def _get_recipients(scenario, program_id: int):
28+
match scenario.recipient_rule:
29+
case RecipientRule.ALL_PARTICIPANTS:
30+
return program_participants(program_id)
31+
case RecipientRule.NO_PROJECT_IN_PROGRAM:
32+
return program_participants_without_project(program_id)
33+
case _:
34+
raise ValueError(f"Unsupported recipient rule: {scenario.recipient_rule}")
35+
36+
37+
def _deadline_date(program):
38+
deadline = program.datetime_project_submission_ends or program.datetime_registration_ends
39+
return timezone.localtime(deadline).date()
40+
41+
42+
def _send_scenario_for_program(scenario, program, scheduled_for):
43+
recipients = _get_recipients(scenario, program.id)
44+
if not recipients.exists():
45+
return 0
46+
47+
pending_or_sent_ids = MailingScenarioLog.objects.filter(
48+
scenario_code=scenario.code,
49+
program=program,
50+
scheduled_for=scheduled_for,
51+
status__in=[
52+
MailingScenarioLog.Status.PENDING,
53+
MailingScenarioLog.Status.SENT,
54+
],
55+
).values_list("user_id", flat=True)
56+
57+
recipients_to_send = recipients.exclude(id__in=pending_or_sent_ids)
58+
user_ids = list(recipients_to_send.values_list("id", flat=True))
59+
if not user_ids:
60+
return 0
61+
62+
MailingScenarioLog.objects.filter(
63+
scenario_code=scenario.code,
64+
program=program,
65+
scheduled_for=scheduled_for,
66+
status=MailingScenarioLog.Status.FAILED,
67+
user_id__in=user_ids,
68+
).update(status=MailingScenarioLog.Status.PENDING, error="", sent_at=None)
69+
70+
logs = [
71+
MailingScenarioLog(
72+
scenario_code=scenario.code,
73+
program=program,
74+
user_id=user_id,
75+
scheduled_for=scheduled_for,
76+
status=MailingScenarioLog.Status.PENDING,
77+
)
78+
for user_id in user_ids
79+
]
80+
MailingScenarioLog.objects.bulk_create(logs, ignore_conflicts=True)
81+
82+
deadline_date = _deadline_date(program)
83+
84+
def context_builder(user):
85+
return scenario.context_builder(program, user, deadline_date)
86+
87+
try:
88+
send_mass_mail_from_template(
89+
recipients_to_send,
90+
scenario.subject,
91+
scenario.template_name,
92+
context_builder=context_builder,
93+
)
94+
except Exception as exc:
95+
MailingScenarioLog.objects.filter(
96+
scenario_code=scenario.code,
97+
program=program,
98+
scheduled_for=scheduled_for,
99+
status=MailingScenarioLog.Status.PENDING,
100+
user_id__in=user_ids,
101+
).update(status=MailingScenarioLog.Status.FAILED, error=str(exc))
102+
logger.exception(
103+
"Scenario %s failed for program %s", scenario.code, program.id
104+
)
105+
return 0
106+
107+
MailingScenarioLog.objects.filter(
108+
scenario_code=scenario.code,
109+
program=program,
110+
scheduled_for=scheduled_for,
111+
status=MailingScenarioLog.Status.PENDING,
112+
user_id__in=user_ids,
113+
).update(
114+
status=MailingScenarioLog.Status.SENT, sent_at=timezone.now(), error=""
115+
)
116+
return len(user_ids)
117+
118+
119+
@app.task
120+
def run_program_mailings() -> int:
121+
today = timezone.localdate()
122+
total_sent = 0
123+
for scenario in SCENARIOS:
124+
target_date = today + timedelta(days=scenario.offset_days)
125+
programs = _get_programs_for_scenario(scenario, target_date)
126+
for program in programs:
127+
total_sent += _send_scenario_for_program(scenario, program, today)
128+
logger.info("Program mailings sent: %s", total_sent)
129+
return total_sent

mailing/utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.core import mail
1212
from django.core.mail import EmailMultiAlternatives
1313
from django.template import Context, Template
14+
from django.template.loader import get_template
1415

1516
from .typing import MailDataDict, EmailDataToPrepare
1617

@@ -124,3 +125,44 @@ def send_mass_mail(
124125
for group in grouped_messages:
125126
num_sent += send_group_messages(group)
126127
return num_sent
128+
129+
130+
def send_mass_mail_from_template(
131+
users: django.db.models.QuerySet | List[User],
132+
subject: str,
133+
template_name: str,
134+
template_context: Union[
135+
MailDataDict,
136+
list,
137+
dict,
138+
] = None,
139+
context_builder=None,
140+
connection=None,
141+
) -> Annotated[int, "Количество отосланных сообщений"]:
142+
"""
143+
Send emails using a template file from Django template loaders.
144+
Allows optional per-user context via context_builder(user) -> dict.
145+
"""
146+
if template_context is None:
147+
template_context = {}
148+
149+
template = get_template(template_name)
150+
messages = []
151+
for user in users:
152+
context = dict(template_context)
153+
if context_builder is not None:
154+
context.update(context_builder(user))
155+
context["user"] = user
156+
html_msg = template.render(context)
157+
plain_msg = template.render(context)
158+
msg = EmailMultiAlternatives(
159+
subject, plain_msg, settings.EMAIL_USER, [user.email]
160+
)
161+
msg.attach_alternative(html_msg, "text/html")
162+
messages.append(msg)
163+
164+
grouped_messages = create_message_groups(messages)
165+
num_sent: int = 0
166+
for group in grouped_messages:
167+
num_sent += send_group_messages(group)
168+
return num_sent

0 commit comments

Comments
 (0)