Compare commits

...
20 Commits
Author SHA1 Message Date
AndreyandClaude Opus 5 4ab26a1bcd 🐛 fix(updates): запускать установщик, а не второй цикл ожидания
Входная точка образа updater'а — сценарий ожидания запросов, поэтому
`docker run ... chatballs-updater-apply.sh` не запускал установку, а
передавал имя сценария аргументом в тот же цикл. Помощник поднимался без
compose-меток, уходил в вечный sleep и никогда не дописывал статус:
интерфейс крутил установку без конца, а новые запросы отбрасывались как
«уже идёт». Та же ошибка была во втором вызове — копировании compose.yaml
на хост.

Обе команды теперь подменяют входную точку. Плюс страховка в цикле: если
помощник исчез, не дописав результат, установка помечается неудачной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:16:19 +03:00
AndreyandClaude Opus 5 09fb0177b1 🔥 refactor(ai): убрать учёт расхода на модель и валюту организации
Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только OpenRouter, для остальных
провайдеров была прайс-таблица из двух моделей и ноль для всех прочих.
Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py,
ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED
вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost,
настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS,
поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных.
Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда
сработать.

Валюта организации удалена целиком: поле принимало единственное значение
RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась.
Убраны колонка с миграцией, валидация «только рубль», payload настроек и
платформенной выдачи, обязательное поле currency в API провижининга,
типы и транспорт на фронте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:02:01 +03:00
AndreyandClaude Opus 5 b76871caf0 🐛 fix(chat): вернуть свёрнутый список диалогов, зелёный акцент по умолчанию
Кнопка «Показать список» жила только в шапке переписки. При пустом списке
или просто без выбранного диалога переписки нет — свёрнутый список
оказывался не вернуть ничем. Теперь кнопка есть и в пустой области, на том
же месте, что и в шапке. Сценарий закрыт e2e-тестом.

Цвет продукта по умолчанию — зелёный #0f9b8e: он же первый в палитре
акцентов профиля. Прежний синий остался вторым пресетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:20:14 +03:00
AndreyandClaude Opus 5 8580b480e7 💄 fix(organization): язык интерфейса селектом, одна вёрстка у обеих форм
После удаления валюты в ряду полей осталась пустая половина. Теперь ряд
занимают часовой пояс и язык интерфейса: язык был сегментом во всю ширину
карточки, стал таким же селектом, как соседнее поле. Подпись под ним
убрана вместе с ключом словаря.

Форма создания организации и «Настройки → Организация» — одна форма по
смыслу, поэтому у них теперь одна ширина колонки и одни отступы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:12:22 +03:00
AndreyandClaude Opus 5 eff6d7ac0c 🔥 refactor(ai): убрать дневной лимит агента и выбор валюты организации
Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:54:14 +03:00
AndreyandClaude Opus 5 1cff9e2183 ✨ feat(onboarding): визард «Начало работы» вместо блока «Запуск»
Онбординг из восьми шагов: приветствие, визард с точным путём и превью
результата на каждом шаге, тур «Показать где» с подсветкой реального
элемента и финальный экран. Точки возврата — ссылка внизу субменю
«Настроек» и пилюля в углу рабочей области. Вёрстка по макету
design/baseline/Онбординг.

Признак «закрыл» и «прошёл» живёт на членстве человека в организации, а
не в localStorage: требование — показать визард всем, кто его ещё не
закрывал, включая тех, кто работает в установке давно. У каждого он свой,
поэтому один администратор не прячет визард команде. Прогресс шагов
считается по факту настройки, ручное «Далее» его не подменяет.

Заодно все селекты приложения переведены на общее меню: SelectMenu в
shared/ui-controls, поверх него FilterDropdown (фильтры списков) и
SelectField (поля форм). Нативных <select> не осталось, вместе с ними
ушли пять копий скина селекта. Изменения переплетены с онбордингом через
общие файлы (словари, e2e-сценарии), поэтому одним коммитом.

Устаревший design/design-system удалён: источник истины по интерфейсу —
макеты design/baseline/<фича>/*.dc.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:22:35 +03:00
AndreyandClaude Fable 5.1 d8420e4379 🔖 release: 1.6.1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:30:31 +03:00
AndreyandClaude Fable 5.1 21b8100598 ⚡ fix(updates): проверка канала релизов раз в 15 минут вместо шести часов
Обновление видно на установке в пределах четверти часа после публикации; кнопка «Проверить» по-прежнему делает запрос сразу. Интервал переопределяется CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:29:18 +03:00
AndreyandClaude Fable 5.1 1da84c9734 🔖 release: 1.6.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:15:14 +03:00
AndreyandClaude Fable 5.1 82805f7b1a ✨ feat(identity): организации из интерфейса — создание, выбор после входа, /join под ролью app
Кнопка «Добавить организацию» внизу переключателя у логотипа (A1) ведёт на
страницу /organizations/new: логотип, название, часовой пояс, валюта, язык.
Создавший становится владельцем и сразу переключается в новую организацию.
Право — у администратора установки и у владельца или администратора любой
организации; сервер проверяет то же (POST /api/v1/organizations/).

После входа учётная запись с несколькими организациями выбирает, с какой
начать: экран в рамке входа, строки «логотип · название · роль». Прямая
ссылка на организацию экран минует.

tenancy/0035: роль app вставляет организацию только в контексте заранее
выделенного id (как мастер первого запуска) вместо политики «только первая»;
security-barrier каталог invitation_directory — ссылка /join открывается без
контекста, и под ролью app приглашение раньше не находилось вовсе. Тем же
путём язык организации в профиле: членства читаются через каталог входа.

Тесты: создание организации по API, RLS под реальной ролью app (вставка
только в своём контексте, поиск приглашения по токену), e2e переключателя,
страницы создания и экрана выбора. UpdateState исключён из проверки покрытия
демо-набором — одна строка на установку.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 04:14:24 +03:00
AndreyandClaude Opus 5 eca0e52667 ♻️ refactor(ui): один селект и один скин полей на всё приложение
Карточка агента рисовала свои выпадающие селекты: «Группа» и «Провайдер»
жили на собственной вёрстке и своём CSS, а рядом, в модалке создания
агента, стоял общий SelectField. Два стандарта одного элемента успели
разойтись в высоте, подложке, размере текста, подписи и фокусе.

Общий SelectField научился тому, из-за чего карточка писала свой: метке
перед значением (точка группы), невалидному состоянию и режиму без прав
правки, где значение показывается текстом. Форм у него две — обычный
селект и бокс, который держит рамку, когда внутрь нужно поставить точку
или текст вместо контрола. Карточка перешла на него, локальный скин
удалён, кольцо фокуса кадра G2 сохранено.

Геометрия базлайна v2 (поле 36px на подложке n-10, текст 13.5px, подпись
12.5/600) стала дефолтом shared/form-controls.css. До этого дефолтом
были 38px на surface-card, а базлайн добирали поверх профиль, настройки
и модалка создания агента — вместе с карточкой четыре копии одного поля.
Копии сняты, мобильные 44px тоже общие. Заодно шеврон встал по центру
поля: его отступ считался от прежних 38px.

Справочник дизайн-системы обновлён следом, чтобы не описывать геометрию,
которой в приложении больше нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 03:59:10 +03:00
AndreyandClaude Fable 5.1 54c35e281a 🔖 release: 1.5.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 5396dda8ad ✨ feat(updates): обновление установки из интерфейса
Установка раз в шесть часов и по кнопке проверяет страницу релизов
GitHub. Администратор установки видит баннер о новой версии и ставит
её одной кнопкой; карточка «Обновления» — в «Настройки → Платформа».

Установку выполняет отдельный сервис updater с Docker-сокетом:
backend-app общается с ним только файлами в томе chatballs-updates
(heartbeat, request.json, status.json). Updater принимает лишь релизы
своего репозитория с образами по digest и применяет compose.yaml в
одноразовом контейнере-помощнике, после чего кладёт файл в каталог
установки. Запрос установки попадает в аудит.

Релизный workflow собирает образ updater и пришпиливает его в
compose.yaml; версия бэкенда зашивается в образ (CHATBALLS_VERSION).
Миграции updates.0001 и tenancy.0034 (гранты на таблицу состояния).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:43:11 +03:00
AndreyandClaude Fable 5.1 29164db094 🔖 release: 1.4.0
SVG-логотип организации; воркер больше не засоряет журнал сбоями опроса
подключений и не опрашивает демо-подключения.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:55 +03:00
AndreyandClaude Fable 5.1 c5a53ccec5 ✨ feat(identity): SVG в качестве логотипа организации
К PNG, JPEG и WebP добавлен SVG. Файл проверяется при загрузке и
отклоняется целиком, если содержит скрипты, обработчики событий,
foreignObject, внешние ссылки в href или url() внутри стилей, DOCTYPE и
сущности — переписывать чужую графику молча хуже, чем попросить другой
файл. При отдаче логотип получает Content-Security-Policy с sandbox и
nosniff как второй рубеж: адрес можно открыть и напрямую.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 049217505a 🔇 fix(worker): пауза после сбоя опроса подключения и тишина в журнале
Подключение с ненастоящим токеном или недоступным сервером писало
предупреждение на каждом цикле опроса, каждые три секунды, на каждое
подключение. Теперь сбой транспорта — исключение PollFailed, а слой пауз
(transports.backoff) пропускает подключение с удвоением паузы от шести
секунд до четверти часа и пишет в журнал только смену состояния: первый
сбой, выход на максимальную паузу и восстановление. Курсор при сбое не
двигается. Демо-подключения из демо-набора помечаются demoSeed и не
опрашиваются вовсе: их токены ненастоящие.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:03:54 +03:00
AndreyandClaude Fable 5.1 132c684630 🔖 release: 1.3.1
Исправления после установки на чистый хост: «Ошибка загрузки» сразу после
мастера первого запуска (кэш адреса установки в соседних процессах) и
пустое состояние «Базы знаний», прижатое к левому краю.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 bfe90e9abd 💄 fix(knowledge): пустое состояние библиотеки снова по центру
Коммит 776bc895 при переносе библиотеки на серверную пагинацию удалил
правило контейнера .knowledge-empty, оставив только вложенный блок шириной
480px — он прижимался к левому краю рабочей области. Правило возвращено
как было: flex, по центру, отступ 40px (кадр KB1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 5b49904d6a 🐛 fix(identity): адрес установки виден соседним процессам сразу, а не через 10 секунд
Кэш адреса установки живёт в каждом процессе gunicorn по 10 секунд. Мастер
первого запуска и смена адреса в «Настройках» сбрасывали его только в том
процессе, где выполнялись: соседний отвечал «Invalid host» на первый же
запрос после мастера, и приложение показывало «Ошибка загрузки» до
обновления страницы. Промах по хосту теперь перечитывает строку настроек,
но не чаще раза в секунду на процесс, чтобы поток запросов с чужим Host не
превращался в поток запросов к базе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:18:43 +03:00
AndreyandClaude Fable 5.1 cd50cce5a8 ⬆️ ci(actions): версии actions под Node.js 24
GitHub снимает Node.js 20 с раннеров и уже принудительно запускает старые
версии actions на Node.js 24, о чём предупреждает в каждом прогоне. Все семь
actions подняты до мажорных версий, чей action.yml объявляет node24:
checkout v7, setup-python v7, upload-artifact v7, build-push-action v7,
login-action v4, setup-buildx-action v4, action-gh-release v3. Входы и выходы
шагов не менялись.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 18:55:51 +03:00
183 changed files with 9278 additions and 4255 deletions

No files matched your search

+4 -4
View File
@@ -23,9 +23,9 @@ jobs:
name: ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -46,9 +46,9 @@ jobs:
name: deployment CLI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
+34 -10
View File
@@ -39,12 +39,13 @@ env:
GATEWAY_BASE_IMAGE: caddy:2.8.4
REDIS_IMAGE: redis:7-alpine
COTURN_IMAGE: coturn/coturn:4.6
UPDATER_BASE_IMAGE: docker:27-cli
jobs:
images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Версия релиза
id: version
@@ -57,9 +58,9 @@ jobs:
echo "value=$version" >> "$GITHUB_OUTPUT"
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,13 +79,16 @@ jobs:
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
- name: Backend
id: backend
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
build-args: |
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
@@ -94,7 +98,7 @@ jobs:
- name: Frontend
id: frontend
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
@@ -109,7 +113,7 @@ jobs:
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
- name: Gateway
id: gateway
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
@@ -124,7 +128,7 @@ jobs:
- name: Postgres
id: postgres
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
@@ -137,6 +141,23 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
# и два сценария внутри; единственный, кому монтируется docker.sock.
- name: Updater
id: updater
uses: docker/build-push-action@v7
with:
context: ${{ env.APP_DIR }}
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
build-args: |
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
push: true
tags: |
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Релизный compose.yaml и release.env
id: artifacts
run: |
@@ -148,6 +169,7 @@ jobs:
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
redis="${{ steps.bases.outputs.redis }}"
coturn="${{ steps.bases.outputs.coturn }}"
@@ -160,7 +182,8 @@ jobs:
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
{
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
@@ -172,6 +195,7 @@ jobs:
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
echo "CHATBALLS_REDIS_IMAGE=$redis"
echo "CHATBALLS_COTURN_IMAGE=$coturn"
echo "CHATBALLS_UPDATER_IMAGE=$updater"
} > dist/release.env
# Файл, который скачает человек, обязан быть валидным сам по себе —
@@ -180,7 +204,7 @@ jobs:
cat dist/compose.yaml
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-compose
path: |
@@ -189,7 +213,7 @@ jobs:
- name: Приложить к релизу
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: |
dist/compose.yaml
+1 -1
View File
@@ -16,7 +16,7 @@ Production deployment / миграция:
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
UI / дизайн:
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
+3 -1
View File
@@ -132,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
### Updating
Download the new release's `compose.yaml` over the old one and restart:
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
```bash
docker compose pull && docker compose up -d --wait
+3 -1
View File
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
### Обновление
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
```bash
docker compose pull && docker compose up -d --wait
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.6.1
+5
View File
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
# Версия релиза попадает в образ при сборке: по ней приложение решает,
# есть ли обновление. Сборка из исходников остаётся «dev».
ARG CHATBALLS_VERSION=dev
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
RUN addgroup --system hub && adduser --system --ingroup hub hub
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
-6
View File
@@ -128,7 +128,6 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
"model": agent.model,
"providerIntegrationId": agent.provider_integration_id,
"modelParams": agent.model_params,
"limits": agent.limits,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
"tone": agent.tone,
@@ -232,7 +231,6 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"modelParams",
"limits",
"persona",
"tone",
"instructions",
@@ -249,11 +247,8 @@ def update_agent_card(
):
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
model_params = body.get("modelParams", agent.model_params)
limits = body.get("limits", agent.limits)
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": t("api.object_required")})
if not isinstance(limits, dict):
raise ValidationError({"limits": t("api.object_required")})
provider_integration_id = body.get(
"providerIntegrationId", agent.provider_integration_id
)
@@ -270,7 +265,6 @@ def update_agent_card(
provider_integration_id=provider_integration_id,
model_params=model_params,
allowed_tools=agent.allowed_tools,
limits=limits,
persona=str(body.get("persona", agent.persona)),
tone=str(body.get("tone", agent.tone)),
instructions=str(body.get("instructions", agent.instructions)),
+1 -28
View File
@@ -2,7 +2,6 @@ import time
from django.conf import settings
from chatballs.ai import limits, pricing
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.pii import redact
from chatballs.ai.provider import routing
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
_breaker = CircuitBreaker()
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
LlmInvocation.objects.create(
organization=channel.organization,
channel=channel,
purpose=purpose,
operation="chat",
model=model,
status=LlmInvocationStatus.BLOCKED,
error=str(error),
)
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
# организации с fallback на модель агента. Без интеграции модель остаётся
@@ -52,18 +39,7 @@ def invoke_chat(
params: dict | None = None,
used_fragment_ids: list | None = None,
) -> ChatResult:
fallback_model = model or channel.ai_agent.model
try:
provider, model = _prepare_invocation(channel=channel, requested_model=model)
limits.assert_within_limits(channel, channel.ai_agent)
except limits.LimitExceeded as error:
_record_blocked(
channel=channel,
purpose=purpose,
model=fallback_model,
error=error,
)
raise
provider, model = _prepare_invocation(channel=channel, requested_model=model)
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
started = time.monotonic()
@@ -96,8 +72,6 @@ def invoke_chat(
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
total_tokens=result.total_tokens,
cost_micros=result.cost_micros
or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
latency_ms=int((time.monotonic() - started) * 1000),
status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [],
@@ -128,7 +102,6 @@ def embed_texts(
model=model,
prompt_tokens=tokens,
total_tokens=tokens,
cost_micros=pricing.cost_micros(model, tokens, 0),
status=LlmInvocationStatus.SUCCESS,
)
return results
-32
View File
@@ -1,32 +0,0 @@
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
class LimitExceeded(Exception):
pass
def _day_start():
now = timezone.localtime()
return now.replace(hour=0, minute=0, second=0, microsecond=0)
def daily_cost_micros(channel=None) -> int:
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
if channel is not None:
queryset = queryset.filter(channel=channel)
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
def assert_within_limits(channel, agent) -> None:
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
if global_limit and daily_cost_micros() >= global_limit:
raise LimitExceeded("Global daily AI cost limit reached")
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
# в micro-USD. 1 цент = 10 000 micro-USD.
channel_limit = (agent.limits or {}).get("dailyCostUsd")
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
raise LimitExceeded("Channel daily AI cost limit reached")
@@ -0,0 +1,18 @@
# Дневной бюджет агента снят вместе с полем `limits`: расход считался по
# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит
# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит
# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS).
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("ai", "0017_agent_answer_language"),
]
operations = [
migrations.RemoveField(
model_name="aiagent",
name="limits",
),
]
@@ -0,0 +1,34 @@
# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена
# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных
# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна
# цифра расхода в продукте не показывалась.
#
# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему.
from django.db import migrations, models
def drop_blocked_rows(apps, schema_editor):
"""Строк со снятым статусом в журнале остаться не должно."""
apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete()
class Migration(migrations.Migration):
dependencies = [
("ai", "0018_remove_agent_limits"),
]
operations = [
migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop),
migrations.RemoveField(model_name="llminvocation", name="cost_micros"),
migrations.RemoveField(model_name="llminvocation", name="currency"),
migrations.AlterField(
model_name="llminvocation",
name="status",
field=models.CharField(
choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")],
default="SUCCESS",
max_length=16,
),
),
]
-10
View File
@@ -417,10 +417,6 @@ class AIAgent(TenantRelationModel):
allowed_tools = models.JSONField(default=list, blank=True)
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
limits = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -463,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
ERROR = "ERROR", "Ошибка"
BLOCKED = "BLOCKED", "Заблокировано лимитом"
@@ -489,10 +483,6 @@ class LlmInvocation(TenantRelationModel):
total_tokens = models.PositiveIntegerField(default=0)
cost_micros = models.PositiveBigIntegerField(default=0)
currency = models.CharField(max_length=3, default="USD")
latency_ms = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
-17
View File
@@ -1,17 +0,0 @@
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
@@ -18,9 +18,6 @@ class ChatResult:
model: str
prompt_tokens: int
completion_tokens: int
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
cost_micros: int = 0
@property
def total_tokens(self) -> int:
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
- POST /chat/completions with {model, messages, ...}; response has
choices[0].message.content and usage (optionally usage.cost in USD).
choices[0].message.content and usage (prompt/completion tokens).
- POST /embeddings with {model, input}; response has data[].embedding and usage.
@@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters
do not duplicate it. Adapters stay responsible for their own product semantics
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
(name, catalog). Stdlib only — no third-party HTTP client.
"""
@@ -116,21 +116,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
timeout: float, proxy_url: str = "", params: dict | None = None,
timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult:
include_cost: bool = False) -> ChatResult:
"""POST /chat/completions and parse the OpenAI-shaped response.
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
usage.cost (USD, converted to micros). Providers that do not report cost
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
"""
"""POST /chat/completions and parse the OpenAI-shaped response."""
payload: dict = {
@@ -142,10 +130,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
}
if include_cost:
payload["usage"] = {"include": True}
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url)
@@ -160,8 +144,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
@@ -172,8 +154,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
)
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
self.proxy_url = proxy_url or ""
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
return openai_http.chat_completions(
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
)
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
-17
View File
@@ -21,7 +21,6 @@ class AgentInput:
provider_integration_id: int | None
model_params: dict
allowed_tools: list
limits: dict
persona: str
tone: str
instructions: str
@@ -39,20 +38,6 @@ class AgentCreateInput:
knowledge_ids: list[int]
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
def _normalize_limits(raw: dict | None) -> dict:
if not isinstance(raw, dict):
return {}
value = raw.get("dailyCostUsd")
try:
cents = int(value)
except (TypeError, ValueError):
return {}
return {"dailyCostUsd": cents} if cents > 0 else {}
def knowledge_for_agent_ids(
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
) -> list[Knowledge]:
@@ -146,7 +131,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
locked.provider_integration = selection.integration
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.limits = _normalize_limits(data.limits)
locked.persona = data.persona
locked.tone = data.tone
locked.instructions = data.instructions
@@ -158,7 +142,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
"provider_integration",
"model_params",
"allowed_tools",
"limits",
"persona",
"tone",
"instructions",
-27
View File
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
self.assertGreater(invocation.total_tokens, 0)
# Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели.
from chatballs.ai import pricing
self.assertEqual(
invocation.cost_micros,
pricing.cost_micros(
invocation.model, invocation.prompt_tokens, invocation.completion_tokens
),
)
def test_pii_is_redacted_before_reaching_provider(self) -> None:
from unittest import mock
@@ -378,24 +369,6 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content)
def test_limit_blocks_and_records(self) -> None:
from chatballs.ai import limits as ai_limits
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.provider.base import ChatMessage
self.agent.limits = {"dailyCostUsd": 1}
self.agent.save(update_fields=["limits"])
# 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros.
LlmInvocation.objects.create(
channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001,
status=LlmInvocationStatus.SUCCESS,
)
with self.assertRaises(ai_limits.LimitExceeded):
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
def test_invocation_records_used_fragment_ids(self) -> None:
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation
@@ -13,7 +13,6 @@ from dataclasses import dataclass
from django.db import IntegrityError, transaction
from django.utils import timezone
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn
@@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
except (ProviderError, LimitExceeded) as error:
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
# отвечаем клиенту понятным fallback.
except ProviderError as error:
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# оператору, уведомляем и отвечаем клиенту понятным fallback.
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
channel__is_active=True,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
and not integration.config.get("demoSeed")
]
total = 0
for integration in integrations:
+1 -15
View File
@@ -8,11 +8,10 @@ from __future__ import annotations
from datetime import datetime, timedelta
from django.db.models import Count, Sum
from django.db.models import Count
from django.db.models.functions import TruncDate, TruncHour
from django.utils import timezone
from chatballs.ai.models import LlmInvocation
from chatballs.channels.selectors import channels_in_organization
from chatballs.conversations.models import (
ControlMode,
@@ -64,17 +63,6 @@ def _chart(
return {"values": values, "labels": labels}
def _ai_cost(
org_id: int,
start: datetime,
end: datetime | None = None,
) -> int:
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
if end is not None:
qs = qs.filter(created_at__lt=end)
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
def sales_overview_stats(context, period: str) -> dict:
organization_id = context.organization_id
now = timezone.now()
@@ -105,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict:
conversation__organization_id=organization_id,
created_at__gte=start,
).count(),
"aiCostMicros": _ai_cost(organization_id, start),
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
}
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
@@ -340,30 +340,34 @@ class ReplyTemplateTests(ChatExtrasTestCase):
self.assertFalse(ReplyTemplate.objects.exists())
class LaunchChecklistTests(TestCase):
class OnboardingTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="New", slug="launch-org")
owner = HumanUser.objects.create_user(
self.owner = HumanUser.objects.create_user(
email="owner@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=owner,
self.membership = OrganizationMembership.objects.create(
user=self.owner,
organization=self.organization,
role=EmployeeRole.OWNER,
position_title="Owner",
)
self.client = APIClient()
self.client.force_authenticate(owner)
self.client.force_authenticate(self.owner)
def test_checklist_marks_steps_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/launch-checklist/").json()
def test_steps_are_marked_by_fact(self) -> None:
initial = self.client.get("/api/v1/company/onboarding/").json()
self.assertEqual(
initial,
initial["steps"],
{
"agentCreated": False,
"providerConnected": False,
"agentActive": False,
"knowledgeFilled": False,
"connectionBound": False,
"widgetPublished": False,
"employeeInvited": False,
"done": False,
"platformConfigured": False,
"firstConversation": False,
},
)
@@ -393,5 +397,56 @@ class LaunchChecklistTests(TestCase):
position_title="Operator",
)
final = self.client.get("/api/v1/company/launch-checklist/").json()
self.assertTrue(final["done"])
final = self.client.get("/api/v1/company/onboarding/").json()
self.assertTrue(final["steps"]["connectionBound"])
self.assertTrue(final["steps"]["employeeInvited"])
def test_existing_member_has_not_dismissed_onboarding(self) -> None:
"""Признак пустой у всех, кто заведён до появления онбординга."""
payload = self.client.get("/api/v1/company/onboarding/").json()
self.assertIsNone(payload["dismissedAt"])
self.assertIsNone(payload["completedAt"])
def test_dismiss_and_complete_and_restart(self) -> None:
dismissed = self.client.post(
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
).json()
self.assertIsNotNone(dismissed["dismissedAt"])
self.assertIsNone(dismissed["completedAt"])
completed = self.client.post(
"/api/v1/company/onboarding/", {"action": "complete"}, format="json"
).json()
self.assertIsNotNone(completed["completedAt"])
restarted = self.client.post(
"/api/v1/company/onboarding/", {"action": "restart"}, format="json"
).json()
self.assertIsNone(restarted["dismissedAt"])
self.assertIsNone(restarted["completedAt"])
def test_dismissal_is_personal(self) -> None:
"""Закрытие одним администратором не прячет визард у второго."""
self.client.post(
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
)
colleague = HumanUser.objects.create_user(
email="admin@launch.test", password="Password-123"
)
OrganizationMembership.objects.create(
user=colleague,
organization=self.organization,
role=EmployeeRole.ADMIN,
position_title="Admin",
)
other = APIClient()
other.force_authenticate(colleague)
self.assertIsNone(other.get("/api/v1/company/onboarding/").json()["dismissedAt"])
def test_unknown_action_is_rejected(self) -> None:
response = self.client.post(
"/api/v1/company/onboarding/", {"action": "nope"}, format="json"
)
self.assertEqual(response.status_code, 400)
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
from chatballs.conversations.selectors import conversation_messages
from chatballs.conversations.serializers import conversation_payload, message_payload
from chatballs.conversations.transports import email as email_transport
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
messages, marker = email_transport.poll_updates(integration)
with self.assertRaises(PollFailed):
email_transport.poll_updates(integration)
self.assertEqual(messages, [])
self.assertEqual(marker, "7:99")
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
self.assertEqual(integration.poll_marker, "7:99")
@@ -0,0 +1,83 @@
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
первый сбой, выход на максимальную паузу и восстановление, а курсор при
сбое не двигается. Демо-подключения не опрашиваются вовсе.
"""
from __future__ import annotations
from unittest import mock
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.conversations import poller
from chatballs.conversations.transports import backoff, poll
from chatballs.conversations.transports.errors import PollFailed
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
class PollBackoffTests(TestCase):
def setUp(self) -> None:
backoff.reset()
self.addCleanup(backoff.reset)
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
with tenant_atomic(self.organization.id):
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
self.integration = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="Bot",
secret="0000:not-a-token",
channel=self.channel,
poll_marker="41",
)
def _fail(self, *_args, **_kwargs):
raise PollFailed("HTTP Error 401: Unauthorized")
def test_failure_is_logged_once_and_then_skipped(self) -> None:
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
self.assertEqual(poll(self.integration), ([], "41"))
self.assertEqual(len(logs.output), 1)
self.assertIn("401", logs.output[0])
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
self.assertEqual(poll(self.integration), ([], "41"))
self.assertTrue(backoff.should_skip(self.integration.id))
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
clock = [1000.0]
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
delays = []
for _ in range(10):
poll(self.integration)
state = backoff._failures[self.integration.id]
delays.append(state.delay)
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
self.assertEqual(poll(self.integration), ([], "42"))
self.assertIn("recovered", logs.output[0])
self.assertNotIn(self.integration.id, backoff._failures)
def test_demo_seed_connections_are_not_polled(self) -> None:
with tenant_atomic(self.organization.id):
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
context = TenantContext.for_resource(self.organization)
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
poller.poll_all_messengers(context)
polled.assert_not_called()
@@ -3,7 +3,6 @@ from unittest import mock
from django.test import TestCase, override_settings
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
)
class IngestLimitHandlingTests(TestCase):
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
провайдера). См. ingest.ingest_inbound.
class IngestProviderFailureTests(TestCase):
"""При сбое провайдера диалог не должен «зависать»: его передают оператору с
fallback-ответом клиенту. См. ingest.ingest_inbound.
"""
def setUp(self) -> None:
@@ -63,13 +61,13 @@ class IngestLimitHandlingTests(TestCase):
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
)
def test_limit_exceeded_hands_off_to_operator(self) -> None:
def test_provider_failure_hands_off_to_operator(self) -> None:
from chatballs.conversations.ingest import ingest_inbound
with (
mock.patch(
"chatballs.conversations.ingest.run_channel_turn",
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
side_effect=ProviderError("provider is down"),
),
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
):
@@ -1,6 +1,8 @@
from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -51,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
def poll(integration):
return _POLL[integration.provider](integration)
"""Опрос подключения с паузой после сбоя (transports.backoff).
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
подключение пропускается с растущей паузой, а журнал видит только смену
состояния. Курсор при сбое не двигается.
"""
if backoff.should_skip(integration.id):
return [], integration.poll_marker
try:
result = _POLL[integration.provider](integration)
except PollFailed as error:
backoff.record_failure(integration, error)
return [], integration.poll_marker
backoff.record_success(integration)
return result
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -0,0 +1,76 @@
"""Пауза между попытками опроса подключения после сбоя.
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
подключение пропускается с растущей паузой, а в журнал попадают только
изменения состояния: первый сбой, выход на максимальную паузу и
восстановление.
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
попытка всё равно нужна.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
FIRST_DELAY_SECONDS = 6.0
MAX_DELAY_SECONDS = 900.0
@dataclass
class _Failure:
failures: int
next_attempt_at: float
delay: float
_failures: dict[int, _Failure] = {}
def _now() -> float:
return time.monotonic()
def should_skip(integration_id: int) -> bool:
state = _failures.get(integration_id)
return state is not None and _now() < state.next_attempt_at
def record_failure(integration, error: object) -> None:
previous = _failures.get(integration.id)
failures = (previous.failures if previous else 0) + 1
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
if failures == 1:
logger.warning(
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
integration.provider, integration.id, error, delay,
)
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
logger.warning(
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
)
def record_success(integration) -> None:
state = _failures.pop(integration.id, None)
if state is not None:
logger.info(
"%s poll recovered for integration %s after %s failure(s)",
integration.provider, integration.id, state.failures,
)
def reset() -> None:
"""Для тестов: забыть все сбои."""
_failures.clear()
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
guess_content_type,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
logger = logging.getLogger(__name__)
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
client = _imap_connect(integration)
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
try:
client.select("INBOX", readonly=True)
validity = _status_value(client, "UIDVALIDITY")
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
return messages, new_marker
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
finally:
try:
client.logout()
@@ -0,0 +1,6 @@
class PollFailed(Exception):
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
записать, принимает слой пауз между попытками (``transports.backoff``).
"""
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -242,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
updates = data.get("updates") or []
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
new_marker = data.get("marker")
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
request_json_multipart,
safe_filename,
)
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
from chatballs.integrations.outbound import host_of
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
try:
data = request_json(url, proxy_url=_proxy(integration))
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
return [], integration.poll_marker
raise PollFailed(str(error)) from error
if not data.get("ok"):
return [], integration.poll_marker
updates = data.get("result") or []
@@ -15,6 +15,7 @@ from chatballs.notifications.binding import poll_notifier_bots
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.lookup import iter_organizations
from chatballs.updates.services import check_for_updates
logger = logging.getLogger(__name__)
@@ -92,6 +93,12 @@ class Command(BaseCommand):
logger.exception("Call sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
# Канал релизов спрашивается не чаще раза в несколько часов:
# интервал держит сама проверка по времени последнего ответа.
try:
check_for_updates()
except Exception: # pragma: no cover
logger.exception("Update check cycle failed")
try:
for context in self._tenant_contexts():
with tenant_atomic(context):
+11 -3
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Choose a logo file",
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
"admin.employee_not_found": "Operator not found",
"admin.group_name_taken": "A group with this name already exists",
"admin.group_not_found": "Group not found",
@@ -98,6 +97,8 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "New message · {contact}",
"notifications.operator_needed": "An operator is needed · {contact}",
"notifications.voice_without_transcript": "A voice message without a transcript",
"onboarding.membership_required": "Onboarding is available to organization members",
"onboarding.unknown_action": "Unknown onboarding action",
"portals.article_not_found": "Article not found",
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
"portals.file_not_found": "File not found",
@@ -111,7 +112,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Invalid section",
"portals.not_found": "Portal not found",
"portals.section_not_found": "Section not found",
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
"profile.choose_photo_file": "Choose a photo file",
"profile.link_not_found": "Link not found",
"profile.notification_bot_not_found": "Notification bot not found",
@@ -213,6 +214,7 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_employee_invited": "Operator invited",
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
"audit.action_updates_install_requested": "Installation update started",
"audit.action_identity_employee_groups_changed": "Operator groups changed",
"audit.action_identity_employee_password_reset": "Operator password reset",
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
@@ -246,6 +248,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Integration added",
"audit.action_integrations_integration_deleted": "Integration deleted",
"audit.action_integrations_integration_updated": "Integration changed",
"audit.action_organization_created": "Organization created from the interface",
"audit.action_organization_owner_activated": "Organization owner activated",
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
"audit.action_organization_provisioned": "Organization created",
@@ -331,8 +334,12 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Invalid credentials",
"identity.invalid_totp_code": "Invalid TOTP code",
"identity.invitation_email_mismatch": "Invitation email does not match the account",
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
"identity.invitation_invalid": "Invitation is invalid or has expired",
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
"updates.nothing_to_install": "The latest version is already installed",
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
"updates.install_in_progress": "An update is already in progress",
"identity.role_conflict": "User already has a different role in this organization",
"identity.token_required": "token is required",
"identity.totp_challenge_inactive": "TOTP challenge is not active",
@@ -355,7 +362,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: a record identifier is expected",
"settings.storage_unavailable": "The storage is unavailable: {error}",
"admin.actor_system": "System",
"admin.image_formats": "PNG, JPEG and WebP are supported",
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
"admin.logo_too_large": "The logo must not exceed 2 MB",
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
"calls.failure_code_required": "FAILED needs a normalized failure_code",
+11 -3
View File
@@ -8,7 +8,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Выберите файл логотипа",
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
"admin.employee_not_found": "Сотрудник не найден",
"admin.group_name_taken": "Группа с таким именем уже есть",
"admin.group_not_found": "Группа не найдена",
@@ -102,6 +101,8 @@ MESSAGES: dict[str, object] = {
"notifications.new_message": "Новое сообщение · {contact}",
"notifications.operator_needed": "Нужен оператор · {contact}",
"notifications.voice_without_transcript": "Голосовое без расшифровки",
"onboarding.membership_required": "Онбординг доступен участнику организации",
"onboarding.unknown_action": "Неизвестное действие онбординга",
"portals.article_not_found": "Статья не найдена",
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
"portals.file_not_found": "Файл не найден",
@@ -115,7 +116,7 @@ MESSAGES: dict[str, object] = {
"portals.invalid_section": "Некорректный раздел",
"portals.not_found": "Портал не найден",
"portals.section_not_found": "Раздел не найден",
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
"profile.choose_photo_file": "Выберите файл фото",
"profile.link_not_found": "Привязка не найдена",
"profile.notification_bot_not_found": "Бот уведомлений не найден",
@@ -217,6 +218,7 @@ MESSAGES: dict[str, object] = {
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
"audit.action_updates_install_requested": "Запущено обновление установки",
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
@@ -250,6 +252,7 @@ MESSAGES: dict[str, object] = {
"audit.action_integrations_integration_created": "Добавлена интеграция",
"audit.action_integrations_integration_deleted": "Удалена интеграция",
"audit.action_integrations_integration_updated": "Изменена интеграция",
"audit.action_organization_created": "Создана организация из интерфейса",
"audit.action_organization_owner_activated": "Активирован владелец организации",
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
"audit.action_organization_provisioned": "Создана организация",
@@ -335,8 +338,12 @@ MESSAGES: dict[str, object] = {
"identity.invalid_credentials": "Неверный email или пароль",
"identity.invalid_totp_code": "Неверный код",
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
"identity.invitation_invalid": "Приглашение недействительно или истекло",
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
"updates.nothing_to_install": "Установлена последняя версия",
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
"updates.install_in_progress": "Обновление уже идёт",
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
"identity.token_required": "Нужен токен",
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
@@ -359,7 +366,8 @@ MESSAGES: dict[str, object] = {
"api.expected_record_id": "{name}: ожидается идентификатор записи",
"settings.storage_unavailable": "Хранилище недоступно: {error}",
"admin.actor_system": "Система",
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
+1 -1
View File
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "currency", "timezone"]
list_display = ["slug", "name", "timezone"]
search_fields = ["slug", "name"]
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
return {
"name": organization.name,
"timezone": organization.timezone,
"currency": organization.currency,
# Пустая строка доезжает до интерфейса как есть: там это отдельный
# пункт «Как в установке», а не отсутствие значения.
"language": organization.language,
@@ -11,6 +11,7 @@ from django.db import transaction
from chatballs.i18n import t
from chatballs.i18n.languages import normalize_language
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
@@ -27,17 +28,18 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
class OrganizationSettingsInput:
name: str
timezone: str
currency: str
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
# и владелец, который его не трогал, не должен получить жёсткий русский
# после того, как язык установки сменили.
language: str = ""
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
для страницы создания организации."""
name = data.name.strip()
timezone = data.timezone.strip()
currency = data.currency.strip().upper()
language = normalize_language(data.language)
if data.language.strip() and not language:
raise ValidationError({"language": t("settings.language_unsupported")})
@@ -51,13 +53,7 @@ def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInpu
raise ValidationError(
{"timezone": t("admin.invalid_timezone")}
) from error
if currency != "RUB":
raise ValidationError(
{"currency": t("admin.currency_rub_only")}
)
return OrganizationSettingsInput(
name=name, timezone=timezone, currency=currency, language=language
)
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
@transaction.atomic
@@ -66,15 +62,14 @@ def update_organization_settings(
context: TenantContext,
data: OrganizationSettingsInput,
) -> Organization:
clean = _validate_input(data)
clean = validate_organization_settings(data)
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
)
organization.name = clean.name
organization.timezone = clean.timezone
organization.currency = clean.currency
organization.language = clean.language
organization.save(update_fields=["name", "timezone", "currency", "language"])
organization.save(update_fields=["name", "timezone", "language"])
return organization
@@ -85,6 +80,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
return "image/jpeg", ".jpg"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp", ".webp"
if looks_like_svg(data):
return SVG_CONTENT_TYPE, ".svg"
return None
@@ -102,6 +99,10 @@ def replace_organization_logo(
detected = _image_type(data)
if detected is None:
raise ValidationError({"file": t("admin.image_formats")})
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
# файл отклоняется целиком, а не переписывается молча.
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
content_type, suffix = detected
organization = Organization.objects.select_for_update().get(
pk=context.organization_id
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
data=OrganizationSettingsInput(
name=str(body.get("name", organization.name)),
timezone=str(body.get("timezone", organization.timezone)),
currency=str(body.get("currency", organization.currency)),
language=str(body.get("language", organization.language)),
),
)
@@ -107,11 +106,18 @@ class OrganizationLogoView(APIView):
organization = request.tenant_context.organization
if not organization.logo:
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
return FileResponse(
response = FileResponse(
organization.logo.open("rb"),
content_type=organization.logo_content_type or "application/octet-stream",
filename="organization-logo",
)
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
# браузеру нельзя угадывать тип.
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
response["X-Content-Type-Options"] = "nosniff"
return response
def post(self, request: Request) -> Response:
upload = request.FILES.get("file")
@@ -286,37 +292,100 @@ def _audit_actors(base) -> list[dict[str, object]]:
return actors
class LaunchChecklistView(APIView):
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
class OnboardingView(APIView):
"""Состояние онбординга «Начало работы» для текущего человека.
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
в организации: у каждого человека свои, и один администратор не прячет
визард команде.
"""
permission_classes = [HasCapability]
required_capability = "settings.view"
def get(self, request: Request) -> Response:
from chatballs.channels.models import Channel
from chatballs.identity.models import OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
membership = request.tenant_context.membership
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
}
)
organization_id = request.tenant_context.organization_id
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
connection_bound = Integration.objects.filter(
def post(self, request: Request) -> Response:
"""Закрыть визард или отметить его пройденным.
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
снимает оба признака: визард снова открывается по ссылке и пилюле.
"""
membership = request.tenant_context.membership
if membership is None:
return Response({"detail": t("onboarding.membership_required")}, status=403)
action = str(request.data.get("action") or "dismiss")
now = django_timezone.now()
if action == "dismiss":
membership.onboarding_dismissed_at = now
elif action == "complete":
membership.onboarding_dismissed_at = now
membership.onboarding_completed_at = now
elif action == "restart":
membership.onboarding_dismissed_at = None
membership.onboarding_completed_at = None
else:
return Response({"detail": t("onboarding.unknown_action")}, status=400)
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
return Response(
{
"steps": onboarding_steps(request.tenant_context.organization_id),
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
"completedAt": _isoformat(membership.onboarding_completed_at),
}
)
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
def onboarding_steps(organization_id: int) -> dict[str, bool]:
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
from chatballs.conversations.models import Conversation
from chatballs.identity import instance_settings
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
organization = Organization.objects.filter(pk=organization_id).first()
return {
"providerConnected": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.LLM_PROVIDER,
).exists(),
"agentActive": AIAgent.objects.filter(
organization_id=organization_id,
status=AIAgentStatus.ACTIVE,
).exists(),
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
"connectionBound": Integration.objects.filter(
organization_id=organization_id,
kind=IntegrationKind.MESSENGER,
channel__isnull=False,
).exists()
employee_invited = (
OrganizationMembership.objects.filter(
organization_id=organization_id
).count()
> 1
or request.tenant_context.organization.invitations.exists()
)
return Response(
{
"agentCreated": agent_created,
"connectionBound": connection_bound,
"employeeInvited": employee_invited,
"done": agent_created and connection_bound and employee_invited,
}
)
).exists(),
"widgetPublished": WebChatWidget.objects.filter(
organization_id=organization_id,
status=WebChatWidgetStatus.PUBLISHED,
).exists(),
"employeeInvited": (
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
or (organization is not None and organization.invitations.exists())
),
# Шаг про саму установку, а не про организацию: домен и исходящая почта
# общие для всех организаций на сервере.
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
}
@@ -81,6 +81,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"identity.employee_invited": "audit.action_identity_employee_invited",
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
"updates.install_requested": "audit.action_updates_install_requested",
"identity.employee_updated": "audit.action_identity_employee_updated",
"identity.employee_blocked": "audit.action_identity_employee_blocked",
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
@@ -110,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
"administration.instance_updated": "audit.action_administration_instance_updated",
# --- Организация ---
"organization.provisioned": "audit.action_organization_provisioned",
"organization.created": "audit.action_organization_created",
"organization.owner_activated": "audit.action_organization_owner_activated",
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
# --- Интеграции и каналы ---
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
from chatballs.identity.instance_settings import default_language
from chatballs.identity.models import HumanUser, OrganizationMembership
from chatballs.identity.sessions import list_user_sessions
from chatballs.tenancy.ingress import user_requires_totp
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
class ProfileUpdateView(APIView):
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
context = getattr(request, "tenant_context", None)
if context is not None:
return context.organization.language or ""
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(user=request.user, blocked_at__isnull=True)
.order_by("created_at", "id")
.first()
)
return membership.organization.language if membership is not None else ""
# Членства роли app без контекста не видны: сначала каталог входа, затем
# каждое членство читается в контексте своей организации — как в
# identity.auth.common._user_payload.
oldest: tuple[object, int, str] | None = None
for route in membership_routes_for_user(request.user.id):
with tenant_atomic(route.organization_id):
membership = (
OrganizationMembership.objects.select_related("organization")
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
.first()
)
if membership is None:
continue
key = (membership.created_at, membership.id, membership.organization.language or "")
if oldest is None or key[:2] < oldest[:2]:
oldest = key
return oldest[2] if oldest is not None else ""
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
defaults={
"name": "Demo",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
)
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
@@ -23,9 +23,9 @@ urlpatterns = [
name="organization-logo",
),
path(
"launch-checklist/",
administration_views.LaunchChecklistView.as_view(),
name="launch-checklist",
"onboarding/",
administration_views.OnboardingView.as_view(),
name="onboarding",
),
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
@@ -30,9 +30,6 @@
"how-to-measure",
"lead-times"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"returns-and-exchanges",
"your-account"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"your-account",
"returns-and-exchanges"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -30,9 +30,6 @@
"kak-snyat-merki",
"sroki-dostavki"
],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41
},
{
@@ -53,7 +50,6 @@
"vozvrat-i-obmen",
"lichnyj-kabinet"
],
"limits": {},
"createdDaysAgo": 30,
"policy": {
"allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"lichnyj-kabinet",
"vozvrat-i-obmen"
],
"limits": {},
"createdDaysAgo": 28,
"policy": {
"allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "",
"knowledge": [],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 3
},
{
@@ -113,7 +107,6 @@
"pricing"
],
"portalArticles": [],
"limits": {},
"createdDaysAgo": 180
}
],
@@ -378,7 +371,6 @@
80,
260
],
"costMicrosPerToken": 0.6,
"failuresTotal": 3
}
}
@@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
agent.persona = item.get("persona", "")
agent.tone = item.get("tone", "")
agent.instructions = item.get("instructions", "")
agent.limits = item.get("limits", {})
if agent.status in ("ACTIVE", "DISABLED"):
agent.provider_integration = llm
agent.model = (llm.config or {}).get("default_model", "demo")
@@ -115,7 +114,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
provider=item["provider"],
name=item["name"],
secret=item.get("secret", ""),
config=item.get("config", {}),
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
config={**item.get("config", {}), "demoSeed": True},
),
)
Integration.objects.filter(pk=integration.pk).update(
@@ -255,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=prompt,
completion_tokens=0 if failed else completion,
total_tokens=prompt + (0 if failed else completion),
cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]),
latency_ms=random.randint(900, 4200),
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
error="Provider timeout after 30s" if failed else "",
@@ -273,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=tokens,
completion_tokens=0,
total_tokens=tokens,
cost_micros=int(tokens * 0.02),
latency_ms=random.randint(200, 900),
status=LlmInvocationStatus.SUCCESS,
)
@@ -72,8 +72,12 @@ class InstanceSettings(models.Model):
_CACHE_TTL_SECONDS = 10.0
# Перечитать кэш при промахе по хосту можно не чаще раза в секунду на процесс:
# иначе поток запросов с чужим Host превращался бы в поток запросов к базе.
_MISS_REFRESH_SECONDS = 1.0
_lock = threading.Lock()
_cached: tuple[float, tuple[str, str]] | None = None
_last_miss_refresh = 0.0
def invalidate_cache() -> None:
@@ -112,6 +116,31 @@ def accepted_hosts() -> tuple[str, ...]:
return tuple(host for host in _hosts() if host)
def host_is_accepted(host: str) -> bool:
"""Свой ли это адрес — с перечитыванием кэша при промахе.
Кэш живёт в каждом процессе gunicorn отдельно. Мастер первого запуска или
смена адреса в «Настройках» сбрасывают его только там, где выполнялись;
соседний процесс до 10 секунд отвечал бы «Invalid host» на адрес, который
установка уже считает своим. Поэтому промах — повод перечитать строку, но
не чаще раза в секунду.
"""
global _last_miss_refresh
if not host:
return False
known = {normalize_domain(item) for item in accepted_hosts()}
if host in known:
return True
now = time.monotonic()
with _lock:
if now - _last_miss_refresh < _MISS_REFRESH_SECONDS:
return False
_last_miss_refresh = now
invalidate_cache()
return host in {normalize_domain(item) for item in accepted_hosts()}
def default_language() -> str:
"""Язык установки: экраны до входа и умолчание для организаций.
@@ -10,6 +10,7 @@ from django.urls import path
from chatballs.identity import instance_views
from chatballs.tenancy import storage_views
from chatballs.updates import views as update_views
urlpatterns = [
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
@@ -25,4 +26,8 @@ urlpatterns = [
storage_views.StorageMigrateView.as_view(),
name="instance-storage-migrate",
),
# Обновления установки: состояние, проверка канала, установка по кнопке.
path("update/", update_views.UpdateStateView.as_view(), name="instance-update"),
path("update/check/", update_views.UpdateCheckView.as_view(), name="instance-update-check"),
path("update/install/", update_views.UpdateInstallView.as_view(), name="instance-update-install"),
]
@@ -25,6 +25,7 @@ from chatballs.identity.models import (
)
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
@@ -204,14 +205,37 @@ def register_and_accept(*, token: str, full_name: str, password: str) -> Accepte
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
return _invitation_for_token(token, accepted=False)
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
"""Приглашение по токену из письма — без tenant-контекста на входе.
Ссылка /join приходит до входа в организацию, а таблица приглашений и
строка организации роли app без контекста не видны (tenancy/0003, 0033).
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
и приглашение читается уже в её контексте — вместе с организацией, чтобы
вызывающий код мог обращаться к ней и после выхода из контекста.
"""
if not token:
return None
return OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=True,
token_hash = _token_hash(token)
route = invitation_route(token_hash)
if route is None:
return None
query = OrganizationInvitation.objects.select_related("organization").filter(
id=route.resource_id,
organization_id=route.organization_id,
token_hash=token_hash,
revoked_at__isnull=True,
expires_at__gt=timezone.now(),
).first()
)
if accepted:
query = query.filter(accepted_at__isnull=False)
else:
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
with tenant_atomic(route.organization_id):
return query.first()
@transaction.atomic
@@ -314,16 +338,13 @@ def _already_accepted_for(
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
invitation = OrganizationInvitation.objects.filter(
token_hash=_token_hash(token),
accepted_at__isnull=False,
revoked_at__isnull=True,
).first()
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
with tenant_atomic(invitation.organization_id):
membership = OrganizationMembership.objects.filter(
user=user, organization=invitation.organization
).first()
if membership is None:
return None
return AcceptedInvitation(
@@ -0,0 +1,72 @@
"""Проверка SVG-логотипа организации.
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
хуже, чем попросить другой файл.
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
SVG_CONTENT_TYPE = "image/svg+xml"
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
def looks_like_svg(data: bytes) -> bool:
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
if head.startswith(b"<svg"):
return True
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
def _local(name: str) -> str:
return name.rsplit("}", 1)[-1].lower()
def _dangerous_value(value: str) -> bool:
compact = re.sub(r"\s+", "", value).lower()
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
def svg_is_safe(data: bytes) -> bool:
if _DECLARATION.search(data):
return False
try:
root = ET.fromstring(data)
except ET.ParseError:
return False
if _local(root.tag) != "svg":
return False
for element in root.iter():
tag = _local(element.tag) if isinstance(element.tag, str) else ""
if tag in _FORBIDDEN_TAGS:
return False
for name, value in element.attrib.items():
local = _local(name)
if local.startswith("on"):
return False
if _dangerous_value(value):
return False
if name in _HREF_ATTRIBUTES or local == "href":
stripped = value.strip()
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
return False
if local == "style" and _EXTERNAL_URL.search(value):
return False
if tag == "style" and element.text and (
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
):
return False
return True
@@ -0,0 +1,25 @@
# Онбординг «Начало работы» показывается всем, кто его ещё не закрыл, включая
# тех, кто работает в установке давно. Признак закрытия — на членстве человека
# в организации: у каждого он свой, и закрытие одним администратором не прячет
# визард у остальных. NULL по умолчанию, поэтому существующие записи считаются
# «не закрывал» и увидят визард при следующем входе.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("identity", "0037_invitation_membership_fields"),
]
operations = [
migrations.AddField(
model_name="organizationmembership",
name="onboarding_dismissed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="organizationmembership",
name="onboarding_completed_at",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Валюта организации удалена: поле принимало только RUB, не читалось нигде и
# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило
# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже
# снят вместе с лимитами.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("identity", "0038_membership_onboarding_dismissed_at"),
]
operations = [
migrations.RemoveField(
model_name="organization",
name="currency",
),
]
+9 -2
View File
@@ -75,7 +75,7 @@ class HumanUser(AbstractUser):
# без этой отметки принимался бы второй раз ещё полторы минуты.
totp_last_counter = models.BigIntegerField(default=0)
# Внешний вид — глобальная настройка пользователя (не membership):
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
# тема и акцентный HEX-цвет; пустой акцент — цвет продукта #0f9b8e.
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
ui_accent = models.CharField(max_length=9, blank=True)
# Язык интерфейса — тоже глобальная настройка пользователя, а не
@@ -125,7 +125,6 @@ class Organization(models.Model):
default=OrganizationStatus.ACTIVE,
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они
@@ -184,6 +183,14 @@ class OrganizationMembership(models.Model):
phone = models.CharField(max_length=32, blank=True)
totp_required = models.BooleanField(default=False)
blocked_at = models.DateTimeField(null=True, blank=True)
# Онбординг закрыт этим человеком в этой организации. Признак живёт на
# членстве, а не на организации: иначе первый же закрывший спрятал бы
# визард всей команде. NULL — не закрывал, значит увидит при следующем
# входе, включая тех, кто работает в системе давно.
onboarding_dismissed_at = models.DateTimeField(null=True, blank=True)
# Визард пройден до конца. Отдельно от «закрыл»: закрыть можно на первом
# шаге, и тогда возвращаться к настройке ещё есть зачем.
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -0,0 +1,142 @@
"""Создание организации человеком из интерфейса.
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
на страницу с полями организации; тот, кто её заполнил, становится владельцем
новой организации и сразу в неё переключается. Это второй путь появления
организации рядом с платформенным провижинингом (platform.provisioning_service):
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
заводит её себе.
Кто может: администратор установки и любой, у кого есть роль владельца или
администратора хотя бы в одной организации. Сотрудник, работающий только в
чате, чужую установку организациями не засевает.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.db import transaction
from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.events.services import DomainEvent, enqueue_event
from chatballs.i18n import t
from chatballs.i18n.audience import customer_language
from chatballs.identity.administration_services import (
OrganizationSettingsInput,
validate_organization_settings,
)
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
OrganizationStatus,
)
from chatballs.tenancy.context import TenantActorKind, TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import membership_routes_for_user
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
@dataclass(frozen=True, slots=True)
class CreatedOrganization:
organization: Organization
membership: OrganizationMembership
def can_create_organization(user: HumanUser) -> bool:
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
if not user.is_active:
return False
if user.is_instance_admin:
return True
for route in membership_routes_for_user(user.id):
with tenant_atomic(route.organization_id):
role = (
OrganizationMembership.objects.filter(
id=route.resource_id, user=user, blocked_at__isnull=True
)
.values_list("role", flat=True)
.first()
)
if role in MANAGER_ROLES:
return True
return False
def unique_organization_slug(name: str) -> str:
"""Слаг из имени, уникальный среди организаций установки.
Проверка идёт через каталог организаций: роль app без контекста строк
организаций не видит (tenancy/0033).
"""
base = slugify(name)[:40].strip("-") or "organization"
candidate = base
suffix = 2
while organization_route_by_slug(candidate) is not None:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def create_organization(
*, data: OrganizationSettingsInput, owner: HumanUser
) -> CreatedOrganization:
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
Порядок тот же, что у мастера первого запуска (identity.setup): id
выделяется заранее, строка вставляется уже в контексте этого id — иначе
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
"""
clean = validate_organization_settings(data)
with transaction.atomic():
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
organization = Organization(
id=organization_id,
name=clean.name,
slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE,
timezone=clean.timezone,
language=clean.language,
)
organization.save(force_insert=True)
ensure_uncategorized_category(organization)
membership = OrganizationMembership.objects.create(
user=owner,
organization=organization,
role=EmployeeRole.OWNER,
# Должность — текстом на языке организации, как в провижининге.
position_title=t("setup.owner_position", language=customer_language(organization)),
totp_required=False,
)
record_audit_event(
action="organization.created",
actor=owner,
organization=organization,
object_type="Organization",
object_id=str(organization.public_id),
payload={"organizationName": organization.name},
)
enqueue_event(
DomainEvent(
aggregate_type="Organization",
aggregate_id=str(organization.public_id),
event_type="organization.provisioned",
payload={},
tenant_context=TenantContext.for_resource(
organization,
actor_kind=TenantActorKind.SYSTEM,
actor_user=owner,
),
)
)
return CreatedOrganization(organization=organization, membership=membership)
@@ -0,0 +1,12 @@
from django.urls import path
from chatballs.identity import organization_views
urlpatterns = [
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
path(
"options/",
organization_views.OrganizationCreateOptionsView.as_view(),
name="organization-create-options",
),
]
@@ -0,0 +1,75 @@
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
(auth.invitations): обновлённая учётная запись со списком членств и публичный
id организации, в которую интерфейсу переключиться.
"""
from __future__ import annotations
from django.core.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.i18n import t
from chatballs.identity.administration_payloads import (
administration_languages,
administration_timezones,
)
from chatballs.identity.administration_services import OrganizationSettingsInput
from chatballs.identity.auth.common import _user_payload, validation_response
from chatballs.identity.organization_creation import (
can_create_organization,
create_organization,
)
def _forbidden() -> Response:
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
class OrganizationCreateOptionsView(APIView):
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
return Response(
{
"timezones": administration_timezones(),
"languages": administration_languages(),
"currencies": ["RUB"],
}
)
class OrganizationCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
if not can_create_organization(request.user):
return _forbidden()
body = request.data if isinstance(request.data, dict) else {}
try:
created = create_organization(
data=OrganizationSettingsInput(
name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
language=str(body.get("language", "")),
),
owner=request.user,
)
except ValidationError as error:
return validation_response(error)
return Response(
{
"user": _user_payload(request.user),
"organizationPublicId": str(created.organization.public_id),
},
status=201,
)
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
name="Example",
slug="administration",
timezone="Europe/Moscow",
currency="RUB",
)
self.owner = HumanUser.objects.create_user(
email="owner@administration.test",
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Example",
"timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала.
"language": "",
"logoUrl": None,
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
{
"name": "Новая компания",
"timezone": "Asia/Yekaterinburg",
"currency": "rub",
},
format="json",
)
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
self.organization.refresh_from_db()
self.assertEqual(self.organization.name, "Новая компания")
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
self.assertEqual(self.organization.currency, "RUB")
def test_invalid_timezone_is_rejected(self) -> None:
response = self.client.patch(
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("timezone", response.json()["errors"])
def test_only_ruble_currency_is_accepted(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/",
{"currency": "USD"},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("российский рубль", response.json()["errors"]["currency"])
def test_logo_upload_download_and_delete(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(
@@ -172,16 +158,16 @@ class AdministrationApiTests(TestCase):
"/api/v1/company/administration/logo/",
{
"file": SimpleUploadedFile(
"logo.svg",
b"<svg></svg>",
content_type="image/svg+xml",
"logo.txt",
b"not an image at all",
content_type="text/plain",
)
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
record_audit_event(
@@ -12,9 +12,12 @@
from __future__ import annotations
import time
from django.core.exceptions import ValidationError
from django.test import TestCase
from chatballs.identity import instance_settings
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.instance_settings import (
InstanceSettings,
@@ -201,3 +204,51 @@ class InstanceSettingsAccessTests(TestCase):
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
)
self.assertEqual(response.status_code, 404)
class StaleHostCacheTests(TestCase):
"""Адрес, записанный мастером в одном процессе, принимает и соседний.
Кэш адреса живёт в каждом процессе gunicorn по 10 секунд. Соседний процесс
с устаревшим кэшем отвечал «Invalid host» на первый же запрос после
мастера — в интерфейсе это «Ошибка загрузки», исчезавшая после обновления
страницы. Промах по хосту теперь перечитывает кэш.
"""
def setUp(self) -> None:
self.result = bootstrap_owner(email="cache-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
# Строка настроек должна существовать: update() ниже её не создаёт.
InstanceSettings.load()
self.addCleanup(invalidate_cache)
def _stale_cache_with_no_host(self) -> None:
# Соседний процесс: только что прочитал пустой адрес, TTL ещё не вышел.
instance_settings._cached = (time.monotonic(), ("", ""))
instance_settings._last_miss_refresh = 0.0
def test_host_written_by_another_process_is_accepted_at_once(self) -> None:
self._stale_cache_with_no_host()
# Запись мимо save(): invalidate_cache() в этом процессе не вызывается,
# как и в реальности, где мастер отработал в другом воркере.
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="crm.example.test")
self.assertEqual(response.status_code, 200, response.content)
def test_unknown_host_does_not_reread_more_than_once_a_second(self) -> None:
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
public_host="crm.example.test"
)
self._stale_cache_with_no_host()
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
# Первый промах перечитал кэш и уже знает настоящий адрес.
self.assertEqual(set(accepted_hosts()), {"crm.example.test"})
# Второй промах в ту же секунду базу не трогает: кэш подменён, но не перечитан.
instance_settings._cached = (time.monotonic(), ("", ""))
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
self.assertEqual(accepted_hosts(), ())
@@ -0,0 +1,77 @@
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
заголовки как второй рубеж.
"""
from __future__ import annotations
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.logo_svg import svg_is_safe
from chatballs.testing import TenantAPIClient
CLEAN_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64">
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
<style>.mark { fill: url(#g); }</style>
<circle class="mark" cx="32" cy="32" r="30"/>
<use xlink:href="#mark"/>
</svg>
"""
class SvgSafetyTests(TestCase):
def test_clean_svg_is_accepted(self) -> None:
self.assertTrue(svg_is_safe(CLEAN_SVG))
def test_dangerous_svg_is_rejected(self) -> None:
samples = {
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
"not svg": b'<html><body>hi</body></html>',
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
}
for name, sample in samples.items():
with self.subTest(name):
self.assertFalse(svg_is_safe(sample))
class SvgLogoApiTests(TestCase):
def setUp(self) -> None:
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def _upload(self, data: bytes):
return self.client.post(
"/api/v1/company/administration/logo/",
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
format="multipart",
)
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
uploaded = self._upload(CLEAN_SVG)
self.assertEqual(uploaded.status_code, 200, uploaded.content)
downloaded = self.client.get("/api/v1/company/administration/logo/")
self.assertEqual(downloaded.status_code, 200)
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
self.assertEqual(response.status_code, 400)
self.assertIn("file", response.json()["errors"])
@@ -0,0 +1,122 @@
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
from __future__ import annotations
import json
from django.test import TestCase, override_settings
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient
URL = "/api/v1/organizations/"
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
class OrganizationCreationTests(TestCase):
def setUp(self) -> None:
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
self.owner = HumanUser.objects.create_user(
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.owner,
role=EmployeeRole.OWNER,
position_title="Владелец",
)
self.employee = HumanUser.objects.create_user(
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
)
OrganizationMembership.objects.create(
organization=self.first,
user=self.employee,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
def _post(self, user: HumanUser, **body):
client = TenantAPIClient()
client.force_login(user)
return client.post(
URL,
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
content_type="application/json",
)
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
response = self._post(self.owner, language="en")
self.assertEqual(response.status_code, 201, response.content)
payload = response.json()
created = Organization.objects.get(public_id=payload["organizationPublicId"])
self.assertEqual(created.name, "Вторая компания")
self.assertEqual(created.language, "en")
self.assertEqual(created.status, "ACTIVE")
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
# Список членств в ответе уже содержит новую организацию: интерфейсу
# есть куда переключиться без повторного запроса сессии.
self.assertEqual(
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
{str(self.first.public_id), str(created.public_id)},
)
self.assertTrue(
AuditEvent.objects.filter(
organization=created, action="organization.created", actor=self.owner
).exists()
)
def test_same_name_gets_a_distinct_slug(self) -> None:
first = self._post(self.owner).json()["organizationPublicId"]
second = self._post(self.owner).json()["organizationPublicId"]
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
self.assertEqual(len(slugs), 2)
def test_employee_cannot_create_organizations(self) -> None:
response = self._post(self.employee)
self.assertEqual(response.status_code, 403)
self.assertEqual(Organization.objects.count(), 1)
def test_instance_admin_without_memberships_can_create(self) -> None:
admin = HumanUser.objects.create_user(
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
)
response = self._post(admin)
self.assertEqual(response.status_code, 201, response.content)
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
def test_empty_name_is_a_field_error(self) -> None:
response = self._post(self.owner, name=" ")
self.assertEqual(response.status_code, 400)
self.assertIn("name", response.json()["errors"])
self.assertEqual(Organization.objects.count(), 1)
def test_anonymous_is_rejected(self) -> None:
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
self.assertIn(response.status_code, {401, 403})
def test_options_list_timezones_and_languages(self) -> None:
client = TenantAPIClient()
client.force_login(self.owner)
response = client.get(f"{URL}options/")
self.assertEqual(response.status_code, 200)
self.assertIn("Europe/Moscow", response.json()["timezones"])
self.assertTrue(response.json()["languages"])
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
# Настройки установки (адрес, по которому её открывают) — тоже одна
# строка на инстанс: их пишет мастер первого запуска, а не демо.
("identity", "instancesettings"),
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
("updates", "updatestate"),
}
COVERAGE_EXEMPT_APPS = {"platform", "events"}
@@ -12,6 +12,7 @@ import secrets
from datetime import timedelta
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from chatballs.conversations import transports
@@ -39,7 +40,11 @@ def notifier_integrations(context):
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
config__purpose=NOTIFIER_PURPOSE,
).exclude(secret="")
return qs.filter(organization=context.organization)
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
return qs.filter(organization=context.organization).filter(
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
)
def deep_link(integration: Integration, code: str) -> str:
@@ -24,7 +24,6 @@ def provisioning_result_payload(
"slug": organization.slug,
"status": organization.status,
"timezone": organization.timezone,
"currency": organization.currency,
},
"provisioning": {
"status": provisioning.status,
@@ -21,7 +21,6 @@ class ProvisioningCommand:
source: str
idempotency_key: str
timezone: str = "Europe/Moscow"
currency: str = "RUB"
locale: str = ""
legal_name: str = ""
tax_profile: dict[str, Any] | None = None
@@ -158,7 +158,6 @@ def _create_organization(
slug=command.organization_slug.strip(),
status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER,
timezone=command.timezone,
currency=command.currency.upper(),
)
except ValidationError as error:
raise ProvisioningValidation(str(error), code="organization_invalid") from error
@@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase):
"slug": "acme",
"owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-1",
@@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase):
"slug": "acme",
"owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow",
"currency": "RUB",
},
format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-2",
@@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase):
"slug": slug,
"owner_email": owner_email,
"timezone": "Europe/Moscow",
"currency": "RUB",
},
},
format="json",
HTTP_IDEMPOTENCY_KEY=key,
)
@@ -10,7 +10,6 @@ _REQUIRED_FIELDS = (
"slug",
"owner_email",
"timezone",
"currency",
)
@@ -32,7 +31,6 @@ def parse_provisioning_body(
source=source,
idempotency_key=idempotency_key,
timezone=str(body.get("timezone", "Europe/Moscow")),
currency=str(body.get("currency", "RUB")),
locale=str(body.get("locale", "")),
legal_name=str(body.get("legal_name", "")),
tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None,
@@ -41,11 +41,13 @@ class SupportPortalHostBoundaryMiddleware:
return True
# Адреса, которые человек задал сам: тот, на котором прошли мастер, и
# предыдущий — чтобы смена адреса в «Настройках» не выбрасывала того,
# кто её делает, до того как новый домен вообще заработал.
from chatballs.identity.instance_settings import accepted_hosts
# кто её делает, до того как новый домен вообще заработал. Промах
# перечитывает кэш: соседний процесс gunicorn мог ещё не увидеть адрес,
# который мастер записал секунду назад.
from chatballs.identity.instance_settings import host_is_accepted
try:
if host and host in {normalize_domain(item) for item in accepted_hosts()}:
if host_is_accepted(host):
return True
except Exception:
pass
+10
View File
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
return _unique_route("call_invite_directory", token_hash)
def invitation_route(token_hash: str) -> IngressRoute | None:
"""Приглашение в организацию по хэшу токена из письма (/join).
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
"""
return _unique_route("invitation_directory", token_hash)
def call_session_route(call_session_id: str) -> IngressRoute | None:
return _unique_route("call_session_directory", call_session_id)
@@ -0,0 +1,26 @@
# Состояние обновлений установки (updates_updatestate): одна строка на
# инсталляцию, не тенантная таблица, без RLS — как настройки установки (0028).
# Пишут её приложение (запрос установки, синхронизация статуса) и воркер
# (периодическая проверка канала релизов) — оба ролью app.
from django.db import migrations
GRANTS = """
GRANT SELECT, INSERT, UPDATE ON updates_updatestate
TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE updates_updatestate_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVOKE = """
REVOKE ALL ON updates_updatestate
FROM chatballs_runtime_app, chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0033_organization_select_scope"),
("updates", "0001_initial"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
@@ -0,0 +1,55 @@
# Две дыры мультиорганизационности, обе на роли app.
#
# 1. Организации создаёт человек из интерфейса (кнопка «Добавить организацию»
# в переключателе, дизайн-базлайн v2, A1), а не только оператор платформы.
# Политика bootstrap из 0032 пускала INSERT роли app лишь до первой
# организации. Теперь строка вставляется в контексте своего же id: сервис
# заранее берёт id из последовательности (tenancy.lookup.reserve_organization_id),
# открывает tenant_atomic(id) и уже в нём пишет строку — ровно так, как
# это делал мастер первого запуска. Без контекста INSERT по-прежнему закрыт.
#
# 2. Ссылка-приглашение /join открывается без tenant-контекста: токен из
# письма — единственное, что есть. Таблица приглашений под RLS, и роль app
# без контекста не находила приглашение вовсе. Security-barrier каталог
# invitation_directory отдаёт по хэшу токена id организации и приглашения —
# по образцу call_invite_directory (0004).
from django.db import migrations
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
CREATE_POLICY = "chatballs_organization_app_create"
FORWARD_SQL = f"""
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
CREATE POLICY {CREATE_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (id = chatballs.current_organization_id());
CREATE OR REPLACE VIEW chatballs.invitation_directory
WITH (security_barrier = true) AS
SELECT invitation.id AS resource_id,
invitation.organization_id,
invitation.token_hash AS lookup_key
FROM identity_organizationinvitation invitation;
ALTER VIEW chatballs.invitation_directory OWNER TO chatballs_schema;
REVOKE ALL ON chatballs.invitation_directory FROM PUBLIC;
GRANT SELECT ON chatballs.invitation_directory
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVERSE_SQL = f"""
DROP VIEW IF EXISTS chatballs.invitation_directory;
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
FOR INSERT TO chatballs_runtime_app
WITH CHECK (NOT chatballs.instance_has_organizations());
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0034_update_state_grants"),
]
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
+48 -3
View File
@@ -1,11 +1,20 @@
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import DatabaseError, connection, transaction
from django.test import TransactionTestCase
from django.utils import timezone
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.channels.models import Channel
from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.invitation_models import OrganizationInvitation
from chatballs.identity.invitation_service import (
invitation_preview,
issue_invitation,
pending_invitation_for_token,
)
from chatballs.identity.models import (
AuditEvent,
AuditResult,
@@ -14,7 +23,8 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
from chatballs.tenancy.database import current_tenant_id, set_local_tenant, tenant_atomic
from chatballs.tenancy.lookup import reserve_organization_id
from chatballs.tenancy.models import StorageReservation
from chatballs.testing import TenantAPIClient
@@ -429,7 +439,7 @@ class RowLevelSecurityTests(TransactionTestCase):
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None:
def test_app_role_reads_ingress_directory_and_adds_organizations_only_in_context(self) -> None:
# Каталоги входа доступны роли app (tenancy/0032): backend-app
# обходится без platform-соединения.
with transaction.atomic():
@@ -441,10 +451,45 @@ class RowLevelSecurityTests(TransactionTestCase):
[self.user.id],
)
self.assertEqual(cursor.fetchone()[0], self.first.id)
# Организации уже есть — INSERT для app закрыт политикой bootstrap.
# Без контекста INSERT организации для app закрыт (tenancy/0035).
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
Organization.objects.create(name="Third", slug="rls-third")
# В контексте заранее выделенного id — открыт: так работает кнопка
# «Добавить организацию» (identity.organization_creation).
with transaction.atomic():
self._set_role("chatballs_runtime_app")
organization_id = reserve_organization_id()
with tenant_atomic(organization_id):
Organization(id=organization_id, name="Third", slug="rls-third").save(force_insert=True)
self.assertEqual(Organization.objects.get(pk=organization_id).slug, "rls-third")
self.assertTrue(Organization.objects.filter(slug="rls-third").exists())
# Чужой контекст не подходит: id строки обязан совпасть с контекстом.
with self.assertRaises(DatabaseError), transaction.atomic():
self._set_role("chatballs_runtime_app")
with tenant_atomic(self.first.id):
Organization(id=reserve_organization_id(), name="Fourth", slug="rls-fourth").save(force_insert=True)
def test_app_role_finds_invitation_by_token_without_context(self) -> None:
# Ссылка /join открывается без контекста: приглашение находит каталог
# invitation_directory (tenancy/0035), иначе роль app видела бы пустоту.
issued = issue_invitation(
organization=self.second,
email="invited@example.test",
role=EmployeeRole.EMPLOYEE,
expires_at=timezone.now() + timedelta(days=1),
created_by=None,
)
with transaction.atomic():
self._set_role("chatballs_runtime_app")
self.assertEqual(OrganizationInvitation.objects.count(), 0)
invitation = pending_invitation_for_token(issued.token)
self.assertIsNotNone(invitation)
self.assertEqual(invitation.id, issued.invitation.id)
self.assertEqual(invitation.organization.slug, "rls-second")
self.assertIsNone(pending_invitation_for_token("wrong-token"))
preview = invitation_preview(issued.token)
self.assertEqual(preview["organizationName"], "Second")
def test_platform_role_can_only_use_ingress_directory(self) -> None:
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UpdatesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chatballs.updates"
verbose_name = "Обновления установки"
+106
View File
@@ -0,0 +1,106 @@
"""Канал релизов: страница релизов GitHub.
Установка спрашивает у GitHub последний релиз репозитория продукта и берёт
из него версию, заметки и ссылку на compose.yaml. Никаких токенов: релизы
публичные, лимита анонимных запросов хватает при проверке раз в несколько
часов.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from django.conf import settings
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?$")
_TIMEOUT_SECONDS = 15
@dataclass(frozen=True, slots=True)
class Release:
version: str
name: str
notes: str
published_at: datetime | None
compose_url: str
page_url: str
class ReleaseChannelError(Exception):
"""Канал релизов недоступен или ответил не тем, что ожидалось."""
def parse_version(tag: str) -> str | None:
version = tag.strip()
if version.startswith(("v", "V")):
version = version[1:]
return version if VERSION_RE.match(version) else None
def version_key(version: str) -> tuple:
"""Ключ сравнения: числовая тройка, затем метка (без метки — новее)."""
core, _, label = version.partition("-")
core = core.split(".")[:3]
numbers = tuple(int(part) for part in core if part.isdigit())
return (numbers, label == "", label)
def is_newer(candidate: str, current: str) -> bool:
if not VERSION_RE.match(candidate) or not VERSION_RE.match(current or ""):
# Dev-сборка без версии: обновлять нечего и не с чем сравнивать.
return False
return version_key(candidate) > version_key(current)
def expected_compose_url(version: str) -> str:
return f"https://github.com/{settings.CHATBALLS_UPDATE_REPO}/releases/download/v{version}/compose.yaml"
def fetch_latest_release() -> Release:
url = f"https://api.github.com/repos/{settings.CHATBALLS_UPDATE_REPO}/releases/latest"
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": f"chatballs/{settings.CHATBALLS_VERSION}",
},
)
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as error:
raise ReleaseChannelError(str(error)) from error
version = parse_version(str(payload.get("tag_name", "")))
if version is None:
raise ReleaseChannelError(f"unexpected tag {payload.get('tag_name')!r}")
compose_url = next(
(
str(asset.get("browser_download_url", ""))
for asset in payload.get("assets", [])
if asset.get("name") == "compose.yaml"
),
"",
)
if compose_url != expected_compose_url(version):
raise ReleaseChannelError("release has no compose.yaml at the expected address")
published = None
raw_published = str(payload.get("published_at", "")).strip()
if raw_published:
try:
published = datetime.fromisoformat(raw_published.replace("Z", "+00:00"))
except ValueError:
published = None
return Release(
version=version,
name=str(payload.get("name") or f"v{version}"),
notes=str(payload.get("body") or ""),
published_at=published,
compose_url=compose_url,
page_url=str(payload.get("html_url") or ""),
)
@@ -0,0 +1,57 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UpdateState",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("latest_version", models.CharField(blank=True, default="", max_length=32)),
("latest_name", models.CharField(blank=True, default="", max_length=128)),
("latest_notes", models.TextField(blank=True, default="")),
("latest_published_at", models.DateTimeField(blank=True, null=True)),
("latest_compose_url", models.URLField(blank=True, default="", max_length=512)),
("latest_page_url", models.URLField(blank=True, default="", max_length=512)),
("checked_at", models.DateTimeField(blank=True, null=True)),
("check_error", models.CharField(blank=True, default="", max_length=500)),
("install_version", models.CharField(blank=True, default="", max_length=32)),
(
"install_status",
models.CharField(
choices=[
("IDLE", "Не запускалась"),
("REQUESTED", "Запрошена"),
("RUNNING", "Идёт"),
("DONE", "Завершена"),
("FAILED", "Ошибка"),
],
default="IDLE",
max_length=12,
),
),
("install_message", models.CharField(blank=True, default="", max_length=1000)),
("install_requested_at", models.DateTimeField(blank=True, null=True)),
("install_updated_at", models.DateTimeField(blank=True, null=True)),
(
"install_requested_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"verbose_name": "Обновления установки"},
),
]
Whitespace-only changes.
+60
View File
@@ -0,0 +1,60 @@
"""Состояние обновлений установки: одна строка на инсталляцию.
Не тенантная таблица: версия у установки одна на всех. Проверка релизов
живёт в воркере, установку запрашивает администратор установки, а выполняет
сервис updater вне приложения (ADR-CHATBALLS-0049).
"""
from __future__ import annotations
from django.db import models
class InstallStatus(models.TextChoices):
IDLE = "IDLE", "Не запускалась"
REQUESTED = "REQUESTED", "Запрошена"
RUNNING = "RUNNING", "Идёт"
DONE = "DONE", "Завершена"
FAILED = "FAILED", "Ошибка"
class UpdateState(models.Model):
SINGLETON_PK = 1
# Что известно о последнем релизе на канале.
latest_version = models.CharField(max_length=32, blank=True, default="")
latest_name = models.CharField(max_length=128, blank=True, default="")
latest_notes = models.TextField(blank=True, default="")
latest_published_at = models.DateTimeField(null=True, blank=True)
latest_compose_url = models.URLField(max_length=512, blank=True, default="")
latest_page_url = models.URLField(max_length=512, blank=True, default="")
checked_at = models.DateTimeField(null=True, blank=True)
check_error = models.CharField(max_length=500, blank=True, default="")
# Последняя запрошенная установка.
install_version = models.CharField(max_length=32, blank=True, default="")
install_status = models.CharField(
max_length=12, choices=InstallStatus.choices, default=InstallStatus.IDLE
)
install_message = models.CharField(max_length=1000, blank=True, default="")
install_requested_at = models.DateTimeField(null=True, blank=True)
install_updated_at = models.DateTimeField(null=True, blank=True)
# Кто нажал кнопку — для аудита и подписи в интерфейсе.
install_requested_by = models.ForeignKey(
"identity.HumanUser", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
)
class Meta:
verbose_name = "Обновления установки"
def __str__(self) -> str:
return f"updates:{self.latest_version or 'нет данных'}"
def save(self, *args, **kwargs):
self.pk = self.SINGLETON_PK
super().save(*args, **kwargs)
@classmethod
def load(cls) -> UpdateState:
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
return obj
+179
View File
@@ -0,0 +1,179 @@
"""Проверка релизов и запрос установки обновления.
Приложение не перезапускает само себя: у публичного процесса нет и не должно
быть доступа к Docker. Оно лишь кладёт запрос в общий том
``CHATBALLS_UPDATES_DIR``, а сервис updater (deploy/updater) забирает его,
проверяет и выполняет ``docker compose pull && up`` уже снаружи. Состояние
установки updater пишет в тот же том файлом ``status``; приложение
переносит его в UpdateState при каждом чтении.
"""
from __future__ import annotations
import json
import logging
from datetime import timedelta
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from chatballs.updates.github import (
ReleaseChannelError,
fetch_latest_release,
is_newer,
)
from chatballs.updates.models import InstallStatus, UpdateState
logger = logging.getLogger(__name__)
REQUEST_FILE = "request.json"
STATUS_FILE = "status.json"
HEARTBEAT_FILE = "heartbeat"
# Пока updater не отметился дольше этого, считаем, что установки из интерфейса нет.
HEARTBEAT_STALE_SECONDS = 90
_STATUS_MAP = {
"running": InstallStatus.RUNNING,
"done": InstallStatus.DONE,
"failed": InstallStatus.FAILED,
}
def updates_dir() -> Path:
return Path(settings.CHATBALLS_UPDATES_DIR)
def check_for_updates(*, force: bool = False) -> UpdateState:
"""Спросить канал релизов, если пора (или если попросили явно)."""
state = UpdateState.load()
interval = timedelta(seconds=settings.CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS)
if not force and state.checked_at and timezone.now() - state.checked_at < interval:
return state
try:
release = fetch_latest_release()
except ReleaseChannelError as error:
state.check_error = str(error)[:500]
state.checked_at = timezone.now()
state.save(update_fields=["check_error", "checked_at"])
logger.warning("Update check failed: %s", error)
return state
state.latest_version = release.version
state.latest_name = release.name
state.latest_notes = release.notes
state.latest_published_at = release.published_at
state.latest_compose_url = release.compose_url
state.latest_page_url = release.page_url
state.check_error = ""
state.checked_at = timezone.now()
state.save()
return state
def update_available(state: UpdateState) -> bool:
return bool(state.latest_version) and is_newer(state.latest_version, settings.CHATBALLS_VERSION)
def updater_online() -> bool:
"""Жив ли сервис updater: он отмечается в томе каждые несколько секунд."""
path = updates_dir() / HEARTBEAT_FILE
try:
age = timezone.now().timestamp() - path.stat().st_mtime
except OSError:
return False
return age < HEARTBEAT_STALE_SECONDS
def sync_install_status(state: UpdateState) -> UpdateState:
"""Перенести статус, который пишет updater, в состояние приложения."""
if state.install_status not in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
return state
path = updates_dir() / STATUS_FILE
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return state
if str(payload.get("version", "")) != state.install_version:
return state
status = _STATUS_MAP.get(str(payload.get("status", "")).lower())
if status is None:
return state
message = str(payload.get("message", ""))[:1000]
if status != state.install_status or message != state.install_message:
state.install_status = status
state.install_message = message
state.install_updated_at = timezone.now()
state.save(update_fields=["install_status", "install_message", "install_updated_at"])
return state
class InstallNotPossible(Exception):
def __init__(self, message: str, *, code: str) -> None:
super().__init__(message)
self.code = code
def request_install(*, actor, state: UpdateState | None = None) -> UpdateState:
"""Положить запрос на установку последней версии в том updater'а."""
from chatballs.i18n import t
state = sync_install_status(state or UpdateState.load())
if not update_available(state):
raise InstallNotPossible(t("updates.nothing_to_install"), code="nothing_to_install")
if not updater_online():
raise InstallNotPossible(t("updates.updater_offline"), code="updater_offline")
if state.install_status in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
raise InstallNotPossible(t("updates.install_in_progress"), code="install_in_progress")
directory = updates_dir()
directory.mkdir(parents=True, exist_ok=True)
request_path = directory / REQUEST_FILE
now = timezone.now()
request_path.write_text(
json.dumps(
{
"version": state.latest_version,
"compose_url": state.latest_compose_url,
"requested_at": now.isoformat(),
"requested_by": getattr(actor, "email", ""),
}
),
encoding="utf-8",
)
# Прежний статус относится к прошлой установке: убрать, чтобы не спутать.
try:
(directory / STATUS_FILE).unlink()
except OSError:
pass
state.install_version = state.latest_version
state.install_status = InstallStatus.REQUESTED
state.install_message = ""
state.install_requested_at = now
state.install_updated_at = now
state.install_requested_by = actor if getattr(actor, "pk", None) else None
state.save()
return state
def update_payload(state: UpdateState) -> dict[str, object]:
return {
"currentVersion": settings.CHATBALLS_VERSION,
"latestVersion": state.latest_version or None,
"latestName": state.latest_name,
"latestNotes": state.latest_notes,
"latestPublishedAt": state.latest_published_at.isoformat() if state.latest_published_at else None,
"latestPageUrl": state.latest_page_url,
"available": update_available(state),
"checkedAt": state.checked_at.isoformat() if state.checked_at else None,
"checkError": state.check_error,
"updaterOnline": updater_online(),
"install": {
"version": state.install_version or None,
"status": state.install_status,
"message": state.install_message,
"requestedAt": state.install_requested_at.isoformat() if state.install_requested_at else None,
"updatedAt": state.install_updated_at.isoformat() if state.install_updated_at else None,
},
}
+173
View File
@@ -0,0 +1,173 @@
"""Обновления из интерфейса: канал релизов, запрос установки, статус, права."""
from __future__ import annotations
import io
import json
import os
import time
from datetime import timedelta
from tempfile import TemporaryDirectory
from unittest import mock
from django.test import TestCase, override_settings
from django.utils import timezone
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.testing import TenantAPIClient
from chatballs.updates import github, services
from chatballs.updates.models import InstallStatus, UpdateState
PASSWORD = "Owner-Password-2026!"
RELEASE = {
"tag_name": "v1.5.0",
"name": "v1.5.0",
"body": "## Что нового\n- тест",
"published_at": "2026-09-13T10:00:00Z",
"html_url": "https://github.com/dartdavros/chatballs/releases/tag/v1.5.0",
"assets": [
{"name": "compose.yaml", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/compose.yaml"},
{"name": "release.env", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/release.env"},
],
}
def _response(payload: dict) -> mock.MagicMock:
"""urlopen отдаёт контекстный менеджер с read(): подделываем ровно это."""
response = mock.MagicMock()
response.__enter__.return_value = io.BytesIO(json.dumps(payload).encode("utf-8"))
return response
class VersionTests(TestCase):
def test_semantic_comparison(self) -> None:
self.assertTrue(github.is_newer("1.5.0", "1.4.0"))
self.assertTrue(github.is_newer("1.10.0", "1.9.9"))
self.assertTrue(github.is_newer("1.5.0", "1.5.0-rc1"))
self.assertFalse(github.is_newer("1.4.0", "1.4.0"))
self.assertFalse(github.is_newer("1.3.9", "1.4.0"))
# Dev-сборка: сравнивать не с чем.
self.assertFalse(github.is_newer("1.5.0", "dev"))
def test_tag_parsing(self) -> None:
self.assertEqual(github.parse_version("v1.5.0"), "1.5.0")
self.assertEqual(github.parse_version("1.5.0-rc1"), "1.5.0-rc1")
self.assertIsNone(github.parse_version("release-1"))
self.assertIsNone(github.parse_version("v1.5"))
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
class UpdateCheckTests(TestCase):
def test_check_records_latest_release(self) -> None:
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
state = services.check_for_updates(force=True)
self.assertEqual(state.latest_version, "1.5.0")
self.assertEqual(state.latest_compose_url, RELEASE["assets"][0]["browser_download_url"])
self.assertEqual(state.check_error, "")
self.assertTrue(services.update_available(state))
def test_check_respects_interval_unless_forced(self) -> None:
state = UpdateState.load()
state.checked_at = timezone.now() - timedelta(minutes=5)
state.save()
with mock.patch("urllib.request.urlopen") as fetch:
services.check_for_updates()
fetch.assert_not_called()
def test_channel_error_is_recorded_not_raised(self) -> None:
with mock.patch("urllib.request.urlopen", side_effect=OSError("offline")):
state = services.check_for_updates(force=True)
self.assertIn("offline", state.check_error)
self.assertFalse(services.update_available(state))
def test_release_without_compose_asset_is_refused(self) -> None:
release = {**RELEASE, "assets": []}
with mock.patch("urllib.request.urlopen", return_value=_response(release)):
state = services.check_for_updates(force=True)
self.assertIn("compose.yaml", state.check_error)
self.assertEqual(state.latest_version, "")
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
class InstallRequestTests(TestCase):
def setUp(self) -> None:
self.tmp = TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.override = override_settings(CHATBALLS_UPDATES_DIR=self.tmp.name)
self.override.enable()
self.addCleanup(self.override.disable)
self.result = bootstrap_owner(email="update-owner@example.com", password=PASSWORD)
self.client = TenantAPIClient()
self.client.force_authenticate(self.result.owner)
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
services.check_for_updates(force=True)
def _heartbeat(self) -> None:
(services.updates_dir() / services.HEARTBEAT_FILE).write_text("", encoding="utf-8")
def test_install_needs_a_live_updater(self) -> None:
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["code"], "updater_offline")
def test_install_writes_request_and_tracks_status_from_updater(self) -> None:
self._heartbeat()
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(response.status_code, 202, response.content)
request = json.loads((services.updates_dir() / services.REQUEST_FILE).read_text(encoding="utf-8"))
self.assertEqual(request["version"], "1.5.0")
self.assertEqual(request["compose_url"], RELEASE["assets"][0]["browser_download_url"])
self.assertEqual(response.json()["update"]["install"]["status"], InstallStatus.REQUESTED)
# Повторный запрос, пока идёт установка, отклоняется.
again = self.client.post("/api/v1/instance/update/install/", {}, format="json")
self.assertEqual(again.status_code, 409)
self.assertEqual(again.json()["code"], "install_in_progress")
# Updater пишет статус в том — приложение подхватывает его при чтении.
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "running", "version": "1.5.0", "message": "pulling"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.RUNNING)
self.assertEqual(state["install"]["message"], "pulling")
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "done", "version": "1.5.0", "message": "updated"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.DONE)
def test_status_for_another_version_is_ignored(self) -> None:
self._heartbeat()
self.client.post("/api/v1/instance/update/install/", {}, format="json")
(services.updates_dir() / services.STATUS_FILE).write_text(
json.dumps({"status": "done", "version": "1.4.9", "message": "stale"}), encoding="utf-8"
)
state = self.client.get("/api/v1/instance/update/").json()["update"]
self.assertEqual(state["install"]["status"], InstallStatus.REQUESTED)
def test_stale_heartbeat_means_offline(self) -> None:
path = services.updates_dir() / services.HEARTBEAT_FILE
path.write_text("", encoding="utf-8")
old = time.time() - services.HEARTBEAT_STALE_SECONDS - 5
os.utime(path, (old, old))
self.assertFalse(services.updater_online())
def test_organization_owner_reads_but_cannot_install_or_check(self) -> None:
other = Organization.objects.create(name="Other", slug="other-upd")
owner = HumanUser.objects.create_user(email="other-owner@example.com", password=PASSWORD)
OrganizationMembership.objects.create(organization=other, user=owner, role=EmployeeRole.OWNER, position_title="Owner")
client = TenantAPIClient()
client.force_authenticate(owner)
self.assertEqual(client.get("/api/v1/instance/update/").status_code, 200)
self.assertEqual(client.post("/api/v1/instance/update/check/", {}, format="json").status_code, 403)
self.assertEqual(client.post("/api/v1/instance/update/install/", {}, format="json").status_code, 403)
+59
View File
@@ -0,0 +1,59 @@
"""Обновления установки в интерфейсе: состояние, проверка, установка.
Читает менеджер любой организации (баннер показывается только администратору
установки, но версия видна всем менеджерам в «Платформе»), проверять канал и
запускать установку может только администратор установки.
"""
from __future__ import annotations
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
from chatballs.updates.models import UpdateState
from chatballs.updates.services import (
InstallNotPossible,
check_for_updates,
request_install,
sync_install_status,
update_payload,
)
class UpdateStateView(APIView):
permission_classes = [InstanceSettingsPermission]
def get(self, request: Request) -> Response:
state = sync_install_status(UpdateState.load())
return Response({"update": update_payload(state)})
class UpdateCheckView(APIView):
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
state = sync_install_status(check_for_updates(force=True))
return Response({"update": update_payload(state)})
class UpdateInstallView(APIView):
permission_classes = [IsInstanceAdmin]
def post(self, request: Request) -> Response:
try:
state = request_install(actor=request.user)
except InstallNotPossible as error:
return Response({"detail": str(error), "code": error.code}, status=409)
record_audit_event(
action="updates.install_requested",
actor=request.user,
organization=None,
object_type="UpdateState",
object_id=state.install_version,
payload={"version": state.install_version},
request=request,
)
return Response({"update": update_payload(state)}, status=202)
@@ -33,6 +33,17 @@ if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
)
CHATBALLS_DELIVERY_MODE = _delivery_mode
# Версия установки: CI передаёт её в образ при сборке релиза; в сборке из
# исходников остаётся «dev», и тогда обновлений из интерфейса нет — сравнивать
# не с чем.
CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
# Канал релизов и обмен с сервисом updater (ADR-CHATBALLS-0049).
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(15 * 60))
)
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
if TESTING:
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
@@ -67,6 +78,7 @@ INSTALLED_APPS = [
"chatballs.events",
"chatballs.support_portals",
"chatballs.calls",
"chatballs.updates",
# django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels»
# конфликтует с доменным chatballs.channels, а без runserver-оверрайда
# (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS.
@@ -184,10 +196,6 @@ if TESTING and not CHATBALLS_AI_PROVIDER:
CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int(
os.environ.get("CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0")
) # 0 = без лимита
CHATBALLS_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
@@ -14,6 +14,9 @@ urlpatterns = [
path("api/v1/auth/", include("chatballs.identity.auth_urls")),
path("api/v1/setup/", include("chatballs.identity.setup_urls")),
path("api/v1/instance/", include("chatballs.identity.instance_urls")),
# Создание организации из интерфейса: адрес без uuid, контекст открывает
# сам сервис. Маршруты с uuid ниже его не перехватывают.
path("api/v1/organizations/", include("chatballs.identity.organization_urls")),
path(
"api/v1/demo-media/avatars/<str:name>",
DemoMediaView.as_view(),
+31 -2
View File
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
@@ -40,6 +40,9 @@ export function App() {
const [user, setUser] = useState<SessionUser | null>(null);
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
// Учётная запись с несколькими организациями после входа выбирает, с какой
// начать. Ссылка на конкретную организацию экран выбора минует.
const [organizationChoice, setOrganizationChoice] = useState<AuthenticatedUser | null>(null);
const [recovering, setRecovering] = useState(false);
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
@@ -132,6 +135,11 @@ export function App() {
}, [loadData, user]);
const landAfterAuth = useCallback((nextIdentity: AuthenticatedUser) => {
if (!initialRoute.organizationPublicId && nextIdentity.memberships.length > 1) {
acceptServerLanguage(nextIdentity.language);
setOrganizationChoice(nextIdentity);
return;
}
const activeUser = useIdentity(nextIdentity, initialRoute.organizationPublicId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
@@ -163,6 +171,24 @@ export function App() {
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
const chooseOrganization = useCallback((organizationId: string) => {
if (!organizationChoice) return;
setOrganizationChoice(null);
const activeUser = useIdentity(organizationChoice, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
}
}, [navigate, organizationChoice, useIdentity]);
// Новая организация создана: сервер вернул учётную запись с обновлённым
// списком членств — переключаемся в неё сразу, как после приглашения.
const finishOrganizationCreate = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
const activeUser = useIdentity(nextIdentity, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
}
}, [navigate, useIdentity]);
const cancelJoin = useCallback(() => {
setJoinToken(null);
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
@@ -170,6 +196,7 @@ export function App() {
async function logout() {
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
setOrganizationChoice(null);
setIdentity(null);
setUser(null);
setOrganizationPublicId(null);
@@ -194,6 +221,8 @@ export function App() {
<ConfigProvider theme={antdTheme} locale={antdLocale}>
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : organizationChoice ? (
<AuthChooseOrganization identity={organizationChoice} onChoose={chooseOrganization} onLogout={logout} />
) : !identity ? (
joinToken && !joinNeedsLogin && !needsSetup ? (
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
@@ -217,7 +246,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} />
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
)}
</ConfigProvider>
);
+8
View File
@@ -17,10 +17,18 @@ export function hasCapability(user: SessionUser, capability: string): boolean {
return user.capabilities.includes(capability);
}
/** Кто заводит новые организации: администратор установки и тот, кто где-либо
* владелец или администратор. Роль в текущей организации не решает —
* сотрудник здесь может быть владельцем в другой. Сервер проверяет то же. */
export function canCreateOrganization(user: SessionUser): boolean {
return user.isInstanceAdmin || user.memberships.some((membership) => membership.role === "OWNER" || membership.role === "ADMIN");
}
export function canAccess(user: SessionUser, route: RouteKey): boolean {
// «Настройки» — настройки организации: только владелец и админ. Личные
// параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2).
if (route === "profile") return true;
if (route === "organizationCreate") return canCreateOrganization(user);
if (isManager(user)) return true;
return EMPLOYEE_ROUTES.has(route);
}
@@ -38,7 +38,7 @@ export function OrganizationLogoField({
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
@@ -72,43 +72,13 @@ export function OrganizationSettingsForm({
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={organization.currency}
label={t("settings.language")}
value={organization.language}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
onChange={(language) => onChange({ ...organization, language })}
options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]}
/>
</div>
{/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов
три, и выбор лучше видеть целиком, чем разворачивать список. В сетку
полей он не встаёт — она выравнивает поля по нижнему краю, и строка с
подписью ломала бы ряд. */}
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
<div className="appearance-theme-options">
<button
className={organization.language === "" ? "active" : ""}
disabled={!canManage}
type="button"
onClick={() => onChange({ ...organization, language: "" })}
>
{t("settings.language_as_installation")}
</button>
{languages.map((item) => (
<button
className={organization.language === item.code ? "active" : ""}
disabled={!canManage}
key={item.code}
lang={item.code}
type="button"
onClick={() => onChange({ ...organization, language: item.code })}
>
{item.label}
</button>
))}
</div>
</div>
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
{error && <div className="administration-message error" role="alert">{error}</div>}
{canManage && (
<div className="administration-actions">
@@ -25,7 +25,6 @@ export async function saveOrganizationSettings(
body: JSON.stringify({
name: settings.name,
timezone: settings.timezone,
currency: settings.currency,
language: settings.language,
}),
});
@@ -9,7 +9,6 @@ export type AdministrationSection = "organization";
export type OrganizationSettings = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
language: string;
logoUrl: string | null;
@@ -40,7 +40,7 @@
overflow: hidden;
color: var(--n-1);
background: var(--n-9);
border: 1px dashed var(--n-6);
border: 1px solid var(--n-7);
border-radius: var(--radius-4xl);
font-size: 22px;
font-weight: 700;
@@ -5,6 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
import { api, ApiError } from "../../api/client";
import { ChannelGlyph } from "../../shared/badges";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
@@ -21,8 +22,6 @@ import {
connectionStatusMeta,
connectionSubtitle,
createdLabel,
dailyCostCents,
dailyCostInput,
deleteAgent,
fetchAgent,
knowledgeLine,
@@ -509,25 +508,16 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
: <span className="agent-field-static">{card.name}</span>}
</label>
<label className="agent-field is-select">
<span>{t("common.group")}</span>
<span className="agent-field-control">
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
{canManage ? (
<select
disabled={busy}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("common.no_group")}</option>
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
</select>
) : (
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
adornment={<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />}
disabled={busy}
label={t("common.group")}
readOnly={!canManage}
readOnlyText={card.groupName ?? t("common.no_group")}
value={card.groupId === null ? "" : String(card.groupId)}
onChange={(next) => void apply({ groupId: next ? Number(next) : null })}
options={[["", t("common.no_group")], ...groups.map((group) => [String(group.id), group.name] as [string, string])]}
/>
</div>
</section>
);
@@ -542,40 +532,24 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [limit, setLimit] = useState(dailyCostInput(card.limits));
useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]);
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
function saveLimit() {
const cents = dailyCostCents(limit);
if (cents === Number(card.limits.dailyCostUsd ?? 0)) return;
void apply({ limits: { ...card.limits, dailyCostUsd: cents } });
}
return (
<section className="agent-card is-side">
<h3>{t("common.model")}</h3>
<p>{t("ai.provider_key_lives_under_settings")}</p>
<div className="agent-side-fields">
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
<span>{t("ai.provider")}</span>
<span className="agent-field-control">
{canManage ? (
<select
disabled={busy}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
>
<option value="">{t("ai.not_selected")}</option>
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
</select>
) : (
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
)}
<Icon name="chevron" size={14} strokeWidth={2.2} />
</span>
</label>
<SelectField
disabled={busy}
invalid={missingProvider}
label={t("ai.provider")}
readOnly={!canManage}
readOnlyText={providerName || t("ai.not_selected")}
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
/>
<label className="agent-field is-model">
<span>{t("common.model")}</span>
<span className="agent-field-control">
@@ -583,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>{t("ai.daily_limit")}</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
: <span className="agent-limit-value">{limit || "0.00"}</span>}
<small>USD</small>
</span>
</div>
</div>
</section>
);
@@ -37,7 +37,6 @@ export type AgentCard = {
model: string;
providerIntegrationId: number | null;
modelParams: Record<string, unknown>;
limits: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
persona: string;
@@ -63,7 +62,6 @@ export type AgentPatch = Partial<{
tone: string;
instructions: string;
knowledgeIds: number[];
limits: Record<string, unknown>;
}>;
// Страница списка агентов (кадр G1): группа, поиск и страница — на сервере.
@@ -255,15 +253,3 @@ export function knowledgeLine(card: AgentCard): string {
return `${head} · ${parts.join(" + ")}`;
}
// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd,
// ADR-CHATBALLS-0023); на экране — доллары с двумя знаками.
export function dailyCostInput(limits: Record<string, unknown>): string {
const cents = Number(limits.dailyCostUsd ?? 0);
return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : "";
}
export function dailyCostCents(value: string): number {
const amount = Number(value.replace(",", ".").trim());
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.round(amount * 100);
}
@@ -352,23 +352,6 @@
gap: 14px;
}
.agent-create-modal .readonly-field span,
.agent-knowledge-modal .readonly-field span {
margin-bottom: 6px;
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
line-height: normal;
}
.agent-create-modal .readonly-field input,
.agent-create-modal .select-like select {
height: 36px;
background: var(--n-10);
font-size: 13.5px;
line-height: normal;
}
.agent-create-actions {
display: flex;
justify-content: flex-end;
@@ -800,7 +783,6 @@
.agent-field textarea,
.agent-field input,
.agent-field select,
.agent-field .agent-field-static {
width: 100%;
padding: 0 12px;
@@ -814,7 +796,6 @@
}
.agent-field input,
.agent-field select,
.agent-field > .agent-field-static {
height: 36px;
line-height: 34px;
@@ -859,15 +840,15 @@
/* Фокус поля — как в кадре G2: акцентная рамка, кольцо 3px и подложка карточки. */
.agent-field textarea:focus,
.agent-field input:focus,
.agent-field select:focus,
.agent-create-modal .form-field.is-editable input:focus,
.agent-create-modal .select-like select:focus {
.agent-create-modal .select-box:focus-visible {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.agent-field-control:focus-within {
/* Тот же фокус для общего селекта карточки. */
.agent-side-fields .select-box:focus-visible {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
@@ -1028,23 +1009,6 @@
font-size: 13.5px;
}
.agent-field.is-invalid .agent-field-control {
border-color: var(--error-border);
color: var(--error-text);
}
.agent-field-control select {
flex: 1;
height: 34px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-size: 13.5px;
appearance: none;
cursor: pointer;
}
.agent-field-control .agent-field-static {
flex: 1;
height: auto;
@@ -1080,54 +1044,6 @@
color: var(--n-5);
}
.agent-limit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 6px;
}
.agent-limit > span:first-child {
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.agent-limit > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--n-1);
font-size: 13px;
}
.agent-limit input,
.agent-limit-value {
display: inline-flex;
width: 56px;
height: 30px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--n-7);
border-radius: 7px;
outline: none;
background: var(--n-10);
color: var(--n-1);
font-family: var(--font-mono);
font-size: 12.5px;
}
.agent-limit input:focus {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.agent-limit small {
color: var(--n-4);
}
/* Подключения. */
.agent-connection {
@@ -7,6 +7,7 @@ import {
type MarkdownTool,
} from "../../../shared/markdown/markdownTools";
import { EmptyState, LoadingState, Segmented } from "../../../shared/ui";
import { SelectField } from "../../../shared/form-controls";
import { Button } from "../../../shared/ui-controls";
import type { RouteKey } from "../../../types";
import type { AgentRef } from "../../agents/model";
@@ -432,19 +433,12 @@ export function KnowledgeEditorPage({
<aside className="knowledge-editor-rail">
<div className="knowledge-rail-fields">
<label>
<span>{t("ai.category")}</span>
<span className="knowledge-select">
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
{catalog.categories.map((item) => (
<option key={item.id} value={item.id}>
{knowledgeCategoryPath(catalog.categories, item.id) || item.name}
</option>
))}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<SelectField
label={t("ai.category")}
value={categoryId}
onChange={setCategoryId}
options={catalog.categories.map((item) => [String(item.id), knowledgeCategoryPath(catalog.categories, item.id) || item.name])}
/>
<label>
<span>{t("ai.short_description")}<small>{t("ai.helps_search_agent")}</small></span>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
@@ -518,36 +518,6 @@
font-weight: 500;
}
.knowledge-select {
position: relative;
display: flex;
height: 34px;
align-items: center;
justify-content: space-between;
padding: 0 10px;
border: 1px solid var(--n-7);
border-radius: 8px;
color: var(--n-1);
font-size: 13px;
}
.knowledge-select select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--n-1);
font-family: inherit;
font-size: 13px;
appearance: none;
cursor: pointer;
}
.knowledge-select svg { position: relative; margin-left: auto; color: var(--n-4); }
.knowledge-rail-fields textarea {
width: 100%;
min-height: 58px;
@@ -505,7 +505,15 @@
.knowledge-table .row-actions { text-align: right; }
/* --- Подвал таблицы --- */
/* --- Пустое состояние (кадр KB1): блок по центру рабочей области --- */
.knowledge-empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 40px;
}
.knowledge-empty > div {
width: 480px;
@@ -0,0 +1,57 @@
import { Icon } from "../../shared/icons";
import { roleLabel } from "../../shared/ui";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Выбор организации после входа — для тех, кто состоит в нескольких. Экран
// в той же матовой рамке, что вход и приглашение: список организаций
// строками «логотип · название · роль». Ссылка на конкретную организацию
// этот экран минует: адрес уже сказал, куда идти.
function initials(name: string): string {
return name.trim().split(/\s+/).map((part) => part[0] ?? "").join("").slice(0, 2).toUpperCase() || "CB";
}
export function AuthChooseOrganization({ identity, onChoose, onLogout }: {
identity: AuthenticatedUser;
onChoose: (organizationPublicId: string) => void;
onLogout: () => void;
}) {
const logoutLink = (
<button type="button" className="auth-back-login" onClick={onLogout}><Icon name="logout" size={14} />{t("auth.choose_organization_logout")}</button>
);
return (
<AuthFrame
title={t("auth.choose_organization_title")}
subtitle={t("auth.choose_organization_subtitle", { name: identity.fullName || identity.email })}
logo="pulse"
width={420}
note={logoutLink}
>
<ul className="auth-card auth-org-list">
{identity.memberships.map((membership) => (
<li key={membership.organizationPublicId}>
<button
type="button"
className="auth-org-item"
onClick={() => onChoose(membership.organizationPublicId)}
>
<span className={`auth-org-mark ${membership.organizationLogoUrl ? "has-logo" : ""}`}>
{membership.organizationLogoUrl
? <img src={membership.organizationLogoUrl} alt="" />
: initials(membership.organizationName)}
</span>
<span className="auth-org-copy">
<strong>{membership.organizationName}</strong>
<small>{membership.positionTitle || roleLabel(membership.role)}</small>
</span>
<Icon name="chevronRight" size={16} />
</button>
</li>
))}
</ul>
</AuthFrame>
);
}
@@ -1,4 +1,5 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthChooseOrganization } from "./AuthChooseOrganization";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
@@ -603,6 +603,102 @@
font-weight: 600;
}
/* Выбор организации после входа: строки «логотип · название · роль» в той же
матовой рамке, что и вход. Плитка логотипа — как знак в сайдбаре (28px),
только крупнее; строка целиком — кнопка. */
.auth-org-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.auth-org-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px 10px 10px;
color: var(--n-1);
background: var(--surface-card);
border: 1px solid var(--n-8);
border-radius: 10px;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.auth-org-item:hover,
.auth-org-item:focus-visible {
background: var(--primary-bg);
border-color: var(--primary-border);
outline: none;
}
.auth-org-item > svg {
flex: none;
color: var(--n-5);
}
.auth-org-item:hover > svg {
color: var(--primary-text);
}
.auth-org-mark {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
overflow: hidden;
color: var(--primary-text);
background: var(--primary-bg);
border-radius: 9px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
.auth-org-mark.has-logo {
background: var(--surface-card);
border: 1px solid var(--n-7);
}
.auth-org-mark img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.auth-org-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 2px;
}
.auth-org-copy strong {
overflow: hidden;
color: var(--n-1);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-org-copy small {
overflow: hidden;
color: var(--n-4);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-back-login {
display: inline-flex;
align-items: center;
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more