diff --git a/courses/admin_config/content.py b/courses/admin_config/content.py index e2aa564c..2ce1deae 100644 --- a/courses/admin_config/content.py +++ b/courses/admin_config/content.py @@ -1,9 +1,18 @@ from django.contrib import admin +from django.db import transaction from django.http import Http404 from django.urls import path -from courses.models import Course, CourseLesson, CourseModule, CourseTask, CourseTaskOption +from courses.models import ( + Course, + CourseContentStatus, + CourseLesson, + CourseModule, + CourseTask, + CourseTaskOption, +) from courses.services.export_course_results import build_course_results_export_response +from notifications.events import notify_course_access_opened from .forms import CourseAdminForm, CourseModuleAdminForm, CourseTaskAdminForm from .helpers import UserFileUploadAdminMixin @@ -106,7 +115,14 @@ def export_results_view(self, request, object_id): raise Http404("Курс не найден.") return build_course_results_export_response(course) + @transaction.atomic def save_model(self, request, obj, form, change): + previous_status = None + if change and obj.pk: + previous_status = ( + Course.objects.filter(pk=obj.pk).values_list("status", flat=True).first() + ) + avatar_upload = form.cleaned_data.get("avatar_upload") if avatar_upload: obj.avatar_file = self.create_user_file(request, avatar_upload) @@ -120,6 +136,16 @@ def save_model(self, request, obj, form, change): obj.header_cover_file = self.create_user_file(request, header_cover_upload) super().save_model(request, obj, form, change) + if ( + previous_status != CourseContentStatus.PUBLISHED + and obj.status == CourseContentStatus.PUBLISHED + and obj.partner_program_id is not None + ): + notify_course_access_opened( + obj, + program=obj.partner_program, + actor=request.user, + ) @admin.register(CourseModule) diff --git a/news/services.py b/news/services.py index 7718c3bd..c8a5f742 100644 --- a/news/services.py +++ b/news/services.py @@ -1,6 +1,9 @@ from typing import Any +from django.db import transaction + from news.models import News +from notifications.events import notify_program_news_published from partner_programs.models import PartnerProgram from projects.models import Project from users.models import CustomUser @@ -33,9 +36,12 @@ def create_user_news( return News.objects.add_news(user, **data) +@transaction.atomic def create_program_news( program: PartnerProgram, author, data: dict[str, Any], ) -> News: - return News.objects.add_news(program, **data) + news = News.objects.add_news(program, **data) + notify_program_news_published(news, program=program, actor=author) + return news diff --git a/notifications/events.py b/notifications/events.py index 254f3ed3..822549b5 100644 --- a/notifications/events.py +++ b/notifications/events.py @@ -19,6 +19,20 @@ def _nextgen_notifications_enabled() -> bool: return settings.NEXTGEN_SURFACE_ENABLED +def _program_member_ids(program_id: int): + from partner_programs.models import PartnerProgramUserProfile + + return ( + PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, + user_id__isnull=False, + ) + .order_by() + .values_list("user_id", flat=True) + .distinct() + ) + + def notify_project_invite_created(invite) -> None: """Уведомляет пользователя о новом приглашении в проект.""" create_notification( @@ -106,6 +120,49 @@ def notify_vacancy_response_resolved(response, *, actor, accepted: bool) -> None ) +def notify_program_news_published(news, *, program, actor) -> None: + """Уведомляет участников программы о созданной новости программы.""" + create_notifications( + recipient_ids=_program_member_ids(program.pk), + actor_id=actor.pk, + notification_type=Notification.Type.PROGRAM_NEWS_PUBLISHED, + title="Новая новость в программе", + message=f"В программе «{program.name}» опубликована новая новость.", + action_url=f"/office/program/{program.pk}", + event_key=_event_key("program-news", news.pk, "published"), + ) + + +def notify_program_material_published(material, *, program, actor) -> None: + """Уведомляет участников программы о новом материале программы.""" + create_notifications( + recipient_ids=_program_member_ids(program.pk), + actor_id=actor.pk, + notification_type=Notification.Type.PROGRAM_MATERIAL_PUBLISHED, + title="Новый материал в программе", + message=(f"В программе «{program.name}» добавлен материал «{material.title}»."), + action_url=f"/office/program/{program.pk}", + event_key=_event_key("program-material", material.pk, "published"), + ) + + +def notify_course_access_opened(course, *, program, actor) -> None: + """Уведомляет участников программы об одном переходе курса в published.""" + create_notifications( + recipient_ids=_program_member_ids(program.pk), + actor_id=actor.pk, + notification_type=Notification.Type.COURSE_ACCESS_OPENED, + title="Открыт доступ к курсу", + message=(f"В программе «{program.name}» открыт доступ к курсу «{course.title}»."), + action_url=f"/office/courses/{course.pk}", + event_key=_event_key( + "course-access", + course.pk, + course.datetime_updated.isoformat(), + ), + ) + + def notify_team_invite_created(invite) -> None: """Уведомляет пользователя о новом приглашении в команду заявки.""" if not _nextgen_notifications_enabled(): diff --git a/notifications/migrations/0002_add_program_notification_types.py b/notifications/migrations/0002_add_program_notification_types.py new file mode 100644 index 00000000..70ba8dc2 --- /dev/null +++ b/notifications/migrations/0002_add_program_notification_types.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.11 on 2026-09-11 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="type", + field=models.CharField( + choices=[ + ("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, + ), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index 1be9ba7d..a33848dd 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -17,6 +17,15 @@ class Type(models.TextChoices): VACANCY_RESPONSE_CREATED = "vacancy_response_created", "Новый отклик на вакансию" VACANCY_RESPONSE_ACCEPTED = "vacancy_response_accepted", "Отклик принят" VACANCY_RESPONSE_DECLINED = "vacancy_response_declined", "Отклик отклонён" + PROGRAM_NEWS_PUBLISHED = ( + "program_news_published", + "Новая новость программы", + ) + PROGRAM_MATERIAL_PUBLISHED = ( + "program_material_published", + "Новый материал программы", + ) + COURSE_ACCESS_OPENED = "course_access_opened", "Открыт доступ к курсу" TEAM_INVITE_CREATED = "team_invite_created", "Приглашение в команду" TEAM_INVITE_ACCEPTED = "team_invite_accepted", "Приглашение в команду принято" TEAM_INVITE_DECLINED = "team_invite_declined", "Приглашение в команду отклонено" @@ -46,6 +55,9 @@ class Category(models.TextChoices): Type.VACANCY_RESPONSE_CREATED: Category.VACANCY, Type.VACANCY_RESPONSE_ACCEPTED: Category.VACANCY, Type.VACANCY_RESPONSE_DECLINED: Category.VACANCY, + Type.PROGRAM_NEWS_PUBLISHED: Category.PROGRAM, + Type.PROGRAM_MATERIAL_PUBLISHED: Category.PROGRAM, + Type.COURSE_ACCESS_OPENED: Category.PROGRAM, Type.TEAM_INVITE_CREATED: Category.PROGRAM, Type.TEAM_INVITE_ACCEPTED: Category.PROGRAM, Type.TEAM_INVITE_DECLINED: Category.PROGRAM, diff --git a/notifications/selectors.py b/notifications/selectors.py new file mode 100644 index 00000000..167ece6e --- /dev/null +++ b/notifications/selectors.py @@ -0,0 +1,25 @@ +from django.conf import settings + +from notifications.models import Notification + + +ANGULAR_NOTIFICATION_TYPES = ( + Notification.Type.PROJECT_INVITE_CREATED, + Notification.Type.PROJECT_INVITE_ACCEPTED, + Notification.Type.PROJECT_INVITE_DECLINED, + Notification.Type.PROJECT_INVITE_REVOKED, + Notification.Type.VACANCY_RESPONSE_CREATED, + Notification.Type.VACANCY_RESPONSE_ACCEPTED, + Notification.Type.VACANCY_RESPONSE_DECLINED, + Notification.Type.PROGRAM_NEWS_PUBLISHED, + Notification.Type.PROGRAM_MATERIAL_PUBLISHED, + Notification.Type.COURSE_ACCESS_OPENED, +) + + +def get_visible_notifications(user): + """Returns the notification surface available to the current frontend.""" + queryset = Notification.objects.filter(recipient=user) + if settings.NEXTGEN_SURFACE_ENABLED: + return queryset + return queryset.filter(type__in=ANGULAR_NOTIFICATION_TYPES) diff --git a/notifications/tests/test_notification_visibility.py b/notifications/tests/test_notification_visibility.py new file mode 100644 index 00000000..54536f8d --- /dev/null +++ b/notifications/tests/test_notification_visibility.py @@ -0,0 +1,106 @@ +from django.test import SimpleTestCase, TestCase, override_settings +from rest_framework.test import APIClient + +from news.tests.helpers import create_user +from notifications.models import Notification +from notifications.selectors import ANGULAR_NOTIFICATION_TYPES + + +def create_typed_notification(*, recipient, notification_type, suffix): + return Notification.objects.create( + recipient=recipient, + type=notification_type, + category=Notification.TYPE_CATEGORY[notification_type], + title="Уведомление", + message="Сообщение", + action_url="/office/program/1", + event_key=f"visibility:{suffix}", + ) + + +class AngularNotificationTypesTests(SimpleTestCase): + def test_angular_surface_contains_only_production_types(self): + self.assertEqual( + set(ANGULAR_NOTIFICATION_TYPES), + { + Notification.Type.PROJECT_INVITE_CREATED, + Notification.Type.PROJECT_INVITE_ACCEPTED, + Notification.Type.PROJECT_INVITE_DECLINED, + Notification.Type.PROJECT_INVITE_REVOKED, + Notification.Type.VACANCY_RESPONSE_CREATED, + Notification.Type.VACANCY_RESPONSE_ACCEPTED, + Notification.Type.VACANCY_RESPONSE_DECLINED, + Notification.Type.PROGRAM_NEWS_PUBLISHED, + Notification.Type.PROGRAM_MATERIAL_PUBLISHED, + Notification.Type.COURSE_ACCESS_OPENED, + }, + ) + + +@override_settings(NEXTGEN_SURFACE_ENABLED=False) +class NotificationVisibilityOffTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="notification-visibility-off") + self.client.force_authenticate(self.user) + self.visible = create_typed_notification( + recipient=self.user, + notification_type=Notification.Type.PROGRAM_NEWS_PUBLISHED, + suffix="visible", + ) + self.hidden = create_typed_notification( + recipient=self.user, + notification_type=Notification.Type.APPLICATION_SUBMITTED, + suffix="hidden-application", + ) + + def test_list_and_counts_ignore_physical_nextgen_row(self): + response = self.client.get("/notifications/") + count_response = self.client.get("/notifications/unread-count/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["unread_count"], 1) + self.assertEqual(response.data["results"][0]["id"], self.visible.pk) + self.assertEqual(count_response.data, {"unread_count": 1}) + self.assertTrue(Notification.objects.filter(pk=self.hidden.pk).exists()) + + def test_read_all_does_not_modify_hidden_row(self): + response = self.client.post("/notifications/read-all/") + + self.assertEqual(response.data, {"updated": 1, "unread_count": 0}) + self.visible.refresh_from_db() + self.hidden.refresh_from_db() + self.assertIsNotNone(self.visible.read_at) + self.assertIsNone(self.hidden.read_at) + + def test_hidden_notification_cannot_be_read_by_id(self): + response = self.client.post(f"/notifications/{self.hidden.pk}/read/") + + self.assertEqual(response.status_code, 404) + self.hidden.refresh_from_db() + self.assertIsNone(self.hidden.read_at) + + +@override_settings(NEXTGEN_SURFACE_ENABLED=True) +class NotificationVisibilityOnTests(TestCase): + def test_nextgen_row_is_visible_and_readable(self): + client = APIClient() + user = create_user(prefix="notification-visibility-on") + hidden_when_off = create_typed_notification( + recipient=user, + notification_type=Notification.Type.APPLICATION_SUBMITTED, + suffix="nextgen-on", + ) + client.force_authenticate(user) + + response = client.get("/notifications/") + read_response = client.post(f"/notifications/{hidden_when_off.pk}/read/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["unread_count"], 1) + self.assertEqual(response.data["results"][0]["id"], hidden_when_off.pk) + self.assertEqual(read_response.status_code, 200) + hidden_when_off.refresh_from_db() + self.assertIsNotNone(hidden_when_off.read_at) diff --git a/notifications/tests/test_program_notification_events.py b/notifications/tests/test_program_notification_events.py new file mode 100644 index 00000000..ac4b670e --- /dev/null +++ b/notifications/tests/test_program_notification_events.py @@ -0,0 +1,402 @@ +from types import SimpleNamespace + +from django.contrib import admin +from django.db import connection, transaction +from django.test import RequestFactory, TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from courses.admin_config.content import CourseAdmin +from courses.models import Course, CourseContentStatus +from news.models import News +from news.services import ( + create_program_news, + create_project_news, + create_user_news, +) +from news.tests.helpers import create_partner_program, create_project, create_user +from notifications.events import notify_program_news_published +from notifications.models import Notification +from partner_programs.admin import PartnerProgramAdmin, PartnerProgramMaterialAdmin +from partner_programs.models import ( + PartnerProgramMaterial, + PartnerProgramUserProfile, +) + + +def add_program_member(program, user): + return PartnerProgramUserProfile.objects.create( + partner_program=program, + user=user, + project=None, + partner_program_data={}, + ) + + +class ProgramNewsNotificationTests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="program-notification-manager") + self.participant = create_user(prefix="program-notification-participant") + self.second_participant = create_user( + prefix="program-notification-second-participant" + ) + self.outsider = create_user(prefix="program-notification-outsider") + self.program = create_partner_program(manager=self.manager) + for user in (self.manager, self.participant, self.second_participant): + add_program_member(self.program, user) + self.client.force_authenticate(self.manager) + + def test_program_news_notifies_only_members_for_both_audiences(self): + for index, audience in enumerate( + (News.Audience.PROGRAM_PARTICIPANTS, News.Audience.PLATFORM), + start=1, + ): + response = self.client.post( + f"/programs/{self.program.pk}/news/", + {"text": f"Новость {index}", "audience": audience}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + event_key = f"program-news:{response.data['id']}:published" + notifications = Notification.objects.filter(event_key=event_key) + self.assertEqual( + set(notifications.values_list("recipient_id", flat=True)), + {self.participant.pk, self.second_participant.pk}, + ) + self.assertFalse(notifications.filter(recipient=self.manager).exists()) + self.assertFalse(notifications.filter(recipient=self.outsider).exists()) + + notification = notifications.get(recipient=self.participant) + self.assertEqual( + notification.type, + Notification.Type.PROGRAM_NEWS_PUBLISHED, + ) + self.assertEqual(notification.category, Notification.Category.PROGRAM) + self.assertEqual(notification.title, "Новая новость в программе") + self.assertEqual( + notification.message, + f"В программе «{self.program.name}» опубликована новая новость.", + ) + self.assertEqual( + notification.action_url, + f"/office/program/{self.program.pk}", + ) + self.assertEqual(notification.actor_id, self.manager.pk) + + def test_update_retry_and_other_news_contexts_do_not_duplicate_event(self): + news = create_program_news( + self.program, + self.manager, + { + "text": "Исходная новость", + "audience": News.Audience.PROGRAM_PARTICIPANTS, + }, + ) + event_key = f"program-news:{news.pk}:published" + initial_count = Notification.objects.filter(event_key=event_key).count() + + response = self.client.patch( + f"/programs/{self.program.pk}/news/{news.pk}/", + {"text": "Обновлённая новость"}, + format="json", + ) + notify_program_news_published( + news, + program=self.program, + actor=self.manager, + ) + create_project_news( + create_project(leader=self.manager), + self.manager, + {"text": "Новость проекта"}, + ) + create_user_news( + self.manager, + self.manager, + {"text": "Новость пользователя"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + Notification.objects.filter(event_key=event_key).count(), + initial_count, + ) + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.PROGRAM_NEWS_PUBLISHED + ).count(), + initial_count, + ) + + def test_news_and_notifications_roll_back_together(self): + with self.assertRaises(RuntimeError): + with transaction.atomic(): + create_program_news( + self.program, + self.manager, + {"text": "Откатываемая новость"}, + ) + raise RuntimeError("rollback") + + self.assertFalse( + News.objects.get_news(self.program) + .filter(text="Откатываемая новость") + .exists() + ) + self.assertFalse( + Notification.objects.filter( + type=Notification.Type.PROGRAM_NEWS_PUBLISHED + ).exists() + ) + + def test_program_event_uses_one_recipient_query_and_one_bulk_insert(self): + news = News.objects.add_news(self.program, text="Массовая новость") + + with CaptureQueriesContext(connection) as queries: + notify_program_news_published( + news, + program=self.program, + actor=self.manager, + ) + + data_queries = [ + query["sql"].lstrip().upper() + for query in queries.captured_queries + if query["sql"].lstrip().upper().startswith(("SELECT", "INSERT")) + ] + self.assertEqual( + sum(sql.startswith("SELECT") for sql in data_queries), + 1, + ) + self.assertEqual( + sum(sql.startswith("INSERT") for sql in data_queries), + 1, + ) + + +class MaterialFormset: + model = PartnerProgramMaterial + + def __init__(self, program, titles): + self.instance = program + self.titles = titles + self.new_objects = [] + + def save(self): + self.new_objects = [ + PartnerProgramMaterial.objects.create( + program=self.instance, + title=title, + url=f"https://example.com/{index}", + ) + for index, title in enumerate(self.titles, start=1) + ] + return self.new_objects + + +class ProgramMaterialNotificationTests(TestCase): + def setUp(self): + self.actor = create_user(prefix="material-notification-actor") + self.participant = create_user(prefix="material-notification-participant") + self.outsider = create_user(prefix="material-notification-outsider") + self.program = create_partner_program(manager=self.actor) + add_program_member(self.program, self.actor) + add_program_member(self.program, self.participant) + self.request = RequestFactory().post("/admin/partner-program/") + self.request.user = self.actor + self.program_admin = PartnerProgramAdmin(self.program.__class__, admin.site) + self.material_admin = PartnerProgramMaterialAdmin( + PartnerProgramMaterial, + admin.site, + ) + + def save_inline_materials(self, *titles): + formset = MaterialFormset(self.program, titles) + self.program_admin.save_formset( + self.request, + SimpleNamespace(), + formset, + change=True, + ) + return formset.new_objects + + def test_inline_create_notifies_members_and_each_material_has_own_event(self): + materials = self.save_inline_materials("Регламент") + materials.extend(self.save_inline_materials("Шаблон", "Презентация")) + + notifications = Notification.objects.filter( + type=Notification.Type.PROGRAM_MATERIAL_PUBLISHED + ) + self.assertEqual(notifications.count(), 3) + self.assertEqual( + set(notifications.values_list("recipient_id", flat=True)), + {self.participant.pk}, + ) + self.assertFalse(notifications.filter(recipient=self.actor).exists()) + self.assertFalse(notifications.filter(recipient=self.outsider).exists()) + self.assertEqual( + set(notifications.values_list("event_key", flat=True)), + {f"program-material:{material.pk}:published" for material in materials}, + ) + + first = notifications.get( + event_key=f"program-material:{materials[0].pk}:published" + ) + self.assertEqual(first.title, "Новый материал в программе") + self.assertEqual( + first.message, + f"В программе «{self.program.name}» добавлен материал «Регламент».", + ) + self.assertEqual(first.action_url, f"/office/program/{self.program.pk}") + self.assertEqual(first.actor_id, self.actor.pk) + + def test_update_and_delete_do_not_create_notifications(self): + material = self.save_inline_materials("Материал")[0] + initial_count = Notification.objects.count() + + material.title = "Обновлённый материал" + self.material_admin.save_model( + self.request, + material, + SimpleNamespace(), + change=True, + ) + self.material_admin.delete_model(self.request, material) + + self.assertEqual(Notification.objects.count(), initial_count) + + def test_standalone_admin_create_uses_same_event(self): + material = PartnerProgramMaterial( + program=self.program, + title="Отдельный материал", + url="https://example.com/standalone", + ) + + self.material_admin.save_model( + self.request, + material, + SimpleNamespace(), + change=False, + ) + + self.assertTrue( + Notification.objects.filter( + recipient=self.participant, + event_key=f"program-material:{material.pk}:published", + ).exists() + ) + + def test_failed_admin_transaction_leaves_no_material_or_notification(self): + with self.assertRaises(RuntimeError): + with transaction.atomic(): + self.save_inline_materials("Откатываемый материал") + raise RuntimeError("rollback") + + self.assertFalse( + PartnerProgramMaterial.objects.filter(title="Откатываемый материал").exists() + ) + self.assertFalse( + Notification.objects.filter( + type=Notification.Type.PROGRAM_MATERIAL_PUBLISHED + ).exists() + ) + + +class CourseNotificationTests(TestCase): + def setUp(self): + self.actor = create_user(prefix="course-notification-actor") + self.participant = create_user(prefix="course-notification-participant") + self.outsider = create_user(prefix="course-notification-outsider") + self.program = create_partner_program(manager=self.actor) + add_program_member(self.program, self.actor) + add_program_member(self.program, self.participant) + self.request = RequestFactory().post("/admin/courses/course/") + self.request.user = self.actor + self.form = SimpleNamespace(cleaned_data={}) + self.course_admin = CourseAdmin(Course, admin.site) + + def save_course(self, course, *, change): + self.course_admin.save_model( + self.request, + course, + self.form, + change=change, + ) + + def test_create_published_course_notifies_only_program_members(self): + course = Course( + title="Курс программы", + status=CourseContentStatus.PUBLISHED, + partner_program=self.program, + ) + + self.save_course(course, change=False) + + notification = Notification.objects.get( + type=Notification.Type.COURSE_ACCESS_OPENED + ) + self.assertEqual(notification.recipient_id, self.participant.pk) + self.assertEqual(notification.actor_id, self.actor.pk) + self.assertEqual(notification.title, "Открыт доступ к курсу") + self.assertEqual( + notification.message, + f"В программе «{self.program.name}» открыт доступ к курсу «{course.title}».", + ) + self.assertEqual(notification.action_url, f"/office/courses/{course.pk}") + self.assertFalse(Notification.objects.filter(recipient=self.outsider).exists()) + + def test_publish_transition_is_idempotent_and_republish_is_new_event(self): + course = Course.objects.create( + title="Переходы курса", + status=CourseContentStatus.DRAFT, + partner_program=self.program, + ) + + course.status = CourseContentStatus.PUBLISHED + self.save_course(course, change=True) + self.save_course(course, change=True) + first_event_keys = set( + Notification.objects.filter( + type=Notification.Type.COURSE_ACCESS_OPENED + ).values_list("event_key", flat=True) + ) + + course.status = CourseContentStatus.DRAFT + self.save_course(course, change=True) + course.status = CourseContentStatus.PUBLISHED + self.save_course(course, change=True) + notifications = Notification.objects.filter( + type=Notification.Type.COURSE_ACCESS_OPENED + ) + + self.assertEqual(len(first_event_keys), 1) + self.assertEqual(notifications.count(), 2) + self.assertEqual( + notifications.values("event_key").distinct().count(), + 2, + ) + + def test_draft_and_programless_courses_do_not_notify(self): + self.save_course( + Course( + title="Черновик", + status=CourseContentStatus.DRAFT, + partner_program=self.program, + ), + change=False, + ) + self.save_course( + Course( + title="Курс без программы", + status=CourseContentStatus.PUBLISHED, + ), + change=False, + ) + + self.assertFalse( + Notification.objects.filter( + type=Notification.Type.COURSE_ACCESS_OPENED + ).exists() + ) diff --git a/notifications/views.py b/notifications/views.py index 827f811a..875e8085 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -7,24 +7,20 @@ from rest_framework.response import Response from rest_framework.views import APIView -from notifications.models import Notification +from notifications.selectors import get_visible_notifications from notifications.serializers import ( NotificationListQuerySerializer, NotificationSerializer, ) -def _user_notifications(user): - return Notification.objects.filter(recipient=user).select_related("actor") - - class NotificationListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): query_serializer = NotificationListQuerySerializer(data=request.query_params) query_serializer.is_valid(raise_exception=True) - queryset = _user_notifications(request.user) + queryset = get_visible_notifications(request.user).select_related("actor") unread_count = queryset.filter(read_at__isnull=True).count() if query_serializer.validated_data["unread"]: queryset = queryset.filter(read_at__isnull=True) @@ -50,10 +46,9 @@ class NotificationUnreadCountView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - unread_count = Notification.objects.filter( - recipient=request.user, - read_at__isnull=True, - ).count() + unread_count = ( + get_visible_notifications(request.user).filter(read_at__isnull=True).count() + ) return Response({"unread_count": unread_count}) @@ -65,9 +60,8 @@ def post(self, request, notification_id): notification = get_object_or_404( # Блокируем только само уведомление: actor nullable, а PostgreSQL # запрещает FOR UPDATE для nullable-стороны LEFT OUTER JOIN. - Notification.objects.select_for_update(), + get_visible_notifications(request.user).select_for_update(), pk=notification_id, - recipient=request.user, ) if notification.read_at is None: notification.read_at = timezone.now() @@ -80,10 +74,11 @@ class NotificationReadAllView(APIView): @transaction.atomic def post(self, request): - updated = Notification.objects.filter( - recipient=request.user, - read_at__isnull=True, - ).update(read_at=timezone.now()) + updated = ( + get_visible_notifications(request.user) + .filter(read_at__isnull=True) + .update(read_at=timezone.now()) + ) return Response( {"updated": updated, "unread_count": 0}, status=status.HTTP_200_OK, diff --git a/partner_programs/admin.py b/partner_programs/admin.py index ed737396..41809b27 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -6,6 +6,7 @@ from django import forms from django.conf import settings from django.contrib import admin +from django.db import transaction from django.db.models import QuerySet from django.http import HttpRequest from django.urls import path @@ -13,6 +14,7 @@ from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender +from notifications.events import notify_program_material_published from partner_programs.models import ( Application, Evaluation, @@ -583,6 +585,18 @@ def get_fieldsets(self, request, obj=None): fieldset for fieldset in fieldsets if fieldset[0] != "Участие и заявки" ) + @transaction.atomic + def save_formset(self, request, form, formset, change): + super().save_formset(request, form, formset, change) + if formset.model is not PartnerProgramMaterial: + return + for material in formset.new_objects: + notify_program_material_published( + material, + program=formset.instance, + actor=request.user, + ) + def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: qs = ( super() @@ -764,6 +778,16 @@ class PartnerProgramMaterialAdmin(admin.ModelAdmin): readonly_fields = ("datetime_created", "datetime_updated") + @transaction.atomic + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + if not change: + notify_program_material_published( + obj, + program=obj.program, + actor=request.user, + ) + def short_url(self, obj): return obj.url[:60] if obj.url else "—"