mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🔀 merge: установка одной командой, разбор аудита, линтер в CI
Ветка feat/one-command-release целиком: - установка на чистый хост одной командой (compose.yaml со страницы релиза); - 19 правок по аудиту: 2FA, сессии при сбросе пароля, TLS на домене установки, WebSocket под TLS, права на файлы, дедупликация входящих, смена адреса, SSRF через редирект, домен портала, пароль прокси, health; - код приведён к своей же конфигурации ruff, линтер включён в CI; - переводы строк в .py нормализованы, GitLab и .claude убраны из репозитория. Проверено: backend pytest 739 passed, playwright 8, vitest 82, CLI 22, typecheck, ruff — всё зелёное; установка поднята и проверена через шлюз.
This commit is contained in:
commit
5f7bd03cb5
156 files changed
+2946
-1596
No files matched your search
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "internal-ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--workspace", "@chatballs/internal-ui", "--", "--host", "127.0.0.1"],
|
||||
"cwd": ".",
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,21 @@
|
||||
*.sh text eol=lf
|
||||
chatballs text eol=lf
|
||||
|
||||
# Python читают и переписывают линуксовые инструменты (ruff в контейнере, CI).
|
||||
# На CRLF-чекауте ruff принимал за перевод строки одиночный возврат каретки и
|
||||
# писал его в вывод: файл оставался рабочим, но обрастал лишними CR. Двадцать
|
||||
# один такой файл успел доехать до репозитория.
|
||||
*.py text eol=lf
|
||||
|
||||
# Dockerfile и манифесты стека читают Linux-инструменты: CR в них ломает
|
||||
# RUN-строки и heredoc'и ровно так же, как шебанг.
|
||||
Dockerfile text eol=lf
|
||||
Dockerfile.* text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
compose*.yaml text eol=lf
|
||||
Caddyfile text eol=lf
|
||||
*.sql text eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Быстрые проверки на каждый push и pull request.
|
||||
#
|
||||
# До этого на GitHub был только релизный workflow по тегу, то есть до первого
|
||||
# тега ничего не проверялось вовсе. Здесь — то, что стоит секунды и ловит
|
||||
# ошибки раньше человека: линтер бэкенда и тесты deployment CLI (они держат
|
||||
# свойство «установка — один compose.yaml без host-mount'ов»).
|
||||
#
|
||||
# Бэкенд-сьют и e2e сюда не вынесены намеренно: им нужны Postgres, Redis и
|
||||
# полчаса, их место — отдельный пайплайн, а не проверка на каждый коммит.
|
||||
|
||||
name: checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Установить ruff той же версии, что и в образе
|
||||
run: |
|
||||
set -eu
|
||||
# Единственный источник версии — requirements бэкенда, чтобы CI и
|
||||
# контейнер не разъезжались.
|
||||
constraint="$(grep -E '^ruff' apps/backend/requirements.txt)"
|
||||
python -m pip install --disable-pip-version-check "$constraint"
|
||||
ruff --version
|
||||
|
||||
# Конфигурация линтера лежит в корневом pyproject.toml (src = apps/backend).
|
||||
- name: ruff check
|
||||
run: ruff check --output-format=github apps/backend
|
||||
|
||||
cli:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: python -m pip install --disable-pip-version-check pytest pyyaml
|
||||
|
||||
# Тесты гоняют настоящий bash-скрипт chatballs с замоканными docker и
|
||||
# flock, плюс проверяют, что production-манифест остаётся
|
||||
# самодостаточным (tests/cli/test_release_compose.py).
|
||||
- run: python -m pytest -q tests/cli
|
||||
+102
-29
@@ -1,9 +1,16 @@
|
||||
# Публикация образов Chatballs в GitHub Container Registry.
|
||||
# Публикация Chatballs: образы в GitHub Container Registry + релизный compose.yaml.
|
||||
#
|
||||
# Смысл: человек не должен собирать продукт, чтобы его запустить. По тегу vX.Y.Z
|
||||
# workflow собирает backend и frontend, публикует их в ghcr.io и прикладывает к
|
||||
# релизу release.env — файл со ссылками на образы по digest. С ним запуск
|
||||
# сводится к «docker compose up» без единой сборки.
|
||||
# Смысл: человек не должен ни собирать продукт, ни распаковывать бандл, чтобы
|
||||
# его запустить. По тегу vX.Y.Z workflow собирает четыре образа (backend,
|
||||
# frontend, gateway с Caddyfile внутри, postgres с init-скриптами внутри),
|
||||
# публикует их в ghcr.io и прикладывает к релизу один файл — compose.yaml с
|
||||
# ссылками по digest. Установка на чистый хост:
|
||||
#
|
||||
# curl -fsSL <ссылка на compose.yaml> -o compose.yaml
|
||||
# docker compose up -d --wait
|
||||
#
|
||||
# release.env кладётся рядом для тех, кто разворачивает через `chatballs`
|
||||
# (закрытый контур): там нужны те же digest'ы отдельным файлом.
|
||||
#
|
||||
# Ничего настраивать не нужно: путь образов выводится из github.repository,
|
||||
# публикация идёт встроенным GITHUB_TOKEN.
|
||||
@@ -28,9 +35,9 @@ env:
|
||||
APP_DIR: .
|
||||
REGISTRY: ghcr.io
|
||||
# Сторонние образы пинуются тем же способом, что и свои: по digest.
|
||||
POSTGRES_IMAGE: pgvector/pgvector:pg16
|
||||
POSTGRES_BASE_IMAGE: pgvector/pgvector:pg16
|
||||
GATEWAY_BASE_IMAGE: caddy:2.8.4
|
||||
REDIS_IMAGE: redis:7-alpine
|
||||
GATEWAY_IMAGE: caddy:2.8.4
|
||||
COTURN_IMAGE: coturn/coturn:4.6
|
||||
|
||||
jobs:
|
||||
@@ -58,6 +65,20 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Базовые образы закрепляем до сборки: тогда digest наших образов
|
||||
# однозначно отвечает известному входу, а не плавающему тегу.
|
||||
- name: Digest базовых образов
|
||||
id: bases
|
||||
run: |
|
||||
set -eu
|
||||
digest_of() {
|
||||
docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"'
|
||||
}
|
||||
echo "postgres=${POSTGRES_BASE_IMAGE}@$(digest_of "$POSTGRES_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
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"
|
||||
|
||||
- name: Backend
|
||||
id: backend
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -84,40 +105,92 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: release.env со ссылками по digest
|
||||
# Шлюз и база — свои образы: Caddyfile и init-скрипты живут внутри них,
|
||||
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
|
||||
- name: Gateway
|
||||
id: gateway
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
|
||||
build-args: |
|
||||
CHATBALLS_GATEWAY_BASE_IMAGE=${{ steps.bases.outputs.gateway }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/gateway:${{ steps.version.outputs.value }}
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/gateway:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Postgres
|
||||
id: postgres
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
|
||||
build-args: |
|
||||
CHATBALLS_POSTGRES_BASE_IMAGE=${{ steps.bases.outputs.postgres }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/postgres:${{ steps.version.outputs.value }}
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/postgres:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Релизный compose.yaml и release.env
|
||||
id: artifacts
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ steps.version.outputs.value }}"
|
||||
repo="${{ steps.version.outputs.repo }}"
|
||||
prefix="${{ env.REGISTRY }}/$repo"
|
||||
prefix="${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}"
|
||||
|
||||
# Digest своих образов отдаёт сам build-push-action; сторонние
|
||||
# спрашиваем у реестра — :latest в релизе недопустим.
|
||||
third_party_digest() {
|
||||
docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"'
|
||||
}
|
||||
backend="$prefix/backend:$version@${{ steps.backend.outputs.digest }}"
|
||||
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
|
||||
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
|
||||
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
|
||||
redis="${{ steps.bases.outputs.redis }}"
|
||||
coturn="${{ steps.bases.outputs.coturn }}"
|
||||
|
||||
python3 scripts/pin-release-compose.py \
|
||||
--source compose.yaml \
|
||||
--output dist/compose.yaml \
|
||||
--version "$version" \
|
||||
--pin "CHATBALLS_BACKEND_IMAGE=$backend" \
|
||||
--pin "CHATBALLS_FRONTEND_IMAGE=$frontend" \
|
||||
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
|
||||
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
|
||||
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
|
||||
{
|
||||
echo "# release.env — сгенерирован ${{ github.workflow }} для $version."
|
||||
echo "# Все образы закреплены по digest: :latest источником релиза не является."
|
||||
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
|
||||
echo "# Тем, кто ставит одной командой, он не нужен: всё уже внутри compose.yaml."
|
||||
echo "CHATBALLS_VERSION=$version"
|
||||
echo "CHATBALLS_BACKEND_IMAGE=$prefix/backend:$version@${{ steps.backend.outputs.digest }}"
|
||||
echo "CHATBALLS_FRONTEND_IMAGE=$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
|
||||
echo "CHATBALLS_POSTGRES_IMAGE=${POSTGRES_IMAGE}@$(third_party_digest "$POSTGRES_IMAGE")"
|
||||
echo "CHATBALLS_REDIS_IMAGE=${REDIS_IMAGE}@$(third_party_digest "$REDIS_IMAGE")"
|
||||
echo "CHATBALLS_GATEWAY_IMAGE=${GATEWAY_IMAGE}@$(third_party_digest "$GATEWAY_IMAGE")"
|
||||
echo "CHATBALLS_COTURN_IMAGE=${COTURN_IMAGE}@$(third_party_digest "$COTURN_IMAGE")"
|
||||
} > release.env
|
||||
echo "CHATBALLS_BACKEND_IMAGE=$backend"
|
||||
echo "CHATBALLS_FRONTEND_IMAGE=$frontend"
|
||||
echo "CHATBALLS_GATEWAY_IMAGE=$gateway"
|
||||
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
|
||||
echo "CHATBALLS_REDIS_IMAGE=$redis"
|
||||
echo "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
} > dist/release.env
|
||||
|
||||
cat release.env
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
# без переменных окружения и без чего-либо рядом.
|
||||
( cd dist && docker compose -f compose.yaml config -q )
|
||||
|
||||
cat dist/compose.yaml
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release.env
|
||||
path: release.env
|
||||
name: release-compose
|
||||
path: |
|
||||
dist/compose.yaml
|
||||
dist/release.env
|
||||
|
||||
- name: Приложить release.env к релизу
|
||||
- name: Приложить к релизу
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release.env
|
||||
files: |
|
||||
dist/compose.yaml
|
||||
dist/release.env
|
||||
@@ -26,3 +26,6 @@ data/
|
||||
|
||||
# Архив, создаваемый pack.bat
|
||||
Chatballs.zip
|
||||
|
||||
# Служебный каталог агента: локальные настройки запуска, а не часть продукта.
|
||||
.claude/
|
||||
@@ -1,29 +0,0 @@
|
||||
# GitLab CI/CD — canonical pipeline Chatballs (ADR-HUB-0028 / PLAN-CHATBALLS-0002).
|
||||
# Application orchestration is owned only by the release-bundled `chatballs` CLI.
|
||||
|
||||
stages:
|
||||
- validate
|
||||
- test
|
||||
- build
|
||||
- release
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
APP_DIR: "."
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_DRIVER: overlay2
|
||||
|
||||
BACKEND_IMAGE: "$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA"
|
||||
FRONTEND_IMAGE: "$CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA"
|
||||
BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest"
|
||||
FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest"
|
||||
|
||||
POSTGRES_IMAGE: "pgvector/pgvector:pg16"
|
||||
REDIS_IMAGE: "redis:7-alpine"
|
||||
GATEWAY_IMAGE: "caddy:2.8.4"
|
||||
COTURN_IMAGE: "coturn/coturn:4.6"
|
||||
|
||||
include:
|
||||
- local: "/.gitlab/ci/validate-test.yml"
|
||||
- local: "/.gitlab/ci/build-release.yml"
|
||||
- local: "/.gitlab/ci/deploy.yml"
|
||||
@@ -1,125 +0,0 @@
|
||||
images:build:
|
||||
stage: build
|
||||
image: docker:27
|
||||
services:
|
||||
- name: docker:27-dind
|
||||
alias: docker
|
||||
needs:
|
||||
- backend:test
|
||||
- frontend:build
|
||||
- gateway:validate
|
||||
- cli:test
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
- docker buildx version
|
||||
script:
|
||||
- |
|
||||
set -eu
|
||||
VERSION="${CI_COMMIT_TAG:-$CI_COMMIT_SHA}"
|
||||
case "$VERSION" in
|
||||
*[!A-Za-z0-9._-]*|'')
|
||||
echo "Unsupported release version: $VERSION" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
docker build \
|
||||
-f "$APP_DIR/apps/backend/Dockerfile.production" \
|
||||
-t "$BACKEND_IMAGE" \
|
||||
-t "$BACKEND_LATEST_IMAGE" \
|
||||
"$APP_DIR"
|
||||
docker build \
|
||||
-f "$APP_DIR/deploy/docker/frontend.Dockerfile" \
|
||||
-t "$FRONTEND_IMAGE" \
|
||||
-t "$FRONTEND_LATEST_IMAGE" \
|
||||
"$APP_DIR"
|
||||
|
||||
docker push "$BACKEND_IMAGE"
|
||||
docker push "$FRONTEND_IMAGE"
|
||||
|
||||
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
|
||||
docker push "$BACKEND_LATEST_IMAGE"
|
||||
docker push "$FRONTEND_LATEST_IMAGE"
|
||||
fi
|
||||
|
||||
resolve_digest_ref() {
|
||||
image="$1"
|
||||
manifest="$(mktemp)"
|
||||
docker buildx imagetools inspect --raw "$image" > "$manifest"
|
||||
test -s "$manifest"
|
||||
digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')"
|
||||
rm -f "$manifest"
|
||||
printf '%s@%s' "$image" "$digest"
|
||||
}
|
||||
|
||||
mkdir -p "$APP_DIR/.ci"
|
||||
cat > "$APP_DIR/.ci/release.env" <<EOF
|
||||
CHATBALLS_VERSION=$VERSION
|
||||
CHATBALLS_BACKEND_IMAGE=$(resolve_digest_ref "$BACKEND_IMAGE")
|
||||
CHATBALLS_FRONTEND_IMAGE=$(resolve_digest_ref "$FRONTEND_IMAGE")
|
||||
CHATBALLS_POSTGRES_IMAGE=$(resolve_digest_ref "$POSTGRES_IMAGE")
|
||||
CHATBALLS_REDIS_IMAGE=$(resolve_digest_ref "$REDIS_IMAGE")
|
||||
CHATBALLS_GATEWAY_IMAGE=$(resolve_digest_ref "$GATEWAY_IMAGE")
|
||||
CHATBALLS_COTURN_IMAGE=$(resolve_digest_ref "$COTURN_IMAGE")
|
||||
EOF
|
||||
artifacts:
|
||||
paths:
|
||||
- "$APP_DIR/.ci/release.env"
|
||||
expire_in: 1 day
|
||||
when: on_success
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
release:bundle:
|
||||
stage: release
|
||||
image: alpine:3.20
|
||||
needs:
|
||||
- job: images:build
|
||||
artifacts: true
|
||||
before_script:
|
||||
- apk add --no-cache coreutils findutils tar
|
||||
script:
|
||||
- |
|
||||
set -eu
|
||||
cd "$APP_DIR"
|
||||
|
||||
VERSION="${CI_COMMIT_TAG:-$CI_COMMIT_SHA}"
|
||||
BUNDLE_DIR="chatballs-${VERSION}"
|
||||
DIST_DIR="dist"
|
||||
|
||||
test "$(awk -F= '$1 == "CHATBALLS_VERSION" {print $2; exit}' .ci/release.env)" = "$VERSION"
|
||||
|
||||
rm -rf "$BUNDLE_DIR" "$DIST_DIR"
|
||||
mkdir -p "$BUNDLE_DIR/deploy/cli" "$BUNDLE_DIR/deploy/postgres" "$DIST_DIR"
|
||||
|
||||
cp compose.yaml compose.dev.yaml Caddyfile "$BUNDLE_DIR/"
|
||||
cp .ci/release.env "$BUNDLE_DIR/release.env"
|
||||
cp chatballs "$BUNDLE_DIR/"
|
||||
chmod +x "$BUNDLE_DIR/chatballs"
|
||||
cp -R deploy/cli/lib "$BUNDLE_DIR/deploy/cli/"
|
||||
cp -R deploy/postgres/. "$BUNDLE_DIR/deploy/postgres/"
|
||||
|
||||
(
|
||||
cd "$BUNDLE_DIR"
|
||||
find . -type f ! -name checksums.txt -print0 \
|
||||
| LC_ALL=C sort -z \
|
||||
| xargs -0 sha256sum > checksums.txt
|
||||
sha256sum -c checksums.txt
|
||||
)
|
||||
|
||||
tar -czf "$DIST_DIR/chatballs-release.tar.gz" "$BUNDLE_DIR"
|
||||
printf 'CHATBALLS_RELEASE_VERSION=%s\n' "$VERSION" > "$DIST_DIR/release-metadata.env"
|
||||
printf 'CHATBALLS_RELEASE_ARCHIVE=%s/dist/chatballs-release.tar.gz\n' "$APP_DIR" >> "$DIST_DIR/release-metadata.env"
|
||||
artifacts:
|
||||
name: "chatballs-$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- "$APP_DIR/dist/chatballs-release.tar.gz"
|
||||
- "$APP_DIR/dist/release-metadata.env"
|
||||
reports:
|
||||
dotenv: "$APP_DIR/dist/release-metadata.env"
|
||||
expire_in: 1 year
|
||||
when: on_success
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
@@ -1,129 +0,0 @@
|
||||
deploy:production:
|
||||
stage: deploy
|
||||
image: alpine:3.20
|
||||
needs:
|
||||
- job: release:bundle
|
||||
artifacts: true
|
||||
resource_group: production
|
||||
interruptible: false
|
||||
variables:
|
||||
DEPLOY_ROOT: "/opt/chatballs"
|
||||
before_script:
|
||||
- apk add --no-cache bash openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- chmod 700 ~/.ssh
|
||||
- printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||
script:
|
||||
- |
|
||||
set -eu
|
||||
REMOTE_ARCHIVE="/tmp/chatballs-${CI_PIPELINE_ID}-${CI_JOB_ID}.tar.gz"
|
||||
scp -P "${DEPLOY_PORT:-22}" \
|
||||
"$CHATBALLS_RELEASE_ARCHIVE" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_ARCHIVE"
|
||||
|
||||
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
bash -s -- "$DEPLOY_ROOT" "$CHATBALLS_RELEASE_VERSION" "$REMOTE_ARCHIVE" <<'REMOTE'
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$1"
|
||||
version="$2"
|
||||
archive="$3"
|
||||
release_name="chatballs-$version"
|
||||
target="$root/releases/$release_name"
|
||||
incoming=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$incoming" ]]; then
|
||||
rm -rf "$incoming"
|
||||
fi
|
||||
rm -f "$archive"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
sudo mkdir -p \
|
||||
"$root/releases" \
|
||||
"$root/instance/state" \
|
||||
"$root/instance/data" \
|
||||
"$root/instance/backups" \
|
||||
"$root/instance/logs"
|
||||
sudo chown "$(id -u):$(id -g)" \
|
||||
"$root" \
|
||||
"$root/releases" \
|
||||
"$root/instance" \
|
||||
"$root/instance/state" \
|
||||
"$root/instance/data" \
|
||||
"$root/instance/backups" \
|
||||
"$root/instance/logs"
|
||||
|
||||
incoming="$(mktemp -d "$root/releases/.incoming-${release_name}.XXXXXX")"
|
||||
tar -xzf "$archive" -C "$incoming"
|
||||
source_dir="$incoming/$release_name"
|
||||
|
||||
[[ -d "$source_dir" ]] || {
|
||||
echo "Release directory is missing in bundle: $release_name" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ -f "$source_dir/checksums.txt" ]] || {
|
||||
echo "checksums.txt is missing in release bundle" >&2
|
||||
exit 1
|
||||
}
|
||||
(
|
||||
cd "$source_dir"
|
||||
sha256sum -c checksums.txt
|
||||
)
|
||||
|
||||
if [[ -e "$target" ]]; then
|
||||
[[ -f "$target/checksums.txt" ]] || {
|
||||
echo "Existing release has no checksums: $target" >&2
|
||||
exit 1
|
||||
}
|
||||
(
|
||||
cd "$target"
|
||||
sha256sum -c checksums.txt
|
||||
)
|
||||
incoming_manifest="$(sha256sum "$source_dir/checksums.txt" | awk '{print $1}')"
|
||||
target_manifest="$(sha256sum "$target/checksums.txt" | awk '{print $1}')"
|
||||
[[ "$incoming_manifest" = "$target_manifest" ]] || {
|
||||
echo "Release $version already exists with different content" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
mv "$source_dir" "$target"
|
||||
fi
|
||||
|
||||
chmod +x "$target/chatballs"
|
||||
REMOTE
|
||||
|
||||
- |
|
||||
set -eu
|
||||
printf '%s' "$CI_REGISTRY_PASSWORD" \
|
||||
| ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin"
|
||||
|
||||
- |
|
||||
set -eu
|
||||
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
bash -s -- "$DEPLOY_ROOT" "$CHATBALLS_RELEASE_VERSION" <<'REMOTE'
|
||||
set -Eeuo pipefail
|
||||
|
||||
root="$1"
|
||||
version="$2"
|
||||
release_dir="$root/releases/chatballs-$version"
|
||||
|
||||
CHATBALLS_INSTANCE_DIR="$root/instance" \
|
||||
"$release_dir/chatballs" deploy --non-interactive
|
||||
|
||||
ln -sfnT "releases/chatballs-$version" "$root/current"
|
||||
REMOTE
|
||||
after_script:
|
||||
- |
|
||||
ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker logout '$CI_REGISTRY' >/dev/null 2>&1 || true" || true
|
||||
environment:
|
||||
name: production
|
||||
url: https://${CHATBALLS_APP_DOMAIN}
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
when: manual
|
||||
@@ -1,86 +0,0 @@
|
||||
frontend:build:
|
||||
stage: validate
|
||||
image: node:22-alpine
|
||||
before_script:
|
||||
- cd "$APP_DIR"
|
||||
- npm ci
|
||||
script:
|
||||
- npm run typecheck
|
||||
- npm run build
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
gateway:validate:
|
||||
stage: validate
|
||||
image: docker:27
|
||||
services:
|
||||
- name: docker:27-dind
|
||||
alias: docker
|
||||
before_script:
|
||||
- mkdir -p "$APP_DIR/.ci-instance/data/caddy" "$APP_DIR/.ci-instance/data/caddy-config"
|
||||
script:
|
||||
- |
|
||||
set -eu
|
||||
# Никакого .env: продукт поднимается со значениями по умолчанию, и
|
||||
# проверять его надо ровно так же, как он ставится у человека.
|
||||
export CHATBALLS_INSTANCE_DIR="$CI_PROJECT_DIR/$APP_DIR/.ci-instance"
|
||||
export CHATBALLS_RELEASE_DIR="$CI_PROJECT_DIR/$APP_DIR"
|
||||
|
||||
docker compose \
|
||||
--project-directory "$CHATBALLS_INSTANCE_DIR" \
|
||||
-f "$APP_DIR/compose.yaml" \
|
||||
config -q
|
||||
|
||||
docker compose \
|
||||
--project-directory "$CHATBALLS_INSTANCE_DIR" \
|
||||
-f "$APP_DIR/compose.yaml" \
|
||||
run --rm --no-deps gateway \
|
||||
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
backend:test:
|
||||
stage: test
|
||||
image: python:3.12-slim
|
||||
services:
|
||||
- name: pgvector/pgvector:pg16
|
||||
alias: postgres
|
||||
- name: redis:7-alpine
|
||||
alias: redis
|
||||
variables:
|
||||
CHATBALLS_DEBUG: "true"
|
||||
CHATBALLS_SECRET_KEY: "ci-test-secret"
|
||||
CHATBALLS_FIELD_ENCRYPTION_KEY: ""
|
||||
POSTGRES_DB: "chatballs_test"
|
||||
POSTGRES_USER: "chatballs"
|
||||
POSTGRES_PASSWORD: "chatballs"
|
||||
POSTGRES_HOST: "postgres"
|
||||
POSTGRES_PORT: "5432"
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
before_script:
|
||||
- cd "$APP_DIR/apps/backend"
|
||||
- python -m pip install --upgrade pip
|
||||
- pip install -r requirements.txt
|
||||
script:
|
||||
- python manage.py check
|
||||
- pytest
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
cli:test:
|
||||
stage: test
|
||||
image: python:3.12-slim
|
||||
before_script:
|
||||
- apt-get update
|
||||
- apt-get install -y --no-install-recommends bash coreutils util-linux
|
||||
- rm -rf /var/lib/apt/lists/*
|
||||
- python -m pip install --no-cache-dir pytest
|
||||
script:
|
||||
- cd "$APP_DIR"
|
||||
- pytest -c tests/cli/pytest.ini tests/cli
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
@@ -1,10 +1,12 @@
|
||||
repos:
|
||||
# Тот же линтер и та же конфигурация, что в CI (.github/workflows/checks.yml).
|
||||
# ruff-format здесь намеренно нет: он переписал бы 374 файла разом, и это
|
||||
# отдельное решение, а не побочный эффект установки хука.
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
|
||||
@@ -2,51 +2,71 @@
|
||||
|
||||
Canonical implementation workspace for Chatballs.
|
||||
|
||||
## Быстрый старт (одна минута)
|
||||
## Установка (одна команда)
|
||||
|
||||
Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose
|
||||
на Linux). Ни одной переменной задавать не нужно и негде: у продукта нет `.env`.
|
||||
Организацию, владельца, домены, интеграции, почту и хранилище человек настраивает
|
||||
в интерфейсе.
|
||||
Нужен только Docker с плагином Compose. Ни одной переменной задавать не нужно и
|
||||
негде: у продукта нет `.env`. Организацию, владельца, домены, интеграции, почту и
|
||||
хранилище человек настраивает в интерфейсе.
|
||||
|
||||
Весь дистрибутив — один файл `compose.yaml` со страницы релиза: ссылки на образы
|
||||
в нём закреплены по digest, а Caddyfile, init-скрипты базы и генератор секретов
|
||||
лежат внутри образов. Рядом с файлом ничего лежать не должно.
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
curl.exe -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
`--wait` держит команду до готовности стека: когда она вернула управление,
|
||||
установка отвечает. Откройте **http://localhost** (или адрес сервера) — вместо
|
||||
входа система покажет **мастер первого запуска**: название организации, ваше имя,
|
||||
e-mail и пароль владельца, переключатель «Установить демо-данные». После кнопки
|
||||
«Начать» вы сразу в приложении под владельцем. Мастер доступен только пока в
|
||||
системе нет ни одной организации; после создания владельца он закрывается
|
||||
навсегда.
|
||||
|
||||
Секреты инстанса (ключ подписи, пароли ролей БД) генерирует сам первый старт и
|
||||
держит в томе `chatballs-secrets`. Состояние установки живёт в именованных томах
|
||||
`chatballs-*` — установка не зависит от того, из какого каталога её запустили.
|
||||
|
||||
Обновление — тот же файл новой версии и та же команда:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/dartdavros/chatballs/releases/latest/download/compose.yaml -o compose.yaml
|
||||
docker compose up -d --wait
|
||||
```
|
||||
|
||||
Миграции прогоняет one-shot сервис `init` на каждом старте. Откат — прежняя
|
||||
копия `compose.yaml` и снова та же команда.
|
||||
|
||||
### Запуск из исходников (разработка)
|
||||
|
||||
Тем, кто правит код, релизный файл не нужен: стек собирается локально.
|
||||
|
||||
```powershell
|
||||
git clone <URL репозитория> chatballs
|
||||
cd chatballs
|
||||
.\scripts\start.ps1
|
||||
```
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
git clone <URL репозитория> chatballs
|
||||
cd chatballs
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
Настраивать нечего: файла `.env` у продукта нет, секреты инстанса (ключ
|
||||
подписи, пароли ролей БД) генерирует сам первый старт и держит их в томе
|
||||
`chatballs-secrets`.
|
||||
|
||||
Если положить рядом со скриптом `release.env` со страницы релиза, образы
|
||||
скачаются из реестра по digest и сборки не будет — это самый быстрый путь.
|
||||
Без `release.env` стек собирается из исходников: так работают те, кто правит
|
||||
код. Когда в логах появится готовность, откройте
|
||||
**http://localhost** — вместо входа система покажет **мастер первого запуска**:
|
||||
название организации, ваше имя, e-mail и пароль владельца, переключатель
|
||||
«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под
|
||||
владельцем. Мастер доступен только пока в системе нет ни одной организации;
|
||||
после создания владельца он закрывается навсегда.
|
||||
|
||||
### Доступ по http и переход на TLS
|
||||
|
||||
Свежая установка отвечает по обычному http — по адресу сервера, пока домена и
|
||||
сертификата ещё нет. Продукт не уводит себя на https принудительно: этим
|
||||
занимается шлюз, когда у него появляется настоящий домен и сертификат.
|
||||
Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https —
|
||||
cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные
|
||||
имена без `Secure`. Настраивать для этого нечего.
|
||||
Первая сборка занимает минуты (`npm ci` + `pip install`). Dev-контур держит
|
||||
состояние в `./data`, публикует порты Postgres/Redis и подменяет frontend на
|
||||
Vite с HMR — см. `compose.dev.yaml`.
|
||||
|
||||
### Демо-данные
|
||||
|
||||
@@ -79,7 +99,11 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
|
||||
|
||||
### Режим поставки
|
||||
|
||||
По умолчанию локально запускается облачный режим. Коробочный режим — тем же
|
||||
По умолчанию коробка считает себя `SELF_HOSTED`; облачный контур выставляет
|
||||
`CHATBALLS_DELIVERY_MODE=CLOUD` явно. Приложение не определяет режим по домену,
|
||||
числу организаций или данным. Задавать что-либо при установке не нужно и негде.
|
||||
|
||||
Локально `scripts/start.ps1` поднимает облачный режим; коробочный — тем же
|
||||
контуром с явным признаком поставки:
|
||||
|
||||
```powershell
|
||||
@@ -87,22 +111,17 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
|
||||
.\scripts\start.ps1 -Mode SelfHosted
|
||||
```
|
||||
|
||||
Приложение не определяет режим по домену, числу организаций или данным.
|
||||
Коробка по умолчанию считает себя `SELF_HOSTED`; облачный контур выставляет
|
||||
`CHATBALLS_DELIVERY_MODE=CLOUD` явно. Задавать что-либо при установке не
|
||||
нужно и негде — файла с переменными у продукта нет.
|
||||
|
||||
### Что поднимается
|
||||
|
||||
- изолированные Django-рантаймы app, platform и loopback-only admin;
|
||||
- фоновый worker (outbox, поллинг мессенджеров, установка демо);
|
||||
- PostgreSQL и Redis;
|
||||
- Internal Hub UI и Web Chat UI;
|
||||
- локальный Nginx reverse proxy.
|
||||
- Internal Hub UI и Web Chat UI одним nginx-образом;
|
||||
- шлюз Caddy — единственная публичная граница (80/443).
|
||||
|
||||
Секретов production в репозитории нет.
|
||||
|
||||
Локальные адреса:
|
||||
Локальные адреса dev-контура:
|
||||
|
||||
- Приложение: `http://localhost` (то же — `http://app.localhost/`)
|
||||
- Health платформы: `http://platform.localhost/api/v1/health/live/`
|
||||
@@ -111,8 +130,33 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
|
||||
- Web Chat: `http://localhost:5175`
|
||||
- App API напрямую: `http://localhost:8010/api/v1`
|
||||
|
||||
### Доступ по http и переход на TLS
|
||||
|
||||
Свежая установка отвечает по обычному http — по адресу сервера, пока домена и
|
||||
сертификата ещё нет. Продукт не уводит себя на https принудительно.
|
||||
|
||||
Когда у установки появляется домен, владелец вписывает его в **Настройки →
|
||||
Адрес установки**. С этого момента шлюз выписывает на него сертификат сам, при
|
||||
первом же запросе по https: он спрашивает разрешение у самой установки, и она
|
||||
подтверждает свой адрес и адреса опубликованных порталов помощи. Прежний адрес
|
||||
остаётся принятым, чтобы смена не выбросила того, кто её делает.
|
||||
|
||||
Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https —
|
||||
cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные
|
||||
имена без `Secure`. Настраивать для этого нечего.
|
||||
|
||||
## Tests
|
||||
|
||||
Линтер бэкенда (та же конфигурация, что в CI — корневой `pyproject.toml`):
|
||||
|
||||
```bash
|
||||
ruff check apps/backend
|
||||
```
|
||||
|
||||
`ruff format` в репозитории не принят: он переписал бы 374 файла, поэтому
|
||||
длина строки (`E501`) из проверок исключена — всё остальное из `E`, `F`, `I`,
|
||||
`UP`, `B` и `DJ` обязано быть зелёным. Миграции не проверяются: их пишет Django.
|
||||
|
||||
All suites run in Docker, so no manual environment is required — the test
|
||||
runners auto-detect themselves and relax production hardening (secret-key
|
||||
fail-fast, SSL redirect, throttling) for the duration of the run.
|
||||
|
||||
@@ -9,6 +9,10 @@ COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
|
||||
|
||||
COPY apps/backend /app/apps/backend
|
||||
# Тот же генератор секретов, что и в production-образе: сервис secrets
|
||||
# крутится на backend-образе и в dev, и в коробке.
|
||||
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
|
||||
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
|
||||
|
||||
WORKDIR /app/apps/backend
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt
|
||||
|
||||
COPY apps/backend /app/apps/backend
|
||||
COPY content /app/content
|
||||
# Генератор секретов инстанса живёт в образе, а не монтируется с хоста:
|
||||
# установка — один compose.yaml, рядом с ним ничего лежать не должно.
|
||||
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
|
||||
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
|
||||
|
||||
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
|
||||
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
|
||||
@@ -30,6 +33,11 @@ RUN cd apps/backend && CHATBALLS_SECRET_KEY=collectstatic-build CHATBALLS_DEBUG=
|
||||
POSTGRES_MIGRATION_USER=build-migration \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Каталог локальных файлов создаётся в образе и принадлежит hub: том
|
||||
# наследует владельца из образа, поэтому загрузки работают и на Linux,
|
||||
# где bind-mount достался бы контейнеру как root:root.
|
||||
RUN mkdir -p /app/apps/backend/media
|
||||
|
||||
RUN chown -R hub:hub /app
|
||||
|
||||
WORKDIR /app/apps/backend
|
||||
|
||||
@@ -13,7 +13,6 @@ from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider_selection import configure_agent_provider
|
||||
from chatballs.ai.serializers import agent_portal_article_payload
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.channels.services import (
|
||||
|
||||
@@ -225,7 +225,6 @@ class AgentCardTestChatView(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
|
||||
required_capability = "ai.manage"
|
||||
require_organization_scope = True
|
||||
|
||||
def post(self, request: Request, agent_id: int) -> Response:
|
||||
try:
|
||||
|
||||
@@ -30,7 +30,7 @@ def _store_fragments(*, organization, chunks: list[str], **source) -> list[Knowl
|
||||
embedding=vector,
|
||||
**source,
|
||||
)
|
||||
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
|
||||
for index, (chunk, vector) in enumerate(zip(chunks, vectors, strict=False))
|
||||
]
|
||||
return KnowledgeFragment.objects.bulk_create(fragments)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.ai.models import AIAgent, Knowledge
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
AI_VIEW = "ai.view"
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
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")␍
|
||||
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")
|
||||
@@ -1,19 +1,11 @@
|
||||
import uuid
|
||||
|
||||
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.db import models
|
||||
|
||||
from pgvector.django import VectorField
|
||||
|
||||
|
||||
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
|
||||
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
|
||||
|
||||
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
@@ -88,13 +80,9 @@ class Knowledge(models.Model):
|
||||
|
||||
|
||||
|
||||
def clean(self) -> None:
|
||||
def __str__(self) -> str:
|
||||
|
||||
super().clean()
|
||||
|
||||
if self.category_id is not None and self.category.organization_id != self.organization_id:
|
||||
|
||||
raise ValidationError({"category": "Category belongs to another organization"})
|
||||
return f"knowledge:{self.organization_id}/{self.title}"
|
||||
|
||||
|
||||
|
||||
@@ -106,9 +94,13 @@ class Knowledge(models.Model):
|
||||
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
def clean(self) -> None:
|
||||
|
||||
return f"knowledge:{self.organization_id}/{self.title}"
|
||||
super().clean()
|
||||
|
||||
if self.category_id is not None and self.category.organization_id != self.organization_id:
|
||||
|
||||
raise ValidationError({"category": "Category belongs to another organization"})
|
||||
|
||||
|
||||
|
||||
@@ -119,15 +111,10 @@ class Knowledge(models.Model):
|
||||
# models after Knowledge exists so they are registered without growing this file.
|
||||
|
||||
from chatballs.ai.knowledge_models import ( # noqa: E402, F401
|
||||
|
||||
KnowledgeCategory,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:
|
||||
|
||||
organization = instance.knowledge.organization
|
||||
@@ -198,8 +185,6 @@ class KnowledgeAttachment(TenantRelationModel):
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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"])␍
|
||||
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"])
|
||||
@@ -1,9 +1,6 @@
|
||||
from chatballs.ai.provider.openrouter import OpenRouterProvider
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class CustomProvider(OpenRouterProvider):
|
||||
|
||||
"""Generic OpenAI-compatible BYOK adapter (ADR-CHATBALLS-0034).
|
||||
|
||||
@@ -15,7 +15,13 @@ import hashlib
|
||||
import math
|
||||
import re
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.base import (
|
||||
ChatMessage,
|
||||
ChatResult,
|
||||
EmbeddingResult,
|
||||
LLMProvider,
|
||||
ProviderError,
|
||||
)
|
||||
|
||||
EMBEDDING_DIM = 16
|
||||
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
|
||||
|
||||
@@ -32,26 +32,15 @@ do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
import http.client
|
||||
|
||||
import json
|
||||
|
||||
import urllib.error
|
||||
|
||||
import urllib.request
|
||||
|
||||
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
|
||||
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
|
||||
|
||||
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import time
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.channels.models import Channel
|
||||
@@ -17,6 +16,7 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class AgentCardTestCase(TestCase):
|
||||
|
||||
@@ -8,10 +8,17 @@ from chatballs.ai.provider.routing import _provider_from_integration
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
|
||||
from chatballs.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from chatballs.integrations.services import (
|
||||
IntegrationInput,
|
||||
create_integration,
|
||||
)
|
||||
from chatballs.integrations.services import (
|
||||
# Алиас обязателен: имя test_* на уровне модуля pytest собирает как тест
|
||||
# и падает на ненайденных фикстурах (как в integrations/tests.py).
|
||||
test_integration as run_integration_test,
|
||||
|
||||
@@ -3,13 +3,14 @@ import tempfile
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from chatballs.testing import TenantAPIClient as APIClient, system_tenant_context
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
|
||||
|
||||
@@ -284,7 +285,11 @@ class ResilienceTests(TestCase):
|
||||
|
||||
def test_circuit_breaker_opens_after_threshold(self) -> None:
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
|
||||
from chatballs.ai.provider.resilience import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
call_with_resilience,
|
||||
)
|
||||
|
||||
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.ai.api_errors import validation_error_response
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.ai.indexing import reindex_knowledge
|
||||
from chatballs.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
|
||||
from chatballs.ai.knowledge_import import import_knowledge_documents
|
||||
from chatballs.ai.knowledge_policy import (
|
||||
@@ -19,7 +19,6 @@ from chatballs.ai.knowledge_services import (
|
||||
delete_knowledge,
|
||||
update_knowledge,
|
||||
)
|
||||
from chatballs.ai.indexing import reindex_knowledge
|
||||
from chatballs.ai.models import Knowledge
|
||||
from chatballs.ai.selectors import (
|
||||
apply_knowledge_filters,
|
||||
@@ -29,6 +28,7 @@ from chatballs.ai.selectors import (
|
||||
writable_knowledge_item_for_employee,
|
||||
)
|
||||
from chatballs.ai.serializers import attachment_payload, knowledge_payload
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import AuditEvent
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
|
||||
|
||||
def _flatten(data: Any) -> str:
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return "; ".join(_flatten(value) for value in data.values())
|
||||
if isinstance(data, (list, tuple)):
|
||||
if isinstance(data, list | tuple):
|
||||
return "; ".join(_flatten(item) for item in data)
|
||||
return str(data)
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ class HasCapability(BasePermission):
|
||||
Views declare ``required_capability`` or a method keyed
|
||||
``required_capabilities`` mapping. Object/resource scope is still checked by the
|
||||
view after loading the canonical resource.
|
||||
|
||||
Организационная область не объявляется вьюхой и не отключается: без
|
||||
членства в организации проверка не проходит вообще. Раньше рядом стоял
|
||||
атрибут ``require_organization_scope = True``, который никто не читал — он
|
||||
выглядел как переключатель там, где переключателя нет.
|
||||
"""
|
||||
|
||||
message = "Required capability is missing"
|
||||
|
||||
@@ -17,10 +17,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
|
||||
from chatballs.calls import signaling
|
||||
from chatballs.calls.errors import CallTokenError
|
||||
from chatballs.calls.models import TERMINAL_CALL_STATUSES
|
||||
from chatballs.calls.models import TERMINAL_CALL_STATUSES, ParticipantSide
|
||||
from chatballs.calls.permissions import staff_call_access_valid
|
||||
from chatballs.calls.services import authorize_call_access_context
|
||||
from chatballs.calls.models import ParticipantSide
|
||||
from chatballs.tenancy.database import run_tenant_operation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,7 +12,6 @@ from chatballs.calls.models import (
|
||||
ParticipantSide,
|
||||
)
|
||||
from chatballs.conversations.models import Message, MessageAuthor
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
CallStatus.REQUESTED: {
|
||||
|
||||
@@ -46,7 +46,7 @@ def record_call_metric(
|
||||
local = _sanitize_candidate_type(local_candidate_type)
|
||||
remote = _sanitize_candidate_type(remote_candidate_type)
|
||||
rtt: int | None = None
|
||||
if isinstance(round_trip_ms, (int, float)) and not isinstance(round_trip_ms, bool):
|
||||
if isinstance(round_trip_ms, int | float) and not isinstance(round_trip_ms, bool):
|
||||
rtt = max(0, min(_MAX_ROUND_TRIP_MS, int(round_trip_ms)))
|
||||
CallMetric.objects.update_or_create(
|
||||
call_session_id=call_session_id,
|
||||
|
||||
@@ -113,15 +113,15 @@ class CallSession(models.Model):
|
||||
]
|
||||
indexes = [models.Index(fields=["organization", "status"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"call:{self.id}/{self.status}"
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> int | None:
|
||||
if self.connected_at is None or self.ended_at is None:
|
||||
return None
|
||||
return max(0, int((self.ended_at - self.connected_at).total_seconds()))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"call:{self.id}/{self.status}"
|
||||
|
||||
|
||||
class CallInvite(TenantRelationModel):
|
||||
tenant_relation_fields = ("call_session", "connection_identity")
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.calls.consumers import CallSignalingConsumer
|
||||
from chatballs.http.ws_middleware import SameOriginWebSocketMiddleware
|
||||
|
||||
# Сигналинг звонка аутентифицируется call access token, а не сессией, поэтому
|
||||
# переименование cookie ему не нужно. Проверка Origin — нужна: страницу звонка
|
||||
# открывает браузер, и чужой сайт не должен открывать сокет за него.
|
||||
websocket_urlpatterns = [
|
||||
path("ws/calls/", CallSignalingConsumer.as_asgi()),
|
||||
path("ws/calls/", SameOriginWebSocketMiddleware(CallSignalingConsumer.as_asgi())),
|
||||
]
|
||||
@@ -14,7 +14,6 @@ from chatballs.calls.errors import (
|
||||
CallTokenError,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.integrations.features import call_allowed
|
||||
from chatballs.calls.metrics import record_call_metric
|
||||
from chatballs.calls.models import (
|
||||
TERMINAL_CALL_STATUSES,
|
||||
@@ -53,6 +52,7 @@ from chatballs.conversations.models import (
|
||||
)
|
||||
from chatballs.conversations.services import ClaimError, claim_locked_conversation
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.integrations.features import call_allowed
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -10,16 +10,17 @@ from django.utils import timezone
|
||||
from chatballs.calls.errors import CallInvalidTransition
|
||||
from chatballs.calls.lifecycle import finish_call, transition_call
|
||||
from chatballs.calls.models import (
|
||||
TERMINAL_CALL_STATUSES,
|
||||
CallParticipant,
|
||||
CallSession,
|
||||
CallStatus,
|
||||
ParticipantConnectionState,
|
||||
TERMINAL_CALL_STATUSES,
|
||||
)
|
||||
from chatballs.calls.serializers import public_call_state_payload
|
||||
from chatballs.calls.services import record_call_metric
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def _call(context: TenantContext, call_id) -> CallSession:
|
||||
return CallSession.objects.select_related("initiated_by").get(
|
||||
id=call_id, organization=context.organization
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import json
|
||||
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.calls.models import CallKind, CallSession, CallStatus, ParticipantSide
|
||||
from chatballs.calls.tests.helpers import CallTestCase, create_call_request
|
||||
from chatballs.calls.tokens import verify_call_access_token
|
||||
from chatballs.conversations.models import ControlMode
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class InternalCallApiTests(CallTestCase):
|
||||
|
||||
@@ -3,13 +3,12 @@ TG/MAX через outbox, истечение и системные событи
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.utils import timezone
|
||||
from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
|
||||
|
||||
from chatballs.calls.event_handlers import handle_call_invite_send
|
||||
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
|
||||
from chatballs.calls.models import (
|
||||
CallEndedBy,
|
||||
CallInvite,
|
||||
@@ -18,14 +17,16 @@ from chatballs.calls.models import (
|
||||
InviteDeliveryStatus,
|
||||
)
|
||||
from chatballs.calls.services import (
|
||||
open_call_for_identity,
|
||||
decline_call_for_identity,
|
||||
open_call_for_identity,
|
||||
)
|
||||
from chatballs.calls.tests.helpers import CallTestCase, create_call_request, expire_stale_calls
|
||||
from chatballs.calls.tokens import hash_invite_token
|
||||
from chatballs.conversations.models import Conversation, Message
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class CancelCallApiTests(CallTestCase):
|
||||
@@ -279,7 +280,7 @@ class MessengerDeliveryTests(CallTestCase):
|
||||
with mock.patch(
|
||||
"chatballs.calls.event_handlers.transports.send_call_invite", return_value=False
|
||||
):
|
||||
with self.assertRaises(Exception):
|
||||
with self.assertRaises(CallInviteDeliveryError):
|
||||
handle_call_invite_send(
|
||||
{"callSessionId": str(created.call_session.id)},
|
||||
tenant_context_for(self.owner, self.organization),
|
||||
|
||||
@@ -270,7 +270,7 @@ class AuthDeadlineTests(CallDomainMixin, TransactionTestCase):
|
||||
self.assertEqual(output["code"], consumers.AUTH_TIMEOUT_CLOSE)
|
||||
|
||||
def test_authenticated_socket_survives_the_deadline(self) -> None:
|
||||
created, customer_token = None, None
|
||||
customer_token = None
|
||||
|
||||
def prepare():
|
||||
create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -32,7 +32,7 @@ class CallAccessClaims:
|
||||
def _derived_secret(purpose: str) -> bytes:
|
||||
return hmac.new(
|
||||
(settings.SECRET_KEY or "").encode("utf-8"),
|
||||
f"hub:{purpose}:v1".encode("utf-8"),
|
||||
f"hub:{purpose}:v1".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from django.apps import AppConfig␍
|
||||
␍
|
||||
␍
|
||||
class ChannelsConfig(AppConfig):␍
|
||||
default_auto_field = "django.db.models.BigAutoField"␍
|
||||
label = "channels"␍
|
||||
name = "chatballs.channels"␍
|
||||
verbose_name = "Processing channels (AI context)"␍
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChannelsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "channels"
|
||||
name = "chatballs.channels"
|
||||
verbose_name = "Processing channels (AI context)"
|
||||
@@ -1,21 +1,21 @@
|
||||
from django.contrib import admin␍
|
||||
␍
|
||||
from chatballs.conversations.models import Contact, Conversation, Message␍
|
||||
␍
|
||||
␍
|
||||
@admin.register(Conversation)␍
|
||||
class ConversationAdmin(admin.ModelAdmin):␍
|
||||
list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")␍
|
||||
list_filter = ("lifecycle", "control_mode")␍
|
||||
␍
|
||||
␍
|
||||
@admin.register(Contact)␍
|
||||
class ContactAdmin(admin.ModelAdmin):␍
|
||||
list_display = ("id", "name", "organization", "created_at")␍
|
||||
search_fields = ("name",)␍
|
||||
␍
|
||||
␍
|
||||
@admin.register(Message)␍
|
||||
class MessageAdmin(admin.ModelAdmin):␍
|
||||
list_display = ("id", "conversation", "author_type", "created_at")␍
|
||||
list_filter = ("author_type",)␍
|
||||
from django.contrib import admin
|
||||
|
||||
from chatballs.conversations.models import Contact, Conversation, Message
|
||||
|
||||
|
||||
@admin.register(Conversation)
|
||||
class ConversationAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "channel", "contact", "lifecycle", "control_mode", "last_activity_at")
|
||||
list_filter = ("lifecycle", "control_mode")
|
||||
|
||||
|
||||
@admin.register(Contact)
|
||||
class ContactAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "name", "organization", "created_at")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "conversation", "author_type", "created_at")
|
||||
list_filter = ("author_type",)
|
||||
@@ -15,10 +15,10 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
ConversationPriority,
|
||||
ControlMode,
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import ConnectionIdentity, ContactMerge, Contact, Conversation
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
|
||||
|
||||
@@ -4,7 +4,6 @@ import html
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"a",
|
||||
"b",
|
||||
|
||||
@@ -48,15 +48,24 @@ _ROLE = {
|
||||
|
||||
|
||||
def _already_processed(context: TenantContext, source: str, external_id: str, text: str) -> bool:
|
||||
"""Отметить сообщение обработанным; True — оно уже приходило.
|
||||
|
||||
Вставка идёт своей точкой сохранения. Вызывают эту функцию изнутри
|
||||
транзакции (воркер держит ``tenant_atomic`` на весь цикл поллинга), а
|
||||
IntegrityError в Postgres обрывает транзакцию целиком: без savepoint
|
||||
первый же повтор сообщения ронял не дедупликацию, а весь цикл — со всеми
|
||||
остальными подключениями организации.
|
||||
"""
|
||||
payload_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
|
||||
try:
|
||||
InboxEvent.objects.create(
|
||||
source=source,
|
||||
external_event_id=external_id,
|
||||
payload_hash=payload_hash,
|
||||
ownership=EventOwnership.TENANT,
|
||||
organization=context.organization,
|
||||
)
|
||||
with transaction.atomic():
|
||||
InboxEvent.objects.create(
|
||||
source=source,
|
||||
external_event_id=external_id,
|
||||
payload_hash=payload_hash,
|
||||
ownership=EventOwnership.TENANT,
|
||||
organization=context.organization,
|
||||
)
|
||||
return False
|
||||
except IntegrityError:
|
||||
return True
|
||||
@@ -89,7 +98,10 @@ class TranscriptionJob:
|
||||
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
|
||||
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.routing import DEFAULT_TRANSCRIPTION_MODEL, resolve_transcription_model
|
||||
from chatballs.ai.provider.routing import (
|
||||
DEFAULT_TRANSCRIPTION_MODEL,
|
||||
resolve_transcription_model,
|
||||
)
|
||||
|
||||
if not message.audio:
|
||||
return None
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
|
||||
from chatballs.conversations.models import Conversation, LifecycleState
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
+44
-44
@@ -1,44 +1,44 @@
|
||||
# Generated by Django 5.2.15 on 2026-09-04 20:40␍
|
||||
␍
|
||||
import chatballs.conversations.models␍
|
||||
from django.db import migrations, models␍
|
||||
␍
|
||||
␍
|
||||
class Migration(migrations.Migration):␍
|
||||
␍
|
||||
dependencies = [␍
|
||||
('conversations', '0011_conversation_archived_at_conversation_note_and_more'),␍
|
||||
]␍
|
||||
␍
|
||||
operations = [␍
|
||||
migrations.AddField(␍
|
||||
model_name='message',␍
|
||||
name='audio',␍
|
||||
field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),␍
|
||||
),␍
|
||||
migrations.AddField(␍
|
||||
model_name='message',␍
|
||||
name='audio_content_type',␍
|
||||
field=models.CharField(blank=True, max_length=64),␍
|
||||
),␍
|
||||
migrations.AddField(␍
|
||||
model_name='message',␍
|
||||
name='duration_seconds',␍
|
||||
field=models.PositiveIntegerField(default=0),␍
|
||||
),␍
|
||||
migrations.AddField(␍
|
||||
model_name='message',␍
|
||||
name='transcript',␍
|
||||
field=models.TextField(blank=True),␍
|
||||
),␍
|
||||
migrations.AddField(␍
|
||||
model_name='message',␍
|
||||
name='transcript_status',␍
|
||||
field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),␍
|
||||
),␍
|
||||
migrations.AlterField(␍
|
||||
model_name='message',␍
|
||||
name='kind',␍
|
||||
field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),␍
|
||||
),␍
|
||||
]␍
|
||||
# Generated by Django 5.2.15 on 2026-09-04 20:40
|
||||
|
||||
import chatballs.conversations.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('conversations', '0011_conversation_archived_at_conversation_note_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='audio',
|
||||
field=models.FileField(blank=True, max_length=512, upload_to=chatballs.conversations.models.message_audio_upload_path),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='audio_content_type',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='duration_seconds',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='transcript',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='transcript_status',
|
||||
field=models.CharField(choices=[('NONE', 'Не расшифровано'), ('READY', 'Готова'), ('FAILED', 'Ошибка')], default='NONE', max_length=8),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='message',
|
||||
name='kind',
|
||||
field=models.CharField(blank=True, choices=[('', 'Текст'), ('contact_request', 'Запрос контакта'), ('contact', 'Контакт'), ('voice', 'Голосовое сообщение')], default='', max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -1,38 +1,38 @@
|
||||
import logging␍
|
||||
␍
|
||||
from chatballs.conversations import transports␍
|
||||
from chatballs.conversations.ingest import ingest_inbound␍
|
||||
from chatballs.integrations.models import Integration␍
|
||||
␍
|
||||
logger = logging.getLogger(__name__)␍
|
||||
␍
|
||||
␍
|
||||
def poll_all_messengers(context) -> int:␍
|
||||
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""␍
|
||||
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).␍
|
||||
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки␍
|
||||
# без ключа purpose (NULL в SQL), т.е. все клиентские боты.␍
|
||||
integrations = [␍
|
||||
integration␍
|
||||
for integration in Integration.objects.filter(␍
|
||||
organization=context.organization,␍
|
||||
provider__in=transports.SUPPORTED_PROVIDERS,␍
|
||||
is_active=True,␍
|
||||
channel__isnull=False,␍
|
||||
channel__is_active=True,␍
|
||||
).exclude(secret="")␍
|
||||
if integration.config.get("purpose") != "notifications"␍
|
||||
]␍
|
||||
total = 0␍
|
||||
for integration in integrations:␍
|
||||
messages, new_marker = transports.poll(integration)␍
|
||||
for inbound in messages:␍
|
||||
try:␍
|
||||
ingest_inbound(integration, inbound)␍
|
||||
total += 1␍
|
||||
except Exception: # pragma: no cover␍
|
||||
logger.exception("Ingest failed for integration %s", integration.id)␍
|
||||
if new_marker and new_marker != integration.poll_marker:␍
|
||||
integration.poll_marker = new_marker␍
|
||||
integration.save(update_fields=["poll_marker", "updated_at"])␍
|
||||
return total␍
|
||||
import logging
|
||||
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.integrations.models import Integration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def poll_all_messengers(context) -> int:
|
||||
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""
|
||||
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).
|
||||
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки
|
||||
# без ключа purpose (NULL в SQL), т.е. все клиентские боты.
|
||||
integrations = [
|
||||
integration
|
||||
for integration in Integration.objects.filter(
|
||||
organization=context.organization,
|
||||
provider__in=transports.SUPPORTED_PROVIDERS,
|
||||
is_active=True,
|
||||
channel__isnull=False,
|
||||
channel__is_active=True,
|
||||
).exclude(secret="")
|
||||
if integration.config.get("purpose") != "notifications"
|
||||
]
|
||||
total = 0
|
||||
for integration in integrations:
|
||||
messages, new_marker = transports.poll(integration)
|
||||
for inbound in messages:
|
||||
try:
|
||||
ingest_inbound(integration, inbound)
|
||||
total += 1
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Ingest failed for integration %s", integration.id)
|
||||
if new_marker and new_marker != integration.poll_marker:
|
||||
integration.poll_marker = new_marker
|
||||
integration.save(update_fields=["poll_marker", "updated_at"])
|
||||
return total
|
||||
@@ -1,16 +1,15 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
|
||||
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.conversations.stats import sales_overview_stats
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
|
||||
|
||||
class ConversationStatsView(ConversationViewBase):
|
||||
|
||||
@@ -2,13 +2,18 @@ from channels.auth import AuthMiddlewareStack
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.conversations.consumers import ConversationEventsConsumer
|
||||
from chatballs.http.ws_middleware import websocket_boundary
|
||||
|
||||
# Оповещения о диалогах аутентифицируются сессией того же SPA — отдельного
|
||||
# токена, как у сигналинга звонков, здесь не нужно: сокет открывает тот же
|
||||
# браузер. Организация стоит в адресе, как и во всём HTTP-слое.
|
||||
#
|
||||
# websocket_boundary снаружи AuthMiddlewareStack: он приводит имена cookie к
|
||||
# тем, по которым Channels ищет сессию (по TLS браузер держит __Host-…), и
|
||||
# отбивает хендшейк с чужим Origin.
|
||||
websocket_urlpatterns = [
|
||||
path(
|
||||
"ws/organizations/<uuid:organization_public_id>/conversations/",
|
||||
AuthMiddlewareStack(ConversationEventsConsumer.as_asgi()),
|
||||
websocket_boundary(AuthMiddlewareStack(ConversationEventsConsumer.as_asgi())),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.db.models import Count, Max, Q
|
||||
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Conversation,
|
||||
@@ -9,6 +8,7 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +1,21 @@
|
||||
from django.db import transaction
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
|
||||
from chatballs.conversations import transports
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
|
||||
ConnectionIdentity,
|
||||
|
||||
Conversation,
|
||||
|
||||
ControlMode,
|
||||
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
|
||||
LifecycleState,
|
||||
|
||||
Message,
|
||||
|
||||
MessageAuthor,
|
||||
|
||||
MessageKind,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
|
||||
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
|
||||
|
||||
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
|
||||
|
||||
@@ -15,8 +15,8 @@ from django.utils import timezone
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
from chatballs.channels.selectors import channels_in_organization
|
||||
from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
Message,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import Contact, Conversation
|
||||
@@ -10,6 +9,7 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class ConversationVisibilityTests(TestCase):
|
||||
|
||||
@@ -2,15 +2,14 @@ import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
ConversationPriority,
|
||||
ControlMode,
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
@@ -21,6 +20,7 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class ChatExtrasTestCase(TestCase):
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, Conversation
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeRole,
|
||||
|
||||
@@ -3,39 +3,22 @@
|
||||
|
||||
|
||||
from email import message_from_bytes, policy
|
||||
|
||||
from email.message import EmailMessage as MimeMessage
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.http import QueryDict
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
|
||||
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
|
||||
|
||||
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.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
|
||||
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
|
||||
"email": "support@example.com",
|
||||
@@ -499,7 +482,6 @@ class EmailIngestThreadMetaTests(TestCase):
|
||||
def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:
|
||||
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Повторная доставка входящего не должна ломать транзакцию.
|
||||
|
||||
Дедупликация ставит запись в inbox и ловит IntegrityError на повторе. Ловить
|
||||
его без точки сохранения нельзя: Postgres обрывает транзакцию целиком, и
|
||||
следующий же запрос падает с TransactionManagementError. А вызывают это
|
||||
изнутри транзакции — воркер держит ``tenant_atomic`` на весь цикл поллинга,
|
||||
так что первый повтор ронял не дедупликацию, а весь цикл организации.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.conversations.ingest import _already_processed
|
||||
from chatballs.events.models import InboxEvent
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
class InboundDeduplicationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_owner(email="ingest-owner@example.com", password="Owner-Password-2026!")
|
||||
self.context = system_tenant_context(result.organization)
|
||||
|
||||
def test_repeat_is_reported_without_breaking_the_transaction(self) -> None:
|
||||
with tenant_atomic(self.context):
|
||||
first = _already_processed(self.context, "telegram:1", "update-42", "привет")
|
||||
second = _already_processed(self.context, "telegram:1", "update-42", "привет")
|
||||
|
||||
self.assertFalse(first)
|
||||
self.assertTrue(second)
|
||||
|
||||
# Главное: транзакция жива и дальше в ней можно работать. Раньше
|
||||
# именно здесь всё и разваливалось.
|
||||
self.assertEqual(
|
||||
InboxEvent.objects.filter(external_event_id="update-42").count(), 1
|
||||
)
|
||||
|
||||
def test_repeat_does_not_roll_back_work_done_earlier(self) -> None:
|
||||
with transaction.atomic():
|
||||
with tenant_atomic(self.context):
|
||||
_already_processed(self.context, "max:7", "update-1", "первое")
|
||||
_already_processed(self.context, "max:7", "update-1", "первое")
|
||||
_already_processed(self.context, "max:7", "update-2", "второе")
|
||||
|
||||
self.assertEqual(InboxEvent.objects.filter(source="max:7").count(), 2)
|
||||
|
||||
def test_different_sources_do_not_collide(self) -> None:
|
||||
with tenant_atomic(self.context):
|
||||
self.assertFalse(_already_processed(self.context, "telegram:1", "shared-id", "текст"))
|
||||
self.assertFalse(_already_processed(self.context, "max:2", "shared-id", "текст"))
|
||||
@@ -1,57 +1,30 @@
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
|
||||
ConnectionIdentity,
|
||||
|
||||
Contact,
|
||||
|
||||
ControlMode,
|
||||
|
||||
Conversation,
|
||||
|
||||
ExpectedResponder,
|
||||
|
||||
LifecycleState,
|
||||
|
||||
MessageAuthor,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import HumanUser, Organization
|
||||
|
||||
from chatballs.integrations.models import (
|
||||
|
||||
Integration,
|
||||
|
||||
IntegrationKind,
|
||||
|
||||
IntegrationProvider,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
|
||||
return Integration.objects.create(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import connections
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import connections
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.provider.local import LocalProvider
|
||||
from chatballs.channels.models import Channel
|
||||
@@ -20,7 +19,6 @@ from chatballs.conversations.models import (
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.tenancy.database import current_tenant_id, tenant_atomic
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
@@ -32,6 +30,8 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.tenancy.database import current_tenant_id, tenant_atomic
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class VoiceFixtureMixin:
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
@@ -19,9 +17,9 @@ from chatballs.conversations.models import (
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.conversations.transports import max as max_transport
|
||||
from chatballs.conversations.transports import telegram as telegram_transport
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -31,6 +29,7 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
def _messenger_connection(channel):
|
||||
|
||||
@@ -13,7 +13,6 @@ from django.conf import settings
|
||||
from chatballs.integrations.outbound import ensure_downloadable
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -94,9 +93,15 @@ def download_bytes(
|
||||
``allowed_host`` — хост из ``base_url`` подключения, который владелец
|
||||
назвал сам.
|
||||
"""
|
||||
ensure_downloadable(url, allowed_host=allowed_host, via_proxy=bool(proxy_url))
|
||||
def guard(candidate: str) -> None:
|
||||
ensure_downloadable(candidate, allowed_host=allowed_host, via_proxy=bool(proxy_url))
|
||||
|
||||
guard(url)
|
||||
request = urllib.request.Request(url)
|
||||
with build_opener(proxy_url).open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
|
||||
# Та же проверка на каждый редирект: провайдер отдаёт адрес данными, и
|
||||
# 302 увёл бы скачивание туда, куда исходный адрес не пустили.
|
||||
opener = build_opener(proxy_url, validate_redirect=guard)
|
||||
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
|
||||
data = response.read(max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
raise ValueError("Файл больше допустимого размера")
|
||||
|
||||
@@ -19,7 +19,13 @@ from email.utils import parseaddr
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.conversations.html_sanitizer import sanitize_email_html
|
||||
from chatballs.conversations.transports.base import MAX_ATTACHMENT_BYTES, InboundFile, InboundMessage, guess_content_type, safe_filename
|
||||
from chatballs.conversations.transports.base import (
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
InboundFile,
|
||||
InboundMessage,
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.conversations import attachment_views, chat_extras_views, reporting_views, views, voice_views
|
||||
from chatballs.conversations import (
|
||||
attachment_views,
|
||||
chat_extras_views,
|
||||
reporting_views,
|
||||
views,
|
||||
voice_views,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ConversationListView.as_view(), name="conversation-list"),
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.selectors import conversation_for_context
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.policy import require_capability
|
||||
|
||||
|
||||
class ConversationViewBase(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "conversations.view"
|
||||
|
||||
def _org(self, request: Request):
|
||||
return request.tenant_context.organization
|
||||
|
||||
def _conversation(
|
||||
self,
|
||||
request: Request,
|
||||
conversation_id: int,
|
||||
capability: str = "conversations.view",
|
||||
) -> Conversation:
|
||||
conversation = conversation_for_context(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
if not require_capability(
|
||||
request.tenant_context.membership, capability, conversation
|
||||
):
|
||||
raise Conversation.DoesNotExist
|
||||
return conversation
|
||||
|
||||
def _audit(
|
||||
self, request: Request, action: str, conversation: Conversation
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
action=f"conversations.{action}",
|
||||
actor=request.user,
|
||||
organization=self._org(request),
|
||||
object_type="Conversation",
|
||||
object_id=str(conversation.id),
|
||||
request=request,
|
||||
)
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.selectors import conversation_for_context
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.policy import require_capability
|
||||
|
||||
|
||||
class ConversationViewBase(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "conversations.view"
|
||||
|
||||
def _org(self, request: Request):
|
||||
return request.tenant_context.organization
|
||||
|
||||
def _conversation(
|
||||
self,
|
||||
request: Request,
|
||||
conversation_id: int,
|
||||
capability: str = "conversations.view",
|
||||
) -> Conversation:
|
||||
conversation = conversation_for_context(
|
||||
context=request.tenant_context, conversation_id=conversation_id
|
||||
)
|
||||
if not require_capability(
|
||||
request.tenant_context.membership, capability, conversation
|
||||
):
|
||||
raise Conversation.DoesNotExist
|
||||
return conversation
|
||||
|
||||
def _audit(
|
||||
self, request: Request, action: str, conversation: Conversation
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
action=f"conversations.{action}",
|
||||
actor=request.user,
|
||||
organization=self._org(request),
|
||||
object_type="Conversation",
|
||||
object_id=str(conversation.id),
|
||||
request=request,
|
||||
)
|
||||
@@ -2,6 +2,12 @@ from django.db import transaction
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.api.pagination import (
|
||||
cursor_id,
|
||||
window,
|
||||
window_payload,
|
||||
window_size,
|
||||
)
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
@@ -9,12 +15,6 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
Message,
|
||||
)
|
||||
from chatballs.api.pagination import (
|
||||
cursor_id,
|
||||
window,
|
||||
window_payload,
|
||||
window_size,
|
||||
)
|
||||
from chatballs.conversations.selectors import (
|
||||
MESSAGES_NEWER_KEYS,
|
||||
MESSAGES_OLDER_KEYS,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.events.services import tenant_context_for_event
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""WebSocket-обвязка: имена cookie по TLS и проверка Origin.
|
||||
|
||||
Установка с сертификатом отдаёт браузеру только ``__Host-``-имена, а Channels
|
||||
ищет сессию строго по ``settings.SESSION_COOKIE_NAME``. Пока это не сходилось,
|
||||
каждый сокет на https закрывался как неаутентифицированный — и живые
|
||||
обновления диалогов молча не работали, притом что REST на той же странице
|
||||
работал.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from chatballs.http.ws_middleware import (
|
||||
SameOriginWebSocketMiddleware,
|
||||
TlsAwareCookieASGIMiddleware,
|
||||
)
|
||||
|
||||
PLAIN = "chatballs_app_session"
|
||||
HARDENED = "__Host-chatballs-app-session"
|
||||
COOKIE_NAMES = {PLAIN: HARDENED, "chatballs_app_csrftoken": "__Host-chatballs-app-csrf"}
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""Внутреннее приложение: запоминает scope, до которого дошёл запрос."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.scope: dict | None = None
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
self.scope = scope
|
||||
|
||||
|
||||
def _scope(*, scheme: str = "wss", cookie: str = "", origin: str = "", host: str = "app.example") -> dict:
|
||||
headers = [(b"host", host.encode())]
|
||||
if cookie:
|
||||
headers.append((b"cookie", cookie.encode()))
|
||||
if origin:
|
||||
headers.append((b"origin", origin.encode()))
|
||||
return {"type": "websocket", "scheme": scheme, "headers": headers}
|
||||
|
||||
|
||||
def _cookies(scope: dict) -> dict[str, str]:
|
||||
raw = next((value for key, value in scope["headers"] if key == b"cookie"), b"")
|
||||
items = [part.strip() for part in raw.decode().split(";") if part.strip()]
|
||||
return dict(item.split("=", 1) for item in items)
|
||||
|
||||
|
||||
@override_settings(CHATBALLS_TLS_COOKIE_NAMES=COOKIE_NAMES)
|
||||
class TlsAwareCookieASGIMiddlewareTests(SimpleTestCase):
|
||||
def _run(self, scope: dict) -> dict:
|
||||
inner = _Recorder()
|
||||
asyncio.run(TlsAwareCookieASGIMiddleware(inner)(scope, None, None))
|
||||
assert inner.scope is not None
|
||||
return inner.scope
|
||||
|
||||
def test_hardened_cookie_is_delivered_under_the_plain_name(self) -> None:
|
||||
scope = self._run(_scope(cookie=f"{HARDENED}=session-value"))
|
||||
|
||||
self.assertEqual(_cookies(scope)[PLAIN], "session-value")
|
||||
|
||||
def test_plain_cookie_alone_is_dropped_over_tls(self) -> None:
|
||||
"""По TLS обычное имя мы не выдаём — значит пришло оно не от нас."""
|
||||
scope = self._run(_scope(cookie=f"{PLAIN}=planted"))
|
||||
|
||||
self.assertNotIn(PLAIN, _cookies(scope))
|
||||
|
||||
def test_hardened_cookie_wins_over_a_planted_plain_one(self) -> None:
|
||||
scope = self._run(_scope(cookie=f"{PLAIN}=planted; {HARDENED}=real"))
|
||||
|
||||
self.assertEqual(_cookies(scope)[PLAIN], "real")
|
||||
|
||||
def test_plain_http_scope_is_untouched(self) -> None:
|
||||
scope = self._run(_scope(scheme="ws", cookie=f"{PLAIN}=session-value"))
|
||||
|
||||
self.assertEqual(_cookies(scope)[PLAIN], "session-value")
|
||||
|
||||
def test_scope_without_cookies_passes_through(self) -> None:
|
||||
scope = self._run(_scope())
|
||||
|
||||
self.assertEqual(_cookies(scope), {})
|
||||
|
||||
|
||||
class SameOriginWebSocketMiddlewareTests(SimpleTestCase):
|
||||
def _run(self, scope: dict) -> tuple[dict | None, list[dict]]:
|
||||
inner = _Recorder()
|
||||
sent: list[dict] = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
asyncio.run(SameOriginWebSocketMiddleware(inner)(scope, None, send))
|
||||
return inner.scope, sent
|
||||
|
||||
def test_same_origin_handshake_passes(self) -> None:
|
||||
scope, sent = self._run(_scope(origin="https://app.example", host="app.example"))
|
||||
|
||||
self.assertIsNotNone(scope)
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_foreign_origin_is_closed(self) -> None:
|
||||
scope, sent = self._run(_scope(origin="https://evil.example", host="app.example"))
|
||||
|
||||
self.assertIsNone(scope)
|
||||
self.assertEqual(sent, [{"type": "websocket.close", "code": 4403}])
|
||||
|
||||
def test_origin_with_matching_port_passes(self) -> None:
|
||||
scope, sent = self._run(
|
||||
_scope(origin="http://localhost:5173", host="localhost:5173")
|
||||
)
|
||||
|
||||
self.assertIsNotNone(scope)
|
||||
self.assertEqual(sent, [])
|
||||
|
||||
def test_client_without_origin_is_allowed(self) -> None:
|
||||
"""Origin шлёт браузер; клиенты без него — не то, от чего мы защищаемся."""
|
||||
scope, sent = self._run(_scope(host="app.example"))
|
||||
|
||||
self.assertIsNotNone(scope)
|
||||
self.assertEqual(sent, [])
|
||||
@@ -0,0 +1,117 @@
|
||||
"""ASGI-обвязка WebSocket: имена cookie по факту TLS и проверка Origin.
|
||||
|
||||
HTTP-слой продукта переименовывает cookie по протоколу запроса
|
||||
(``chatballs.http.middleware.TlsAwareCookieMiddleware``): по https браузер
|
||||
держит ``__Host-…``, по http — обычное имя. Django-middleware на
|
||||
WebSocket-хендшейк не выполняется, а Channels ищет cookie строго по
|
||||
``settings.SESSION_COOKIE_NAME``. Из-за этого на установке с TLS сокет не
|
||||
находил сессию вообще: браузер присылал только защищённое имя, и каждое
|
||||
подключение закрывалось как неаутентифицированное — живые обновления диалогов
|
||||
молча переставали работать.
|
||||
|
||||
Здесь то же правило применяется к scope до ``AuthMiddlewareStack``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _tls_cookie_pairs() -> tuple[tuple[str, str], ...]:
|
||||
mapping = getattr(settings, "CHATBALLS_TLS_COOKIE_NAMES", {})
|
||||
return tuple((plain, hardened) for plain, hardened in mapping.items() if plain != hardened)
|
||||
|
||||
|
||||
def _header(scope: dict, name: bytes) -> bytes:
|
||||
for key, value in scope.get("headers") or ():
|
||||
if key.lower() == name:
|
||||
return value
|
||||
return b""
|
||||
|
||||
|
||||
def _replace_header(scope: dict, name: bytes, value: bytes) -> None:
|
||||
headers = [(key, item) for key, item in (scope.get("headers") or ()) if key.lower() != name]
|
||||
if value:
|
||||
headers.append((name, value))
|
||||
scope["headers"] = headers
|
||||
|
||||
|
||||
class TlsAwareCookieASGIMiddleware:
|
||||
"""По wss отдаёт вглубь защищённые cookie под обычными именами."""
|
||||
|
||||
def __init__(self, inner: Callable) -> None:
|
||||
self.inner = inner
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") == "websocket" and scope.get("scheme") in {"wss", "https"}:
|
||||
self._rewrite(scope)
|
||||
return await self.inner(scope, receive, send)
|
||||
|
||||
@staticmethod
|
||||
def _rewrite(scope: dict) -> None:
|
||||
pairs = _tls_cookie_pairs()
|
||||
if not pairs:
|
||||
return
|
||||
raw = _header(scope, b"cookie")
|
||||
if not raw:
|
||||
return
|
||||
jar = SimpleCookie()
|
||||
jar.load(raw.decode("latin-1"))
|
||||
values = {key: morsel.value for key, morsel in jar.items()}
|
||||
changed = False
|
||||
for plain, hardened in pairs:
|
||||
if hardened in values:
|
||||
# Защищённое имя всегда сильнее обычного — то же правило, что и
|
||||
# в HTTP-слое: cookie с префиксом __Host- браузер принимает
|
||||
# только с самого хоста и только по TLS.
|
||||
if values.get(plain) != values[hardened]:
|
||||
values[plain] = values[hardened]
|
||||
changed = True
|
||||
elif plain in values:
|
||||
# По TLS обычное имя мы не выдаём — значит пришло не от нас.
|
||||
del values[plain]
|
||||
changed = True
|
||||
if not changed:
|
||||
return
|
||||
rebuilt = "; ".join(f"{key}={value}" for key, value in values.items())
|
||||
_replace_header(scope, b"cookie", rebuilt.encode("latin-1"))
|
||||
|
||||
|
||||
class SameOriginWebSocketMiddleware:
|
||||
"""Отклоняет хендшейк, у которого Origin не совпадает с Host.
|
||||
|
||||
Сокет открывает то же SPA, что и REST, поэтому Origin у него всегда наш.
|
||||
Кросс-сайтовый запрос сейчас и так остаётся без cookie (``SameSite=Lax``),
|
||||
но полагаться на один барьер не стоит: у cookie этот флаг настраиваемый.
|
||||
Клиенты без браузера Origin не присылают — их не трогаем.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Callable) -> None:
|
||||
self.inner = inner
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") == "websocket" and not self._allowed(scope):
|
||||
await send({"type": "websocket.close", "code": 4403})
|
||||
return
|
||||
return await self.inner(scope, receive, send)
|
||||
|
||||
@staticmethod
|
||||
def _allowed(scope: dict) -> bool:
|
||||
origin = _header(scope, b"origin").decode("latin-1").strip()
|
||||
if not origin:
|
||||
return True
|
||||
host = _header(scope, b"host").decode("latin-1").strip().lower()
|
||||
if not host:
|
||||
return False
|
||||
origin_host = (urlsplit(origin).netloc or "").lower()
|
||||
return origin_host == host
|
||||
|
||||
|
||||
def websocket_boundary(inner: Callable) -> Callable:
|
||||
"""Обе проверки одним вызовом — порядок важен, Origin проверяется первым."""
|
||||
|
||||
return SameOriginWebSocketMiddleware(TlsAwareCookieASGIMiddleware(inner))
|
||||
@@ -13,7 +13,6 @@ from chatballs.identity.audit_catalog import (
|
||||
)
|
||||
from chatballs.identity.models import AuditEvent, Organization
|
||||
|
||||
|
||||
ORGANIZATION_CHANGE_ACTIONS = (
|
||||
"administration.organization_updated",
|
||||
"administration.logo_updated",
|
||||
|
||||
@@ -18,7 +18,6 @@ from chatballs.tenancy.storage_quota import (
|
||||
reserve_storage,
|
||||
)
|
||||
|
||||
|
||||
MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ from datetime import datetime, time, timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.http import FileResponse
|
||||
from django.utils import timezone as django_timezone
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
@@ -50,7 +50,6 @@ class OrganizationSettingsView(APIView):
|
||||
"GET": "settings.view",
|
||||
"PATCH": "settings.manage",
|
||||
}
|
||||
require_organization_scope = True
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
return Response(
|
||||
@@ -93,7 +92,6 @@ class OrganizationLogoView(APIView):
|
||||
"POST": "settings.manage",
|
||||
"DELETE": "settings.manage",
|
||||
}
|
||||
require_organization_scope = True
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
@@ -173,7 +171,6 @@ class AuditListView(APIView):
|
||||
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "audit.view"
|
||||
require_organization_scope = True
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
organization_id = request.tenant_context.organization_id
|
||||
|
||||
@@ -5,9 +5,9 @@ from chatballs.identity.auth.password_reset import (
|
||||
PasswordResetValidateView,
|
||||
)
|
||||
from chatballs.identity.auth.profile import (
|
||||
ChangeTemporaryPasswordView,
|
||||
ProfileAppearanceView,
|
||||
ProfileAvatarView,
|
||||
ChangeTemporaryPasswordView,
|
||||
ProfilePasswordView,
|
||||
ProfileRevokeOtherSessionsView,
|
||||
ProfileSessionsView,
|
||||
@@ -26,6 +26,8 @@ __all__ = [
|
||||
"PasswordResetValidateView",
|
||||
"PasswordResetConfirmView",
|
||||
"ProfileUpdateView",
|
||||
"ProfileAppearanceView",
|
||||
"ProfileAvatarView",
|
||||
"ProfilePasswordView",
|
||||
"ProfileTotpStartView",
|
||||
"ProfileTotpDisableView",
|
||||
|
||||
@@ -16,6 +16,7 @@ from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.event_handlers import PASSWORD_RESET_REQUESTED
|
||||
from chatballs.identity.models import AuditResult, HumanUser
|
||||
from chatballs.identity.sessions import revoke_user_sessions
|
||||
|
||||
|
||||
def _user_from_reset_link(uid: str, token: str) -> HumanUser | None:
|
||||
@@ -98,9 +99,15 @@ class PasswordResetConfirmView(APIView):
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = timezone.now()
|
||||
user.save(update_fields=["password", "must_change_password", "password_changed_at"])
|
||||
# Пароль сбрасывают именно тогда, когда доступ к учётной записи мог
|
||||
# оказаться у чужого. Оставить его сессии живыми — значит не сделать
|
||||
# ничего: смена пароля из профиля и админский сброс их завершают,
|
||||
# этот путь обязан вести себя так же.
|
||||
revoked = revoke_user_sessions(user.id)
|
||||
record_audit_event(
|
||||
action="identity.password_reset_completed",
|
||||
actor=user,
|
||||
payload={"revoked": revoked},
|
||||
request=request,
|
||||
)
|
||||
return Response({"ok": True})
|
||||
return Response({"ok": True, "revoked": revoked})
|
||||
@@ -11,10 +11,10 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.models import HumanUser
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
|
||||
|
||||
class ProfileUpdateView(APIView):
|
||||
@@ -115,12 +115,24 @@ class ProfilePasswordView(APIView):
|
||||
|
||||
|
||||
class ProfileTotpStartView(APIView):
|
||||
"""Начало настройки 2FA: выдать пользователю новый секрет.
|
||||
|
||||
Выключать этим уже включённую 2FA нельзя. Иначе достаточно было бы
|
||||
угнанной сессии: отключение (``ProfileTotpDisableView``) спрашивает
|
||||
текущий пароль и не даёт обойти требование организации, а этот эндпоинт
|
||||
молча делал ровно то же самое без единой проверки.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
request.user.totp_enabled = False
|
||||
if request.user.totp_enabled:
|
||||
return Response(
|
||||
{"detail": "TOTP уже включена: сначала отключите её текущим паролем"},
|
||||
status=409,
|
||||
)
|
||||
request.user.totp_secret = ""
|
||||
request.user.save(update_fields=["totp_enabled", "totp_secret"])
|
||||
request.user.save(update_fields=["totp_secret"])
|
||||
record_audit_event(
|
||||
action="identity.profile_totp_setup_started",
|
||||
actor=request.user,
|
||||
|
||||
@@ -59,8 +59,34 @@ def decrypt_secret(token: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def ciphertext_length(plaintext_chars: int) -> int:
|
||||
"""Сколько символов занимает Fernet-токен для строки такой длины.
|
||||
|
||||
В колонке лежит не значение, а шифротекст: Fernet добавляет версию,
|
||||
метку времени, IV и подпись, дополняет до блока AES и кодирует всё в
|
||||
base64. Строка в 512 символов уже не помещается в varchar(512) — запись
|
||||
падала бы на длинном пароле SMTP или ключе S3. Считаем по худшему случаю:
|
||||
4 байта на символ (UTF-8).
|
||||
"""
|
||||
payload = plaintext_chars * 4
|
||||
blocks = payload // 16 + 1 # PKCS#7 всегда добавляет хотя бы один байт
|
||||
raw = 57 + 16 * blocks # 57 = версия + timestamp + IV + HMAC
|
||||
return (raw + 2) // 3 * 4 # base64 без переносов
|
||||
|
||||
|
||||
class EncryptedCharField(models.CharField):
|
||||
"""CharField прозрачно шифрующий значение в БД (Fernet)."""
|
||||
"""CharField, прозрачно шифрующий значение в БД (Fernet).
|
||||
|
||||
``max_length`` описывает открытое значение — то, что вводит человек. Под
|
||||
колонку берётся длина шифротекста: иначе ограничение поля и ограничение
|
||||
столбца означают разное, и запись падает уже в базе.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.plaintext_max_length = kwargs.get("max_length")
|
||||
if self.plaintext_max_length:
|
||||
kwargs["max_length"] = ciphertext_length(self.plaintext_max_length)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def from_db_value(self, value, expression, connection): # noqa: ANN001
|
||||
if not value:
|
||||
|
||||
@@ -11,8 +11,8 @@ from django.core.files.base import ContentFile
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ConversationLabel,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
ConversationRead,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
@@ -147,10 +147,11 @@ def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, cur
|
||||
channel = refs.channels[item["agent"]]
|
||||
connection = refs.integrations[item["connection"]]
|
||||
if item.get("webGuest"):
|
||||
contact, identity, external_chat_id = _web_guest(context, refs, item)
|
||||
# Identity гостя заводит _web_guest; диалогу она не нужна — его
|
||||
# единственный источник identity это контакт (Conversation.contact).
|
||||
contact, _identity, external_chat_id = _web_guest(context, refs, item)
|
||||
else:
|
||||
contact = refs.contacts.get(item.get("contact"))
|
||||
identity = refs.identities.get(item.get("identity"))
|
||||
external_chat_id = item["externalChatId"]
|
||||
|
||||
existing = Conversation.objects.filter(
|
||||
|
||||
@@ -30,7 +30,6 @@ class DemoDataView(APIView):
|
||||
"POST": "company.manage",
|
||||
"DELETE": "company.manage",
|
||||
}
|
||||
require_organization_scope = True
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
return Response(service.demo_status(request.tenant_context.organization))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework.request import Request
|
||||
|
||||
from chatballs.identity.governance import employee_management_flags
|
||||
from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.governance import employee_management_flags
|
||||
from chatballs.identity.models import AuditEvent, OrganizationMembership
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework.views import APIView
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
|
||||
from chatballs.identity.employee_selectors import employees_for
|
||||
from chatballs.identity.employee_support import employee_payload, get_owned_profile
|
||||
from chatballs.identity.employee_validation import (
|
||||
@@ -16,7 +17,6 @@ from chatballs.identity.employee_validation import (
|
||||
deny_employee_action,
|
||||
resolve_groups,
|
||||
)
|
||||
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
|
||||
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
|
||||
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
|
||||
from chatballs.identity.group_models import EmployeeGroupMember
|
||||
|
||||
@@ -29,17 +29,17 @@ class EmployeeGroup(models.Model):
|
||||
)
|
||||
]
|
||||
|
||||
def clean(self) -> None:
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Group name is required"})
|
||||
def __str__(self) -> str:
|
||||
return f"{self.organization.slug}/{self.name}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.organization.slug}/{self.name}"
|
||||
def clean(self) -> None:
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Group name is required"})
|
||||
|
||||
|
||||
class EmployeeGroupMember(TenantRelationModel):
|
||||
|
||||
@@ -24,6 +24,12 @@ class InstanceSettings(models.Model):
|
||||
|
||||
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10».
|
||||
public_host = models.CharField(max_length=253, blank=True, default="")
|
||||
# Предыдущий адрес: остаётся принятым, чтобы смена адреса не выбрасывала
|
||||
# того, кто её делает. Владелец меняет адрес заранее — до того, как домен
|
||||
# начал резолвиться и получил сертификат, — и сидит при этом на старом.
|
||||
# Без этого он получал «Invalid host» через десять секунд после
|
||||
# сохранения, а мастер уже закрыт: вернуться было бы неоткуда.
|
||||
previous_public_host = models.CharField(max_length=253, blank=True, default="")
|
||||
# Схема, по которой установку открывают снаружи. Меняется вместе с
|
||||
# адресом, когда перед установкой появляется домен и сертификат.
|
||||
public_scheme = models.CharField(max_length=5, blank=True, default="")
|
||||
@@ -62,7 +68,7 @@ class InstanceSettings(models.Model):
|
||||
|
||||
_CACHE_TTL_SECONDS = 10.0
|
||||
_lock = threading.Lock()
|
||||
_cached: tuple[float, str] | None = None
|
||||
_cached: tuple[float, tuple[str, str]] | None = None
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
@@ -71,8 +77,8 @@ def invalidate_cache() -> None:
|
||||
_cached = None
|
||||
|
||||
|
||||
def public_host() -> str:
|
||||
"""Адрес установки, запомненный мастером, или пустая строка."""
|
||||
def _hosts() -> tuple[str, str]:
|
||||
"""Текущий и предыдущий адрес установки (оба могут быть пустыми)."""
|
||||
|
||||
global _cached
|
||||
now = time.monotonic()
|
||||
@@ -81,14 +87,26 @@ def public_host() -> str:
|
||||
return _cached[1]
|
||||
try:
|
||||
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
|
||||
value = row.public_host if row is not None else ""
|
||||
value = (row.public_host, row.previous_public_host) if row is not None else ("", "")
|
||||
except Exception: # таблицы ещё нет (первые миграции)
|
||||
return ""
|
||||
return ("", "")
|
||||
with _lock:
|
||||
_cached = (now, value)
|
||||
return value
|
||||
|
||||
|
||||
def public_host() -> str:
|
||||
"""Адрес установки, запомненный мастером, или пустая строка."""
|
||||
|
||||
return _hosts()[0]
|
||||
|
||||
|
||||
def accepted_hosts() -> tuple[str, ...]:
|
||||
"""Адреса, которые установка признаёт своими: текущий и предыдущий."""
|
||||
|
||||
return tuple(host for host in _hosts() if host)
|
||||
|
||||
|
||||
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
|
||||
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
|
||||
|
||||
|
||||
@@ -88,7 +88,11 @@ class InstanceAddressView(APIView):
|
||||
{"detail": next(iter(errors.values())), "errors": errors}, status=400
|
||||
)
|
||||
|
||||
fields = ["public_host", "public_scheme", "updated_at"]
|
||||
fields = ["public_host", "public_scheme", "previous_public_host", "updated_at"]
|
||||
if host != row.public_host:
|
||||
# Прежний адрес остаётся принятым: владелец меняет адрес заранее,
|
||||
# сидя на старом, и не должен выпасть из установки в тот же миг.
|
||||
row.previous_public_host = row.public_host
|
||||
row.public_host = host
|
||||
row.public_scheme = scheme
|
||||
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
|
||||
#
|
||||
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
|
||||
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
|
||||
# момент выката.
|
||||
#
|
||||
# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
|
||||
# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
|
||||
# трогаем назначения: каждое действующее назначение профиля продолжает работать
|
||||
# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
|
||||
# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
|
||||
from django.db import migrations, models
|
||||
|
||||
AI_VIEW = "ai.view"
|
||||
AI_MANAGE = "ai.manage"
|
||||
CHANNELS_VIEW = "channels.view"
|
||||
CHANNELS_MANAGE = "channels.manage"
|
||||
|
||||
|
||||
def _grant(apps, *, source: str, target: str) -> None:
|
||||
AccessProfile = apps.get_model("identity", "AccessProfile")
|
||||
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
|
||||
|
||||
already_granted = set(
|
||||
AccessProfileCapability.objects.filter(capability_code=target).values_list(
|
||||
"access_profile_id", flat=True
|
||||
)
|
||||
)
|
||||
# organization_id берётся у профиля, а не выводится: триггер
|
||||
# chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.
|
||||
profiles = (
|
||||
AccessProfile.objects.filter(capability_links__capability_code=source)
|
||||
.values_list("id", "organization_id")
|
||||
.distinct()
|
||||
)
|
||||
AccessProfileCapability.objects.bulk_create(
|
||||
[
|
||||
AccessProfileCapability(
|
||||
access_profile_id=profile_id,
|
||||
organization_id=organization_id,
|
||||
capability_code=target,
|
||||
)
|
||||
for profile_id, organization_id in profiles
|
||||
if profile_id not in already_granted
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def grant_channel_capabilities(apps, schema_editor):
|
||||
_grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
|
||||
_grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
|
||||
|
||||
|
||||
def revoke_channel_capabilities(apps, schema_editor):
|
||||
# В отличие от 0010 откат обязан удалить выданные строки: следом
|
||||
# восстанавливается прежний check-constraint реестра, и строки с кодом вне
|
||||
# списка сделали бы обратную миграцию невыполнимой.
|
||||
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
|
||||
AccessProfileCapability.objects.filter(
|
||||
capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('identity', '0015_organization_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='accessprofilecapability',
|
||||
name='access_profile_capability_registry',
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='accessprofilecapability',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
|
||||
),
|
||||
# Порядок важен: при откате Django исполняет операции в обратном порядке,
|
||||
# поэтому строки удаляются до восстановления старого constraint.
|
||||
migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
|
||||
]
|
||||
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
|
||||
#
|
||||
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
|
||||
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
|
||||
# момент выката.
|
||||
#
|
||||
# Scope в этой модели живёт на `EmployeeAccessAssignment`, а capability — на
|
||||
# `AccessProfile`. Поэтому «с тем же scope» достигается тем, что мы вообще не
|
||||
# трогаем назначения: каждое действующее назначение профиля продолжает работать
|
||||
# со своим scope. Обе новые capability допускают ORGANIZATION и DEPARTMENT, так
|
||||
# что набор допустимых scope профиля (`allowed_profile_scopes`) не сужается.
|
||||
from django.db import migrations, models
|
||||
|
||||
AI_VIEW = "ai.view"
|
||||
AI_MANAGE = "ai.manage"
|
||||
CHANNELS_VIEW = "channels.view"
|
||||
CHANNELS_MANAGE = "channels.manage"
|
||||
|
||||
|
||||
def _grant(apps, *, source: str, target: str) -> None:
|
||||
AccessProfile = apps.get_model("identity", "AccessProfile")
|
||||
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
|
||||
|
||||
already_granted = set(
|
||||
AccessProfileCapability.objects.filter(capability_code=target).values_list(
|
||||
"access_profile_id", flat=True
|
||||
)
|
||||
)
|
||||
# organization_id берётся у профиля, а не выводится: триггер
|
||||
# chatballs.enforce_tenant_fk требует совпадения с владельцем профиля.
|
||||
profiles = (
|
||||
AccessProfile.objects.filter(capability_links__capability_code=source)
|
||||
.values_list("id", "organization_id")
|
||||
.distinct()
|
||||
)
|
||||
AccessProfileCapability.objects.bulk_create(
|
||||
[
|
||||
AccessProfileCapability(
|
||||
access_profile_id=profile_id,
|
||||
organization_id=organization_id,
|
||||
capability_code=target,
|
||||
)
|
||||
for profile_id, organization_id in profiles
|
||||
if profile_id not in already_granted
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def grant_channel_capabilities(apps, schema_editor):
|
||||
_grant(apps, source=AI_VIEW, target=CHANNELS_VIEW)
|
||||
_grant(apps, source=AI_MANAGE, target=CHANNELS_MANAGE)
|
||||
|
||||
|
||||
def revoke_channel_capabilities(apps, schema_editor):
|
||||
# В отличие от 0010 откат обязан удалить выданные строки: следом
|
||||
# восстанавливается прежний check-constraint реестра, и строки с кодом вне
|
||||
# списка сделали бы обратную миграцию невыполнимой.
|
||||
AccessProfileCapability = apps.get_model("identity", "AccessProfileCapability")
|
||||
AccessProfileCapability.objects.filter(
|
||||
capability_code__in=(CHANNELS_VIEW, CHANNELS_MANAGE)
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('identity', '0015_organization_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='accessprofilecapability',
|
||||
name='access_profile_capability_registry',
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='accessprofilecapability',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('capability_code__in', ['company.view', 'company.manage', 'departments.view', 'departments.manage', 'employees.view', 'employees.manage', 'products.view', 'products.manage', 'channels.view', 'channels.manage', 'ai.view', 'ai.manage', 'ai.publish', 'integrations.view', 'integrations.manage', 'secrets.manage', 'settings.view', 'settings.manage', 'audit.view', 'conversations.view', 'conversations.operate', 'conversations.call', 'customers.view', 'customers.manage', 'sales.view', 'sales.operate', 'sales.correct', 'sales_sources.manage', 'support.view', 'support.operate', 'notifications.manage'])), name='access_profile_capability_registry'),
|
||||
),
|
||||
# Порядок важен: при откате Django исполняет операции в обратном порядке,
|
||||
# поэтому строки удаляются до восстановления старого constraint.
|
||||
migrations.RunPython(grant_channel_capabilities, revoke_channel_capabilities),
|
||||
]
|
||||
+173
-173
@@ -1,173 +1,173 @@
|
||||
# Generated by Django 5.2.15 on 2026-09-03 22:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations, models
|
||||
|
||||
# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
|
||||
# грантов, которые исходно раздавала tenancy/0003 (она при откате не
|
||||
# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
|
||||
# читать identity_department и migration-тесты падают на старых состояниях.
|
||||
# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
|
||||
# откате (операции разворачиваются в обратном порядке), когда таблицы уже
|
||||
# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
|
||||
# тестами не используется, поэтому политики не восстанавливаем.
|
||||
_RESTORE_GRANTS_SQL = "\n".join(
|
||||
f"""
|
||||
ALTER TABLE {table} OWNER TO chatballs_schema;
|
||||
GRANT ALL ON {table} TO chatballs_schema;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
|
||||
"""
|
||||
for table in (
|
||||
"identity_department",
|
||||
"identity_accessprofile",
|
||||
"identity_accessprofilecapability",
|
||||
"identity_employeeaccessassignment",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0014_remove_knowledgedepartment_department_and_more'),
|
||||
('channels', '0006_remove_channel_department'),
|
||||
('identity', '0019_drop_sales_capabilities'),
|
||||
('notifications', '0008_remove_notification_department'),
|
||||
('support_portals', '0008_remove_supportportal_department'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
|
||||
migrations.CreateModel(
|
||||
name='EmployeeGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EmployeeGroupMember',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
# Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
|
||||
# state хранит constraint на несуществующее поле и обратная миграция
|
||||
# (reverse DeleteModel в migration-тестах) падает при create_model.
|
||||
migrations.RemoveConstraint(
|
||||
model_name='accessprofile',
|
||||
name='uniq_access_profile_org_name_ci',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='accessprofilecapability',
|
||||
name='uniq_access_profile_capability',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='department',
|
||||
name='uniq_department_org_code',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeeaccessassignment',
|
||||
name='access_assignment_scope_department',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeeaccessassignment',
|
||||
name='uniq_active_org_access_assignment',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeeaccessassignment',
|
||||
name='uniq_active_dept_access_assignment',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessprofile',
|
||||
name='organization',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessprofilecapability',
|
||||
name='access_profile',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='access_profile',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessprofilecapability',
|
||||
name='organization',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='department',
|
||||
name='organization',
|
||||
),
|
||||
# Сначала снимаем constraint, зависящий от primary_department: дроп
|
||||
# колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
|
||||
migrations.RemoveConstraint(
|
||||
model_name='organizationmembership',
|
||||
name='owner_is_company_level',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizationmembership',
|
||||
name='primary_department',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='department',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='assigned_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='employee',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='organization',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroup',
|
||||
name='organization',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroupmember',
|
||||
name='employee',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroupmember',
|
||||
name='group',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroupmember',
|
||||
name='organization',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='AccessProfile',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='AccessProfileCapability',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Department',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EmployeeAccessAssignment',
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='employeegroup',
|
||||
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='employeegroupmember',
|
||||
constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
|
||||
),
|
||||
]
|
||||
# Generated by Django 5.2.15 on 2026-09-03 22:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations, models
|
||||
|
||||
# При откате Django пересоздаёт снесённые таблицы «голыми» — без ownership и
|
||||
# грантов, которые исходно раздавала tenancy/0003 (она при откате не
|
||||
# переприменяется). SECURITY DEFINER-триггеры (enforce_tenant_fk) тогда не могут
|
||||
# читать identity_department и migration-тесты падают на старых состояниях.
|
||||
# Первый operation ниже — noop вперёд; его reverse выполняется ПОСЛЕДНИМ при
|
||||
# откате (операции разворачиваются в обратном порядке), когда таблицы уже
|
||||
# пересозданы, и возвращает им владельца и гранты. RLS на старых состояниях
|
||||
# тестами не используется, поэтому политики не восстанавливаем.
|
||||
_RESTORE_GRANTS_SQL = "\n".join(
|
||||
f"""
|
||||
ALTER TABLE {table} OWNER TO chatballs_schema;
|
||||
GRANT ALL ON {table} TO chatballs_schema;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO chatballs_runtime_app;
|
||||
"""
|
||||
for table in (
|
||||
"identity_department",
|
||||
"identity_accessprofile",
|
||||
"identity_accessprofilecapability",
|
||||
"identity_employeeaccessassignment",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0014_remove_knowledgedepartment_department_and_more'),
|
||||
('channels', '0006_remove_channel_department'),
|
||||
('identity', '0019_drop_sales_capabilities'),
|
||||
('notifications', '0008_remove_notification_department'),
|
||||
('support_portals', '0008_remove_supportportal_department'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(migrations.RunSQL.noop, _RESTORE_GRANTS_SQL),
|
||||
migrations.CreateModel(
|
||||
name='EmployeeGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EmployeeGroupMember',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
# Снимаем constraint'ы удаляемых моделей до удаления их полей: иначе
|
||||
# state хранит constraint на несуществующее поле и обратная миграция
|
||||
# (reverse DeleteModel в migration-тестах) падает при create_model.
|
||||
migrations.RemoveConstraint(
|
||||
model_name='accessprofile',
|
||||
name='uniq_access_profile_org_name_ci',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='accessprofilecapability',
|
||||
name='uniq_access_profile_capability',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='department',
|
||||
name='uniq_department_org_code',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeeaccessassignment',
|
||||
name='access_assignment_scope_department',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeeaccessassignment',
|
||||
name='uniq_active_org_access_assignment',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='employeeaccessassignment',
|
||||
name='uniq_active_dept_access_assignment',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessprofile',
|
||||
name='organization',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessprofilecapability',
|
||||
name='access_profile',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='access_profile',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accessprofilecapability',
|
||||
name='organization',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='department',
|
||||
name='organization',
|
||||
),
|
||||
# Сначала снимаем constraint, зависящий от primary_department: дроп
|
||||
# колонки удалил бы его каскадно и RemoveConstraint ниже упал бы.
|
||||
migrations.RemoveConstraint(
|
||||
model_name='organizationmembership',
|
||||
name='owner_is_company_level',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizationmembership',
|
||||
name='primary_department',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='department',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='assigned_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='employee',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='employeeaccessassignment',
|
||||
name='organization',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroup',
|
||||
name='organization',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_groups', to='identity.organization'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroupmember',
|
||||
name='employee',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_links', to='identity.organizationmembership'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroupmember',
|
||||
name='group',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_links', to='identity.employeegroup'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeegroupmember',
|
||||
name='organization',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='identity.organization'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='AccessProfile',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='AccessProfileCapability',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Department',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EmployeeAccessAssignment',
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='employeegroup',
|
||||
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('organization'), name='uniq_employee_group_org_name_ci'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='employeegroupmember',
|
||||
constraint=models.UniqueConstraint(fields=('group', 'employee'), name='uniq_employee_group_member'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Прежний адрес установки остаётся принятым после смены адреса.
|
||||
|
||||
Владелец меняет адрес в «Настройках» заранее — до того, как новый домен начал
|
||||
резолвиться и получил сертификат, — и сидит при этом на старом. Пока принятым
|
||||
был только новый адрес, сохранение выбрасывало его из установки через десять
|
||||
секунд (TTL кэша), а мастер первого запуска уже закрыт: вернуться было неоткуда.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0032_remove_organization_tax_regime_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="instancesettings",
|
||||
name="previous_public_host",
|
||||
field=models.CharField(blank=True, default="", max_length=253),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Колонка вмещает шифротекст, а не открытое значение.
|
||||
|
||||
``EncryptedCharField`` хранит Fernet-токен: версия, метка времени, IV, подпись,
|
||||
дополнение до блока AES и base64 поверх всего. Значение в 512 символов
|
||||
занимает почти 2.9 КБ, и в ``varchar(512)`` не помещалось — длинный пароль
|
||||
SMTP или ключ S3 ронял запись уже в базе. ``max_length`` поля по-прежнему
|
||||
описывает открытое значение; ширину столбца считает
|
||||
``chatballs.identity.crypto.ciphertext_length``.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
import chatballs.identity.crypto
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0033_instance_previous_public_host"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="humanuser",
|
||||
name="totp_secret",
|
||||
field=chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1444),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="instancesettings",
|
||||
name="email_password",
|
||||
field=chatballs.identity.crypto.EncryptedCharField(
|
||||
blank=True, default="", max_length=2828
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -16,7 +16,7 @@ from chatballs.identity.crypto import EncryptedCharField
|
||||
class HumanUserManager(UserManager):
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, email: str, password: str | None, **extra_fields: object) -> "HumanUser":
|
||||
def _create_user(self, email: str, password: str | None, **extra_fields: object) -> HumanUser:
|
||||
if not email:
|
||||
raise ValueError("The email must be set")
|
||||
email = self.normalize_email(email)
|
||||
@@ -25,12 +25,12 @@ class HumanUserManager(UserManager):
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email: str, password: str | None = None, **extra_fields: object) -> "HumanUser":
|
||||
def create_user(self, email: str, password: str | None = None, **extra_fields: object) -> HumanUser:
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email: str, password: str | None = None, **extra_fields: object) -> "HumanUser":
|
||||
def create_superuser(self, email: str, password: str | None = None, **extra_fields: object) -> HumanUser:
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Границы, которые обходились через соседний эндпоинт.
|
||||
|
||||
Два места вели себя не так, как обещает соседняя ручка того же экрана:
|
||||
|
||||
- начало настройки 2FA молча выключало уже включённую, минуя и пароль, и
|
||||
требование организации, — то есть было бесплатным способом снять 2FA для
|
||||
того, у кого уже есть чужая сессия;
|
||||
- сброс пароля по письму оставлял чужие сессии живыми, хотя пароль сбрасывают
|
||||
как раз тогда, когда доступ мог оказаться у чужого.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.test import TestCase
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
|
||||
from chatballs.identity.auth.totp_utils import _generate_totp_secret
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
PASSWORD = "Owner-Password-2026!"
|
||||
NEW_PASSWORD = "Owner-Password-2027!"
|
||||
|
||||
|
||||
class TotpSetupStartTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="totp-owner@example.com", password=PASSWORD)
|
||||
self.owner = self.result.owner
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.owner)
|
||||
|
||||
def test_start_issues_a_secret_while_totp_is_off(self) -> None:
|
||||
self.owner.totp_secret = "OLDSECRET"
|
||||
self.owner.save(update_fields=["totp_secret"])
|
||||
|
||||
response = self.client.post("/api/v1/auth/profile/totp/start/")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.owner.refresh_from_db()
|
||||
self.assertEqual(self.owner.totp_secret, "")
|
||||
self.assertFalse(self.owner.totp_enabled)
|
||||
|
||||
def test_start_cannot_disable_enabled_totp(self) -> None:
|
||||
secret = _generate_totp_secret()
|
||||
self.owner.totp_secret = secret
|
||||
self.owner.totp_enabled = True
|
||||
self.owner.save(update_fields=["totp_secret", "totp_enabled"])
|
||||
|
||||
response = self.client.post("/api/v1/auth/profile/totp/start/")
|
||||
|
||||
self.assertEqual(response.status_code, 409, response.content)
|
||||
self.owner.refresh_from_db()
|
||||
self.assertTrue(self.owner.totp_enabled)
|
||||
self.assertEqual(self.owner.totp_secret, secret)
|
||||
|
||||
def test_disable_still_requires_the_current_password(self) -> None:
|
||||
self.owner.totp_secret = _generate_totp_secret()
|
||||
self.owner.totp_enabled = True
|
||||
self.owner.save(update_fields=["totp_secret", "totp_enabled"])
|
||||
|
||||
rejected = self.client.post(
|
||||
"/api/v1/auth/profile/totp/disable/",
|
||||
{"currentPassword": "wrong-password"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(rejected.status_code, 400, rejected.content)
|
||||
|
||||
accepted = self.client.post(
|
||||
"/api/v1/auth/profile/totp/disable/",
|
||||
{"currentPassword": PASSWORD},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(accepted.status_code, 200, accepted.content)
|
||||
self.owner.refresh_from_db()
|
||||
self.assertFalse(self.owner.totp_enabled)
|
||||
|
||||
|
||||
class PasswordResetSessionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="reset-owner@example.com", password=PASSWORD)
|
||||
self.owner = self.result.owner
|
||||
|
||||
def _reset_link(self) -> tuple[str, str]:
|
||||
# Токен считается от текущего состояния пользователя (в том числе
|
||||
# last_login), поэтому берём его после всех входов.
|
||||
self.owner.refresh_from_db()
|
||||
return (
|
||||
urlsafe_base64_encode(force_bytes(self.owner.pk)),
|
||||
default_token_generator.make_token(self.owner),
|
||||
)
|
||||
|
||||
def _session_keys(self) -> set[str]:
|
||||
keys = set()
|
||||
for session in Session.objects.all():
|
||||
if str(session.get_decoded().get("_auth_user_id", "")) == str(self.owner.pk):
|
||||
keys.add(session.session_key)
|
||||
return keys
|
||||
|
||||
def test_reset_revokes_existing_sessions(self) -> None:
|
||||
stolen = TenantAPIClient()
|
||||
self.assertTrue(stolen.login(email=self.owner.email, password=PASSWORD))
|
||||
self.assertTrue(self._session_keys())
|
||||
|
||||
uid, token = self._reset_link()
|
||||
anonymous = TenantAPIClient()
|
||||
response = anonymous.post(
|
||||
"/api/v1/auth/password-reset/confirm/",
|
||||
{"uid": uid, "token": token, "newPassword": NEW_PASSWORD},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertGreaterEqual(response.json()["revoked"], 1)
|
||||
self.assertEqual(self._session_keys(), set())
|
||||
|
||||
# Прежняя сессия больше не открывает приложение.
|
||||
session = stolen.get("/api/v1/auth/session/")
|
||||
self.assertFalse(session.json()["authenticated"])
|
||||
|
||||
def test_reset_still_sets_the_new_password(self) -> None:
|
||||
uid, token = self._reset_link()
|
||||
TenantAPIClient().post(
|
||||
"/api/v1/auth/password-reset/confirm/",
|
||||
{"uid": uid, "token": token, "newPassword": NEW_PASSWORD},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.owner.refresh_from_db()
|
||||
self.assertTrue(self.owner.check_password(NEW_PASSWORD))
|
||||
self.assertFalse(self.owner.must_change_password)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Шифротекст должен помещаться в колонку.
|
||||
|
||||
``EncryptedCharField`` кладёт в базу Fernet-токен: версия, метка времени, IV,
|
||||
подпись, дополнение до блока AES и base64 поверх всего. Значение в 512
|
||||
символов занимает почти 2.9 КБ — в varchar(512) оно не помещалось, и длинный
|
||||
пароль SMTP или ключ S3 ронял запись уже в базе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity.crypto import ciphertext_length, decrypt_secret, encrypt_secret
|
||||
from chatballs.identity.instance_settings import InstanceSettings
|
||||
from chatballs.tenancy.storage_settings import StorageSettings
|
||||
|
||||
|
||||
class CiphertextLengthTests(TestCase):
|
||||
def test_estimate_covers_the_real_token(self) -> None:
|
||||
for plaintext_chars in (1, 16, 255, 512, 1024):
|
||||
value = "щ" * plaintext_chars # 2 байта на символ в UTF-8
|
||||
self.assertLessEqual(
|
||||
len(encrypt_secret(value)),
|
||||
ciphertext_length(plaintext_chars),
|
||||
f"оценка мала для {plaintext_chars} символов",
|
||||
)
|
||||
|
||||
def test_round_trip_survives(self) -> None:
|
||||
value = "п" * 400
|
||||
self.assertEqual(decrypt_secret(encrypt_secret(value)), value)
|
||||
|
||||
|
||||
class EncryptedColumnWidthTests(TestCase):
|
||||
def test_long_smtp_password_is_stored(self) -> None:
|
||||
password = "Пароль-" + "x" * 500
|
||||
row = InstanceSettings.load()
|
||||
row.email_host = "smtp.example.test"
|
||||
row.email_password = password
|
||||
row.save(update_fields=["email_host", "email_password", "updated_at"])
|
||||
|
||||
row.refresh_from_db()
|
||||
self.assertEqual(row.email_password, password)
|
||||
|
||||
def test_long_s3_keys_are_stored(self) -> None:
|
||||
secret = "S" * 500
|
||||
row = StorageSettings.load()
|
||||
row.s3_access_key = "A" * 500
|
||||
row.s3_secret_key = secret
|
||||
row.save(update_fields=["s3_access_key", "s3_secret_key"])
|
||||
|
||||
row.refresh_from_db()
|
||||
self.assertEqual(row.s3_secret_key, secret)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Адрес установки: смена не должна выбрасывать того, кто её делает.
|
||||
|
||||
Владелец меняет адрес заранее — до того, как новый домен начал резолвиться и
|
||||
получил сертификат, — и сидит при этом на старом. Пока принятым был только
|
||||
новый адрес, сохранение отвечало «Invalid host» через десять секунд (TTL
|
||||
кэша), а мастер первого запуска уже закрыт: вернуться было неоткуда.
|
||||
|
||||
Здесь же — запрет вешать портал помощи на адрес самой установки: SPA решает,
|
||||
что рисовать, по ответу ``/api/v1/help/``, и такой портал подменял бы
|
||||
сотрудникам приложение своим Help Center.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.instance_settings import (
|
||||
InstanceSettings,
|
||||
accepted_hosts,
|
||||
invalidate_cache,
|
||||
)
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
PASSWORD = "Owner-Password-2026!"
|
||||
|
||||
|
||||
class InstanceAddressChangeTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="address-owner@example.com", password=PASSWORD)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.result.owner)
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = "203.0.113.10"
|
||||
row.public_scheme = "http"
|
||||
row.previous_public_host = ""
|
||||
row.save(update_fields=["public_host", "public_scheme", "previous_public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
|
||||
def _patch(self, host: str, scheme: str = "https"):
|
||||
return self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
{"publicHost": host, "publicScheme": scheme},
|
||||
format="json",
|
||||
)
|
||||
|
||||
def test_previous_address_stays_accepted(self) -> None:
|
||||
response = self._patch("crm.example.test")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
invalidate_cache()
|
||||
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
|
||||
|
||||
def test_old_address_still_answers_after_the_change(self) -> None:
|
||||
self._patch("crm.example.test")
|
||||
invalidate_cache()
|
||||
|
||||
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="203.0.113.10")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_stranger_host_is_still_rejected(self) -> None:
|
||||
self._patch("crm.example.test")
|
||||
invalidate_cache()
|
||||
|
||||
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_only_one_previous_address_is_kept(self) -> None:
|
||||
self._patch("first.example.test")
|
||||
self._patch("second.example.test")
|
||||
invalidate_cache()
|
||||
|
||||
self.assertEqual(set(accepted_hosts()), {"second.example.test", "first.example.test"})
|
||||
|
||||
def test_saving_the_same_address_does_not_shift_history(self) -> None:
|
||||
self._patch("crm.example.test")
|
||||
self._patch("crm.example.test")
|
||||
invalidate_cache()
|
||||
|
||||
self.assertEqual(set(accepted_hosts()), {"crm.example.test", "203.0.113.10"})
|
||||
|
||||
|
||||
class PortalDomainCollisionTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="portal-owner@example.com", password=PASSWORD)
|
||||
row = InstanceSettings.load()
|
||||
row.public_host = "crm.example.test"
|
||||
row.previous_public_host = "203.0.113.10"
|
||||
row.save(update_fields=["public_host", "previous_public_host", "updated_at"])
|
||||
invalidate_cache()
|
||||
self.addCleanup(invalidate_cache)
|
||||
|
||||
def _portal(self, custom_domain: str) -> SupportPortal:
|
||||
return SupportPortal(
|
||||
organization=self.result.organization,
|
||||
slug="help",
|
||||
name="Help",
|
||||
custom_domain=custom_domain,
|
||||
)
|
||||
|
||||
def test_installation_address_cannot_become_a_portal_domain(self) -> None:
|
||||
with self.assertRaises(ValidationError) as error:
|
||||
self._portal("crm.example.test").clean()
|
||||
|
||||
self.assertIn("custom_domain", error.exception.message_dict)
|
||||
|
||||
def test_previous_installation_address_is_also_refused(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
self._portal("203.0.113.10").clean()
|
||||
|
||||
def test_unrelated_domain_is_allowed(self) -> None:
|
||||
portal = self._portal("help.example.test")
|
||||
|
||||
portal.clean() # не должно бросать
|
||||
|
||||
self.assertEqual(portal.custom_domain, "help.example.test")
|
||||
@@ -17,6 +17,8 @@ from chatballs.identity.models import (
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
|
||||
|
||||
def _pending_org(slug: str = "pending-org") -> Organization:
|
||||
return Organization.objects.create(
|
||||
name="Pending Org",
|
||||
|
||||
@@ -8,7 +8,6 @@ import tempfile
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.test import TestCase, override_settings
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment
|
||||
from chatballs.conversations.models import (
|
||||
@@ -27,6 +26,7 @@ from chatballs.identity.models import HumanUser, Organization, OrganizationMembe
|
||||
from chatballs.identity.setup import SetupInput, complete_setup
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-demo-media-")
|
||||
|
||||
|
||||
@@ -9,15 +9,14 @@ from django.core import mail
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.auth.totp_utils import (
|
||||
TOTP_CHALLENGE_TTL_SECONDS,
|
||||
TOTP_STARTED_KEY,
|
||||
_totp_code,
|
||||
)
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeGroup,
|
||||
@@ -27,6 +26,7 @@ from chatballs.identity.models import (
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.identity.policy import ResourceScope, authorize
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from chatballs.integrations.models import Integration
|
||||
|
||||
|
||||
@admin.register(Integration)
|
||||
class IntegrationAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "provider", "kind", "status", "last_checked_at")
|
||||
list_filter = ("provider", "kind", "status")
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
|
||||
from django.contrib import admin
|
||||
|
||||
from chatballs.integrations.models import Integration
|
||||
|
||||
|
||||
@admin.register(Integration)
|
||||
class IntegrationAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "provider", "kind", "status", "last_checked_at")
|
||||
list_filter = ("provider", "kind", "status")
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ("last_checked_at", "last_error", "created_at", "updated_at")
|
||||
@@ -1,8 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "integrations"
|
||||
name = "chatballs.integrations"
|
||||
verbose_name = "Integrations: providers and connections"
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "integrations"
|
||||
name = "chatballs.integrations"
|
||||
verbose_name = "Integrations: providers and connections"
|
||||
@@ -1,38 +1,38 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-27 21:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import chatballs.identity.crypto
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('identity', '0006_alter_employeeprofile_totp_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Integration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
|
||||
('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
|
||||
('config', models.JSONField(blank=True, default=dict)),
|
||||
('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
|
||||
('last_checked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['provider', 'name'],
|
||||
'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
|
||||
},
|
||||
),
|
||||
]
|
||||
# Generated by Django 5.2.15 on 2026-06-27 21:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import chatballs.identity.crypto
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('identity', '0006_alter_employeeprofile_totp_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Integration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('kind', models.CharField(choices=[('LLM_PROVIDER', 'LLM-провайдер'), ('MESSENGER', 'Подключение-мессенджер')], max_length=16)),
|
||||
('provider', models.CharField(choices=[('OPENROUTER', 'OpenRouter'), ('MAX', 'MAX'), ('TELEGRAM', 'Telegram'), ('WEB', 'Web-виджет')], max_length=16)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('secret', chatballs.identity.crypto.EncryptedCharField(blank=True, max_length=1024)),
|
||||
('config', models.JSONField(blank=True, default=dict)),
|
||||
('status', models.CharField(choices=[('UNCHECKED', 'Не проверено'), ('OK', 'Подключено'), ('ERROR', 'Ошибка')], default='UNCHECKED', max_length=16)),
|
||||
('last_checked_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='integrations', to='identity.organization')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['provider', 'name'],
|
||||
'constraints': [models.UniqueConstraint(fields=('organization', 'provider', 'name'), name='uniq_integration_org_provider_name')],
|
||||
},
|
||||
),
|
||||
]
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user