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
29 changes: 29 additions & 0 deletions docs/notifications-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@
исторический снимок заголовка и текста, внутренний `action_url`, `event_key`,
`read_at` и время создания.

`image_url: string | null` — отдельное изображение визуального источника события,
сохранённое на момент создания уведомления. Оно не заменяет `actor` и не влияет
на получателей, исключение автора, visibility или идемпотентность.

Три программных события (`program_news_published`, `program_material_published`,
`course_access_opened`) используют `PartnerProgram.image_address`; пустая строка
и отсутствие изображения сохраняются как `null`. Остальные события по умолчанию
получают `image_url=null` и сохраняют прежний контракт actor.

Миграция `notifications.0003_notification_image_url` добавляет только nullable
URLField. Data migration/backfill нет: старые тексты не обновляются, исторические
строки получают `image_url=null`. Retry старого события также не дополняет его
новым изображением. Изменение изображения программы не переписывает уведомления.

Новые программные тексты:

| Тип | Заголовок | Сообщение |
|---|---|---|
| `program_news_published` | Новая публикация | В программе «{program.name}» появилась новость. |
| `program_material_published` | Новый материал | В программе «{program.name}» добавлен материал «{material.title}». |
| `course_access_opened` | Открыт доступ к курсу | В программе «{program.name}» открыт доступ к курсу «{course.title}». |

Angular получает поле через общий CamelcaseInterceptor как `imageUrl`. Для этих
трёх типов он показывает только изображение программы либо прежнюю иконку типа
(feed/file/academic-hat), включая старые уведомления и ошибку загрузки картинки.
Actor avatar для программных типов не используется. Остальные типы сохраняют
actor avatar и прежний fallback; дополнительных запросов программы нет.

- удаление получателя каскадно удаляет его уведомления;
- удаление инициатора сохраняет уведомление с `actor=null`;
- `UniqueConstraint(recipient, event_key)` защищает от повторов и гонок;
Expand Down Expand Up @@ -62,6 +90,7 @@ GET /notifications/?limit=20&offset=0&unread=true
"category": "vacancy",
"title": "Новый отклик на вакансию",
"message": "Получен отклик на вакансию «Frontend-разработчик».",
"image_url": null,
"action_url": "/office/projects/7/vacancies/21/responses",
"read_at": null,
"created_at": "2026-08-15T12:00:00Z",
Expand Down
9 changes: 6 additions & 3 deletions notifications/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ def notify_program_news_published(news, *, program, actor) -> None:
recipient_ids=_program_member_ids(program.pk),
actor_id=actor.pk,
notification_type=Notification.Type.PROGRAM_NEWS_PUBLISHED,
title="Новая новость в программе",
message=f"В программе «{program.name}» опубликована новая новость.",
title="Новая публикация",
message=f"В программе «{program.name}» появилась новость.",
image_url=program.image_address or None,
action_url=f"/office/program/{program.pk}",
event_key=_event_key("program-news", news.pk, "published"),
)
Expand All @@ -139,8 +140,9 @@ def notify_program_material_published(material, *, program, actor) -> None:
recipient_ids=_program_member_ids(program.pk),
actor_id=actor.pk,
notification_type=Notification.Type.PROGRAM_MATERIAL_PUBLISHED,
title="Новый материал в программе",
title="Новый материал",
message=(f"В программе «{program.name}» добавлен материал «{material.title}»."),
image_url=program.image_address or None,
action_url=f"/office/program/{program.pk}",
event_key=_event_key("program-material", material.pk, "published"),
)
Expand All @@ -154,6 +156,7 @@ def notify_course_access_opened(course, *, program, actor) -> None:
notification_type=Notification.Type.COURSE_ACCESS_OPENED,
title="Открыт доступ к курсу",
message=(f"В программе «{program.name}» открыт доступ к курсу «{course.title}»."),
image_url=program.image_address or None,
action_url=f"/office/courses/{course.pk}",
event_key=_event_key(
"course-access",
Expand Down
18 changes: 18 additions & 0 deletions notifications/migrations/0003_notification_image_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Сгенерировано Django 4.2.11: nullable-изображение без изменения исторических данных.

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('notifications', '0002_add_program_notification_types'),
]

operations = [
migrations.AddField(
model_name='notification',
name='image_url',
field=models.URLField(blank=True, null=True),
),
]
2 changes: 2 additions & 0 deletions notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ class Category(models.TextChoices):
category = models.CharField(max_length=16, choices=Category.choices)
title = models.CharField(max_length=160)
message = models.TextField()
# Изображение фиксируется на момент события и не подменяет пользователя actor.
image_url = models.URLField(null=True, blank=True)
action_url = models.CharField(max_length=500, null=True, blank=True)
event_key = models.CharField(max_length=255)
read_at = models.DateTimeField(null=True, blank=True)
Expand Down
1 change: 1 addition & 0 deletions notifications/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Meta:
"category",
"title",
"message",
"image_url",
"action_url",
"read_at",
"created_at",
Expand Down
18 changes: 16 additions & 2 deletions notifications/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def _notification_defaults(
title: str,
message: str,
action_url: str | None,
image_url: str | None = None,
) -> dict:
try:
category = Notification.TYPE_CATEGORY[notification_type]
Expand All @@ -41,6 +42,7 @@ def _notification_defaults(
"category": category,
"title": title,
"message": message,
"image_url": image_url,
"action_url": _validate_action_url(action_url),
}

Expand All @@ -55,8 +57,13 @@ def create_notification(
message: str,
action_url: str | None,
event_key: str,
image_url: str | None = None,
) -> Notification | None:
"""Создаёт одно идемпотентное уведомление внутри транзакции события."""
"""Создаёт идемпотентное уведомление внутри транзакции события.

image_url задаёт визуальный источник отдельно от actor. Повтор события
не переписывает сохранённые текст и изображение старого уведомления.
"""
if actor_id is not None and actor_id == recipient_id:
return None
notification, _created = Notification.objects.get_or_create(
Expand All @@ -68,6 +75,7 @@ def create_notification(
title=title,
message=message,
action_url=action_url,
image_url=image_url,
),
)
return notification
Expand All @@ -83,8 +91,13 @@ def create_notifications(
message: str,
action_url: str | None,
event_key: str,
image_url: str | None = None,
) -> list[Notification]:
"""Создаёт уведомления нескольким уникальным получателям одним INSERT."""
"""Создаёт уведомления уникальным получателям одним INSERT.

image_url общий для события и не влияет на исключение автора из получателей.
Конфликт event_key сохраняет исходное уведомление, включая его изображение.
"""
recipients = sorted(
{
recipient_id
Expand All @@ -100,6 +113,7 @@ def create_notifications(
title=title,
message=message,
action_url=action_url,
image_url=image_url,
)
notifications = [
Notification(
Expand Down
149 changes: 149 additions & 0 deletions notifications/tests/test_notification_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from types import SimpleNamespace

from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient

from news.tests.helpers import create_partner_program, create_user
from notifications.events import (
notify_course_access_opened,
notify_program_material_published,
notify_program_news_published,
)
from notifications.models import Notification
from notifications.services import create_notification
from partner_programs.models import PartnerProgramUserProfile


class ProgramNotificationImageTests(TestCase):
def setUp(self):
self.actor = create_user(prefix="image-publisher")
self.actor.avatar = "https://example.com/publisher.png"
self.actor.save(update_fields=["avatar"])
self.members = [create_user(prefix="image-member") for _ in range(2)]
self.outsider = create_user(prefix="image-outsider")
self.program = create_partner_program(manager=self.actor)
self.program.image_address = "https://example.com/program.png"
self.program.save(update_fields=["image_address"])
for user in [self.actor, *self.members]:
PartnerProgramUserProfile.objects.create(
partner_program=self.program,
user=user,
partner_program_data={},
)

def events(self, suffix=1):
"""Задаёт старые ключи и маршруты независимо от реализации emitters."""
item = SimpleNamespace(
pk=suffix, title="Материал / курс", datetime_updated=timezone.now()
)
return (
(
notify_program_news_published,
item,
Notification.Type.PROGRAM_NEWS_PUBLISHED,
"Новая публикация",
f"В программе «{self.program.name}» появилась новость.",
f"program-news:{suffix}:published",
f"/office/program/{self.program.pk}",
),
(
notify_program_material_published,
item,
Notification.Type.PROGRAM_MATERIAL_PUBLISHED,
"Новый материал",
f"В программе «{self.program.name}» добавлен материал «{item.title}».",
f"program-material:{suffix}:published",
f"/office/program/{self.program.pk}",
),
(
notify_course_access_opened,
item,
Notification.Type.COURSE_ACCESS_OPENED,
"Открыт доступ к курсу",
f"В программе «{self.program.name}» открыт доступ к курсу «{item.title}».",
f"course-access:{suffix}:{item.datetime_updated.isoformat()}",
f"/office/courses/{suffix}",
),
)

def test_program_images_keep_actor_recipients_routes_keys_and_retry_snapshot(self):
for emit, item, kind, title, message, key, url in self.events():
with self.subTest(kind=kind):
self.program.image_address = "https://example.com/program.png"
emit(item, program=self.program, actor=self.actor)
self.program.image_address = "https://example.com/replaced.png"
emit(item, program=self.program, actor=self.actor)
rows = Notification.objects.filter(event_key=key)
self.assertEqual(rows.count(), 2)
self.assertEqual(
set(rows.values_list("recipient_id", flat=True)),
{member.pk for member in self.members},
)
for row in rows:
self.assertEqual(row.image_url, "https://example.com/program.png")
self.assertEqual(row.actor_id, self.actor.pk)
self.assertEqual(row.type, kind)
self.assertEqual(row.category, Notification.Category.PROGRAM)
self.assertEqual(row.title, title)
self.assertEqual(row.message, message)
self.assertEqual(row.action_url, url)

def test_missing_and_blank_program_image_are_stored_as_null(self):
for index, image in enumerate((None, ""), start=1):
self.program.image_address = image
for emit, item, kind, _title, _message, key, _url in self.events(index):
with self.subTest(kind=kind, image=image):
emit(item, program=self.program, actor=self.actor)
rows = Notification.objects.filter(event_key=key)
self.assertEqual(rows.count(), 2)
self.assertTrue(all(row.image_url is None for row in rows))

def test_api_keeps_image_and_real_actor_in_list_and_mark_read(self):
emit, item, *_ = self.events()[0]
emit(item, program=self.program, actor=self.actor)
client = APIClient()
client.force_authenticate(self.members[0])
response = client.get("/notifications/")
self.assertEqual(response.status_code, 200)
row = response.data["results"][0]
self.assertEqual(row["image_url"], self.program.image_address)
self.assertEqual(row["actor"]["id"], self.actor.pk)
self.assertEqual(row["actor"]["avatar"], self.actor.avatar)
marked = client.post(f"/notifications/{row['id']}/read/")
self.assertEqual(marked.status_code, 200)
self.assertEqual(marked.data["image_url"], row["image_url"])
self.assertEqual(marked.data["actor"], row["actor"])

def test_other_types_default_to_null_and_keep_actor_contract(self):
client = APIClient()
client.force_authenticate(self.members[0])
for kind in (
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.VACANCY_RESPONSE_CREATED,
):
with self.subTest(kind=kind):
notification = create_notification(
recipient_id=self.members[0].pk,
actor_id=self.actor.pk,
notification_type=kind,
title="Без изменений",
message="Без изменений",
action_url="/office/projects/invites",
event_key=f"ordinary:{kind}",
)
self.assertIsNone(notification.image_url)
response = client.get("/notifications/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 2)
for row in response.data["results"]:
self.assertIsNone(row["image_url"])
self.assertEqual(
row["actor"],
{
"id": self.actor.pk,
"first_name": self.actor.first_name,
"last_name": self.actor.last_name,
"avatar": self.actor.avatar,
},
)
15 changes: 15 additions & 0 deletions notifications/tests/test_notification_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ def test_self_notification_is_skipped(self):
self.assertIsNone(result)
self.assertFalse(Notification.objects.exists())

def test_single_image_is_saved_without_overwriting_existing_event(self):
image = "https://example.com/program.png"
first = self.create(image_url=image)
second = self.create(image_url="https://example.com/new.png")
self.assertEqual(first.pk, second.pk)
second.refresh_from_db()
self.assertEqual(second.image_url, image)
self.assertEqual(second.actor_id, self.actor.pk)

def test_retry_does_not_backfill_old_notification(self):
first = self.create()
self.create(image_url="https://example.com/program.png")
first.refresh_from_db()
self.assertIsNone(first.image_url)

def test_bulk_service_deduplicates_recipients_and_skips_actor(self):
second = create_user(prefix="service-second")

Expand Down
5 changes: 4 additions & 1 deletion notifications/tests/test_notifications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
from notifications.models import Notification


def create_notification(*, recipient, actor=None, suffix="1", read=False):
def create_notification(*, recipient, actor=None, suffix="1", read=False, image_url=None):
return Notification.objects.create(
recipient=recipient,
actor=actor,
image_url=image_url,
type=Notification.Type.PROJECT_INVITE_CREATED,
category=Notification.Category.PROJECT,
title=f"Уведомление {suffix}",
Expand Down Expand Up @@ -136,6 +137,7 @@ def test_list_has_fixed_query_count_for_many_notifications(self):
recipient=self.user,
actor=self.actor,
suffix=f"query-{index}",
image_url=f"https://example.com/program-{index}.png",
)
self.client.force_authenticate(self.user)

Expand All @@ -145,3 +147,4 @@ def test_list_has_fixed_query_count_for_many_notifications(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["results"]), 12)
self.assertEqual(len(queries), 3)
self.assertTrue(all(row["image_url"] for row in response.data["results"]))
6 changes: 3 additions & 3 deletions notifications/tests/test_program_notification_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ def test_program_news_notifies_only_members_for_both_audiences(self):
Notification.Type.PROGRAM_NEWS_PUBLISHED,
)
self.assertEqual(notification.category, Notification.Category.PROGRAM)
self.assertEqual(notification.title, "Новая новость в программе")
self.assertEqual(notification.title, "Новая публикация")
self.assertEqual(
notification.message,
f"В программе «{self.program.name}» опубликована новая новость.",
f"В программе «{self.program.name}» появилась новость.",
)
self.assertEqual(
notification.action_url,
Expand Down Expand Up @@ -244,7 +244,7 @@ def test_inline_create_notifies_members_and_each_material_has_own_event(self):
first = notifications.get(
event_key=f"program-material:{materials[0].pk}:published"
)
self.assertEqual(first.title, "Новый материал в программе")
self.assertEqual(first.title, "Новый материал")
self.assertEqual(
first.message,
f"В программе «{self.program.name}» добавлен материал «Регламент».",
Expand Down
Loading