Merge branch 'develop'

This commit is contained in:
lizerk 2026-09-03 14:38:33 +03:00
commit 2e85a4e09d
15 changed files with 927 additions and 123 deletions

View File

@ -176,7 +176,7 @@ class TaskColumnViewSet(viewsets.ModelViewSet):
TaskBoard.objects.filter(
task_board_access_q(request.user),
is_archived=False,
),
).distinct(),
pk=board_id,
)

View File

@ -65,11 +65,14 @@ from ..mentions import (
update_unread_mention_counters,
)
from ..polls import PollActionError, cast_poll_vote
from ..services import soft_delete_message, update_chat_notification_preferences
from ..services import (
ChatReadMarkerService,
soft_delete_message,
update_chat_notification_preferences,
)
from ..utils import _coerce_ts, user_can_access_chat
from communications.serialization import (
serialize_message,
serialize_message_reader,
serialize_poll,
)
@ -705,97 +708,10 @@ class ChatViewSet(viewsets.ModelViewSet):
if not messages:
return
last_message = messages[-1]
read_state, created = ChatReadState.objects.get_or_create(
ChatReadMarkerService().mark_read(
chat=chat,
user=user,
defaults={
"last_read_message": last_message,
"unread_count": 0, # Прочитали → обнуляем
"unread_mention_count": 0,
},
)
if created:
logger.info(
"Chat read marker created",
extra={
"event": "chat.read_marker.created",
"user_id": user.id,
"chat_id": chat.id,
"message_id": last_message.id,
},
)
self._send_marked_read_event(user, chat.id, last_message.id)
return
# Защита от откатов: только если НОВЕЕ
if (
read_state.last_read_message_id
and last_message.id <= read_state.last_read_message_id
):
logger.debug(
"Chat read marker update skipped",
extra={
"event": "chat.read_marker.update_skipped",
"user_id": user.id,
"chat_id": chat.id,
"message_id": last_message.id,
"current_message_id": read_state.last_read_message_id,
"reason": "marker_not_newer",
},
)
return
read_state.last_read_message = last_message
read_state.unread_count = 0 # Прочитали → обнуляем
read_state.unread_mention_count = 0
read_state.save(
update_fields=[
"last_read_message",
"unread_count",
"unread_mention_count",
"updated_at",
]
)
logger.info(
"Chat read marker updated",
extra={
"event": "chat.read_marker.updated",
"user_id": user.id,
"chat_id": chat.id,
"message_id": last_message.id,
},
)
self._send_marked_read_event(user, chat.id, last_message.id)
def _send_marked_read_event(self, user, chat_id, message_id):
"""WebSocket событие для синхронизации и read receipts."""
channel_layer = get_channel_layer()
reader = serialize_message_reader(user)
event_payload = {
"chat_id": chat_id,
"last_read_message_id": message_id,
"reader_user_id": user.id,
"reader": reader,
}
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",
**event_payload,
},
message=messages[-1],
)
@action(detail=True, methods=["get"])

View File

@ -140,6 +140,8 @@ def notify_message_mentions(
"title": MessageTemplates.mention(author_name),
"chat_id": chat.id,
"chat_name": get_chat_name(chat),
"chat_type": chat.type,
"can_reply": chat.can_reply,
"message_id": message.id,
"author_id": author.id,
"mentioned": True,
@ -218,6 +220,8 @@ def notify_new_message(message):
"title": MessageTemplates.reply(author_name),
"chat_id": chat.id,
"chat_name": get_chat_name(chat),
"chat_type": chat.type,
"can_reply": chat.can_reply,
"message_id": message.id,
"reply_to_id": message.reply_to.id,
"author_id": author.id,
@ -256,6 +260,8 @@ def notify_new_message(message):
metadata = {
"chat_id": chat.id,
"chat_name": get_chat_name(chat),
"chat_type": chat.type,
"can_reply": chat.can_reply,
"message_id": message.id,
"author_id": author.id,
}

View File

@ -1,10 +1,15 @@
from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService
from .chat_quick_reply import ChatQuickReplyError, ChatQuickReplyService
from .chat_read_marker import ChatReadMarkerService
from .chat_notification_preferences import update_chat_notification_preferences
from .message_deletion import soft_delete_message
__all__ = [
'ChatNotificationReadResult',
'ChatNotificationReadService',
'ChatQuickReplyError',
'ChatQuickReplyService',
'ChatReadMarkerService',
'update_chat_notification_preferences',
'soft_delete_message',
]

View File

@ -0,0 +1,53 @@
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
import rules
from communications.models import Message
from communications.serialization import serialize_message
from communications.utils import user_can_access_chat
class ChatQuickReplyError(Exception):
"""A native push reply cannot be sent to the requested chat."""
class ChatQuickReplyService:
"""Create and broadcast a plain-text reply received from native push UI."""
MAX_CONTENT_LENGTH = 4000
def send(self, *, chat, user, reply_to, content: str) -> Message:
normalized_content = content.strip()
if not normalized_content:
raise ChatQuickReplyError("empty reply")
if len(normalized_content) > self.MAX_CONTENT_LENGTH:
raise ChatQuickReplyError("reply too long")
if not chat.can_reply:
raise ChatQuickReplyError("replies are disabled")
if not user_can_access_chat(chat, user) or not rules.test_rule(
"communications.send_message",
user,
chat,
):
raise ChatQuickReplyError("sending is not allowed")
message = Message.objects.create(
chat=chat,
author=user,
content=normalized_content,
reply_to=reply_to,
)
self._broadcast(message)
return message
@staticmethod
def _broadcast(message: Message) -> None:
payload = serialize_message(message)
async_to_sync(get_channel_layer().group_send)(
f"chat_{message.chat_id}",
{
"type": "chat.message",
"chat_id": message.chat_id,
"payload": payload,
},
)

View File

@ -0,0 +1,83 @@
import logging
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from communications.models import ChatReadState
from communications.serialization import serialize_message_reader
logger = logging.getLogger(__name__)
class ChatReadMarkerService:
"""Advance a user's chat read marker and notify every active client."""
def mark_read(self, *, chat, user, message) -> bool:
read_state, created = ChatReadState.objects.get_or_create(
chat=chat,
user=user,
defaults={
"last_read_message": message,
"unread_count": 0,
"unread_mention_count": 0,
},
)
if not created:
if (
read_state.last_read_message_id
and message.id <= read_state.last_read_message_id
):
return False
read_state.last_read_message = message
read_state.unread_count = 0
read_state.unread_mention_count = 0
read_state.save(
update_fields=[
"last_read_message",
"unread_count",
"unread_mention_count",
"updated_at",
]
)
logger.info(
"Chat read marker advanced",
extra={
"event": "chat.read_marker.advanced",
"user_id": user.id,
"chat_id": chat.id,
"message_id": message.id,
"read_state_created": created,
},
)
self._broadcast(user=user, chat_id=chat.id, message_id=message.id)
return True
@staticmethod
def _broadcast(*, user, chat_id: int, message_id: int) -> None:
channel_layer = get_channel_layer()
reader = serialize_message_reader(user)
event_payload = {
"chat_id": chat_id,
"last_read_message_id": message_id,
"reader_user_id": user.id,
"reader": reader,
}
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",
**event_payload,
},
)

View File

@ -33,6 +33,9 @@ urlpatterns = [
path("preferences/", views.channel_preferences, name="preferences"),
# Web Push интеграция
path("push/vapid-key/", views.get_vapid_public_key, name="vapid_key"),
path("push/action/read/", views.push_mark_chat_read, name="push_mark_chat_read"),
path("push/action/reply/", views.push_reply_to_chat, name="push_reply_to_chat"),
path("push/avatar/", views.push_avatar, name="push_avatar"),
path("push/subscribe/", views.subscribe_push, name="push_subscribe"),
path("push/unsubscribe/", views.unsubscribe_push, name="push_unsubscribe"),
path(

View File

@ -8,16 +8,34 @@ API endpoints для системы уведомлений v2.0
- UserChannelPreferences для настроек каналов
"""
from datetime import datetime
from io import BytesIO
import uuid
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.core import signing
from django.core.files.storage import default_storage
from django.db import transaction
from django.db.models import Count, Q
from django.http import HttpResponse
from django.utils import timezone
from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import (
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from communications.models import Chat, Message
from communications.services import (
ChatNotificationReadService,
ChatQuickReplyError,
ChatQuickReplyService,
ChatReadMarkerService,
)
from ..models import (
Notification,
NotificationDeliveryRouting,
@ -36,6 +54,11 @@ from ..realtime import (
send_notifications_read_all_event,
)
from ..push_lifecycle import dismiss_notifications_push
from ..push_actions import (
load_push_avatar_token,
load_push_read_token,
load_push_reply_token,
)
from .serializers import (
ChannelPreferencesSerializer,
CountResponseSerializer,
@ -632,6 +655,238 @@ def get_vapid_public_key(request):
})
@api_view(['POST'])
@authentication_classes([])
@permission_classes([AllowAny])
def push_mark_chat_read(request):
"""Mark the pushed chat message as read using a short-lived signed token."""
token = request.data.get('token')
if not isinstance(token, str) or not token:
return Response(
{'status': 'error', 'message': 'token is required'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
payload = load_push_read_token(token)
except signing.SignatureExpired:
return Response(
{'status': 'error', 'message': 'token expired'},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
{'status': 'error', 'message': 'invalid token'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
notification = Notification.objects.select_related('recipient').get(
id=int(payload['notification_id']),
recipient_id=int(payload['user_id']),
deleted=False,
)
chat = Chat.objects.get(id=int(payload['chat_id']))
message = Message.objects.get(
id=int(payload['message_id']),
chat=chat,
is_deleted=False,
)
except (
KeyError,
TypeError,
ValueError,
Notification.DoesNotExist,
Chat.DoesNotExist,
Message.DoesNotExist,
):
return Response(
{'status': 'error', 'message': 'push target not found'},
status=status.HTTP_404_NOT_FOUND,
)
notification_data = (
notification.data if isinstance(notification.data, dict) else {}
)
if (
str(notification_data.get('chat_id')) != str(chat.id)
or str(notification_data.get('message_id')) != str(message.id)
):
return Response(
{'status': 'error', 'message': 'push target mismatch'},
status=status.HTTP_400_BAD_REQUEST,
)
ChatReadMarkerService().mark_read(
chat=chat,
user=notification.recipient,
message=message,
)
read_result = ChatNotificationReadService().mark_chat_notifications_as_read(
user=notification.recipient,
chat_id=chat.id,
)
return Response({
'status': 'success',
'chat_id': chat.id,
'message_id': message.id,
'notifications_read': read_result.count,
})
@api_view(['POST'])
@authentication_classes([])
@permission_classes([AllowAny])
def push_reply_to_chat(request):
"""Send a one-time reply entered in Chromium's native notification UI."""
token = request.data.get('token')
content = request.data.get('reply')
if not isinstance(token, str) or not token:
return Response(
{'status': 'error', 'message': 'token is required'},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(content, str) or not content.strip():
return Response(
{'status': 'error', 'message': 'reply is required'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
payload = load_push_reply_token(token)
except signing.SignatureExpired:
return Response(
{'status': 'error', 'message': 'token expired'},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
{'status': 'error', 'message': 'invalid token'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
with transaction.atomic():
notification = (
Notification.objects.select_for_update()
.select_related('recipient')
.get(
id=int(payload['notification_id']),
recipient_id=int(payload['user_id']),
deleted=False,
)
)
chat = Chat.objects.get(id=int(payload['chat_id']))
source_message = Message.objects.get(
id=int(payload['message_id']),
chat=chat,
is_deleted=False,
)
notification_data = (
notification.data if isinstance(notification.data, dict) else {}
)
if (
str(notification_data.get('chat_id')) != str(chat.id)
or str(notification_data.get('message_id'))
!= str(source_message.id)
):
return Response(
{'status': 'error', 'message': 'push target mismatch'},
status=status.HTTP_400_BAD_REQUEST,
)
if notification_data.get('push_reply_message_id'):
return Response(
{'status': 'error', 'message': 'reply already sent'},
status=status.HTTP_409_CONFLICT,
)
reply_message = ChatQuickReplyService().send(
chat=chat,
user=notification.recipient,
reply_to=source_message,
content=content,
)
notification.data = {
**notification_data,
'push_reply_message_id': reply_message.id,
}
notification.save(update_fields=['data'])
except (
KeyError,
TypeError,
ValueError,
Notification.DoesNotExist,
Chat.DoesNotExist,
Message.DoesNotExist,
):
return Response(
{'status': 'error', 'message': 'push target not found'},
status=status.HTTP_404_NOT_FOUND,
)
except ChatQuickReplyError:
return Response(
{'status': 'error', 'message': 'reply is not allowed'},
status=status.HTTP_403_FORBIDDEN,
)
ChatReadMarkerService().mark_read(
chat=chat,
user=notification.recipient,
message=source_message,
)
ChatNotificationReadService().mark_chat_notifications_as_read(
user=notification.recipient,
chat_id=chat.id,
)
return Response(
{
'status': 'success',
'chat_id': chat.id,
'message_id': reply_message.id,
'reply_to_id': source_message.id,
},
status=status.HTTP_201_CREATED,
)
@api_view(['GET'])
@authentication_classes([])
@permission_classes([AllowAny])
def push_avatar(request):
"""Return a signed avatar as a transparent circular PNG for native push UI."""
token = request.query_params.get('token', '')
try:
payload = load_push_avatar_token(token)
path = payload['path']
if not isinstance(path, str) or not path:
raise signing.BadSignature('missing path')
with default_storage.open(path, 'rb') as avatar_file:
with Image.open(avatar_file) as source:
source = ImageOps.exif_transpose(source).convert('RGBA')
image = ImageOps.fit(
source,
(192, 192),
method=Image.Resampling.LANCZOS,
)
except signing.SignatureExpired:
return Response(status=status.HTTP_410_GONE)
except (signing.BadSignature, KeyError, OSError, UnidentifiedImageError):
return Response(status=status.HTTP_404_NOT_FOUND)
mask = Image.new('L', image.size, 0)
ImageDraw.Draw(mask).ellipse(
(0, 0, image.width - 1, image.height - 1),
fill=255,
)
image.putalpha(mask)
output = BytesIO()
image.save(output, format='PNG', optimize=True)
response = HttpResponse(output.getvalue(), content_type='image/png')
response['Cache-Control'] = 'public, max-age=2592000, immutable'
return response
@extend_schema(
tags=["Notifications"],
summary="Подписаться на Web Push",

View File

@ -0,0 +1,79 @@
from django.core import signing
PUSH_READ_SALT = "notifications.push.read.v1"
PUSH_REPLY_SALT = "notifications.push.reply.v1"
PUSH_AVATAR_SALT = "notifications.push.avatar.v1"
PUSH_READ_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
PUSH_REPLY_MAX_AGE_SECONDS = 24 * 60 * 60
PUSH_AVATAR_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def build_push_read_token(
*,
notification_id: int,
user_id: int,
chat_id: int,
message_id: int,
) -> str:
return signing.dumps(
{
"notification_id": notification_id,
"user_id": user_id,
"chat_id": chat_id,
"message_id": message_id,
},
salt=PUSH_READ_SALT,
compress=True,
)
def load_push_read_token(token: str) -> dict:
return signing.loads(
token,
salt=PUSH_READ_SALT,
max_age=PUSH_READ_MAX_AGE_SECONDS,
)
def build_push_reply_token(
*,
notification_id: int,
user_id: int,
chat_id: int,
message_id: int,
) -> str:
return signing.dumps(
{
"notification_id": notification_id,
"user_id": user_id,
"chat_id": chat_id,
"message_id": message_id,
},
salt=PUSH_REPLY_SALT,
compress=True,
)
def load_push_reply_token(token: str) -> dict:
return signing.loads(
token,
salt=PUSH_REPLY_SALT,
max_age=PUSH_REPLY_MAX_AGE_SECONDS,
)
def build_push_avatar_token(path: str) -> str:
return signing.dumps(
{"path": path},
salt=PUSH_AVATAR_SALT,
compress=True,
)
def load_push_avatar_token(token: str) -> dict:
return signing.loads(
token,
salt=PUSH_AVATAR_SALT,
max_age=PUSH_AVATAR_MAX_AGE_SECONDS,
)

View File

@ -4,6 +4,7 @@ Web Push отправитель для browser push notifications
import json
from datetime import timedelta
from urllib.parse import urlencode
from django.conf import settings
from django.utils import timezone
@ -14,6 +15,11 @@ from ..config import get
from ..models import NotificationDeliveryRouting, NotificationInstallation
from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
from notifications.chat_suppression import suppress_for_active_chat
from notifications.push_actions import (
build_push_avatar_token,
build_push_read_token,
build_push_reply_token,
)
class PushNotificationSender(BaseNotificationSender):
@ -72,20 +78,22 @@ class PushNotificationSender(BaseNotificationSender):
avatar = getattr(actor, "avatar", None)
if not avatar:
return ""
avatar_url = avatar.url
avatar_path = avatar.name
except Exception:
return ""
if not avatar_url:
if not avatar_path:
return ""
if avatar_url.startswith(("http://", "https://")):
return avatar_url
if avatar_url.startswith("/"):
return f"{self._get_site_url()}{avatar_url}"
return f"{self._get_site_url()}/{avatar_url}"
token = build_push_avatar_token(avatar_path)
query = urlencode({"token": token})
return f"{self._get_site_url()}/api/v1/notifications/push/avatar/?{query}"
def _build_title(self, notification) -> str:
def _build_title(self, notification, source_data: dict) -> str:
if notification.verb in self.CHAT_NOTIFICATION_VERBS:
chat_type = source_data.get("chat_type")
chat_name = source_data.get("chat_name")
if chat_type and chat_type != "private" and chat_name:
return str(chat_name).strip()
return self._get_actor_name(notification) or self.DEFAULT_TITLE
return self._get_data_title(notification) or self.DEFAULT_TITLE
@ -112,12 +120,20 @@ class PushNotificationSender(BaseNotificationSender):
preferences is None or preferences.show_notification_preview
)
title = self._build_title(notification)
actor_name = self._get_actor_name(notification)
title = self._build_title(notification, source_data)
body = notification.description or ""
if not show_preview and notification.verb in self.CHAT_NOTIFICATION_VERBS:
title = source_data.get("chat_name") or self.DEFAULT_TITLE
body = "Новое сообщение"
elif len(body) > 300:
elif (
notification.verb in self.CHAT_NOTIFICATION_VERBS
and source_data.get("chat_type") not in (None, "private")
and actor_name
):
body = f"{actor_name}: {body}"
if len(body) > 300:
body = body[:297] + "..."
message_data = {
@ -136,9 +152,60 @@ class PushNotificationSender(BaseNotificationSender):
"notification_id": notification.id,
"chat_id": chat_id,
"message_id": message_id,
"chat_name": source_data.get("chat_name"),
"chat_type": source_data.get("chat_type"),
"can_reply": source_data.get("can_reply", True),
"actor_name": actor_name,
},
}
if (
notification.verb in self.CHAT_NOTIFICATION_VERBS
and chat_id
and message_id
):
try:
read_token = build_push_read_token(
notification_id=notification.id,
user_id=notification.recipient_id,
chat_id=int(chat_id),
message_id=int(message_id),
)
reply_token = build_push_reply_token(
notification_id=notification.id,
user_id=notification.recipient_id,
chat_id=int(chat_id),
message_id=int(message_id),
)
except (TypeError, ValueError):
read_token = ""
reply_token = ""
if read_token and reply_token:
actions = []
if show_preview and source_data.get("can_reply", True):
actions.append({
"action": "reply",
"type": "text",
"title": "Ответить",
"placeholder": "Введите ответ…",
})
actions.append({
"action": "mark-read",
"type": "button",
"title": "Прочитано",
})
message_data["actions"] = actions
message_data["data"].update({
"mark_read_url": "/api/v1/notifications/push/action/read/",
"mark_read_token": read_token,
})
if show_preview and source_data.get("can_reply", True):
message_data["data"].update({
"reply_url": "/api/v1/notifications/push/action/reply/",
"reply_token": reply_token,
})
actor_icon = (
self._get_actor_avatar_url(notification)
if notification.verb in self.CHAT_NOTIFICATION_VERBS and show_preview

View File

@ -381,7 +381,8 @@ class TestChatViewSet:
user2,
):
"""Read receipt содержит данные для обновления списка читателей без API-запроса."""
from communications.api import viewsets as communications_viewsets
from communications.services import chat_read_marker
from communications.services import ChatReadMarkerService
sent_events = []
@ -390,20 +391,20 @@ class TestChatViewSet:
return None
monkeypatch.setattr(
communications_viewsets,
chat_read_marker,
"get_channel_layer",
lambda: FakeChannelLayer(),
)
monkeypatch.setattr(
communications_viewsets,
chat_read_marker,
"async_to_sync",
lambda _callback: lambda group, event: sent_events.append((group, event)),
)
communications_viewsets.ChatViewSet()._send_marked_read_event(
user2,
private_chat.id,
321,
ChatReadMarkerService._broadcast(
user=user2,
chat_id=private_chat.id,
message_id=321,
)
expected_reader = {

View File

@ -1,12 +1,18 @@
from io import BytesIO
from unittest.mock import patch
import pytest
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from PIL import Image
from rest_framework.test import APIRequestFactory
from push_notifications.models import WebPushDevice
from communications.models import Chat, ChatMembership, ChatReadState, Message
from notifications.cache import unread_summary_cache_key
from notifications.models import (
Notification,
@ -14,11 +20,163 @@ from notifications.models import (
NotificationInstallation,
UserChannelPreferences,
)
from notifications.push_actions import (
build_push_avatar_token,
build_push_read_token,
build_push_reply_token,
)
from notifications.api.views import (
push_avatar,
push_mark_chat_read,
push_reply_to_chat,
)
pytestmark = pytest.mark.django_db
class TestPushNotificationActions:
@patch("communications.services.chat_read_marker.ChatReadMarkerService._broadcast")
def test_signed_push_action_marks_chat_and_notifications_read(
self,
broadcast,
user_factory,
):
recipient = user_factory(email="push-read-recipient@example.com")
actor = user_factory(email="push-read-actor@example.com")
chat = Chat.objects.create(type="group", name="Рабочая группа", created_by=actor)
message = Message.objects.create(chat=chat, author=actor, content="Готово")
notification = Notification.objects.create(
recipient=recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.id,
verb="chat_new_message",
description=message.content,
action_url=f"/messages/{chat.id}",
data={"chat_id": chat.id, "message_id": message.id},
)
token = build_push_read_token(
notification_id=notification.id,
user_id=recipient.id,
chat_id=chat.id,
message_id=message.id,
)
request = APIRequestFactory().post(
"/push/action/read/", {"token": token}, format="json"
)
response = push_mark_chat_read(request)
assert response.status_code == 200
notification.refresh_from_db()
assert notification.unread is False
read_state = ChatReadState.objects.get(chat=chat, user=recipient)
assert read_state.last_read_message_id == message.id
assert read_state.unread_count == 0
broadcast.assert_called_once()
def test_push_read_action_rejects_invalid_token(self):
request = APIRequestFactory().post(
"/push/action/read/", {"token": "invalid"}, format="json"
)
response = push_mark_chat_read(request)
assert response.status_code == 400
@patch("communications.notifications.signals.notify_new_message")
@patch("communications.services.chat_read_marker.ChatReadMarkerService._broadcast")
@patch("communications.services.chat_quick_reply.ChatQuickReplyService._broadcast")
def test_signed_native_reply_sends_message_once(
self,
reply_broadcast,
read_broadcast,
notify_new_message,
user_factory,
):
recipient = user_factory(email="push-reply-recipient@example.com")
actor = user_factory(email="push-reply-actor@example.com")
chat = Chat.objects.create(type="group", name="Рабочая группа", created_by=actor)
ChatMembership.objects.create(chat=chat, user=recipient, role="member")
source_message = Message.objects.create(
chat=chat,
author=actor,
content="Какой статус?",
)
notify_new_message.reset_mock()
notification = Notification.objects.create(
recipient=recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.id,
verb="chat_new_message",
description=source_message.content,
action_url=f"/messages/{chat.id}",
data={"chat_id": chat.id, "message_id": source_message.id},
)
token = build_push_reply_token(
notification_id=notification.id,
user_id=recipient.id,
chat_id=chat.id,
message_id=source_message.id,
)
request = APIRequestFactory().post(
"/push/action/reply/",
{"token": token, "reply": " Уже готово "},
format="json",
)
response = push_reply_to_chat(request)
assert response.status_code == 201
reply = Message.objects.get(id=response.data["message_id"])
assert reply.author == recipient
assert reply.chat == chat
assert reply.reply_to == source_message
assert reply.content == "Уже готово"
notification.refresh_from_db()
assert notification.unread is False
assert notification.data["push_reply_message_id"] == reply.id
reply_broadcast.assert_called_once_with(reply)
read_broadcast.assert_called_once()
assert notify_new_message.call_count == 1
replay_request = APIRequestFactory().post(
"/push/action/reply/",
{"token": token, "reply": "Отправить повторно"},
format="json",
)
replay_response = push_reply_to_chat(replay_request)
assert replay_response.status_code == 409
assert Message.objects.filter(author=recipient, chat=chat).count() == 1
def test_push_avatar_has_transparent_round_corners(
self,
tmp_path,
):
source = Image.new("RGB", (80, 40), "#38bdf8")
source_bytes = BytesIO()
source.save(source_bytes, format="PNG")
avatar_path = "push-tests/avatar.png"
with override_settings(MEDIA_ROOT=tmp_path):
from django.core.files.storage import storages
storages["default"].save(
avatar_path, ContentFile(source_bytes.getvalue())
)
token = build_push_avatar_token(avatar_path)
request = APIRequestFactory().get(
"/push/avatar/", {"token": token}
)
response = push_avatar(request)
assert response.status_code == 200
assert response["Content-Type"] == "image/png"
rendered = Image.open(BytesIO(response.content)).convert("RGBA")
assert rendered.size == (192, 192)
assert rendered.getpixel((0, 0))[3] == 0
assert rendered.getpixel((96, 96))[3] == 255
class TestNotificationList:
def test_notification_actors_are_prefetched(self, api_client, user_factory):
recipient = user_factory()

View File

@ -78,6 +78,8 @@ class TestPushNotificationSenderPayload:
data={
"title": "Новое сообщение от Рубцова Олеся",
"chat_id": 123,
"chat_name": "Личный чат",
"chat_type": "private",
"message_id": 456,
},
)
@ -88,12 +90,30 @@ class TestPushNotificationSenderPayload:
assert payload["head"] == "Рубцова Олеся"
assert payload["body"] == "Полинина"
assert payload["url"] == "/messages/123"
assert payload["data"] == {
"url": "/messages/123",
"notification_id": notification.id,
"chat_id": 123,
"message_id": 456,
}
assert payload["data"]["url"] == "/messages/123"
assert payload["data"]["notification_id"] == notification.id
assert payload["data"]["chat_id"] == 123
assert payload["data"]["message_id"] == 456
assert payload["data"]["chat_name"] == "Личный чат"
assert payload["data"]["chat_type"] == "private"
assert payload["data"]["actor_name"] == "Рубцова Олеся"
assert payload["data"]["mark_read_url"].endswith("/push/action/read/")
assert payload["data"]["mark_read_token"]
assert payload["data"]["reply_url"].endswith("/push/action/reply/")
assert payload["data"]["reply_token"]
assert payload["actions"] == [
{
"action": "reply",
"type": "text",
"title": "Ответить",
"placeholder": "Введите ответ…",
},
{
"action": "mark-read",
"type": "button",
"title": "Прочитано",
},
]
assert payload["tag"] == "chat-123"
assert "icon" not in payload
assert "chat_new_message" not in json.dumps(payload)
@ -124,6 +144,7 @@ class TestPushNotificationSenderPayload:
data={
"chat_id": chat.id,
"chat_name": chat.name,
"chat_type": chat.type,
"message_id": 77,
},
)
@ -133,8 +154,45 @@ class TestPushNotificationSenderPayload:
assert payload["title"] == "Секретный чат"
assert payload["body"] == "Новое сообщение"
assert payload["silent"] is True
assert payload["actions"] == [
{
"action": "mark-read",
"type": "button",
"title": "Прочитано",
},
]
assert "reply_token" not in payload["data"]
assert "Секретный текст" not in json.dumps(payload, ensure_ascii=False)
def test_group_chat_push_shows_chat_then_sender(self, user_factory):
recipient = user_factory(email="group-recipient@example.com")
actor = user_factory(
email="group-actor@example.com",
first_name="Олеся",
last_name="Рубцова",
)
_make_push_device(recipient)
notification = _make_notification(
recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.pk,
verb="chat_new_message",
description="Проверила макет",
action_url="/messages/321",
data={
"chat_id": 321,
"chat_name": "Команда продукта",
"chat_type": "group",
"message_id": 654,
},
)
payload = _send_and_load_payload(notification)
assert payload["title"] == "Команда продукта"
assert payload["body"] == "Рубцова Олеся: Проверила макет"
assert payload["tag"] == "chat-321"
@override_settings(SITE_URL="https://corp.example.test")
def test_chat_push_uses_actor_avatar_as_icon(self, user_factory):
recipient = user_factory(email="avatar-recipient@example.com")
@ -163,9 +221,8 @@ class TestPushNotificationSenderPayload:
payload = _send_and_load_payload(notification)
assert (
payload["icon"]
== "https://corp.example.test/media/users/avatars/actor.jpg"
assert payload["icon"].startswith(
"https://corp.example.test/api/v1/notifications/push/avatar/?token="
)
def test_non_chat_push_uses_notification_data_title(self, user_factory):

View File

@ -1427,8 +1427,21 @@ def test_opening_board_marks_only_its_notifications_as_read(
def test_board_member_reorders_columns_for_everyone(api_client, user):
other_user = make_user("task-column-order@example.com", "+79994440093")
first_department = Department.objects.create(name="Первый отдел порядка")
second_department = Department.objects.create(name="Второй отдел порядка")
EmployeeDepartment.objects.create(
employee=other_user,
department=first_department,
is_active=True,
)
EmployeeDepartment.objects.create(
employee=other_user,
department=second_department,
is_active=True,
)
board = TaskBoard.objects.create(name="Общий порядок", created_by=user)
board.members.add(user, other_user)
board.departments.add(first_department, second_department)
first = TaskColumn.objects.create(
board=board,
name="Первая",

View File

@ -6,7 +6,7 @@
* нативные уведомления даже когда страница закрыта.
*/
const SW_VERSION = '2.2.1';
const SW_VERSION = '2.4.0';
const SW_APP_KIND = self.CORP_SW_APP_KIND || 'main';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
@ -30,6 +30,13 @@ const DEFAULT_NOTIFICATION_OPTIONS = {
renotify: true,
};
function supportsNativeInlineReply() {
const userAgent = self.navigator?.userAgent || '';
const isChromium = /(?:Chrome|Chromium|Edg|OPR)\//.test(userAgent);
const isSupportedPlatform = /(?:Windows NT|Android|CrOS)/.test(userAgent);
return isChromium && isSupportedPlatform;
}
function buildNotificationData(payload = {}) {
const notificationData = {
title: payload.title || payload.head || 'Новое уведомление',
@ -49,7 +56,11 @@ function buildNotificationData(payload = {}) {
notificationData.vibrate = payload.vibrate || DEFAULT_NOTIFICATION_OPTIONS.vibrate;
}
if (payload.image) notificationData.image = payload.image;
if (payload.actions) notificationData.actions = payload.actions;
if (payload.actions) {
notificationData.actions = payload.actions.filter(
(action) => action.type !== 'text' || supportsNativeInlineReply(),
);
}
return notificationData;
}
@ -95,6 +106,81 @@ async function applyNotificationControl(payload) {
await self.registration.showNotification(title, options);
}
async function markPushNotificationRead(notification) {
const data = notification.data || {};
if (!data.mark_read_url || !data.mark_read_token) {
throw new Error('Push notification has no signed read action');
}
const response = await fetch(
new URL(data.mark_read_url, self.location.origin).toString(),
{
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: data.mark_read_token }),
},
);
if (!response.ok) {
throw new Error(`Push read action failed with HTTP ${response.status}`);
}
const notifications = await self.registration.getNotifications({ includeTriggered: true });
notifications
.filter((item) => sameIdentity(item.data?.chat_id, data.chat_id))
.forEach((item) => item.close());
const windowClients = await clients.matchAll({ type: 'window', includeUncontrolled: true });
windowClients.forEach((client) => client.postMessage({
type: 'PUSH_CHAT_MARKED_READ',
chatId: data.chat_id,
messageId: data.message_id,
notificationId: data.notification_id,
}));
}
async function sendPushQuickReply(notification, reply) {
const data = notification.data || {};
const normalizedReply = typeof reply === 'string' ? reply.trim() : '';
if (!normalizedReply) {
throw new Error('Native notification did not provide reply text');
}
if (!data.reply_url || !data.reply_token) {
throw new Error('Push notification has no signed reply action');
}
const response = await fetch(
new URL(data.reply_url, self.location.origin).toString(),
{
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: data.reply_token,
reply: normalizedReply,
}),
},
);
if (!response.ok) {
throw new Error(`Push reply failed with HTTP ${response.status}`);
}
const result = await response.json();
const notifications = await self.registration.getNotifications({ includeTriggered: true });
notifications
.filter((item) => sameIdentity(item.data?.chat_id, data.chat_id))
.forEach((item) => item.close());
const windowClients = await clients.matchAll({ type: 'window', includeUncontrolled: true });
windowClients.forEach((client) => client.postMessage({
type: 'PUSH_CHAT_QUICK_REPLIED',
chatId: data.chat_id,
messageId: result.message_id,
replyToId: data.message_id,
notificationId: data.notification_id,
}));
}
/**
* Событие установки Service Worker
*/
@ -242,7 +328,29 @@ self.addEventListener('message', (event) => {
*/
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked:', event.notification.tag);
if (event.action === 'reply') {
if (typeof event.reply !== 'string' || !event.reply.trim()) {
console.warn('[SW] Native inline reply is unavailable on this platform');
return;
}
event.waitUntil(
sendPushQuickReply(event.notification, event.reply).catch((error) => {
console.error('[SW] Failed to send push reply:', error);
}),
);
return;
}
if (event.action === 'mark-read') {
event.waitUntil(
markPushNotificationRead(event.notification).catch((error) => {
console.error('[SW] Failed to mark push notification as read:', error);
}),
);
return;
}
event.notification.close();
// Получаем URL из данных уведомления