diff --git a/.skaro/tasks/T-020-nastroyki-oformleniya-na-servere-i-ochis.md b/.skaro/tasks/T-020-nastroyki-oformleniya-na-servere-i-ochis.md
new file mode 100644
index 0000000..438d340
--- /dev/null
+++ b/.skaro/tasks/T-020-nastroyki-oformleniya-na-servere-i-ochis.md
@@ -0,0 +1,26 @@
+---
+id: T-020
+title: Настройки оформления на сервере и очистка своего CSS
+milestone: M04
+status: done
+depends_on: []
+order: 2
+spec: "0021"
+created: 2026-09-29
+branch: skaro/T-020-nastroyki-oformleniya-na-serve
+---
+
+## Цель
+
+Сервер хранит и валидирует config.appearance и отдаёт его виджету (R-1, R-6, R-7 серверная часть, R-11).
+
+## Критерии приёмки
+
+- [x] PATCH подключения валидирует appearance: accent #RRGGBB, позиция, размер 48/56/64, форма, URL иконок, customCss до 10 КБ
+- [x] Из customCss вырезаются @import, url() с внешними адресами и expression (тест)
+- [x] Публичная конфигурация отдаёт appearance; существующий config.accent продолжает работать
+- [x] Кэш конфигурации и лоадера не задерживает изменения дольше 5 минут
+
+## Итог
+
+Сервер хранит и проверяет config.appearance веб-подключения: цвет, положение, размер, форму, иконки и свой CSS до 10 КБ. Из CSS вырезаются @import, внешние url() и expression. Публичная конфигурация отдаёт appearance целиком, config.accent продолжает работать, кэш не задерживает изменения дольше 5 минут. Ветка перенесена на свежую main, конфликты со схемой своих полей разрешены, обе части сохранены.
diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py
index c9cf46b..e72ee32 100644
--- a/apps/backend/chatballs/i18n/messages/en.py
+++ b/apps/backend/chatballs/i18n/messages/en.py
@@ -496,6 +496,13 @@ MESSAGES: dict[str, object] = {
"portals.widget_needs_channel": "Portal widget must be bound to a channel",
"settings.allowed_origins_list": "allowedOrigins must be a list of strings",
"settings.api_key_required": "Custom API key is required",
+ "settings.appearance": "Appearance settings are not recognised",
+ "settings.appearance_accent": "The colour is a HEX value like #1677ff",
+ "settings.appearance_css_too_large": "Custom CSS is limited to 10 KB",
+ "settings.appearance_icon": "The icon must be a link to an uploaded file",
+ "settings.appearance_position": "The button goes on the left or the right",
+ "settings.appearance_shape": "The button shape is a circle, rounded or square",
+ "settings.appearance_size": "The button size is 48, 56 or 64",
"settings.custom_base_url_required": "Custom Base URL is required",
"settings.custom_model_required": "Custom model is required",
"settings.email_hosts_required": "Email address, IMAP host and SMTP host are required",
diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py
index 5e9a7c7..defab7a 100644
--- a/apps/backend/chatballs/i18n/messages/ru.py
+++ b/apps/backend/chatballs/i18n/messages/ru.py
@@ -500,6 +500,13 @@ MESSAGES: dict[str, object] = {
"portals.widget_needs_channel": "Виджет портала должен быть привязан к каналу",
"settings.allowed_origins_list": "allowedOrigins — список строк",
"settings.api_key_required": "Укажите API-ключ",
+ "settings.appearance": "Настройки оформления не распознаны",
+ "settings.appearance_accent": "Цвет — HEX вида #1677ff",
+ "settings.appearance_css_too_large": "Свой CSS — не больше 10 КБ",
+ "settings.appearance_icon": "Иконка — ссылка на загруженный файл",
+ "settings.appearance_position": "Кнопка — слева или справа",
+ "settings.appearance_shape": "Форма кнопки — круг, скруглённая или квадрат",
+ "settings.appearance_size": "Размер кнопки — 48, 56 или 64",
"settings.custom_base_url_required": "Укажите Base URL",
"settings.custom_model_required": "Укажите модель",
"settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер",
diff --git a/apps/backend/chatballs/integrations/serializers.py b/apps/backend/chatballs/integrations/serializers.py
index 3c10a4c..eafacb7 100644
--- a/apps/backend/chatballs/integrations/serializers.py
+++ b/apps/backend/chatballs/integrations/serializers.py
@@ -1,6 +1,7 @@
from urllib.parse import urlsplit, urlunsplit
-from chatballs.integrations.models import Integration
+from chatballs.integrations.models import Integration, IntegrationProvider
+from chatballs.webchat.appearance import stored_appearance
from chatballs.webchat.field_schema import fields_payload
# Пароль прокси наружу не отдаётся: в списке подключений его видел бы каждый,
@@ -86,6 +87,11 @@ def integration_payload(integration: Integration) -> dict[str, object]:
"consentVersion": integration.config.get("consent_version", ""),
# Свои поля веб-подключения (SPEC-0019).
"fields": fields_payload(integration.config.get("fields", [])),
+ "appearance": (
+ stored_appearance(integration.config)
+ if integration.provider == IntegrationProvider.WEB
+ else None
+ ),
# Email-подключение (ADR-CHATBALLS-0035).
"email": integration.config.get("email", ""),
"imapHost": integration.config.get("imap_host", ""),
diff --git a/apps/backend/chatballs/integrations/services.py b/apps/backend/chatballs/integrations/services.py
index e59c903..e1c91df 100644
--- a/apps/backend/chatballs/integrations/services.py
+++ b/apps/backend/chatballs/integrations/services.py
@@ -22,6 +22,7 @@ from chatballs.integrations.runtime import (
advance_revision_after_configuration_change,
)
from chatballs.tenancy.context import TenantContext
+from chatballs.webchat.appearance import normalize_appearance
from chatballs.webchat.field_schema import normalize_fields
@@ -95,6 +96,7 @@ def _normalized_config(provider: str, config: dict, previous_config: dict | None
if provider == IntegrationProvider.EMAIL:
return _email_config(config)
if provider == IntegrationProvider.WEB:
+ appearance = normalize_appearance(config, previous_config)
allowed = config.get("allowedOrigins", config.get("allowed_domains", []))
if not isinstance(allowed, list) or not all(isinstance(item, str) for item in allowed):
raise ValidationError({"config": t("settings.allowed_origins_list")})
@@ -106,7 +108,9 @@ def _normalized_config(provider: str, config: dict, previous_config: dict | None
return {
"allowed_domains": [item.strip() for item in allowed if item.strip()],
"title": str(config.get("title", "")).strip(),
- "accent": str(config.get("accent", "")).strip(),
+ # Прежнее место цвета: его читают виджеты, сохранённые до appearance.
+ "accent": appearance["accent"],
+ "appearance": appearance,
"greeting": str(config.get("greeting", "")).strip(),
"quick_replies": quick_replies,
"consent_text": str(config.get("consentText", config.get("consent_text", ""))).strip(),
diff --git a/apps/backend/chatballs/webchat/appearance.py b/apps/backend/chatballs/webchat/appearance.py
new file mode 100644
index 0000000..1f5419c
--- /dev/null
+++ b/apps/backend/chatballs/webchat/appearance.py
@@ -0,0 +1,132 @@
+"""Оформление веб-виджета: ``integration.config.appearance`` (SPEC-0021 R-1).
+
+Иконки: пустая строка — стандартный знак агента; у шапки пустая строка —
+«как у кнопки», ``None`` — без иконки.
+"""
+
+import re
+from urllib.parse import urlsplit
+
+from django.core.exceptions import ValidationError
+
+from chatballs.i18n import t
+from chatballs.webchat.custom_css import sanitize_custom_css
+
+DEFAULT_ACCENT = "#1677ff"
+POSITIONS = ("left", "right")
+SIZES = (48, 56, 64)
+SHAPES = ("circle", "rounded", "square")
+MAX_CUSTOM_CSS_BYTES = 10 * 1024
+MAX_ICON_URL_LENGTH = 2048
+
+DEFAULTS = {
+ "accent": "",
+ "launcherIcon": "",
+ "headerIcon": "",
+ "launcherPosition": "right",
+ "launcherSize": 56,
+ "launcherShape": "circle",
+ "customCss": "",
+}
+
+_HEX = re.compile(r"#[0-9a-f]{6}")
+_URL_NOISE = re.compile(r"[\x00-\x20\x7f\\]")
+
+
+def _invalid(key: str) -> ValidationError:
+ return ValidationError({"config": t(key)})
+
+
+def _accent(value: object) -> str:
+ accent = str(value or "").strip().lower()
+ if accent and not _HEX.fullmatch(accent):
+ raise _invalid("settings.appearance_accent")
+ return accent
+
+
+def _choice(value: object, allowed: tuple, error: str) -> object:
+ if isinstance(value, bool) or value not in allowed:
+ raise _invalid(error)
+ return allowed[allowed.index(value)]
+
+
+def _icon(value: object, *, nullable: bool) -> str | None:
+ if value is None:
+ return None if nullable else ""
+ if not isinstance(value, str):
+ raise _invalid("settings.appearance_icon")
+ url = value.strip()
+ if not url:
+ return ""
+ parsed = urlsplit(url)
+ public = parsed.scheme in ("http", "https") and bool(parsed.netloc)
+ # Путь от корня — файл в хранилище на этом же сервере; «//host» — уже чужой адрес.
+ local = not parsed.scheme and url.startswith("/") and not url.startswith("//")
+ if len(url) > MAX_ICON_URL_LENGTH or _URL_NOISE.search(url) or not (public or local):
+ raise _invalid("settings.appearance_icon")
+ return url
+
+
+def _custom_css(value: object) -> str:
+ if value is None:
+ return ""
+ if not isinstance(value, str):
+ raise _invalid("settings.appearance")
+ if len(value.encode("utf-8")) > MAX_CUSTOM_CSS_BYTES:
+ raise _invalid("settings.appearance_css_too_large")
+ return sanitize_custom_css(value).strip()
+
+
+def stored_appearance(config: dict | None) -> dict:
+ """Сохранённое оформление с умолчаниями; цвет — и из прежнего ``config.accent``."""
+ config = config if isinstance(config, dict) else {}
+ stored = config.get("appearance")
+ appearance = {**DEFAULTS, **(stored if isinstance(stored, dict) else {})}
+ appearance["accent"] = appearance["accent"] or str(config.get("accent") or "")
+ return appearance
+
+
+def normalize_appearance(config: dict, previous: dict | None = None) -> dict:
+ """Оформление из ``config`` запроса.
+
+ Форма подключения отправляет config целиком, но без ``appearance`` —
+ тогда оформление остаётся прежним, меняется только цвет из ``config.accent``.
+ """
+ raw = config.get("appearance")
+ if raw is None:
+ appearance = stored_appearance(previous)
+ if "accent" in config:
+ appearance["accent"] = _accent(config["accent"])
+ return appearance
+ if not isinstance(raw, dict):
+ raise _invalid("settings.appearance")
+ return {
+ "accent": _accent(raw["accent"] if "accent" in raw else config.get("accent")),
+ "launcherIcon": _icon(raw.get("launcherIcon"), nullable=False),
+ "headerIcon": _icon(raw.get("headerIcon", ""), nullable=True),
+ "launcherPosition": _choice(
+ raw.get("launcherPosition", DEFAULTS["launcherPosition"]),
+ POSITIONS,
+ "settings.appearance_position",
+ ),
+ "launcherSize": _choice(
+ raw.get("launcherSize", DEFAULTS["launcherSize"]),
+ SIZES,
+ "settings.appearance_size",
+ ),
+ "launcherShape": _choice(
+ raw.get("launcherShape", DEFAULTS["launcherShape"]),
+ SHAPES,
+ "settings.appearance_shape",
+ ),
+ "customCss": _custom_css(raw.get("customCss")),
+ }
+
+
+def public_appearance(presentation: dict) -> dict:
+ """Оформление для виджета: умолчания подставлены, иконка шапки разрешена."""
+ appearance = stored_appearance(presentation)
+ appearance["accent"] = appearance["accent"] or DEFAULT_ACCENT
+ if appearance["headerIcon"] == "":
+ appearance["headerIcon"] = appearance["launcherIcon"]
+ return appearance
diff --git a/apps/backend/chatballs/webchat/custom_css.py b/apps/backend/chatballs/webchat/custom_css.py
new file mode 100644
index 0000000..2a87d8e
--- /dev/null
+++ b/apps/backend/chatballs/webchat/custom_css.py
@@ -0,0 +1,55 @@
+"""Очистка своего CSS виджета (SPEC-0021 R-7).
+
+CSS вставляется `` не должен выйти из тега, как бы виджет ни вставлял CSS.
+ return css.replace("<", "\\3c ")
diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py
index 5823658..185af7b 100644
--- a/apps/backend/chatballs/webchat/services.py
+++ b/apps/backend/chatballs/webchat/services.py
@@ -26,10 +26,9 @@ from chatballs.identity.instance_settings import default_language
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.tenancy.context import TenantContext
+from chatballs.webchat.appearance import public_appearance
from chatballs.webchat.models import WebChatWidget, WebSession
-DEFAULT_ACCENT = "#1677ff"
-
_STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"}
_ROLE = {"CONTACT": "client", "AI": "ai", "OPERATOR": "operator", "SYSTEM": "system"}
@@ -105,6 +104,7 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
fallback.append({"label": t("webchat.write_in_telegram"), "url": f"https://t.me/{username}"})
elif sib.provider == IntegrationProvider.MAX and username:
fallback.append({"label": t("webchat.write_in_max"), "url": ""})
+ appearance = public_appearance(cfg)
return {
"available": True,
"widgetKey": widget.public_key,
@@ -115,7 +115,8 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str)
# Что разрешено в этой точке входа: виджет прячет микрофон при запрете.
"features": features_payload(integration),
"title": cfg.get("title") or channel.name,
- "accent": cfg.get("accent") or DEFAULT_ACCENT,
+ "accent": appearance["accent"],
+ "appearance": appearance,
"greeting": cfg.get("greeting") or t("webchat.default_greeting", language=language),
"consent": {
"text": consent.get("consent_text") or t("webchat.default_consent", language=language),
diff --git a/apps/backend/chatballs/webchat/test_appearance.py b/apps/backend/chatballs/webchat/test_appearance.py
new file mode 100644
index 0000000..78a96ec
--- /dev/null
+++ b/apps/backend/chatballs/webchat/test_appearance.py
@@ -0,0 +1,197 @@
+from django.test import SimpleTestCase, TestCase
+
+from chatballs.channels.models import Channel
+from chatballs.identity.bootstrap import bootstrap_owner
+from chatballs.identity.models import Organization
+from chatballs.integrations.models import Integration, IntegrationProvider
+from chatballs.integrations.services import IntegrationInput, create_integration
+from chatballs.testing import system_tenant_context
+from chatballs.webchat.custom_css import sanitize_custom_css
+
+
+class CustomCssSanitizingTests(SimpleTestCase):
+ def test_import_is_cut(self) -> None:
+ for css in (
+ '@import "https://evil.example/x.css";',
+ "@import url(https://evil.example/x.css) screen;",
+ "@IMPORT 'x.css';",
+ "@\\69mport 'x.css';",
+ ):
+ with self.subTest(css=css):
+ self.assertNotIn("import", sanitize_custom_css(css + "\n.cb-header{color:red}").lower())
+ self.assertIn(".cb-header{color:red}", sanitize_custom_css("@import 'x.css';\n.cb-header{color:red}"))
+
+ def test_external_url_is_cut(self) -> None:
+ for target in (
+ "https://evil.example/a.png",
+ "'http://evil.example/a.png'",
+ '"//evil.example/a.png"',
+ "javascript:alert(1)",
+ '"/\t/evil.example/a.png"',
+ "/\\evil.example/a.png",
+ "https://evil.example/a.png",
+ ):
+ with self.subTest(target=target):
+ css = f".cb-body{{background:url({target})}}"
+ self.assertNotIn("evil", sanitize_custom_css(css))
+ self.assertNotIn("javascript", sanitize_custom_css(css))
+ escaped = sanitize_custom_css(".cb-body{background:\\75 rl(https://evil.example/a.png)}")
+ self.assertNotIn("evil", escaped)
+ unterminated = sanitize_custom_css(".cb-body{background:url(https://evil.example/a.png")
+ self.assertNotIn("evil", unterminated)
+
+ def test_local_url_is_kept(self) -> None:
+ css = (
+ ".a{background:url(#grad)}"
+ ".b{background:url('/media/organizations/x/bg.png')}"
+ ".c{background:url(data:image/png;base64,AAAA)}"
+ ".d{background:url(img/bg.png)}"
+ )
+ self.assertEqual(sanitize_custom_css(css), css)
+
+ def test_expression_is_cut(self) -> None:
+ cleaned = sanitize_custom_css(".cb-header{width:expression(alert(1));color:red}")
+ self.assertNotIn("expression", cleaned)
+ self.assertIn("color:red", cleaned)
+ self.assertNotIn("expression", sanitize_custom_css(".x{width:e\\78pression(alert(1))}"))
+
+ def test_style_tag_cannot_be_closed(self) -> None:
+ cleaned = sanitize_custom_css('.x{content:""}')
+ self.assertNotIn("<", cleaned)
+
+ def test_regular_css_is_untouched_and_sanitizing_is_stable(self) -> None:
+ css = '.cb-header{background:#0d8a7e!important}\n.hover\\:x > .\\31 23{content:"\\2014"}'
+ self.assertEqual(sanitize_custom_css(css), css)
+ once = sanitize_custom_css('.x{content:"<"}')
+ self.assertEqual(sanitize_custom_css(once), once)
+
+
+class WebAppearanceApiTests(TestCase):
+ def setUp(self) -> None:
+ bootstrap_owner(email="owner@example.com", password="temporary-password")
+ self.organization = Organization.objects.get(slug="demo")
+ self.context = system_tenant_context(self.organization)
+ self.channel = Channel.objects.create(organization=self.organization, code="site", name="Сайт")
+ self.integration = create_integration(
+ context=self.context,
+ data=IntegrationInput(
+ provider=IntegrationProvider.WEB,
+ name="Виджет",
+ channel_id=self.channel.id,
+ config={"allowedOrigins": ["example.com"], "accent": "#0d8a7e"},
+ ),
+ )
+ self.client.login(username="owner@example.com", password="temporary-password")
+
+ def _url(self) -> str:
+ return f"/api/v1/organizations/{self.organization.public_id}/integrations/{self.integration.id}/"
+
+ def _patch(self, config: dict):
+ return self.client.patch(
+ self._url(),
+ data={"config": {"allowedOrigins": ["example.com"], **config}},
+ content_type="application/json",
+ )
+
+ def _public_config(self) -> dict:
+ response = self.client.get(
+ "/api/v1/webchat/config/",
+ {"widgetKey": self.integration.web_chat_widget.public_key},
+ HTTP_ORIGIN="https://example.com",
+ )
+ self.assertEqual(response["Cache-Control"], "no-cache")
+ return response.json()
+
+ def test_valid_appearance_is_saved_and_published(self) -> None:
+ appearance = {
+ "accent": "#FFD400",
+ "launcherIcon": "/media/organizations/x/webchat/icon.svg",
+ "headerIcon": None,
+ "launcherPosition": "left",
+ "launcherSize": 64,
+ "launcherShape": "rounded",
+ "customCss": "@import 'x.css';\n.cb-header{background:url(https://evil.example/a.png);color:red}",
+ }
+ response = self._patch({"appearance": appearance})
+
+ self.assertEqual(response.status_code, 200, response.content)
+ saved = response.json()["integration"]["config"]
+ self.assertEqual(saved["accent"], "#ffd400")
+ self.assertEqual(saved["appearance"]["launcherPosition"], "left")
+ self.assertEqual(saved["appearance"]["launcherSize"], 64)
+ self.assertIsNone(saved["appearance"]["headerIcon"])
+ self.assertEqual(saved["appearance"]["customCss"], ".cb-header{background:;color:red}")
+
+ public = self._public_config()
+ self.assertEqual(public["accent"], "#ffd400")
+ self.assertEqual(
+ public["appearance"],
+ {
+ "accent": "#ffd400",
+ "launcherIcon": "/media/organizations/x/webchat/icon.svg",
+ "headerIcon": None,
+ "launcherPosition": "left",
+ "launcherSize": 64,
+ "launcherShape": "rounded",
+ "customCss": ".cb-header{background:;color:red}",
+ },
+ )
+
+ def test_invalid_appearance_is_rejected(self) -> None:
+ for appearance in (
+ {"accent": "blue"},
+ {"accent": "#12345"},
+ {"launcherPosition": "top"},
+ {"launcherSize": 50},
+ {"launcherSize": True},
+ {"launcherShape": "oval"},
+ {"launcherIcon": "javascript:alert(1)"},
+ {"launcherIcon": "//evil.example/icon.svg"},
+ {"headerIcon": "data:image/svg+xml,"},
+ {"customCss": "a{}" * 4000},
+ ):
+ with self.subTest(appearance=appearance):
+ response = self._patch({"appearance": appearance})
+ self.assertEqual(response.status_code, 400, response.content)
+ self.assertEqual(self._patch({"accent": "not-a-colour"}).status_code, 400)
+ self.integration.refresh_from_db()
+ self.assertEqual(self.integration.config["accent"], "#0d8a7e")
+
+ def test_custom_css_limit_is_10_kb(self) -> None:
+ exactly = "a" * (10 * 1024)
+ self.assertEqual(self._patch({"appearance": {"customCss": exactly}}).status_code, 200)
+ self.assertEqual(self._patch({"appearance": {"customCss": exactly + "a"}}).status_code, 400)
+
+ def test_legacy_accent_keeps_working(self) -> None:
+ # Виджет, сохранённый до appearance: цвет только в config.accent.
+ Integration.objects.filter(id=self.integration.id).update(
+ config={"allowed_domains": ["example.com"], "accent": "#be123c"}
+ )
+ widget = self.integration.web_chat_widget
+ widget.presentation_config = {"accent": "#be123c"}
+ widget.save(update_fields=["presentation_config"])
+
+ public = self._public_config()
+ self.assertEqual(public["accent"], "#be123c")
+ self.assertEqual(public["appearance"]["accent"], "#be123c")
+ self.assertEqual(public["appearance"]["launcherPosition"], "right")
+ self.assertEqual(public["appearance"]["launcherSize"], 56)
+ self.assertEqual(public["appearance"]["launcherShape"], "circle")
+
+ # Прежняя форма шлёт config без appearance: оформление не сбрасывается.
+ self._patch({"appearance": {"launcherPosition": "left", "headerIcon": "https://cdn.example/h.png"}})
+ response = self._patch({"accent": "#4f46e5", "title": "Поддержка"})
+ self.assertEqual(response.status_code, 200, response.content)
+ public = self._public_config()
+ self.assertEqual(public["accent"], "#4f46e5")
+ self.assertEqual(public["appearance"]["launcherPosition"], "left")
+ self.assertEqual(public["appearance"]["headerIcon"], "https://cdn.example/h.png")
+
+ def test_header_icon_defaults_to_launcher_icon(self) -> None:
+ self._patch({"appearance": {"launcherIcon": "https://cdn.example/l.svg"}})
+ public = self._public_config()
+ self.assertEqual(public["appearance"]["headerIcon"], "https://cdn.example/l.svg")
+
+ def test_loader_is_cached_no_longer_than_five_minutes(self) -> None:
+ response = self.client.get("/chat-widget.js")
+ self.assertEqual(response["Cache-Control"], "public, max-age=300")
diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py
index ee619b0..6174fb2 100644
--- a/apps/backend/chatballs/webchat/views.py
+++ b/apps/backend/chatballs/webchat/views.py
@@ -36,6 +36,8 @@ from chatballs.webchat.throttling import (
WebchatTrafficThrottle,
)
+LOADER_MAX_AGE_SECONDS = 300
+
class _Public(APIView):
authentication_classes: list = [] # публичные endpoint'ы: токен сессии, без CSRF/сессии Django
@@ -117,14 +119,19 @@ class WebchatConfigView(_Public):
channel_code = request.GET.get("channel", "")
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
if context is None or widget is None:
- return Response({"available": False})
- return Response(
- services.public_config(
- context=context,
- widget=widget,
- origin=host_origin(request),
+ response = Response({"available": False})
+ else:
+ response = Response(
+ services.public_config(
+ context=context,
+ widget=widget,
+ origin=host_origin(request),
+ )
)
- )
+ # Оформление меняют в админке, и сайт должен увидеть его на следующей
+ # загрузке страницы (SPEC-0021 R-11). Ответ к тому же зависит от Origin.
+ response["Cache-Control"] = "no-cache"
+ return response
class WebchatSessionView(_Public):
@@ -309,5 +316,6 @@ class WebchatCallDeclineView(_PublicSession):
class WidgetLoaderView(View):
def get(self, request) -> HttpResponse:
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
- response["Cache-Control"] = "public, max-age=300"
+ # Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
+ response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
return response
diff --git a/apps/backend/chatballs/webchat/widgets.py b/apps/backend/chatballs/webchat/widgets.py
index 1adeff0..b2462b9 100644
--- a/apps/backend/chatballs/webchat/widgets.py
+++ b/apps/backend/chatballs/webchat/widgets.py
@@ -40,8 +40,8 @@ def ensure_widget(integration: Integration) -> WebChatWidget | None:
config = integration.config if isinstance(integration.config, dict) else {}
presentation = {
key: config[key]
- for key in ("title", "accent", "greeting", "quick_replies")
- if config.get(key) not in (None, "", [])
+ for key in ("title", "accent", "greeting", "quick_replies", "appearance")
+ if config.get(key) not in (None, "", [], {})
}
fields = public_fields(config.get("fields", []))
if fields: