diff --git a/docs/notifications-api.md b/docs/notifications-api.md index 56e70bc9..01056de0 100644 --- a/docs/notifications-api.md +++ b/docs/notifications-api.md @@ -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)` защищает от повторов и гонок; @@ -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", diff --git a/notifications/events.py b/notifications/events.py index 822549b5..16b11ec0 100644 --- a/notifications/events.py +++ b/notifications/events.py @@ -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"), ) @@ -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"), ) @@ -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", diff --git a/notifications/migrations/0003_notification_image_url.py b/notifications/migrations/0003_notification_image_url.py new file mode 100644 index 00000000..15606375 --- /dev/null +++ b/notifications/migrations/0003_notification_image_url.py @@ -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), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index a33848dd..80d5f4e6 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -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) diff --git a/notifications/serializers.py b/notifications/serializers.py index 76fe2b85..9d3d1c26 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -22,6 +22,7 @@ class Meta: "category", "title", "message", + "image_url", "action_url", "read_at", "created_at", diff --git a/notifications/services.py b/notifications/services.py index eea9111d..b2724613 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -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] @@ -41,6 +42,7 @@ def _notification_defaults( "category": category, "title": title, "message": message, + "image_url": image_url, "action_url": _validate_action_url(action_url), } @@ -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( @@ -68,6 +75,7 @@ def create_notification( title=title, message=message, action_url=action_url, + image_url=image_url, ), ) return notification @@ -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 @@ -100,6 +113,7 @@ def create_notifications( title=title, message=message, action_url=action_url, + image_url=image_url, ) notifications = [ Notification( diff --git a/notifications/tests/test_notification_images.py b/notifications/tests/test_notification_images.py new file mode 100644 index 00000000..40b08435 --- /dev/null +++ b/notifications/tests/test_notification_images.py @@ -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, + }, + ) diff --git a/notifications/tests/test_notification_services.py b/notifications/tests/test_notification_services.py index 9033ade1..07c494dc 100644 --- a/notifications/tests/test_notification_services.py +++ b/notifications/tests/test_notification_services.py @@ -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") diff --git a/notifications/tests/test_notifications_api.py b/notifications/tests/test_notifications_api.py index 7641d247..cc069fbb 100644 --- a/notifications/tests/test_notifications_api.py +++ b/notifications/tests/test_notifications_api.py @@ -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}", @@ -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) @@ -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"])) diff --git a/notifications/tests/test_program_notification_events.py b/notifications/tests/test_program_notification_events.py index ac4b670e..1423a822 100644 --- a/notifications/tests/test_program_notification_events.py +++ b/notifications/tests/test_program_notification_events.py @@ -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, @@ -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}» добавлен материал «Регламент».",