mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ feat(webchat): сохранять данные сайта в контакте
This commit is contained in:
1 parent
dc6e38b689
commit
c3ea8395d0
15 files changed
+474
-9
No files matched your search
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: T-010
|
||||
title: Хранение значений полей и приём данных с сайта на сервере
|
||||
milestone: M02
|
||||
status: done
|
||||
depends_on:
|
||||
- T-009
|
||||
order: 2
|
||||
spec: "0019"
|
||||
created: 2026-09-29
|
||||
branch: skaro/T-010-hranenie-znacheniy-poley-i-pri
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Появляется таблица значений полей и сервис, который валидирует данные с сайта и пишет их в контакт и в значения; данные принимают старт сессии и новый endpoint (R-9, R-10, R-11, R-15, ADR-0030).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [x] Модель contact_field_values (организация, контакт, подключение, ключ, значение jsonb, updated_at) с уникальностью по контакт+подключение+ключ, под RLS; тест изоляции под runtime-ролью проходит
|
||||
- [x] POST /api/v1/webchat/fields/ с токеном сессии и POST /webchat/session с fields сохраняют валидные значения; null очищает значение
|
||||
- [x] Неизвестные ключи и неверные типы отбрасываются без ошибки для сайта, в журнал пишется предупреждение без значения; enum принимает только value из options, строки обрезаются/отклоняются свыше 500 символов
|
||||
- [x] name/email/phone пишутся в контакт только если там пусто или прошлое значение пришло с этого же подключения — ручная правка оператора не затирается (тест)
|
||||
- [x] Endpoint под теми же throttle, что остальные публичные по сессии
|
||||
|
||||
## Заметки
|
||||
|
||||
webchat/services.py issue_session, webchat/views.py, urls.py. Источник встроенного поля нужно где-то помнить (например, отметка в значениях с тем же ключом) — решить при реализации, не ломая модель Contact без нужды.
|
||||
|
||||
## Итог
|
||||
|
||||
Задача T-010 выполнена в ветке, коммит `2df2c17c`. Skaro отметил все пять критериев приёмки и показал карточку слияния.
|
||||
|
||||
1. Таблица значений и RLS: миграции применились; тест под runtime-ролью подтвердил изоляцию организаций.
|
||||
2. Старт сессии и `POST /api/v1/webchat/fields/`: тесты подтвердили сохранение значений и очистку через `null`.
|
||||
3. Валидация: неизвестные ключи, неверные типы, значения enum вне списка и слишком длинные строки отбрасываются; предупреждения не содержат значений.
|
||||
4. Ручные правки: тесты подтвердили, что сайт не затирает изменённые оператором имя, email и телефон.
|
||||
5. Ограничения запросов: тест подтвердил требование токена и ответ `429` при исчерпании лимита записи.
|
||||
|
||||
Адресные тесты, проверка миграций, Django check и Ruff прошли.
|
||||
@@ -17,6 +17,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.conversations.models import (
|
||||
ContactFieldValue,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
@@ -124,6 +125,7 @@ class ConversationContactView(ConversationViewBase):
|
||||
changed.append(field)
|
||||
if changed:
|
||||
contact.save(update_fields=changed)
|
||||
ContactFieldValue.objects.filter(contact=contact, key__in=changed).delete()
|
||||
self._audit(request, "contact_updated", conversation)
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -164,7 +164,7 @@ def client_row(contact: Contact) -> dict:
|
||||
"isGuest": not contact.name,
|
||||
"phone": contact.phone,
|
||||
"avatarUrl": contact_avatar_url_in(contact, contact.organization_id),
|
||||
"email": next(
|
||||
"email": contact.email or next(
|
||||
(
|
||||
identity.external_user_id
|
||||
for identity in contact.identities.all()
|
||||
@@ -308,7 +308,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
|
||||
"description": contact.description,
|
||||
"company": contact.company,
|
||||
"city": contact.city,
|
||||
"email": next(
|
||||
"email": contact.email or next(
|
||||
(
|
||||
identity.external_user_id
|
||||
for identity in identity_qs
|
||||
|
||||
@@ -12,13 +12,18 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ContactMerge,
|
||||
Conversation,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
|
||||
# они пустые. Что именно заполнили — запоминаем, чтобы очистить при разъединении.
|
||||
CARD_FIELDS = ("name", "phone", "avatar_url", "description", "company", "city")
|
||||
CARD_FIELDS = ("name", "email", "phone", "avatar_url", "description", "company", "city")
|
||||
MIN_REASON_LENGTH = 5
|
||||
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("conversations", "0026_message_ai_turn_state"),
|
||||
("integrations", "0010_integration_runtime_revision"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="contact",
|
||||
name="email",
|
||||
field=models.EmailField(blank=True, default="", max_length=254),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ContactFieldValue",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("key", models.CharField(max_length=40)),
|
||||
("value", models.JSONField()),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("contact", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="site_field_values", to="conversations.contact")),
|
||||
("integration", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="contact_field_values", to="integrations.integration")),
|
||||
("organization", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="identity.organization")),
|
||||
],
|
||||
options={
|
||||
"db_table": "contact_field_values",
|
||||
"constraints": [models.UniqueConstraint(fields=("contact", "integration", "key"), name="uniq_contact_integration_field_key")],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,7 @@ def contact_avatar_upload_path(instance: "Contact", filename: str) -> str:
|
||||
class Contact(models.Model):
|
||||
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="contacts")
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
email = models.EmailField(blank=True, default="")
|
||||
# Телефон приходит только через явный шаринг контакта (кнопка в TG/MAX,
|
||||
# форма в веб-чате) — автоматически мессенджеры его не отдают.
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
@@ -65,6 +66,27 @@ class Contact(models.Model):
|
||||
return self.name or f"contact:{self.id}"
|
||||
|
||||
|
||||
class ContactFieldValue(TenantRelationModel):
|
||||
"""Последнее значение с сайта для контакта и WEB-подключения."""
|
||||
|
||||
tenant_relation_fields = ("contact", "integration")
|
||||
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="site_field_values")
|
||||
integration = models.ForeignKey(
|
||||
"integrations.Integration", on_delete=models.CASCADE, related_name="contact_field_values"
|
||||
)
|
||||
key = models.CharField(max_length=40)
|
||||
value = models.JSONField()
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "contact_field_values"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["contact", "integration", "key"], name="uniq_contact_integration_field_key"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ContactMerge(models.Model):
|
||||
"""Журнал объединения контактов (ADR-CHATBALLS-0006).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework.response import Response
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
|
||||
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.conversations.models import Contact, ContactFieldValue
|
||||
from chatballs.conversations.stats import sales_overview_stats
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.i18n import t
|
||||
@@ -87,6 +87,7 @@ class ClientDetailView(ConversationViewBase):
|
||||
changed.append(field)
|
||||
if changed:
|
||||
contact.save(update_fields=changed)
|
||||
ContactFieldValue.objects.filter(contact=contact, key__in=changed).delete()
|
||||
record_audit_event(
|
||||
action="conversation.contact_updated",
|
||||
actor=request.user,
|
||||
|
||||
@@ -182,6 +182,8 @@ def _contact_is_guest(contact, identity: ConnectionIdentity | None) -> bool:
|
||||
|
||||
|
||||
def _contact_email(conversation: Conversation) -> str:
|
||||
if conversation.contact.email:
|
||||
return conversation.contact.email
|
||||
if (
|
||||
conversation.connection_id
|
||||
and conversation.connection.provider == IntegrationProvider.EMAIL
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from chatballs.conversations.models import ContactFieldValue
|
||||
from chatballs.conversations.test_chat_extras import ChatExtrasTestCase
|
||||
from chatballs.webchat.testing import create_web_widget
|
||||
|
||||
|
||||
class SiteFieldManualEditTests(ChatExtrasTestCase):
|
||||
def test_dialog_edit_clears_site_provenance_even_if_value_is_same(self) -> None:
|
||||
widget = create_web_widget(self.channel)
|
||||
contact = self.conversation.contact
|
||||
ContactFieldValue.objects.create(
|
||||
organization=self.organization, contact=contact, integration=widget.integration,
|
||||
key="name", value=contact.name,
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/v1/conversations/{self.conversation.id}/contact/",
|
||||
{"name": contact.name}, format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertFalse(ContactFieldValue.objects.filter(contact=contact, key="name").exists())
|
||||
|
||||
def test_client_edit_clears_site_provenance(self) -> None:
|
||||
widget = create_web_widget(self.channel)
|
||||
contact = self.conversation.contact
|
||||
ContactFieldValue.objects.create(
|
||||
organization=self.organization, contact=contact, integration=widget.integration,
|
||||
key="phone", value="+79991234567",
|
||||
)
|
||||
response = self.admin_client.patch(
|
||||
f"/api/v1/conversations/clients/{contact.id}/",
|
||||
{"phone": "+79991234567"}, format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertFalse(ContactFieldValue.objects.filter(contact=contact, key="phone").exists())
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
TABLE = "contact_field_values"
|
||||
|
||||
FORWARD_SQL = f"""
|
||||
ALTER TABLE {TABLE} OWNER TO chatballs_schema;
|
||||
ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON TABLE {TABLE} FROM PUBLIC;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {TABLE} TO chatballs_runtime_app;
|
||||
GRANT ALL ON TABLE {TABLE} TO chatballs_schema;
|
||||
GRANT USAGE, SELECT ON SEQUENCE {TABLE}_id_seq TO chatballs_runtime_app, chatballs_schema;
|
||||
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
|
||||
CREATE POLICY chatballs_tenant_isolation ON {TABLE}
|
||||
FOR ALL TO chatballs_runtime_app
|
||||
USING (organization_id = chatballs.current_organization_id())
|
||||
WITH CHECK (organization_id = chatballs.current_organization_id());
|
||||
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
|
||||
CREATE POLICY chatballs_schema_access ON {TABLE}
|
||||
FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
|
||||
|
||||
CREATE CONSTRAINT TRIGGER contact_field_value_contact
|
||||
AFTER INSERT OR UPDATE ON {TABLE}
|
||||
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
|
||||
chatballs.enforce_tenant_fk('conversations_contact', 'contact_id');
|
||||
CREATE CONSTRAINT TRIGGER contact_field_value_integration
|
||||
AFTER INSERT OR UPDATE ON {TABLE}
|
||||
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
|
||||
chatballs.enforce_tenant_fk('integrations_integration', 'integration_id');
|
||||
"""
|
||||
|
||||
REVERSE_SQL = f"""
|
||||
DROP TRIGGER IF EXISTS contact_field_value_integration ON {TABLE};
|
||||
DROP TRIGGER IF EXISTS contact_field_value_contact ON {TABLE};
|
||||
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
|
||||
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
|
||||
ALTER TABLE {TABLE} NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE {TABLE} DISABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON TABLE {TABLE} FROM chatballs_runtime_app;
|
||||
REVOKE USAGE, SELECT ON SEQUENCE {TABLE}_id_seq FROM chatballs_runtime_app;
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tenancy", "0038_widget_asset_guards"),
|
||||
("conversations", "0027_contact_email_contactfieldvalue"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
|
||||
@@ -130,7 +130,7 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def issue_session(*, context: TenantContext, widget: WebChatWidget) -> dict | None:
|
||||
def issue_session(*, context: TenantContext, widget: WebChatWidget, fields: object = None) -> dict | None:
|
||||
integration = widget.integration
|
||||
if integration is None or integration.channel_id is None:
|
||||
return None
|
||||
@@ -149,13 +149,17 @@ def issue_session(*, context: TenantContext, widget: WebChatWidget) -> dict | No
|
||||
display_name=guest_name,
|
||||
)
|
||||
token = secrets.token_urlsafe(32)
|
||||
WebSession.objects.create(
|
||||
session = WebSession.objects.create(
|
||||
organization=context.organization,
|
||||
token_hash=hash_session_token(token),
|
||||
connection=integration,
|
||||
widget=widget,
|
||||
identity=identity,
|
||||
)
|
||||
if fields is not None:
|
||||
from chatballs.webchat.site_fields import save_site_fields
|
||||
|
||||
save_site_fields(session, fields)
|
||||
return {"token": token, "sessionId": session_id}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Проверка и сохранение недоверенных значений, присланных сайтом."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator, validate_email
|
||||
from django.db import transaction
|
||||
|
||||
from chatballs.conversations.models import Contact, ContactFieldValue
|
||||
from chatballs.webchat.field_schema import RESERVED_KEYS
|
||||
from chatballs.webchat.services import normalize_phone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_STRING_LENGTH = 500
|
||||
BUILTIN_TYPES = {"name": "string", "email": "email", "phone": "phone"}
|
||||
|
||||
|
||||
def _valid_value(value: object, field_type: str) -> object:
|
||||
if value is None:
|
||||
return None
|
||||
if field_type == "boolean":
|
||||
if type(value) is bool:
|
||||
return value
|
||||
raise ValueError("type")
|
||||
if field_type == "number":
|
||||
if type(value) is int and value.bit_length() <= 1024:
|
||||
return value
|
||||
if type(value) is float and math.isfinite(value):
|
||||
return value
|
||||
raise ValueError("type")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("type")
|
||||
if len(value) > MAX_STRING_LENGTH:
|
||||
raise ValueError("length")
|
||||
if field_type == "datetime":
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("format") from exc
|
||||
elif field_type == "email":
|
||||
try:
|
||||
validate_email(value)
|
||||
except ValidationError as exc:
|
||||
raise ValueError("format") from exc
|
||||
elif field_type == "phone":
|
||||
phone = normalize_phone(value)
|
||||
if not phone:
|
||||
raise ValueError("format")
|
||||
return phone
|
||||
elif field_type == "url":
|
||||
try:
|
||||
URLValidator(schemes=["http", "https"])(value)
|
||||
except ValidationError as exc:
|
||||
raise ValueError("format") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _schema(integration) -> dict[str, dict]:
|
||||
raw = integration.config.get("fields", [])
|
||||
fields = {item["key"]: item for item in raw if isinstance(item, dict) and item.get("key")}
|
||||
return {**{key: {"type": kind} for key, kind in BUILTIN_TYPES.items()}, **fields}
|
||||
|
||||
|
||||
def _apply_builtin(contact: Contact, key: str, value: object, previous: ContactFieldValue | None, session) -> bool:
|
||||
current = getattr(contact, key)
|
||||
old_site_value = previous.value if previous else None
|
||||
guest_name = key == "name" and current == session.identity.display_name and session.identity.external_user_id[:6] in current
|
||||
if current and not guest_name and (previous is None or current != old_site_value):
|
||||
return False
|
||||
if value is not None and len(value) > Contact._meta.get_field(key).max_length:
|
||||
raise ValueError("length")
|
||||
setattr(contact, key, value or "")
|
||||
contact.save(update_fields=[key])
|
||||
return True
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_site_fields(session, fields: object) -> None:
|
||||
"""Частичное обновление; плохие ключи/типы не прерывают приём."""
|
||||
if not isinstance(fields, dict):
|
||||
logger.warning("webchat fields ignored: invalid payload")
|
||||
return
|
||||
contact = Contact.objects.select_for_update().get(
|
||||
id=session.identity.contact_id, organization_id=session.organization_id
|
||||
)
|
||||
schema = _schema(session.connection)
|
||||
for key, raw_value in fields.items():
|
||||
definition = schema.get(key) if isinstance(key, str) else None
|
||||
if definition is None:
|
||||
logger.warning("webchat field ignored: unknown key")
|
||||
continue
|
||||
try:
|
||||
value = _valid_value(raw_value, definition["type"])
|
||||
if definition["type"] == "enum" and value is not None:
|
||||
if value not in {option["value"] for option in definition.get("options", [])}:
|
||||
raise ValueError("option")
|
||||
previous = ContactFieldValue.objects.filter(
|
||||
contact=contact, integration=session.connection, key=key
|
||||
).first()
|
||||
if key in RESERVED_KEYS and not _apply_builtin(contact, key, value, previous, session):
|
||||
continue
|
||||
except ValueError as exc:
|
||||
logger.warning("webchat field ignored: %s", exc)
|
||||
continue
|
||||
if value is None:
|
||||
if previous:
|
||||
previous.delete()
|
||||
elif previous:
|
||||
previous.value = value
|
||||
previous.save(update_fields=["value", "updated_at"])
|
||||
else:
|
||||
ContactFieldValue.objects.create(
|
||||
organization_id=session.organization_id,
|
||||
contact=contact,
|
||||
integration=session.connection,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
from django.core.cache import cache
|
||||
from django.db import connection, transaction
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import Contact, ContactFieldValue
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.webchat.models import WebSession
|
||||
from chatballs.webchat.testing import create_web_widget
|
||||
|
||||
FIELDS = [
|
||||
{"key": "order_status", "type": "enum", "options": [{"value": "cooking", "label": "Готовится"}]},
|
||||
{"key": "has_order", "type": "boolean"},
|
||||
{"key": "user_id", "type": "string"},
|
||||
{"key": "amount", "type": "number"},
|
||||
]
|
||||
|
||||
|
||||
class SiteFieldApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.organization = Organization.objects.create(name="Site fields", slug="site-fields")
|
||||
channel = Channel.objects.create(organization=self.organization, code="site-fields", name="Site")
|
||||
self.widget = create_web_widget(channel)
|
||||
self.widget.integration.config = {"fields": FIELDS}
|
||||
self.widget.integration.save(update_fields=["config"])
|
||||
self.client = APIClient()
|
||||
|
||||
def _session(self, fields=None):
|
||||
payload = {"widgetKey": self.widget.public_key}
|
||||
if fields is not None:
|
||||
payload["fields"] = fields
|
||||
response = self.client.post("/api/v1/webchat/session/", payload, format="json")
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
return response.json()["token"], WebSession.objects.latest("id")
|
||||
|
||||
def _fields(self, token, fields, **kwargs):
|
||||
return self.client.post(
|
||||
"/api/v1/webchat/fields/", {"fields": fields}, format="json",
|
||||
headers={"Authorization": f"Bearer {token}"}, **kwargs,
|
||||
)
|
||||
|
||||
def test_initial_and_incremental_values_with_null_clear(self):
|
||||
token, session = self._session({"name": "Иван", "email": "ivan@example.test", "order_status": "cooking"})
|
||||
contact = session.identity.contact
|
||||
contact.refresh_from_db()
|
||||
self.assertEqual((contact.name, contact.email), ("Иван", "ivan@example.test"))
|
||||
self.assertEqual(ContactFieldValue.objects.get(contact=contact, key="order_status").value, "cooking")
|
||||
self.assertEqual(self._fields(token, {"has_order": True, "order_status": None}).status_code, 200)
|
||||
self.assertFalse(ContactFieldValue.objects.filter(contact=contact, key="order_status").exists())
|
||||
self.assertIs(ContactFieldValue.objects.get(contact=contact, key="has_order").value, True)
|
||||
self.assertEqual(self._fields(token, {"name": None, "email": None}).status_code, 200)
|
||||
contact.refresh_from_db()
|
||||
self.assertEqual((contact.name, contact.email), ("", ""))
|
||||
|
||||
def test_invalid_values_are_ignored_and_not_logged(self):
|
||||
token, session = self._session()
|
||||
with self.assertLogs("chatballs.webchat.site_fields", level="WARNING") as logs:
|
||||
response = self._fields(token, {
|
||||
"unknown": "SECRET", "has_order": "SECRET", "order_status": "SECRET",
|
||||
"user_id": "SECRET" * 101,
|
||||
"amount": 10 ** 400,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(ContactFieldValue.objects.filter(contact=session.identity.contact).exists())
|
||||
self.assertNotIn("SECRET", " ".join(logs.output))
|
||||
|
||||
def test_operator_edits_are_preserved(self):
|
||||
token, session = self._session({"name": "Иван", "email": "ivan@example.test", "phone": "+79991234567"})
|
||||
contact = session.identity.contact
|
||||
Contact.objects.filter(pk=contact.pk).update(
|
||||
name="Оператор", email="manual@example.test", phone="+79990000000"
|
||||
)
|
||||
self.assertEqual(self._fields(token, {"name": "Пётр", "email": "new@example.test", "phone": "+79991111111"}).status_code, 200)
|
||||
contact.refresh_from_db()
|
||||
self.assertEqual((contact.name, contact.email, contact.phone), ("Оператор", "manual@example.test", "+79990000000"))
|
||||
|
||||
def test_site_fields_do_not_identify_or_merge_visitors(self):
|
||||
fields = {"email": "same@example.test", "phone": "+79991234567", "user_id": "same"}
|
||||
_, first = self._session(fields)
|
||||
_, second = self._session(fields)
|
||||
self.assertNotEqual(first.identity.contact_id, second.identity.contact_id)
|
||||
self.assertEqual(Contact.objects.filter(organization=self.organization).count(), 2)
|
||||
|
||||
def test_fields_endpoint_requires_session_and_uses_write_throttle(self):
|
||||
self.assertEqual(self.client.post("/api/v1/webchat/fields/", {"fields": {}}, format="json").status_code, 401)
|
||||
token, _ = self._session()
|
||||
original = SimpleRateThrottle.THROTTLE_RATES
|
||||
SimpleRateThrottle.THROTTLE_RATES = {**original, "webchat_session_write": "1/min"}
|
||||
cache.clear()
|
||||
try:
|
||||
self.assertEqual(self._fields(token, {}).status_code, 200)
|
||||
self.assertEqual(self._fields(token, {}, REMOTE_ADDR="203.0.113.2").status_code, 429)
|
||||
finally:
|
||||
SimpleRateThrottle.THROTTLE_RATES = original
|
||||
cache.clear()
|
||||
|
||||
|
||||
class SiteFieldRuntimeRoleTests(TransactionTestCase):
|
||||
def test_values_are_tenant_scoped_under_app_role(self):
|
||||
first = Organization.objects.create(name="First", slug="site-fields-first")
|
||||
second = Organization.objects.create(name="Second", slug="site-fields-second")
|
||||
first_contact = None
|
||||
first_integration = None
|
||||
for organization in (first, second):
|
||||
channel = Channel.objects.create(organization=organization, code="site", name="Site")
|
||||
widget = create_web_widget(channel)
|
||||
contact = Contact.objects.create(organization=organization, name="Visitor")
|
||||
ContactFieldValue.objects.create(
|
||||
organization=organization, contact=contact, integration=widget.integration,
|
||||
key="user_id", value="private",
|
||||
)
|
||||
if organization == first:
|
||||
first_contact = contact
|
||||
first_integration = widget.integration
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SET ROLE chatballs_runtime_app")
|
||||
try:
|
||||
with tenant_atomic(first.id):
|
||||
self.assertEqual(ContactFieldValue.objects.count(), 1)
|
||||
self.assertEqual(ContactFieldValue.objects.get().organization_id, first.id)
|
||||
self.assertEqual(ContactFieldValue.objects.filter(organization=second).update(value="leak"), 0)
|
||||
ContactFieldValue.objects.create(
|
||||
organization=first, contact=first_contact, integration=first_integration,
|
||||
key="has_order", value=True,
|
||||
)
|
||||
self.assertEqual(ContactFieldValue.objects.count(), 2)
|
||||
with tenant_atomic(second.id):
|
||||
self.assertEqual(ContactFieldValue.objects.count(), 1)
|
||||
self.assertEqual(ContactFieldValue.objects.get().organization_id, second.id)
|
||||
with transaction.atomic():
|
||||
self.assertEqual(ContactFieldValue.objects.count(), 0)
|
||||
finally:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("RESET ROLE")
|
||||
@@ -7,6 +7,7 @@ urlpatterns = [
|
||||
path("assets/<uuid:public_id>/", WidgetAssetView.as_view(), name="webchat-asset"),
|
||||
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
|
||||
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
|
||||
path("fields/", views.WebchatFieldsView.as_view(), name="webchat-fields"),
|
||||
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),
|
||||
path("messages/<int:message_id>/audio/", views.WebchatMessageAudioView.as_view(), name="webchat-message-audio"),
|
||||
path("messages/<int:message_id>/attachment/", views.WebchatMessageAttachmentView.as_view(), name="webchat-message-attachment"),
|
||||
|
||||
@@ -148,12 +148,23 @@ class WebchatSessionView(_Public):
|
||||
or not services.origin_allowed(widget, host_origin(request))
|
||||
):
|
||||
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
|
||||
result = services.issue_session(context=context, widget=widget)
|
||||
result = services.issue_session(context=context, widget=widget, fields=request.data.get("fields"))
|
||||
if result is None:
|
||||
return Response({"detail": t("webchat.widget_unavailable")}, status=404)
|
||||
return Response(result, status=201)
|
||||
|
||||
|
||||
class WebchatFieldsView(_PublicSession):
|
||||
def post(self, request: Request) -> Response:
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
if session is None:
|
||||
return Response({"detail": t("webchat.session_not_found")}, status=401)
|
||||
from chatballs.webchat.site_fields import save_site_fields
|
||||
|
||||
save_site_fields(session, request.data.get("fields"))
|
||||
return Response({"ok": True})
|
||||
|
||||
|
||||
class WebchatMessagesView(_PublicSession):
|
||||
# JSON — текст, multipart — голосовое из записи в виджете.
|
||||
parser_classes = [JSONParser, MultiPartParser, FormParser]
|
||||
@@ -275,7 +286,10 @@ class WebchatContactView(_PublicSession):
|
||||
class WebchatCallOpenView(_PublicSession):
|
||||
def post(self, request: Request) -> Response:
|
||||
from chatballs.calls.errors import CallTokenError
|
||||
from chatballs.calls.serializers import ice_servers_payload, public_invite_payload
|
||||
from chatballs.calls.serializers import (
|
||||
ice_servers_payload,
|
||||
public_invite_payload,
|
||||
)
|
||||
from chatballs.calls.services import open_call_for_identity
|
||||
|
||||
with _resolved_web_session(request) as (_context, session):
|
||||
|
||||
Reference in new issue
Block a user