diff --git a/apps/backend/hub_platform/conversations/chat_extras_views.py b/apps/backend/hub_platform/conversations/chat_extras_views.py new file mode 100644 index 0000000..c68a120 --- /dev/null +++ b/apps/backend/hub_platform/conversations/chat_extras_views.py @@ -0,0 +1,345 @@ +"""Дизайн-базлайн v2: приоритет, метки, заметка, архив, шаблоны, счётчики. + +Решения владельца (2026-09-04): метки и приоритет входят в модель; «удалить +диалог» = архив, архив видят только администраторы; шаблоны ответов общие на +организацию (редактируют администраторы, используют все). +""" + +from __future__ import annotations + +from django.db.models import Count +from django.utils import timezone +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.api.permissions import HasCapability +from hub_platform.conversations.models import ( + Conversation, + ConversationLabel, + ConversationPriority, + ControlMode, + LifecycleState, + ReplyTemplate, +) +from hub_platform.conversations.selectors import apply_conversation_visibility +from hub_platform.conversations.serializers import conversation_payload +from hub_platform.conversations.view_base import ConversationViewBase +from hub_platform.identity.policy import can_administer_access + + +def _label_payload(label: ConversationLabel) -> dict[str, object]: + return {"id": label.id, "name": label.name, "color": label.color} + + +def _template_payload(template: ReplyTemplate) -> dict[str, object]: + return { + "id": template.id, + "title": template.title, + "text": template.text, + "updatedAt": template.updated_at.isoformat(), + } + + +class ConversationPriorityView(ConversationViewBase): + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + conversation = self._conversation( + request, conversation_id, self.required_capability + ) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + priority = str(request.data.get("priority", "")).strip().upper() + if priority not in ConversationPriority.values: + return Response({"detail": "Неизвестный приоритет"}, status=400) + conversation.priority = priority + conversation.save(update_fields=["priority"]) + self._audit(request, "priority_changed", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, with_messages=True, viewer_id=request.user.id + ) + } + ) + + +class ConversationNoteView(ConversationViewBase): + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + conversation = self._conversation( + request, conversation_id, self.required_capability + ) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + note = str(request.data.get("note", "")) + if len(note) > 4000: + return Response({"detail": "Заметка длиннее 4000 символов"}, status=400) + conversation.note = note + conversation.save(update_fields=["note"]) + return Response( + { + "conversation": conversation_payload( + conversation, with_messages=True, viewer_id=request.user.id + ) + } + ) + + +class ConversationLabelsView(ConversationViewBase): + """Полная замена набора меток диалога (чипы в карточке).""" + + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + conversation = self._conversation( + request, conversation_id, self.required_capability + ) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + label_ids = request.data.get("labelIds") + if not isinstance(label_ids, list) or not all( + isinstance(item, int) for item in label_ids + ): + return Response({"detail": "labelIds must be a list of ids"}, status=400) + labels = list( + ConversationLabel.objects.filter( + organization_id=request.tenant_context.organization_id, + id__in=set(label_ids), + ) + ) + if len(labels) != len(set(label_ids)): + return Response({"detail": "Неизвестная метка"}, status=400) + conversation.labels.set(labels) + return Response( + { + "conversation": conversation_payload( + conversation, with_messages=True, viewer_id=request.user.id + ) + } + ) + + +class ConversationArchiveView(ConversationViewBase): + """«Удалить диалог» = архив: скрыт из списков, восстановить может только + администратор.""" + + required_capability = "conversations.operate" + + def post(self, request: Request, conversation_id: int) -> Response: + try: + conversation = self._conversation( + request, conversation_id, self.required_capability + ) + except Conversation.DoesNotExist: + return Response({"detail": "Диалог не найден"}, status=404) + archived = request.data.get("archived") + if not isinstance(archived, bool): + return Response({"detail": "archived must be a boolean"}, status=400) + if not archived and not can_administer_access(request.tenant_context.membership): + return Response( + {"detail": "Восстановить диалог может только администратор"}, status=403 + ) + conversation.archived_at = timezone.now() if archived else None + conversation.save(update_fields=["archived_at"]) + self._audit(request, "archived" if archived else "unarchived", conversation) + return Response( + { + "conversation": conversation_payload( + conversation, with_messages=True, viewer_id=request.user.id + ) + } + ) + + +class ConversationCountersView(ConversationViewBase): + """Счётчики для дерева фильтров (сайдбар сотрудника / охват админа).""" + + def get(self, request: Request) -> Response: + # Чистый queryset без инбокс-аннотаций: values().annotate() иначе + # группировал бы по _last_message_at. + base = ( + apply_conversation_visibility( + Conversation.objects.filter( + organization_id=request.tenant_context.organization_id + ), + request.tenant_context, + ) + .filter(archived_at__isnull=True) + .exclude(lifecycle=LifecycleState.SPAM) + .order_by() + ) + open_qs = base.filter(lifecycle=LifecycleState.OPEN) + groups = [ + {"id": row["group_id"], "name": row["group__name"], "count": row["count"]} + for row in open_qs.filter(group__isnull=False) + .values("group_id", "group__name") + .annotate(count=Count("id")) + .order_by("group__name") + ] + ungrouped = open_qs.filter(group__isnull=True).count() + agents = [ + { + "id": row["channel_id"], + "name": row["channel__name"], + "count": row["count"], + } + for row in open_qs.values("channel_id", "channel__name") + .annotate(count=Count("id")) + .order_by("channel__name") + ] + return Response( + { + "all": open_qs.count(), + "waiting": open_qs.filter(control_mode=ControlMode.PAUSED).count(), + "mine": base.filter(assigned_operator_id=request.user.id).count(), + "ungrouped": ungrouped, + "groups": groups, + "agents": agents, + } + ) + + +class LabelListView(APIView): + """Словарь меток организации. Создание — лёгкое действие оператора + («+ Добавить» в карточке), правка/удаление — администратора.""" + + permission_classes = [HasCapability] + required_capabilities = { + "GET": "conversations.view", + "POST": "conversations.operate", + } + + def get(self, request: Request) -> Response: + labels = ConversationLabel.objects.filter( + organization_id=request.tenant_context.organization_id + ) + return Response({"items": [_label_payload(label) for label in labels]}) + + def post(self, request: Request) -> Response: + name = str(request.data.get("name", "")).strip() + color = str(request.data.get("color", "")).strip() + if not name or len(name) > 60: + return Response({"detail": "Название метки: 1-60 символов"}, status=400) + if len(color) > 20: + return Response({"detail": "Некорректный цвет"}, status=400) + existing = ConversationLabel.objects.filter( + organization_id=request.tenant_context.organization_id, name__iexact=name + ).first() + if existing is not None: + return Response({"label": _label_payload(existing)}) + label = ConversationLabel.objects.create( + organization_id=request.tenant_context.organization_id, + name=name, + color=color, + ) + return Response({"label": _label_payload(label)}, status=201) + + +class LabelDetailView(APIView): + permission_classes = [HasCapability] + required_capabilities = {"PATCH": "settings.manage", "DELETE": "settings.manage"} + + def _label(self, request: Request, label_id: int) -> ConversationLabel: + return ConversationLabel.objects.get( + organization_id=request.tenant_context.organization_id, id=label_id + ) + + def patch(self, request: Request, label_id: int) -> Response: + try: + label = self._label(request, label_id) + except ConversationLabel.DoesNotExist: + return Response({"detail": "Метка не найдена"}, status=404) + if "name" in request.data: + name = str(request.data.get("name", "")).strip() + if not name or len(name) > 60: + return Response({"detail": "Название метки: 1-60 символов"}, status=400) + label.name = name + if "color" in request.data: + color = str(request.data.get("color", "")).strip() + if len(color) > 20: + return Response({"detail": "Некорректный цвет"}, status=400) + label.color = color + label.save() + return Response({"label": _label_payload(label)}) + + def delete(self, request: Request, label_id: int) -> Response: + try: + label = self._label(request, label_id) + except ConversationLabel.DoesNotExist: + return Response({"detail": "Метка не найдена"}, status=404) + label.delete() + return Response(status=204) + + +class ReplyTemplateListView(APIView): + permission_classes = [HasCapability] + required_capabilities = { + "GET": "conversations.view", + "POST": "settings.manage", + } + + def get(self, request: Request) -> Response: + templates = ReplyTemplate.objects.filter( + organization_id=request.tenant_context.organization_id + ) + return Response({"items": [_template_payload(item) for item in templates]}) + + def post(self, request: Request) -> Response: + title = str(request.data.get("title", "")).strip() + text = str(request.data.get("text", "")).strip() + if not title or len(title) > 120: + return Response({"detail": "Название шаблона: 1-120 символов"}, status=400) + if not text: + return Response({"detail": "Текст шаблона обязателен"}, status=400) + if ReplyTemplate.objects.filter( + organization_id=request.tenant_context.organization_id, title__iexact=title + ).exists(): + return Response({"detail": "Шаблон с таким названием уже есть"}, status=409) + template = ReplyTemplate.objects.create( + organization_id=request.tenant_context.organization_id, + title=title, + text=text, + ) + return Response({"template": _template_payload(template)}, status=201) + + +class ReplyTemplateDetailView(APIView): + permission_classes = [HasCapability] + required_capabilities = {"PATCH": "settings.manage", "DELETE": "settings.manage"} + + def _template(self, request: Request, template_id: int) -> ReplyTemplate: + return ReplyTemplate.objects.get( + organization_id=request.tenant_context.organization_id, id=template_id + ) + + def patch(self, request: Request, template_id: int) -> Response: + try: + template = self._template(request, template_id) + except ReplyTemplate.DoesNotExist: + return Response({"detail": "Шаблон не найден"}, status=404) + if "title" in request.data: + title = str(request.data.get("title", "")).strip() + if not title or len(title) > 120: + return Response({"detail": "Название шаблона: 1-120 символов"}, status=400) + template.title = title + if "text" in request.data: + text = str(request.data.get("text", "")).strip() + if not text: + return Response({"detail": "Текст шаблона обязателен"}, status=400) + template.text = text + template.save() + return Response({"template": _template_payload(template)}) + + def delete(self, request: Request, template_id: int) -> Response: + try: + template = self._template(request, template_id) + except ReplyTemplate.DoesNotExist: + return Response({"detail": "Шаблон не найден"}, status=404) + template.delete() + return Response(status=204) diff --git a/apps/backend/hub_platform/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py b/apps/backend/hub_platform/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py new file mode 100644 index 0000000..f0fd579 --- /dev/null +++ b/apps/backend/hub_platform/conversations/migrations/0011_conversation_archived_at_conversation_note_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.15 on 2026-09-04 16:41 + +import django.db.models.deletion +import django.db.models.functions.text +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conversations', '0010_conversation_group'), + ('identity', '0020_employeegroup_employeegroupmember_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='archived_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='conversation', + name='note', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='conversation', + name='priority', + field=models.CharField(choices=[('HIGH', 'Высокий'), ('MEDIUM', 'Средний'), ('LOW', 'Низкий'), ('NONE', 'Не задан')], default='NONE', max_length=8), + ), + migrations.CreateModel( + name='ConversationLabel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=60)), + ('color', models.CharField(blank=True, max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='conversation_labels', to='identity.organization')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='conversation', + name='labels', + field=models.ManyToManyField(blank=True, related_name='conversations', to='conversations.conversationlabel'), + ), + migrations.CreateModel( + name='ReplyTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=120)), + ('text', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reply_templates', to='identity.organization')), + ], + options={ + 'ordering': ['title'], + }, + ), + migrations.AddConstraint( + model_name='conversationlabel', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_conversation_label_org_name_ci'), + ), + migrations.AddConstraint( + model_name='replytemplate', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('title'), models.F('organization'), name='uniq_reply_template_org_title_ci'), + ), + ] diff --git a/apps/backend/hub_platform/conversations/models.py b/apps/backend/hub_platform/conversations/models.py index e1b2c20..b2a7038 100644 --- a/apps/backend/hub_platform/conversations/models.py +++ b/apps/backend/hub_platform/conversations/models.py @@ -50,6 +50,38 @@ class LifecycleState(models.TextChoices): SPAM = "SPAM", "Спам" +class ConversationPriority(models.TextChoices): + # Приоритет диалога (дизайн-базлайн v2, решение владельца 2026-09-04). + HIGH = "HIGH", "Высокий" + MEDIUM = "MEDIUM", "Средний" + LOW = "LOW", "Низкий" + NONE = "NONE", "Не задан" + + +class ConversationLabel(models.Model): + """Метка диалога: цветной чип, общий словарь организации.""" + + organization = models.ForeignKey( + "identity.Organization", on_delete=models.PROTECT, related_name="conversation_labels" + ) + name = models.CharField(max_length=60) + # HEX-цвет чипа; палитру предлагает клиент. + color = models.CharField(max_length=20, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["name"] + constraints = [ + models.UniqueConstraint( + models.functions.Lower("name"), "organization", + name="uniq_conversation_label_org_name_ci", + ) + ] + + def __str__(self) -> str: + return f"label:{self.organization_id}/{self.name}" + + class ControlMode(models.TextChoices): AI = "AI", "AI" HUMAN = "HUMAN", "Оператор" @@ -97,6 +129,15 @@ class Conversation(models.Model): ) # «Ответственный» (ADR-HUB-0043): видит диалог независимо от групп. assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations") + # Дизайн-базлайн v2: приоритет, метки и заметка оператора. + priority = models.CharField( + max_length=8, choices=ConversationPriority.choices, default=ConversationPriority.NONE + ) + labels = models.ManyToManyField(ConversationLabel, blank=True, related_name="conversations") + note = models.TextField(blank=True) + # «Удалить диалог» = архив (решение владельца): скрыт из списков, видят + # только администраторы; данные не удаляются. + archived_at = models.DateTimeField(null=True, blank=True) previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+") created_at = models.DateTimeField(auto_now_add=True) last_activity_at = models.DateTimeField(auto_now_add=True, db_index=True) @@ -171,3 +212,28 @@ class Message(TenantRelationModel): def __str__(self) -> str: return f"msg:{self.conversation_id}/{self.author_type}" + + +class ReplyTemplate(models.Model): + """Шаблон ответа оператора («/» в композере). Общий на организацию: + редактируют администраторы, используют все сотрудники.""" + + organization = models.ForeignKey( + "identity.Organization", on_delete=models.PROTECT, related_name="reply_templates" + ) + title = models.CharField(max_length=120) + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["title"] + constraints = [ + models.UniqueConstraint( + models.functions.Lower("title"), "organization", + name="uniq_reply_template_org_title_ci", + ) + ] + + def __str__(self) -> str: + return f"template:{self.organization_id}/{self.title}" diff --git a/apps/backend/hub_platform/conversations/selectors.py b/apps/backend/hub_platform/conversations/selectors.py index 7fb567d..8ba7981 100644 --- a/apps/backend/hub_platform/conversations/selectors.py +++ b/apps/backend/hub_platform/conversations/selectors.py @@ -17,6 +17,7 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]: "support_identity_snapshot", "group", ) + .prefetch_related("labels") # Инбокс сортируется по времени последнего сообщения (а не по служебной # активности вроде claim/takeover); fallback — last_activity_at для # диалогов без сообщений. diff --git a/apps/backend/hub_platform/conversations/serializers.py b/apps/backend/hub_platform/conversations/serializers.py index 09969e4..103aa21 100644 --- a/apps/backend/hub_platform/conversations/serializers.py +++ b/apps/backend/hub_platform/conversations/serializers.py @@ -155,6 +155,21 @@ def conversation_payload( "isAssignedToViewer": bool( viewer_id and conversation.assigned_operator_id == viewer_id ), + "group": ( + {"id": conversation.group_id, "name": conversation.group.name} + if conversation.group_id + else None + ), + # Дизайн-базлайн v2: приоритет, метки, заметка, архив. + "priority": conversation.priority, + "labels": [ + {"id": label.id, "name": label.name, "color": label.color} + for label in conversation.labels.all() + ], + "note": conversation.note, + "archivedAt": ( + conversation.archived_at.isoformat() if conversation.archived_at else None + ), "lastActivityAt": conversation.last_activity_at.isoformat(), "createdAt": conversation.created_at.isoformat(), } diff --git a/apps/backend/hub_platform/conversations/test_chat_extras.py b/apps/backend/hub_platform/conversations/test_chat_extras.py new file mode 100644 index 0000000..74c9cca --- /dev/null +++ b/apps/backend/hub_platform/conversations/test_chat_extras.py @@ -0,0 +1,332 @@ +import json + +from django.test import TestCase +from hub_platform.testing import TenantAPIClient as APIClient + +from hub_platform.channels.models import Channel +from hub_platform.conversations.models import ( + Contact, + Conversation, + ConversationLabel, + ConversationPriority, + ControlMode, + LifecycleState, + ReplyTemplate, +) +from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember +from hub_platform.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) + + +class ChatExtrasTestCase(TestCase): + """Дизайн-базлайн v2: приоритет, метки, заметка, архив, шаблоны, счётчики.""" + + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Chat", slug="chat-extras") + self.operators = EmployeeGroup.objects.create( + organization=self.organization, name="Операторы" + ) + self.owner = self._member("owner@chat.test", EmployeeRole.OWNER) + self.employee = self._member("employee@chat.test", EmployeeRole.EMPLOYEE) + EmployeeGroupMember.objects.create( + organization=self.organization, group=self.operators, employee=self.employee + ) + self.channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + self.conversation = self._conversation("Иван") + self.client = APIClient() + self.client.force_authenticate(self.employee.user) + self.admin_client = APIClient() + self.admin_client.force_authenticate(self.owner.user) + + def _member(self, email: str, role: str) -> OrganizationMembership: + user = HumanUser.objects.create_user(email=email, password="Password-123") + return OrganizationMembership.objects.create( + user=user, + organization=self.organization, + role=role, + position_title="Specialist", + ) + + def _conversation(self, name: str, **extra) -> Conversation: + contact = Contact.objects.create(organization=self.organization, name=name) + return Conversation.objects.create( + organization=self.organization, + channel=self.channel, + contact=contact, + **extra, + ) + + def _post(self, client, suffix: str, body: dict): + return client.post( + f"/api/v1/conversations/{self.conversation.id}/{suffix}/", + data=json.dumps(body), + content_type="application/json", + ) + + +class PriorityNoteTests(ChatExtrasTestCase): + def test_operator_sets_priority_and_note(self) -> None: + response = self._post(self.client, "priority", {"priority": "HIGH"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["conversation"]["priority"], "HIGH") + + response = self._post(self.client, "note", {"note": "Просила писать на почту"}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["conversation"]["note"], "Просила писать на почту" + ) + + def test_unknown_priority_is_rejected(self) -> None: + response = self._post(self.client, "priority", {"priority": "URGENT"}) + self.assertEqual(response.status_code, 400) + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.priority, ConversationPriority.NONE) + + +class LabelTests(ChatExtrasTestCase): + def test_operator_creates_label_and_assigns_it(self) -> None: + created = self.client.post( + "/api/v1/conversations/labels/", + data=json.dumps({"name": "доставка", "color": "#1677ff"}), + content_type="application/json", + ) + self.assertEqual(created.status_code, 201) + label_id = created.json()["label"]["id"] + + # Повтор с тем же именем идемпотентен. + repeat = self.client.post( + "/api/v1/conversations/labels/", + data=json.dumps({"name": "Доставка"}), + content_type="application/json", + ) + self.assertEqual(repeat.status_code, 200) + self.assertEqual(repeat.json()["label"]["id"], label_id) + + assigned = self._post(self.client, "labels", {"labelIds": [label_id]}) + self.assertEqual(assigned.status_code, 200) + self.assertEqual( + [item["name"] for item in assigned.json()["conversation"]["labels"]], + ["доставка"], + ) + + cleared = self._post(self.client, "labels", {"labelIds": []}) + self.assertEqual(cleared.json()["conversation"]["labels"], []) + + def test_foreign_label_is_rejected(self) -> None: + other = Organization.objects.create(name="Other", slug="chat-extras-other") + foreign = ConversationLabel.objects.create(organization=other, name="чужая") + response = self._post(self.client, "labels", {"labelIds": [foreign.id]}) + self.assertEqual(response.status_code, 400) + + def test_label_management_requires_admin(self) -> None: + label = ConversationLabel.objects.create( + organization=self.organization, name="возврат" + ) + renamed = self.client.patch( + f"/api/v1/conversations/labels/{label.id}/", + data=json.dumps({"name": "обмен"}), + content_type="application/json", + ) + self.assertEqual(renamed.status_code, 403) + renamed = self.admin_client.patch( + f"/api/v1/conversations/labels/{label.id}/", + data=json.dumps({"name": "обмен"}), + content_type="application/json", + ) + self.assertEqual(renamed.status_code, 200) + deleted = self.admin_client.delete(f"/api/v1/conversations/labels/{label.id}/") + self.assertEqual(deleted.status_code, 204) + + +class ArchiveTests(ChatExtrasTestCase): + def test_archived_dialog_is_hidden_from_lists_and_admin_restores(self) -> None: + response = self._post(self.client, "archive", {"archived": True}) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.json()["conversation"]["archivedAt"]) + + listed = self.client.get("/api/v1/conversations/") + self.assertEqual(listed.json()["items"], []) + + # Архив видит только администратор. + denied = self.client.get("/api/v1/conversations/?archived=1") + self.assertEqual(denied.status_code, 403) + archive = self.admin_client.get("/api/v1/conversations/?archived=1") + self.assertEqual( + [item["id"] for item in archive.json()["items"]], [self.conversation.id] + ) + + # Восстановление — только администратор. + rejected = self._post(self.client, "archive", {"archived": False}) + self.assertEqual(rejected.status_code, 403) + restored = self._post(self.admin_client, "archive", {"archived": False}) + self.assertEqual(restored.status_code, 200) + self.assertIsNone(restored.json()["conversation"]["archivedAt"]) + + def test_spam_is_hidden_from_default_list(self) -> None: + self.conversation.lifecycle = LifecycleState.SPAM + self.conversation.save(update_fields=["lifecycle"]) + listed = self.client.get("/api/v1/conversations/") + self.assertEqual(listed.json()["items"], []) + spam = self.client.get("/api/v1/conversations/?lifecycle=SPAM") + self.assertEqual(len(spam.json()["items"]), 1) + + +class ListFilterTests(ChatExtrasTestCase): + def test_filters_by_agent_assigned_and_waiting(self) -> None: + other_channel = Channel.objects.create( + organization=self.organization, code="second", name="Вторая линия" + ) + waiting = self._conversation("Ожидающий", control_mode=ControlMode.PAUSED) + mine = self._conversation("Мой", assigned_operator=self.employee.user) + Conversation.objects.filter(id=mine.id).update(channel=other_channel) + + by_agent = self.client.get(f"/api/v1/conversations/?agent={other_channel.id}") + self.assertEqual([i["id"] for i in by_agent.json()["items"]], [mine.id]) + + assigned = self.client.get("/api/v1/conversations/?assigned=me") + self.assertEqual([i["id"] for i in assigned.json()["items"]], [mine.id]) + + waits = self.client.get("/api/v1/conversations/?waiting=1") + self.assertEqual([i["id"] for i in waits.json()["items"]], [waiting.id]) + + def test_search_matches_contact_name(self) -> None: + self._conversation("Мария Соколова") + found = self.client.get("/api/v1/conversations/?q=соколова") + self.assertEqual(len(found.json()["items"]), 1) + empty = self.client.get("/api/v1/conversations/?q=нет-такого") + self.assertEqual(found.status_code, 200) + self.assertEqual(empty.json()["items"], []) + + +class CountersTests(ChatExtrasTestCase): + def test_counters_reflect_visibility(self) -> None: + support = EmployeeGroup.objects.create( + organization=self.organization, name="Поддержка" + ) + self._conversation("Групповой", group=self.operators) + self._conversation("Чужой", group=support) + self._conversation("Мой", assigned_operator=self.employee.user) + self._conversation( + "Ожидающий", group=self.operators, control_mode=ControlMode.PAUSED + ) + + response = self.client.get("/api/v1/conversations/counters/") + payload = response.json() + # EMPLOYEE не видит диалог чужой группы. + self.assertEqual(payload["all"], 4) + self.assertEqual(payload["waiting"], 1) + self.assertEqual(payload["mine"], 1) + self.assertEqual( + payload["groups"], + [{"id": self.operators.id, "name": "Операторы", "count": 2}], + ) + self.assertEqual(payload["ungrouped"], 2) + + admin = self.admin_client.get("/api/v1/conversations/counters/").json() + self.assertEqual(admin["all"], 5) + self.assertEqual(len(admin["groups"]), 2) + + +class ReplyTemplateTests(ChatExtrasTestCase): + def test_admin_manages_templates_employee_reads(self) -> None: + created = self.admin_client.post( + "/api/v1/conversations/templates/", + data=json.dumps({"title": "Приветствие", "text": "Здравствуйте!"}), + content_type="application/json", + ) + self.assertEqual(created.status_code, 201) + template_id = created.json()["template"]["id"] + + duplicate = self.admin_client.post( + "/api/v1/conversations/templates/", + data=json.dumps({"title": "приветствие", "text": "Привет"}), + content_type="application/json", + ) + self.assertEqual(duplicate.status_code, 409) + + forbidden = self.client.post( + "/api/v1/conversations/templates/", + data=json.dumps({"title": "X", "text": "Y"}), + content_type="application/json", + ) + self.assertEqual(forbidden.status_code, 403) + + listed = self.client.get("/api/v1/conversations/templates/") + self.assertEqual( + [item["title"] for item in listed.json()["items"]], ["Приветствие"] + ) + + updated = self.admin_client.patch( + f"/api/v1/conversations/templates/{template_id}/", + data=json.dumps({"text": "Здравствуйте! Чем помочь?"}), + content_type="application/json", + ) + self.assertEqual(updated.status_code, 200) + deleted = self.admin_client.delete( + f"/api/v1/conversations/templates/{template_id}/" + ) + self.assertEqual(deleted.status_code, 204) + self.assertFalse(ReplyTemplate.objects.exists()) + + +class LaunchChecklistTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="New", slug="launch-org") + owner = HumanUser.objects.create_user( + email="owner@launch.test", password="Password-123" + ) + OrganizationMembership.objects.create( + user=owner, + organization=self.organization, + role=EmployeeRole.OWNER, + position_title="Owner", + ) + self.client = APIClient() + self.client.force_authenticate(owner) + + def test_checklist_marks_steps_by_fact(self) -> None: + initial = self.client.get("/api/v1/company/launch-checklist/").json() + self.assertEqual( + initial, + { + "agentCreated": False, + "connectionBound": False, + "employeeInvited": False, + "done": False, + }, + ) + + channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + from hub_platform.integrations.models import ( + Integration, + IntegrationKind, + IntegrationProvider, + ) + + Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="Bot", + channel=channel, + ) + second = HumanUser.objects.create_user( + email="second@launch.test", password="Password-123" + ) + OrganizationMembership.objects.create( + user=second, + organization=self.organization, + role=EmployeeRole.EMPLOYEE, + position_title="Operator", + ) + + final = self.client.get("/api/v1/company/launch-checklist/").json() + self.assertTrue(final["done"]) diff --git a/apps/backend/hub_platform/conversations/urls.py b/apps/backend/hub_platform/conversations/urls.py index fdd033d..ede98aa 100644 --- a/apps/backend/hub_platform/conversations/urls.py +++ b/apps/backend/hub_platform/conversations/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from hub_platform.conversations import reporting_views, views +from hub_platform.conversations import chat_extras_views, reporting_views, views urlpatterns = [ path("", views.ConversationListView.as_view(), name="conversation-list"), @@ -18,4 +18,13 @@ urlpatterns = [ path("/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"), path("/group/", views.ConversationGroupView.as_view(), name="conversation-group"), path("/assignee/", views.ConversationAssigneeView.as_view(), name="conversation-assignee"), + path("/priority/", chat_extras_views.ConversationPriorityView.as_view(), name="conversation-priority"), + path("/note/", chat_extras_views.ConversationNoteView.as_view(), name="conversation-note"), + path("/labels/", chat_extras_views.ConversationLabelsView.as_view(), name="conversation-labels"), + path("/archive/", chat_extras_views.ConversationArchiveView.as_view(), name="conversation-archive"), + path("counters/", chat_extras_views.ConversationCountersView.as_view(), name="conversation-counters"), + path("labels/", chat_extras_views.LabelListView.as_view(), name="conversation-label-list"), + path("labels//", chat_extras_views.LabelDetailView.as_view(), name="conversation-label-detail"), + path("templates/", chat_extras_views.ReplyTemplateListView.as_view(), name="reply-template-list"), + path("templates//", chat_extras_views.ReplyTemplateDetailView.as_view(), name="reply-template-detail"), ] diff --git a/apps/backend/hub_platform/conversations/views.py b/apps/backend/hub_platform/conversations/views.py index 60d01d6..fd49310 100644 --- a/apps/backend/hub_platform/conversations/views.py +++ b/apps/backend/hub_platform/conversations/views.py @@ -22,20 +22,49 @@ from hub_platform.conversations.services import ( from hub_platform.conversations.view_base import ConversationViewBase from hub_platform.identity.group_models import EmployeeGroup from hub_platform.identity.models import OrganizationMembership -from hub_platform.identity.policy import ResourceScope, authorize +from hub_platform.identity.policy import ResourceScope, authorize, can_administer_access class ConversationListView(ConversationViewBase): def get(self, request: Request) -> Response: + from django.db.models import Q + + params = request.query_params items = visible_conversations_for(request.tenant_context) - group = request.query_params.get("group") + # «Удалённые» (архив) скрыты; просмотр архива — только администратор. + if params.get("archived") == "1": + if not can_administer_access(request.tenant_context.membership): + return Response({"detail": "Архив доступен администраторам"}, status=403) + items = items.filter(archived_at__isnull=False) + else: + items = items.filter(archived_at__isnull=True) + group = params.get("group") if group == "none": items = items.filter(group__isnull=True) elif group: items = items.filter(group_id=group) - lifecycle = request.query_params.get("lifecycle") + agent = params.get("agent") + if agent: + items = items.filter(channel_id=agent) + lifecycle = params.get("lifecycle") if lifecycle: items = items.filter(lifecycle=lifecycle) + else: + # Спам не показывается в обычных вкладках (дизайн-базлайн v2). + items = items.exclude(lifecycle=LifecycleState.SPAM) + if params.get("assigned") == "me": + items = items.filter(assigned_operator_id=request.user.id) + if params.get("waiting") == "1": + items = items.filter( + lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED + ) + query = params.get("q", "").strip() + if query: + items = items.filter( + Q(contact__name__icontains=query) + | Q(support_identity_snapshot__display_name__icontains=query) + | Q(messages__text__icontains=query) + ).distinct() items = list(items) # Отметки прочтения просматривающего: бейдж считается персонально. read_map = dict( diff --git a/apps/backend/hub_platform/tenancy/migrations/0021_chat_extras_guards.py b/apps/backend/hub_platform/tenancy/migrations/0021_chat_extras_guards.py new file mode 100644 index 0000000..0f6279d --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0021_chat_extras_guards.py @@ -0,0 +1,94 @@ +# Дизайн-базлайн v2: RLS, гранты и cross-tenant триггеры для меток диалогов и +# шаблонов ответов. M2M «диалог-метка» получает indirect-политику и парный +# триггер по образцу ai_aiagent_knowledge_items (tenancy/0002/0003). +from django.db import migrations + +ORG_TABLES = ("conversations_conversationlabel", "conversations_replytemplate") +M2M_TABLE = "conversations_conversation_labels" + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO custocrm_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO custocrm_runtime_app; +GRANT ALL ON {table} TO custocrm_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO custocrm_runtime_app, custocrm_schema; +DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; +CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); +DROP POLICY IF EXISTS custocrm_schema_access ON {table}; +CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); +""" + +M2M_RLS = f""" +ALTER TABLE {M2M_TABLE} OWNER TO custocrm_schema; +ALTER TABLE {M2M_TABLE} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {M2M_TABLE} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {M2M_TABLE} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {M2M_TABLE} TO custocrm_runtime_app; +GRANT ALL ON {M2M_TABLE} TO custocrm_schema; +GRANT USAGE, SELECT ON SEQUENCE {M2M_TABLE}_id_seq + TO custocrm_runtime_app, custocrm_schema; +DROP POLICY IF EXISTS custocrm_tenant_isolation ON {M2M_TABLE}; +CREATE POLICY custocrm_tenant_isolation ON {M2M_TABLE} + FOR ALL TO custocrm_runtime_app + USING ( + EXISTS ( + SELECT 1 FROM conversations_conversation conversation + JOIN conversations_conversationlabel label ON label.id = conversationlabel_id + WHERE conversation.id = conversation_id + AND conversation.organization_id = custocrm.current_organization_id() + AND label.organization_id = conversation.organization_id + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM conversations_conversation conversation + JOIN conversations_conversationlabel label ON label.id = conversationlabel_id + WHERE conversation.id = conversation_id + AND conversation.organization_id = custocrm.current_organization_id() + AND label.organization_id = conversation.organization_id + ) + ); +DROP POLICY IF EXISTS custocrm_schema_access ON {M2M_TABLE}; +CREATE POLICY custocrm_schema_access ON {M2M_TABLE} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in ORG_TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + schema_editor.execute(M2M_RLS) + schema_editor.execute( + f"DROP TRIGGER IF EXISTS c04_label_tpair ON {M2M_TABLE}" + ) + schema_editor.execute( + f"CREATE CONSTRAINT TRIGGER c04_label_tpair AFTER INSERT OR UPDATE ON {M2M_TABLE} " + "DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION " + "custocrm.enforce_tenant_pair('conversations_conversation', 'conversation_id', " + "'conversations_conversationlabel', 'conversationlabel_id')" + ) + + +def remove_guards(apps, schema_editor): + schema_editor.execute(f"DROP TRIGGER IF EXISTS c04_label_tpair ON {M2M_TABLE}") + for table in (*ORG_TABLES, M2M_TABLE): + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0020_drop_billing"), + ("conversations", "0011_conversation_archived_at_conversation_note_and_more"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/internal-ui/src/features/conversations/ConversationActions.tsx b/apps/internal-ui/src/features/conversations/ConversationActions.tsx index 08cc553..2d25543 100644 --- a/apps/internal-ui/src/features/conversations/ConversationActions.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationActions.tsx @@ -7,14 +7,21 @@ import { Button } from "../../shared/ui-controls"; export function ConversationActions({ open, + canReturnQueue, onClose, onSpam, + onReturnQueue, + onArchive, }: { open: boolean; + canReturnQueue: boolean; onClose: () => void; onSpam: () => Promise; + onReturnQueue: () => void; + onArchive: () => Promise; }) { const [confirmSpam, setConfirmSpam] = useState(false); + const [confirmArchive, setConfirmArchive] = useState(false); const [busy, setBusy] = useState(false); if (!open) return null; @@ -28,7 +35,22 @@ export function ConversationActions({ } } + async function confirmArchiveAction() { + setBusy(true); + try { + if (await onArchive()) setConfirmArchive(false); + } finally { + setBusy(false); + } + } + const items = [ + ...(canReturnQueue + ? [{ + key: "queue", + label: , + }] + : []), { key: "close", label: , @@ -38,6 +60,10 @@ export function ConversationActions({ key: "spam", label: , }, + { + key: "archive", + label: , + }, ]; return ( @@ -57,6 +83,18 @@ export function ConversationActions({ } /> + !busy && setConfirmArchive(false)} + tone="danger" + icon="trash" + title="Удалить диалог?" + description="Диалог уйдёт в архив и исчезнет из списков. Архив видят только администраторы." + actions={<> + + + } + /> ); } diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index 05a0528..5f718ef 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -14,7 +14,7 @@ function fmtTime(value: string): string { return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } -export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise }) { +export function ConversationThread({ controlMode, dialog, detail, isOwner = false, onClaim, onCall, onClose, onSpam, onReturnQueue, onArchive }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; isOwner?: boolean; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise; onReturnQueue: () => void; onArchive: () => Promise }) { const timelineRef = useRef(null); const messages = detail?.messages ?? []; const lastMessageId = messages.length ? messages[messages.length - 1].id : 0; @@ -54,7 +54,7 @@ export function ConversationThread({ controlMode, dialog, detail, isOwner = fals onCall("VIDEO")} /> )} - +
diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index d929244..ecbcdfc 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -12,6 +12,7 @@ import { fetchConversations, markConversationAsSpam, releaseConversation, + setConversationArchived, returnToQueue, toConversationListItem, type ApiConversation, @@ -27,7 +28,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh isOwner?: boolean; listTitle?: string; searchPlaceholder?: string; - renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null }) => ReactNode; + renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void }) => ReactNode; initialConversationId?: number | null; }) { const [listTab, setListTab] = useState("all"); @@ -100,9 +101,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh return dialogs.filter((dialog) => { if (query && !`${dialog.name} ${dialog.email} ${dialog.product} ${dialog.preview}`.toLowerCase().includes(query)) return false; if (listTab === "wait") return dialog.mode === "wait"; - if (listTab === "ai") return dialog.mode === "ai"; - if (listTab === "operator") return dialog.mode === "operator"; - if (listTab === "unread") return dialog.unread > 0; + if (listTab === "mine") return dialog.isMine; return true; }); }, [dialogs, listTab, search]); @@ -134,6 +133,12 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh const onReturnQueue = () => { void updateConversation(returnToQueue); }; const onClose = () => { void updateConversation(closeConversation); }; const onSpam = () => updateConversation(markConversationAsSpam); + const onArchive = async () => { + const done = await updateConversation((id) => setConversationArchived(id, true)); + // Архивный диалог исчезает из списка — снимаем выбор. + if (done) setSelectedId(null); + return done; + }; return (
@@ -151,7 +156,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh setSelectedId={setSelectedId} />
- void callController.start(kind)} onClose={onClose} onSpam={onSpam} /> + void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} /> {(detailError || actionError) &&
{detailError || actionError}
} selectedId != null && loadDetail(selectedId)} />
- {renderContextPanel({ dialog: selectedDialog, detail })} + {renderContextPanel({ dialog: selectedDialog, detail, applyConversation: applyUpdated })}
); } diff --git a/apps/internal-ui/src/features/conversations/DialogControls.tsx b/apps/internal-ui/src/features/conversations/DialogControls.tsx new file mode 100644 index 0000000..df76715 --- /dev/null +++ b/apps/internal-ui/src/features/conversations/DialogControls.tsx @@ -0,0 +1,280 @@ +import { useEffect, useState } from "react"; + +import { ContextSection } from "./ContextSection"; +import { PriorityBars } from "./DialogList"; +import { + createConversationLabel, + fetchConversationLabels, + setConversationArchived, + setConversationAssignee, + setConversationGroup, + setConversationLabels, + setConversationNote, + setConversationPriority, + type ApiConversation, + type ConversationLabelRef, + type ConversationPriority, +} from "./model"; +import type { EmployeeGroupRef } from "../../types"; + +// Блок «Диалог» контекст-панели (дизайн-базлайн v2 §5): ответственный, группа, +// приоритет, метки, заметка. Селекты группы/ответственного заполняются данными +// менеджера; у сотрудника значения read-only, взятие диалога — в шапке ленты. + +const PRIORITY_OPTIONS: Array<[ConversationPriority, string]> = [ + ["NONE", "Не задан"], + ["LOW", "Низкий"], + ["MEDIUM", "Средний"], + ["HIGH", "Высокий"], +]; + +export function DialogControls({ + detail, + groups, + employees, + applyConversation, +}: { + detail: ApiConversation; + groups: EmployeeGroupRef[]; + employees: Array<{ id: number; name: string }>; + applyConversation: (updated: ApiConversation) => void; +}) { + const [busy, setBusy] = useState(false); + const [errorText, setErrorText] = useState(""); + const onError = (text: string) => setErrorText(text); + const [note, setNote] = useState(detail.note); + const [labels, setLabels] = useState([]); + const [newLabel, setNewLabel] = useState(""); + + useEffect(() => { + setNote(detail.note); + setNewLabel(""); + }, [detail.id, detail.note]); + + useEffect(() => { + fetchConversationLabels().then(setLabels).catch(() => setLabels([])); + }, []); + + async function run(action: () => Promise) { + setBusy(true); + setErrorText(""); + try { + applyConversation(await action()); + } catch (error) { + onError(error instanceof Error ? error.message : "Не удалось сохранить"); + } finally { + setBusy(false); + } + } + + async function addLabel() { + const name = newLabel.trim(); + if (!name) return; + setBusy(true); + try { + const label = await createConversationLabel(name); + setLabels((current) => + current.some((item) => item.id === label.id) ? current : [...current, label], + ); + const ids = [...detail.labels.map((item) => item.id), label.id]; + applyConversation(await setConversationLabels(detail.id, [...new Set(ids)])); + setNewLabel(""); + } catch (error) { + onError(error instanceof Error ? error.message : "Не удалось добавить метку"); + } finally { + setBusy(false); + } + } + + const assignedIds = new Set(detail.labels.map((item) => item.id)); + const availableLabels = labels.filter((item) => !assignedIds.has(item.id)); + const noteDirty = note !== detail.note; + + return ( + +
+ {errorText &&

{errorText}

} + + + + + + +
+ Метки +
+ {detail.labels.map((label) => ( + + + {label.name} + + + ))} + {availableLabels.length > 0 && ( + + )} +
+
+ setNewLabel(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void addLabel(); + } + }} + /> + +
+
+ +
+ Заметка +