Add websocket read receipts and AsyncAPI docs
This commit is contained in:
parent
0a71d6a3c9
commit
d1aea28be9
@ -8,6 +8,4 @@ class ApiConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""Инициализация при загрузке приложения."""
|
||||
# Патч для django-scheduler теперь применяется в
|
||||
# scheduling.apps.SchedulingConfig
|
||||
pass
|
||||
|
||||
61
backend/api/docs_views.py
Normal file
61
backend/api/docs_views.py
Normal file
@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
|
||||
ASYNCAPI_SCHEMA_PATH = (
|
||||
Path(settings.BASE_DIR)
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "websocket-asyncapi.yaml"
|
||||
)
|
||||
|
||||
|
||||
def _load_asyncapi_schema_text() -> str:
|
||||
try:
|
||||
return ASYNCAPI_SCHEMA_PATH.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise Http404("AsyncAPI schema file not found") from exc
|
||||
|
||||
|
||||
class AsyncAPISchemaView(View):
|
||||
"""Отдаёт AsyncAPI схему WebSocket-контура отдельным endpoint."""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
schema_text = _load_asyncapi_schema_text()
|
||||
response = HttpResponse(
|
||||
schema_text,
|
||||
content_type="application/yaml; charset=utf-8",
|
||||
)
|
||||
response["Content-Disposition"] = (
|
||||
'inline; filename="websocket-asyncapi.yaml"'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class AsyncAPIDocsView(TemplateView):
|
||||
"""Простая HTML-страница с WS-документацией и raw AsyncAPI схемой."""
|
||||
|
||||
template_name = "api/websocket_docs.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
ws_scheme = "wss" if self.request.is_secure() else "ws"
|
||||
context.update(
|
||||
{
|
||||
"asyncapi_schema_url": reverse("api:ws-schema"),
|
||||
"openapi_schema_url": reverse("api:schema"),
|
||||
"swagger_url": reverse("api:swagger-ui"),
|
||||
"redoc_url": reverse("api:redoc"),
|
||||
"ws_endpoint_url": (
|
||||
f"{ws_scheme}://{self.request.get_host()}/ws/"
|
||||
),
|
||||
"schema_text": _load_asyncapi_schema_text(),
|
||||
}
|
||||
)
|
||||
return context
|
||||
@ -9,6 +9,7 @@ from drf_spectacular.views import (
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from .auth.views import JWTTokenRefreshView, PhoneOrEmailTokenObtainPairView
|
||||
from .docs_views import AsyncAPIDocsView, AsyncAPISchemaView
|
||||
app_name = "api"
|
||||
|
||||
urlpatterns = [
|
||||
@ -38,6 +39,8 @@ urlpatterns = [
|
||||
),
|
||||
name="redoc",
|
||||
),
|
||||
path("ws/schema/", AsyncAPISchemaView.as_view(), name="ws-schema"),
|
||||
path("ws/docs/", AsyncAPIDocsView.as_view(), name="ws-docs"),
|
||||
path("v1/", include(("api.v1.urls", "v1"), namespace="v1")),
|
||||
path(
|
||||
"auth/token/",
|
||||
|
||||
@ -562,14 +562,27 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
self._send_marked_read_event(user.id, chat.id, last_message.id)
|
||||
|
||||
def _send_marked_read_event(self, user_id, chat_id, message_id):
|
||||
"""WebSocket событие для синхронизации между вкладками"""
|
||||
"""WebSocket событие для синхронизации и read receipts."""
|
||||
channel_layer = get_channel_layer()
|
||||
event_payload = {
|
||||
'chat_id': chat_id,
|
||||
'last_read_message_id': message_id,
|
||||
'reader_user_id': user_id,
|
||||
}
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f'user_{user_id}',
|
||||
{
|
||||
'type': 'chat_marked_read_sync',
|
||||
**event_payload,
|
||||
}
|
||||
)
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f'chat_{chat_id}',
|
||||
{
|
||||
'type': 'chat_marked_read',
|
||||
'chat_id': chat_id,
|
||||
'last_read_message_id': message_id
|
||||
**event_payload,
|
||||
}
|
||||
)
|
||||
|
||||
@ -607,7 +620,7 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
queryset = chat.messages.filter(is_deleted=False).select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options'
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states'
|
||||
)
|
||||
|
||||
# Определяем порядок сортировки в зависимости от типа запроса
|
||||
@ -706,7 +719,7 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
queryset = chat.messages.filter(is_deleted=False).select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options'
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states'
|
||||
)
|
||||
|
||||
# Если нет around_id, пытаемся получить last_read_message_id
|
||||
@ -1058,9 +1071,9 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Перезагружаем с prefetch
|
||||
message = Message.objects.select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
'author', 'reply_to', 'reply_to__author', 'poll', 'chat'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user'
|
||||
'attachments', 'reactions', 'reactions__user', 'chat__read_states'
|
||||
).get(pk=message.id)
|
||||
|
||||
# Отправляем через WebSocket
|
||||
@ -1215,9 +1228,9 @@ class MessageViewSet(viewsets.ModelViewSet):
|
||||
# Перезагружаем с нуля чтобы получить обновленные attachments
|
||||
# Используем новый запрос, чтобы избежать кэширования
|
||||
instance = Message.objects.select_related(
|
||||
'author', 'reply_to', 'reply_to__author', 'poll'
|
||||
'author', 'reply_to', 'reply_to__author', 'poll', 'chat'
|
||||
).prefetch_related(
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options'
|
||||
'attachments', 'reactions', 'reactions__user', 'poll__options', 'chat__read_states'
|
||||
).get(pk=instance.id)
|
||||
|
||||
# WebSocket уведомление
|
||||
|
||||
@ -299,17 +299,21 @@ class ChatConsumerMixin:
|
||||
}
|
||||
)
|
||||
|
||||
async def chat_marked_read(self, event):
|
||||
"""Синхронизация отметки прочитанного между вкладками.
|
||||
|
||||
Telegram-style.
|
||||
"""
|
||||
async def _send_marked_read_to_client(self, event, *, allow_self: bool):
|
||||
chat_id = event.get("chat_id")
|
||||
last_read_message_id = event.get("last_read_message_id")
|
||||
reader_user_id = event.get("reader_user_id")
|
||||
|
||||
if chat_id != self.active_chat_id:
|
||||
return
|
||||
|
||||
if not allow_self and reader_user_id == getattr(self.user, "id", None):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[ChatMixin.chat_marked_read] Sending to client: "
|
||||
f"chat={chat_id}, last_read_message_id={last_read_message_id}"
|
||||
f"chat={chat_id}, last_read_message_id={last_read_message_id}, "
|
||||
f"reader_user_id={reader_user_id}"
|
||||
)
|
||||
|
||||
await self.send_json(
|
||||
@ -317,9 +321,18 @@ class ChatConsumerMixin:
|
||||
"type": "marked_read",
|
||||
"chat_id": chat_id,
|
||||
"last_read_message_id": last_read_message_id,
|
||||
"reader_user_id": reader_user_id,
|
||||
}
|
||||
)
|
||||
|
||||
async def chat_marked_read(self, event):
|
||||
"""Read receipt для других участников активного чата."""
|
||||
await self._send_marked_read_to_client(event, allow_self=False)
|
||||
|
||||
async def chat_marked_read_sync(self, event):
|
||||
"""Синхронизация отметки прочитанного между вкладками читателя."""
|
||||
await self._send_marked_read_to_client(event, allow_self=True)
|
||||
|
||||
# ==================== Обработка действий пользователя ====================
|
||||
|
||||
async def _handle_send_message(self, content):
|
||||
@ -710,8 +723,8 @@ class ChatConsumerMixin:
|
||||
"""Получить последние сообщения чата"""
|
||||
messages = (
|
||||
Message.objects.filter(chat_id=chat_id)
|
||||
.select_related("author")
|
||||
.prefetch_related("attachments", "reactions")
|
||||
.select_related("author", "chat")
|
||||
.prefetch_related("attachments", "reactions", "chat__read_states")
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
|
||||
@ -48,6 +48,31 @@ def _get_author_url(author) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_message_read(m) -> bool:
|
||||
"""True, если сообщение прочитал хотя бы один другой участник."""
|
||||
chat = getattr(m, "chat", None)
|
||||
if (
|
||||
not chat
|
||||
or not getattr(m, "id", None)
|
||||
or not getattr(m, "author_id", None)
|
||||
):
|
||||
return False
|
||||
|
||||
prefetched = getattr(chat, "_prefetched_objects_cache", {})
|
||||
read_states = prefetched.get("read_states")
|
||||
if read_states is not None:
|
||||
return any(
|
||||
state.user_id != m.author_id
|
||||
and state.last_read_message_id
|
||||
and state.last_read_message_id >= m.id
|
||||
for state in read_states
|
||||
)
|
||||
|
||||
return chat.read_states.exclude(user_id=m.author_id).filter(
|
||||
last_read_message_id__gte=m.id
|
||||
).exists()
|
||||
|
||||
|
||||
def serialize_message(m) -> dict:
|
||||
"""
|
||||
Сериализация сообщения с поддержкой всех полей.
|
||||
@ -83,6 +108,7 @@ def serialize_message(m) -> dict:
|
||||
# Статусные поля
|
||||
"is_edited": m.is_edited,
|
||||
"edited_at": m.edited_at.isoformat() if m.edited_at else None,
|
||||
"is_read": _is_message_read(m),
|
||||
"is_deleted": m.is_deleted,
|
||||
"is_pinned": m.is_pinned,
|
||||
"is_forwarded": m.is_forwarded,
|
||||
|
||||
560
backend/docs/architecture/websocket-asyncapi.yaml
Normal file
560
backend/docs/architecture/websocket-asyncapi.yaml
Normal file
@ -0,0 +1,560 @@
|
||||
asyncapi: 2.6.0
|
||||
info:
|
||||
title: EUSRR Realtime WebSocket API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR.
|
||||
|
||||
HTTP API продолжает документироваться через OpenAPI в /api/schema/.
|
||||
Этот документ описывает только двусторонний realtime-канал /ws/.
|
||||
|
||||
Аутентификация:
|
||||
- query-параметр ?token=<jwt_access_token>
|
||||
- или заголовок Authorization: Bearer <jwt_access_token>
|
||||
|
||||
defaultContentType: application/json
|
||||
servers:
|
||||
development:
|
||||
url: localhost:9000/ws/
|
||||
protocol: ws
|
||||
description: Локальная разработка
|
||||
production:
|
||||
url: api.example.com/ws/
|
||||
protocol: wss
|
||||
description: Продакшен через TLS
|
||||
channels:
|
||||
userRealtime:
|
||||
address: /ws/
|
||||
description: |
|
||||
Универсальное пользовательское соединение для всех real-time сценариев:
|
||||
- сообщения и список чатов
|
||||
- уведомления и unread count
|
||||
- typing indicators
|
||||
- реакции и голосования
|
||||
- procurement updates
|
||||
publish:
|
||||
operationId: clientAction
|
||||
summary: Команды от клиента к серверу
|
||||
message:
|
||||
oneOf:
|
||||
- $ref: '#/components/messages/OpenChatAction'
|
||||
- $ref: '#/components/messages/CloseChatAction'
|
||||
- $ref: '#/components/messages/SendMessageAction'
|
||||
- $ref: '#/components/messages/EditMessageAction'
|
||||
- $ref: '#/components/messages/DeleteMessageAction'
|
||||
- $ref: '#/components/messages/AddReactionAction'
|
||||
- $ref: '#/components/messages/RemoveReactionAction'
|
||||
- $ref: '#/components/messages/TypingAction'
|
||||
- $ref: '#/components/messages/StopTypingAction'
|
||||
- $ref: '#/components/messages/VotePollAction'
|
||||
- $ref: '#/components/messages/MarkReadAction'
|
||||
subscribe:
|
||||
operationId: serverEvent
|
||||
summary: События от сервера клиенту
|
||||
message:
|
||||
oneOf:
|
||||
- $ref: '#/components/messages/PingEvent'
|
||||
- $ref: '#/components/messages/ErrorEvent'
|
||||
- $ref: '#/components/messages/ChatOpenedEvent'
|
||||
- $ref: '#/components/messages/ChatClosedEvent'
|
||||
- $ref: '#/components/messages/InitialMessagesEvent'
|
||||
- $ref: '#/components/messages/NewMessageEvent'
|
||||
- $ref: '#/components/messages/ListUpdateEvent'
|
||||
- $ref: '#/components/messages/MessageUpdatedEvent'
|
||||
- $ref: '#/components/messages/MessageDeletedEvent'
|
||||
- $ref: '#/components/messages/ReactionAddedEvent'
|
||||
- $ref: '#/components/messages/ReactionRemovedEvent'
|
||||
- $ref: '#/components/messages/TypingStartEvent'
|
||||
- $ref: '#/components/messages/TypingStopEvent'
|
||||
- $ref: '#/components/messages/PollUpdateEvent'
|
||||
- $ref: '#/components/messages/MarkedReadEvent'
|
||||
- $ref: '#/components/messages/NotificationEvent'
|
||||
- $ref: '#/components/messages/UnreadCountEvent'
|
||||
- $ref: '#/components/messages/NotificationReadEvent'
|
||||
- $ref: '#/components/messages/NotificationsReadAllEvent'
|
||||
- $ref: '#/components/messages/ProcurementUpdateEvent'
|
||||
components:
|
||||
schemas:
|
||||
ChatId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
MessageId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
UserId:
|
||||
type: integer
|
||||
minimum: 1
|
||||
Emoji:
|
||||
type: string
|
||||
minLength: 1
|
||||
GenericMessagePayload:
|
||||
type: object
|
||||
description: |
|
||||
Сериализованное сообщение чата. Полный набор полей зависит от
|
||||
communications.serialization.serialize_message.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
content:
|
||||
type: string
|
||||
nullable: true
|
||||
chat:
|
||||
type: integer
|
||||
author:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
additionalProperties: true
|
||||
NotificationPayload:
|
||||
type: object
|
||||
description: Сериализованное уведомление пользователя.
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
verb:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
unread:
|
||||
type: boolean
|
||||
action_url:
|
||||
type: string
|
||||
nullable: true
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required:
|
||||
- id
|
||||
- verb
|
||||
- unread
|
||||
additionalProperties: true
|
||||
messages:
|
||||
OpenChatAction:
|
||||
name: OpenChatAction
|
||||
title: Открыть чат
|
||||
payload:
|
||||
type: object
|
||||
required: [action, chat_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [open_chat]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
load_history:
|
||||
type: boolean
|
||||
default: false
|
||||
CloseChatAction:
|
||||
name: CloseChatAction
|
||||
title: Закрыть чат
|
||||
payload:
|
||||
type: object
|
||||
required: [action, chat_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [close_chat]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
SendMessageAction:
|
||||
name: SendMessageAction
|
||||
title: Отправить сообщение
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [send_message]
|
||||
content:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
EditMessageAction:
|
||||
name: EditMessageAction
|
||||
title: Редактировать сообщение
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id, content]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [edit_message]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
content:
|
||||
type: string
|
||||
DeleteMessageAction:
|
||||
name: DeleteMessageAction
|
||||
title: Удалить сообщение
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [delete_message]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
AddReactionAction:
|
||||
name: AddReactionAction
|
||||
title: Добавить реакцию
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id, emoji]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [add_reaction]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
RemoveReactionAction:
|
||||
name: RemoveReactionAction
|
||||
title: Удалить реакцию
|
||||
payload:
|
||||
type: object
|
||||
required: [action, message_id, emoji]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [remove_reaction]
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
TypingAction:
|
||||
name: TypingAction
|
||||
title: Пользователь печатает
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [typing]
|
||||
StopTypingAction:
|
||||
name: StopTypingAction
|
||||
title: Пользователь перестал печатать
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [stop_typing]
|
||||
VotePollAction:
|
||||
name: VotePollAction
|
||||
title: Голосование в опросе
|
||||
payload:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [vote_poll]
|
||||
additionalProperties: true
|
||||
MarkReadAction:
|
||||
name: MarkReadAction
|
||||
title: Deprecated mark_read
|
||||
payload:
|
||||
type: object
|
||||
required: [action, chat_id]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [mark_read]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
note:
|
||||
type: string
|
||||
example: Deprecated, используйте HTTP mark-read endpoint
|
||||
PingEvent:
|
||||
name: PingEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, timestamp]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [ping]
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
ErrorEvent:
|
||||
name: ErrorEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, error]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [error]
|
||||
error:
|
||||
type: string
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
ChatOpenedEvent:
|
||||
name: ChatOpenedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [chat_opened]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
ChatClosedEvent:
|
||||
name: ChatClosedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [chat_closed]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
InitialMessagesEvent:
|
||||
name: InitialMessagesEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, messages]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [initial_messages]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
NewMessageEvent:
|
||||
name: NewMessageEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [new_message]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
ListUpdateEvent:
|
||||
name: ListUpdateEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [list_update]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
MessageUpdatedEvent:
|
||||
name: MessageUpdatedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [message_updated]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message:
|
||||
$ref: '#/components/schemas/GenericMessagePayload'
|
||||
MessageDeletedEvent:
|
||||
name: MessageDeletedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [message_deleted]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
ReactionAddedEvent:
|
||||
name: ReactionAddedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message_id, emoji, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [reaction_added]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
user_name:
|
||||
type: string
|
||||
reactions_summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ReactionRemovedEvent:
|
||||
name: ReactionRemovedEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, message_id, emoji, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [reaction_removed]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
emoji:
|
||||
$ref: '#/components/schemas/Emoji'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
reactions_summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
TypingStartEvent:
|
||||
name: TypingStartEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [typing_start]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
user_name:
|
||||
type: string
|
||||
TypingStopEvent:
|
||||
name: TypingStopEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, user_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [typing_stop]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
user_id:
|
||||
$ref: '#/components/schemas/UserId'
|
||||
PollUpdateEvent:
|
||||
name: PollUpdateEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [poll_update]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
poll_id:
|
||||
type: integer
|
||||
message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
results:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
MarkedReadEvent:
|
||||
name: MarkedReadEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, chat_id, last_read_message_id]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [marked_read]
|
||||
chat_id:
|
||||
$ref: '#/components/schemas/ChatId'
|
||||
last_read_message_id:
|
||||
$ref: '#/components/schemas/MessageId'
|
||||
NotificationEvent:
|
||||
name: NotificationEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, notification]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notification]
|
||||
notification:
|
||||
$ref: '#/components/schemas/NotificationPayload'
|
||||
UnreadCountEvent:
|
||||
name: UnreadCountEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, count]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [unread_count]
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
NotificationReadEvent:
|
||||
name: NotificationReadEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, notification_id, unread_count]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notification_read]
|
||||
notification_id:
|
||||
type: integer
|
||||
unread_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
NotificationsReadAllEvent:
|
||||
name: NotificationsReadAllEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, notification_ids, unread_count]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notifications_read_all]
|
||||
notification_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
category:
|
||||
type: string
|
||||
nullable: true
|
||||
unread_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
ProcurementUpdateEvent:
|
||||
name: ProcurementUpdateEvent
|
||||
payload:
|
||||
type: object
|
||||
required: [type, event, data]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [procurement_update]
|
||||
event:
|
||||
type: string
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
137
backend/templates/api/websocket_docs.html
Normal file
137
backend/templates/api/websocket_docs.html
Normal file
@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>EUSRR WebSocket AsyncAPI</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f8fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #ffffff, #eef6ff);
|
||||
border: 1px solid #dbe5f1;
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
h1, h2 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
.links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c7d7ea;
|
||||
background: #fff;
|
||||
color: #0f4c81;
|
||||
font-weight: 600;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #dbe5f1;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
code, pre {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
code {
|
||||
background: #eef4fb;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<h1>WebSocket AsyncAPI</h1>
|
||||
<p>
|
||||
Отдельная документация realtime-контура EUSRR. HTTP API продолжает
|
||||
жить в OpenAPI, а WebSocket-контур документируется отдельно через
|
||||
AsyncAPI, без смешивания двух протоколов в одной схеме.
|
||||
</p>
|
||||
<div class="links">
|
||||
<a href="{{ asyncapi_schema_url }}">Скачать AsyncAPI YAML</a>
|
||||
<a href="{{ swagger_url }}">HTTP Swagger</a>
|
||||
<a href="{{ redoc_url }}">HTTP ReDoc</a>
|
||||
<a href="{{ openapi_schema_url }}">HTTP OpenAPI schema</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid section">
|
||||
<article class="card">
|
||||
<h2>Точка подключения</h2>
|
||||
<p><code>{{ ws_endpoint_url }}</code></p>
|
||||
<p>
|
||||
Аутентификация поддерживает либо query-параметр
|
||||
<code>?token=<jwt_access_token></code>, либо заголовок
|
||||
<code>Authorization: Bearer <jwt_access_token></code>.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Что описано</h2>
|
||||
<p>
|
||||
В схеме отражены клиентские action-сообщения и серверные
|
||||
realtime-события для чатов, уведомлений, typing, реакций,
|
||||
read-sync и procurement updates.
|
||||
</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Почему отдельно</h2>
|
||||
<p>
|
||||
Swagger и ReDoc используют OpenAPI-схему HTTP API. Для
|
||||
WebSocket-протокола отдельный AsyncAPI-документ чище,
|
||||
понятнее и лучше масштабируется.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Содержимое AsyncAPI</h2>
|
||||
<pre>{{ schema_text }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -51,3 +51,27 @@ def test_swagger_and_redoc_pages_are_available(api_client: APIClient):
|
||||
|
||||
assert "swagger-ui" in swagger_response.content.decode().lower()
|
||||
assert "redoc" in redoc_response.content.decode().lower()
|
||||
|
||||
|
||||
def test_websocket_asyncapi_schema_is_available(api_client: APIClient):
|
||||
response = api_client.get(reverse("api:ws-schema"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "application/yaml" in response["Content-Type"]
|
||||
|
||||
schema = response.content.decode()
|
||||
assert "asyncapi: 2.6.0" in schema
|
||||
assert "address: /ws/" in schema
|
||||
assert "OpenChatAction" in schema
|
||||
assert "NotificationEvent" in schema
|
||||
|
||||
|
||||
def test_websocket_asyncapi_docs_page_is_available(api_client: APIClient):
|
||||
response = api_client.get(reverse("api:ws-docs"))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
html = response.content.decode().lower()
|
||||
assert "websocket asyncapi" in html
|
||||
assert reverse("api:ws-schema") in response.content.decode()
|
||||
assert "/ws/" in response.content.decode()
|
||||
|
||||
@ -380,15 +380,34 @@ export default function MessageDialogPage() {
|
||||
}
|
||||
else if (data.type === 'marked_read' && data.chat_id === chatId && typeof data.last_read_message_id === 'number') {
|
||||
const lastReadMessageId = data.last_read_message_id;
|
||||
const readerUserId = typeof data.reader_user_id === 'number' ? data.reader_user_id : null;
|
||||
|
||||
setChat((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId),
|
||||
if (readerUserId !== null) {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => {
|
||||
const authorId = message.author_id ?? message.author?.id ?? message.sender?.id;
|
||||
if (message.id > lastReadMessageId || authorId === readerUserId || message.is_read) {
|
||||
return message;
|
||||
}
|
||||
: prev
|
||||
);
|
||||
|
||||
return {
|
||||
...message,
|
||||
is_read: true,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (readerUserId === null || readerUserId === user?.id) {
|
||||
setChat((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
last_read_message_id: Math.max(prev.last_read_message_id ?? 0, lastReadMessageId),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ChevronRight, FileText } from "lucide-react";
|
||||
import { Check, CheckCheck, ChevronRight, FileText } from "lucide-react";
|
||||
|
||||
import { resolveMediaUrl } from "@/lib/url";
|
||||
import {
|
||||
@ -44,6 +44,20 @@ function getMessageAuthorLabel(message: Message): string {
|
||||
return message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник";
|
||||
}
|
||||
|
||||
function MessageDeliveryStatus({ isRead }: { isRead: boolean }) {
|
||||
const Icon = isRead ? CheckCheck : Check;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center ${isRead ? "text-white" : "text-sky-100"}`}
|
||||
title={isRead ? "Прочитано" : "Отправлено"}
|
||||
aria-label={isRead ? "Прочитано" : "Отправлено"}
|
||||
>
|
||||
<Icon size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatMessageItem({
|
||||
message,
|
||||
currentUserId,
|
||||
@ -68,6 +82,7 @@ export default function ChatMessageItem({
|
||||
(message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId)
|
||||
);
|
||||
const hasActions = canReply || canManage;
|
||||
const isRead = Boolean(message.is_read);
|
||||
|
||||
return (
|
||||
<div data-message-id={message.id} data-message-date={currentDate?.toISOString() || ""} className="mb-3 last:mb-0">
|
||||
@ -267,7 +282,10 @@ export default function ChatMessageItem({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className={`mt-1 text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
|
||||
<div className={`mt-1 flex items-center ${isMine ? "justify-end gap-1.5" : "justify-end"}`}>
|
||||
<p className={`text-right text-[11px] ${isMine ? "text-sky-100" : "text-gray-400"}`}>{messageTime(message)}</p>
|
||||
{isMine ? <MessageDeliveryStatus isRead={isRead} /> : null}
|
||||
</div>
|
||||
|
||||
{Object.keys(message.reactions_summary || {}).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
|
||||
@ -12,6 +12,7 @@ interface WebSocketMessage {
|
||||
user_id?: number;
|
||||
chat_id?: number;
|
||||
last_read_message_id?: number;
|
||||
reader_user_id?: number;
|
||||
reactions_summary?: ReactionsSummary;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user