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
45 changes: 45 additions & 0 deletions notifications/migrations/0004_program_attention_reminder_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 4.2.11 on 2026-10-21 10:15

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("notifications", "0003_notification_image_url"),
]

operations = [
migrations.AlterField(
model_name="notification",
name="type",
field=models.CharField(
choices=[
("program_attention_reminder", "Напоминание по программе"),
("project_invite_created", "Приглашение в проект"),
("project_invite_accepted", "Приглашение в проект принято"),
("project_invite_declined", "Приглашение в проект отклонено"),
("project_invite_revoked", "Приглашение в проект отозвано"),
("vacancy_response_created", "Новый отклик на вакансию"),
("vacancy_response_accepted", "Отклик принят"),
("vacancy_response_declined", "Отклик отклонён"),
("program_news_published", "Новая новость программы"),
("program_material_published", "Новый материал программы"),
("course_access_opened", "Открыт доступ к курсу"),
("team_invite_created", "Приглашение в команду"),
("team_invite_accepted", "Приглашение в команду принято"),
("team_invite_declined", "Приглашение в команду отклонено"),
("team_invite_revoked", "Приглашение в команду отозвано"),
("application_submitted", "Заявка отправлена"),
("application_status_changed", "Статус заявки изменён"),
("submission_submitted", "Решение отправлено"),
("submission_status_changed", "Статус решения изменён"),
("expert_assignment_created", "Назначена экспертиза"),
("expert_assignment_revoked", "Экспертиза отозвана"),
("evaluation_submitted", "Оценка отправлена"),
("news_comment_created", "Новый комментарий"),
],
max_length=64,
),
),
]
5 changes: 5 additions & 0 deletions notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

class Notification(models.Model):
class Type(models.TextChoices):
PROGRAM_ATTENTION_REMINDER = (
"program_attention_reminder",
"Напоминание по программе",
)
PROJECT_INVITE_CREATED = "project_invite_created", "Приглашение в проект"
PROJECT_INVITE_ACCEPTED = (
"project_invite_accepted",
Expand Down Expand Up @@ -48,6 +52,7 @@ class Category(models.TextChoices):
SYSTEM = "system", "Система"

TYPE_CATEGORY = {
Type.PROGRAM_ATTENTION_REMINDER: Category.PROGRAM,
Type.PROJECT_INVITE_CREATED: Category.PROJECT,
Type.PROJECT_INVITE_ACCEPTED: Category.PROJECT,
Type.PROJECT_INVITE_DECLINED: Category.PROJECT,
Expand Down
1 change: 1 addition & 0 deletions notifications/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@


ANGULAR_NOTIFICATION_TYPES = (
Notification.Type.PROGRAM_ATTENTION_REMINDER,
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.PROJECT_INVITE_ACCEPTED,
Notification.Type.PROJECT_INVITE_DECLINED,
Expand Down
1 change: 1 addition & 0 deletions notifications/tests/test_notification_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def test_angular_surface_contains_only_production_types(self):
self.assertEqual(
set(ANGULAR_NOTIFICATION_TYPES),
{
Notification.Type.PROGRAM_ATTENTION_REMINDER,
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.PROJECT_INVITE_ACCEPTED,
Notification.Type.PROJECT_INVITE_DECLINED,
Expand Down
55 changes: 55 additions & 0 deletions partner_programs/attention_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Личные напоминания организатора и восстанавливаемая очередь писем."""

from django.conf import settings
from django.db import models
from django.utils import timezone


class ProgramAttentionReminderBatch(models.Model):
id = models.UUIDField(primary_key=True, editable=False)
program = models.ForeignKey(
"partner_programs.PartnerProgram", on_delete=models.CASCADE
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
)
category = models.CharField(max_length=48)
created_at = models.DateTimeField(default=timezone.now, editable=False)
result = models.JSONField(default=dict)


class ProgramAttentionReminder(models.Model):
batch = models.ForeignKey(
ProgramAttentionReminderBatch,
on_delete=models.CASCADE,
related_name="notifications",
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="attention_reminders",
)
text = models.TextField()
action_path = models.CharField(max_length=300)
source_ids = models.JSONField(default=list)
assignment_ids = models.JSONField(default=list)
created_at = models.DateTimeField(default=timezone.now, editable=False)
read_at = models.DateTimeField(null=True, blank=True)
emailed_at = models.DateTimeField(null=True, blank=True)
delivery_status = models.CharField(
max_length=12,
default="queued",
choices=[(v, v) for v in ("queued", "sent", "failed", "cancelled")],
)
attempts = models.PositiveSmallIntegerField(default=0)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["batch", "recipient"], name="unique_attention_batch_recipient"
)
]
indexes = [
models.Index(fields=["delivery_status", "id"]),
models.Index(fields=["recipient", "-created_at"]),
]
55 changes: 55 additions & 0 deletions partner_programs/attention_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from django.http import Http404, HttpResponse
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from partner_programs.serializers.attention_actions import (
CATEGORIES,
AttentionActionSerializer,
AttentionConfirmationSerializer,
)
from partner_programs.services.attention_actions import (
confirm_reminder,
export_attention,
preview_reminder,
)
from partner_programs.services.analytics_actions_support import managed_program


class AttentionActionView(APIView):
permission_classes = [IsAuthenticated]

def post(self, request, pk, category, action):
program = managed_program(pk, request.user)
if category not in CATEGORIES or action not in (
"reminder-preview",
"reminders",
"export",
):
raise Http404
serializer = (
AttentionConfirmationSerializer(data=request.data)
if action == "reminders"
else AttentionActionSerializer(data=request.data)
)
serializer.is_valid(raise_exception=True)
if action == "reminders":
response = Response(
confirm_reminder(
program, category, request.user, serializer.validated_data["token"]
)
)
elif action == "reminder-preview":
response = Response(
preview_reminder(
program, category, request.user, serializer.validated_data
)
)
else:
response = HttpResponse(
export_attention(program, category, serializer.validated_data),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response["Content-Disposition"] = 'attachment; filename="attention.xlsx"'
response["Cache-Control"] = "private, no-store"
return response
123 changes: 123 additions & 0 deletions partner_programs/migrations/0026_program_attention_reminders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Generated by Django 4.2.11 on 2026-10-20 21:31

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("partner_programs", "0025_partnerprogramuserprofile_welcome_acknowledged_at"),
]

operations = [
migrations.CreateModel(
name="ProgramAttentionReminderBatch",
fields=[
(
"id",
models.UUIDField(editable=False, primary_key=True, serialize=False),
),
("category", models.CharField(max_length=48)),
(
"created_at",
models.DateTimeField(
default=django.utils.timezone.now, editable=False
),
),
("result", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"program",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="partner_programs.partnerprogram",
),
),
],
),
migrations.CreateModel(
name="ProgramAttentionReminder",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("text", models.TextField()),
("action_path", models.CharField(max_length=300)),
("source_ids", models.JSONField(default=list)),
("assignment_ids", models.JSONField(default=list)),
(
"created_at",
models.DateTimeField(
default=django.utils.timezone.now, editable=False
),
),
("read_at", models.DateTimeField(blank=True, null=True)),
("emailed_at", models.DateTimeField(blank=True, null=True)),
(
"delivery_status",
models.CharField(
choices=[
("queued", "queued"),
("sent", "sent"),
("failed", "failed"),
("cancelled", "cancelled"),
],
default="queued",
max_length=12,
),
),
("attempts", models.PositiveSmallIntegerField(default=0)),
(
"batch",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="partner_programs.programattentionreminderbatch",
),
),
(
"recipient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attention_reminders",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["delivery_status", "id"],
name="partner_pro_deliver_0b22ac_idx",
),
models.Index(
fields=["recipient", "-created_at"],
name="partner_pro_recipie_e24c88_idx",
),
],
},
),
migrations.AddConstraint(
model_name="programattentionreminder",
constraint=models.UniqueConstraint(
fields=("batch", "recipient"), name="unique_attention_batch_recipient"
),
),
]
6 changes: 6 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1415,3 +1415,9 @@ def save(self, *args, **kwargs):
return super().save(*args, **kwargs)
self.full_clean()
super().save(*args, **kwargs)


from .attention_models import ( # noqa: E402,F401
ProgramAttentionReminder,
ProgramAttentionReminderBatch,
)
26 changes: 26 additions & 0 deletions partner_programs/report_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.db import connection, transaction
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView

from core.utils import build_xlsx_download_response
from partner_programs.services.analytics_actions_support import managed_program
from partner_programs.services.program_report import build_program_report


class ProgramReportView(APIView):
permission_classes = [IsAuthenticated]

def get(self, request, pk):
with transaction.atomic():
if connection.vendor == "postgresql":
with connection.cursor() as cursor:
cursor.execute(
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
)
program = managed_program(pk, request.user)
data = build_program_report(program)
response = build_xlsx_download_response(
data, base_name=f"Программа - {program.name}"
)
response["Cache-Control"] = "private, no-store"
return response
42 changes: 42 additions & 0 deletions partner_programs/serializers/attention_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from rest_framework import serializers

MAX_SELECTION = 10000
CATEGORIES = (
"participants-without-visit",
"participants-without-team",
"projects-awaiting-evaluation",
)


class AttentionSelectionSerializer(serializers.Serializer):
mode = serializers.ChoiceField(choices=("selected", "all"))
ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
max_length=MAX_SELECTION,
default=list,
)
excluded_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
max_length=MAX_SELECTION,
default=list,
)

def validate(self, data):
if data["mode"] == "all" and data["ids"]:
raise serializers.ValidationError(
"Для всех найденных используйте только исключения."
)
if data["mode"] == "selected" and (not data["ids"] or data["excluded_ids"]):
raise serializers.ValidationError("Выберите хотя бы одну строку.")
return data


class AttentionActionSerializer(serializers.Serializer):
search = serializers.CharField(
max_length=200, allow_blank=True, default="", trim_whitespace=True
)
selection = AttentionSelectionSerializer()


class AttentionConfirmationSerializer(serializers.Serializer):
token = serializers.CharField(max_length=1000000)
Loading
Loading