mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab26a1bcd | ||
|
|
09fb0177b1 | ||
|
|
b76871caf0 | ||
|
|
8580b480e7 | ||
|
|
eff6d7ac0c | ||
|
|
1cff9e2183 | ||
|
|
d8420e4379 | ||
|
|
21b8100598 | ||
|
|
1da84c9734 | ||
|
|
82805f7b1a | ||
|
|
eca0e52667 | ||
|
|
54c35e281a | ||
|
|
5396dda8ad | ||
|
|
29164db094 | ||
|
|
c5a53ccec5 | ||
|
|
049217505a | ||
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 | ||
|
|
a4ca7e9373 | ||
|
|
145c44a17d | ||
|
|
ac824e192c | ||
|
|
3e790af6e5 | ||
|
|
1390ccc426 | ||
|
|
2e52bdaea0 |
No files matched your search
@@ -23,9 +23,9 @@ jobs:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -39,12 +39,13 @@ env:
|
||||
GATEWAY_BASE_IMAGE: caddy:2.8.4
|
||||
REDIS_IMAGE: redis:7-alpine
|
||||
COTURN_IMAGE: coturn/coturn:4.6
|
||||
UPDATER_BASE_IMAGE: docker:27-cli
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Версия релиза
|
||||
id: version
|
||||
@@ -57,9 +58,9 @@ jobs:
|
||||
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -78,13 +79,16 @@ jobs:
|
||||
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Backend
|
||||
id: backend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
|
||||
build-args: |
|
||||
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
|
||||
@@ -94,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Frontend
|
||||
id: frontend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
|
||||
@@ -109,7 +113,7 @@ jobs:
|
||||
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
|
||||
- name: Gateway
|
||||
id: gateway
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
|
||||
@@ -124,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Postgres
|
||||
id: postgres
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
|
||||
@@ -137,6 +141,23 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
|
||||
# и два сценария внутри; единственный, кому монтируется docker.sock.
|
||||
- name: Updater
|
||||
id: updater
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
|
||||
build-args: |
|
||||
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Релизный compose.yaml и release.env
|
||||
id: artifacts
|
||||
run: |
|
||||
@@ -148,6 +169,7 @@ jobs:
|
||||
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
|
||||
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
|
||||
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
|
||||
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
|
||||
redis="${{ steps.bases.outputs.redis }}"
|
||||
coturn="${{ steps.bases.outputs.coturn }}"
|
||||
|
||||
@@ -160,7 +182,8 @@ jobs:
|
||||
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
|
||||
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
|
||||
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
|
||||
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
|
||||
{
|
||||
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
|
||||
@@ -172,6 +195,7 @@ jobs:
|
||||
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
|
||||
echo "CHATBALLS_REDIS_IMAGE=$redis"
|
||||
echo "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
echo "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
} > dist/release.env
|
||||
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
@@ -180,7 +204,7 @@ jobs:
|
||||
|
||||
cat dist/compose.yaml
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-compose
|
||||
path: |
|
||||
@@ -189,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Приложить к релизу
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/compose.yaml
|
||||
|
||||
@@ -16,7 +16,7 @@ Production deployment / миграция:
|
||||
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
|
||||
|
||||
UI / дизайн:
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
|
||||
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
|
||||
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
|
||||
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
|
||||
|
||||
@@ -132,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
|
||||
|
||||
### Updating
|
||||
|
||||
Download the new release's `compose.yaml` over the old one and restart:
|
||||
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
|
||||
|
||||
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -311,13 +313,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
|
||||
<details>
|
||||
<summary><strong>Do not delete the secrets volume</strong></summary>
|
||||
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Backup</strong></summary>
|
||||
|
||||
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
|
||||
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
|
||||
+5
-3
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -311,13 +313,13 @@ docker compose ps
|
||||
<details>
|
||||
<summary><strong>Не удаляйте том с секретами</strong></summary>
|
||||
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Резервная копия</strong></summary>
|
||||
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
|
||||
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Версия релиза попадает в образ при сборке: по ней приложение решает,
|
||||
# есть ли обновление. Сборка из исходников остаётся «dev».
|
||||
ARG CHATBALLS_VERSION=dev
|
||||
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
|
||||
|
||||
RUN addgroup --system hub && adduser --system --ingroup hub hub
|
||||
|
||||
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
|
||||
@@ -128,7 +128,6 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
|
||||
"model": agent.model,
|
||||
"providerIntegrationId": agent.provider_integration_id,
|
||||
"modelParams": agent.model_params,
|
||||
"limits": agent.limits,
|
||||
"answerLanguage": agent.answer_language,
|
||||
"persona": agent.persona,
|
||||
"tone": agent.tone,
|
||||
@@ -232,7 +231,6 @@ def update_agent_card(
|
||||
ai_fields = {
|
||||
"providerIntegrationId",
|
||||
"modelParams",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
@@ -249,11 +247,8 @@ def update_agent_card(
|
||||
):
|
||||
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
|
||||
model_params = body.get("modelParams", agent.model_params)
|
||||
limits = body.get("limits", agent.limits)
|
||||
if not isinstance(model_params, dict):
|
||||
raise ValidationError({"modelParams": t("api.object_required")})
|
||||
if not isinstance(limits, dict):
|
||||
raise ValidationError({"limits": t("api.object_required")})
|
||||
provider_integration_id = body.get(
|
||||
"providerIntegrationId", agent.provider_integration_id
|
||||
)
|
||||
@@ -270,7 +265,6 @@ def update_agent_card(
|
||||
provider_integration_id=provider_integration_id,
|
||||
model_params=model_params,
|
||||
allowed_tools=agent.allowed_tools,
|
||||
limits=limits,
|
||||
persona=str(body.get("persona", agent.persona)),
|
||||
tone=str(body.get("tone", agent.tone)),
|
||||
instructions=str(body.get("instructions", agent.instructions)),
|
||||
|
||||
@@ -4,10 +4,10 @@ from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.ai.models import KnowledgeAttachment
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import attachment_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
class AttachmentDownloadView(APIView):
|
||||
@@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView):
|
||||
route = attachment_route(str(public_id))
|
||||
if route is None:
|
||||
raise Http404
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist as error:
|
||||
raise Http404 from error
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise Http404
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
attachment = KnowledgeAttachment.objects.filter(
|
||||
|
||||
@@ -2,7 +2,6 @@ import time
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.ai import limits, pricing
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
from chatballs.ai.pii import redact
|
||||
from chatballs.ai.provider import routing
|
||||
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
|
||||
_breaker = CircuitBreaker()
|
||||
|
||||
|
||||
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
operation="chat",
|
||||
model=model,
|
||||
status=LlmInvocationStatus.BLOCKED,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
|
||||
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
|
||||
# организации с fallback на модель агента. Без интеграции модель остаётся
|
||||
@@ -52,18 +39,7 @@ def invoke_chat(
|
||||
params: dict | None = None,
|
||||
used_fragment_ids: list | None = None,
|
||||
) -> ChatResult:
|
||||
fallback_model = model or channel.ai_agent.model
|
||||
try:
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
limits.assert_within_limits(channel, channel.ai_agent)
|
||||
except limits.LimitExceeded as error:
|
||||
_record_blocked(
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
model=fallback_model,
|
||||
error=error,
|
||||
)
|
||||
raise
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
|
||||
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
|
||||
started = time.monotonic()
|
||||
@@ -96,8 +72,6 @@ def invoke_chat(
|
||||
prompt_tokens=result.prompt_tokens,
|
||||
completion_tokens=result.completion_tokens,
|
||||
total_tokens=result.total_tokens,
|
||||
cost_micros=result.cost_micros
|
||||
or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
used_fragment_ids=used_fragment_ids or [],
|
||||
@@ -128,7 +102,6 @@ def embed_texts(
|
||||
model=model,
|
||||
prompt_tokens=tokens,
|
||||
total_tokens=tokens,
|
||||
cost_micros=pricing.cost_micros(model, tokens, 0),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
return results
|
||||
@@ -1,32 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
|
||||
|
||||
class LimitExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _day_start():
|
||||
now = timezone.localtime()
|
||||
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def daily_cost_micros(channel=None) -> int:
|
||||
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
|
||||
if channel is not None:
|
||||
queryset = queryset.filter(channel=channel)
|
||||
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
|
||||
|
||||
|
||||
def assert_within_limits(channel, agent) -> None:
|
||||
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
|
||||
if global_limit and daily_cost_micros() >= global_limit:
|
||||
raise LimitExceeded("Global daily AI cost limit reached")
|
||||
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
|
||||
# в micro-USD. 1 цент = 10 000 micro-USD.
|
||||
channel_limit = (agent.limits or {}).get("dailyCostUsd")
|
||||
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
|
||||
raise LimitExceeded("Channel daily AI cost limit reached")
|
||||
@@ -0,0 +1,18 @@
|
||||
# Дневной бюджет агента снят вместе с полем `limits`: расход считался по
|
||||
# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит
|
||||
# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит
|
||||
# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS).
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("ai", "0017_agent_answer_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="aiagent",
|
||||
name="limits",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена
|
||||
# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных
|
||||
# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна
|
||||
# цифра расхода в продукте не показывалась.
|
||||
#
|
||||
# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def drop_blocked_rows(apps, schema_editor):
|
||||
"""Строк со снятым статусом в журнале остаться не должно."""
|
||||
|
||||
apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("ai", "0018_remove_agent_limits"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name="llminvocation", name="cost_micros"),
|
||||
migrations.RemoveField(model_name="llminvocation", name="currency"),
|
||||
migrations.AlterField(
|
||||
model_name="llminvocation",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")],
|
||||
default="SUCCESS",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -417,10 +417,6 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
allowed_tools = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
|
||||
|
||||
limits = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -463,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
|
||||
|
||||
ERROR = "ERROR", "Ошибка"
|
||||
|
||||
BLOCKED = "BLOCKED", "Заблокировано лимитом"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -489,10 +483,6 @@ class LlmInvocation(TenantRelationModel):
|
||||
|
||||
total_tokens = models.PositiveIntegerField(default=0)
|
||||
|
||||
cost_micros = models.PositiveBigIntegerField(default=0)
|
||||
|
||||
currency = models.CharField(max_length=3, default="USD")
|
||||
|
||||
latency_ms = models.PositiveIntegerField(default=0)
|
||||
|
||||
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.conf import settings
|
||||
|
||||
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
|
||||
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
|
||||
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
|
||||
DEFAULT_PRICING = {
|
||||
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
|
||||
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
|
||||
}
|
||||
|
||||
|
||||
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
|
||||
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
|
||||
price = table.get(model)
|
||||
if not price:
|
||||
return 0
|
||||
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
|
||||
@@ -18,9 +18,6 @@ class ChatResult:
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
|
||||
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
|
||||
cost_micros: int = 0
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
|
||||
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
|
||||
|
||||
- POST /chat/completions with {model, messages, ...}; response has
|
||||
|
||||
choices[0].message.content and usage (optionally usage.cost in USD).
|
||||
choices[0].message.content and usage (prompt/completion tokens).
|
||||
|
||||
- POST /embeddings with {model, input}; response has data[].embedding and usage.
|
||||
|
||||
@@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters
|
||||
|
||||
do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
|
||||
(name, catalog). Stdlib only — no third-party HTTP client.
|
||||
|
||||
"""
|
||||
|
||||
@@ -116,21 +116,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur
|
||||
|
||||
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
|
||||
|
||||
timeout: float, proxy_url: str = "", params: dict | None = None,
|
||||
timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult:
|
||||
|
||||
include_cost: bool = False) -> ChatResult:
|
||||
|
||||
"""POST /chat/completions and parse the OpenAI-shaped response.
|
||||
|
||||
|
||||
|
||||
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
|
||||
|
||||
usage.cost (USD, converted to micros). Providers that do not report cost
|
||||
|
||||
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
|
||||
|
||||
"""
|
||||
"""POST /chat/completions and parse the OpenAI-shaped response."""
|
||||
|
||||
payload: dict = {
|
||||
|
||||
@@ -142,10 +130,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
}
|
||||
|
||||
if include_cost:
|
||||
|
||||
payload["usage"] = {"include": True}
|
||||
|
||||
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
|
||||
|
||||
payload=payload, timeout=timeout, proxy_url=proxy_url)
|
||||
@@ -160,8 +144,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
usage = data.get("usage") or {}
|
||||
|
||||
cost = usage.get("cost")
|
||||
|
||||
return ChatResult(
|
||||
|
||||
text=text,
|
||||
@@ -172,8 +154,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
completion_tokens=int(usage.get("completion_tokens", 0)),
|
||||
|
||||
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
|
||||
self.proxy_url = proxy_url or ""
|
||||
|
||||
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
|
||||
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
|
||||
return openai_http.chat_completions(
|
||||
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
|
||||
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
|
||||
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
|
||||
)
|
||||
|
||||
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
|
||||
|
||||
@@ -21,7 +21,6 @@ class AgentInput:
|
||||
provider_integration_id: int | None
|
||||
model_params: dict
|
||||
allowed_tools: list
|
||||
limits: dict
|
||||
persona: str
|
||||
tone: str
|
||||
instructions: str
|
||||
@@ -39,20 +38,6 @@ class AgentCreateInput:
|
||||
knowledge_ids: list[int]
|
||||
|
||||
|
||||
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
|
||||
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
|
||||
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
|
||||
def _normalize_limits(raw: dict | None) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
value = raw.get("dailyCostUsd")
|
||||
try:
|
||||
cents = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return {"dailyCostUsd": cents} if cents > 0 else {}
|
||||
|
||||
|
||||
def knowledge_for_agent_ids(
|
||||
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
|
||||
) -> list[Knowledge]:
|
||||
@@ -146,7 +131,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
locked.provider_integration = selection.integration
|
||||
locked.model_params = data.model_params
|
||||
locked.allowed_tools = data.allowed_tools
|
||||
locked.limits = _normalize_limits(data.limits)
|
||||
locked.persona = data.persona
|
||||
locked.tone = data.tone
|
||||
locked.instructions = data.instructions
|
||||
@@ -158,7 +142,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
"provider_integration",
|
||||
"model_params",
|
||||
"allowed_tools",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
|
||||
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
|
||||
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
|
||||
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
|
||||
self.assertGreater(invocation.total_tokens, 0)
|
||||
# Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели.
|
||||
from chatballs.ai import pricing
|
||||
|
||||
self.assertEqual(
|
||||
invocation.cost_micros,
|
||||
pricing.cost_micros(
|
||||
invocation.model, invocation.prompt_tokens, invocation.completion_tokens
|
||||
),
|
||||
)
|
||||
|
||||
def test_pii_is_redacted_before_reaching_provider(self) -> None:
|
||||
from unittest import mock
|
||||
@@ -378,24 +369,6 @@ class ChatInvocationTests(TestCase):
|
||||
|
||||
self.assertNotIn("a@b.com", captured["messages"][0].content)
|
||||
|
||||
def test_limit_blocks_and_records(self) -> None:
|
||||
from chatballs.ai import limits as ai_limits
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
from chatballs.ai.provider.base import ChatMessage
|
||||
|
||||
self.agent.limits = {"dailyCostUsd": 1}
|
||||
self.agent.save(update_fields=["limits"])
|
||||
# 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros.
|
||||
LlmInvocation.objects.create(
|
||||
channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001,
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
|
||||
with self.assertRaises(ai_limits.LimitExceeded):
|
||||
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
|
||||
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
|
||||
|
||||
def test_invocation_records_used_fragment_ids(self) -> None:
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
|
||||
@@ -27,10 +27,11 @@ from chatballs.calls.tokens import (
|
||||
verify_call_access_token,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import call_invite_route, call_session_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,10 +46,9 @@ def resolve_invite(*, token: str) -> ResolvedInvite:
|
||||
route = call_invite_route(token_hash)
|
||||
if route is None:
|
||||
raise CallTokenError(message)
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(message) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(message)
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
invite = (
|
||||
@@ -92,10 +92,9 @@ def _authorize_call_access(
|
||||
route = call_session_route(str(claims.call_session_id))
|
||||
if route is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(t("calls.token_invalid")) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
resource_context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(resource_context):
|
||||
try:
|
||||
|
||||
@@ -22,8 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.realtime import conversation_group, inbox_group
|
||||
from chatballs.conversations.selectors import conversation_is_visible
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import organization_by_public_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,8 +83,10 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
@database_sync_to_async
|
||||
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
|
||||
try:
|
||||
organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id)))
|
||||
except (ValueError, Organization.DoesNotExist):
|
||||
organization = organization_by_public_id(uuid.UUID(str(raw_public_id)))
|
||||
except ValueError:
|
||||
return None
|
||||
if organization is None:
|
||||
return None
|
||||
with tenant_atomic(organization.pk):
|
||||
membership = (
|
||||
|
||||
@@ -13,7 +13,6 @@ from dataclasses import dataclass
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.channels.runtime import run_channel_turn
|
||||
@@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
|
||||
except (ProviderError, LimitExceeded) as error:
|
||||
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
|
||||
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
|
||||
# отвечаем клиенту понятным fallback.
|
||||
except ProviderError as error:
|
||||
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
|
||||
# оператору, уведомляем и отвечаем клиенту понятным fallback.
|
||||
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
|
||||
channel__is_active=True,
|
||||
).exclude(secret="")
|
||||
if integration.config.get("purpose") != "notifications"
|
||||
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
|
||||
and not integration.config.get("demoSeed")
|
||||
]
|
||||
total = 0
|
||||
for integration in integrations:
|
||||
|
||||
@@ -8,11 +8,10 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import TruncDate, TruncHour
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
from chatballs.channels.selectors import channels_in_organization
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
@@ -64,17 +63,6 @@ def _chart(
|
||||
return {"values": values, "labels": labels}
|
||||
|
||||
|
||||
def _ai_cost(
|
||||
org_id: int,
|
||||
start: datetime,
|
||||
end: datetime | None = None,
|
||||
) -> int:
|
||||
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
|
||||
if end is not None:
|
||||
qs = qs.filter(created_at__lt=end)
|
||||
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
|
||||
|
||||
|
||||
def sales_overview_stats(context, period: str) -> dict:
|
||||
organization_id = context.organization_id
|
||||
now = timezone.now()
|
||||
@@ -105,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict:
|
||||
conversation__organization_id=organization_id,
|
||||
created_at__gte=start,
|
||||
).count(),
|
||||
"aiCostMicros": _ai_cost(organization_id, start),
|
||||
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
|
||||
}
|
||||
|
||||
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
|
||||
|
||||
@@ -340,30 +340,34 @@ class ReplyTemplateTests(ChatExtrasTestCase):
|
||||
self.assertFalse(ReplyTemplate.objects.exists())
|
||||
|
||||
|
||||
class LaunchChecklistTests(TestCase):
|
||||
class OnboardingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="New", slug="launch-org")
|
||||
owner = HumanUser.objects.create_user(
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@launch.test", password="Password-123"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
self.membership = OrganizationMembership.objects.create(
|
||||
user=self.owner,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Owner",
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(owner)
|
||||
self.client.force_authenticate(self.owner)
|
||||
|
||||
def test_checklist_marks_steps_by_fact(self) -> None:
|
||||
initial = self.client.get("/api/v1/company/launch-checklist/").json()
|
||||
def test_steps_are_marked_by_fact(self) -> None:
|
||||
initial = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertEqual(
|
||||
initial,
|
||||
initial["steps"],
|
||||
{
|
||||
"agentCreated": False,
|
||||
"providerConnected": False,
|
||||
"agentActive": False,
|
||||
"knowledgeFilled": False,
|
||||
"connectionBound": False,
|
||||
"widgetPublished": False,
|
||||
"employeeInvited": False,
|
||||
"done": False,
|
||||
"platformConfigured": False,
|
||||
"firstConversation": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -393,5 +397,56 @@ class LaunchChecklistTests(TestCase):
|
||||
position_title="Operator",
|
||||
)
|
||||
|
||||
final = self.client.get("/api/v1/company/launch-checklist/").json()
|
||||
self.assertTrue(final["done"])
|
||||
final = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertTrue(final["steps"]["connectionBound"])
|
||||
self.assertTrue(final["steps"]["employeeInvited"])
|
||||
|
||||
def test_existing_member_has_not_dismissed_onboarding(self) -> None:
|
||||
"""Признак пустой у всех, кто заведён до появления онбординга."""
|
||||
|
||||
payload = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertIsNone(payload["dismissedAt"])
|
||||
self.assertIsNone(payload["completedAt"])
|
||||
|
||||
def test_dismiss_and_complete_and_restart(self) -> None:
|
||||
dismissed = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
|
||||
).json()
|
||||
self.assertIsNotNone(dismissed["dismissedAt"])
|
||||
self.assertIsNone(dismissed["completedAt"])
|
||||
|
||||
completed = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "complete"}, format="json"
|
||||
).json()
|
||||
self.assertIsNotNone(completed["completedAt"])
|
||||
|
||||
restarted = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "restart"}, format="json"
|
||||
).json()
|
||||
self.assertIsNone(restarted["dismissedAt"])
|
||||
self.assertIsNone(restarted["completedAt"])
|
||||
|
||||
def test_dismissal_is_personal(self) -> None:
|
||||
"""Закрытие одним администратором не прячет визард у второго."""
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
|
||||
)
|
||||
colleague = HumanUser.objects.create_user(
|
||||
email="admin@launch.test", password="Password-123"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=colleague,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.ADMIN,
|
||||
position_title="Admin",
|
||||
)
|
||||
other = APIClient()
|
||||
other.force_authenticate(colleague)
|
||||
self.assertIsNone(other.get("/api/v1/company/onboarding/").json()["dismissedAt"])
|
||||
|
||||
def test_unknown_action_is_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "nope"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
from chatballs.conversations.selectors import conversation_messages
|
||||
from chatballs.conversations.serializers import conversation_payload, message_payload
|
||||
from chatballs.conversations.transports import email as email_transport
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
|
||||
|
||||
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
|
||||
|
||||
messages, marker = email_transport.poll_updates(integration)
|
||||
with self.assertRaises(PollFailed):
|
||||
email_transport.poll_updates(integration)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "7:99")
|
||||
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
|
||||
self.assertEqual(integration.poll_marker, "7:99")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
|
||||
|
||||
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
|
||||
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
|
||||
первый сбой, выход на максимальную паузу и восстановление, а курсор при
|
||||
сбое не двигается. Демо-подключения не опрашиваются вовсе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import poller
|
||||
from chatballs.conversations.transports import backoff, poll
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
|
||||
class PollBackoffTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
backoff.reset()
|
||||
self.addCleanup(backoff.reset)
|
||||
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
|
||||
with tenant_atomic(self.organization.id):
|
||||
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="Bot",
|
||||
secret="0000:not-a-token",
|
||||
channel=self.channel,
|
||||
poll_marker="41",
|
||||
)
|
||||
|
||||
def _fail(self, *_args, **_kwargs):
|
||||
raise PollFailed("HTTP Error 401: Unauthorized")
|
||||
|
||||
def test_failure_is_logged_once_and_then_skipped(self) -> None:
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn("401", logs.output[0])
|
||||
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
|
||||
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
|
||||
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertTrue(backoff.should_skip(self.integration.id))
|
||||
|
||||
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
|
||||
clock = [1000.0]
|
||||
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
delays = []
|
||||
for _ in range(10):
|
||||
poll(self.integration)
|
||||
state = backoff._failures[self.integration.id]
|
||||
delays.append(state.delay)
|
||||
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
|
||||
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
|
||||
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
|
||||
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
|
||||
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "42"))
|
||||
self.assertIn("recovered", logs.output[0])
|
||||
self.assertNotIn(self.integration.id, backoff._failures)
|
||||
|
||||
def test_demo_seed_connections_are_not_polled(self) -> None:
|
||||
with tenant_atomic(self.organization.id):
|
||||
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
|
||||
context = TenantContext.for_resource(self.organization)
|
||||
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
|
||||
poller.poll_all_messengers(context)
|
||||
polled.assert_not_called()
|
||||
@@ -3,7 +3,6 @@ from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
|
||||
)
|
||||
|
||||
|
||||
class IngestLimitHandlingTests(TestCase):
|
||||
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
|
||||
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
|
||||
провайдера). См. ingest.ingest_inbound.
|
||||
class IngestProviderFailureTests(TestCase):
|
||||
"""При сбое провайдера диалог не должен «зависать»: его передают оператору с
|
||||
fallback-ответом клиенту. См. ingest.ingest_inbound.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
@@ -63,13 +61,13 @@ class IngestLimitHandlingTests(TestCase):
|
||||
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
|
||||
)
|
||||
|
||||
def test_limit_exceeded_hands_off_to_operator(self) -> None:
|
||||
def test_provider_failure_hands_off_to_operator(self) -> None:
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
|
||||
side_effect=ProviderError("provider is down"),
|
||||
),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
|
||||
):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from chatballs.conversations.transports import backoff
|
||||
from chatballs.conversations.transports import email as _email
|
||||
from chatballs.conversations.transports import max as _max
|
||||
from chatballs.conversations.transports import telegram as _telegram
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
@@ -51,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
|
||||
|
||||
|
||||
def poll(integration):
|
||||
return _POLL[integration.provider](integration)
|
||||
"""Опрос подключения с паузой после сбоя (transports.backoff).
|
||||
|
||||
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
|
||||
подключение пропускается с растущей паузой, а журнал видит только смену
|
||||
состояния. Курсор при сбое не двигается.
|
||||
"""
|
||||
|
||||
if backoff.should_skip(integration.id):
|
||||
return [], integration.poll_marker
|
||||
try:
|
||||
result = _POLL[integration.provider](integration)
|
||||
except PollFailed as error:
|
||||
backoff.record_failure(integration, error)
|
||||
return [], integration.poll_marker
|
||||
backoff.record_success(integration)
|
||||
return result
|
||||
|
||||
|
||||
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Пауза между попытками опроса подключения после сбоя.
|
||||
|
||||
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
|
||||
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
|
||||
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
|
||||
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
|
||||
подключение пропускается с растущей паузой, а в журнал попадают только
|
||||
изменения состояния: первый сбой, выход на максимальную паузу и
|
||||
восстановление.
|
||||
|
||||
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
|
||||
попытка всё равно нужна.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
|
||||
FIRST_DELAY_SECONDS = 6.0
|
||||
MAX_DELAY_SECONDS = 900.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Failure:
|
||||
failures: int
|
||||
next_attempt_at: float
|
||||
delay: float
|
||||
|
||||
|
||||
_failures: dict[int, _Failure] = {}
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def should_skip(integration_id: int) -> bool:
|
||||
state = _failures.get(integration_id)
|
||||
return state is not None and _now() < state.next_attempt_at
|
||||
|
||||
|
||||
def record_failure(integration, error: object) -> None:
|
||||
previous = _failures.get(integration.id)
|
||||
failures = (previous.failures if previous else 0) + 1
|
||||
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
|
||||
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
|
||||
if failures == 1:
|
||||
logger.warning(
|
||||
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
|
||||
integration.provider, integration.id, error, delay,
|
||||
)
|
||||
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
|
||||
logger.warning(
|
||||
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
|
||||
)
|
||||
|
||||
|
||||
def record_success(integration) -> None:
|
||||
state = _failures.pop(integration.id, None)
|
||||
if state is not None:
|
||||
logger.info(
|
||||
"%s poll recovered for integration %s after %s failure(s)",
|
||||
integration.provider, integration.id, state.failures,
|
||||
)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Для тестов: забыть все сбои."""
|
||||
|
||||
_failures.clear()
|
||||
@@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import (
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -135,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
client = _imap_connect(integration)
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
try:
|
||||
client.select("INBOX", readonly=True)
|
||||
validity = _status_value(client, "UIDVALIDITY")
|
||||
@@ -162,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
|
||||
return messages, new_marker
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class PollFailed(Exception):
|
||||
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
|
||||
|
||||
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
|
||||
записать, принимает слой пауз между попытками (``transports.backoff``).
|
||||
"""
|
||||
@@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
@@ -242,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
updates = data.get("updates") or []
|
||||
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
|
||||
new_marker = data.get("marker")
|
||||
|
||||
@@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
@@ -124,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
if not data.get("ok"):
|
||||
return [], integration.poll_marker
|
||||
updates = data.get("result") or []
|
||||
|
||||
@@ -11,10 +11,11 @@ from chatballs.conversations.poller import poll_all_messengers
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxStatus
|
||||
from chatballs.events.services import claim_next_outbox_event, mark_retry
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.notifications.binding import poll_notifier_bots
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import iter_organizations
|
||||
from chatballs.updates.services import check_for_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,7 +29,7 @@ class Command(BaseCommand):
|
||||
|
||||
@staticmethod
|
||||
def _tenant_contexts():
|
||||
for organization in Organization.objects.order_by("id").iterator():
|
||||
for organization in iter_organizations():
|
||||
yield TenantContext.for_resource(
|
||||
organization, actor_kind=TenantActorKind.SYSTEM
|
||||
)
|
||||
@@ -92,6 +93,12 @@ class Command(BaseCommand):
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
# интервал держит сама проверка по времени последнего ответа.
|
||||
try:
|
||||
check_for_updates()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Update check cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.utils import timezone
|
||||
|
||||
from chatballs.events.context import get_correlation_id
|
||||
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
|
||||
return None
|
||||
if event.organization_id is None:
|
||||
raise ValueError("Tenant event has no organization")
|
||||
organization = Organization.objects.get(pk=event.organization_id)
|
||||
organization = load_organization(event.organization_id)
|
||||
if organization is None:
|
||||
raise ValueError("Tenant event organization does not exist")
|
||||
try:
|
||||
actor_kind = TenantActorKind(event.actor_kind)
|
||||
except ValueError as error:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Choose a logo file",
|
||||
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
|
||||
"admin.employee_not_found": "Operator not found",
|
||||
"admin.group_name_taken": "A group with this name already exists",
|
||||
"admin.group_not_found": "Group not found",
|
||||
@@ -82,6 +81,8 @@ MESSAGES: dict[str, object] = {
|
||||
"emails.initial_access_subject": "Your Chatballs access",
|
||||
"emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.",
|
||||
"emails.password_reset_subject": "Chatballs access recovery",
|
||||
"emails.membership_invitation_subject": "Invitation to {organization}",
|
||||
"emails.membership_invitation_body": "Hello, {name}.\n\nYou are invited to join the organization \u201c{organization}\u201d in Chatballs. To accept the invitation, follow this link:\n{url}\n\nThe link is valid for 7 days. If you did not expect this e-mail, simply ignore it.",
|
||||
"identity.link_invalid_or_expired": "The link is not valid or has expired",
|
||||
"identity.password_needs_digit": "The password must contain a digit.",
|
||||
"identity.password_needs_letter": "The password must contain a letter.",
|
||||
@@ -96,6 +97,8 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "New message · {contact}",
|
||||
"notifications.operator_needed": "An operator is needed · {contact}",
|
||||
"notifications.voice_without_transcript": "A voice message without a transcript",
|
||||
"onboarding.membership_required": "Onboarding is available to organization members",
|
||||
"onboarding.unknown_action": "Unknown onboarding action",
|
||||
"portals.article_not_found": "Article not found",
|
||||
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
|
||||
"portals.file_not_found": "File not found",
|
||||
@@ -109,7 +112,7 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_section": "Invalid section",
|
||||
"portals.not_found": "Portal not found",
|
||||
"portals.section_not_found": "Section not found",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
|
||||
"profile.choose_photo_file": "Choose a photo file",
|
||||
"profile.link_not_found": "Link not found",
|
||||
"profile.notification_bot_not_found": "Notification bot not found",
|
||||
@@ -208,6 +211,10 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_identity_avatar_updated": "Profile photo changed",
|
||||
"audit.action_identity_employee_blocked": "Operator blocked",
|
||||
"audit.action_identity_employee_created": "Operator added",
|
||||
"audit.action_identity_employee_invited": "Operator invited",
|
||||
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
|
||||
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
|
||||
"audit.action_updates_install_requested": "Installation update started",
|
||||
"audit.action_identity_employee_groups_changed": "Operator groups changed",
|
||||
"audit.action_identity_employee_password_reset": "Operator password reset",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
|
||||
@@ -241,6 +248,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Integration added",
|
||||
"audit.action_integrations_integration_deleted": "Integration deleted",
|
||||
"audit.action_integrations_integration_updated": "Integration changed",
|
||||
"audit.action_organization_created": "Organization created from the interface",
|
||||
"audit.action_organization_owner_activated": "Organization owner activated",
|
||||
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
|
||||
"audit.action_organization_provisioned": "Organization created",
|
||||
@@ -326,7 +334,12 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.invalid_credentials": "Invalid credentials",
|
||||
"identity.invalid_totp_code": "Invalid TOTP code",
|
||||
"identity.invitation_email_mismatch": "Invitation email does not match the account",
|
||||
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
|
||||
"identity.invitation_invalid": "Invitation is invalid or has expired",
|
||||
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
|
||||
"updates.nothing_to_install": "The latest version is already installed",
|
||||
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
|
||||
"updates.install_in_progress": "An update is already in progress",
|
||||
"identity.role_conflict": "User already has a different role in this organization",
|
||||
"identity.token_required": "token is required",
|
||||
"identity.totp_challenge_inactive": "TOTP challenge is not active",
|
||||
@@ -349,7 +362,8 @@ MESSAGES: dict[str, object] = {
|
||||
"api.expected_record_id": "{name}: a record identifier is expected",
|
||||
"settings.storage_unavailable": "The storage is unavailable: {error}",
|
||||
"admin.actor_system": "System",
|
||||
"admin.image_formats": "PNG, JPEG and WebP are supported",
|
||||
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
|
||||
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
|
||||
"admin.logo_too_large": "The logo must not exceed 2 MB",
|
||||
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
|
||||
"calls.failure_code_required": "FAILED needs a normalized failure_code",
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Выберите файл логотипа",
|
||||
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
|
||||
"admin.employee_not_found": "Сотрудник не найден",
|
||||
"admin.group_name_taken": "Группа с таким именем уже есть",
|
||||
"admin.group_not_found": "Группа не найдена",
|
||||
@@ -86,6 +85,8 @@ MESSAGES: dict[str, object] = {
|
||||
"emails.initial_access_subject": "Первичный доступ к Chatballs",
|
||||
"emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.",
|
||||
"emails.password_reset_subject": "Восстановление доступа к Chatballs",
|
||||
"emails.membership_invitation_subject": "Приглашение в организацию {organization}",
|
||||
"emails.membership_invitation_body": "Здравствуйте, {name}.\n\nВас приглашают в организацию «{organization}» в Chatballs. Чтобы принять приглашение, перейдите по ссылке:\n{url}\n\nСсылка действует 7 дней. Если вы не ожидали это письмо, просто проигнорируйте его.",
|
||||
"identity.link_invalid_or_expired": "Ссылка недействительна или истекла",
|
||||
"identity.password_needs_digit": "Пароль должен содержать цифру.",
|
||||
"identity.password_needs_letter": "Пароль должен содержать букву.",
|
||||
@@ -100,6 +101,8 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "Новое сообщение · {contact}",
|
||||
"notifications.operator_needed": "Нужен оператор · {contact}",
|
||||
"notifications.voice_without_transcript": "Голосовое без расшифровки",
|
||||
"onboarding.membership_required": "Онбординг доступен участнику организации",
|
||||
"onboarding.unknown_action": "Неизвестное действие онбординга",
|
||||
"portals.article_not_found": "Статья не найдена",
|
||||
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
|
||||
"portals.file_not_found": "Файл не найден",
|
||||
@@ -113,7 +116,7 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_section": "Некорректный раздел",
|
||||
"portals.not_found": "Портал не найден",
|
||||
"portals.section_not_found": "Раздел не найден",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
|
||||
"profile.choose_photo_file": "Выберите файл фото",
|
||||
"profile.link_not_found": "Привязка не найдена",
|
||||
"profile.notification_bot_not_found": "Бот уведомлений не найден",
|
||||
@@ -212,6 +215,10 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_identity_avatar_updated": "Изменено фото профиля",
|
||||
"audit.action_identity_employee_blocked": "Сотрудник заблокирован",
|
||||
"audit.action_identity_employee_created": "Добавлен сотрудник",
|
||||
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
|
||||
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
|
||||
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
|
||||
"audit.action_updates_install_requested": "Запущено обновление установки",
|
||||
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
|
||||
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
|
||||
@@ -245,6 +252,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Добавлена интеграция",
|
||||
"audit.action_integrations_integration_deleted": "Удалена интеграция",
|
||||
"audit.action_integrations_integration_updated": "Изменена интеграция",
|
||||
"audit.action_organization_created": "Создана организация из интерфейса",
|
||||
"audit.action_organization_owner_activated": "Активирован владелец организации",
|
||||
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"audit.action_organization_provisioned": "Создана организация",
|
||||
@@ -330,7 +338,12 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.invalid_credentials": "Неверный email или пароль",
|
||||
"identity.invalid_totp_code": "Неверный код",
|
||||
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
|
||||
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
|
||||
"identity.invitation_invalid": "Приглашение недействительно или истекло",
|
||||
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
|
||||
"updates.nothing_to_install": "Установлена последняя версия",
|
||||
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
|
||||
"updates.install_in_progress": "Обновление уже идёт",
|
||||
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
|
||||
"identity.token_required": "Нужен токен",
|
||||
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
|
||||
@@ -353,7 +366,8 @@ MESSAGES: dict[str, object] = {
|
||||
"api.expected_record_id": "{name}: ожидается идентификатор записи",
|
||||
"settings.storage_unavailable": "Хранилище недоступно: {error}",
|
||||
"admin.actor_system": "Система",
|
||||
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
|
||||
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
|
||||
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
|
||||
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
|
||||
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
|
||||
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
|
||||
|
||||
@@ -14,12 +14,12 @@ from chatballs.identity.models import (
|
||||
@admin.register(HumanUser)
|
||||
class HumanUserAdmin(UserAdmin):
|
||||
ordering = ["email"]
|
||||
list_display = ["email", "full_name", "is_active", "is_staff", "last_login"]
|
||||
list_display = ["email", "full_name", "is_active", "is_staff", "is_instance_admin", "last_login"]
|
||||
search_fields = ["email", "full_name"]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("email", "password")}),
|
||||
("Personal info", {"fields": ("full_name", "first_name", "last_name")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "is_instance_admin", "groups", "user_permissions")}),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
|
||||
|
||||
@admin.register(Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
list_display = ["slug", "name", "currency", "timezone"]
|
||||
list_display = ["slug", "name", "timezone"]
|
||||
search_fields = ["slug", "name"]
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
|
||||
return {
|
||||
"name": organization.name,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
# Пустая строка доезжает до интерфейса как есть: там это отдельный
|
||||
# пункт «Как в установке», а не отсутствие значения.
|
||||
"language": organization.language,
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language
|
||||
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
@@ -27,17 +28,18 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
class OrganizationSettingsInput:
|
||||
name: str
|
||||
timezone: str
|
||||
currency: str
|
||||
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
|
||||
# и владелец, который его не трогал, не должен получить жёсткий русский
|
||||
# после того, как язык установки сменили.
|
||||
language: str = ""
|
||||
|
||||
|
||||
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
|
||||
для страницы создания организации."""
|
||||
|
||||
name = data.name.strip()
|
||||
timezone = data.timezone.strip()
|
||||
currency = data.currency.strip().upper()
|
||||
language = normalize_language(data.language)
|
||||
if data.language.strip() and not language:
|
||||
raise ValidationError({"language": t("settings.language_unsupported")})
|
||||
@@ -51,13 +53,7 @@ def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInpu
|
||||
raise ValidationError(
|
||||
{"timezone": t("admin.invalid_timezone")}
|
||||
) from error
|
||||
if currency != "RUB":
|
||||
raise ValidationError(
|
||||
{"currency": t("admin.currency_rub_only")}
|
||||
)
|
||||
return OrganizationSettingsInput(
|
||||
name=name, timezone=timezone, currency=currency, language=language
|
||||
)
|
||||
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -66,15 +62,14 @@ def update_organization_settings(
|
||||
context: TenantContext,
|
||||
data: OrganizationSettingsInput,
|
||||
) -> Organization:
|
||||
clean = _validate_input(data)
|
||||
clean = validate_organization_settings(data)
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
)
|
||||
organization.name = clean.name
|
||||
organization.timezone = clean.timezone
|
||||
organization.currency = clean.currency
|
||||
organization.language = clean.language
|
||||
organization.save(update_fields=["name", "timezone", "currency", "language"])
|
||||
organization.save(update_fields=["name", "timezone", "language"])
|
||||
return organization
|
||||
|
||||
|
||||
@@ -85,6 +80,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
|
||||
return "image/jpeg", ".jpg"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
if looks_like_svg(data):
|
||||
return SVG_CONTENT_TYPE, ".svg"
|
||||
return None
|
||||
|
||||
|
||||
@@ -102,6 +99,10 @@ def replace_organization_logo(
|
||||
detected = _image_type(data)
|
||||
if detected is None:
|
||||
raise ValidationError({"file": t("admin.image_formats")})
|
||||
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
|
||||
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
|
||||
# файл отклоняется целиком, а не переписывается молча.
|
||||
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
|
||||
content_type, suffix = detected
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
|
||||
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
|
||||
data=OrganizationSettingsInput(
|
||||
name=str(body.get("name", organization.name)),
|
||||
timezone=str(body.get("timezone", organization.timezone)),
|
||||
currency=str(body.get("currency", organization.currency)),
|
||||
language=str(body.get("language", organization.language)),
|
||||
),
|
||||
)
|
||||
@@ -107,11 +106,18 @@ class OrganizationLogoView(APIView):
|
||||
organization = request.tenant_context.organization
|
||||
if not organization.logo:
|
||||
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
|
||||
return FileResponse(
|
||||
response = FileResponse(
|
||||
organization.logo.open("rb"),
|
||||
content_type=organization.logo_content_type or "application/octet-stream",
|
||||
filename="organization-logo",
|
||||
)
|
||||
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
|
||||
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
|
||||
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
|
||||
# браузеру нельзя угадывать тип.
|
||||
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
@@ -286,37 +292,100 @@ def _audit_actors(base) -> list[dict[str, object]]:
|
||||
return actors
|
||||
|
||||
|
||||
class LaunchChecklistView(APIView):
|
||||
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
|
||||
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
|
||||
class OnboardingView(APIView):
|
||||
"""Состояние онбординга «Начало работы» для текущего человека.
|
||||
|
||||
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
|
||||
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
|
||||
в организации: у каждого человека свои, и один администратор не прячет
|
||||
визард команде.
|
||||
"""
|
||||
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "settings.view"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
membership = request.tenant_context.membership
|
||||
return Response(
|
||||
{
|
||||
"steps": onboarding_steps(request.tenant_context.organization_id),
|
||||
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
|
||||
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
|
||||
}
|
||||
)
|
||||
|
||||
organization_id = request.tenant_context.organization_id
|
||||
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
|
||||
connection_bound = Integration.objects.filter(
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Закрыть визард или отметить его пройденным.
|
||||
|
||||
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
|
||||
снимает оба признака: визард снова открывается по ссылке и пилюле.
|
||||
"""
|
||||
|
||||
membership = request.tenant_context.membership
|
||||
if membership is None:
|
||||
return Response({"detail": t("onboarding.membership_required")}, status=403)
|
||||
action = str(request.data.get("action") or "dismiss")
|
||||
now = django_timezone.now()
|
||||
if action == "dismiss":
|
||||
membership.onboarding_dismissed_at = now
|
||||
elif action == "complete":
|
||||
membership.onboarding_dismissed_at = now
|
||||
membership.onboarding_completed_at = now
|
||||
elif action == "restart":
|
||||
membership.onboarding_dismissed_at = None
|
||||
membership.onboarding_completed_at = None
|
||||
else:
|
||||
return Response({"detail": t("onboarding.unknown_action")}, status=400)
|
||||
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
|
||||
return Response(
|
||||
{
|
||||
"steps": onboarding_steps(request.tenant_context.organization_id),
|
||||
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
|
||||
"completedAt": _isoformat(membership.onboarding_completed_at),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def onboarding_steps(organization_id: int) -> dict[str, bool]:
|
||||
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.identity import instance_settings
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
|
||||
|
||||
organization = Organization.objects.filter(pk=organization_id).first()
|
||||
return {
|
||||
"providerConnected": Integration.objects.filter(
|
||||
organization_id=organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
).exists(),
|
||||
"agentActive": AIAgent.objects.filter(
|
||||
organization_id=organization_id,
|
||||
status=AIAgentStatus.ACTIVE,
|
||||
).exists(),
|
||||
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
|
||||
"connectionBound": Integration.objects.filter(
|
||||
organization_id=organization_id,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
channel__isnull=False,
|
||||
).exists()
|
||||
employee_invited = (
|
||||
OrganizationMembership.objects.filter(
|
||||
organization_id=organization_id
|
||||
).count()
|
||||
> 1
|
||||
or request.tenant_context.organization.invitations.exists()
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"agentCreated": agent_created,
|
||||
"connectionBound": connection_bound,
|
||||
"employeeInvited": employee_invited,
|
||||
"done": agent_created and connection_bound and employee_invited,
|
||||
}
|
||||
)
|
||||
).exists(),
|
||||
"widgetPublished": WebChatWidget.objects.filter(
|
||||
organization_id=organization_id,
|
||||
status=WebChatWidgetStatus.PUBLISHED,
|
||||
).exists(),
|
||||
"employeeInvited": (
|
||||
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
|
||||
or (organization is not None and organization.invitations.exists())
|
||||
),
|
||||
# Шаг про саму установку, а не про организацию: домен и исходящая почта
|
||||
# общие для всех организаций на сервере.
|
||||
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
|
||||
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
|
||||
}
|
||||
@@ -78,6 +78,10 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"identity.avatar_updated": "audit.action_identity_avatar_updated",
|
||||
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
|
||||
"identity.employee_created": "audit.action_identity_employee_created",
|
||||
"identity.employee_invited": "audit.action_identity_employee_invited",
|
||||
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
|
||||
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
|
||||
"updates.install_requested": "audit.action_updates_install_requested",
|
||||
"identity.employee_updated": "audit.action_identity_employee_updated",
|
||||
"identity.employee_blocked": "audit.action_identity_employee_blocked",
|
||||
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
|
||||
@@ -107,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"administration.instance_updated": "audit.action_administration_instance_updated",
|
||||
# --- Организация ---
|
||||
"organization.provisioned": "audit.action_organization_provisioned",
|
||||
"organization.created": "audit.action_organization_created",
|
||||
"organization.owner_activated": "audit.action_organization_owner_activated",
|
||||
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
|
||||
# --- Интеграции и каналы ---
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from chatballs.identity.auth.invitations import InvitationAcceptView
|
||||
from chatballs.identity.auth.invitations import (
|
||||
InvitationAcceptView,
|
||||
InvitationPreviewView,
|
||||
InvitationRegisterView,
|
||||
)
|
||||
from chatballs.identity.auth.password_reset import (
|
||||
PasswordResetConfirmView,
|
||||
PasswordResetRequestView,
|
||||
@@ -40,4 +44,6 @@ __all__ = [
|
||||
"TotpConfirmView",
|
||||
"TotpVerifyView",
|
||||
"InvitationAcceptView",
|
||||
"InvitationPreviewView",
|
||||
"InvitationRegisterView",
|
||||
]
|
||||
@@ -1,32 +1,48 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.i18n import current_language
|
||||
from chatballs.i18n import current_language, t
|
||||
from chatballs.identity.avatars import own_avatar_url
|
||||
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import get_effective_access
|
||||
from chatballs.identity.sessions import revoke_user_sessions
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
|
||||
|
||||
def validation_response(error: ValidationError) -> Response:
|
||||
"""Ошибки формы полями: мастер первого запуска и регистрация по приглашению."""
|
||||
|
||||
if hasattr(error, "message_dict"):
|
||||
errors = {
|
||||
key: messages[0] if isinstance(messages, list) else str(messages)
|
||||
for key, messages in error.message_dict.items()
|
||||
}
|
||||
# validate_password кладёт сообщения без ключа поля.
|
||||
if "__all__" in errors:
|
||||
errors["password"] = " ".join(error.message_dict["__all__"])
|
||||
del errors["__all__"]
|
||||
detail = next(iter(errors.values()), t("setup.check_fields"))
|
||||
return Response({"detail": detail, "errors": errors}, status=400)
|
||||
message = " ".join(error.messages)
|
||||
return Response({"detail": message, "errors": {"password": message}}, status=400)
|
||||
|
||||
|
||||
def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
memberships = []
|
||||
routes = membership_routes_for_user(user.id)
|
||||
organizations = Organization.objects.in_bulk(
|
||||
[route.organization_id for route in routes]
|
||||
)
|
||||
for route in routes:
|
||||
organization = organizations.get(route.organization_id)
|
||||
if organization is None:
|
||||
continue
|
||||
with tenant_atomic(organization.id):
|
||||
# Организация читается вместе с членством внутри её контекста: роль
|
||||
# app не видит чужие строки организаций (tenancy/0033).
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(
|
||||
id=route.resource_id,
|
||||
user=user,
|
||||
organization=organization,
|
||||
organization_id=route.organization_id,
|
||||
blocked_at__isnull=True,
|
||||
)
|
||||
.first()
|
||||
@@ -70,6 +86,9 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
"uiLanguage": user.ui_language,
|
||||
"language": current_language(),
|
||||
"avatarUrl": own_avatar_url(user),
|
||||
# Администратор установки видит раздел «Платформа» и хранилище
|
||||
# файлов: это свойства инсталляции, а не организации.
|
||||
"isInstanceAdmin": user.is_instance_admin,
|
||||
"memberships": memberships,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.contrib.auth import login
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.auth.common import _user_payload
|
||||
from chatballs.identity.invitation_service import InvitationError, accept_invitation
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.invitation_service import (
|
||||
InvitationError,
|
||||
accept_invitation,
|
||||
invitation_preview,
|
||||
register_and_accept,
|
||||
)
|
||||
from chatballs.identity.sessions import remember_device
|
||||
|
||||
|
||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||
class InvitationPreviewView(APIView):
|
||||
"""Что стоит за ссылкой /join до входа: организация, адрес, есть ли учётная запись.
|
||||
|
||||
Ответ нужен экрану, чтобы решить, показать вход или форму создания пароля.
|
||||
Токен — секрет из письма, поэтому подробности отдаются только по нему.
|
||||
"""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
token = str(request.query_params.get("token", "")).strip()
|
||||
preview = invitation_preview(token) if token else None
|
||||
if preview is None:
|
||||
return Response({"valid": False})
|
||||
return Response({"valid": True, **preview})
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name="dispatch")
|
||||
class InvitationRegisterView(APIView):
|
||||
"""Создать учётную запись по приглашению, принять его и войти."""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
token = str(body.get("token", "")).strip()
|
||||
if not token:
|
||||
return Response({"detail": t("identity.token_required")}, status=400)
|
||||
try:
|
||||
accepted = register_and_accept(
|
||||
token=token,
|
||||
full_name=str(body.get("fullName", "")),
|
||||
password=str(body.get("password", "")),
|
||||
)
|
||||
except InvitationError as error:
|
||||
return Response({"detail": str(error), "code": error.code}, status=400)
|
||||
except ValidationError as error:
|
||||
return validation_response(error)
|
||||
user = accepted.membership.user
|
||||
user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
login(request, user)
|
||||
remember_device(request)
|
||||
return Response(
|
||||
{
|
||||
"authenticated": True,
|
||||
"user": _user_payload(user),
|
||||
"organizationPublicId": str(accepted.organization.public_id),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
class InvitationAcceptView(APIView):
|
||||
"""Accept an OWNER invitation (SPEC-HUB-0021 §8.2).
|
||||
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
|
||||
|
||||
Authenticated endpoint: the caller must already have a HumanUser account
|
||||
(created through sign-up / password setup). The token is read from the body;
|
||||
@@ -26,7 +96,13 @@ class InvitationAcceptView(APIView):
|
||||
if not token:
|
||||
return Response({"detail": t("identity.token_required")}, status=400)
|
||||
try:
|
||||
accept_invitation(token=token, user=request.user)
|
||||
accepted = accept_invitation(token=token, user=request.user)
|
||||
except InvitationError as error:
|
||||
return Response({"detail": str(error)}, status=400)
|
||||
return Response({"user": _user_payload(request.user)})
|
||||
return Response(
|
||||
{
|
||||
"user": _user_payload(request.user),
|
||||
# Куда открыть приложение после принятия.
|
||||
"organizationPublicId": str(accepted.organization.public_id),
|
||||
}
|
||||
)
|
||||
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
|
||||
|
||||
|
||||
class ProfileUpdateView(APIView):
|
||||
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is not None:
|
||||
return context.organization.language or ""
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(user=request.user, blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return membership.organization.language if membership is not None else ""
|
||||
# Членства роли app без контекста не видны: сначала каталог входа, затем
|
||||
# каждое членство читается в контексте своей организации — как в
|
||||
# identity.auth.common._user_payload.
|
||||
oldest: tuple[object, int, str] | None = None
|
||||
for route in membership_routes_for_user(request.user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
continue
|
||||
key = (membership.created_at, membership.id, membership.organization.language or "")
|
||||
if oldest is None or key[:2] < oldest[:2]:
|
||||
oldest = key
|
||||
return oldest[2] if oldest is not None else ""
|
||||
@@ -23,4 +23,6 @@ urlpatterns = [
|
||||
path("totp/confirm/", auth.TotpConfirmView.as_view(), name="auth-totp-confirm"),
|
||||
path("totp/verify/", auth.TotpVerifyView.as_view(), name="auth-totp-verify"),
|
||||
path("invitations/accept/", auth.InvitationAcceptView.as_view(), name="auth-invitation-accept"),
|
||||
path("invitations/preview/", auth.InvitationPreviewView.as_view(), name="auth-invitation-preview"),
|
||||
path("invitations/register/", auth.InvitationRegisterView.as_view(), name="auth-invitation-register"),
|
||||
]
|
||||
@@ -38,9 +38,12 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def organization_public_id(organization_id: int) -> str:
|
||||
"""public_id организации по id — неизменяем, поэтому кэшируется."""
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.ingress import organization_public_id_of
|
||||
|
||||
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
|
||||
public_id = organization_public_id_of(organization_id)
|
||||
if public_id is None:
|
||||
raise LookupError(f"Organization {organization_id} does not exist")
|
||||
return public_id
|
||||
|
||||
|
||||
def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None:
|
||||
|
||||
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
defaults={
|
||||
"name": "Demo",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
)
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
@@ -50,15 +49,17 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
"full_name": full_name,
|
||||
"is_staff": True,
|
||||
"is_superuser": True,
|
||||
"is_instance_admin": True,
|
||||
},
|
||||
)
|
||||
if created_owner:
|
||||
owner.set_password(password)
|
||||
owner.save(update_fields=["password"])
|
||||
elif not owner.is_staff or not owner.is_superuser:
|
||||
elif not owner.is_staff or not owner.is_superuser or not owner.is_instance_admin:
|
||||
owner.is_staff = True
|
||||
owner.is_superuser = True
|
||||
owner.save(update_fields=["is_staff", "is_superuser"])
|
||||
owner.is_instance_admin = True
|
||||
owner.save(update_fields=["is_staff", "is_superuser", "is_instance_admin"])
|
||||
|
||||
OrganizationMembership.objects.get_or_create(
|
||||
user=owner,
|
||||
|
||||
@@ -4,10 +4,8 @@ from chatballs.identity import (
|
||||
administration_views,
|
||||
demo_views,
|
||||
group_views,
|
||||
instance_views,
|
||||
)
|
||||
from chatballs.integrations import feature_views
|
||||
from chatballs.tenancy import storage_views
|
||||
|
||||
urlpatterns = [
|
||||
path("groups/", group_views.GroupListView.as_view(), name="group-list"),
|
||||
@@ -17,31 +15,20 @@ urlpatterns = [
|
||||
administration_views.OrganizationSettingsView.as_view(),
|
||||
name="organization-settings",
|
||||
),
|
||||
path(
|
||||
"administration/instance/",
|
||||
instance_views.InstanceAddressView.as_view(),
|
||||
name="instance-address",
|
||||
),
|
||||
path(
|
||||
"administration/instance/email-check/",
|
||||
instance_views.InstanceEmailCheckView.as_view(),
|
||||
name="instance-email-check",
|
||||
),
|
||||
# Настройки установки (адрес, почта, TURN, хранилище) — не свойства
|
||||
# организации: они живут на /api/v1/instance/ (identity.instance_urls).
|
||||
path(
|
||||
"administration/logo/",
|
||||
administration_views.OrganizationLogoView.as_view(),
|
||||
name="organization-logo",
|
||||
),
|
||||
path(
|
||||
"launch-checklist/",
|
||||
administration_views.LaunchChecklistView.as_view(),
|
||||
name="launch-checklist",
|
||||
"onboarding/",
|
||||
administration_views.OnboardingView.as_view(),
|
||||
name="onboarding",
|
||||
),
|
||||
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
|
||||
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
|
||||
path("administration/storage/", storage_views.StorageSettingsView.as_view(), name="storage-settings"),
|
||||
path("administration/storage/check/", storage_views.StorageCheckView.as_view(), name="storage-check"),
|
||||
path("administration/storage/migrate/", storage_views.StorageMigrateView.as_view(), name="storage-migrate"),
|
||||
path(
|
||||
"administration/audit/",
|
||||
administration_views.AuditListView.as_view(),
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"how-to-measure",
|
||||
"lead-times"
|
||||
],
|
||||
"limits": {
|
||||
"dailyCostUsd": 300
|
||||
},
|
||||
"createdDaysAgo": 41
|
||||
},
|
||||
{
|
||||
@@ -53,7 +50,6 @@
|
||||
"returns-and-exchanges",
|
||||
"your-account"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 30,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -78,7 +74,6 @@
|
||||
"your-account",
|
||||
"returns-and-exchanges"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 28,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -96,7 +91,6 @@
|
||||
"instructions": "",
|
||||
"knowledge": [],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 3
|
||||
},
|
||||
{
|
||||
@@ -113,7 +107,6 @@
|
||||
"pricing"
|
||||
],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 180
|
||||
}
|
||||
],
|
||||
@@ -378,7 +371,6 @@
|
||||
80,
|
||||
260
|
||||
],
|
||||
"costMicrosPerToken": 0.6,
|
||||
"failuresTotal": 3
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,6 @@
|
||||
"kak-snyat-merki",
|
||||
"sroki-dostavki"
|
||||
],
|
||||
"limits": {
|
||||
"dailyCostUsd": 300
|
||||
},
|
||||
"createdDaysAgo": 41
|
||||
},
|
||||
{
|
||||
@@ -53,7 +50,6 @@
|
||||
"vozvrat-i-obmen",
|
||||
"lichnyj-kabinet"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 30,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -78,7 +74,6 @@
|
||||
"lichnyj-kabinet",
|
||||
"vozvrat-i-obmen"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 28,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -96,7 +91,6 @@
|
||||
"instructions": "",
|
||||
"knowledge": [],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 3
|
||||
},
|
||||
{
|
||||
@@ -113,7 +107,6 @@
|
||||
"pricing"
|
||||
],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 180
|
||||
}
|
||||
],
|
||||
@@ -378,7 +371,6 @@
|
||||
80,
|
||||
260
|
||||
],
|
||||
"costMicrosPerToken": 0.6,
|
||||
"failuresTotal": 3
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
|
||||
agent.persona = item.get("persona", "")
|
||||
agent.tone = item.get("tone", "")
|
||||
agent.instructions = item.get("instructions", "")
|
||||
agent.limits = item.get("limits", {})
|
||||
if agent.status in ("ACTIVE", "DISABLED"):
|
||||
agent.provider_integration = llm
|
||||
agent.model = (llm.config or {}).get("default_model", "demo")
|
||||
@@ -115,7 +114,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
|
||||
provider=item["provider"],
|
||||
name=item["name"],
|
||||
secret=item.get("secret", ""),
|
||||
config=item.get("config", {}),
|
||||
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
|
||||
config={**item.get("config", {}), "demoSeed": True},
|
||||
),
|
||||
)
|
||||
Integration.objects.filter(pk=integration.pk).update(
|
||||
@@ -255,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=0 if failed else completion,
|
||||
total_tokens=prompt + (0 if failed else completion),
|
||||
cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]),
|
||||
latency_ms=random.randint(900, 4200),
|
||||
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
|
||||
error="Provider timeout after 30s" if failed else "",
|
||||
@@ -273,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
|
||||
prompt_tokens=tokens,
|
||||
completion_tokens=0,
|
||||
total_tokens=tokens,
|
||||
cost_micros=int(tokens * 0.02),
|
||||
latency_ms=random.randint(200, 900),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from chatballs.identity.instance_settings import (
|
||||
email_from_address,
|
||||
public_base_url,
|
||||
)
|
||||
from chatballs.identity.invitation_models import OrganizationInvitation
|
||||
from chatballs.identity.models import HumanUser
|
||||
|
||||
|
||||
@@ -60,6 +61,40 @@ def send_initial_access_email(user: HumanUser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def send_membership_invitation_email(
|
||||
invitation: OrganizationInvitation, token: str, user: HumanUser | None = None
|
||||
) -> None:
|
||||
"""Приглашение в организацию по ссылке /join.
|
||||
|
||||
Существующая учётная запись входит под своим паролем, новая создаёт его
|
||||
по той же ссылке. Язык — получателя, если он известен, дальше
|
||||
приглашающей организации.
|
||||
"""
|
||||
|
||||
join_url = f"{public_base_url()}/join?token={token}"
|
||||
language = resolve_language(
|
||||
user_language=user.ui_language if user else "",
|
||||
organization_language=invitation.organization.language,
|
||||
instance_language=default_language(),
|
||||
)
|
||||
with translation.override(language):
|
||||
send_mail(
|
||||
subject=t(
|
||||
"emails.membership_invitation_subject",
|
||||
organization=invitation.organization.name,
|
||||
),
|
||||
message=t(
|
||||
"emails.membership_invitation_body",
|
||||
name=(user.full_name if user else "") or invitation.email,
|
||||
organization=invitation.organization.name,
|
||||
url=join_url,
|
||||
),
|
||||
from_email=email_from_address(),
|
||||
recipient_list=[invitation.email],
|
||||
connection=email_connection(),
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_email(user: HumanUser) -> None:
|
||||
reset_url = _password_setup_url(user)
|
||||
with translation.override(_recipient_language(user)):
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Ожидающие приглашения в списке сотрудников (кадры E1/E2, статус «Приглашён»).
|
||||
|
||||
Приглашение существующей учётной записи ещё не членство, но администратор
|
||||
должен его видеть там же, где сотрудников: строкой с той же ролью, должностью
|
||||
и группами, что придут после принятия. Отсюда же приглашение отправляют ещё
|
||||
раз или отзывают.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.employee_selectors import ROLE_ANY
|
||||
from chatballs.identity.governance import can_create_role
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.invitation_models import OrganizationInvitation
|
||||
from chatballs.identity.invitation_service import (
|
||||
MEMBERSHIP_INVITATION_REQUESTED,
|
||||
MEMBERSHIP_INVITATION_TTL,
|
||||
)
|
||||
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
|
||||
|
||||
def _pending(organization_id: int):
|
||||
return OrganizationInvitation.objects.filter(
|
||||
organization_id=organization_id,
|
||||
accepted_at__isnull=True,
|
||||
revoked_at__isnull=True,
|
||||
expires_at__gt=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
def pending_invitations_payload(organization: Organization, params) -> list[dict[str, object]]:
|
||||
"""Строки приглашений с теми же фильтрами, что у списка сотрудников."""
|
||||
|
||||
invitations = list(_pending(organization.id).order_by("email"))
|
||||
if not invitations:
|
||||
return []
|
||||
users = {
|
||||
user.email.lower(): user
|
||||
for user in HumanUser.objects.filter(
|
||||
email__in=[invitation.email for invitation in invitations]
|
||||
)
|
||||
}
|
||||
group_ids = {gid for invitation in invitations for gid in invitation.group_ids}
|
||||
groups = {
|
||||
group.id: group
|
||||
for group in EmployeeGroup.objects.filter(organization=organization, id__in=group_ids)
|
||||
}
|
||||
role = params.get("role")
|
||||
group_filter = params.get("group")
|
||||
query = params.get("q", "").strip().lower()
|
||||
rows: list[dict[str, object]] = []
|
||||
for invitation in invitations:
|
||||
user = users.get(invitation.email.lower())
|
||||
if user is None:
|
||||
continue
|
||||
if role and role != ROLE_ANY and invitation.role != role:
|
||||
continue
|
||||
if group_filter and group_filter != ROLE_ANY and str(group_filter).isdigit():
|
||||
if int(group_filter) not in invitation.group_ids:
|
||||
continue
|
||||
haystack = f"{user.full_name} {user.email} {invitation.position_title}".lower()
|
||||
if query and query not in haystack:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"id": invitation.id,
|
||||
"email": user.email,
|
||||
"fullName": user.full_name,
|
||||
"avatarUrl": user_avatar_url(user, organization.public_id),
|
||||
"role": invitation.role,
|
||||
"positionTitle": invitation.position_title,
|
||||
"phone": invitation.phone,
|
||||
"groups": [
|
||||
{"id": gid, "name": groups[gid].name}
|
||||
for gid in invitation.group_ids
|
||||
if gid in groups
|
||||
],
|
||||
"invitedAt": invitation.created_at.isoformat(),
|
||||
"expiresAt": invitation.expires_at.isoformat(),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _load_for_action(request: Request, invitation_id: int) -> OrganizationInvitation | Response:
|
||||
actor: OrganizationMembership = request.tenant_context.membership
|
||||
if not has_capability_any_scope(actor, "employees.manage"):
|
||||
return Response({"detail": t("admin.not_allowed")}, status=403)
|
||||
invitation = _pending(actor.organization_id).filter(pk=invitation_id).first()
|
||||
if invitation is None:
|
||||
return Response({"detail": t("identity.invitation_invalid")}, status=404)
|
||||
# Приглашение с ролью, которую актор выдать не вправе, ему и не отозвать.
|
||||
if not can_create_role(actor, invitation.role):
|
||||
return Response({"detail": t("admin.not_allowed")}, status=403)
|
||||
return invitation
|
||||
|
||||
|
||||
class InvitationResendView(APIView):
|
||||
"""Отправить письмо ещё раз: срок продлевается, токен выпускает воркер."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request, invitation_id: int) -> Response:
|
||||
loaded = _load_for_action(request, invitation_id)
|
||||
if isinstance(loaded, Response):
|
||||
return loaded
|
||||
loaded.expires_at = timezone.now() + MEMBERSHIP_INVITATION_TTL
|
||||
loaded.save(update_fields=["expires_at"])
|
||||
record_audit_event(
|
||||
action="identity.employee_invited",
|
||||
actor=request.user,
|
||||
organization=loaded.organization,
|
||||
object_type="OrganizationInvitation",
|
||||
object_id=str(loaded.id),
|
||||
payload={"role": loaded.role, "resend": True},
|
||||
request=request,
|
||||
)
|
||||
enqueue_event(
|
||||
DomainEvent(
|
||||
aggregate_type="OrganizationInvitation",
|
||||
aggregate_id=str(loaded.id),
|
||||
event_type=MEMBERSHIP_INVITATION_REQUESTED,
|
||||
payload={"invitationId": loaded.id},
|
||||
tenant_context=request.tenant_context,
|
||||
)
|
||||
)
|
||||
return Response({"ok": True})
|
||||
|
||||
|
||||
class InvitationRevokeView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request, invitation_id: int) -> Response:
|
||||
loaded = _load_for_action(request, invitation_id)
|
||||
if isinstance(loaded, Response):
|
||||
return loaded
|
||||
loaded.revoked_at = timezone.now()
|
||||
loaded.save(update_fields=["revoked_at"])
|
||||
record_audit_event(
|
||||
action="identity.invitation_revoked",
|
||||
actor=request.user,
|
||||
organization=loaded.organization,
|
||||
object_type="OrganizationInvitation",
|
||||
object_id=str(loaded.id),
|
||||
payload={"role": loaded.role},
|
||||
request=request,
|
||||
)
|
||||
return Response({"ok": True})
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.identity import (
|
||||
employee_invitations,
|
||||
employee_security_views,
|
||||
employee_views,
|
||||
ownership_views,
|
||||
@@ -11,6 +12,9 @@ urlpatterns = [
|
||||
# Создание сотрудника (ADMIN/EMPLOYEE по policy). Путь operators/ сохранён для
|
||||
# обратной совместимости; поле role в теле выбирает системную роль.
|
||||
path("operators/", employee_views.EmployeeCreateView.as_view(), name="employee-create"),
|
||||
# Ожидающие приглашения существующих учётных записей (статус «Приглашён»).
|
||||
path("invitations/<int:invitation_id>/resend/", employee_invitations.InvitationResendView.as_view(), name="employee-invitation-resend"),
|
||||
path("invitations/<int:invitation_id>/revoke/", employee_invitations.InvitationRevokeView.as_view(), name="employee-invitation-revoke"),
|
||||
path("<int:user_id>/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"),
|
||||
path("<int:user_id>/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"),
|
||||
path("<int:user_id>/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"),
|
||||
|
||||
@@ -9,6 +9,7 @@ from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.employee_invitations import pending_invitations_payload
|
||||
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
|
||||
@@ -21,6 +22,7 @@ from chatballs.identity.employee_validation import (
|
||||
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
|
||||
from chatballs.identity.invitation_service import invite_existing_user
|
||||
from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
|
||||
@@ -72,7 +74,13 @@ class EmployeeListView(APIView):
|
||||
employees_for(actor.organization_id, request.query_params),
|
||||
request.query_params,
|
||||
)
|
||||
return Response(page_payload(page, lambda employee: employee_payload(employee, actor)))
|
||||
payload = page_payload(page, lambda employee: employee_payload(employee, actor))
|
||||
# Ожидающие приглашения — строками со статусом «Приглашён»: их мало,
|
||||
# они не листаются и показываются на первой странице.
|
||||
payload["invitations"] = pending_invitations_payload(
|
||||
actor.organization, request.query_params
|
||||
)
|
||||
return Response(payload)
|
||||
|
||||
|
||||
class EmployeeCreateView(APIView):
|
||||
@@ -107,13 +115,33 @@ class EmployeeCreateView(APIView):
|
||||
return Response({"detail": t("admin.temporary_passwords_unsupported")}, status=400)
|
||||
if password_mode is None:
|
||||
return Response({"detail": t("admin.unknown_password_mode")}, status=400)
|
||||
if HumanUser.objects.filter(email=email).exists():
|
||||
return Response({"detail": t("admin.email_taken")}, status=400)
|
||||
|
||||
groups, groups_error = resolve_groups(actor.organization, body.get("groupIds"))
|
||||
if groups_error:
|
||||
return Response({"detail": groups_error}, status=400)
|
||||
|
||||
existing = HumanUser.objects.filter(email=email).first()
|
||||
if existing is not None:
|
||||
# Учётная запись глобальная, а вход в организацию — по согласию
|
||||
# человека: вместо «e-mail занят» уходит приглашение с той же ролью
|
||||
# и должностью, членство появится после его принятия. В своей
|
||||
# организации адрес и правда занят.
|
||||
if not existing.is_active or OrganizationMembership.objects.filter(
|
||||
user=existing, organization=actor.organization
|
||||
).exists():
|
||||
return Response({"detail": t("admin.email_taken")}, status=400)
|
||||
invite_existing_user(
|
||||
organization=actor.organization,
|
||||
user=existing,
|
||||
role=requested_role,
|
||||
position_title=position_title,
|
||||
phone=phone,
|
||||
groups=groups or [],
|
||||
created_by=actor,
|
||||
context=request.tenant_context,
|
||||
request=request,
|
||||
)
|
||||
return Response({"employee": None, "password": None, "invited": True}, status=201)
|
||||
|
||||
user = HumanUser.objects.create_user(
|
||||
email=email,
|
||||
password=None,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.identity.emails import send_initial_access_email, send_password_reset_email
|
||||
from chatballs.identity.emails import (
|
||||
send_initial_access_email,
|
||||
send_membership_invitation_email,
|
||||
send_password_reset_email,
|
||||
)
|
||||
from chatballs.identity.invitation_models import OrganizationInvitation
|
||||
from chatballs.identity.invitation_service import (
|
||||
MEMBERSHIP_INVITATION_REQUESTED,
|
||||
OWNER_INVITATION_REQUESTED,
|
||||
refresh_invitation_token,
|
||||
)
|
||||
from chatballs.identity.models import HumanUser
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
@@ -39,6 +49,37 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None
|
||||
send_password_reset_email(user)
|
||||
|
||||
|
||||
def _send_invitation(payload: dict, context: TenantContext | None, *, require_account: bool) -> None:
|
||||
if context is None:
|
||||
raise ValueError("Invitation is a tenant event")
|
||||
invitation = (
|
||||
OrganizationInvitation.objects.select_related("organization")
|
||||
.filter(pk=payload.get("invitationId"), organization_id=context.organization_id)
|
||||
.first()
|
||||
)
|
||||
if invitation is None or not invitation.is_pending:
|
||||
return
|
||||
user = HumanUser.objects.filter(email__iexact=invitation.email, is_active=True).first()
|
||||
if user is None and require_account:
|
||||
return
|
||||
# Токен выпускается здесь, в момент отправки: открытый токен нигде не
|
||||
# хранится, а письмо уходит позже, чем приглашение выписано.
|
||||
token = refresh_invitation_token(invitation)
|
||||
send_membership_invitation_email(invitation, token, user)
|
||||
|
||||
|
||||
@register(MEMBERSHIP_INVITATION_REQUESTED)
|
||||
def handle_membership_invitation_requested(payload: dict, context: TenantContext | None) -> None:
|
||||
# Сотрудника приглашают только с существующей учётной записью.
|
||||
_send_invitation(payload, context, require_account=True)
|
||||
|
||||
|
||||
@register(OWNER_INVITATION_REQUESTED)
|
||||
def handle_owner_invitation_requested(payload: dict, context: TenantContext | None) -> None:
|
||||
# Владельца из провижининга учётная запись может ждать: он создаст её по ссылке.
|
||||
_send_invitation(payload, context, require_account=False)
|
||||
|
||||
|
||||
# --- Демо-данные (мастер первого запуска и «Настройки») -----------------------
|
||||
|
||||
DEMO_INSTALL_REQUESTED = "demo.install_requested"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Доступ к настройкам установки: адрес, почта, TURN, хранилище файлов.
|
||||
|
||||
Это свойства инсталляции, а не организации, поэтому право их менять не
|
||||
выводится из роли в организации: владелец одной организации не должен
|
||||
переключать SMTP или бакет, общие для всех. Менять их может только
|
||||
администратор установки — глобальный признак на учётной записи
|
||||
(``HumanUser.is_instance_admin``). Первым его получает владелец из мастера
|
||||
первого запуска; дальше признак передают командой ``set_instance_admin``.
|
||||
|
||||
Читать настройки без секретов может тот, кто видит «Настройки» хотя бы в одной
|
||||
организации: карточка relay для звонков показывает адреса TURN администратору
|
||||
организации, менять их он не может.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
|
||||
INSTANCE_SETTINGS_VIEW_CAPABILITY = "settings.view"
|
||||
|
||||
|
||||
def is_instance_admin(user: object) -> bool:
|
||||
return (
|
||||
isinstance(user, HumanUser)
|
||||
and user.is_active
|
||||
and bool(getattr(user, "is_instance_admin", False))
|
||||
)
|
||||
|
||||
|
||||
def can_view_instance_settings(user: object) -> bool:
|
||||
"""Администратор установки или менеджер хотя бы одной организации.
|
||||
|
||||
Членства — тенантные строки: каждое читается в контексте своей организации,
|
||||
как при сборке сессии.
|
||||
"""
|
||||
|
||||
if is_instance_admin(user):
|
||||
return True
|
||||
if not isinstance(user, HumanUser) or not user.is_active:
|
||||
return False
|
||||
for route in membership_routes_for_user(user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("user")
|
||||
.filter(id=route.resource_id, user=user, blocked_at__isnull=True)
|
||||
.first()
|
||||
)
|
||||
if membership is not None and has_capability_any_scope(
|
||||
membership, INSTANCE_SETTINGS_VIEW_CAPABILITY
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class InstanceSettingsPermission(BasePermission):
|
||||
"""GET — менеджеру любой организации, изменения — администратору установки."""
|
||||
|
||||
message = "Instance administrator rights are required"
|
||||
|
||||
def has_permission(self, request: Request, view: APIView) -> bool:
|
||||
if request.method in SAFE_METHODS:
|
||||
return can_view_instance_settings(request.user)
|
||||
return is_instance_admin(request.user)
|
||||
|
||||
|
||||
class IsInstanceAdmin(BasePermission):
|
||||
message = "Instance administrator rights are required"
|
||||
|
||||
def has_permission(self, request: Request, view: APIView) -> bool:
|
||||
return is_instance_admin(request.user)
|
||||
@@ -72,8 +72,12 @@ class InstanceSettings(models.Model):
|
||||
|
||||
|
||||
_CACHE_TTL_SECONDS = 10.0
|
||||
# Перечитать кэш при промахе по хосту можно не чаще раза в секунду на процесс:
|
||||
# иначе поток запросов с чужим Host превращался бы в поток запросов к базе.
|
||||
_MISS_REFRESH_SECONDS = 1.0
|
||||
_lock = threading.Lock()
|
||||
_cached: tuple[float, tuple[str, str]] | None = None
|
||||
_last_miss_refresh = 0.0
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
@@ -112,6 +116,31 @@ def accepted_hosts() -> tuple[str, ...]:
|
||||
return tuple(host for host in _hosts() if host)
|
||||
|
||||
|
||||
def host_is_accepted(host: str) -> bool:
|
||||
"""Свой ли это адрес — с перечитыванием кэша при промахе.
|
||||
|
||||
Кэш живёт в каждом процессе gunicorn отдельно. Мастер первого запуска или
|
||||
смена адреса в «Настройках» сбрасывают его только там, где выполнялись;
|
||||
соседний процесс до 10 секунд отвечал бы «Invalid host» на адрес, который
|
||||
установка уже считает своим. Поэтому промах — повод перечитать строку, но
|
||||
не чаще раза в секунду.
|
||||
"""
|
||||
|
||||
global _last_miss_refresh
|
||||
if not host:
|
||||
return False
|
||||
known = {normalize_domain(item) for item in accepted_hosts()}
|
||||
if host in known:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if now - _last_miss_refresh < _MISS_REFRESH_SECONDS:
|
||||
return False
|
||||
_last_miss_refresh = now
|
||||
invalidate_cache()
|
||||
return host in {normalize_domain(item) for item in accepted_hosts()}
|
||||
|
||||
|
||||
def default_language() -> str:
|
||||
"""Язык установки: экраны до входа и умолчание для организаций.
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Настройки установки: путь без организации в адресе.
|
||||
|
||||
Адрес, почта, TURN и хранилище файлов общие для всех организаций, поэтому
|
||||
они не живут под ``/api/v1/organizations/<uuid>/`` и не проходят tenant
|
||||
middleware. Доступ — по признаку администратора установки
|
||||
(см. ``identity.instance_access``).
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.identity import instance_views
|
||||
from chatballs.tenancy import storage_views
|
||||
from chatballs.updates import views as update_views
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
|
||||
path(
|
||||
"settings/email-check/",
|
||||
instance_views.InstanceEmailCheckView.as_view(),
|
||||
name="instance-email-check",
|
||||
),
|
||||
path("storage/", storage_views.StorageSettingsView.as_view(), name="instance-storage"),
|
||||
path("storage/check/", storage_views.StorageCheckView.as_view(), name="instance-storage-check"),
|
||||
path(
|
||||
"storage/migrate/",
|
||||
storage_views.StorageMigrateView.as_view(),
|
||||
name="instance-storage-migrate",
|
||||
),
|
||||
# Обновления установки: состояние, проверка канала, установка по кнопке.
|
||||
path("update/", update_views.UpdateStateView.as_view(), name="instance-update"),
|
||||
path("update/check/", update_views.UpdateCheckView.as_view(), name="instance-update-check"),
|
||||
path("update/install/", update_views.UpdateInstallView.as_view(), name="instance-update-install"),
|
||||
]
|
||||
@@ -12,9 +12,9 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import DEFAULT_LANGUAGE, LANGUAGES, normalize_language
|
||||
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
|
||||
from chatballs.identity.instance_settings import (
|
||||
InstanceSettings,
|
||||
email_connection,
|
||||
@@ -58,8 +58,9 @@ def instance_payload(row: InstanceSettings) -> dict:
|
||||
|
||||
|
||||
class InstanceAddressView(APIView):
|
||||
permission_classes = [HasCapability]
|
||||
required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"}
|
||||
# Путь без организации: читает менеджер любой организации, меняет только
|
||||
# администратор установки (identity.instance_access).
|
||||
permission_classes = [InstanceSettingsPermission]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
return Response({"instance": instance_payload(InstanceSettings.load())})
|
||||
@@ -171,8 +172,7 @@ class InstanceEmailCheckView(APIView):
|
||||
получил приглашение.
|
||||
"""
|
||||
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "company.manage"
|
||||
permission_classes = [IsInstanceAdmin]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
from django.core.mail import send_mail
|
||||
|
||||
@@ -19,6 +19,12 @@ class OrganizationInvitation(models.Model):
|
||||
)
|
||||
email = models.EmailField()
|
||||
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
|
||||
# Поля будущего членства: приглашение существующего пользователя несёт
|
||||
# то же, что форма создания сотрудника, а членство собирается из них при
|
||||
# принятии. Группы — по id: к моменту принятия часть могла исчезнуть.
|
||||
position_title = models.CharField(max_length=120, blank=True, default="")
|
||||
phone = models.CharField(max_length=32, blank=True, default="")
|
||||
group_ids = models.JSONField(default=list, blank=True)
|
||||
token_hash = models.CharField(max_length=128, unique=True)
|
||||
expires_at = models.DateTimeField()
|
||||
created_by = models.ForeignKey(
|
||||
|
||||
@@ -3,13 +3,18 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
@@ -18,7 +23,17 @@ from chatballs.identity.models import (
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import invitation_route
|
||||
|
||||
# Приглашение существующего пользователя в организацию: письмо отправляет
|
||||
# воркер по этому событию (identity.event_handlers).
|
||||
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
|
||||
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
|
||||
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
|
||||
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
|
||||
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
|
||||
|
||||
|
||||
def _token_hash(token: str) -> str:
|
||||
@@ -54,15 +69,19 @@ def issue_invitation(
|
||||
role: str,
|
||||
expires_at: datetime,
|
||||
created_by: OrganizationMembership | None,
|
||||
position_title: str = "",
|
||||
phone: str = "",
|
||||
group_ids: list[int] | None = None,
|
||||
) -> IssuedInvitation:
|
||||
normalized_email = email.strip().lower()
|
||||
now = timezone.now()
|
||||
# Повторное приглашение на тот же адрес заменяет прежнее: старое письмо
|
||||
# перестаёт работать, а не живёт параллельно с новым.
|
||||
OrganizationInvitation.objects.filter(
|
||||
organization=organization,
|
||||
email__iexact=normalized_email,
|
||||
accepted_at__isnull=True,
|
||||
revoked_at__isnull=True,
|
||||
expires_at__lte=now,
|
||||
).update(revoked_at=now)
|
||||
token = secrets.token_urlsafe(32)
|
||||
invitation = OrganizationInvitation.objects.create(
|
||||
@@ -72,27 +91,160 @@ def issue_invitation(
|
||||
token_hash=_token_hash(token),
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
position_title=position_title,
|
||||
phone=phone,
|
||||
group_ids=list(group_ids or []),
|
||||
)
|
||||
return IssuedInvitation(invitation=invitation, token=token)
|
||||
|
||||
|
||||
def refresh_invitation_token(invitation: OrganizationInvitation) -> str:
|
||||
"""Новый токен взамен прежнего.
|
||||
|
||||
Открытый токен нигде не хранится, а письмо уходит из воркера позже, чем
|
||||
приглашение выписано, — поэтому воркер выпускает токен сам, в момент
|
||||
отправки, и старый перестаёт действовать.
|
||||
"""
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
invitation.token_hash = _token_hash(token)
|
||||
invitation.save(update_fields=["token_hash"])
|
||||
return token
|
||||
|
||||
|
||||
def invite_existing_user(
|
||||
*,
|
||||
organization: Organization,
|
||||
user: HumanUser,
|
||||
role: str,
|
||||
position_title: str,
|
||||
phone: str,
|
||||
groups: list[EmployeeGroup],
|
||||
created_by: OrganizationMembership | None,
|
||||
context: TenantContext,
|
||||
request=None,
|
||||
) -> OrganizationInvitation:
|
||||
"""Пригласить уже существующую учётную запись в организацию.
|
||||
|
||||
Учётная запись глобальная, а членство появляется только с согласия
|
||||
человека: он получает письмо и принимает приглашение под своим входом.
|
||||
"""
|
||||
|
||||
issued = issue_invitation(
|
||||
organization=organization,
|
||||
email=user.email,
|
||||
role=role,
|
||||
expires_at=timezone.now() + MEMBERSHIP_INVITATION_TTL,
|
||||
created_by=created_by,
|
||||
position_title=position_title,
|
||||
phone=phone,
|
||||
group_ids=[group.id for group in groups],
|
||||
)
|
||||
record_audit_event(
|
||||
action="identity.employee_invited",
|
||||
actor=context.actor_user,
|
||||
organization=organization,
|
||||
object_type="OrganizationInvitation",
|
||||
object_id=str(issued.invitation.id),
|
||||
payload={"role": role},
|
||||
request=request,
|
||||
)
|
||||
enqueue_event(
|
||||
DomainEvent(
|
||||
aggregate_type="OrganizationInvitation",
|
||||
aggregate_id=str(issued.invitation.id),
|
||||
event_type=MEMBERSHIP_INVITATION_REQUESTED,
|
||||
payload={"invitationId": issued.invitation.id},
|
||||
tenant_context=context,
|
||||
)
|
||||
)
|
||||
return issued.invitation
|
||||
|
||||
|
||||
def invitation_preview(token: str) -> dict[str, object] | None:
|
||||
"""Что видит человек по ссылке до входа: куда зовут и есть ли учётная запись."""
|
||||
|
||||
invitation = pending_invitation_for_token(token)
|
||||
if invitation is None:
|
||||
return None
|
||||
return {
|
||||
"email": invitation.email,
|
||||
"organizationName": invitation.organization.name,
|
||||
"accountExists": HumanUser.objects.filter(email__iexact=invitation.email).exists(),
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def register_and_accept(*, token: str, full_name: str, password: str) -> AcceptedInvitation:
|
||||
"""Создать учётную запись по приглашению и сразу принять его.
|
||||
|
||||
Только для адреса без учётной записи: у существующей есть пароль, и
|
||||
приглашение принимается после входа. Пароль проверяется теми же
|
||||
правилами, что в мастере первого запуска.
|
||||
"""
|
||||
|
||||
invitation = pending_invitation_for_token(token)
|
||||
if invitation is None:
|
||||
raise InvitationError(t("identity.invitation_invalid"), code="invitation_invalid")
|
||||
if HumanUser.objects.filter(email__iexact=invitation.email).exists():
|
||||
raise InvitationError(t("identity.invitation_account_exists"), code="account_exists")
|
||||
name = " ".join(full_name.split())
|
||||
if not name:
|
||||
raise ValidationError({"fullName": t("setup.your_name_required")})
|
||||
probe = HumanUser(email=invitation.email, full_name=name)
|
||||
validate_password(password, user=probe)
|
||||
user = HumanUser.objects.create_user(
|
||||
email=invitation.email,
|
||||
password=password,
|
||||
full_name=name,
|
||||
is_staff=False,
|
||||
is_superuser=False,
|
||||
must_change_password=False,
|
||||
)
|
||||
return accept_invitation(token=token, user=user)
|
||||
|
||||
|
||||
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
|
||||
return _invitation_for_token(token, accepted=False)
|
||||
|
||||
|
||||
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
|
||||
"""Приглашение по токену из письма — без tenant-контекста на входе.
|
||||
|
||||
Ссылка /join приходит до входа в организацию, а таблица приглашений и
|
||||
строка организации роли app без контекста не видны (tenancy/0003, 0033).
|
||||
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
|
||||
и приглашение читается уже в её контексте — вместе с организацией, чтобы
|
||||
вызывающий код мог обращаться к ней и после выхода из контекста.
|
||||
"""
|
||||
|
||||
if not token:
|
||||
return None
|
||||
return OrganizationInvitation.objects.filter(
|
||||
token_hash=_token_hash(token),
|
||||
accepted_at__isnull=True,
|
||||
token_hash = _token_hash(token)
|
||||
route = invitation_route(token_hash)
|
||||
if route is None:
|
||||
return None
|
||||
query = OrganizationInvitation.objects.select_related("organization").filter(
|
||||
id=route.resource_id,
|
||||
organization_id=route.organization_id,
|
||||
token_hash=token_hash,
|
||||
revoked_at__isnull=True,
|
||||
expires_at__gt=timezone.now(),
|
||||
).first()
|
||||
)
|
||||
if accepted:
|
||||
query = query.filter(accepted_at__isnull=False)
|
||||
else:
|
||||
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
|
||||
with tenant_atomic(route.organization_id):
|
||||
return query.first()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
|
||||
"""Accept an OWNER invitation and activate the organization (SPEC-HUB-0021 §8.2).
|
||||
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
|
||||
|
||||
Idempotent: re-accepting the same token does not create a second membership
|
||||
or usage period. Requires an authenticated HumanUser with a matching email.
|
||||
Членство собирается из полей приглашения; для владельца организация ещё
|
||||
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
|
||||
второго членства. Нужна учётная запись с тем же e-mail.
|
||||
"""
|
||||
invitation = pending_invitation_for_token(token)
|
||||
if invitation is None:
|
||||
@@ -110,13 +262,17 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
|
||||
|
||||
organization = invitation.organization
|
||||
with tenant_atomic(organization.id):
|
||||
membership = _ensure_owner_membership(organization, user)
|
||||
membership = _ensure_membership(organization, user, invitation)
|
||||
if invitation.role == EmployeeRole.OWNER:
|
||||
_activate_organization(organization, user, membership)
|
||||
invitation.accepted_at = timezone.now()
|
||||
invitation.save(update_fields=["accepted_at"])
|
||||
record_audit_event(
|
||||
action="organization.owner_activated",
|
||||
action=(
|
||||
"organization.owner_activated"
|
||||
if invitation.role == EmployeeRole.OWNER
|
||||
else "identity.invitation_accepted"
|
||||
),
|
||||
actor=user,
|
||||
organization=organization,
|
||||
object_type="OrganizationInvitation",
|
||||
@@ -128,25 +284,42 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_owner_membership(
|
||||
organization: Organization, user: HumanUser
|
||||
def _ensure_membership(
|
||||
organization: Organization, user: HumanUser, invitation: OrganizationInvitation
|
||||
) -> OrganizationMembership:
|
||||
membership, _ = OrganizationMembership.objects.get_or_create(
|
||||
position_title = invitation.position_title
|
||||
if not position_title and invitation.role == EmployeeRole.OWNER:
|
||||
position_title = t("setup.owner_position", language=customer_language(organization))
|
||||
membership, created = OrganizationMembership.objects.get_or_create(
|
||||
user=user,
|
||||
organization=organization,
|
||||
defaults={
|
||||
"role": EmployeeRole.OWNER,
|
||||
"position_title": "Владелец",
|
||||
"role": invitation.role,
|
||||
"position_title": position_title,
|
||||
"phone": invitation.phone,
|
||||
"totp_required": False,
|
||||
},
|
||||
)
|
||||
if membership.role != EmployeeRole.OWNER:
|
||||
# An existing non-OWNER membership for this user should not be silently
|
||||
# promoted by an invitation; surface as a conflict instead.
|
||||
if membership.role != invitation.role:
|
||||
# Уже существующее членство с другой ролью приглашение молча не меняет:
|
||||
# это конфликт, а не повышение.
|
||||
raise InvitationError(
|
||||
t("identity.role_conflict"),
|
||||
code="role_conflict",
|
||||
)
|
||||
if created and invitation.group_ids:
|
||||
# Группы, которые к моменту принятия ещё существуют.
|
||||
groups = EmployeeGroup.objects.filter(
|
||||
organization=organization, id__in=list(invitation.group_ids)
|
||||
)
|
||||
EmployeeGroupMember.objects.bulk_create(
|
||||
[
|
||||
EmployeeGroupMember(
|
||||
organization_id=organization.id, group=group, employee=membership
|
||||
)
|
||||
for group in groups
|
||||
]
|
||||
)
|
||||
return membership
|
||||
|
||||
|
||||
@@ -165,16 +338,13 @@ def _already_accepted_for(
|
||||
) -> AcceptedInvitation | None:
|
||||
"""Idempotent re-accept: if this token was already accepted by the same user,
|
||||
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
|
||||
invitation = OrganizationInvitation.objects.filter(
|
||||
token_hash=_token_hash(token),
|
||||
accepted_at__isnull=False,
|
||||
revoked_at__isnull=True,
|
||||
).first()
|
||||
invitation = _invitation_for_token(token, accepted=True)
|
||||
if invitation is None:
|
||||
return None
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
user=user, organization=invitation.organization
|
||||
).first()
|
||||
with tenant_atomic(invitation.organization_id):
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
user=user, organization=invitation.organization
|
||||
).first()
|
||||
if membership is None:
|
||||
return None
|
||||
return AcceptedInvitation(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Проверка SVG-логотипа организации.
|
||||
|
||||
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
|
||||
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
|
||||
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
|
||||
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
|
||||
хуже, чем попросить другой файл.
|
||||
|
||||
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
|
||||
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
|
||||
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
SVG_CONTENT_TYPE = "image/svg+xml"
|
||||
|
||||
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
|
||||
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
|
||||
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
|
||||
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
|
||||
|
||||
|
||||
def looks_like_svg(data: bytes) -> bool:
|
||||
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
|
||||
if head.startswith(b"<svg"):
|
||||
return True
|
||||
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
|
||||
|
||||
|
||||
def _local(name: str) -> str:
|
||||
return name.rsplit("}", 1)[-1].lower()
|
||||
|
||||
|
||||
def _dangerous_value(value: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", value).lower()
|
||||
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
|
||||
|
||||
|
||||
def svg_is_safe(data: bytes) -> bool:
|
||||
if _DECLARATION.search(data):
|
||||
return False
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return False
|
||||
if _local(root.tag) != "svg":
|
||||
return False
|
||||
for element in root.iter():
|
||||
tag = _local(element.tag) if isinstance(element.tag, str) else ""
|
||||
if tag in _FORBIDDEN_TAGS:
|
||||
return False
|
||||
for name, value in element.attrib.items():
|
||||
local = _local(name)
|
||||
if local.startswith("on"):
|
||||
return False
|
||||
if _dangerous_value(value):
|
||||
return False
|
||||
if name in _HREF_ATTRIBUTES or local == "href":
|
||||
stripped = value.strip()
|
||||
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
|
||||
return False
|
||||
if local == "style" and _EXTERNAL_URL.search(value):
|
||||
return False
|
||||
if tag == "style" and element.text and (
|
||||
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
@@ -11,9 +11,9 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus
|
||||
from chatballs.identity.demo_seed import service
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import organization_by_slug
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -26,10 +26,9 @@ class Command(BaseCommand):
|
||||
group.add_argument("--remove", action="store_true", help="Remove the installed demo dataset")
|
||||
|
||||
def handle(self, *args: object, **options: object) -> None:
|
||||
try:
|
||||
organization = Organization.objects.get(slug=options["organization"])
|
||||
except Organization.DoesNotExist as error:
|
||||
raise CommandError(f"Organization {options['organization']!r} not found") from error
|
||||
organization = organization_by_slug(str(options["organization"]))
|
||||
if organization is None:
|
||||
raise CommandError(f"Organization {options['organization']!r} not found")
|
||||
context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
|
||||
|
||||
if not options["apply"] and not options["remove"]:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from chatballs.identity.models import HumanUser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Grants or revokes the installation administrator flag for an existing "
|
||||
"account. The flag controls installation-wide settings (address, mail, "
|
||||
"TURN, file storage) and is not derived from any organization role. "
|
||||
"Passwords and memberships are not touched."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--email", required=True, help="Account e-mail")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--grant", action="store_true", help="Make the account an installation administrator")
|
||||
group.add_argument("--revoke", action="store_true", help="Remove the installation administrator flag")
|
||||
|
||||
def handle(self, *args: object, **options: object) -> None:
|
||||
email = HumanUser.objects.normalize_email(str(options["email"])).lower()
|
||||
user = HumanUser.objects.filter(email__iexact=email).first()
|
||||
if user is None:
|
||||
raise CommandError(f"Account {email!r} not found")
|
||||
grant = bool(options["grant"])
|
||||
if grant and not user.is_active:
|
||||
raise CommandError(f"Account {email!r} is inactive")
|
||||
if not grant and not HumanUser.objects.filter(is_instance_admin=True, is_active=True).exclude(pk=user.pk).exists():
|
||||
raise CommandError("Cannot revoke the last active installation administrator")
|
||||
if user.is_instance_admin == grant:
|
||||
self.stdout.write(f"{email}: already {'an' if grant else 'not an'} installation administrator")
|
||||
return
|
||||
user.is_instance_admin = grant
|
||||
user.save(update_fields=["is_instance_admin"])
|
||||
self.stdout.write(self.style.SUCCESS(f"{email}: installation administrator {'granted' if grant else 'revoked'}"))
|
||||
@@ -0,0 +1,28 @@
|
||||
# Администратор установки — глобальный признак учётной записи, а не роль в
|
||||
# организации: настройки инсталляции (адрес, почта, TURN, хранилище) общие для
|
||||
# всех организаций, и менять их вправе не любой владелец организации.
|
||||
#
|
||||
# Существующие установки: признак получают те, кому мастер первого запуска
|
||||
# (или bootstrap локального контура) выдал is_superuser — это и есть владелец
|
||||
# установки. Провижининг через платформенный API суперпользователей не создаёт.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def grant_to_setup_owners(apps, schema_editor):
|
||||
HumanUser = apps.get_model("identity", "HumanUser")
|
||||
HumanUser.objects.filter(is_superuser=True).update(is_instance_admin=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0035_language_settings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="humanuser",
|
||||
name="is_instance_admin",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(grant_to_setup_owners, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Приглашение существующего пользователя во вторую организацию: вместо ошибки
|
||||
# «e-mail занят» администратор выписывает приглашение с той же ролью,
|
||||
# должностью, телефоном и группами, что и при создании сотрудника. Членство
|
||||
# создаётся из этих полей в момент принятия, а не в момент приглашения.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0036_humanuser_is_instance_admin"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="organizationinvitation",
|
||||
name="position_title",
|
||||
field=models.CharField(blank=True, default="", max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationinvitation",
|
||||
name="phone",
|
||||
field=models.CharField(blank=True, default="", max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationinvitation",
|
||||
name="group_ids",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Онбординг «Начало работы» показывается всем, кто его ещё не закрыл, включая
|
||||
# тех, кто работает в установке давно. Признак закрытия — на членстве человека
|
||||
# в организации: у каждого он свой, и закрытие одним администратором не прячет
|
||||
# визард у остальных. NULL по умолчанию, поэтому существующие записи считаются
|
||||
# «не закрывал» и увидят визард при следующем входе.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0037_invitation_membership_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="organizationmembership",
|
||||
name="onboarding_dismissed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationmembership",
|
||||
name="onboarding_completed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Валюта организации удалена: поле принимало только RUB, не читалось нигде и
|
||||
# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило
|
||||
# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже
|
||||
# снят вместе с лимитами.
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0038_membership_onboarding_dismissed_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="organization",
|
||||
name="currency",
|
||||
),
|
||||
]
|
||||
@@ -75,7 +75,7 @@ class HumanUser(AbstractUser):
|
||||
# без этой отметки принимался бы второй раз ещё полторы минуты.
|
||||
totp_last_counter = models.BigIntegerField(default=0)
|
||||
# Внешний вид — глобальная настройка пользователя (не membership):
|
||||
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
|
||||
# тема и акцентный HEX-цвет; пустой акцент — цвет продукта #0f9b8e.
|
||||
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
|
||||
ui_accent = models.CharField(max_length=9, blank=True)
|
||||
# Язык интерфейса — тоже глобальная настройка пользователя, а не
|
||||
@@ -84,6 +84,11 @@ class HumanUser(AbstractUser):
|
||||
# русский, а отсутствие личного выбора, и она переживает смену языка
|
||||
# организации, тогда как записанный при регистрации код — нет.
|
||||
ui_language = models.CharField(max_length=5, blank=True, default="")
|
||||
# Администратор установки: право менять свойства инсталляции — адрес,
|
||||
# почту, TURN, хранилище файлов. Это не роль в организации, а признак
|
||||
# учётной записи: первым его получает владелец из мастера первого
|
||||
# запуска, дальше его передают командой set_instance_admin.
|
||||
is_instance_admin = models.BooleanField(default=False)
|
||||
# Фото сотрудника (дизайн-базлайн v2): видно коллегам в сайдбаре, подписи
|
||||
# сообщений, выборе ответственного. Загружается в профиле.
|
||||
avatar = models.FileField(upload_to=user_avatar_upload_path, storage=user_storage, max_length=512, blank=True, default="")
|
||||
@@ -120,7 +125,6 @@ class Organization(models.Model):
|
||||
default=OrganizationStatus.ACTIVE,
|
||||
)
|
||||
timezone = models.CharField(max_length=64, default="Europe/Moscow")
|
||||
currency = models.CharField(max_length=3, default="RUB")
|
||||
# Язык рабочего места по умолчанию: на нём организация открывается всем,
|
||||
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
|
||||
# это такой же региональный параметр организации, и в «Настройках» они
|
||||
@@ -179,6 +183,14 @@ class OrganizationMembership(models.Model):
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
totp_required = models.BooleanField(default=False)
|
||||
blocked_at = models.DateTimeField(null=True, blank=True)
|
||||
# Онбординг закрыт этим человеком в этой организации. Признак живёт на
|
||||
# членстве, а не на организации: иначе первый же закрывший спрятал бы
|
||||
# визард всей команде. NULL — не закрывал, значит увидит при следующем
|
||||
# входе, включая тех, кто работает в системе давно.
|
||||
onboarding_dismissed_at = models.DateTimeField(null=True, blank=True)
|
||||
# Визард пройден до конца. Отдельно от «закрыл»: закрыть можно на первом
|
||||
# шаге, и тогда возвращаться к настройке ещё есть зачем.
|
||||
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Создание организации человеком из интерфейса.
|
||||
|
||||
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
|
||||
на страницу с полями организации; тот, кто её заполнил, становится владельцем
|
||||
новой организации и сразу в неё переключается. Это второй путь появления
|
||||
организации рядом с платформенным провижинингом (platform.provisioning_service):
|
||||
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
|
||||
заводит её себе.
|
||||
|
||||
Кто может: администратор установки и любой, у кого есть роль владельца или
|
||||
администратора хотя бы в одной организации. Сотрудник, работающий только в
|
||||
чате, чужую установку организациями не засевает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.identity.administration_services import (
|
||||
OrganizationSettingsInput,
|
||||
validate_organization_settings,
|
||||
)
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
|
||||
|
||||
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreatedOrganization:
|
||||
organization: Organization
|
||||
membership: OrganizationMembership
|
||||
|
||||
|
||||
def can_create_organization(user: HumanUser) -> bool:
|
||||
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
|
||||
|
||||
if not user.is_active:
|
||||
return False
|
||||
if user.is_instance_admin:
|
||||
return True
|
||||
for route in membership_routes_for_user(user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
role = (
|
||||
OrganizationMembership.objects.filter(
|
||||
id=route.resource_id, user=user, blocked_at__isnull=True
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
if role in MANAGER_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def unique_organization_slug(name: str) -> str:
|
||||
"""Слаг из имени, уникальный среди организаций установки.
|
||||
|
||||
Проверка идёт через каталог организаций: роль app без контекста строк
|
||||
организаций не видит (tenancy/0033).
|
||||
"""
|
||||
|
||||
base = slugify(name)[:40].strip("-") or "organization"
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while organization_route_by_slug(candidate) is not None:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def create_organization(
|
||||
*, data: OrganizationSettingsInput, owner: HumanUser
|
||||
) -> CreatedOrganization:
|
||||
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
|
||||
|
||||
Порядок тот же, что у мастера первого запуска (identity.setup): id
|
||||
выделяется заранее, строка вставляется уже в контексте этого id — иначе
|
||||
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
|
||||
"""
|
||||
|
||||
clean = validate_organization_settings(data)
|
||||
with transaction.atomic():
|
||||
organization_id = reserve_organization_id()
|
||||
with tenant_atomic(organization_id):
|
||||
organization = Organization(
|
||||
id=organization_id,
|
||||
name=clean.name,
|
||||
slug=unique_organization_slug(clean.name),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
timezone=clean.timezone,
|
||||
language=clean.language,
|
||||
)
|
||||
organization.save(force_insert=True)
|
||||
ensure_uncategorized_category(organization)
|
||||
membership = OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
organization=organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
# Должность — текстом на языке организации, как в провижининге.
|
||||
position_title=t("setup.owner_position", language=customer_language(organization)),
|
||||
totp_required=False,
|
||||
)
|
||||
record_audit_event(
|
||||
action="organization.created",
|
||||
actor=owner,
|
||||
organization=organization,
|
||||
object_type="Organization",
|
||||
object_id=str(organization.public_id),
|
||||
payload={"organizationName": organization.name},
|
||||
)
|
||||
enqueue_event(
|
||||
DomainEvent(
|
||||
aggregate_type="Organization",
|
||||
aggregate_id=str(organization.public_id),
|
||||
event_type="organization.provisioned",
|
||||
payload={},
|
||||
tenant_context=TenantContext.for_resource(
|
||||
organization,
|
||||
actor_kind=TenantActorKind.SYSTEM,
|
||||
actor_user=owner,
|
||||
),
|
||||
)
|
||||
)
|
||||
return CreatedOrganization(organization=organization, membership=membership)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.identity import organization_views
|
||||
|
||||
urlpatterns = [
|
||||
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
|
||||
path(
|
||||
"options/",
|
||||
organization_views.OrganizationCreateOptionsView.as_view(),
|
||||
name="organization-create-options",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
|
||||
|
||||
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
|
||||
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
|
||||
(auth.invitations): обновлённая учётная запись со списком членств и публичный
|
||||
id организации, в которую интерфейсу переключиться.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.administration_payloads import (
|
||||
administration_languages,
|
||||
administration_timezones,
|
||||
)
|
||||
from chatballs.identity.administration_services import OrganizationSettingsInput
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.organization_creation import (
|
||||
can_create_organization,
|
||||
create_organization,
|
||||
)
|
||||
|
||||
|
||||
def _forbidden() -> Response:
|
||||
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
|
||||
|
||||
|
||||
class OrganizationCreateOptionsView(APIView):
|
||||
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
if not can_create_organization(request.user):
|
||||
return _forbidden()
|
||||
return Response(
|
||||
{
|
||||
"timezones": administration_timezones(),
|
||||
"languages": administration_languages(),
|
||||
"currencies": ["RUB"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OrganizationCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
if not can_create_organization(request.user):
|
||||
return _forbidden()
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
try:
|
||||
created = create_organization(
|
||||
data=OrganizationSettingsInput(
|
||||
name=str(body.get("name", "")),
|
||||
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
|
||||
language=str(body.get("language", "")),
|
||||
),
|
||||
owner=request.user,
|
||||
)
|
||||
except ValidationError as error:
|
||||
return validation_response(error)
|
||||
return Response(
|
||||
{
|
||||
"user": _user_payload(request.user),
|
||||
"organizationPublicId": str(created.organization.public_id),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
@@ -5,16 +5,16 @@
|
||||
Никаких параметров в .env и CLI: всё задаёт человек в браузере. После
|
||||
создания владельца мастер закрывается навсегда (409).
|
||||
|
||||
Запись идёт на соединении ``platform`` — единственной runtime-роли с правом
|
||||
создавать организации (SPEC-HUB-0021 §10); RLS-контекст и транзакция живут
|
||||
на том же соединении.
|
||||
Запись идёт по основному соединению процесса. Роль app вправе вставить
|
||||
организацию только пока их нет (политика tenancy/0032); дальше создавать
|
||||
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
|
||||
platform-роли не нужен процессу backend-app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
@@ -35,14 +35,15 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.routing import use_database
|
||||
from chatballs.tenancy.ingress import organization_route_by_slug
|
||||
from chatballs.tenancy.lookup import instance_has_organizations, reserve_organization_id
|
||||
|
||||
ORGANIZATION_NAME_MAX_LENGTH = 255
|
||||
FULL_NAME_MAX_LENGTH = 255
|
||||
|
||||
# Алиас соединения для операций уровня инстанса. В тестах оба алиаса —
|
||||
# зеркала одной тестовой БД под ролью-владельцем кластера.
|
||||
INSTANCE_DB_ALIAS = "default" if settings.TESTING else "platform"
|
||||
# Мастер работает по основному соединению процесса: отдельный алиас с ролью
|
||||
# platform ему больше не нужен.
|
||||
INSTANCE_DB_ALIAS = "default"
|
||||
|
||||
|
||||
class SetupAlreadyCompleted(Exception):
|
||||
@@ -70,11 +71,10 @@ class SetupResult:
|
||||
def instance_needs_setup() -> bool:
|
||||
"""Мастер нужен, пока не создана ни одна организация.
|
||||
|
||||
Организации видны роли app целиком (RLS SELECT USING true), поэтому
|
||||
проверка не требует tenant-контекста.
|
||||
Строки организаций роли app без контекста не видны (tenancy/0033):
|
||||
наличие хотя бы одной проверяет SECURITY DEFINER-функция.
|
||||
"""
|
||||
with use_database(INSTANCE_DB_ALIAS):
|
||||
return not Organization.objects.exists()
|
||||
return not instance_has_organizations()
|
||||
|
||||
|
||||
def _clean(data: SetupInput) -> SetupInput:
|
||||
@@ -110,7 +110,7 @@ def _unique_slug(name: str) -> str:
|
||||
base = slugify(name)[:40].strip("-") or "organization"
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while Organization.objects.filter(slug=candidate).exists():
|
||||
while organization_route_by_slug(candidate) is not None:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
@@ -138,7 +138,7 @@ def _complete_setup(
|
||||
data: SetupInput, language: str, public_host: str, public_scheme: str
|
||||
) -> SetupResult:
|
||||
clean = _clean(data)
|
||||
with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS):
|
||||
with transaction.atomic(using=INSTANCE_DB_ALIAS):
|
||||
# Адрес, на котором человек прошёл мастер, и есть публичный адрес
|
||||
# установки: другого источника у коробки нет.
|
||||
if public_host:
|
||||
@@ -148,7 +148,7 @@ def _complete_setup(
|
||||
# увидит созданную организацию.
|
||||
with connections[INSTANCE_DB_ALIAS].cursor() as cursor:
|
||||
cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatballs.instance_setup'))")
|
||||
if Organization.objects.exists():
|
||||
if instance_has_organizations(using=INSTANCE_DB_ALIAS):
|
||||
raise SetupAlreadyCompleted()
|
||||
|
||||
# Пароль проверяется против атрибутов будущего пользователя (схожесть
|
||||
@@ -156,12 +156,17 @@ def _complete_setup(
|
||||
probe = HumanUser(email=clean.email, full_name=clean.full_name)
|
||||
validate_password(clean.password, user=probe)
|
||||
|
||||
organization = Organization.objects.create(
|
||||
name=clean.organization_name,
|
||||
slug=_unique_slug(clean.organization_name),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
)
|
||||
with tenant_atomic(organization.id, using=INSTANCE_DB_ALIAS):
|
||||
# id выделяется заранее: строка организации видна роли app только в
|
||||
# её контексте, и вставка идёт уже внутри него (tenancy/0033).
|
||||
organization_id = reserve_organization_id(using=INSTANCE_DB_ALIAS)
|
||||
with tenant_atomic(organization_id, using=INSTANCE_DB_ALIAS):
|
||||
organization = Organization(
|
||||
id=organization_id,
|
||||
name=clean.organization_name,
|
||||
slug=_unique_slug(clean.organization_name),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
)
|
||||
organization.save(force_insert=True)
|
||||
ensure_uncategorized_category(organization)
|
||||
owner = HumanUser.objects.create_user(
|
||||
email=clean.email,
|
||||
@@ -169,6 +174,7 @@ def _complete_setup(
|
||||
full_name=clean.full_name,
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
is_instance_admin=True,
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
|
||||
@@ -9,7 +9,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.auth.common import _user_payload
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.setup import (
|
||||
SetupAlreadyCompleted,
|
||||
SetupInput,
|
||||
@@ -23,22 +23,6 @@ def setup_closed() -> dict[str, str]:
|
||||
return {"detail": t("identity.setup_already_done")}
|
||||
|
||||
|
||||
def _validation_response(error: ValidationError) -> Response:
|
||||
if hasattr(error, "message_dict"):
|
||||
errors = {
|
||||
key: messages[0] if isinstance(messages, list) else str(messages)
|
||||
for key, messages in error.message_dict.items()
|
||||
}
|
||||
# validate_password кладёт сообщения без ключа поля.
|
||||
if "__all__" in errors:
|
||||
errors["password"] = " ".join(error.message_dict["__all__"])
|
||||
del errors["__all__"]
|
||||
detail = next(iter(errors.values()), t("setup.check_fields"))
|
||||
return Response({"detail": detail, "errors": errors}, status=400)
|
||||
message = " ".join(error.messages)
|
||||
return Response({"detail": message, "errors": {"password": message}}, status=400)
|
||||
|
||||
|
||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||
class SetupStatusView(APIView):
|
||||
authentication_classes: list = []
|
||||
@@ -75,7 +59,7 @@ class SetupView(APIView):
|
||||
except SetupAlreadyCompleted:
|
||||
return Response(setup_closed(), status=409)
|
||||
except ValidationError as error:
|
||||
return _validation_response(error)
|
||||
return validation_response(error)
|
||||
owner = result.owner
|
||||
owner.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
login(request, owner)
|
||||
|
||||
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
|
||||
name="Example",
|
||||
slug="administration",
|
||||
timezone="Europe/Moscow",
|
||||
currency="RUB",
|
||||
)
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@administration.test",
|
||||
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
|
||||
{
|
||||
"name": "Example",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
# Пустой язык — «как в установке»: организация своего не выбрала.
|
||||
"language": "",
|
||||
"logoUrl": None,
|
||||
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
|
||||
{
|
||||
"name": "Новая компания",
|
||||
"timezone": "Asia/Yekaterinburg",
|
||||
"currency": "rub",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
|
||||
self.organization.refresh_from_db()
|
||||
self.assertEqual(self.organization.name, "Новая компания")
|
||||
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
|
||||
self.assertEqual(self.organization.currency, "RUB")
|
||||
|
||||
def test_invalid_timezone_is_rejected(self) -> None:
|
||||
response = self.client.patch(
|
||||
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("timezone", response.json()["errors"])
|
||||
|
||||
def test_only_ruble_currency_is_accepted(self) -> None:
|
||||
response = self.client.patch(
|
||||
"/api/v1/company/administration/",
|
||||
{"currency": "USD"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("российский рубль", response.json()["errors"]["currency"])
|
||||
|
||||
def test_logo_upload_download_and_delete(self) -> None:
|
||||
png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
uploaded = self.client.post(
|
||||
@@ -172,16 +158,16 @@ class AdministrationApiTests(TestCase):
|
||||
"/api/v1/company/administration/logo/",
|
||||
{
|
||||
"file": SimpleUploadedFile(
|
||||
"logo.svg",
|
||||
b"<svg></svg>",
|
||||
content_type="image/svg+xml",
|
||||
"logo.txt",
|
||||
b"not an image at all",
|
||||
content_type="text/plain",
|
||||
)
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
|
||||
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
|
||||
|
||||
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
|
||||
record_audit_event(
|
||||
@@ -370,7 +356,7 @@ class InstanceAddressTests(TestCase):
|
||||
|
||||
def test_owner_sets_domain_and_scheme(self) -> None:
|
||||
response = self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "crm.example.com", "publicScheme": "https"},
|
||||
format="json",
|
||||
)
|
||||
@@ -383,7 +369,7 @@ class InstanceAddressTests(TestCase):
|
||||
def test_address_may_be_a_bare_ip(self) -> None:
|
||||
# Коробку часто так и оставляют: сервер по IP, без домена.
|
||||
response = self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "203.0.113.10", "publicScheme": "http"},
|
||||
format="json",
|
||||
)
|
||||
@@ -393,7 +379,7 @@ class InstanceAddressTests(TestCase):
|
||||
|
||||
def test_url_is_accepted_and_trimmed_to_host(self) -> None:
|
||||
response = self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "https://crm.example.com/settings", "publicScheme": "https"},
|
||||
format="json",
|
||||
)
|
||||
@@ -403,7 +389,7 @@ class InstanceAddressTests(TestCase):
|
||||
|
||||
def test_garbage_is_rejected(self) -> None:
|
||||
response = self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "не адрес!", "publicScheme": "ftp"},
|
||||
format="json",
|
||||
)
|
||||
@@ -421,7 +407,7 @@ class InstanceEmailTests(TestCase):
|
||||
|
||||
def patch(self, **email):
|
||||
return self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "crm.example.com", "publicScheme": "https", "email": email},
|
||||
format="json",
|
||||
)
|
||||
@@ -453,7 +439,7 @@ class InstanceEmailTests(TestCase):
|
||||
|
||||
def test_check_without_smtp_explains_itself(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/company/administration/instance/email-check/", {}, format="json"
|
||||
"/api/v1/instance/settings/email-check/", {}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Приглашение существующей учётной записи во вторую организацию.
|
||||
|
||||
Учётная запись глобальная, членство — по согласию человека: создание
|
||||
сотрудника с уже занятым e-mail выписывает приглашение, письмо уходит из
|
||||
воркера с новым токеном, а членство появляется после принятия.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.invitation_models import OrganizationInvitation
|
||||
from chatballs.identity.invitation_service import MEMBERSHIP_INVITATION_REQUESTED
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
PASSWORD = "Long-and-strong-passphrase-42"
|
||||
|
||||
|
||||
class InvitationTestBase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.first = Organization.objects.create(name="First", slug="inv-first")
|
||||
self.second = Organization.objects.create(name="Second", slug="inv-second")
|
||||
self.second_owner = HumanUser.objects.create_user(
|
||||
email="second-owner@example.test", password=PASSWORD, full_name="Second Owner"
|
||||
)
|
||||
self.second_membership = OrganizationMembership.objects.create(
|
||||
organization=self.second,
|
||||
user=self.second_owner,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Owner",
|
||||
)
|
||||
self.group = EmployeeGroup.objects.create(organization=self.second, name="Support")
|
||||
# Человек уже работает в первой организации.
|
||||
self.person = HumanUser.objects.create_user(
|
||||
email="person@example.test", password=PASSWORD, full_name="Person"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.person,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Operator",
|
||||
)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.second_owner)
|
||||
self.client.set_tenant(self.second)
|
||||
|
||||
def _create(self, email: str = "person@example.test"):
|
||||
return self.client.post(
|
||||
"/api/v1/employees/operators/",
|
||||
{
|
||||
"email": email,
|
||||
"fullName": "Person",
|
||||
"positionTitle": "Support operator",
|
||||
"role": EmployeeRole.EMPLOYEE,
|
||||
"passwordMode": "mail",
|
||||
"groupIds": [self.group.id],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
|
||||
class InviteExistingUserTests(InvitationTestBase):
|
||||
def test_existing_account_gets_an_invitation_instead_of_an_error(self) -> None:
|
||||
response = self._create()
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertTrue(response.json()["invited"])
|
||||
self.assertIsNone(response.json()["employee"])
|
||||
self.assertFalse(
|
||||
OrganizationMembership.objects.filter(user=self.person, organization=self.second).exists()
|
||||
)
|
||||
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
|
||||
self.assertEqual(invitation.role, EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(invitation.position_title, "Support operator")
|
||||
self.assertEqual(invitation.group_ids, [self.group.id])
|
||||
self.assertTrue(
|
||||
OutboxEvent.objects.filter(
|
||||
event_type=MEMBERSHIP_INVITATION_REQUESTED, organization=self.second
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_same_organization_still_reports_the_address_as_taken(self) -> None:
|
||||
response = self._create(email="second-owner@example.test")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(OrganizationInvitation.objects.filter(organization=self.second).exists())
|
||||
|
||||
def test_worker_sends_the_letter_and_the_link_accepts(self) -> None:
|
||||
self._create()
|
||||
event = OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED)
|
||||
|
||||
dispatch(event)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
letter = mail.outbox[0]
|
||||
self.assertEqual(letter.to, ["person@example.test"])
|
||||
self.assertIn("/join?token=", letter.body)
|
||||
token = letter.body.split("/join?token=", 1)[1].split()[0]
|
||||
|
||||
person_client = TenantAPIClient()
|
||||
person_client.force_authenticate(self.person)
|
||||
accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
|
||||
|
||||
self.assertEqual(accepted.status_code, 200, accepted.content)
|
||||
self.assertEqual(accepted.json()["organizationPublicId"], str(self.second.public_id))
|
||||
membership = OrganizationMembership.objects.get(user=self.person, organization=self.second)
|
||||
self.assertEqual(membership.role, EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(membership.position_title, "Support operator")
|
||||
self.assertEqual([link.group_id for link in membership.group_links.all()], [self.group.id])
|
||||
self.assertEqual(
|
||||
{item["organizationPublicId"] for item in accepted.json()["user"]["memberships"]},
|
||||
{str(self.first.public_id), str(self.second.public_id)},
|
||||
)
|
||||
|
||||
def test_reinvite_replaces_the_pending_invitation(self) -> None:
|
||||
self._create()
|
||||
first = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
|
||||
|
||||
self._create()
|
||||
|
||||
first.refresh_from_db()
|
||||
self.assertIsNotNone(first.revoked_at)
|
||||
self.assertEqual(
|
||||
OrganizationInvitation.objects.filter(
|
||||
organization=self.second, email="person@example.test", revoked_at__isnull=True
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_stranger_cannot_use_someone_elses_invitation(self) -> None:
|
||||
self._create()
|
||||
dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED))
|
||||
token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0]
|
||||
stranger = HumanUser.objects.create_user(email="stranger@example.test", password=PASSWORD)
|
||||
client = TenantAPIClient()
|
||||
client.force_authenticate(stranger)
|
||||
|
||||
response = client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(
|
||||
OrganizationMembership.objects.filter(user=stranger, organization=self.second).exists()
|
||||
)
|
||||
|
||||
|
||||
class PendingInvitationRowsTests(InvitationTestBase):
|
||||
"""Ожидающие приглашения видны в списке сотрудников и управляются оттуда."""
|
||||
|
||||
def test_list_shows_the_invitation_with_role_position_and_groups(self) -> None:
|
||||
self._create()
|
||||
|
||||
payload = self.client.get("/api/v1/employees/").json()
|
||||
|
||||
self.assertEqual(len(payload["invitations"]), 1)
|
||||
row = payload["invitations"][0]
|
||||
self.assertEqual(row["email"], "person@example.test")
|
||||
self.assertEqual(row["fullName"], "Person")
|
||||
self.assertEqual(row["role"], EmployeeRole.EMPLOYEE)
|
||||
self.assertEqual(row["positionTitle"], "Support operator")
|
||||
self.assertEqual([group["id"] for group in row["groups"]], [self.group.id])
|
||||
# Фильтры списка действуют и на приглашения.
|
||||
self.assertEqual(self.client.get("/api/v1/employees/?role=ADMIN").json()["invitations"], [])
|
||||
self.assertEqual(len(self.client.get("/api/v1/employees/?q=person").json()["invitations"]), 1)
|
||||
self.assertEqual(self.client.get("/api/v1/employees/?q=nobody").json()["invitations"], [])
|
||||
|
||||
def test_resend_extends_expiry_and_queues_a_new_letter(self) -> None:
|
||||
self._create()
|
||||
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
|
||||
OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).delete()
|
||||
|
||||
response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual(OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).count(), 1)
|
||||
refreshed = OrganizationInvitation.objects.get(pk=invitation.id)
|
||||
self.assertGreaterEqual(refreshed.expires_at, invitation.expires_at)
|
||||
|
||||
def test_revoke_hides_the_row_and_kills_the_link(self) -> None:
|
||||
self._create()
|
||||
dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED))
|
||||
token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0]
|
||||
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
|
||||
|
||||
response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual(self.client.get("/api/v1/employees/").json()["invitations"], [])
|
||||
person_client = TenantAPIClient()
|
||||
person_client.force_authenticate(self.person)
|
||||
accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json")
|
||||
self.assertEqual(accepted.status_code, 400)
|
||||
|
||||
def test_employee_cannot_manage_invitations(self) -> None:
|
||||
self._create()
|
||||
invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test")
|
||||
operator = HumanUser.objects.create_user(email="operator@example.test", password=PASSWORD)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.second, user=operator, role=EmployeeRole.EMPLOYEE, position_title="Operator"
|
||||
)
|
||||
client = TenantAPIClient()
|
||||
client.force_authenticate(operator)
|
||||
client.set_tenant(self.second)
|
||||
|
||||
self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/").status_code, 403)
|
||||
self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/").status_code, 403)
|
||||
@@ -12,15 +12,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity import instance_settings
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.instance_settings import (
|
||||
InstanceSettings,
|
||||
accepted_hosts,
|
||||
invalidate_cache,
|
||||
)
|
||||
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
@@ -42,7 +46,7 @@ class InstanceAddressChangeTests(TestCase):
|
||||
|
||||
def _patch(self, host: str, scheme: str = "https"):
|
||||
return self.client.patch(
|
||||
"/api/v1/company/administration/instance/",
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": host, "publicScheme": scheme},
|
||||
format="json",
|
||||
)
|
||||
@@ -119,3 +123,132 @@ class PortalDomainCollisionTests(TestCase):
|
||||
portal.clean() # не должно бросать
|
||||
|
||||
self.assertEqual(portal.custom_domain, "help.example.test")
|
||||
|
||||
|
||||
class InstanceSettingsAccessTests(TestCase):
|
||||
"""Настройки установки меняет только её администратор.
|
||||
|
||||
Владелец другой организации видит адреса TURN (карточка relay), но не может
|
||||
переключить общий SMTP или адрес установки; сотрудник не видит ничего.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="instance-owner@example.com", password=PASSWORD)
|
||||
self.other_org = Organization.objects.create(name="Other", slug="other-org")
|
||||
self.other_owner = HumanUser.objects.create_user(
|
||||
email="other-owner@example.com", password=PASSWORD, full_name="Other Owner"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.other_org,
|
||||
user=self.other_owner,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Owner",
|
||||
)
|
||||
self.employee = HumanUser.objects.create_user(
|
||||
email="employee@example.com", password=PASSWORD, full_name="Employee"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.other_org,
|
||||
user=self.employee,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Operator",
|
||||
)
|
||||
|
||||
def _client(self, user: HumanUser) -> TenantAPIClient:
|
||||
client = TenantAPIClient()
|
||||
client.force_authenticate(user)
|
||||
return client
|
||||
|
||||
def test_setup_owner_is_instance_admin_and_may_change_settings(self) -> None:
|
||||
self.assertTrue(self.result.owner.is_instance_admin)
|
||||
response = self._client(self.result.owner).patch(
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "crm.example.test", "publicScheme": "https"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_organization_owner_reads_but_cannot_change(self) -> None:
|
||||
client = self._client(self.other_owner)
|
||||
self.assertEqual(client.get("/api/v1/instance/settings/").status_code, 200)
|
||||
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 200)
|
||||
denied = client.patch(
|
||||
"/api/v1/instance/settings/",
|
||||
{"publicHost": "crm.example.test", "publicScheme": "https"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
self.assertEqual(
|
||||
client.post("/api/v1/instance/settings/email-check/", {}, format="json").status_code, 403
|
||||
)
|
||||
self.assertEqual(
|
||||
client.patch("/api/v1/instance/storage/", {"backend": "LOCAL"}, format="json").status_code,
|
||||
403,
|
||||
)
|
||||
|
||||
def test_employee_sees_nothing(self) -> None:
|
||||
client = self._client(self.employee)
|
||||
self.assertEqual(client.get("/api/v1/instance/settings/").status_code, 403)
|
||||
self.assertEqual(client.get("/api/v1/instance/storage/").status_code, 403)
|
||||
|
||||
def test_session_reports_the_flag(self) -> None:
|
||||
admin_session = self._client(self.result.owner).get("/api/v1/auth/session/").json()
|
||||
owner_session = self._client(self.other_owner).get("/api/v1/auth/session/").json()
|
||||
self.assertTrue(admin_session["user"]["isInstanceAdmin"])
|
||||
self.assertFalse(owner_session["user"]["isInstanceAdmin"])
|
||||
|
||||
def test_old_organization_scoped_paths_are_gone(self) -> None:
|
||||
client = self._client(self.result.owner)
|
||||
client.organization_public_id = str(self.result.organization.public_id)
|
||||
response = client.get(
|
||||
f"/api/v1/organizations/{self.result.organization.public_id}/company/administration/instance/"
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class StaleHostCacheTests(TestCase):
|
||||
"""Адрес, записанный мастером в одном процессе, принимает и соседний.
|
||||
|
||||
Кэш адреса живёт в каждом процессе gunicorn по 10 секунд. Соседний процесс
|
||||
с устаревшим кэшем отвечал «Invalid host» на первый же запрос после
|
||||
мастера — в интерфейсе это «Ошибка загрузки», исчезавшая после обновления
|
||||
страницы. Промах по хосту теперь перечитывает кэш.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.result = bootstrap_owner(email="cache-owner@example.com", password=PASSWORD)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.result.owner)
|
||||
# Строка настроек должна существовать: update() ниже её не создаёт.
|
||||
InstanceSettings.load()
|
||||
self.addCleanup(invalidate_cache)
|
||||
|
||||
def _stale_cache_with_no_host(self) -> None:
|
||||
# Соседний процесс: только что прочитал пустой адрес, TTL ещё не вышел.
|
||||
instance_settings._cached = (time.monotonic(), ("", ""))
|
||||
instance_settings._last_miss_refresh = 0.0
|
||||
|
||||
def test_host_written_by_another_process_is_accepted_at_once(self) -> None:
|
||||
self._stale_cache_with_no_host()
|
||||
# Запись мимо save(): invalidate_cache() в этом процессе не вызывается,
|
||||
# как и в реальности, где мастер отработал в другом воркере.
|
||||
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
|
||||
public_host="crm.example.test"
|
||||
)
|
||||
|
||||
response = self.client.get("/api/v1/auth/session/", HTTP_HOST="crm.example.test")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
|
||||
def test_unknown_host_does_not_reread_more_than_once_a_second(self) -> None:
|
||||
InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).update(
|
||||
public_host="crm.example.test"
|
||||
)
|
||||
self._stale_cache_with_no_host()
|
||||
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
|
||||
# Первый промах перечитал кэш и уже знает настоящий адрес.
|
||||
self.assertEqual(set(accepted_hosts()), {"crm.example.test"})
|
||||
# Второй промах в ту же секунду базу не трогает: кэш подменён, но не перечитан.
|
||||
instance_settings._cached = (time.monotonic(), ("", ""))
|
||||
self.assertEqual(self.client.get("/api/v1/auth/session/", HTTP_HOST="evil.example").status_code, 400)
|
||||
self.assertEqual(accepted_hosts(), ())
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
|
||||
|
||||
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
|
||||
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
|
||||
заголовки как второй рубеж.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.logo_svg import svg_is_safe
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
CLEAN_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64">
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
|
||||
<style>.mark { fill: url(#g); }</style>
|
||||
<circle class="mark" cx="32" cy="32" r="30"/>
|
||||
<use xlink:href="#mark"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
class SvgSafetyTests(TestCase):
|
||||
def test_clean_svg_is_accepted(self) -> None:
|
||||
self.assertTrue(svg_is_safe(CLEAN_SVG))
|
||||
|
||||
def test_dangerous_svg_is_rejected(self) -> None:
|
||||
samples = {
|
||||
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
|
||||
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
|
||||
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
|
||||
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
|
||||
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
|
||||
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
|
||||
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
"not svg": b'<html><body>hi</body></html>',
|
||||
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
|
||||
}
|
||||
for name, sample in samples.items():
|
||||
with self.subTest(name):
|
||||
self.assertFalse(svg_is_safe(sample))
|
||||
|
||||
|
||||
class SvgLogoApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(result.owner)
|
||||
|
||||
def _upload(self, data: bytes):
|
||||
return self.client.post(
|
||||
"/api/v1/company/administration/logo/",
|
||||
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
|
||||
uploaded = self._upload(CLEAN_SVG)
|
||||
|
||||
self.assertEqual(uploaded.status_code, 200, uploaded.content)
|
||||
downloaded = self.client.get("/api/v1/company/administration/logo/")
|
||||
self.assertEqual(downloaded.status_code, 200)
|
||||
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
|
||||
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
|
||||
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
|
||||
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
|
||||
|
||||
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
|
||||
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("file", response.json()["errors"])
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
URL = "/api/v1/organizations/"
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
|
||||
class OrganizationCreationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.owner,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Владелец",
|
||||
)
|
||||
self.employee = HumanUser.objects.create_user(
|
||||
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.employee,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
)
|
||||
|
||||
def _post(self, user: HumanUser, **body):
|
||||
client = TenantAPIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
URL,
|
||||
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
|
||||
response = self._post(self.owner, language="en")
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
payload = response.json()
|
||||
created = Organization.objects.get(public_id=payload["organizationPublicId"])
|
||||
self.assertEqual(created.name, "Вторая компания")
|
||||
self.assertEqual(created.language, "en")
|
||||
self.assertEqual(created.status, "ACTIVE")
|
||||
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
|
||||
self.assertEqual(membership.role, EmployeeRole.OWNER)
|
||||
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
|
||||
# Список членств в ответе уже содержит новую организацию: интерфейсу
|
||||
# есть куда переключиться без повторного запроса сессии.
|
||||
self.assertEqual(
|
||||
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
|
||||
{str(self.first.public_id), str(created.public_id)},
|
||||
)
|
||||
self.assertTrue(
|
||||
AuditEvent.objects.filter(
|
||||
organization=created, action="organization.created", actor=self.owner
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_same_name_gets_a_distinct_slug(self) -> None:
|
||||
first = self._post(self.owner).json()["organizationPublicId"]
|
||||
second = self._post(self.owner).json()["organizationPublicId"]
|
||||
|
||||
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
|
||||
self.assertEqual(len(slugs), 2)
|
||||
|
||||
def test_employee_cannot_create_organizations(self) -> None:
|
||||
response = self._post(self.employee)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(Organization.objects.count(), 1)
|
||||
|
||||
def test_instance_admin_without_memberships_can_create(self) -> None:
|
||||
admin = HumanUser.objects.create_user(
|
||||
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
|
||||
)
|
||||
|
||||
response = self._post(admin)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
|
||||
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
|
||||
|
||||
def test_empty_name_is_a_field_error(self) -> None:
|
||||
response = self._post(self.owner, name=" ")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("name", response.json()["errors"])
|
||||
self.assertEqual(Organization.objects.count(), 1)
|
||||
|
||||
def test_anonymous_is_rejected(self) -> None:
|
||||
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
|
||||
|
||||
self.assertIn(response.status_code, {401, 403})
|
||||
|
||||
def test_options_list_timezones_and_languages(self) -> None:
|
||||
client = TenantAPIClient()
|
||||
client.force_login(self.owner)
|
||||
|
||||
response = client.get(f"{URL}options/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Europe/Moscow", response.json()["timezones"])
|
||||
self.assertTrue(response.json()["languages"])
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Приглашение владельца из платформенного провижининга доходит до человека.
|
||||
|
||||
Учётной записи может ещё не быть: письмо уходит из воркера, а по ссылке
|
||||
человек задаёт имя и пароль, принимает приглашение и оказывается владельцем
|
||||
активированной организации. Существующая учётная запись идёт на обычный вход.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core import mail
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.identity.invitation_service import OWNER_INVITATION_REQUESTED
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
from chatballs.platform.provisioning_command import ProvisioningCommand
|
||||
from chatballs.platform.provisioning_service import provision_organization
|
||||
from chatballs.platform.testing import create_platform_operator
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
PASSWORD = "Very-strong-passphrase-42"
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
|
||||
class OwnerInvitationFlowTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.operator, _ = create_platform_operator()
|
||||
|
||||
def _provision(self, email: str = "new-owner@example.test"):
|
||||
return provision_organization(
|
||||
command=ProvisioningCommand(
|
||||
organization_name="Fresh Co",
|
||||
organization_slug="fresh-co",
|
||||
owner_email=email,
|
||||
source="PLATFORM_OPERATOR",
|
||||
idempotency_key=f"idem-{email}",
|
||||
),
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
def _token_from_mail(self) -> str:
|
||||
return mail.outbox[-1].body.split("/join?token=", 1)[1].split()[0]
|
||||
|
||||
def test_letter_goes_out_and_a_new_person_registers_as_owner(self) -> None:
|
||||
result = self._provision()
|
||||
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].to, ["new-owner@example.test"])
|
||||
token = self._token_from_mail()
|
||||
client = TenantAPIClient()
|
||||
|
||||
preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json()
|
||||
self.assertEqual(preview["valid"], True)
|
||||
self.assertEqual(preview["organizationName"], "Fresh Co")
|
||||
self.assertFalse(preview["accountExists"])
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/invitations/register/",
|
||||
{"token": token, "fullName": "New Owner", "password": PASSWORD},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
body = response.json()
|
||||
self.assertEqual(body["organizationPublicId"], str(result.organization.public_id))
|
||||
self.assertEqual(body["user"]["email"], "new-owner@example.test")
|
||||
self.assertFalse(body["user"]["isInstanceAdmin"])
|
||||
result.organization.refresh_from_db()
|
||||
self.assertEqual(result.organization.status, OrganizationStatus.ACTIVE)
|
||||
owner = HumanUser.objects.get(email="new-owner@example.test")
|
||||
self.assertTrue(owner.check_password(PASSWORD))
|
||||
self.assertFalse(owner.must_change_password)
|
||||
self.assertTrue(
|
||||
OrganizationMembership.objects.filter(
|
||||
organization=result.organization, user=owner, role=EmployeeRole.OWNER
|
||||
).exists()
|
||||
)
|
||||
# Сессия установлена: приложение сразу открывается под владельцем.
|
||||
self.assertTrue(client.get("/api/v1/auth/session/").json()["authenticated"])
|
||||
|
||||
def test_weak_password_and_empty_name_are_reported_by_field(self) -> None:
|
||||
self._provision()
|
||||
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
|
||||
token = self._token_from_mail()
|
||||
client = TenantAPIClient()
|
||||
|
||||
weak = client.post(
|
||||
"/api/v1/auth/invitations/register/",
|
||||
{"token": token, "fullName": "New Owner", "password": "short"},
|
||||
format="json",
|
||||
)
|
||||
nameless = client.post(
|
||||
"/api/v1/auth/invitations/register/",
|
||||
{"token": token, "fullName": " ", "password": PASSWORD},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(weak.status_code, 400)
|
||||
self.assertIn("password", weak.json()["errors"])
|
||||
self.assertEqual(nameless.status_code, 400)
|
||||
self.assertIn("fullName", nameless.json()["errors"])
|
||||
self.assertFalse(HumanUser.objects.filter(email="new-owner@example.test").exists())
|
||||
|
||||
def test_existing_account_is_sent_to_login_and_cannot_register(self) -> None:
|
||||
HumanUser.objects.create_user(email="known@example.test", password=PASSWORD, is_active=True)
|
||||
# Активная учётная запись получает владение сразу, приглашение не нужно:
|
||||
# проверяем ветку через приглашение сотрудника той же организации.
|
||||
result = self._provision(email="known-owner@example.test")
|
||||
dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED))
|
||||
token = self._token_from_mail()
|
||||
HumanUser.objects.create_user(email="known-owner@example.test", password=PASSWORD)
|
||||
client = TenantAPIClient()
|
||||
|
||||
preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json()
|
||||
register = client.post(
|
||||
"/api/v1/auth/invitations/register/",
|
||||
{"token": token, "fullName": "Someone", "password": PASSWORD},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertTrue(preview["accountExists"])
|
||||
self.assertEqual(register.status_code, 400)
|
||||
self.assertEqual(register.json()["code"], "account_exists")
|
||||
self.assertEqual(result.organization.status, OrganizationStatus.PENDING_OWNER)
|
||||
|
||||
def test_unknown_token_previews_as_invalid(self) -> None:
|
||||
response = TenantAPIClient().get("/api/v1/auth/invitations/preview/?token=nope")
|
||||
self.assertEqual(response.json(), {"valid": False})
|
||||
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
|
||||
# Настройки установки (адрес, по которому её открывают) — тоже одна
|
||||
# строка на инстанс: их пишет мастер первого запуска, а не демо.
|
||||
("identity", "instancesettings"),
|
||||
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
|
||||
("updates", "updatestate"),
|
||||
}
|
||||
COVERAGE_EXEMPT_APPS = {"platform", "events"}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ class SetupWizardTests(TestCase):
|
||||
owner = HumanUser.objects.get(email="e.kuznetsova@atelie-nord.ru")
|
||||
self.assertTrue(owner.check_password(VALID["password"]))
|
||||
self.assertTrue(owner.is_superuser)
|
||||
# Владелец из мастера — администратор установки: раздел «Платформа» его.
|
||||
self.assertTrue(owner.is_instance_admin)
|
||||
self.assertTrue(user["isInstanceAdmin"])
|
||||
membership = OrganizationMembership.objects.get(user=owner, organization=organization)
|
||||
self.assertEqual(membership.role, EmployeeRole.OWNER)
|
||||
self.assertTrue(KnowledgeCategory.objects.filter(organization=organization).exists())
|
||||
|
||||
@@ -12,6 +12,7 @@ import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations import transports
|
||||
@@ -39,7 +40,11 @@ def notifier_integrations(context):
|
||||
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
|
||||
config__purpose=NOTIFIER_PURPOSE,
|
||||
).exclude(secret="")
|
||||
return qs.filter(organization=context.organization)
|
||||
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
|
||||
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
|
||||
return qs.filter(organization=context.organization).filter(
|
||||
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
|
||||
)
|
||||
|
||||
|
||||
def deep_link(integration: Integration, code: str) -> str:
|
||||
|
||||
@@ -24,7 +24,6 @@ def provisioning_result_payload(
|
||||
"slug": organization.slug,
|
||||
"status": organization.status,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
},
|
||||
"provisioning": {
|
||||
"status": provisioning.status,
|
||||
|
||||
@@ -21,7 +21,6 @@ class ProvisioningCommand:
|
||||
source: str
|
||||
idempotency_key: str
|
||||
timezone: str = "Europe/Moscow"
|
||||
currency: str = "RUB"
|
||||
locale: str = ""
|
||||
legal_name: str = ""
|
||||
tax_profile: dict[str, Any] | None = None
|
||||
|
||||
@@ -8,6 +8,8 @@ from django.utils import timezone
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.invitation_service import issue_invitation
|
||||
from chatballs.identity.models import (
|
||||
@@ -156,7 +158,6 @@ def _create_organization(
|
||||
slug=command.organization_slug.strip(),
|
||||
status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER,
|
||||
timezone=command.timezone,
|
||||
currency=command.currency.upper(),
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise ProvisioningValidation(str(error), code="organization_invalid") from error
|
||||
@@ -173,7 +174,7 @@ def _provision_active_owner(
|
||||
user=owner_user,
|
||||
organization=org,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title=_owner_position_title(),
|
||||
position_title=_owner_position_title(org),
|
||||
)
|
||||
record_audit_event(
|
||||
action="organization.provisioned",
|
||||
@@ -228,8 +229,10 @@ def _provision_pending_owner(
|
||||
)
|
||||
|
||||
|
||||
def _owner_position_title() -> str:
|
||||
return "Владелец"
|
||||
def _owner_position_title(org: Organization) -> str:
|
||||
# Должность хранится текстом, поэтому пишется сразу на языке организации,
|
||||
# а не оператора платформы: переводить её потом будет нечем.
|
||||
return t("setup.owner_position", language=customer_language(org))
|
||||
|
||||
|
||||
def _safe_message(error: Exception) -> str:
|
||||
|
||||
@@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase):
|
||||
"slug": "acme",
|
||||
"owner_email": "owner-acme@example.test",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY="idem-api-1",
|
||||
@@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase):
|
||||
"slug": "acme",
|
||||
"owner_email": "owner-acme@example.test",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY="idem-api-2",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TransactionTestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.platform.models import OrganizationProvisioning, ProvisioningStatus
|
||||
from chatballs.platform.testing import create_platform_operator
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
|
||||
class PlatformRoleProvisioningTests(TransactionTestCase):
|
||||
"""Провижининг под реальной runtime-ролью platform (tenancy/0031).
|
||||
|
||||
Остальные тесты ходят в базу владельцем кластера и не заметили бы
|
||||
отсутствующий GRANT: в деплое backend-platform работает ролью
|
||||
chatballs_platform, и без прав на платформенные таблицы уже проверка
|
||||
токена падала с «permission denied». Здесь весь HTTP-запрос выполняется
|
||||
под этой ролью — от чтения токена до записи приглашения владельца.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.operator, self.token = create_platform_operator()
|
||||
|
||||
def _post(self, token: str, *, slug: str, owner_email: str, key: str):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Token {token}")
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SET ROLE chatballs_runtime_platform")
|
||||
try:
|
||||
return client.post(
|
||||
"/api/v1/organizations",
|
||||
data={
|
||||
"name": "Role Co",
|
||||
"slug": slug,
|
||||
"owner_email": owner_email,
|
||||
"timezone": "Europe/Moscow",
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY=key,
|
||||
)
|
||||
finally:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("RESET ROLE")
|
||||
|
||||
def test_unknown_token_is_rejected_not_crashed(self) -> None:
|
||||
response = self._post(
|
||||
"ctp_not_a_real_token", slug="role-co", owner_email="x@example.test", key="k0"
|
||||
)
|
||||
self.assertEqual(response.status_code, 401, response.content)
|
||||
|
||||
def test_active_owner_is_provisioned_under_platform_role(self) -> None:
|
||||
HumanUser.objects.create_user(email="role-owner@example.test")
|
||||
response = self._post(
|
||||
self.token, slug="role-co", owner_email="role-owner@example.test", key="k1"
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["owner"]["state"], "active")
|
||||
self.assertEqual(payload["provisioning"]["status"], ProvisioningStatus.COMPLETED)
|
||||
self.assertTrue(
|
||||
OrganizationMembership.objects.filter(
|
||||
organization__slug="role-co", role=EmployeeRole.OWNER
|
||||
).exists()
|
||||
)
|
||||
record = OrganizationProvisioning.objects.get(idempotency_key="k1")
|
||||
self.assertEqual(record.status, ProvisioningStatus.COMPLETED)
|
||||
|
||||
def test_pending_owner_gets_invitation_under_platform_role(self) -> None:
|
||||
response = self._post(
|
||||
self.token, slug="role-co", owner_email="new-owner@example.test", key="k2"
|
||||
)
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertEqual(response.json()["owner"]["state"], "pending_invitation")
|
||||
self.assertTrue(
|
||||
OrganizationInvitation.objects.filter(
|
||||
organization__slug="role-co",
|
||||
email="new-owner@example.test",
|
||||
role=EmployeeRole.OWNER,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_replay_is_idempotent_under_platform_role(self) -> None:
|
||||
HumanUser.objects.create_user(email="role-owner@example.test")
|
||||
first = self._post(
|
||||
self.token, slug="role-co", owner_email="role-owner@example.test", key="k3"
|
||||
)
|
||||
second = self._post(
|
||||
self.token, slug="role-co", owner_email="role-owner@example.test", key="k3"
|
||||
)
|
||||
self.assertEqual(first.status_code, 201, first.content)
|
||||
self.assertEqual(second.status_code, 200, second.content)
|
||||
self.assertEqual(OrganizationProvisioning.objects.filter(idempotency_key="k3").count(), 1)
|
||||
@@ -10,7 +10,6 @@ _REQUIRED_FIELDS = (
|
||||
"slug",
|
||||
"owner_email",
|
||||
"timezone",
|
||||
"currency",
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +31,6 @@ def parse_provisioning_body(
|
||||
source=source,
|
||||
idempotency_key=idempotency_key,
|
||||
timezone=str(body.get("timezone", "Europe/Moscow")),
|
||||
currency=str(body.get("currency", "RUB")),
|
||||
locale=str(body.get("locale", "")),
|
||||
legal_name=str(body.get("legal_name", "")),
|
||||
tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None,
|
||||
|
||||
@@ -13,6 +13,7 @@ from chatballs.platform.payloads import owner_state_for, provisioning_result_pay
|
||||
from chatballs.platform.permissions import HasPlatformCapability
|
||||
from chatballs.platform.provisioning_service import provision_organization
|
||||
from chatballs.platform.validation import parse_provisioning_body
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
_IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
|
||||
@@ -44,9 +45,12 @@ class OrganizationProvisionView(APIView):
|
||||
result = provision_organization(command=command, operator=request.user)
|
||||
except ProvisioningError as error:
|
||||
return Response({"detail": str(error)}, status=error.status_code)
|
||||
owner_membership = OrganizationMembership.objects.filter(
|
||||
organization=result.organization, role=EmployeeRole.OWNER
|
||||
).first()
|
||||
# Членство — тенантная строка: под ролью platform без tenant-контекста
|
||||
# RLS её не покажет, и ответ назвал бы активного владельца ожидающим.
|
||||
with tenant_atomic(result.organization.pk):
|
||||
owner_membership = OrganizationMembership.objects.filter(
|
||||
organization=result.organization, role=EmployeeRole.OWNER
|
||||
).first()
|
||||
payload = provisioning_result_payload(
|
||||
provisioning=result.provisioning,
|
||||
organization=result.organization,
|
||||
|
||||
@@ -3,12 +3,12 @@ from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.support_portals.content_services import INLINE_CONTENT_TYPES
|
||||
from chatballs.support_portals.models import PortalArticleFile
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import portal_article_file_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
class PortalArticleFileView(APIView):
|
||||
@@ -26,10 +26,9 @@ class PortalArticleFileView(APIView):
|
||||
route = portal_article_file_route(str(public_id))
|
||||
if route is None:
|
||||
raise Http404
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist as error:
|
||||
raise Http404 from error
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise Http404
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
article_file = PortalArticleFile.objects.filter(
|
||||
|
||||
@@ -41,11 +41,13 @@ class SupportPortalHostBoundaryMiddleware:
|
||||
return True
|
||||
# Адреса, которые человек задал сам: тот, на котором прошли мастер, и
|
||||
# предыдущий — чтобы смена адреса в «Настройках» не выбрасывала того,
|
||||
# кто её делает, до того как новый домен вообще заработал.
|
||||
from chatballs.identity.instance_settings import accepted_hosts
|
||||
# кто её делает, до того как новый домен вообще заработал. Промах
|
||||
# перечитывает кэш: соседний процесс gunicorn мог ещё не увидеть адрес,
|
||||
# который мастер записал секунду назад.
|
||||
from chatballs.identity.instance_settings import host_is_accepted
|
||||
|
||||
try:
|
||||
if host and host in {normalize_domain(item) for item in accepted_hosts()}:
|
||||
if host_is_accepted(host):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -5,7 +5,6 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.support_portals.content_services import record_feedback
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
from chatballs.support_portals.selectors import category_article_counts, public_articles
|
||||
@@ -17,6 +16,7 @@ from chatballs.support_portals.serializers import (
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import support_portal_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
class PublicPortalView(APIView):
|
||||
@@ -28,9 +28,8 @@ class PublicPortalView(APIView):
|
||||
route = support_portal_route(hostname)
|
||||
if route is None:
|
||||
return None
|
||||
try:
|
||||
organization = Organization.objects.get(id=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
return None
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
|
||||
|
||||
@@ -14,8 +13,9 @@ class IngressRoute:
|
||||
|
||||
|
||||
def _rows(query: str, parameters: list[Any]) -> list[tuple]:
|
||||
alias = "default" if settings.TESTING else "platform"
|
||||
with connections[alias].cursor() as cursor:
|
||||
# Каталоги — security-barrier вьюхи, на них есть SELECT у роли app
|
||||
# (tenancy/0032): чтение идёт по основному соединению процесса.
|
||||
with connections["default"].cursor() as cursor:
|
||||
cursor.execute(query, parameters)
|
||||
return list(cursor.fetchall())
|
||||
|
||||
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
|
||||
return _unique_route("call_invite_directory", token_hash)
|
||||
|
||||
|
||||
def invitation_route(token_hash: str) -> IngressRoute | None:
|
||||
"""Приглашение в организацию по хэшу токена из письма (/join).
|
||||
|
||||
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
|
||||
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
|
||||
"""
|
||||
|
||||
return _unique_route("invitation_directory", token_hash)
|
||||
|
||||
|
||||
def call_session_route(call_session_id: str) -> IngressRoute | None:
|
||||
return _unique_route("call_session_directory", call_session_id)
|
||||
|
||||
@@ -84,3 +94,45 @@ def support_portal_route(hostname: str) -> IngressRoute | None:
|
||||
return _unique_route("support_portal_directory", hostname.strip().lower().rstrip("."))
|
||||
|
||||
|
||||
# Каталог организаций: id по публичному id или слагу, публичный id по id и
|
||||
# список всех id. Роль app видит строку организации только в её контексте
|
||||
# (tenancy/0033), а сюда приходят до того, как контекст открыт.
|
||||
def organization_route_by_public_id(public_id: str) -> IngressRoute | None:
|
||||
rows = _rows(
|
||||
"SELECT organization_id, public_id FROM chatballs.organization_directory "
|
||||
"WHERE public_id = %s::uuid",
|
||||
[public_id],
|
||||
)
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1]))
|
||||
|
||||
|
||||
def organization_route_by_slug(slug: str) -> IngressRoute | None:
|
||||
rows = _rows(
|
||||
"SELECT organization_id, slug FROM chatballs.organization_directory WHERE slug = %s",
|
||||
[slug],
|
||||
)
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
return IngressRoute(organization_id=int(rows[0][0]), resource_id=str(rows[0][1]))
|
||||
|
||||
|
||||
def organization_public_id_of(organization_id: int) -> str | None:
|
||||
rows = _rows(
|
||||
"SELECT public_id FROM chatballs.organization_directory WHERE organization_id = %s",
|
||||
[int(organization_id)],
|
||||
)
|
||||
return str(rows[0][0]) if rows else None
|
||||
|
||||
|
||||
def organization_ids() -> list[int]:
|
||||
return [
|
||||
int(row[0])
|
||||
for row in _rows(
|
||||
"SELECT organization_id FROM chatballs.organization_directory ORDER BY organization_id",
|
||||
[],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Поиск организации там, где tenant-контекста ещё нет.
|
||||
|
||||
Роль app видит строку организации только в контексте этой организации
|
||||
(tenancy/0033). Входы, которые начинаются с внешнего ключа — адрес, публичный
|
||||
id, слаг, id из outbox-события, — сначала находят id через security-barrier
|
||||
каталог ``chatballs.organization_directory`` и лишь затем открывают контекст и
|
||||
читают строку. Так процесс приложения не перечисляет чужие организации.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from django.db import DEFAULT_DB_ALIAS, connections
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import (
|
||||
organization_ids,
|
||||
organization_route_by_public_id,
|
||||
organization_route_by_slug,
|
||||
)
|
||||
|
||||
|
||||
def load_organization(organization_id: int) -> Organization | None:
|
||||
"""Строка организации по id: читается в её собственном контексте."""
|
||||
|
||||
with tenant_atomic(int(organization_id)):
|
||||
return Organization.objects.filter(pk=organization_id).first()
|
||||
|
||||
|
||||
def organization_by_public_id(public_id: object) -> Organization | None:
|
||||
route = organization_route_by_public_id(str(public_id))
|
||||
return load_organization(route.organization_id) if route is not None else None
|
||||
|
||||
|
||||
def organization_by_slug(slug: str) -> Organization | None:
|
||||
route = organization_route_by_slug(slug)
|
||||
return load_organization(route.organization_id) if route is not None else None
|
||||
|
||||
|
||||
def iter_organizations() -> Iterator[Organization]:
|
||||
"""Все организации установки по одной, каждая в своём контексте (воркер)."""
|
||||
|
||||
for organization_id in organization_ids():
|
||||
organization = load_organization(organization_id)
|
||||
if organization is not None:
|
||||
yield organization
|
||||
|
||||
|
||||
def reserve_organization_id(*, using: str = DEFAULT_DB_ALIAS) -> int:
|
||||
"""Выделить id для будущей организации до INSERT.
|
||||
|
||||
Роль app видит строку организации только в её контексте, а INSERT с
|
||||
RETURNING обязан вернуть видимую строку. Поэтому мастер первого запуска
|
||||
берёт id из последовательности заранее, открывает контекст этого id и уже
|
||||
в нём вставляет строку.
|
||||
"""
|
||||
|
||||
with connections[using].cursor() as cursor:
|
||||
cursor.execute("SELECT nextval('identity_organization_id_seq')")
|
||||
return int(cursor.fetchone()[0])
|
||||
|
||||
|
||||
def instance_has_organizations(*, using: str = DEFAULT_DB_ALIAS) -> bool:
|
||||
"""Есть ли в установке хоть одна организация — без tenant-контекста.
|
||||
|
||||
Проверку делает SECURITY DEFINER-функция (tenancy/0032): та же, что держит
|
||||
политику «первая организация» мастера первого запуска.
|
||||
"""
|
||||
|
||||
with connections[using].cursor() as cursor:
|
||||
cursor.execute("SELECT chatballs.instance_has_organizations()")
|
||||
return bool(cursor.fetchone()[0])
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user