diff --git a/docs/notifications-api.md b/docs/notifications-api.md new file mode 100644 index 00000000..56e70bc9 --- /dev/null +++ b/docs/notifications-api.md @@ -0,0 +1,148 @@ +# API внутренних уведомлений + +## Назначение + +Модуль `notifications` хранит адресные внутриприложенные уведомления о значимых +бизнес-событиях. Он не заменяет email, чат или WebSocket и не создаёт рассылки о +лайках и технических изменениях. + +Уведомление создаётся из существующего доменного service/view внутри той же +транзакции, что и бизнес-изменение. Django signals намеренно не используются: +получатели и смысл события определяются в явной точке перехода состояния. + +## Модель + +`Notification` содержит получателя, nullable-инициатора, тип, категорию, +исторический снимок заголовка и текста, внутренний `action_url`, `event_key`, +`read_at` и время создания. + +- удаление получателя каскадно удаляет его уведомления; +- удаление инициатора сохраняет уведомление с `actor=null`; +- `UniqueConstraint(recipient, event_key)` защищает от повторов и гонок; +- для переходов Application и Submission ключ включает сохранённый момент + перехода: `submitted_at` для отправки и `updated_at` для смены статуса; +- повторный вызов для одного сохранённого перехода остаётся идемпотентным, а + новый цикл в тот же статус получает отдельный `event_key`; +- индексы `(recipient, -created_at)` и + `(recipient, read_at, -created_at)` обслуживают историю и непрочитанные; +- `action_url` может быть только относительным маршрутом `/office/`, созданным + backend-кодом; +- actor в API содержит только `id`, `first_name`, `last_name`, `avatar`. + +Email, телефон, дата рождения, права администратора и другие служебные данные в +модели и публичном serializer отсутствуют. + +## REST API + +Все endpoints требуют авторизации и работают только с уведомлениями текущего +пользователя. + +### Список + +```http +GET /notifications/?limit=20&offset=0 +GET /notifications/?limit=20&offset=0&unread=true +``` + +`limit` принимает значения `1..100`, `offset` — неотрицательное число. Сначала +возвращаются новые записи, при одинаковом времени — запись с большим `id`. +`unread_count` всегда считает все непрочитанные уведомления пользователя, даже +когда текущая страница отфильтрована или ограничена. + +```json +{ + "count": 1, + "unread_count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 125, + "type": "vacancy_response_created", + "category": "vacancy", + "title": "Новый отклик на вакансию", + "message": "Получен отклик на вакансию «Frontend-разработчик».", + "action_url": "/office/projects/7/vacancies/21/responses", + "read_at": null, + "created_at": "2026-08-15T12:00:00Z", + "actor": { + "id": 15, + "first_name": "Иван", + "last_name": "Петров", + "avatar": null + } + } + ] +} +``` + +### Счётчик + +```http +GET /notifications/unread-count/ +``` + +```json +{"unread_count": 4} +``` + +### Отметка о прочтении + +```http +POST /notifications//read/ +POST /notifications/read-all/ +``` + +Первый endpoint возвращает обновлённое уведомление. Повторный вызов сохраняет +первоначальный `read_at`; чужой или неизвестный `id` скрывается через `404`. +`read-all` обновляет только непрочитанные записи текущего пользователя: + +```json +{"updated": 4, "unread_count": 0} +``` + +## Типы и переходы + +| Тип | Получатель | `action_url` | +| --- | --- | --- | +| `project_invite_created` | приглашённый | `/office/projects/invites` | +| `project_invite_accepted` | руководитель проекта | `/office/projects//edit?section=team` | +| `project_invite_declined` | руководитель проекта | `/office/projects//edit?section=team` | +| `project_invite_revoked` | приглашённый | `/office/projects/invites` | +| `vacancy_response_created` | руководитель проекта | `/office/projects//vacancies//responses` | +| `vacancy_response_accepted` | кандидат | `/office/vacancies/my` | +| `vacancy_response_declined` | кандидат | `/office/vacancies/my` | +| `team_invite_created` | приглашённый | `/office/team-invites` | +| `team_invite_accepted` | капитан | `/office/applications//team` | +| `team_invite_declined` | капитан | `/office/applications//team` | +| `team_invite_revoked` | приглашённый | `/office/team-invites` | +| `application_submitted` | менеджеры программы | `/office/program/` | +| `application_status_changed` | владелец заявки | `/office/program/` | +| `submission_submitted` | менеджеры программы | `/office/program/` | +| `submission_status_changed` | владелец и принятые участники | `/office/program//submission` | +| `expert_assignment_created` | эксперт | `/office/expert/submissions` | +| `expert_assignment_revoked` | эксперт | `/office/expert/submissions` | +| `evaluation_submitted` | менеджеры программы | `/office/analytics?programId=` | +| `news_comment_created` | владелец источника публикации | `/office/news/` | + +При принятии одного отклика остальные ожидающие отклики отклоняются в той же +транзакции, и каждый кандидат получает отдельное уведомление. Для нескольких +менеджеров и участников применяется `bulk_create`. Инициатор исключается из +получателей, а повторный retry использует тот же `event_key`. + +## Текущее покрытие lifecycle + +В текущем API существуют переходы submit/withdraw для Application, +submit/cancel для Submission, а также приглашения, отклики, назначения, +отправка Evaluation и комментарии. Отдельных публичных операций approve/reject +Application и returned/final Submission на момент DEV-094 нет. Типы +`application_status_changed` и `submission_status_changed` уже закреплены в +контракте и подключаются к фактически существующим переходам; новые lifecycle +endpoints ради уведомлений не добавлялись. + +## Границы этапа + +Не реализованы WebSocket, push, email/Telegram-рассылки, уведомления о лайках и +чатах, пользовательские настройки, удаление уведомлений и ручные массовые +рассылки. Frontend-центр уведомлений реализуется отдельным этапом после фиксации +и развёртывания backend-контракта. diff --git a/feed/news_views.py b/feed/news_views.py index 9c2497f4..4b6d7ef3 100644 --- a/feed/news_views.py +++ b/feed/news_views.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from notifications.events import notify_news_comment_created from core.models import Like, View from core.services import add_view, set_like from feed.news_pagination import NewsCommentPagination, ReactNewsFeedPagination @@ -116,6 +117,7 @@ def post(self, request: Request, *args, **kwargs) -> Response: author=request.user, text=serializer.validated_data["text"], ) + notify_news_comment_created(comment) return Response( NewsCommentResponseSerializer( comment, diff --git a/feed/tests/test_react_news_comments_api.py b/feed/tests/test_react_news_comments_api.py index fe0fdbc8..5fb04615 100644 --- a/feed/tests/test_react_news_comments_api.py +++ b/feed/tests/test_react_news_comments_api.py @@ -2,6 +2,7 @@ from rest_framework.test import APIClient from news.models import News, NewsComment +from notifications.models import Notification from news.tests.helpers import ( create_news_for, create_partner_program, @@ -27,6 +28,7 @@ def detail_url(self, comment: NewsComment, news: News | None = None) -> str: return f"/feed/news/{(news or self.news).pk}/comments/{comment.pk}/" def test_user_can_create_and_read_comments_oldest_first(self): + self.program.managers.add(self.other_user) first_response = self.client.post( self.list_url, {"text": " First comment "}, @@ -50,6 +52,13 @@ def test_user_can_create_and_read_comments_oldest_first(self): [item["id"] for item in list_response.data["results"]], [first_response.data["id"], second_response.data["id"]], ) + self.assertEqual( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.NEWS_COMMENT_CREATED, + ).count(), + 2, + ) def test_author_can_edit_comment_and_user_input_is_preserved(self): comment = NewsComment.objects.create( diff --git a/invites/tests/test_project_workspace_invitation_api.py b/invites/tests/test_project_workspace_invitation_api.py index c967d968..38e944ff 100644 --- a/invites/tests/test_project_workspace_invitation_api.py +++ b/invites/tests/test_project_workspace_invitation_api.py @@ -16,6 +16,7 @@ link_project_to_program, ) from projects.models import Collaborator +from notifications.models import Notification class ProjectWorkspaceInvitationAPITests(TestCase): @@ -119,6 +120,12 @@ def test_leader_creates_pending_invitation_with_safe_response(self): self.assertNotIn("email", response.data["recipient"]) self.assertEqual(response.data["message"], "Присоединяйтесь к проекту") self.assertIsNone(response.data["processed_at"]) + self.assertTrue( + Notification.objects.filter( + recipient=self.recipient, + type=Notification.Type.PROJECT_INVITE_CREATED, + ).exists() + ) def test_only_leader_or_staff_can_create_invitation(self): for actor, expected_status in ( @@ -292,6 +299,12 @@ def test_recipient_accepts_invitation_atomically_and_only_once(self): self.assertEqual(collaborator.specialization, invitation.specialization) self.assertEqual(response.data["status"], Invite.STATUS_ACCEPTED) self.assertIsNotNone(response.data["processed_at"]) + self.assertTrue( + Notification.objects.filter( + recipient=self.leader, + type=Notification.Type.PROJECT_INVITE_ACCEPTED, + ).exists() + ) repeated = self.client.post(self.accept_url(invitation), {}, format="json") self.assertEqual(repeated.status_code, status.HTTP_409_CONFLICT) @@ -351,6 +364,12 @@ def test_recipient_declines_once_and_declined_or_revoked_cannot_be_accepted(self decline_response = self.client.post(self.decline_url(declined), {}, format="json") self.assertEqual(decline_response.status_code, status.HTTP_200_OK) self.assertEqual(decline_response.data["status"], Invite.STATUS_DECLINED) + self.assertTrue( + Notification.objects.filter( + recipient=self.leader, + type=Notification.Type.PROJECT_INVITE_DECLINED, + ).exists() + ) repeated_decline = self.client.post(self.decline_url(declined), {}, format="json") self.assertEqual(repeated_decline.status_code, status.HTTP_409_CONFLICT) declined_accept = self.client.post(self.accept_url(declined), {}, format="json") @@ -374,6 +393,12 @@ def test_leader_revokes_pending_invitation_without_deleting_history(self): self.assertEqual(invitation.status, Invite.STATUS_REVOKED) self.assertIsNotNone(invitation.resolved_at) self.assertTrue(Invite.objects.filter(pk=invitation.pk).exists()) + self.assertTrue( + Notification.objects.filter( + recipient=self.recipient, + type=Notification.Type.PROJECT_INVITE_REVOKED, + ).exists() + ) repeated = self.client.post(self.revoke_url(invitation), {}, format="json") self.assertEqual(repeated.status_code, status.HTTP_409_CONFLICT) diff --git a/invites/workspace_services.py b/invites/workspace_services.py index dc3b9ce3..043faf96 100644 --- a/invites/workspace_services.py +++ b/invites/workspace_services.py @@ -3,6 +3,10 @@ from django.utils import timezone from invites.models import Invite +from notifications.events import ( + notify_project_invite_created, + notify_project_invite_resolved, +) from partner_programs.models import PartnerProgramUserProfile from projects.models import Collaborator, Project @@ -138,6 +142,7 @@ def create_project_invitation( if pending.exists(): raise ProjectInvitationDuplicateError() from exc raise + notify_project_invite_created(invitation) return invitation @@ -160,6 +165,7 @@ def accept_project_invitation(*, invitation_id: int, actor: User) -> Invite: invitation.is_accepted = True invitation.resolved_at = timezone.now() invitation.save(update_fields=["is_accepted", "resolved_at", "datetime_updated"]) + notify_project_invite_resolved(invitation, actor=actor, status="accepted") return invitation @@ -173,6 +179,7 @@ def decline_project_invitation(*, invitation_id: int, actor: User) -> Invite: invitation.is_accepted = False invitation.resolved_at = timezone.now() invitation.save(update_fields=["is_accepted", "resolved_at", "datetime_updated"]) + notify_project_invite_resolved(invitation, actor=actor, status="declined") return invitation @@ -186,4 +193,5 @@ def revoke_project_invitation(*, invitation_id: int, actor: User) -> Invite: invitation.is_revoked = True invitation.resolved_at = timezone.now() invitation.save(update_fields=["is_revoked", "resolved_at", "datetime_updated"]) + notify_project_invite_resolved(invitation, actor=actor, status="revoked") return invitation diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 00000000..b6483bf0 --- /dev/null +++ b/notifications/__init__.py @@ -0,0 +1 @@ +"""Внутренние уведомления PROCOLLAB.""" diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 00000000..fe3ab7fc --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" + verbose_name = "Уведомления" diff --git a/notifications/events.py b/notifications/events.py new file mode 100644 index 00000000..fb761c87 --- /dev/null +++ b/notifications/events.py @@ -0,0 +1,309 @@ +from notifications.models import Notification +from notifications.services import create_notification, create_notifications + + +def _event_key(*parts) -> str: + """Собирает стабильный ключ события без пользовательских данных.""" + return ":".join(str(part) for part in parts) + + +def _user_name(user) -> str: + """Возвращает безопасное отображаемое имя без email и служебных полей.""" + name = user.get_full_name().strip() + return name or "Пользователь" + + +def notify_project_invite_created(invite) -> None: + """Уведомляет пользователя о новом приглашении в проект.""" + create_notification( + recipient_id=invite.user_id, + actor_id=invite.invited_by_id, + notification_type=Notification.Type.PROJECT_INVITE_CREATED, + title="Приглашение в проект", + message=f"Вас пригласили в проект «{invite.project.name}».", + action_url="/office/projects/invites", + event_key=_event_key("project-invite", invite.pk, "created"), + ) + + +def notify_project_invite_resolved(invite, *, actor, status: str) -> None: + """Уведомляет нужную сторону о принятии, отклонении или отзыве приглашения.""" + config = { + "accepted": ( + invite.project.leader_id, + Notification.Type.PROJECT_INVITE_ACCEPTED, + "Приглашение принято", + f"{_user_name(actor)} принял приглашение в проект «{invite.project.name}».", + f"/office/projects/{invite.project_id}/edit?section=team", + ), + "declined": ( + invite.project.leader_id, + Notification.Type.PROJECT_INVITE_DECLINED, + "Приглашение отклонено", + f"{_user_name(actor)} отклонил приглашение в проект «{invite.project.name}».", + f"/office/projects/{invite.project_id}/edit?section=team", + ), + "revoked": ( + invite.user_id, + Notification.Type.PROJECT_INVITE_REVOKED, + "Приглашение отозвано", + f"Приглашение в проект «{invite.project.name}» было отозвано.", + "/office/projects/invites", + ), + } + recipient_id, notification_type, title, message, action_url = config[status] + create_notification( + recipient_id=recipient_id, + actor_id=actor.pk, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + event_key=_event_key("project-invite", invite.pk, status), + ) + + +def notify_vacancy_response_created(response) -> None: + """Уведомляет руководителя проекта о новом отклике на вакансию.""" + vacancy = response.vacancy + create_notification( + recipient_id=vacancy.project.leader_id, + actor_id=response.user_id, + notification_type=Notification.Type.VACANCY_RESPONSE_CREATED, + title="Новый отклик на вакансию", + message=f"Получен отклик на вакансию «{vacancy.role}».", + action_url=( + f"/office/projects/{vacancy.project_id}/vacancies/" f"{vacancy.pk}/responses" + ), + event_key=_event_key("vacancy-response", response.pk, "created"), + ) + + +def notify_vacancy_response_resolved(response, *, actor, accepted: bool) -> None: + """Уведомляет кандидата о принятии либо отклонении его отклика.""" + notification_type = ( + Notification.Type.VACANCY_RESPONSE_ACCEPTED + if accepted + else Notification.Type.VACANCY_RESPONSE_DECLINED + ) + decision = "принят" if accepted else "отклонён" + create_notification( + recipient_id=response.user_id, + actor_id=actor.pk, + notification_type=notification_type, + title=f"Отклик {decision}", + message=f"Ваш отклик на вакансию «{response.vacancy.role}» {decision}.", + action_url="/office/vacancies/my", + event_key=_event_key( + "vacancy-response", + response.pk, + "accepted" if accepted else "declined", + ), + ) + + +def notify_team_invite_created(invite) -> None: + """Уведомляет пользователя о новом приглашении в команду заявки.""" + create_notification( + recipient_id=invite.user_id, + actor_id=invite.invited_by_id, + notification_type=Notification.Type.TEAM_INVITE_CREATED, + title="Приглашение в команду", + message=f"Вас пригласили в команду «{invite.team.name or 'Без названия'}».", + action_url="/office/team-invites", + event_key=_event_key("team-invite", invite.pk, "created"), + ) + + +def notify_team_invite_resolved(invite, *, actor, status: str) -> None: + """Уведомляет капитана либо приглашённого о завершении приглашения.""" + application_id = invite.team.application_id + config = { + "accepted": ( + invite.team.captain_id, + Notification.Type.TEAM_INVITE_ACCEPTED, + "Приглашение принято", + f"{_user_name(actor)} присоединился к команде.", + f"/office/applications/{application_id}/team", + ), + "declined": ( + invite.team.captain_id, + Notification.Type.TEAM_INVITE_DECLINED, + "Приглашение отклонено", + f"{_user_name(actor)} отклонил приглашение в команду.", + f"/office/applications/{application_id}/team", + ), + "revoked": ( + invite.user_id, + Notification.Type.TEAM_INVITE_REVOKED, + "Приглашение отозвано", + "Приглашение в команду было отозвано.", + "/office/team-invites", + ), + } + recipient_id, notification_type, title, message, action_url = config[status] + create_notification( + recipient_id=recipient_id, + actor_id=actor.pk, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + event_key=_event_key("team-invite", invite.pk, status), + ) + + +def notify_application_submitted(application, *, actor) -> None: + """Уведомляет всех менеджеров программы об отправленной заявке.""" + create_notifications( + recipient_ids=application.program.managers.values_list("pk", flat=True), + actor_id=actor.pk, + notification_type=Notification.Type.APPLICATION_SUBMITTED, + title="Новая заявка", + message=f"Отправлена заявка в программу «{application.program.name}».", + action_url=f"/office/program/{application.program_id}", + event_key=_event_key( + "application", + application.pk, + "submitted", + application.submitted_at.isoformat(), + ), + ) + + +def notify_application_status_changed(application, *, actor) -> None: + """Уведомляет владельца заявки о подтверждённом изменении статуса.""" + if application.user_id is None: + return + create_notification( + recipient_id=application.user_id, + actor_id=actor.pk, + notification_type=Notification.Type.APPLICATION_STATUS_CHANGED, + title="Статус заявки изменён", + message=f"Новый статус заявки: {application.get_status_display()}.", + action_url=f"/office/program/{application.program_id}", + event_key=_event_key( + "application", + application.pk, + "status", + application.status, + application.updated_at.isoformat(), + ), + ) + + +def notify_submission_submitted(submission, *, actor) -> None: + """Уведомляет менеджеров программы об отправленном решении.""" + create_notifications( + recipient_ids=submission.program.managers.values_list("pk", flat=True), + actor_id=actor.pk, + notification_type=Notification.Type.SUBMISSION_SUBMITTED, + title="Новое решение", + message=f"В программу «{submission.program.name}» отправлено решение.", + action_url=f"/office/program/{submission.program_id}", + event_key=_event_key( + "submission", + submission.pk, + "submitted", + submission.submitted_at.isoformat(), + ), + ) + + +def notify_submission_status_changed(submission, *, actor) -> None: + """Уведомляет владельца заявки и принятых участников её команды.""" + from partner_programs.models import TeamMember + + recipient_ids = [submission.application.user_id] + recipient_ids.extend( + TeamMember.objects.filter( + team__application_id=submission.application_id, + status=TeamMember.STATUS_ACCEPTED, + ).values_list("user_id", flat=True) + ) + create_notifications( + recipient_ids=recipient_ids, + actor_id=actor.pk, + notification_type=Notification.Type.SUBMISSION_STATUS_CHANGED, + title="Статус решения изменён", + message=f"Новый статус решения: {submission.get_status_display()}.", + action_url=f"/office/program/{submission.program_id}/submission", + event_key=_event_key( + "submission", + submission.pk, + "status", + submission.status, + submission.updated_at.isoformat(), + ), + ) + + +def notify_expert_assignment_created(assignment) -> None: + """Уведомляет эксперта о назначенной работе.""" + create_notification( + recipient_id=assignment.expert.user_id, + actor_id=assignment.assigned_by_id, + notification_type=Notification.Type.EXPERT_ASSIGNMENT_CREATED, + title="Назначена экспертиза", + message="Вам назначено решение для оценки.", + action_url="/office/expert/submissions", + event_key=_event_key("expert-assignment", assignment.pk, "created"), + ) + + +def notify_expert_assignment_revoked(assignment, *, actor) -> None: + """Уведомляет эксперта об отзыве назначения.""" + create_notification( + recipient_id=assignment.expert.user_id, + actor_id=actor.pk, + notification_type=Notification.Type.EXPERT_ASSIGNMENT_REVOKED, + title="Назначение отозвано", + message="Назначение на оценивание решения было отозвано.", + action_url="/office/expert/submissions", + event_key=_event_key("expert-assignment", assignment.pk, "revoked"), + ) + + +def notify_evaluation_submitted(evaluation, *, actor) -> None: + """Уведомляет менеджеров программы о финально отправленной оценке.""" + submission = evaluation.submission + create_notifications( + recipient_ids=submission.program.managers.values_list("pk", flat=True), + actor_id=actor.pk, + notification_type=Notification.Type.EVALUATION_SUBMITTED, + title="Оценка отправлена", + message=f"Эксперт отправил оценку решения «{submission.title}».", + action_url=f"/office/analytics?programId={submission.program_id}", + event_key=_event_key("evaluation", evaluation.pk, "submitted"), + ) + + +def notify_news_comment_created(comment) -> None: + """Уведомляет владельцев источника новости о новом комментарии.""" + news = comment.news + model = news.content_type.model + if model == "customuser": + recipient_ids = [news.object_id] + elif model == "project": + from projects.models import Project + + recipient_ids = Project.objects.filter(pk=news.object_id).values_list( + "leader_id", flat=True + ) + elif model == "partnerprogram": + from partner_programs.models import PartnerProgram + + recipient_ids = PartnerProgram.objects.filter(pk=news.object_id).values_list( + "managers__id", flat=True + ) + else: + return + create_notifications( + recipient_ids=recipient_ids, + actor_id=comment.author_id, + notification_type=Notification.Type.NEWS_COMMENT_CREATED, + title="Новый комментарий", + message="К вашей публикации добавлен комментарий.", + action_url=f"/office/news/{news.pk}", + event_key=_event_key("news-comment", comment.pk, "created"), + ) diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..1d9cb1d5 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.11 on 2026-08-15 13:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('project_invite_created', 'Приглашение в проект'), ('project_invite_accepted', 'Приглашение в проект принято'), ('project_invite_declined', 'Приглашение в проект отклонено'), ('project_invite_revoked', 'Приглашение в проект отозвано'), ('vacancy_response_created', 'Новый отклик на вакансию'), ('vacancy_response_accepted', 'Отклик принят'), ('vacancy_response_declined', 'Отклик отклонён'), ('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)), + ('category', models.CharField(choices=[('project', 'Проекты'), ('vacancy', 'Вакансии'), ('program', 'Программы'), ('expert', 'Экспертиза'), ('news', 'Новости'), ('system', 'Система')], max_length=16)), + ('title', models.CharField(max_length=160)), + ('message', models.TextField()), + ('action_url', models.CharField(blank=True, max_length=500, null=True)), + ('event_key', models.CharField(max_length=255)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='acted_notifications', to=settings.AUTH_USER_MODEL)), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['recipient', '-created_at'], name='notif_rec_created_idx'), models.Index(fields=['recipient', 'read_at', '-created_at'], name='notif_rec_read_created_idx')], + }, + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.UniqueConstraint(fields=('recipient', 'event_key'), name='uniq_notification_recipient_event'), + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 00000000..1be9ba7d --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.db import models + + +class Notification(models.Model): + class Type(models.TextChoices): + PROJECT_INVITE_CREATED = "project_invite_created", "Приглашение в проект" + PROJECT_INVITE_ACCEPTED = ( + "project_invite_accepted", + "Приглашение в проект принято", + ) + PROJECT_INVITE_DECLINED = ( + "project_invite_declined", + "Приглашение в проект отклонено", + ) + PROJECT_INVITE_REVOKED = "project_invite_revoked", "Приглашение в проект отозвано" + VACANCY_RESPONSE_CREATED = "vacancy_response_created", "Новый отклик на вакансию" + VACANCY_RESPONSE_ACCEPTED = "vacancy_response_accepted", "Отклик принят" + VACANCY_RESPONSE_DECLINED = "vacancy_response_declined", "Отклик отклонён" + TEAM_INVITE_CREATED = "team_invite_created", "Приглашение в команду" + TEAM_INVITE_ACCEPTED = "team_invite_accepted", "Приглашение в команду принято" + TEAM_INVITE_DECLINED = "team_invite_declined", "Приглашение в команду отклонено" + TEAM_INVITE_REVOKED = "team_invite_revoked", "Приглашение в команду отозвано" + APPLICATION_SUBMITTED = "application_submitted", "Заявка отправлена" + APPLICATION_STATUS_CHANGED = "application_status_changed", "Статус заявки изменён" + SUBMISSION_SUBMITTED = "submission_submitted", "Решение отправлено" + SUBMISSION_STATUS_CHANGED = "submission_status_changed", "Статус решения изменён" + EXPERT_ASSIGNMENT_CREATED = "expert_assignment_created", "Назначена экспертиза" + EXPERT_ASSIGNMENT_REVOKED = "expert_assignment_revoked", "Экспертиза отозвана" + EVALUATION_SUBMITTED = "evaluation_submitted", "Оценка отправлена" + NEWS_COMMENT_CREATED = "news_comment_created", "Новый комментарий" + + class Category(models.TextChoices): + PROJECT = "project", "Проекты" + VACANCY = "vacancy", "Вакансии" + PROGRAM = "program", "Программы" + EXPERT = "expert", "Экспертиза" + NEWS = "news", "Новости" + SYSTEM = "system", "Система" + + TYPE_CATEGORY = { + Type.PROJECT_INVITE_CREATED: Category.PROJECT, + Type.PROJECT_INVITE_ACCEPTED: Category.PROJECT, + Type.PROJECT_INVITE_DECLINED: Category.PROJECT, + Type.PROJECT_INVITE_REVOKED: Category.PROJECT, + Type.VACANCY_RESPONSE_CREATED: Category.VACANCY, + Type.VACANCY_RESPONSE_ACCEPTED: Category.VACANCY, + Type.VACANCY_RESPONSE_DECLINED: Category.VACANCY, + Type.TEAM_INVITE_CREATED: Category.PROGRAM, + Type.TEAM_INVITE_ACCEPTED: Category.PROGRAM, + Type.TEAM_INVITE_DECLINED: Category.PROGRAM, + Type.TEAM_INVITE_REVOKED: Category.PROGRAM, + Type.APPLICATION_SUBMITTED: Category.PROGRAM, + Type.APPLICATION_STATUS_CHANGED: Category.PROGRAM, + Type.SUBMISSION_SUBMITTED: Category.PROGRAM, + Type.SUBMISSION_STATUS_CHANGED: Category.PROGRAM, + Type.EXPERT_ASSIGNMENT_CREATED: Category.EXPERT, + Type.EXPERT_ASSIGNMENT_REVOKED: Category.EXPERT, + Type.EVALUATION_SUBMITTED: Category.EXPERT, + Type.NEWS_COMMENT_CREATED: Category.NEWS, + } + + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="received_notifications", + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="acted_notifications", + null=True, + blank=True, + ) + type = models.CharField(max_length=64, choices=Type.choices) + category = models.CharField(max_length=16, choices=Category.choices) + title = models.CharField(max_length=160) + message = models.TextField() + 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) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["recipient", "event_key"], + name="uniq_notification_recipient_event", + ) + ] + indexes = [ + models.Index( + fields=["recipient", "-created_at"], + name="notif_rec_created_idx", + ), + models.Index( + fields=["recipient", "read_at", "-created_at"], + name="notif_rec_read_created_idx", + ), + ] + + def __str__(self): + return f"Notification<{self.pk}> recipient={self.recipient_id} type={self.type}" diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 00000000..76fe2b85 --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from notifications.models import Notification +from users.models import CustomUser + + +class NotificationActorSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ["id", "first_name", "last_name", "avatar"] + read_only_fields = fields + + +class NotificationSerializer(serializers.ModelSerializer): + actor = NotificationActorSerializer(read_only=True) + + class Meta: + model = Notification + fields = [ + "id", + "type", + "category", + "title", + "message", + "action_url", + "read_at", + "created_at", + "actor", + ] + read_only_fields = fields + + +class NotificationListQuerySerializer(serializers.Serializer): + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + unread = serializers.BooleanField(default=False) diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 00000000..eea9111d --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,113 @@ +from collections.abc import Iterable +from urllib.parse import urlsplit + +from django.db import transaction + +from notifications.models import Notification + + +def _validate_action_url(action_url: str | None) -> str | None: + """Разрешает только внутренние маршруты office, сформированные backend-кодом.""" + if action_url is None: + return None + parsed = urlsplit(action_url) + if ( + parsed.scheme + or parsed.netloc + or parsed.fragment + or not parsed.path.startswith("/office/") + or parsed.path.startswith("//") + or "\\" in action_url + ): + raise ValueError("action_url должен быть относительным маршрутом /office/.") + return action_url + + +def _notification_defaults( + *, + actor_id: int | None, + notification_type: str, + title: str, + message: str, + action_url: str | None, +) -> dict: + try: + category = Notification.TYPE_CATEGORY[notification_type] + except KeyError as exc: + raise ValueError("Неизвестный тип уведомления.") from exc + return { + "actor_id": actor_id, + "type": notification_type, + "category": category, + "title": title, + "message": message, + "action_url": _validate_action_url(action_url), + } + + +@transaction.atomic +def create_notification( + *, + recipient_id: int, + actor_id: int | None, + notification_type: str, + title: str, + message: str, + action_url: str | None, + event_key: str, +) -> Notification | None: + """Создаёт одно идемпотентное уведомление внутри транзакции события.""" + if actor_id is not None and actor_id == recipient_id: + return None + notification, _created = Notification.objects.get_or_create( + recipient_id=recipient_id, + event_key=event_key, + defaults=_notification_defaults( + actor_id=actor_id, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + ), + ) + return notification + + +@transaction.atomic +def create_notifications( + *, + recipient_ids: Iterable[int | None], + actor_id: int | None, + notification_type: str, + title: str, + message: str, + action_url: str | None, + event_key: str, +) -> list[Notification]: + """Создаёт уведомления нескольким уникальным получателям одним INSERT.""" + recipients = sorted( + { + recipient_id + for recipient_id in recipient_ids + if recipient_id is not None and recipient_id != actor_id + } + ) + if not recipients: + return [] + defaults = _notification_defaults( + actor_id=actor_id, + notification_type=notification_type, + title=title, + message=message, + action_url=action_url, + ) + notifications = [ + Notification( + recipient_id=recipient_id, + event_key=event_key, + **defaults, + ) + for recipient_id in recipients + ] + # UniqueConstraint остаётся окончательной защитой от retry и гонок. + return Notification.objects.bulk_create(notifications, ignore_conflicts=True) diff --git a/notifications/tests/__init__.py b/notifications/tests/__init__.py new file mode 100644 index 00000000..ad533d8a --- /dev/null +++ b/notifications/tests/__init__.py @@ -0,0 +1 @@ +"""Тесты API и доменных событий уведомлений.""" diff --git a/notifications/tests/test_notification_events.py b/notifications/tests/test_notification_events.py new file mode 100644 index 00000000..fd48d2cb --- /dev/null +++ b/notifications/tests/test_notification_events.py @@ -0,0 +1,164 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from news.tests.helpers import create_partner_program, create_user +from notifications.events import ( + notify_application_status_changed, + notify_application_submitted, + notify_submission_status_changed, + notify_submission_submitted, +) +from notifications.models import Notification +from partner_programs.models import Application, Submission, Team, TeamMember + + +class NotificationEventTests(TestCase): + def test_application_transition_key_distinguishes_repeated_status_cycles(self): + owner = create_user(prefix="application-transition-owner") + manager = create_user(prefix="application-transition-manager") + program = create_partner_program(manager=manager) + first_submitted_at = timezone.now() + application = Application.objects.create( + program=program, + user=owner, + created_by=owner, + status=Application.STATUS_SUBMITTED, + submitted_at=first_submitted_at, + ) + + notify_application_submitted(application, actor=owner) + notify_application_submitted(application, actor=owner) + application.submitted_at = first_submitted_at + timedelta(seconds=1) + application.save(update_fields=["submitted_at", "updated_at"]) + notify_application_submitted(application, actor=owner) + + application.status = Application.STATUS_WITHDRAWN + application.save(update_fields=["status", "updated_at"]) + notify_application_status_changed(application, actor=manager) + notify_application_status_changed(application, actor=manager) + application.status = Application.STATUS_DRAFT + application.save(update_fields=["status", "updated_at"]) + application.status = Application.STATUS_WITHDRAWN + application.save(update_fields=["status", "updated_at"]) + notify_application_status_changed(application, actor=manager) + + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.APPLICATION_SUBMITTED + ).count(), + 2, + ) + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.APPLICATION_STATUS_CHANGED + ).count(), + 2, + ) + + def test_submission_transition_key_distinguishes_repeated_status_cycles(self): + owner = create_user(prefix="submission-transition-owner") + manager = create_user(prefix="submission-transition-manager") + program = create_partner_program(manager=manager) + application = Application.objects.create( + program=program, + user=owner, + created_by=owner, + status=Application.STATUS_SUBMITTED, + ) + first_submitted_at = timezone.now() + submission = Submission.objects.create( + application=application, + program=program, + submitted_by=owner, + title="Решение", + status=Submission.STATUS_SUBMITTED, + submitted_at=first_submitted_at, + ) + + notify_submission_submitted(submission, actor=owner) + notify_submission_submitted(submission, actor=owner) + submission.submitted_at = first_submitted_at + timedelta(seconds=1) + submission.save(update_fields=["submitted_at", "updated_at"]) + notify_submission_submitted(submission, actor=owner) + + submission.status = Submission.STATUS_RETURNED + submission.save(update_fields=["status", "updated_at"]) + notify_submission_status_changed(submission, actor=manager) + notify_submission_status_changed(submission, actor=manager) + submission.status = Submission.STATUS_SUBMITTED + submission.save(update_fields=["status", "updated_at"]) + submission.status = Submission.STATUS_RETURNED + submission.save(update_fields=["status", "updated_at"]) + notify_submission_status_changed(submission, actor=manager) + + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.SUBMISSION_SUBMITTED + ).count(), + 2, + ) + self.assertEqual( + Notification.objects.filter( + type=Notification.Type.SUBMISSION_STATUS_CHANGED + ).count(), + 2, + ) + + def test_submission_status_notifies_owner_and_only_accepted_team_members(self): + owner = create_user(prefix="notification-owner") + accepted = create_user(prefix="notification-accepted") + invited = create_user(prefix="notification-invited") + removed = create_user(prefix="notification-removed") + actor = create_user(prefix="notification-manager") + program = create_partner_program(manager=actor) + application = Application.objects.create( + program=program, + user=owner, + created_by=owner, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + status=Application.STATUS_SUBMITTED, + ) + team = Team.objects.create( + application=application, + name="Команда", + captain=owner, + ) + for user, role, status in ( + (owner, TeamMember.ROLE_CAPTAIN, TeamMember.STATUS_ACCEPTED), + (accepted, TeamMember.ROLE_MEMBER, TeamMember.STATUS_ACCEPTED), + (invited, TeamMember.ROLE_MEMBER, TeamMember.STATUS_INVITED), + (removed, TeamMember.ROLE_MEMBER, TeamMember.STATUS_REMOVED), + ): + TeamMember.objects.create( + team=team, + user=user, + role=role, + status=status, + invited_by=owner, + ) + submission = Submission.objects.create( + application=application, + program=program, + submitted_by=owner, + title="Решение", + status=Submission.STATUS_RETURNED, + ) + + notify_submission_status_changed(submission, actor=actor) + + notifications = Notification.objects.filter( + type=Notification.Type.SUBMISSION_STATUS_CHANGED + ) + self.assertEqual( + set(notifications.values_list("recipient_id", flat=True)), + {owner.pk, accepted.pk}, + ) + self.assertFalse(notifications.filter(recipient__in=[invited, removed]).exists()) + self.assertTrue( + all( + item.action_url == f"/office/program/{program.pk}/submission" + for item in notifications + ) + ) diff --git a/notifications/tests/test_notification_services.py b/notifications/tests/test_notification_services.py new file mode 100644 index 00000000..9033ade1 --- /dev/null +++ b/notifications/tests/test_notification_services.py @@ -0,0 +1,124 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from news.tests.helpers import create_user +from notifications.models import Notification +from notifications.services import create_notification, create_notifications + + +class NotificationServiceTests(TestCase): + def setUp(self): + self.recipient = create_user(prefix="service-recipient") + self.actor = create_user(prefix="service-actor") + + def create(self, **overrides): + params = { + "recipient_id": self.recipient.pk, + "actor_id": self.actor.pk, + "notification_type": Notification.Type.PROJECT_INVITE_CREATED, + "title": "Приглашение", + "message": "Вас пригласили в проект.", + "action_url": "/office/projects/invites", + "event_key": "project-invite:1:created", + } + params.update(overrides) + return create_notification(**params) + + def test_duplicate_event_is_idempotent(self): + first = self.create() + second = self.create(title="Повтор не должен изменить снимок") + + self.assertEqual(first.pk, second.pk) + self.assertEqual(Notification.objects.count(), 1) + self.assertEqual(second.title, "Приглашение") + + def test_self_notification_is_skipped(self): + result = self.create(actor_id=self.recipient.pk) + + self.assertIsNone(result) + self.assertFalse(Notification.objects.exists()) + + def test_bulk_service_deduplicates_recipients_and_skips_actor(self): + second = create_user(prefix="service-second") + + created = create_notifications( + recipient_ids=[ + self.recipient.pk, + self.recipient.pk, + self.actor.pk, + second.pk, + ], + actor_id=self.actor.pk, + notification_type=Notification.Type.APPLICATION_SUBMITTED, + title="Заявка", + message="Отправлена новая заявка.", + action_url="/office/program/1", + event_key="application:1:submitted", + ) + + self.assertEqual(len(created), 2) + self.assertEqual( + set(Notification.objects.values_list("recipient_id", flat=True)), + {self.recipient.pk, second.pk}, + ) + + def test_only_internal_office_urls_are_allowed(self): + invalid_urls = ( + "https://example.com/office/news/1", + "//example.com/office/news/1", + "/projects/1", + "/office/news/1#fragment", + "/office/..\\admin", + ) + + for index, action_url in enumerate(invalid_urls): + with self.subTest(action_url=action_url), self.assertRaises(ValueError): + self.create(action_url=action_url, event_key="invalid:" + str(index)) + + self.assertFalse(Notification.objects.exists()) + + def test_unknown_type_is_rejected(self): + with self.assertRaises(ValueError): + self.create(notification_type="unknown") + + def test_notification_rolls_back_with_business_transaction(self): + with self.assertRaises(RuntimeError): + with transaction.atomic(): + self.create() + raise RuntimeError("rollback") + + self.assertFalse(Notification.objects.exists()) + + def test_database_constraint_guards_duplicate_recipient_event(self): + self.create() + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Notification.objects.create( + recipient=self.recipient, + type=Notification.Type.NEWS_COMMENT_CREATED, + category=Notification.Category.NEWS, + title="Дубликат", + message="Дубликат", + event_key="project-invite:1:created", + ) + + def test_every_declared_type_has_category_and_can_be_created(self): + for index, (notification_type, _label) in enumerate(Notification.Type.choices): + self.create( + notification_type=notification_type, + event_key="type:" + str(index), + ) + + self.assertEqual(Notification.objects.count(), len(Notification.Type.choices)) + self.assertFalse(Notification.objects.filter(category="").exists()) + + def test_actor_deletion_keeps_notification_and_recipient_deletion_cascades(self): + notification = self.create() + self.actor.delete() + + notification.refresh_from_db() + self.assertIsNone(notification.actor_id) + + self.recipient.delete() + self.assertFalse(Notification.objects.exists()) diff --git a/notifications/tests/test_notifications_api.py b/notifications/tests/test_notifications_api.py new file mode 100644 index 00000000..7641d247 --- /dev/null +++ b/notifications/tests/test_notifications_api.py @@ -0,0 +1,147 @@ +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from news.tests.helpers import create_user +from notifications.models import Notification + + +def create_notification(*, recipient, actor=None, suffix="1", read=False): + return Notification.objects.create( + recipient=recipient, + actor=actor, + type=Notification.Type.PROJECT_INVITE_CREATED, + category=Notification.Category.PROJECT, + title=f"Уведомление {suffix}", + message="Безопасное сообщение", + action_url="/office/projects/invites", + event_key="test:" + suffix, + read_at=timezone.now() if read else None, + ) + + +class NotificationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="notification-recipient") + self.actor = create_user(prefix="notification-actor") + self.other = create_user(prefix="notification-other") + + def test_all_endpoints_require_authentication(self): + notification = create_notification(recipient=self.user) + + responses = ( + self.client.get("/notifications/"), + self.client.get("/notifications/unread-count/"), + self.client.post(f"/notifications/{notification.pk}/read/"), + self.client.post("/notifications/read-all/"), + ) + + self.assertTrue(all(response.status_code == 401 for response in responses)) + + def test_list_is_paginated_newest_first_and_scoped_to_recipient(self): + first = create_notification(recipient=self.user, actor=self.actor, suffix="1") + second = create_notification(recipient=self.user, actor=self.actor, suffix="2") + create_notification(recipient=self.other, suffix="other") + self.client.force_authenticate(self.user) + + response = self.client.get("/notifications/?limit=1&offset=0") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 2) + self.assertEqual(response.data["unread_count"], 2) + self.assertIsNotNone(response.data["next"]) + self.assertIsNone(response.data["previous"]) + self.assertEqual(response.data["results"][0]["id"], second.pk) + self.assertNotEqual(response.data["results"][0]["id"], first.pk) + + next_response = self.client.get("/notifications/?limit=1&offset=1") + self.assertEqual(next_response.data["results"][0]["id"], first.pk) + self.assertIsNone(next_response.data["next"]) + self.assertIsNotNone(next_response.data["previous"]) + + def test_unread_filter_and_count_use_all_user_notifications(self): + unread = create_notification(recipient=self.user, suffix="unread") + create_notification(recipient=self.user, suffix="read", read=True) + self.client.force_authenticate(self.user) + + response = self.client.get("/notifications/?unread=true") + count_response = self.client.get("/notifications/unread-count/") + + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["unread_count"], 1) + self.assertEqual(response.data["results"][0]["id"], unread.pk) + self.assertEqual(count_response.data, {"unread_count": 1}) + + def test_read_is_idempotent_and_preserves_first_timestamp(self): + notification = create_notification(recipient=self.user) + self.client.force_authenticate(self.user) + + first = self.client.post(f"/notifications/{notification.pk}/read/") + first_read_at = first.data["read_at"] + second = self.client.post(f"/notifications/{notification.pk}/read/") + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.data["read_at"], first_read_at) + + def test_foreign_notification_is_hidden_by_404(self): + notification = create_notification(recipient=self.other) + self.client.force_authenticate(self.user) + + response = self.client.post(f"/notifications/{notification.pk}/read/") + + self.assertEqual(response.status_code, 404) + notification.refresh_from_db() + self.assertIsNone(notification.read_at) + + def test_read_all_changes_only_current_users_unread_notifications(self): + own_unread = create_notification(recipient=self.user, suffix="own-unread") + own_read = create_notification(recipient=self.user, suffix="own-read", read=True) + other_unread = create_notification(recipient=self.other, suffix="other-unread") + self.client.force_authenticate(self.user) + + response = self.client.post("/notifications/read-all/") + + self.assertEqual(response.data, {"updated": 1, "unread_count": 0}) + own_unread.refresh_from_db() + own_read.refresh_from_db() + other_unread.refresh_from_db() + self.assertIsNotNone(own_unread.read_at) + self.assertIsNotNone(own_read.read_at) + self.assertIsNone(other_unread.read_at) + + def test_actor_contract_does_not_expose_private_fields(self): + self.actor.avatar = "https://cdn.example.com/avatar.png" + self.actor.save(update_fields=["avatar"]) + create_notification(recipient=self.user, actor=self.actor) + self.client.force_authenticate(self.user) + + response = self.client.get("/notifications/") + + actor = response.data["results"][0]["actor"] + self.assertEqual( + set(actor), + {"id", "first_name", "last_name", "avatar"}, + ) + serialized = str(response.data).lower() + for forbidden in ("email", "phone", "birthday", "is_staff", "password"): + self.assertNotIn(forbidden, serialized) + + def test_list_has_fixed_query_count_for_many_notifications(self): + for index in range(12): + create_notification( + recipient=self.user, + actor=self.actor, + suffix=f"query-{index}", + ) + self.client.force_authenticate(self.user) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/notifications/?limit=20") + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 12) + self.assertEqual(len(queries), 3) diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 00000000..2fb2b780 --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from notifications.views import ( + NotificationListView, + NotificationReadAllView, + NotificationReadView, + NotificationUnreadCountView, +) + +app_name = "notifications" + +urlpatterns = [ + path("", NotificationListView.as_view(), name="list"), + path("unread-count/", NotificationUnreadCountView.as_view(), name="unread-count"), + path("read-all/", NotificationReadAllView.as_view(), name="read-all"), + path("/read/", NotificationReadView.as_view(), name="read"), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 00000000..827f811a --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,90 @@ +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from notifications.models import Notification +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) + unread_count = queryset.filter(read_at__isnull=True).count() + if query_serializer.validated_data["unread"]: + queryset = queryset.filter(read_at__isnull=True) + + paginator = LimitOffsetPagination() + paginator.default_limit = query_serializer.validated_data["limit"] + paginator.max_limit = 100 + page = paginator.paginate_queryset(queryset, request, view=self) + response = paginator.get_paginated_response( + NotificationSerializer(page, many=True).data + ) + response.data = { + "count": response.data["count"], + "unread_count": unread_count, + "next": response.data["next"], + "previous": response.data["previous"], + "results": response.data["results"], + } + return response + + +class NotificationUnreadCountView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + unread_count = Notification.objects.filter( + recipient=request.user, + read_at__isnull=True, + ).count() + return Response({"unread_count": unread_count}) + + +class NotificationReadView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + 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(), + pk=notification_id, + recipient=request.user, + ) + if notification.read_at is None: + notification.read_at = timezone.now() + notification.save(update_fields=["read_at"]) + return Response(NotificationSerializer(notification).data) + + +class NotificationReadAllView(APIView): + permission_classes = [IsAuthenticated] + + @transaction.atomic + def post(self, request): + updated = Notification.objects.filter( + recipient=request.user, + 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/applications_views.py b/partner_programs/applications_views.py index f99af84a..89f23c76 100644 --- a/partner_programs/applications_views.py +++ b/partner_programs/applications_views.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from notifications.events import notify_application_status_changed from core.throttling import PostOnlyScopedRateThrottle from partner_programs.models import Application, PartnerProgram, TeamMember from partner_programs.permissions import can_edit_application @@ -288,4 +289,5 @@ def post(self, request, application_id): application.status = Application.STATUS_WITHDRAWN application.withdrawn_at = timezone.now() application.save(update_fields=["status", "withdrawn_at", "updated_at"]) + notify_application_status_changed(application, actor=request.user) return _application_response(application, request) diff --git a/partner_programs/services/application_team.py b/partner_programs/services/application_team.py index 46388b3f..4cf1fc64 100644 --- a/partner_programs/services/application_team.py +++ b/partner_programs/services/application_team.py @@ -4,6 +4,7 @@ from django.db import IntegrityError, transaction from django.utils import timezone +from notifications.events import notify_application_submitted from partner_programs.models import ( Application, PartnerProgram, @@ -131,10 +132,13 @@ def _lock_application(application: Application) -> Application: def _require_registration(*, program: PartnerProgram, user: User) -> None: - if user is None or not PartnerProgramUserProfile.objects.filter( - partner_program=program, - user=user, - ).exists(): + if ( + user is None + or not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=user, + ).exists() + ): raise RegistrationRequiredError() @@ -170,12 +174,16 @@ def _active_application_ids_for_user( user=user, status__in=Application.ACTIVE_STATUSES, ).values_list("pk", flat=True) - membership_ids = TeamMember.objects.select_for_update().filter( - user=user, - status=TeamMember.STATUS_ACCEPTED, - team__application__program=program, - team__application__status__in=Application.ACTIVE_STATUSES, - ).values_list("team__application_id", flat=True) + membership_ids = ( + TeamMember.objects.select_for_update() + .filter( + user=user, + status=TeamMember.STATUS_ACCEPTED, + team__application__program=program, + team__application__status__in=Application.ACTIVE_STATUSES, + ) + .values_list("team__application_id", flat=True) + ) return set(owned_ids).union(membership_ids) @@ -374,9 +382,7 @@ def change_application_participation_mode( with transaction.atomic(): program = _lock_program(application.program) application = _lock_application(application) - if application.user_id != actor.pk and not ( - actor.is_staff or actor.is_superuser - ): + if application.user_id != actor.pk and not (actor.is_staff or actor.is_superuser): raise ApplicationNotEditableError( "Только владелец может изменить формат заявки." ) @@ -465,9 +471,11 @@ def validate_team_invariants(application: Application) -> None: if application.participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: raise ParticipationModeUndecidedError() - team = Team.objects.select_related("application", "captain").filter( - application=application - ).first() + team = ( + Team.objects.select_related("application", "captain") + .filter(application=application) + .first() + ) if application.participation_mode == Application.PARTICIPATION_MODE_INDIVIDUAL: if team is not None: raise TeamNotAllowedError() @@ -481,8 +489,9 @@ def validate_team_invariants(application: Application) -> None: # Invited/declined/removed/left сохраняют историю, но участниками команды # при submit считаются только accepted-записи, включая капитана. accepted_members = list( - TeamMember.objects.filter(team=team, status=TeamMember.STATUS_ACCEPTED) - .select_related("user") + TeamMember.objects.filter( + team=team, status=TeamMember.STATUS_ACCEPTED + ).select_related("user") ) accepted_user_ids = {member.user_id for member in accepted_members} registered_user_ids = set( @@ -535,9 +544,7 @@ def submit_application(*, application: Application, actor: User) -> Application: with transaction.atomic(): program = _lock_program(application.program) application = _lock_application(application) - if application.user_id != actor.pk and not ( - actor.is_staff or actor.is_superuser - ): + if application.user_id != actor.pk and not (actor.is_staff or actor.is_superuser): raise ApplicationNotEditableError( "Только владелец или staff может отправить заявку." ) @@ -547,9 +554,7 @@ def submit_application(*, application: Application, actor: User) -> Application: if application.status == Application.STATUS_SUBMITTED: return application if application.status != Application.STATUS_DRAFT: - raise ApplicationNotEditableError( - "Отправить можно только черновик заявки." - ) + raise ApplicationNotEditableError("Отправить можно только черновик заявки.") _require_registration(program=program, user=application.user) _require_open_application_deadline(program) @@ -564,4 +569,5 @@ def submit_application(*, application: Application, actor: User) -> Application: application.status = Application.STATUS_SUBMITTED application.submitted_at = timezone.now() application.save(update_fields=["status", "submitted_at", "updated_at"]) + notify_application_submitted(application, actor=actor) return application diff --git a/partner_programs/services/evaluations.py b/partner_programs/services/evaluations.py index d1ade334..5223c89e 100644 --- a/partner_programs/services/evaluations.py +++ b/partner_programs/services/evaluations.py @@ -9,6 +9,7 @@ from django.db.models import OuterRef, Prefetch, Subquery from django.utils import timezone +from notifications.events import notify_evaluation_submitted from partner_programs.models import ( Evaluation, EvaluationAmendment, @@ -566,6 +567,7 @@ def submit_evaluation(*, evaluation_id, user): "updated_at", ] ) + notify_evaluation_submitted(evaluation, actor=user) return evaluation diff --git a/partner_programs/services/submission_assignments.py b/partner_programs/services/submission_assignments.py index 40ae2022..fc9e7803 100644 --- a/partner_programs/services/submission_assignments.py +++ b/partner_programs/services/submission_assignments.py @@ -4,6 +4,10 @@ from django.db import IntegrityError, transaction from django.utils import timezone +from notifications.events import ( + notify_expert_assignment_created, + notify_expert_assignment_revoked, +) from partner_programs.models import ( Evaluation, PartnerProgram, @@ -153,6 +157,7 @@ def create_submission_assignment( "The assignment changed concurrently. Please retry." ) from exc + notify_expert_assignment_created(assignment) return SubmissionAssignmentCreationResult( assignment=assignment, created=True, @@ -204,4 +209,5 @@ def revoke_submission_assignment( "updated_at", ] ) + notify_expert_assignment_revoked(assignment, actor=actor) return assignment diff --git a/partner_programs/services/team_invites.py b/partner_programs/services/team_invites.py index 08895a7c..f41b2d74 100644 --- a/partner_programs/services/team_invites.py +++ b/partner_programs/services/team_invites.py @@ -14,6 +14,10 @@ TeamInvite, TeamMember, ) +from notifications.events import ( + notify_team_invite_created, + notify_team_invite_resolved, +) from partner_programs.permissions import can_manage_team from partner_programs.services.application_team import ( ActiveApplicationConflictError, @@ -365,6 +369,7 @@ def create_team_invite( raise TeamInviteDuplicateError() from exc return TeamInviteCreationResult(invite, created=False) + notify_team_invite_created(invite) return TeamInviteCreationResult(invite, created=True) @@ -419,6 +424,7 @@ def accept_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: invite.status = TeamInvite.STATUS_ACCEPTED invite.resolved_at = resolved_at invite.save(update_fields=["status", "resolved_at", "updated_at"]) + notify_team_invite_resolved(invite, actor=actor, status="accepted") # Принятие места в одной команде закрывает конкурирующие pending-инвайты # этого пользователя в рамках той же Program, но сохраняет их историю. @@ -455,6 +461,7 @@ def decline_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: invite.status = TeamInvite.STATUS_DECLINED invite.resolved_at = timezone.now() invite.save(update_fields=["status", "resolved_at", "updated_at"]) + notify_team_invite_resolved(invite, actor=actor, status="declined") return invite @@ -473,4 +480,5 @@ def revoke_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: invite.status = TeamInvite.STATUS_REVOKED invite.resolved_at = timezone.now() invite.save(update_fields=["status", "resolved_at", "updated_at"]) + notify_team_invite_resolved(invite, actor=actor, status="revoked") return invite diff --git a/partner_programs/submission_views.py b/partner_programs/submission_views.py index d49af696..09100da0 100644 --- a/partner_programs/submission_views.py +++ b/partner_programs/submission_views.py @@ -9,6 +9,10 @@ from rest_framework.response import Response from rest_framework.views import APIView +from notifications.events import ( + notify_submission_status_changed, + notify_submission_submitted, +) from core.throttling import PostOnlyScopedRateThrottle from partner_programs.models import Application, Submission, TeamMember from partner_programs.permissions import can_edit_application, can_edit_submission @@ -99,9 +103,7 @@ def post(self, request, application_id): submission_data = dict(serializer.validated_data) stage_key = submission_data.pop("stage_key", "main") validated_version = submission_data.pop("version", None) - requested_version = ( - validated_version if "version" in request.data else None - ) + requested_version = validated_version if "version" in request.data else None version = requested_version try: @@ -245,6 +247,7 @@ def post(self, request, submission_id): submission.status = Submission.STATUS_SUBMITTED submission.submitted_at = timezone.now() submission.save(update_fields=["status", "submitted_at", "updated_at"]) + notify_submission_submitted(submission, actor=request.user) return _submission_response(submission) @@ -281,4 +284,5 @@ def post(self, request, submission_id): submission.status = Submission.STATUS_CANCELLED submission.save(update_fields=["status", "updated_at"]) + notify_submission_status_changed(submission, actor=request.user) return _submission_response(submission) diff --git a/partner_programs/tests/test_application_api.py b/partner_programs/tests/test_application_api.py index 96f9ae4a..0383c2f2 100644 --- a/partner_programs/tests/test_application_api.py +++ b/partner_programs/tests/test_application_api.py @@ -12,6 +12,7 @@ Team, TeamMember, ) +from notifications.models import Notification from partner_programs.tests.helpers import ( create_partner_program, create_program_member, @@ -112,9 +113,7 @@ def test_application_create_requires_program_registration(self): def test_team_application_can_be_created_through_extended_request(self): PartnerProgram.objects.filter(pk=self.program.pk).update( - participation_format=( - PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM - ), + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), team_min_size=2, team_max_size=5, ) @@ -209,9 +208,7 @@ def test_patch_draft_updates_form_data(self): def test_patch_participation_mode_uses_team_service(self): PartnerProgram.objects.filter(pk=self.program.pk).update( - participation_format=( - PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM - ), + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), team_min_size=2, team_max_size=5, ) @@ -301,6 +298,7 @@ def test_patch_submitted_application_is_rejected(self): def test_submit_transitions_draft_and_sets_submitted_at(self): application = self.create_application() + self.program.managers.add(self.other_user) self.authenticate() response = self.client.post( @@ -314,9 +312,16 @@ def test_submit_transitions_draft_and_sets_submitted_at(self): self.assertEqual(application.status, Application.STATUS_SUBMITTED) self.assertIsNotNone(application.submitted_at) self.assertEqual(response.data["status"], Application.STATUS_SUBMITTED) + self.assertTrue( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.APPLICATION_SUBMITTED, + ).exists() + ) def test_repeated_submit_preserves_submitted_at(self): application = self.create_application() + self.program.managers.add(self.other_user) self.authenticate() url = f"/applications/{application.id}/submit/" @@ -329,6 +334,13 @@ def test_repeated_submit_preserves_submitted_at(self): self.assertEqual(second_response.status_code, 200) application.refresh_from_db() self.assertEqual(application.submitted_at, first_submitted_at) + self.assertEqual( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.APPLICATION_SUBMITTED, + ).count(), + 1, + ) def test_withdraw_transitions_application_and_is_idempotent(self): application = self.create_application( @@ -444,6 +456,12 @@ def test_staff_can_submit_and_withdraw_application(self): self.assertEqual(withdraw_response.status_code, 200) application.refresh_from_db() self.assertEqual(application.status, Application.STATUS_WITHDRAWN) + self.assertTrue( + Notification.objects.filter( + recipient=self.user, + type=Notification.Type.APPLICATION_STATUS_CHANGED, + ).exists() + ) def test_project_can_be_null(self): self.authenticate() diff --git a/partner_programs/tests/test_expert_evaluation_api.py b/partner_programs/tests/test_expert_evaluation_api.py index 3f6d8deb..4fa9f5dd 100644 --- a/partner_programs/tests/test_expert_evaluation_api.py +++ b/partner_programs/tests/test_expert_evaluation_api.py @@ -16,6 +16,7 @@ from django.utils import timezone from rest_framework.test import APIClient +from notifications.models import Notification from partner_programs.models import ( Application, Evaluation, @@ -707,6 +708,12 @@ def test_submit_completes_evaluation_and_assignment(self): self.assignment.completed_at, self.evaluation.submitted_at, ) + self.assertTrue( + Notification.objects.filter( + recipient=self.manager, + type=Notification.Type.EVALUATION_SUBMITTED, + ).exists() + ) def test_incomplete_criteria_return_400(self): self.evaluation.scores.filter(criterion=self.float_criterion).delete() diff --git a/partner_programs/tests/test_submission_api.py b/partner_programs/tests/test_submission_api.py index 1465ec68..49ed0587 100644 --- a/partner_programs/tests/test_submission_api.py +++ b/partner_programs/tests/test_submission_api.py @@ -5,6 +5,7 @@ from rest_framework.test import APIClient from partner_programs.models import Application, Submission +from notifications.models import Notification from partner_programs.tests.helpers import create_partner_program, create_user @@ -96,9 +97,7 @@ def test_user_cannot_list_another_users_application_submissions(self): other_application = self.create_application(user=self.other_user) self.authenticate() - response = self.client.get( - f"/applications/{other_application.id}/submissions/" - ) + response = self.client.get(f"/applications/{other_application.id}/submissions/") self.assertEqual(response.status_code, 404) @@ -313,6 +312,7 @@ def test_patch_rejects_immutable_fields(self): def test_submit_draft_sets_status_and_submitted_at(self): submission = self.create_submission() + self.program.managers.add(self.other_user) self.authenticate() response = self.client.post( @@ -325,9 +325,16 @@ def test_submit_draft_sets_status_and_submitted_at(self): submission.refresh_from_db() self.assertEqual(submission.status, Submission.STATUS_SUBMITTED) self.assertIsNotNone(submission.submitted_at) + self.assertTrue( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.SUBMISSION_SUBMITTED, + ).exists() + ) def test_repeated_submit_preserves_submitted_at(self): submission = self.create_submission() + self.program.managers.add(self.other_user) self.authenticate() url = f"/submissions/{submission.id}/submit/" @@ -340,6 +347,13 @@ def test_repeated_submit_preserves_submitted_at(self): self.assertEqual(second_response.status_code, 200) submission.refresh_from_db() self.assertEqual(submission.submitted_at, first_submitted_at) + self.assertEqual( + Notification.objects.filter( + recipient=self.other_user, + type=Notification.Type.SUBMISSION_SUBMITTED, + ).count(), + 1, + ) def test_submit_final_or_cancelled_returns_bad_request(self): submission = self.create_submission() diff --git a/partner_programs/tests/test_submission_assignment_api.py b/partner_programs/tests/test_submission_assignment_api.py index 4e5826ad..5c6e4133 100644 --- a/partner_programs/tests/test_submission_assignment_api.py +++ b/partner_programs/tests/test_submission_assignment_api.py @@ -9,6 +9,7 @@ from django.utils import timezone from rest_framework.test import APIClient +from notifications.models import Notification from partner_programs.models import ( Application, Evaluation, @@ -375,6 +376,12 @@ def test_manager_creates_assigned_assignment_with_201(self): self.assertEqual(response.status_code, 201) self.assertEqual(response.data["status"], "assigned") self.assertEqual(SubmissionExpertAssignment.objects.count(), 1) + self.assertTrue( + Notification.objects.filter( + recipient=self.expert_user, + type=Notification.Type.EXPERT_ASSIGNMENT_CREATED, + ).exists() + ) def test_assigned_by_is_request_user(self): self.authenticate() @@ -638,6 +645,12 @@ def test_manager_revokes_assigned_assignment(self): self.assignment.status, SubmissionExpertAssignment.STATUS_REVOKED, ) + self.assertTrue( + Notification.objects.filter( + recipient=self.expert_user, + type=Notification.Type.EXPERT_ASSIGNMENT_REVOKED, + ).exists() + ) def test_revoke_records_actor_timestamp_and_trimmed_reason(self): self.authenticate() diff --git a/partner_programs/tests/test_team_invite_api.py b/partner_programs/tests/test_team_invite_api.py index dc4a2dc9..d352f6c2 100644 --- a/partner_programs/tests/test_team_invite_api.py +++ b/partner_programs/tests/test_team_invite_api.py @@ -12,6 +12,7 @@ TeamInvite, TeamMember, ) +from notifications.models import Notification from partner_programs.services.application_team import create_or_get_application from partner_programs.services.team_invites import create_team_invite from partner_programs.tests.helpers import ( @@ -142,6 +143,13 @@ def test_create_is_idempotent_and_exposes_only_safe_user_fields(self): self.assertFalse( TeamMember.objects.filter(team=self.team, user=self.target).exists() ) + self.assertEqual( + Notification.objects.filter( + recipient=self.target, + type=Notification.Type.TEAM_INVITE_CREATED, + ).count(), + 1, + ) def test_member_and_manager_cannot_create_outsider_gets_hidden_404(self): for user in (self.member_user, self.manager): @@ -216,7 +224,9 @@ def test_my_invites_returns_only_current_user_pending_first(self): response = self.client.get("/team-invites/my/") self.assertEqual(response.status_code, 200) - self.assertEqual([item["id"] for item in response.data], [pending.pk, resolved.pk]) + self.assertEqual( + [item["id"] for item in response.data], [pending.pk, resolved.pk] + ) self.assertEqual(response.data[0]["application_id"], self.application.pk) self.assertEqual(response.data[0]["program"]["id"], self.program.pk) self.assertEqual(response.data[0]["captain"]["id"], self.captain.pk) @@ -244,11 +254,19 @@ def test_pending_invite_does_not_grant_access_but_accept_does(self): self.assertEqual(accepted.status_code, 200) self.assertEqual(accepted.data["status"], TeamInvite.STATUS_ACCEPTED) + self.assertTrue( + Notification.objects.filter( + recipient=self.captain, + type=Notification.Type.TEAM_INVITE_ACCEPTED, + ).exists() + ) self.assertEqual(self.client.get(application_url).status_code, 200) self.assertEqual(self.client.get(team_url).status_code, 200) self.assertEqual(self.client.get(submission_url).status_code, 200) self.assertEqual( - self.client.patch(application_url, {"form_data": {}}, format="json").status_code, + self.client.patch( + application_url, {"form_data": {}}, format="json" + ).status_code, 403, ) self.assertEqual( @@ -256,7 +274,9 @@ def test_pending_invite_does_not_grant_access_but_accept_does(self): 403, ) self.assertEqual( - self.client.patch(submission_url, {"title": "Нельзя"}, format="json").status_code, + self.client.patch( + submission_url, {"title": "Нельзя"}, format="json" + ).status_code, 403, ) @@ -281,6 +301,12 @@ def test_accept_decline_and_revoke_actions(self): ) self.assertEqual(declined.status_code, 200) self.assertEqual(declined.data["status"], TeamInvite.STATUS_DECLINED) + self.assertTrue( + Notification.objects.filter( + recipient=self.captain, + type=Notification.Type.TEAM_INVITE_DECLINED, + ).exists() + ) self.assertFalse( TeamMember.objects.filter(team=self.team, user=decline_target).exists() ) @@ -293,6 +319,12 @@ def test_accept_decline_and_revoke_actions(self): {}, format="json", ) + self.assertTrue( + Notification.objects.filter( + recipient=revoke_target, + type=Notification.Type.TEAM_INVITE_REVOKED, + ).exists() + ) self.assertEqual(revoked.status_code, 200) self.assertEqual(revoked.data["status"], TeamInvite.STATUS_REVOKED) @@ -317,7 +349,9 @@ def test_action_permission_boundaries_are_404_or_403(self): self.client.post(self.action_url(invite, "accept")).status_code, 400, ) - self.assertEqual(self.client.post("/team-invites/999999/accept/").status_code, 404) + self.assertEqual( + self.client.post("/team-invites/999999/accept/").status_code, 404 + ) def test_invite_mutations_are_blocked_after_application_submit(self): accept_target = self.target diff --git a/procollab/settings.py b/procollab/settings.py index af74fdfc..8c7ad03c 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -97,6 +97,7 @@ "mailing.apps.MailingConfig", "feed.apps.FeedConfig", "project_rates.apps.ProjectRatesConfig", + "notifications.apps.NotificationsConfig", # Rest framework "rest_framework", "rest_framework_simplejwt", diff --git a/procollab/urls.py b/procollab/urls.py index b89c133b..3fdc1d2e 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -86,6 +86,10 @@ path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")), + path( + "notifications/", + include("notifications.urls", namespace="notifications"), + ), path( "api/token/", ThrottledTokenObtainPairView.as_view(), diff --git a/vacancy/response_services.py b/vacancy/response_services.py index a12325b1..979a6162 100644 --- a/vacancy/response_services.py +++ b/vacancy/response_services.py @@ -4,6 +4,10 @@ from rest_framework.exceptions import NotFound, PermissionDenied from projects.models import Collaborator +from notifications.events import ( + notify_vacancy_response_created, + notify_vacancy_response_resolved, +) from vacancy.mapping import CeleryEmailParams, MessageTypeEnum from vacancy.models import Vacancy, VacancyResponse from vacancy.tasks import send_email @@ -71,6 +75,7 @@ def create_vacancy_response( user=user, **validated_data, ) + notify_vacancy_response_created(response) transaction.on_commit( lambda: send_email.delay( CeleryEmailParams( @@ -131,6 +136,14 @@ def accept_vacancy_response(response_id: int, *, actor) -> VacancyResponse: is_approved=False, datetime_updated=timezone.now(), ) + notify_vacancy_response_resolved(response, actor=actor, accepted=True) + for rejected_response in rejected: + rejected_response.vacancy = vacancy + notify_vacancy_response_resolved( + rejected_response, + actor=actor, + accepted=False, + ) transaction.on_commit( lambda: send_email.delay(_email_payload(response, MessageTypeEnum.ACCEPTED.value)) @@ -155,6 +168,7 @@ def decline_vacancy_response(response_id: int, *, actor) -> VacancyResponse: raise serializers.ValidationError("Отклик уже обработан.") response.is_approved = False response.save(update_fields=("is_approved", "datetime_updated")) + notify_vacancy_response_resolved(response, actor=actor, accepted=False) transaction.on_commit( lambda: send_email.delay(_email_payload(response, MessageTypeEnum.REJECTED.value)) ) @@ -172,10 +186,19 @@ def close_vacancy(vacancy_id: int, *, actor) -> Vacancy: if vacancy.is_active: vacancy.is_active = False vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + pending_responses = list( + VacancyResponse.objects.select_for_update().filter( + vacancy=vacancy, + is_approved__isnull=True, + ) + ) VacancyResponse.objects.filter( vacancy=vacancy, is_approved__isnull=True, ).update(is_approved=False, datetime_updated=timezone.now()) + for response in pending_responses: + response.vacancy = vacancy + notify_vacancy_response_resolved(response, actor=actor, accepted=False) return vacancy diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py index 074e996d..56ff4009 100644 --- a/vacancy/tests/test_vacancy_contract_api.py +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -9,6 +9,7 @@ from rest_framework.test import APIClient from projects.models import Collaborator +from notifications.models import Notification from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule from vacancy.models import Vacancy, VacancyResponse from vacancy.tests.helpers import ( @@ -403,6 +404,18 @@ def test_accept_is_atomic_closes_vacancy_and_declines_other_pending(self, send_e ).exists() ) self.assertEqual(send_email.call_count, 2) + self.assertTrue( + Notification.objects.filter( + recipient=accepted_user, + type=Notification.Type.VACANCY_RESPONSE_ACCEPTED, + ).exists() + ) + self.assertTrue( + Notification.objects.filter( + recipient=rejected_user, + type=Notification.Type.VACANCY_RESPONSE_DECLINED, + ).exists() + ) self.assertEqual( self.client.post(f"/vacancies/responses/{accepted.id}/accept/").status_code, status.HTTP_400_BAD_REQUEST, diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py index 937e8c60..2d35cc16 100644 --- a/vacancy/tests/test_vacancy_responses_api.py +++ b/vacancy/tests/test_vacancy_responses_api.py @@ -4,6 +4,7 @@ from rest_framework import status from rest_framework.test import APIClient +from notifications.models import Notification from projects.models import Collaborator from vacancy.models import VacancyResponse from vacancy.tests.helpers import ( @@ -42,6 +43,12 @@ def test_user_can_apply_to_active_vacancy(self, send_email_delay): self.assertEqual(vacancy_response.vacancy, vacancy) self.assertEqual(vacancy_response.accompanying_file, file) send_email_delay.assert_called_once() + self.assertTrue( + Notification.objects.filter( + recipient=vacancy.project.leader, + type=Notification.Type.VACANCY_RESPONSE_CREATED, + ).exists() + ) @patch("vacancy.response_services.send_email.delay") def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): @@ -148,6 +155,12 @@ def test_project_leader_can_accept_response(self, send_email_delay): ).exists() ) self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + self.assertTrue( + Notification.objects.filter( + recipient=applicant, + type=Notification.Type.VACANCY_RESPONSE_ACCEPTED, + ).exists() + ) @patch("vacancy.response_services.send_email.delay") def test_project_leader_can_decline_response(self, send_email_delay): @@ -167,6 +180,12 @@ def test_project_leader_can_decline_response(self, send_email_delay): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(vacancy_response.is_approved) self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + self.assertTrue( + Notification.objects.filter( + recipient=applicant, + type=Notification.Type.VACANCY_RESPONSE_DECLINED, + ).exists() + ) @patch("vacancy.response_services.send_email.delay") def test_non_leader_cannot_accept_response(self, send_email_delay):