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
28 changes: 24 additions & 4 deletions core/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from partner_programs.services.registration_notifications import (
visible_request_notifications,
)
from partner_programs.services.attention_actions import visible_reminders
from project_rates.notifications import (
visible_notifications as visible_expert_notifications,
)
Expand All @@ -21,6 +22,7 @@ def unread_count(user):
return (
visible_expert_notifications(user).filter(read_at__isnull=True).count()
+ visible_request_notifications(user).filter(read_at__isnull=True).count()
+ visible_reminders(user).filter(read_at__isnull=True).count()
)


Expand All @@ -37,6 +39,7 @@ def get(self, request, notification_id=None):
return Response(status=405)
experts = visible_expert_notifications(request.user)
connections = visible_request_notifications(request.user)
reminders = visible_reminders(request.user)
results = [
{
"id": f"expert_joined:{row.pk}",
Expand Down Expand Up @@ -66,6 +69,18 @@ def get(self, request, notification_id=None):
}
for row in connections[:100]
)
results.extend(
{
"id": f"program_attention:{row.pk}",
"kind": "program_attention",
"text": row.text,
"program_id": row.batch.program_id,
"category": row.batch.category,
"created_at": row.created_at,
"read_at": row.read_at,
}
for row in reminders[:100]
)
results.sort(key=lambda item: (item["created_at"], item["id"]), reverse=True)
return Response(
{"unread_count": unread_count(request.user), "results": results[:100]}
Expand All @@ -75,15 +90,20 @@ def post(self, request, notification_id=None):
if notification_id is None:
return Response(status=405)
match = re.fullmatch(
r"(expert_joined|registration_connection):([1-9][0-9]{0,18})", notification_id
r"(expert_joined|registration_connection|program_attention):([1-9][0-9]{0,18})",
notification_id,
)
if not match or int(match[2]) > 2**63 - 1:
raise Http404
kind, pk = match[1], int(match[2])
rows = (
visible_expert_notifications(request.user)
if kind == "expert_joined"
else visible_request_notifications(request.user)
visible_reminders(request.user)
if kind == "program_attention"
else (
visible_expert_notifications(request.user)
if kind == "expert_joined"
else visible_request_notifications(request.user)
)
)
notice = get_object_or_404(rows, pk=pk)
if notice.read_at is None:
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.management 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
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", "0027_registration_request_notifications"),
]

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"
),
),
]
4 changes: 4 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from files.models import UserFile
from partner_programs.constants import get_default_data_schema
from projects.models import Project
from partner_programs.attention_models import ( # noqa: F401
ProgramAttentionReminder,
ProgramAttentionReminderBatch,
)
from partner_programs.registration_models import ( # noqa: F401
ProgramRegistration,
PendingProgramRegistration,
Expand Down
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