mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ 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:
1 parent
1175e51e4a
commit
464d4d89fd
40 files changed
+1566
-681
No files matched your search
@@ -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 |
File renamed without changes.
@@ -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`). Если файла
|
||||
нет, сид пропускает сообщение с предупреждением в логе — остальные данные ставятся.
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user