Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions docs/notifications-api.md
Original file line number Diff line number Diff line change
@@ -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/<id>/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/<id>/edit?section=team` |
| `project_invite_declined` | руководитель проекта | `/office/projects/<id>/edit?section=team` |
| `project_invite_revoked` | приглашённый | `/office/projects/invites` |
| `vacancy_response_created` | руководитель проекта | `/office/projects/<project_id>/vacancies/<vacancy_id>/responses` |
| `vacancy_response_accepted` | кандидат | `/office/vacancies/my` |
| `vacancy_response_declined` | кандидат | `/office/vacancies/my` |
| `team_invite_created` | приглашённый | `/office/team-invites` |
| `team_invite_accepted` | капитан | `/office/applications/<id>/team` |
| `team_invite_declined` | капитан | `/office/applications/<id>/team` |
| `team_invite_revoked` | приглашённый | `/office/team-invites` |
| `application_submitted` | менеджеры программы | `/office/program/<id>` |
| `application_status_changed` | владелец заявки | `/office/program/<id>` |
| `submission_submitted` | менеджеры программы | `/office/program/<id>` |
| `submission_status_changed` | владелец и принятые участники | `/office/program/<id>/submission` |
| `expert_assignment_created` | эксперт | `/office/expert/submissions` |
| `expert_assignment_revoked` | эксперт | `/office/expert/submissions` |
| `evaluation_submitted` | менеджеры программы | `/office/analytics?programId=<id>` |
| `news_comment_created` | владелец источника публикации | `/office/news/<id>` |

При принятии одного отклика остальные ожидающие отклики отклоняются в той же
транзакции, и каждый кандидат получает отдельное уведомление. Для нескольких
менеджеров и участников применяется `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-контракта.
2 changes: 2 additions & 0 deletions feed/news_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions feed/tests/test_react_news_comments_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 "},
Expand All @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions invites/tests/test_project_workspace_invitation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
link_project_to_program,
)
from projects.models import Collaborator
from notifications.models import Notification


class ProjectWorkspaceInvitationAPITests(TestCase):
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions invites/workspace_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -138,6 +142,7 @@ def create_project_invitation(
if pending.exists():
raise ProjectInvitationDuplicateError() from exc
raise
notify_project_invite_created(invitation)
return invitation


Expand All @@ -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


Expand All @@ -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


Expand All @@ -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
1 change: 1 addition & 0 deletions notifications/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Внутренние уведомления PROCOLLAB."""
7 changes: 7 additions & 0 deletions notifications/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "notifications"
verbose_name = "Уведомления"
Loading
Loading