✨ feat(backend): каст демо по дизайн-базлайну v2, фото сотрудников, карточка контакта, цвет группы

- демо «Ателье Норд» повторяет кадры: сотрудники Елена Кузнецова и Анна Ким (фото),
  группы Операторы/Поддержка/VIP-клиенты с цветами, агенты Консультант · Поддержка
  сайта · Личный кабинет, семь видимых диалогов A–H с точными сообщениями, история
  Марии (кадр F), метки строчными; покрытие остальных функций — в архиве и спаме
- голосовые из Mozilla Common Voice 26.0 (ru, CC0): 4 клипа OGG/Opus, расшифровки
  совпадают с записью; README с источниками
- HumanUser.avatar (+ хранилище users/ вне тенантного ограждения), профиль:
  GET/POST/DELETE /auth/profile/avatar/, коллегам — /employees/<id>/avatar/;
  avatarUrl в сессии, сотрудниках, ответственном, авторе сообщения, счётчиках
- Contact.description/company/city, правка из диалога POST <id>/contact/
- EmployeeGroup.color (HEX) в API групп, счётчиках, справочнике, диалоге
- гость виджета называется «Гость · xxxxxx» (кадр M1); гость может представиться в сиде

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-05 20:17:23 +03:00
1 parent 1175e51e4a
commit 464d4d89fd
40 files changed
+1566 -681

No files matched your search

+25 -14
View File
@@ -22,31 +22,42 @@ def build_storage_settings(
default_storage: dict = {
"BACKEND": "hub_platform.tenancy.storage_backends.TenantFileSystemStorage",
}
# Файлы пользователя (фото профиля) — вне тенантного ограждения: пользователь
# может состоять в нескольких организациях. Ключи под users/<id>/.
users_storage: dict = {
"BACKEND": "hub_platform.tenancy.storage_backends.UserFileSystemStorage",
}
if backend == "s3":
bucket_name = os.environ.get("CUS_S3_BUCKET", "")
if not bucket_name:
raise ImproperlyConfigured("CUS_S3_BUCKET is required for S3 storage")
s3_options = {
"bucket_name": bucket_name,
"endpoint_url": os.environ.get("CUS_S3_ENDPOINT_URL") or None,
"region_name": os.environ.get("CUS_S3_REGION") or None,
"access_key": os.environ.get("CUS_S3_ACCESS_KEY") or None,
"secret_key": os.environ.get("CUS_S3_SECRET_KEY") or None,
"addressing_style": os.environ.get("CUS_S3_ADDRESSING_STYLE", "path"),
"default_acl": None,
"file_overwrite": False,
"querystring_auth": True,
"querystring_expire": int(
os.environ.get("CUS_S3_URL_EXPIRY_SECONDS", "900")
),
}
default_storage = {
"BACKEND": "hub_platform.tenancy.storage_backends.TenantS3Storage",
"OPTIONS": {
"bucket_name": bucket_name,
"endpoint_url": os.environ.get("CUS_S3_ENDPOINT_URL") or None,
"region_name": os.environ.get("CUS_S3_REGION") or None,
"access_key": os.environ.get("CUS_S3_ACCESS_KEY") or None,
"secret_key": os.environ.get("CUS_S3_SECRET_KEY") or None,
"addressing_style": os.environ.get("CUS_S3_ADDRESSING_STYLE", "path"),
"default_acl": None,
"file_overwrite": False,
"querystring_auth": True,
"querystring_expire": int(
os.environ.get("CUS_S3_URL_EXPIRY_SECONDS", "900")
),
},
"OPTIONS": s3_options,
}
users_storage = {
"BACKEND": "hub_platform.tenancy.storage_backends.UserS3Storage",
"OPTIONS": s3_options,
}
media_root = Path(os.environ.get("CUS_MEDIA_ROOT", base_dir / "media"))
storages = {
"default": default_storage,
"users": users_storage,
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
if testing
@@ -25,8 +25,9 @@ from hub_platform.conversations.models import (
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.avatars import user_avatar_url
from hub_platform.identity.group_models import EmployeeGroup
from hub_platform.identity.models import OrganizationMembership
from hub_platform.identity.models import HumanUser, OrganizationMembership
from hub_platform.identity.policy import can_administer_access
@@ -68,6 +69,46 @@ class ConversationPriorityView(ConversationViewBase):
)
class ConversationContactView(ConversationViewBase):
"""Карточка контакта из диалога (дизайн-базлайн v2, карандаш у имени):
имя, описание, телефон, компания, город."""
required_capability = "conversations.operate"
LIMITS = {"name": 255, "description": 2000, "phone": 32, "company": 160, "city": 120}
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)
contact = conversation.contact
if contact is None:
return Response({"detail": "У диалога нет контакта"}, status=400)
changed: list[str] = []
for field, limit in self.LIMITS.items():
if field not in request.data:
continue
value = str(request.data.get(field) or "").strip()
if len(value) > limit:
return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400)
if field == "name" and not value:
return Response({"detail": "Имя контакта не может быть пустым"}, status=400)
setattr(contact, field, value)
changed.append(field)
if changed:
contact.save(update_fields=changed)
self._audit(request, "contact_updated", conversation)
return Response(
{
"conversation": conversation_payload(
conversation, with_messages=True, viewer_id=request.user.id
)
}
)
class ConversationNoteView(ConversationViewBase):
required_capability = "conversations.operate"
@@ -178,9 +219,9 @@ class ConversationCountersView(ConversationViewBase):
)
open_qs = base.filter(lifecycle=LifecycleState.OPEN)
groups = [
{"id": row["group_id"], "name": row["group__name"], "count": row["count"]}
{"id": row["group_id"], "name": row["group__name"], "color": row["group__color"], "count": row["count"]}
for row in open_qs.filter(group__isnull=False)
.values("group_id", "group__name")
.values("group_id", "group__name", "group__color")
.annotate(count=Count("id"))
.order_by("group__name")
]
@@ -208,6 +249,12 @@ class ConversationCountersView(ConversationViewBase):
.annotate(count=Count("id"))
.order_by("-count", "assigned_operator__full_name")
]
avatars = {
user.id: user
for user in HumanUser.objects.filter(id__in=[a["id"] for a in assignees]).exclude(avatar="")
}
for assignee in assignees:
assignee["avatarUrl"] = user_avatar_url(avatars.get(assignee["id"]), request.tenant_context.organization.public_id)
return Response(
{
"all": open_qs.count(),
@@ -240,9 +287,13 @@ class ConversationDirectoryView(APIView):
)
return Response(
{
"groups": [{"id": group.id, "name": group.name} for group in groups],
"groups": [{"id": group.id, "name": group.name, "color": group.color} for group in groups],
"employees": [
{"id": member.user_id, "name": member.user.full_name or member.user.email}
{
"id": member.user_id,
"name": member.user.full_name or member.user.email,
"avatarUrl": user_avatar_url(member.user, request.tenant_context.organization.public_id),
}
for member in members
],
}
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("conversations", "0013_message_conv_message_text_fts"),
]
operations = [
migrations.AddField(model_name="contact", name="description", field=models.TextField(blank=True, default="")),
migrations.AddField(model_name="contact", name="company", field=models.CharField(blank=True, default="", max_length=160)),
migrations.AddField(model_name="contact", name="city", field=models.CharField(blank=True, default="", max_length=120)),
]
@@ -20,6 +20,11 @@ class Contact(models.Model):
# getUpdates, поэтому для него поле остаётся пустым. Хранится только URL —
# само изображение живёт на стороне провайдера.
avatar_url = models.URLField(max_length=512, blank=True, default="")
# Карточка контакта (дизайн-базлайн v2, решение 5): описание, компания, город —
# заполняет оператор.
description = models.TextField(blank=True, default="")
company = models.CharField(max_length=160, blank=True, default="")
city = models.CharField(max_length=120, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
@@ -1,4 +1,5 @@
from hub_platform.conversations.models import ConnectionIdentity, Conversation, Message, MessageAuthor
from hub_platform.identity.avatars import user_avatar_url_in
from hub_platform.integrations.models import IntegrationProvider
@@ -9,6 +10,7 @@ def message_payload(message: Message) -> dict[str, object]:
"authorUserId": message.author_user_id,
# Подпись исходящего сообщения сотрудника (дизайн-базлайн v2, 4a).
"authorName": (message.author_user.full_name or message.author_user.email) if message.author_user_id and message.author_user else "",
"authorAvatarUrl": user_avatar_url_in(message.author_user, message.organization_id) if message.author_user_id and message.author_user else None,
"kind": message.kind,
"text": message.text,
"contentHtml": message.content_html,
@@ -140,6 +142,9 @@ def conversation_payload(
"name": conversation.contact.name,
"phone": conversation.contact.phone,
"avatarUrl": conversation.contact.avatar_url,
"description": conversation.contact.description,
"company": conversation.contact.company,
"city": conversation.contact.city,
"email": _contact_email(conversation),
"username": _contact_username(conversation) if with_messages else "",
}
@@ -158,6 +163,7 @@ def conversation_payload(
conversation.assigned_operator.full_name
or conversation.assigned_operator.email
),
"avatarUrl": user_avatar_url_in(conversation.assigned_operator, conversation.organization_id),
}
if conversation.assigned_operator_id
else None
@@ -166,7 +172,7 @@ def conversation_payload(
viewer_id and conversation.assigned_operator_id == viewer_id
),
"group": (
{"id": conversation.group_id, "name": conversation.group.name}
{"id": conversation.group_id, "name": conversation.group.name, "color": conversation.group.color}
if conversation.group_id
else None
),
@@ -206,6 +206,23 @@ class ListFilterTests(ChatExtrasTestCase):
class CountersTests(ChatExtrasTestCase):
def test_contact_card_edit_from_dialog(self) -> None:
response = self.client.post(
f"/api/v1/conversations/{self.conversation.id}/contact/",
{"description": "Постоянный клиент", "company": "ООО «Дом текстиля»", "city": "Казань", "phone": "+7 900 000-00-01"},
format="json",
)
self.assertEqual(response.status_code, 200)
contact = response.json()["conversation"]["contact"]
self.assertEqual(contact["description"], "Постоянный клиент")
self.assertEqual(contact["company"], "ООО «Дом текстиля»")
self.assertEqual(contact["city"], "Казань")
self.assertEqual(contact["phone"], "+7 900 000-00-01")
empty_name = self.client.post(
f"/api/v1/conversations/{self.conversation.id}/contact/", {"name": ""}, format="json"
)
self.assertEqual(empty_name.status_code, 400)
def test_directory_lists_all_groups_and_colleagues_for_employee(self) -> None:
support = EmployeeGroup.objects.create(
organization=self.organization, name="Поддержка"
@@ -244,7 +261,7 @@ class CountersTests(ChatExtrasTestCase):
self.assertEqual(payload["mine"], 1)
self.assertEqual(
payload["groups"],
[{"id": self.operators.id, "name": "Операторы", "count": 2}],
[{"id": self.operators.id, "name": "Операторы", "color": "", "count": 2}],
)
self.assertEqual(payload["ungrouped"], 2)
@@ -253,7 +270,7 @@ class CountersTests(ChatExtrasTestCase):
self.assertEqual(len(admin["groups"]), 2)
self.assertEqual(
admin["assignees"],
[{"id": self.employee.user_id, "name": "employee@chat.test", "count": 1}],
[{"id": self.employee.user_id, "name": "employee@chat.test", "count": 1, "avatarUrl": None}],
)
by_assignee = self.admin_client.get(
f"/api/v1/conversations/?assigned={self.employee.user_id}"
@@ -13,6 +13,7 @@ urlpatterns = [
path("<int:conversation_id>/return-queue/", views.ConversationReturnQueueView.as_view(), name="conversation-return-queue"),
path("<int:conversation_id>/messages/", views.ConversationMessageView.as_view(), name="conversation-messages"),
path("<int:conversation_id>/request-contact/", views.ConversationRequestContactView.as_view(), name="conversation-request-contact"),
path("<int:conversation_id>/contact/", chat_extras_views.ConversationContactView.as_view(), name="conversation-contact"),
path("<int:conversation_id>/close/", views.ConversationCloseView.as_view(), name="conversation-close"),
path("<int:conversation_id>/spam/", views.ConversationSpamView.as_view(), name="conversation-spam"),
path("<int:conversation_id>/group/", views.ConversationGroupView.as_view(), name="conversation-group"),
@@ -6,6 +6,7 @@ from hub_platform.identity.auth.password_reset import (
)
from hub_platform.identity.auth.profile import (
ProfileAppearanceView,
ProfileAvatarView,
ChangeTemporaryPasswordView,
ProfilePasswordView,
ProfileRevokeOtherSessionsView,
@@ -1,6 +1,7 @@
from django.conf import settings
from rest_framework.request import Request
from hub_platform.identity.avatars import own_avatar_url
from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
from hub_platform.identity.policy import get_effective_access
from hub_platform.identity.sessions import revoke_user_sessions
@@ -58,6 +59,7 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
"deliveryMode": settings.CUS_DELIVERY_MODE,
"uiTheme": user.ui_theme,
"uiAccent": user.ui_accent,
"avatarUrl": own_avatar_url(user),
"memberships": memberships,
}
@@ -1,6 +1,8 @@
from django.contrib.auth import login
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.http import FileResponse
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
@@ -9,6 +11,7 @@ from rest_framework.views import APIView
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.auth.common import _revoke_other_user_sessions, _user_payload
from hub_platform.tenancy.ingress import user_requires_totp
from hub_platform.identity.avatars import delete_user_avatar, replace_user_avatar
from hub_platform.identity.models import HumanUser
@@ -39,6 +42,48 @@ class ProfileUpdateView(APIView):
return Response({"authenticated": True, "user": _user_payload(request.user)})
class ProfileAvatarView(APIView):
"""Фото профиля: показать, заменить, удалить (дизайн-базлайн v2)."""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def get(self, request: Request) -> Response | FileResponse:
user = request.user
if not user.avatar:
return Response({"detail": "Фото не задано"}, status=404)
return FileResponse(
user.avatar.open("rb"),
content_type=user.avatar_content_type or "application/octet-stream",
filename="avatar",
)
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "Выберите файл фото"}, status=400)
replace_user_avatar(request.user, upload)
record_audit_event(
action="identity.avatar_updated",
actor=request.user,
object_type="HumanUser",
object_id=str(request.user.id),
request=request,
)
return Response({"authenticated": True, "user": _user_payload(request.user)})
def delete(self, request: Request) -> Response:
delete_user_avatar(request.user)
record_audit_event(
action="identity.avatar_deleted",
actor=request.user,
object_type="HumanUser",
object_id=str(request.user.id),
request=request,
)
return Response({"authenticated": True, "user": _user_payload(request.user)})
class ProfilePasswordView(APIView):
permission_classes = [IsAuthenticated]
@@ -11,6 +11,7 @@ urlpatterns = [
path("password-reset/confirm/", auth.PasswordResetConfirmView.as_view(), name="auth-password-reset-confirm"),
path("profile/update/", auth.ProfileUpdateView.as_view(), name="auth-profile-update"),
path("profile/appearance/", auth.ProfileAppearanceView.as_view(), name="auth-profile-appearance"),
path("profile/avatar/", auth.ProfileAvatarView.as_view(), name="auth-profile-avatar"),
path("profile/password/", auth.ProfilePasswordView.as_view(), name="auth-profile-password"),
path("profile/totp/start/", auth.ProfileTotpStartView.as_view(), name="auth-profile-totp-start"),
path("profile/totp/disable/", auth.ProfileTotpDisableView.as_view(), name="auth-profile-totp-disable"),
@@ -0,0 +1,96 @@
"""Фото сотрудников (дизайн-базлайн v2).
Ссылка на фото строится от организации, в которой на него смотрят: файл
отдаёт тенантный эндпоинт, доступный любому участнику организации. Своё фото
в сессии — через профиль. В URL добавлен «хвост» имени файла, чтобы браузер
не показывал старое фото после замены.
"""
from __future__ import annotations
import functools
import hashlib
import uuid
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from rest_framework.exceptions import ValidationError
from hub_platform.identity.models import HumanUser
MAX_AVATAR_BYTES = 2 * 1024 * 1024
def _version(user: HumanUser) -> str:
return hashlib.sha1(user.avatar.name.encode("utf-8")).hexdigest()[:8]
def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | None:
if user is None or not user.avatar:
return None
return (
f"/api/v1/organizations/{organization_public_id}/employees/{user.id}/avatar/"
f"?v={_version(user)}"
)
@functools.lru_cache(maxsize=4096)
def organization_public_id(organization_id: int) -> str:
"""public_id организации по id — неизменяем, поэтому кэшируется."""
from hub_platform.identity.models import Organization
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None:
return user_avatar_url(user, organization_public_id(organization_id)) if user is not None else None
def own_avatar_url(user: HumanUser) -> str | None:
if not user.avatar:
return None
return f"/api/v1/auth/profile/avatar/?v={_version(user)}"
def image_type(data: bytes) -> tuple[str, str] | None:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png", ".png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg", ".jpg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
return None
def replace_user_avatar(user: HumanUser, upload: UploadedFile) -> HumanUser:
data = upload.read()
if not data:
raise ValidationError({"file": "Выберите файл фото"})
if len(data) > MAX_AVATAR_BYTES:
raise ValidationError({"file": "Размер фото не должен превышать 2 МБ"})
detected = image_type(data)
if detected is None:
raise ValidationError({"file": "Поддерживаются PNG, JPEG и WebP"})
content_type, suffix = detected
return set_user_avatar(user, data, content_type, suffix)
def set_user_avatar(user: HumanUser, data: bytes, content_type: str, suffix: str) -> HumanUser:
previous = user.avatar.name
user.avatar.save(f"avatar-{uuid.uuid4()}{suffix}", ContentFile(data), save=False)
user.avatar_content_type = content_type
user.save(update_fields=["avatar", "avatar_content_type"])
if previous:
user.avatar.storage.delete(previous)
return user
def delete_user_avatar(user: HumanUser) -> HumanUser:
previous = user.avatar.name
if not previous:
return user
user.avatar = ""
user.avatar_content_type = ""
user.save(update_fields=["avatar", "avatar_content_type"])
user.avatar.storage.delete(previous)
return user
@@ -14,7 +14,7 @@
"key": "consultant",
"code": "consultant",
"name": "Консультант",
"group": "consultants",
"group": "operators",
"aiStatus": "ACTIVE",
"persona": "Ты — консультант ателье «Норд». Ателье шьёт шторы, тюль и текстиль для дома по индивидуальным размерам, доставляет по России.",
"tone": "Дружелюбно, коротко, без канцелярита. Обращайся по имени, если клиент представился.",
@@ -40,7 +40,6 @@
"code": "site-support",
"name": "Поддержка сайта",
"group": "support",
"product": "atelie-nord-site",
"aiStatus": "ACTIVE",
"persona": "Ты — помощник службы поддержки сайта ателье «Норд»: личный кабинет, заказы, оплата, доставка, возвраты.",
"tone": "Спокойно и по делу.",
@@ -56,6 +55,33 @@
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"requires_authenticated_product_identity": false,
"allow_anonymous_sessions": true,
"allow_self_reported_contact": true
}
},
{
"key": "portal",
"code": "portal",
"name": "Личный кабинет",
"group": "support",
"product": "atelie-nord-site",
"aiStatus": "ACTIVE",
"persona": "Ты — помощник личного кабинета ателье «Норд»: заказы, оплата, статусы, доставка, возвраты. Клиент авторизован — его заказы известны.",
"tone": "Спокойно и по делу.",
"instructions": "Опирайся на данные заказа клиента и знания о кабинете. Жалобы на оплату — передай сотруднику.",
"knowledge": [
"account",
"delivery",
"returns"
],
"portalArticles": [
"lichnyj-kabinet",
"vozvrat-i-obmen"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"requires_authenticated_product_identity": true,
"allow_anonymous_sessions": false,
@@ -80,7 +106,7 @@
"key": "legacy",
"code": "old-line",
"name": "Старая линия (Instagram)",
"group": "operators",
"group": "vip",
"isActive": false,
"aiStatus": "DISABLED",
"persona": "Консультант ателье.",
@@ -134,7 +160,7 @@
{
"key": "web",
"provider": "WEB",
"name": "Виджет сайта atelie-nord.ru",
"name": "Web Chat · atelie-nord.ru",
"agent": "consultant",
"status": "OK",
"config": {
@@ -151,11 +177,31 @@
},
"createdDaysAgo": 28
},
{
"key": "web_help",
"provider": "WEB",
"name": "Web Chat · раздел «Помощь»",
"agent": "support",
"status": "OK",
"config": {
"title": "Ателье Норд",
"greeting": "Здравствуйте! Подскажем по тканям, срокам и заказам.",
"quick_replies": [
"Сроки пошива",
"Статус заказа",
"Возврат"
],
"allowed_origins": [
"https://atelie-nord.ru"
]
},
"createdDaysAgo": 28
},
{
"key": "web_account",
"provider": "WEB",
"name": "Виджет личного кабинета",
"agent": "support",
"name": "Web Chat · личный кабинет",
"agent": "portal",
"status": "OK",
"config": {
"title": "Поддержка · Ателье Норд",
@@ -316,6 +362,10 @@
"support": [
3,
9
],
"portal": [
1,
4
]
},
"embeddingPerDay": [
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -1,15 +1,15 @@
# Голосовые сообщения демо
Сюда кладутся аудиофайлы, на которые ссылается `data/conversations.json`
(`messages[].voice.file`). Речь должна быть человеческой и совпадать с текстом
`transcript` соответствующего сообщения — расшифровка показывается рядом с плеером.
Аудио — из открытого датасета **Mozilla Common Voice 26.0 (ru), лицензия CC0**:
живая человеческая речь, текст расшифровки совпадает с записью. Клипы
обрезаны по тишине и переупакованы в OGG/Opus (как в Telegram и MAX), моно, 48 кГц.
| Файл | Кто говорит | Длительность | Текст |
| Файл | Кто говорит | Текст | Источник |
| --- | --- | --- | --- |
| `maria-hurry.ogg` | клиентка, Мария | ~14 с | «Анна, спасибо! Если получится отправить завтра, будет идеально — в субботу приезжают гости, хочу успеть повесить.» |
| `olga-color.ogg` | клиентка, Ольга | ~9 с | любой текст: сообщение показывается нерасшифрованным (кнопка «Расшифровать») |
| `dmitry-samples.ogg` | сотрудник, Дмитрий | ~18 с | «Дарья, добрый день, это Дмитрий из ателье. Отправлю вам три образца блэкаута: графит, дымчатый и тёмно-серый. Напишите адрес с индексом, уйдут завтра почтой.» |
| `olga-eighty-cm.ogg` | клиентка, Ольга | «Вы видите? Ровно восемьдесят сантиметров!» | common_voice_ru_18915366 |
| `olga-saturday-post.ogg` | клиентка, Ольга | «А по субботам почта доставляет посылки?» | common_voice_ru_18913175 |
| `elena-start-with-numbers.ogg` | сотрудник, Елена | «Давайте начнём с цифр.» (не расшифровано — показывает кнопку «Расшифровать») | common_voice_ru_19823077 |
| `artem-prepayment.ogg` | клиент, Артём | «Предоплата какая? Желательно, чтобы вы указали в процентах.» | common_voice_ru_37103762 |
Формат — OGG/Opus (как в Telegram и MAX), моно, 16–48 кГц. Лицензия — совместимая
с открытой публикацией (CC0/CC BY или собственная запись). Если файла нет, сид
пропускает сообщение с предупреждением в логе — остальные данные ставятся.
Ссылки на файлы — в `data/conversations.json` (`messages[].voice.file`). Если файла
нет, сид пропускает сообщение с предупреждением в логе — остальные данные ставятся.
@@ -1,25 +1,161 @@
{
"schemaVersion": 1,
"calls": [
{"key": "call_maria", "conversation": "maria_first", "initiatedBy": "anna", "contactIdentity": "maria_tg", "deliveryConnection": "tg", "kind": "AUDIO", "status": "ENDED", "endedBy": "STAFF", "daysAgo": 33, "durationSeconds": 262, "metrics": {"connectionType": "RELAY", "localCandidateType": "relay", "remoteCandidateType": "relay", "roundTripMs": 64}},
{"key": "call_darya", "conversation": "darya_measure", "initiatedBy": "dmitry", "contactIdentity": "darya_tg", "deliveryConnection": "tg", "kind": "VIDEO", "status": "ENDED", "endedBy": "CUSTOMER", "minutesAgo": 1395, "durationSeconds": 540, "metrics": {"connectionType": "DIRECT", "localCandidateType": "srflx", "remoteCandidateType": "srflx", "roundTripMs": 31}},
{"key": "call_olga_missed", "conversation": "olga_archived", "initiatedBy": "anna", "contactIdentity": "olga_max", "deliveryConnection": "max", "kind": "AUDIO", "status": "MISSED", "daysAgo": 12},
{"key": "call_viktor_declined", "conversation": "viktor_wholesale", "initiatedBy": "svetlana", "contactIdentity": "viktor_tg", "deliveryConnection": "tg", "kind": "AUDIO", "status": "DECLINED", "endedBy": "CUSTOMER", "minutesAgo": 4185},
{"key": "call_sergey_expired", "conversation": "portal_sergey", "initiatedBy": "artem", "contactIdentity": "sergey_web", "deliveryConnection": "web", "kind": "AUDIO", "status": "EXPIRED", "daysAgo": 6, "invite": {"deliveryStatus": "DELIVERED", "expiredMinutesAfter": 2}}
{
"key": "call_maria",
"conversation": "maria_measure",
"initiatedBy": "anna",
"contactIdentity": "maria_tg",
"deliveryConnection": "tg",
"kind": "AUDIO",
"status": "ENDED",
"endedBy": "STAFF",
"daysAgo": 24,
"durationSeconds": 262,
"metrics": {
"connectionType": "RELAY",
"localCandidateType": "relay",
"remoteCandidateType": "relay",
"roundTripMs": 64
}
},
{
"key": "call_olga_video",
"conversation": "olga_return",
"initiatedBy": "elena",
"contactIdentity": "olga_tg",
"deliveryConnection": "tg",
"kind": "VIDEO",
"status": "ENDED",
"endedBy": "CUSTOMER",
"minutesAgo": 107,
"durationSeconds": 95,
"metrics": {
"connectionType": "DIRECT",
"localCandidateType": "srflx",
"remoteCandidateType": "srflx",
"roundTripMs": 31
}
},
{
"key": "call_sergey_missed",
"conversation": "sergey_address",
"initiatedBy": "elena",
"contactIdentity": "sergey_tg",
"deliveryConnection": "tg",
"kind": "AUDIO",
"status": "MISSED",
"hoursAgo": 30,
"minutesAgo": 6
},
{
"key": "call_viktor_declined",
"conversation": "viktor_wholesale",
"initiatedBy": "kirill",
"contactIdentity": "viktor_tg",
"deliveryConnection": "tg",
"kind": "AUDIO",
"status": "DECLINED",
"endedBy": "CUSTOMER",
"daysAgo": 3
},
{
"key": "call_sergey_expired",
"conversation": "portal_sergey",
"initiatedBy": "igor",
"contactIdentity": "sergey_web",
"deliveryConnection": "web_account",
"kind": "AUDIO",
"status": "EXPIRED",
"daysAgo": 6,
"invite": {
"deliveryStatus": "DELIVERED",
"expiredMinutesAfter": 2
}
}
],
"notifications": [
{"type": "DIALOG_WAITING", "level": "WARNING", "title": "Ирина Левина ждёт оператора", "body": "Заказ 4390: клиент попросил человека. Ждёт 1 ч 50 мин.", "targetRoute": "chat", "targetConversation": "irina_status", "audience": "ALL", "minutesAgo": 110},
{"type": "DIALOG_WAITING", "level": "WARNING", "title": "Ольга Мельник ждёт оператора", "body": "Возврат: цвет не совпал с сайтом.", "targetRoute": "chat", "targetConversation": "olga_return", "audience": "ALL", "minutesAgo": 21},
{"type": "DIALOG_WAITING", "level": "WARNING", "title": "Полина Ракитина ждёт оператора", "body": "Почта: не приходит письмо для сброса пароля.", "targetRoute": "chat", "targetConversation": "polina_password", "audience": "ALL", "minutesAgo": 200, "readBy": ["anna", "artem"]},
{"type": "DIALOG_NEW_MESSAGE", "level": "INFO", "title": "Новое сообщение от Марии Соколовой", "body": "«Спасибо, понял»", "targetRoute": "chat", "targetConversation": "maria_delivery", "audience": "USER", "recipientUser": "anna", "minutesAgo": 4},
{"type": "INTEGRATION_ERROR", "level": "CRITICAL", "title": "Telegram · бот доставки: ошибка подключения", "body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.", "targetRoute": "settings", "audience": "OWNER", "minutesAgo": 4200},
{"type": "RELEASE_PUBLISHED", "level": "SUCCESS", "title": "Обновление Chatbolls: голосовые сообщения", "body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.", "targetRoute": "chat", "audience": "ALL", "daysAgo": 2, "readBy": ["anna", "dmitry", "svetlana"]}
{
"type": "DIALOG_WAITING",
"level": "WARNING",
"title": "Дмитрий Орлов ждёт оператора",
"body": "Web Chat: клиент попросил сотрудника. Ждёт 6 мин.",
"targetRoute": "chat",
"targetConversation": "dmitry_custom",
"audience": "ALL",
"minutesAgo": 10
},
{
"type": "DIALOG_NEW_MESSAGE",
"level": "INFO",
"title": "Новое голосовое от Ольги Мельник",
"body": "«А по субботам почта доставляет посылки?»",
"targetRoute": "chat",
"targetConversation": "olga_return",
"audience": "USER",
"recipientUser": "elena",
"minutesAgo": 105
},
{
"type": "DIALOG_NEW_MESSAGE",
"level": "INFO",
"title": "Новое сообщение от Марии Соколовой",
"body": "«Хорошо, жду»",
"targetRoute": "chat",
"targetConversation": "maria_delivery",
"audience": "ALL",
"minutesAgo": 4
},
{
"type": "INTEGRATION_ERROR",
"level": "CRITICAL",
"title": "Telegram · бот доставки: ошибка подключения",
"body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.",
"targetRoute": "settings",
"audience": "OWNER",
"minutesAgo": 4200
},
{
"type": "RELEASE_PUBLISHED",
"level": "SUCCESS",
"title": "Обновление Chatbolls: голосовые сообщения",
"body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.",
"targetRoute": "chat",
"audience": "ALL",
"daysAgo": 2,
"readBy": [
"elena",
"anna",
"svetlana"
]
}
],
"messengerBindings": [
{"user": "anna", "connection": "tg", "externalChatId": "482900010", "pushTypes": ["DIALOG_WAITING", "DIALOG_NEW_MESSAGE", "INTEGRATION_ERROR"]},
{"user": "svetlana", "connection": "tg", "externalChatId": "482900011", "pushTypes": ["DIALOG_WAITING"]}
{
"user": "elena",
"connection": "tg",
"externalChatId": "482900010",
"pushTypes": [
"DIALOG_WAITING",
"DIALOG_NEW_MESSAGE",
"INTEGRATION_ERROR"
]
},
{
"user": "svetlana",
"connection": "tg",
"externalChatId": "482900011",
"pushTypes": [
"DIALOG_WAITING"
]
}
],
"messengerBindingCodes": [
{"user": "dmitry", "connection": "tg", "code": "DEMO-7K2P", "expiresInMinutes": 25}
{
"user": "igor",
"connection": "tg",
"code": "DEMO-7K2P",
"expiresInMinutes": 25
}
]
}
@@ -1,14 +1,46 @@
{
"schemaVersion": 1,
"_comment": "Демо «Ателье Норд» (дизайн-базлайн v2). Ставится в организацию установщика: организация и её владелец здесь не создаются. Пароль всех демо-сотрудников один — см. demoPassword.",
"_comment": "Демо «Ателье Норд» (дизайн-базлайн v2, каст кадров A1/A2 и A–H). Ставится в организацию установщика: организация и её владелец здесь не создаются. Пароль всех демо-сотрудников один — см. demoPassword.",
"demoPassword": "Chatbolls-Demo-2026",
"showcaseAccounts": ["anna", "dmitry", "artem"],
"showcaseAccounts": [
"elena",
"svetlana",
"igor"
],
"groups": [
{"key": "consultants", "name": "Консультанты"},
{"key": "support", "name": "Поддержка"},
{"key": "operators", "name": "Операторы"}
{
"key": "operators",
"name": "Операторы",
"color": "#1677ff"
},
{
"key": "support",
"name": "Поддержка",
"color": "#2aa876"
},
{
"key": "vip",
"name": "VIP-клиенты",
"color": "#eb2f96"
}
],
"accounts": [
{
"key": "elena",
"email": "e.kuznetsova@atelie-nord.ru",
"fullName": "Елена Кузнецова",
"role": "ADMIN",
"positionTitle": "Администратор",
"phone": "+7 921 300-41-16",
"groups": [
"operators",
"support"
],
"avatar": "elena-kuznetsova.png",
"uiTheme": "LIGHT",
"uiAccent": "#1677ff",
"createdDaysAgo": 120
},
{
"key": "anna",
"email": "a.kim@atelie-nord.ru",
@@ -16,70 +48,87 @@
"role": "ADMIN",
"positionTitle": "Администратор",
"phone": "+7 921 300-41-17",
"groups": ["consultants", "support"],
"groups": [
"operators",
"support"
],
"avatar": "anna-kim.png",
"uiTheme": "LIGHT",
"uiAccent": "#1677ff"
},
{
"key": "dmitry",
"email": "d.orlov@atelie-nord.ru",
"fullName": "Дмитрий Орлов",
"role": "EMPLOYEE",
"positionTitle": "Консультант",
"phone": "+7 921 300-41-18",
"groups": ["consultants"],
"uiTheme": "DARK",
"uiAccent": "#7c3aed"
},
{
"key": "artem",
"email": "a.gusev@atelie-nord.ru",
"fullName": "Артём Гусев",
"role": "EMPLOYEE",
"positionTitle": "Специалист поддержки",
"phone": "+7 921 300-41-19",
"groups": ["support"],
"uiTheme": "SYSTEM",
"uiAccent": "#0f9d58"
"uiAccent": "#1677ff",
"createdDaysAgo": 110
},
{
"key": "svetlana",
"email": "s.petrova@atelie-nord.ru",
"fullName": "Светлана Петрова",
"role": "EMPLOYEE",
"positionTitle": "Старший консультант",
"positionTitle": "Оператор",
"phone": "+7 921 300-41-20",
"groups": ["consultants", "support", "operators"],
"uiTheme": "LIGHT",
"uiAccent": "#d6336c"
"groups": [
"operators"
],
"avatar": "svetlana-petrova.jpg",
"uiTheme": "DARK",
"uiAccent": "#7c3aed",
"createdDaysAgo": 80
},
{
"key": "igor",
"email": "i.saveliev@atelie-nord.ru",
"fullName": "Игорь Савельев",
"role": "EMPLOYEE",
"positionTitle": "Оператор",
"phone": "",
"groups": [],
"totpEnabled": true,
"positionTitle": "Специалист поддержки",
"phone": "+7 921 300-41-19",
"groups": [
"support"
],
"uiTheme": "SYSTEM",
"uiAccent": "#1677ff"
"uiAccent": "#0f9d58",
"createdDaysAgo": 60
},
{
"key": "kirill",
"email": "k.volkov@atelie-nord.ru",
"fullName": "Кирилл Волков",
"role": "EMPLOYEE",
"positionTitle": "Менеджер VIP-клиентов",
"phone": "",
"groups": [
"vip"
],
"totpEnabled": true,
"uiTheme": "LIGHT",
"uiAccent": "#d6336c",
"createdDaysAgo": 40
},
{
"key": "natalia",
"email": "n.frolova@atelie-nord.ru",
"fullName": "Наталья Фролова",
"role": "EMPLOYEE",
"positionTitle": "Консультант (уволена)",
"positionTitle": "Оператор (уволена)",
"phone": "",
"groups": ["consultants"],
"groups": [
"operators"
],
"blocked": true,
"uiTheme": "SYSTEM",
"uiAccent": "#1677ff"
"uiAccent": "#1677ff",
"createdDaysAgo": 200
}
],
"invitations": [
{"email": "v.kotova@atelie-nord.ru", "role": "EMPLOYEE", "createdBy": "anna", "expiresInDays": 6},
{"email": "old.invite@atelie-nord.ru", "role": "EMPLOYEE", "createdBy": "anna", "expiresInDays": -2}
{
"email": "v.kotova@atelie-nord.ru",
"role": "EMPLOYEE",
"createdBy": "elena",
"expiresInDays": 6
},
{
"email": "old.invite@atelie-nord.ru",
"role": "EMPLOYEE",
"createdBy": "elena",
"expiresInDays": -2
}
]
}
@@ -1,7 +1,13 @@
{
"schemaVersion": 1,
"contracts": [
{"key": "site_contract", "code": "SITE-2026", "product": "atelie-nord-site", "version": 1, "status": "ACTIVE"}
{
"key": "site_contract",
"code": "SITE-2026",
"product": "atelie-nord-site",
"version": 1,
"status": "ACTIVE"
}
],
"identitySnapshots": [
{
@@ -11,9 +17,24 @@
"accountKey": "acc-4402",
"displayName": "Сергей Крылов",
"displayEmail": "s.krylov@example.ru",
"payload": {"orders": [{"number": "4402", "status": "В производстве", "total": 12480}], "city": "Казань"},
"operatorContext": {"Клиент с": "апрель 2026", "Заказов": "2", "Последний заказ": "4402 — комплект штор, 12 480 ₽"},
"aiContext": {"orders": "4402 in production"},
"payload": {
"orders": [
{
"number": "4402",
"status": "В производстве",
"total": 12480
}
],
"city": "Казань"
},
"operatorContext": {
"Клиент с": "апрель 2026",
"Заказов": "2",
"Последний заказ": "4402 — комплект штор, 12 480 ₽"
},
"aiContext": {
"orders": "4402 in production"
},
"searchText": "Сергей Крылов s.krylov@example.ru 4402 Казань",
"issuedDaysAgo": 6,
"expiresInDays": 24
@@ -24,23 +45,119 @@
"name": "Помощь · Ателье Норд",
"locale": "ru",
"status": "PUBLISHED",
"widgetConnection": "web",
"widgetConnection": "web_help",
"accountWidgetConnection": "web_account",
"products": ["atelie-nord-site"],
"products": [
"atelie-nord-site"
],
"categories": [
{"slug": "zakaz", "name": "Заказ и оплата", "description": "Как оформить заказ, оплата, статусы", "sortOrder": 1},
{"slug": "dostavka", "name": "Доставка", "description": "Сроки, курьер, СДЭК", "sortOrder": 2},
{"slug": "vozvrat", "name": "Возврат и обмен", "description": "Условия возврата изделий", "sortOrder": 3},
{"slug": "uhod", "name": "Уход за изделиями", "description": "Стирка и глажка по типам тканей", "sortOrder": 4}
{
"slug": "zakaz",
"name": "Заказ и оплата",
"description": "Как оформить заказ, оплата, статусы",
"sortOrder": 1
},
{
"slug": "dostavka",
"name": "Доставка",
"description": "Сроки, курьер, СДЭК",
"sortOrder": 2
},
{
"slug": "vozvrat",
"name": "Возврат и обмен",
"description": "Условия возврата изделий",
"sortOrder": 3
},
{
"slug": "uhod",
"name": "Уход за изделиями",
"description": "Стирка и глажка по типам тканей",
"sortOrder": 4
}
],
"articles": [
{"slug": "kak-snyat-merki", "category": "zakaz", "title": "Как снять мерки для штор", "summary": "Карниз, высота, припуски — за пять минут", "content": "## Длина карниза\n\nИзмерьте карниз от края до края — не окно. Шторы должны закрывать окно с запасом 15–20 см с каждой стороны.\n\n## Высота\n\nОт крючка карниза (или верха трубы) до желаемой длины: до подоконника минус 1 см, до пола минус 1–2 см, «в пол» с напуском плюс 3–5 см.\n\n## Люверсы\n\nДля штор на люверсах прибавьте 3 см сверху.\n\nСфотографируйте окно целиком и пришлите консультанту — проверим расчёт бесплатно.", "status": "PUBLISHED", "helpfulVotes": 14, "unhelpfulVotes": 1, "publishedDaysAgo": 27},
{"slug": "sroki-dostavki", "category": "dostavka", "title": "Сроки пошива и доставки", "summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России", "content": "Пошив занимает **5–7 рабочих дней** после согласования расчёта и оплаты.\n\n### По Санкт-Петербургу\n\nКурьер — 400 ₽, бесплатно при заказе от 10 000 ₽. Интервалы 10:00–13:00, 13:00–17:00, 17:00–20:00. Курьер звонит за час.\n\n### По России\n\nСДЭК до пункта выдачи или до двери, 3–5 рабочих дней. Трек-номер приходит на почту в день отправки.", "status": "PUBLISHED", "helpfulVotes": 22, "unhelpfulVotes": 3, "publishedDaysAgo": 26, "secondRevision": {"summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России. Перенос интервала — бесплатно", "contentAppend": "\n\n### Перенос доставки\n\nИнтервал можно перенести бесплатно до 18:00 накануне — напишите в чат или на info@atelie-nord.ru.", "daysAgo": 9}},
{"slug": "vozvrat-i-obmen", "category": "vozvrat", "title": "Возврат и обмен изделий", "summary": "Готовые изделия — 14 дней; индивидуальный пошив — переделка при браке", "content": "Готовые изделия из каталога возвращаются в течение **14 дней**, если не были в использовании.\n\nИзделия по индивидуальным размерам возврату не подлежат (ст. 26.1 ЗоЗПП), кроме брака и несоответствия заказу: неверный цвет или размер, дефекты пошива. В этом случае — бесплатная переделка или обмен, срок 5–7 рабочих дней.\n\nДля заявки пришлите номер заказа и фото изделия в чат.", "status": "PUBLISHED", "helpfulVotes": 9, "unhelpfulVotes": 4, "publishedDaysAgo": 25},
{"slug": "lichnyj-kabinet", "category": "zakaz", "title": "Личный кабинет: вход и восстановление пароля", "summary": "Письмо для сброса приходит в течение 5 минут", "content": "Вход — по e-mail. Письмо для сброса пароля приходит с адреса no-reply@atelie-nord.ru в течение 5 минут. Если письма нет — проверьте «Спам». Повторный запрос доступен через 10 минут.\n\nСтатусы заказа: «Ожидает оплаты» → «В производстве» → «Готов» → «Передан в доставку» → «Доставлен».", "status": "PUBLISHED", "helpfulVotes": 6, "unhelpfulVotes": 0, "publishedDaysAgo": 20},
{"slug": "uhod-za-shtorami", "category": "uhod", "title": "Как ухаживать за шторами", "summary": "Стирка и глажка по типам тканей", "content": "**Блэкаут** — деликатная стирка 30°, без отжима, глажка с изнанки на минимуме.\n\n**Лён** — стирка 40°; усадка до 3% заложена в расчёт.\n\n**Бархат** — только сухая чистка.\n\n**Тюль** — стирка 30° в мешке, сушить прямо на карнизе.", "status": "PUBLISHED", "helpfulVotes": 11, "unhelpfulVotes": 0, "publishedDaysAgo": 15},
{"slug": "oplata-chastyami", "category": "zakaz", "title": "Оплата частями", "summary": "Черновик: условия 50/50 для заказов от 30 000 ₽", "content": "Для заказов от 30 000 ₽ возможна оплата 50% при заказе и 50% перед отправкой. Черновик — согласовать с бухгалтерией.", "status": "DRAFT", "helpfulVotes": 0, "unhelpfulVotes": 0},
{"slug": "akciya-leto-2026", "category": "zakaz", "title": "Летняя акция 2026", "summary": "Скидка 10% на тюль до 31 августа", "content": "Акция завершена.", "status": "ARCHIVED", "helpfulVotes": 3, "unhelpfulVotes": 0, "publishedDaysAgo": 70}
{
"slug": "kak-snyat-merki",
"category": "zakaz",
"title": "Как снять мерки для штор",
"summary": "Карниз, высота, припуски — за пять минут",
"content": "## Длина карниза\n\nИзмерьте карниз от края до края — не окно. Шторы должны закрывать окно с запасом 15–20 см с каждой стороны.\n\n## Высота\n\nОт крючка карниза (или верха трубы) до желаемой длины: до подоконника минус 1 см, до пола минус 1–2 см, «в пол» с напуском плюс 3–5 см.\n\n## Люверсы\n\nДля штор на люверсах прибавьте 3 см сверху.\n\nСфотографируйте окно целиком и пришлите консультанту — проверим расчёт бесплатно.",
"status": "PUBLISHED",
"helpfulVotes": 14,
"unhelpfulVotes": 1,
"publishedDaysAgo": 27
},
{
"slug": "sroki-dostavki",
"category": "dostavka",
"title": "Сроки пошива и доставки",
"summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России",
"content": "Пошив занимает **5–7 рабочих дней** после согласования расчёта и оплаты.\n\n### По Санкт-Петербургу\n\nКурьер — 400 ₽, бесплатно при заказе от 10 000 ₽. Интервалы 10:00–13:00, 13:00–17:00, 17:00–20:00. Курьер звонит за час.\n\n### По России\n\nСДЭК до пункта выдачи или до двери, 3–5 рабочих дней. Трек-номер приходит на почту в день отправки.",
"status": "PUBLISHED",
"helpfulVotes": 22,
"unhelpfulVotes": 3,
"publishedDaysAgo": 26,
"secondRevision": {
"summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России. Перенос интервала — бесплатно",
"contentAppend": "\n\n### Перенос доставки\n\nИнтервал можно перенести бесплатно до 18:00 накануне — напишите в чат или на info@atelie-nord.ru.",
"daysAgo": 9
}
},
{
"slug": "vozvrat-i-obmen",
"category": "vozvrat",
"title": "Возврат и обмен изделий",
"summary": "Готовые изделия — 14 дней; индивидуальный пошив — переделка при браке",
"content": "Готовые изделия из каталога возвращаются в течение **14 дней**, если не были в использовании.\n\nИзделия по индивидуальным размерам возврату не подлежат (ст. 26.1 ЗоЗПП), кроме брака и несоответствия заказу: неверный цвет или размер, дефекты пошива. В этом случае — бесплатная переделка или обмен, срок 5–7 рабочих дней.\n\nДля заявки пришлите номер заказа и фото изделия в чат.",
"status": "PUBLISHED",
"helpfulVotes": 9,
"unhelpfulVotes": 4,
"publishedDaysAgo": 25
},
{
"slug": "lichnyj-kabinet",
"category": "zakaz",
"title": "Личный кабинет: вход и восстановление пароля",
"summary": "Письмо для сброса приходит в течение 5 минут",
"content": "Вход — по e-mail. Письмо для сброса пароля приходит с адреса no-reply@atelie-nord.ru в течение 5 минут. Если письма нет — проверьте «Спам». Повторный запрос доступен через 10 минут.\n\nСтатусы заказа: «Ожидает оплаты» → «В производстве» → «Готов» → «Передан в доставку» → «Доставлен».",
"status": "PUBLISHED",
"helpfulVotes": 6,
"unhelpfulVotes": 0,
"publishedDaysAgo": 20
},
{
"slug": "uhod-za-shtorami",
"category": "uhod",
"title": "Как ухаживать за шторами",
"summary": "Стирка и глажка по типам тканей",
"content": "**Блэкаут** — деликатная стирка 30°, без отжима, глажка с изнанки на минимуме.\n\n**Лён** — стирка 40°; усадка до 3% заложена в расчёт.\n\n**Бархат** — только сухая чистка.\n\n**Тюль** — стирка 30° в мешке, сушить прямо на карнизе.",
"status": "PUBLISHED",
"helpfulVotes": 11,
"unhelpfulVotes": 0,
"publishedDaysAgo": 15
},
{
"slug": "oplata-chastyami",
"category": "zakaz",
"title": "Оплата частями",
"summary": "Черновик: условия 50/50 для заказов от 30 000 ₽",
"content": "Для заказов от 30 000 ₽ возможна оплата 50% при заказе и 50% перед отправкой. Черновик — согласовать с бухгалтерией.",
"status": "DRAFT",
"helpfulVotes": 0,
"unhelpfulVotes": 0
},
{
"slug": "akciya-leto-2026",
"category": "zakaz",
"title": "Летняя акция 2026",
"summary": "Скидка 10% на тюль до 31 августа",
"content": "Акция завершена.",
"status": "ARCHIVED",
"helpfulVotes": 3,
"unhelpfulVotes": 0,
"publishedDaysAgo": 70
}
]
}
}
@@ -43,6 +43,9 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
defaults={
"phone": item.get("phone", ""),
"avatar_url": AVATAR_URL.format(name=item["avatar"]) if item.get("avatar") else "",
"description": item.get("description", ""),
"company": item.get("company", ""),
"city": item.get("city", ""),
},
)
if created:
@@ -107,7 +110,15 @@ def _web_guest(context: TenantContext, refs: DemoRefs, item: dict) -> tuple[Cont
identity = ConnectionIdentity.objects.get(
connection=refs.integrations[item["connection"]], external_user_id=session["sessionId"]
)
return identity.contact, identity, session["sessionId"]
contact = identity.contact
# Гость, который представился в виджете (кадр B: «Дмитрий Орлов», Web Chat).
if item.get("guestName") or item.get("guestAvatar"):
if item.get("guestName"):
contact.name = item["guestName"]
if item.get("guestAvatar"):
contact.avatar_url = AVATAR_URL.format(name=item["guestAvatar"])
contact.save(update_fields=["name", "avatar_url"])
return contact, identity, session["sessionId"]
def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, current) -> None:
@@ -11,6 +11,7 @@ from datetime import timedelta
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.auth.totp_utils import _generate_totp_secret
from hub_platform.identity.avatars import image_type, set_user_avatar
from hub_platform.identity.demo_seed import manifest
from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now
from hub_platform.identity.demo_seed.refs import DemoRefs
@@ -26,7 +27,9 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
current = now()
for item in data.get("groups", []):
group, _ = EmployeeGroup.objects.get_or_create(organization=organization, name=item["name"])
group, _ = EmployeeGroup.objects.get_or_create(
organization=organization, name=item["name"], defaults={"color": item.get("color", "")}
)
refs.groups[item["key"]] = group
password = data["demoPassword"]
@@ -82,6 +85,13 @@ def _ensure_account(refs: DemoRefs, item: dict, password: str, current) -> None:
user.totp_enabled = True
user.totp_secret = _generate_totp_secret()
user.save()
# Фото сотрудника (дизайн-базлайн v2) — из медиа демо.
avatar = item.get("avatar")
if avatar and manifest.media_exists(f"avatars/{avatar}"):
payload = manifest.media_bytes(f"avatars/{avatar}")
detected = image_type(payload)
if detected is not None:
set_user_avatar(user, payload, detected[0], detected[1])
refs.users[item["key"]] = user
membership, membership_created = OrganizationMembership.objects.get_or_create(
@@ -1,6 +1,7 @@
from rest_framework.request import Request
from hub_platform.identity.governance import employee_management_flags
from hub_platform.identity.avatars import user_avatar_url
from hub_platform.identity.models import AuditEvent, OrganizationMembership
@@ -18,6 +19,7 @@ def employee_payload(
"id": profile.user_id,
"email": profile.user.email,
"fullName": profile.user.full_name,
"avatarUrl": user_avatar_url(profile.user, profile.organization.public_id),
"role": profile.role,
"positionTitle": profile.position_title,
"phone": profile.phone,
@@ -12,6 +12,7 @@ urlpatterns = [
# обратной совместимости; поле role в теле выбирает системную роль.
path("operators/", employee_views.EmployeeCreateView.as_view(), name="employee-create"),
path("<int:user_id>/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"),
path("<int:user_id>/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"),
path("<int:user_id>/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"),
path("<int:user_id>/reset-password/", employee_security_views.EmployeeResetPasswordView.as_view(), name="employee-reset-password"),
path("<int:user_id>/revoke-sessions/", employee_security_views.EmployeeRevokeSessionsView.as_view(), name="employee-revoke-sessions"),
@@ -1,4 +1,5 @@
from django.db import transaction
from django.http import FileResponse
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
@@ -34,6 +35,28 @@ def _set_groups(profile: OrganizationMembership, groups) -> None:
)
class EmployeeAvatarView(APIView):
"""Фото коллеги — любому участнику организации (сайдбар, подписи, выбор ответственного)."""
permission_classes = [IsAuthenticated]
def get(self, request: Request, user_id: int) -> Response | FileResponse:
membership = (
OrganizationMembership.objects.select_related("user")
.filter(organization=request.tenant_context.organization, user_id=user_id)
.first()
)
if membership is None or not membership.user.avatar:
return Response({"detail": "Фото не найдено"}, status=404)
response = FileResponse(
membership.user.avatar.open("rb"),
content_type=membership.user.avatar_content_type or "application/octet-stream",
filename="avatar",
)
response["Cache-Control"] = "private, max-age=86400"
return response
class EmployeeListView(APIView):
permission_classes = [IsAuthenticated]
@@ -17,6 +17,8 @@ class EmployeeGroup(models.Model):
Organization, on_delete=models.PROTECT, related_name="employee_groups"
)
name = models.CharField(max_length=120)
# Цвет точки группы в чате (дизайн-базлайн v2); пустой — палитра по id.
color = models.CharField(max_length=20, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -1,3 +1,5 @@
import re
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.db.models import Count
@@ -14,12 +16,24 @@ from hub_platform.identity.models import OrganizationMembership
# диалогов. Управляют OWNER/ADMIN; сотрудник видит свои группы в session payload.
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
def _clean_color(value: object) -> str | None:
"""Цвет группы — HEX вида #rrggbb или пусто (палитра по умолчанию)."""
text = str(value or "").strip()
if not text:
return ""
return text.lower() if _COLOR_RE.match(text) else None
def _group_payload(group: EmployeeGroup, member_count: int | None = None) -> dict[str, object]:
if member_count is None:
member_count = group.member_links.count()
return {
"id": group.id,
"name": group.name,
"color": group.color,
"memberCount": member_count,
"memberIds": sorted(
group.member_links.values_list("employee__user_id", flat=True)
@@ -65,8 +79,11 @@ class GroupListView(APIView):
members, members_error = _resolve_members(organization, request.data.get("memberIds"))
if members_error:
return Response({"detail": members_error}, status=400)
color = _clean_color(request.data.get("color"))
if color is None:
return Response({"detail": "Некорректный цвет"}, status=400)
try:
group = EmployeeGroup.objects.create(organization=organization, name=name)
group = EmployeeGroup.objects.create(organization=organization, name=name, color=color)
except (ValidationError, IntegrityError):
return Response({"detail": "Группа с таким именем уже есть"}, status=400)
if members:
@@ -104,8 +121,14 @@ class GroupDetailView(APIView):
group = self._group(request, group_id)
if group is None:
return Response({"detail": "Group not found"}, status=404)
if "name" in request.data:
group.name = str(request.data.get("name", "")).strip()
if "color" in request.data:
color = _clean_color(request.data.get("color"))
if color is None:
return Response({"detail": "Некорректный цвет"}, status=400)
group.color = color
if "name" in request.data or "color" in request.data:
if "name" in request.data:
group.name = str(request.data.get("name", "")).strip()
try:
group.save()
except (ValidationError, IntegrityError):
@@ -0,0 +1,22 @@
from django.db import migrations, models
import hub_platform.identity.models
class Migration(migrations.Migration):
dependencies = [
("identity", "0022_demo_dataset_registry"),
]
operations = [
migrations.AddField(
model_name="humanuser",
name="avatar",
field=models.FileField(blank=True, default="", max_length=512, storage=hub_platform.identity.models.user_storage, upload_to=hub_platform.identity.models.user_avatar_upload_path),
),
migrations.AddField(
model_name="humanuser",
name="avatar_content_type",
field=models.CharField(blank=True, default="", max_length=64),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0023_humanuser_avatar"),
]
operations = [
migrations.AddField(
model_name="employeegroup",
name="color",
field=models.CharField(blank=True, default="", max_length=20),
),
]
@@ -47,6 +47,17 @@ class UiTheme(models.TextChoices):
SYSTEM = "SYSTEM", "Как в системе"
def user_storage():
from django.core.files.storage import storages
return storages["users"]
def user_avatar_upload_path(instance: "HumanUser", filename: str) -> str:
suffix = Path(filename).suffix.lower()
return f"users/{instance.id}/avatar-{uuid.uuid4()}{suffix}"
class HumanUser(AbstractUser):
username = None
email = models.EmailField(unique=True)
@@ -58,6 +69,10 @@ class HumanUser(AbstractUser):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
# сообщений, выборе ответственного. Загружается в профиле.
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
avatar_content_type = models.CharField(max_length=64, blank=True, default="")
USERNAME_FIELD = "email"
REQUIRED_FIELDS: list[str] = []
@@ -130,6 +130,38 @@ class AdministrationApiTests(TestCase):
self.assertEqual(deleted.status_code, 200)
self.assertIsNone(deleted.json()["organization"]["logoUrl"])
def test_profile_avatar_upload_visible_to_colleagues(self) -> None:
png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
uploaded = self.client.post(
"/api/v1/auth/profile/avatar/",
{"file": SimpleUploadedFile("me.png", png, content_type="image/png")},
format="multipart",
)
self.assertEqual(uploaded.status_code, 200)
own_url = uploaded.json()["user"]["avatarUrl"]
self.assertTrue(own_url.startswith("/api/v1/auth/profile/avatar/?v="))
self.assertEqual(self.client.get("/api/v1/auth/profile/avatar/").status_code, 200)
# Коллеги видят фото через тенантный эндпоинт; ссылка приходит в списке сотрудников.
employees = self.client.get("/api/v1/employees/").json()["items"]
colleague_url = employees[0]["avatarUrl"]
self.assertIn(f"/employees/{self.owner.id}/avatar/", colleague_url)
self.assertEqual(self.client.get(colleague_url.split("?")[0]).status_code, 200)
removed = self.client.delete("/api/v1/auth/profile/avatar/")
self.assertEqual(removed.status_code, 200)
self.assertIsNone(removed.json()["user"]["avatarUrl"])
self.assertEqual(self.client.get("/api/v1/auth/profile/avatar/").status_code, 404)
def test_group_color_is_stored_and_validated(self) -> None:
created = self.client.post(
"/api/v1/company/groups/", {"name": "Операторы", "color": "#2AA876"}, format="json"
)
self.assertEqual(created.status_code, 201)
self.assertEqual(created.json()["group"]["color"], "#2aa876")
bad = self.client.post("/api/v1/company/groups/", {"name": "Плохая", "color": "red"}, format="json")
self.assertEqual(bad.status_code, 400)
def test_non_image_logo_is_rejected(self) -> None:
response = self.client.post(
"/api/v1/company/administration/logo/",
@@ -22,6 +22,7 @@ from hub_platform.events.handlers import dispatch
from hub_platform.events.models import OutboxEvent
from hub_platform.identity.demo_models import DemoDataset, DemoDatasetStatus, DemoRecord
from hub_platform.identity.demo_seed import service
from hub_platform.identity.group_models import EmployeeGroup
from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
from hub_platform.identity.setup import SetupInput, complete_setup
from hub_platform.tenancy.context import TenantActorKind, TenantContext
@@ -63,6 +64,11 @@ def _counts() -> dict[str, int]:
@override_settings(MEDIA_ROOT=_MEDIA_ROOT)
def maria_contact_fields(organization) -> tuple[str, str, str]:
contact = Contact.objects.get(organization=organization, name="Мария Соколова")
return contact.description, contact.company, contact.city
class DemoDatasetTests(TestCase):
def setUp(self) -> None:
result = complete_setup(SetupInput(**OWNER))
@@ -99,11 +105,11 @@ class DemoDatasetTests(TestCase):
self.assertEqual(memberships.filter(blocked_at__isnull=False).count(), 1)
anna = HumanUser.objects.get(email="a.kim@atelie-nord.ru")
self.assertTrue(anna.check_password("Chatbolls-Demo-2026"))
self.assertTrue(HumanUser.objects.get(email="i.saveliev@atelie-nord.ru").totp_enabled)
self.assertTrue(HumanUser.objects.get(email="k.volkov@atelie-nord.ru").totp_enabled)
# Агенты: активный с AI, черновик без AI, выключенный канал.
agents = AIAgent.objects.filter(channel__organization=organization)
self.assertEqual(agents.filter(status=AIAgentStatus.ACTIVE).count(), 2)
self.assertEqual(agents.filter(status=AIAgentStatus.ACTIVE).count(), 3)
self.assertEqual(agents.filter(status=AIAgentStatus.DRAFT).count(), 1)
self.assertEqual(organization.channels.filter(is_active=False).count(), 1)
@@ -115,17 +121,26 @@ class DemoDatasetTests(TestCase):
conversations = Conversation.objects.filter(organization=organization)
self.assertEqual(conversations.filter(lifecycle=LifecycleState.SPAM).count(), 1)
self.assertGreaterEqual(conversations.filter(lifecycle=LifecycleState.CLOSED).count(), 3)
self.assertEqual(conversations.filter(archived_at__isnull=False).count(), 1)
self.assertGreaterEqual(conversations.filter(control_mode="PAUSED", lifecycle="OPEN").count(), 3)
self.assertEqual(conversations.filter(archived_at__isnull=False).count(), 3)
self.assertEqual(conversations.filter(control_mode="PAUSED", lifecycle="OPEN").count(), 1)
# Основной список — ровно семь диалогов кадров (архив и спам скрыты).
self.assertEqual(conversations.filter(archived_at__isnull=True).exclude(lifecycle=LifecycleState.SPAM).exclude(contact=Contact.objects.get(organization=organization, name="Мария Соколова"), lifecycle=LifecycleState.CLOSED).count(), 7)
self.assertTrue(conversations.filter(assigned_operator=anna).exists())
elena = HumanUser.objects.get(email="e.kuznetsova@atelie-nord.ru")
self.assertTrue(elena.avatar, "фото сотрудника из медиа демо")
self.assertEqual(conversations.filter(assigned_operator=elena, lifecycle="OPEN").count(), 2)
self.assertTrue(conversations.filter(previous_conversation__isnull=False).exists())
self.assertTrue(conversations.filter(labels__name="Срочно").exists())
# История контакта: у Марии два диалога.
self.assertTrue(conversations.filter(labels__name="срочно").exists())
self.assertEqual(EmployeeGroup.objects.get(organization=organization, name="Поддержка").color, "#2aa876")
self.assertEqual(maria_contact_fields(organization), ("Заказ 4471 — комплект штор. Постоянный клиент с августа, предпочитает Telegram.", "", ""))
# История контакта: у Марии три диалога (кадр F).
maria = Contact.objects.get(organization=organization, name="Мария Соколова")
self.assertEqual(conversations.filter(contact=maria).count(), 2)
self.assertEqual(conversations.filter(contact=maria).count(), 3)
self.assertTrue(maria.avatar_url.startswith("/api/v1/demo-media/avatars/"))
# Веб-гость получил настоящую сессию виджета.
self.assertTrue(conversations.filter(contact__name__startswith="Веб-гость").exists())
self.assertTrue(conversations.filter(contact__name__startswith="Гость ·").exists())
# Гость, который представился (кадр B): имя и фото у анонимной сессии.
self.assertTrue(conversations.filter(contact__name="Дмитрий Орлов", connection__provider="WEB").exists())
# Сообщения: контакт-шаринг и системные события есть; голосовые — при наличии файлов.
messages = Message.objects.filter(conversation__organization=organization)
self.assertTrue(messages.filter(kind=MessageKind.CONTACT).exists())
@@ -180,8 +195,8 @@ class DemoDatasetApiTests(TestCase):
self.assertGreater(installed["recordsCount"], 200)
# Витринные учётки: админ и сотрудники из разных групп, с паролем.
accounts = installed["accounts"]
self.assertEqual([a["email"] for a in accounts], ["a.kim@atelie-nord.ru", "d.orlov@atelie-nord.ru", "a.gusev@atelie-nord.ru"])
self.assertEqual(accounts[1]["groups"], ["Консультанты"])
self.assertEqual([a["email"] for a in accounts], ["e.kuznetsova@atelie-nord.ru", "s.petrova@atelie-nord.ru", "i.saveliev@atelie-nord.ru"])
self.assertEqual(accounts[1]["groups"], ["Операторы"])
self.assertEqual(accounts[2]["groups"], ["Поддержка"])
self.assertTrue(all(a["password"] == "Chatbolls-Demo-2026" for a in accounts))
self.assertEqual(self.client.post("/api/v1/company/demo/").status_code, 400)
@@ -197,7 +212,7 @@ class DemoDatasetApiTests(TestCase):
self.client.post("/api/v1/company/demo/")
self._dispatch_pending()
employee = APIClient()
employee.login(username="d.orlov@atelie-nord.ru", password="Chatbolls-Demo-2026")
employee.login(username="s.petrova@atelie-nord.ru", password="Chatbolls-Demo-2026")
self.assertEqual(employee.post("/api/v1/company/demo/").status_code, 403)
self.assertEqual(employee.delete("/api/v1/company/demo/").status_code, 403)
@@ -59,6 +59,45 @@ class TenantFileSystemStorage(TenantStorageGuardMixin, FileSystemStorage):
pass
def _assert_user_key(name: str) -> None:
normalized = str(name).replace("\\", "/").lstrip("/")
if not normalized.startswith("users/"):
raise SuspiciousFileOperation("User storage key must live under users/")
class UserStorageGuardMixin:
"""Хранилище файлов пользователя (фото профиля): ключи только под users/,
без тенантного контекста — пользователь общий для организаций."""
def open(self, name, mode="rb"):
_assert_user_key(name)
return super().open(name, mode)
def save(self, name, content, max_length=None):
_assert_user_key(name)
return super().save(name, content, max_length=max_length)
def delete(self, name):
_assert_user_key(name)
return super().delete(name)
def exists(self, name):
_assert_user_key(name)
return super().exists(name)
def size(self, name):
_assert_user_key(name)
return super().size(name)
def path(self, name):
_assert_user_key(name)
return super().path(name)
class UserFileSystemStorage(UserStorageGuardMixin, FileSystemStorage):
pass
try:
from storages.backends.s3 import S3Storage
except ImportError: # pragma: no cover - deployment configuration guard
@@ -70,8 +109,14 @@ if S3Storage is not None:
class TenantS3Storage(TenantStorageGuardMixin, S3Storage):
pass
class UserS3Storage(UserStorageGuardMixin, S3Storage):
pass
else:
class TenantS3Storage:
def __init__(self, *args, **kwargs) -> None:
raise ImproperlyConfigured("django-storages[s3] is required for S3 storage")
class UserS3Storage(TenantS3Storage):
pass
@@ -113,7 +113,7 @@ def issue_session(*, context: TenantContext, widget: WebChatWidget) -> dict | No
if integration is None or integration.channel_id is None:
return None
session_id = uuid.uuid4().hex
guest_name = f"Веб-гость · {session_id[:6]}"
guest_name = f"Гость · {session_id[:6]}"
contact = Contact.objects.create(organization=integration.channel.organization, name=guest_name)
identity = ConnectionIdentity.objects.create(
organization=context.organization,