diff --git a/.skaro/tasks/T-010-hranenie-znacheniy-poley-i-priem-dannyh.md b/.skaro/tasks/T-010-hranenie-znacheniy-poley-i-priem-dannyh.md new file mode 100644 index 0000000..0b32b3b --- /dev/null +++ b/.skaro/tasks/T-010-hranenie-znacheniy-poley-i-priem-dannyh.md @@ -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 прошли. diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 551f86d..fd2de87 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -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( { diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 42863fa..05c7b08 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -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 diff --git a/apps/backend/chatballs/conversations/contacts_merge.py b/apps/backend/chatballs/conversations/contacts_merge.py index f8e4d23..78fea75 100644 --- a/apps/backend/chatballs/conversations/contacts_merge.py +++ b/apps/backend/chatballs/conversations/contacts_merge.py @@ -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 diff --git a/apps/backend/chatballs/conversations/migrations/0027_contact_email_contactfieldvalue.py b/apps/backend/chatballs/conversations/migrations/0027_contact_email_contactfieldvalue.py new file mode 100644 index 0000000..3bc9c43 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0027_contact_email_contactfieldvalue.py @@ -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")], + }, + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index d63521a..cff7bff 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -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). diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index 338869c..3c6f1ce 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -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, diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index c8df70e..f6b6bdd 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -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 diff --git a/apps/backend/chatballs/conversations/test_site_field_manual_edits.py b/apps/backend/chatballs/conversations/test_site_field_manual_edits.py new file mode 100644 index 0000000..5f13765 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_site_field_manual_edits.py @@ -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()) diff --git a/apps/backend/chatballs/tenancy/migrations/0039_contact_field_values_guards.py b/apps/backend/chatballs/tenancy/migrations/0039_contact_field_values_guards.py new file mode 100644 index 0000000..fecb452 --- /dev/null +++ b/apps/backend/chatballs/tenancy/migrations/0039_contact_field_values_guards.py @@ -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)] diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index 185af7b..66fe07d 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -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} diff --git a/apps/backend/chatballs/webchat/site_fields.py b/apps/backend/chatballs/webchat/site_fields.py new file mode 100644 index 0000000..0ede314 --- /dev/null +++ b/apps/backend/chatballs/webchat/site_fields.py @@ -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, + ) diff --git a/apps/backend/chatballs/webchat/test_site_fields.py b/apps/backend/chatballs/webchat/test_site_fields.py new file mode 100644 index 0000000..5c6f5c7 --- /dev/null +++ b/apps/backend/chatballs/webchat/test_site_fields.py @@ -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") diff --git a/apps/backend/chatballs/webchat/urls.py b/apps/backend/chatballs/webchat/urls.py index 9a1a503..a83ba80 100644 --- a/apps/backend/chatballs/webchat/urls.py +++ b/apps/backend/chatballs/webchat/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ path("assets//", 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//audio/", views.WebchatMessageAudioView.as_view(), name="webchat-message-audio"), path("messages//attachment/", views.WebchatMessageAttachmentView.as_view(), name="webchat-message-attachment"), diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index 6174fb2..ebb1dc4 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -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):