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 |
No files matched your search
@@ -39,6 +39,7 @@ 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:
|
||||
@@ -78,6 +79,7 @@ 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
|
||||
@@ -85,6 +87,8 @@ jobs:
|
||||
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 }}
|
||||
@@ -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
|
||||
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -15,6 +15,7 @@ from chatballs.notifications.binding import poll_notifier_bots
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import iter_organizations
|
||||
from chatballs.updates.services import check_for_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,6 +93,12 @@ class Command(BaseCommand):
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
# интервал держит сама проверка по времени последнего ответа.
|
||||
try:
|
||||
check_for_updates()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Update check cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Choose a logo file",
|
||||
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
|
||||
"admin.employee_not_found": "Operator not found",
|
||||
"admin.group_name_taken": "A group with this name already exists",
|
||||
"admin.group_not_found": "Group not found",
|
||||
@@ -98,6 +97,8 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "New message · {contact}",
|
||||
"notifications.operator_needed": "An operator is needed · {contact}",
|
||||
"notifications.voice_without_transcript": "A voice message without a transcript",
|
||||
"onboarding.membership_required": "Onboarding is available to organization members",
|
||||
"onboarding.unknown_action": "Unknown onboarding action",
|
||||
"portals.article_not_found": "Article not found",
|
||||
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
|
||||
"portals.file_not_found": "File not found",
|
||||
@@ -111,7 +112,7 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_section": "Invalid section",
|
||||
"portals.not_found": "Portal not found",
|
||||
"portals.section_not_found": "Section not found",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
|
||||
"profile.choose_photo_file": "Choose a photo file",
|
||||
"profile.link_not_found": "Link not found",
|
||||
"profile.notification_bot_not_found": "Notification bot not found",
|
||||
@@ -213,6 +214,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_identity_employee_invited": "Operator invited",
|
||||
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
|
||||
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
|
||||
"audit.action_updates_install_requested": "Installation update started",
|
||||
"audit.action_identity_employee_groups_changed": "Operator groups changed",
|
||||
"audit.action_identity_employee_password_reset": "Operator password reset",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
|
||||
@@ -246,6 +248,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Integration added",
|
||||
"audit.action_integrations_integration_deleted": "Integration deleted",
|
||||
"audit.action_integrations_integration_updated": "Integration changed",
|
||||
"audit.action_organization_created": "Organization created from the interface",
|
||||
"audit.action_organization_owner_activated": "Organization owner activated",
|
||||
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
|
||||
"audit.action_organization_provisioned": "Organization created",
|
||||
@@ -331,8 +334,12 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.invalid_credentials": "Invalid credentials",
|
||||
"identity.invalid_totp_code": "Invalid TOTP code",
|
||||
"identity.invitation_email_mismatch": "Invitation email does not match the account",
|
||||
"identity.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
|
||||
"identity.invitation_invalid": "Invitation is invalid or has expired",
|
||||
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
|
||||
"updates.nothing_to_install": "The latest version is already installed",
|
||||
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
|
||||
"updates.install_in_progress": "An update is already in progress",
|
||||
"identity.role_conflict": "User already has a different role in this organization",
|
||||
"identity.token_required": "token is required",
|
||||
"identity.totp_challenge_inactive": "TOTP challenge is not active",
|
||||
@@ -355,7 +362,8 @@ MESSAGES: dict[str, object] = {
|
||||
"api.expected_record_id": "{name}: a record identifier is expected",
|
||||
"settings.storage_unavailable": "The storage is unavailable: {error}",
|
||||
"admin.actor_system": "System",
|
||||
"admin.image_formats": "PNG, JPEG and WebP are supported",
|
||||
"admin.image_formats": "PNG, JPEG, WebP and SVG are supported",
|
||||
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
|
||||
"admin.logo_too_large": "The logo must not exceed 2 MB",
|
||||
"calls.calls_off_entry_point": "Calls are switched off for this entry point",
|
||||
"calls.failure_code_required": "FAILED needs a normalized failure_code",
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Выберите файл логотипа",
|
||||
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
|
||||
"admin.employee_not_found": "Сотрудник не найден",
|
||||
"admin.group_name_taken": "Группа с таким именем уже есть",
|
||||
"admin.group_not_found": "Группа не найдена",
|
||||
@@ -102,6 +101,8 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "Новое сообщение · {contact}",
|
||||
"notifications.operator_needed": "Нужен оператор · {contact}",
|
||||
"notifications.voice_without_transcript": "Голосовое без расшифровки",
|
||||
"onboarding.membership_required": "Онбординг доступен участнику организации",
|
||||
"onboarding.unknown_action": "Неизвестное действие онбординга",
|
||||
"portals.article_not_found": "Статья не найдена",
|
||||
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
|
||||
"portals.file_not_found": "Файл не найден",
|
||||
@@ -115,7 +116,7 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_section": "Некорректный раздел",
|
||||
"portals.not_found": "Портал не найден",
|
||||
"portals.section_not_found": "Раздел не найден",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
|
||||
"profile.choose_photo_file": "Выберите файл фото",
|
||||
"profile.link_not_found": "Привязка не найдена",
|
||||
"profile.notification_bot_not_found": "Бот уведомлений не найден",
|
||||
@@ -217,6 +218,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
|
||||
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
|
||||
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
|
||||
"audit.action_updates_install_requested": "Запущено обновление установки",
|
||||
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
|
||||
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
|
||||
@@ -250,6 +252,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Добавлена интеграция",
|
||||
"audit.action_integrations_integration_deleted": "Удалена интеграция",
|
||||
"audit.action_integrations_integration_updated": "Изменена интеграция",
|
||||
"audit.action_organization_created": "Создана организация из интерфейса",
|
||||
"audit.action_organization_owner_activated": "Активирован владелец организации",
|
||||
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"audit.action_organization_provisioned": "Создана организация",
|
||||
@@ -335,8 +338,12 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.invalid_credentials": "Неверный email или пароль",
|
||||
"identity.invalid_totp_code": "Неверный код",
|
||||
"identity.invitation_email_mismatch": "Приглашение выписано на другой адрес",
|
||||
"identity.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
|
||||
"identity.invitation_invalid": "Приглашение недействительно или истекло",
|
||||
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
|
||||
"updates.nothing_to_install": "Установлена последняя версия",
|
||||
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
|
||||
"updates.install_in_progress": "Обновление уже идёт",
|
||||
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
|
||||
"identity.token_required": "Нужен токен",
|
||||
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
|
||||
@@ -359,7 +366,8 @@ MESSAGES: dict[str, object] = {
|
||||
"api.expected_record_id": "{name}: ожидается идентификатор записи",
|
||||
"settings.storage_unavailable": "Хранилище недоступно: {error}",
|
||||
"admin.actor_system": "Система",
|
||||
"admin.image_formats": "Поддерживаются PNG, JPEG и WebP",
|
||||
"admin.image_formats": "Поддерживаются PNG, JPEG, WebP и SVG",
|
||||
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
|
||||
"admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ",
|
||||
"calls.calls_off_entry_point": "Звонки отключены для этой точки входа",
|
||||
"calls.failure_code_required": "Для FAILED требуется нормализованный failure_code",
|
||||
|
||||
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
|
||||
|
||||
@admin.register(Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
list_display = ["slug", "name", "currency", "timezone"]
|
||||
list_display = ["slug", "name", "timezone"]
|
||||
search_fields = ["slug", "name"]
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
|
||||
return {
|
||||
"name": organization.name,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
# Пустая строка доезжает до интерфейса как есть: там это отдельный
|
||||
# пункт «Как в установке», а не отсутствие значения.
|
||||
"language": organization.language,
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language
|
||||
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
@@ -27,17 +28,18 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
class OrganizationSettingsInput:
|
||||
name: str
|
||||
timezone: str
|
||||
currency: str
|
||||
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
|
||||
# и владелец, который его не трогал, не должен получить жёсткий русский
|
||||
# после того, как язык установки сменили.
|
||||
language: str = ""
|
||||
|
||||
|
||||
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
|
||||
для страницы создания организации."""
|
||||
|
||||
name = data.name.strip()
|
||||
timezone = data.timezone.strip()
|
||||
currency = data.currency.strip().upper()
|
||||
language = normalize_language(data.language)
|
||||
if data.language.strip() and not language:
|
||||
raise ValidationError({"language": t("settings.language_unsupported")})
|
||||
@@ -51,13 +53,7 @@ def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInpu
|
||||
raise ValidationError(
|
||||
{"timezone": t("admin.invalid_timezone")}
|
||||
) from error
|
||||
if currency != "RUB":
|
||||
raise ValidationError(
|
||||
{"currency": t("admin.currency_rub_only")}
|
||||
)
|
||||
return OrganizationSettingsInput(
|
||||
name=name, timezone=timezone, currency=currency, language=language
|
||||
)
|
||||
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -66,15 +62,14 @@ def update_organization_settings(
|
||||
context: TenantContext,
|
||||
data: OrganizationSettingsInput,
|
||||
) -> Organization:
|
||||
clean = _validate_input(data)
|
||||
clean = validate_organization_settings(data)
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
)
|
||||
organization.name = clean.name
|
||||
organization.timezone = clean.timezone
|
||||
organization.currency = clean.currency
|
||||
organization.language = clean.language
|
||||
organization.save(update_fields=["name", "timezone", "currency", "language"])
|
||||
organization.save(update_fields=["name", "timezone", "language"])
|
||||
return organization
|
||||
|
||||
|
||||
@@ -85,6 +80,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
|
||||
return "image/jpeg", ".jpg"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
if looks_like_svg(data):
|
||||
return SVG_CONTENT_TYPE, ".svg"
|
||||
return None
|
||||
|
||||
|
||||
@@ -102,6 +99,10 @@ def replace_organization_logo(
|
||||
detected = _image_type(data)
|
||||
if detected is None:
|
||||
raise ValidationError({"file": t("admin.image_formats")})
|
||||
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
|
||||
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
|
||||
# файл отклоняется целиком, а не переписывается молча.
|
||||
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
|
||||
content_type, suffix = detected
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
|
||||
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
|
||||
data=OrganizationSettingsInput(
|
||||
name=str(body.get("name", organization.name)),
|
||||
timezone=str(body.get("timezone", organization.timezone)),
|
||||
currency=str(body.get("currency", organization.currency)),
|
||||
language=str(body.get("language", organization.language)),
|
||||
),
|
||||
)
|
||||
@@ -107,11 +106,18 @@ class OrganizationLogoView(APIView):
|
||||
organization = request.tenant_context.organization
|
||||
if not organization.logo:
|
||||
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
|
||||
return FileResponse(
|
||||
response = FileResponse(
|
||||
organization.logo.open("rb"),
|
||||
content_type=organization.logo_content_type or "application/octet-stream",
|
||||
filename="organization-logo",
|
||||
)
|
||||
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
|
||||
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
|
||||
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
|
||||
# браузеру нельзя угадывать тип.
|
||||
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
@@ -286,37 +292,100 @@ def _audit_actors(base) -> list[dict[str, object]]:
|
||||
return actors
|
||||
|
||||
|
||||
class LaunchChecklistView(APIView):
|
||||
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
|
||||
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
|
||||
class OnboardingView(APIView):
|
||||
"""Состояние онбординга «Начало работы» для текущего человека.
|
||||
|
||||
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
|
||||
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
|
||||
в организации: у каждого человека свои, и один администратор не прячет
|
||||
визард команде.
|
||||
"""
|
||||
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "settings.view"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
membership = request.tenant_context.membership
|
||||
return Response(
|
||||
{
|
||||
"steps": onboarding_steps(request.tenant_context.organization_id),
|
||||
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
|
||||
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
|
||||
}
|
||||
)
|
||||
|
||||
organization_id = request.tenant_context.organization_id
|
||||
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
|
||||
connection_bound = Integration.objects.filter(
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Закрыть визард или отметить его пройденным.
|
||||
|
||||
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
|
||||
снимает оба признака: визард снова открывается по ссылке и пилюле.
|
||||
"""
|
||||
|
||||
membership = request.tenant_context.membership
|
||||
if membership is None:
|
||||
return Response({"detail": t("onboarding.membership_required")}, status=403)
|
||||
action = str(request.data.get("action") or "dismiss")
|
||||
now = django_timezone.now()
|
||||
if action == "dismiss":
|
||||
membership.onboarding_dismissed_at = now
|
||||
elif action == "complete":
|
||||
membership.onboarding_dismissed_at = now
|
||||
membership.onboarding_completed_at = now
|
||||
elif action == "restart":
|
||||
membership.onboarding_dismissed_at = None
|
||||
membership.onboarding_completed_at = None
|
||||
else:
|
||||
return Response({"detail": t("onboarding.unknown_action")}, status=400)
|
||||
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
|
||||
return Response(
|
||||
{
|
||||
"steps": onboarding_steps(request.tenant_context.organization_id),
|
||||
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
|
||||
"completedAt": _isoformat(membership.onboarding_completed_at),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def onboarding_steps(organization_id: int) -> dict[str, bool]:
|
||||
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.identity import instance_settings
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
|
||||
|
||||
organization = Organization.objects.filter(pk=organization_id).first()
|
||||
return {
|
||||
"providerConnected": Integration.objects.filter(
|
||||
organization_id=organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
).exists(),
|
||||
"agentActive": AIAgent.objects.filter(
|
||||
organization_id=organization_id,
|
||||
status=AIAgentStatus.ACTIVE,
|
||||
).exists(),
|
||||
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
|
||||
"connectionBound": Integration.objects.filter(
|
||||
organization_id=organization_id,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
channel__isnull=False,
|
||||
).exists()
|
||||
employee_invited = (
|
||||
OrganizationMembership.objects.filter(
|
||||
organization_id=organization_id
|
||||
).count()
|
||||
> 1
|
||||
or request.tenant_context.organization.invitations.exists()
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"agentCreated": agent_created,
|
||||
"connectionBound": connection_bound,
|
||||
"employeeInvited": employee_invited,
|
||||
"done": agent_created and connection_bound and employee_invited,
|
||||
}
|
||||
)
|
||||
).exists(),
|
||||
"widgetPublished": WebChatWidget.objects.filter(
|
||||
organization_id=organization_id,
|
||||
status=WebChatWidgetStatus.PUBLISHED,
|
||||
).exists(),
|
||||
"employeeInvited": (
|
||||
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
|
||||
or (organization is not None and organization.invitations.exists())
|
||||
),
|
||||
# Шаг про саму установку, а не про организацию: домен и исходящая почта
|
||||
# общие для всех организаций на сервере.
|
||||
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
|
||||
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
|
||||
}
|
||||
@@ -81,6 +81,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"identity.employee_invited": "audit.action_identity_employee_invited",
|
||||
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
|
||||
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
|
||||
"updates.install_requested": "audit.action_updates_install_requested",
|
||||
"identity.employee_updated": "audit.action_identity_employee_updated",
|
||||
"identity.employee_blocked": "audit.action_identity_employee_blocked",
|
||||
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
|
||||
@@ -110,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"administration.instance_updated": "audit.action_administration_instance_updated",
|
||||
# --- Организация ---
|
||||
"organization.provisioned": "audit.action_organization_provisioned",
|
||||
"organization.created": "audit.action_organization_created",
|
||||
"organization.owner_activated": "audit.action_organization_owner_activated",
|
||||
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
|
||||
# --- Интеграции и каналы ---
|
||||
|
||||
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
|
||||
|
||||
|
||||
class ProfileUpdateView(APIView):
|
||||
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is not None:
|
||||
return context.organization.language or ""
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(user=request.user, blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return membership.organization.language if membership is not None else ""
|
||||
# Членства роли app без контекста не видны: сначала каталог входа, затем
|
||||
# каждое членство читается в контексте своей организации — как в
|
||||
# identity.auth.common._user_payload.
|
||||
oldest: tuple[object, int, str] | None = None
|
||||
for route in membership_routes_for_user(request.user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
continue
|
||||
key = (membership.created_at, membership.id, membership.organization.language or "")
|
||||
if oldest is None or key[:2] < oldest[:2]:
|
||||
oldest = key
|
||||
return oldest[2] if oldest is not None else ""
|
||||
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
defaults={
|
||||
"name": "Demo",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
)
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
|
||||
@@ -23,9 +23,9 @@ urlpatterns = [
|
||||
name="organization-logo",
|
||||
),
|
||||
path(
|
||||
"launch-checklist/",
|
||||
administration_views.LaunchChecklistView.as_view(),
|
||||
name="launch-checklist",
|
||||
"onboarding/",
|
||||
administration_views.OnboardingView.as_view(),
|
||||
name="onboarding",
|
||||
),
|
||||
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
|
||||
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"how-to-measure",
|
||||
"lead-times"
|
||||
],
|
||||
"limits": {
|
||||
"dailyCostUsd": 300
|
||||
},
|
||||
"createdDaysAgo": 41
|
||||
},
|
||||
{
|
||||
@@ -53,7 +50,6 @@
|
||||
"returns-and-exchanges",
|
||||
"your-account"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 30,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -78,7 +74,6 @@
|
||||
"your-account",
|
||||
"returns-and-exchanges"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 28,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -96,7 +91,6 @@
|
||||
"instructions": "",
|
||||
"knowledge": [],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 3
|
||||
},
|
||||
{
|
||||
@@ -113,7 +107,6 @@
|
||||
"pricing"
|
||||
],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 180
|
||||
}
|
||||
],
|
||||
@@ -378,7 +371,6 @@
|
||||
80,
|
||||
260
|
||||
],
|
||||
"costMicrosPerToken": 0.6,
|
||||
"failuresTotal": 3
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,6 @@
|
||||
"kak-snyat-merki",
|
||||
"sroki-dostavki"
|
||||
],
|
||||
"limits": {
|
||||
"dailyCostUsd": 300
|
||||
},
|
||||
"createdDaysAgo": 41
|
||||
},
|
||||
{
|
||||
@@ -53,7 +50,6 @@
|
||||
"vozvrat-i-obmen",
|
||||
"lichnyj-kabinet"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 30,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -78,7 +74,6 @@
|
||||
"lichnyj-kabinet",
|
||||
"vozvrat-i-obmen"
|
||||
],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 28,
|
||||
"policy": {
|
||||
"allow_anonymous_sessions": true,
|
||||
@@ -96,7 +91,6 @@
|
||||
"instructions": "",
|
||||
"knowledge": [],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 3
|
||||
},
|
||||
{
|
||||
@@ -113,7 +107,6 @@
|
||||
"pricing"
|
||||
],
|
||||
"portalArticles": [],
|
||||
"limits": {},
|
||||
"createdDaysAgo": 180
|
||||
}
|
||||
],
|
||||
@@ -378,7 +371,6 @@
|
||||
80,
|
||||
260
|
||||
],
|
||||
"costMicrosPerToken": 0.6,
|
||||
"failuresTotal": 3
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
|
||||
agent.persona = item.get("persona", "")
|
||||
agent.tone = item.get("tone", "")
|
||||
agent.instructions = item.get("instructions", "")
|
||||
agent.limits = item.get("limits", {})
|
||||
if agent.status in ("ACTIVE", "DISABLED"):
|
||||
agent.provider_integration = llm
|
||||
agent.model = (llm.config or {}).get("default_model", "demo")
|
||||
@@ -115,7 +114,8 @@ def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, curre
|
||||
provider=item["provider"],
|
||||
name=item["name"],
|
||||
secret=item.get("secret", ""),
|
||||
config=item.get("config", {}),
|
||||
# Токены демо-подключений ненастоящие: воркер их не опрашивает.
|
||||
config={**item.get("config", {}), "demoSeed": True},
|
||||
),
|
||||
)
|
||||
Integration.objects.filter(pk=integration.pk).update(
|
||||
@@ -255,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=0 if failed else completion,
|
||||
total_tokens=prompt + (0 if failed else completion),
|
||||
cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]),
|
||||
latency_ms=random.randint(900, 4200),
|
||||
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
|
||||
error="Provider timeout after 30s" if failed else "",
|
||||
@@ -273,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
|
||||
prompt_tokens=tokens,
|
||||
completion_tokens=0,
|
||||
total_tokens=tokens,
|
||||
cost_micros=int(tokens * 0.02),
|
||||
latency_ms=random.randint(200, 900),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.urls import path
|
||||
|
||||
from chatballs.identity import instance_views
|
||||
from chatballs.tenancy import storage_views
|
||||
from chatballs.updates import views as update_views
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", instance_views.InstanceAddressView.as_view(), name="instance-settings"),
|
||||
@@ -25,4 +26,8 @@ urlpatterns = [
|
||||
storage_views.StorageMigrateView.as_view(),
|
||||
name="instance-storage-migrate",
|
||||
),
|
||||
# Обновления установки: состояние, проверка канала, установка по кнопке.
|
||||
path("update/", update_views.UpdateStateView.as_view(), name="instance-update"),
|
||||
path("update/check/", update_views.UpdateCheckView.as_view(), name="instance-update-check"),
|
||||
path("update/install/", update_views.UpdateInstallView.as_view(), name="instance-update-install"),
|
||||
]
|
||||
@@ -25,6 +25,7 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import invitation_route
|
||||
|
||||
# Приглашение существующего пользователя в организацию: письмо отправляет
|
||||
# воркер по этому событию (identity.event_handlers).
|
||||
@@ -204,14 +205,37 @@ def register_and_accept(*, token: str, full_name: str, password: str) -> Accepte
|
||||
|
||||
|
||||
def pending_invitation_for_token(token: str) -> OrganizationInvitation | None:
|
||||
return _invitation_for_token(token, accepted=False)
|
||||
|
||||
|
||||
def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitation | None:
|
||||
"""Приглашение по токену из письма — без tenant-контекста на входе.
|
||||
|
||||
Ссылка /join приходит до входа в организацию, а таблица приглашений и
|
||||
строка организации роли app без контекста не видны (tenancy/0003, 0033).
|
||||
Организацию находит security-barrier каталог по хэшу токена (tenancy/0035),
|
||||
и приглашение читается уже в её контексте — вместе с организацией, чтобы
|
||||
вызывающий код мог обращаться к ней и после выхода из контекста.
|
||||
"""
|
||||
|
||||
if not token:
|
||||
return None
|
||||
return OrganizationInvitation.objects.filter(
|
||||
token_hash=_token_hash(token),
|
||||
accepted_at__isnull=True,
|
||||
token_hash = _token_hash(token)
|
||||
route = invitation_route(token_hash)
|
||||
if route is None:
|
||||
return None
|
||||
query = OrganizationInvitation.objects.select_related("organization").filter(
|
||||
id=route.resource_id,
|
||||
organization_id=route.organization_id,
|
||||
token_hash=token_hash,
|
||||
revoked_at__isnull=True,
|
||||
expires_at__gt=timezone.now(),
|
||||
).first()
|
||||
)
|
||||
if accepted:
|
||||
query = query.filter(accepted_at__isnull=False)
|
||||
else:
|
||||
query = query.filter(accepted_at__isnull=True, expires_at__gt=timezone.now())
|
||||
with tenant_atomic(route.organization_id):
|
||||
return query.first()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -314,16 +338,13 @@ def _already_accepted_for(
|
||||
) -> AcceptedInvitation | None:
|
||||
"""Idempotent re-accept: if this token was already accepted by the same user,
|
||||
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
|
||||
invitation = OrganizationInvitation.objects.filter(
|
||||
token_hash=_token_hash(token),
|
||||
accepted_at__isnull=False,
|
||||
revoked_at__isnull=True,
|
||||
).first()
|
||||
invitation = _invitation_for_token(token, accepted=True)
|
||||
if invitation is None:
|
||||
return None
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
user=user, organization=invitation.organization
|
||||
).first()
|
||||
with tenant_atomic(invitation.organization_id):
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
user=user, organization=invitation.organization
|
||||
).first()
|
||||
if membership is None:
|
||||
return None
|
||||
return AcceptedInvitation(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Проверка SVG-логотипа организации.
|
||||
|
||||
SVG — это XML с возможностью исполнять скрипты и тянуть внешние ресурсы.
|
||||
Логотип отдаётся с адреса самого приложения, поэтому опасный файл принимать
|
||||
нельзя даже с защитными заголовками при отдаче: файл проверяется при загрузке
|
||||
и отклоняется целиком, а не «чистится» — переписывать чужую графику молча
|
||||
хуже, чем попросить другой файл.
|
||||
|
||||
Отклоняется: DOCTYPE и сущности, элементы script/foreignObject/iframe/
|
||||
embed/object/audio/video, атрибуты-обработчики on*, ссылки javascript: и
|
||||
data:text, внешние адреса в href/xlink:href и в url() внутри стилей.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
SVG_CONTENT_TYPE = "image/svg+xml"
|
||||
|
||||
_FORBIDDEN_TAGS = frozenset({"script", "foreignobject", "iframe", "embed", "object", "audio", "video"})
|
||||
_HREF_ATTRIBUTES = frozenset({"href", "{http://www.w3.org/1999/xlink}href"})
|
||||
_DECLARATION = re.compile(rb"<!\s*(DOCTYPE|ENTITY)", re.IGNORECASE)
|
||||
_EXTERNAL_URL = re.compile(r"url\(\s*['\"]?\s*(?!#|data:image/)", re.IGNORECASE)
|
||||
|
||||
|
||||
def looks_like_svg(data: bytes) -> bool:
|
||||
head = data.lstrip(b"\xef\xbb\xbf \t\r\n")[:4096].lower()
|
||||
if head.startswith(b"<svg"):
|
||||
return True
|
||||
return head.startswith((b"<?xml", b"<!--")) and b"<svg" in head
|
||||
|
||||
|
||||
def _local(name: str) -> str:
|
||||
return name.rsplit("}", 1)[-1].lower()
|
||||
|
||||
|
||||
def _dangerous_value(value: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", value).lower()
|
||||
return compact.startswith("javascript:") or compact.startswith("data:text") or compact.startswith("vbscript:")
|
||||
|
||||
|
||||
def svg_is_safe(data: bytes) -> bool:
|
||||
if _DECLARATION.search(data):
|
||||
return False
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return False
|
||||
if _local(root.tag) != "svg":
|
||||
return False
|
||||
for element in root.iter():
|
||||
tag = _local(element.tag) if isinstance(element.tag, str) else ""
|
||||
if tag in _FORBIDDEN_TAGS:
|
||||
return False
|
||||
for name, value in element.attrib.items():
|
||||
local = _local(name)
|
||||
if local.startswith("on"):
|
||||
return False
|
||||
if _dangerous_value(value):
|
||||
return False
|
||||
if name in _HREF_ATTRIBUTES or local == "href":
|
||||
stripped = value.strip()
|
||||
if stripped and not (stripped.startswith("#") or stripped.lower().startswith("data:image/")):
|
||||
return False
|
||||
if local == "style" and _EXTERNAL_URL.search(value):
|
||||
return False
|
||||
if tag == "style" and element.text and (
|
||||
"@import" in element.text.lower() or _EXTERNAL_URL.search(element.text)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,25 @@
|
||||
# Онбординг «Начало работы» показывается всем, кто его ещё не закрыл, включая
|
||||
# тех, кто работает в установке давно. Признак закрытия — на членстве человека
|
||||
# в организации: у каждого он свой, и закрытие одним администратором не прячет
|
||||
# визард у остальных. NULL по умолчанию, поэтому существующие записи считаются
|
||||
# «не закрывал» и увидят визард при следующем входе.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0037_invitation_membership_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="organizationmembership",
|
||||
name="onboarding_dismissed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationmembership",
|
||||
name="onboarding_completed_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Валюта организации удалена: поле принимало только RUB, не читалось нигде и
|
||||
# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило
|
||||
# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже
|
||||
# снят вместе с лимитами.
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("identity", "0038_membership_onboarding_dismissed_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="organization",
|
||||
name="currency",
|
||||
),
|
||||
]
|
||||
@@ -75,7 +75,7 @@ class HumanUser(AbstractUser):
|
||||
# без этой отметки принимался бы второй раз ещё полторы минуты.
|
||||
totp_last_counter = models.BigIntegerField(default=0)
|
||||
# Внешний вид — глобальная настройка пользователя (не membership):
|
||||
# тема и акцентный HEX-цвет; пустой акцент — дефолтный синий #1677ff.
|
||||
# тема и акцентный HEX-цвет; пустой акцент — цвет продукта #0f9b8e.
|
||||
ui_theme = models.CharField(max_length=8, choices=UiTheme.choices, default=UiTheme.SYSTEM)
|
||||
ui_accent = models.CharField(max_length=9, blank=True)
|
||||
# Язык интерфейса — тоже глобальная настройка пользователя, а не
|
||||
@@ -125,7 +125,6 @@ class Organization(models.Model):
|
||||
default=OrganizationStatus.ACTIVE,
|
||||
)
|
||||
timezone = models.CharField(max_length=64, default="Europe/Moscow")
|
||||
currency = models.CharField(max_length=3, default="RUB")
|
||||
# Язык рабочего места по умолчанию: на нём организация открывается всем,
|
||||
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
|
||||
# это такой же региональный параметр организации, и в «Настройках» они
|
||||
@@ -184,6 +183,14 @@ class OrganizationMembership(models.Model):
|
||||
phone = models.CharField(max_length=32, blank=True)
|
||||
totp_required = models.BooleanField(default=False)
|
||||
blocked_at = models.DateTimeField(null=True, blank=True)
|
||||
# Онбординг закрыт этим человеком в этой организации. Признак живёт на
|
||||
# членстве, а не на организации: иначе первый же закрывший спрятал бы
|
||||
# визард всей команде. NULL — не закрывал, значит увидит при следующем
|
||||
# входе, включая тех, кто работает в системе давно.
|
||||
onboarding_dismissed_at = models.DateTimeField(null=True, blank=True)
|
||||
# Визард пройден до конца. Отдельно от «закрыл»: закрыть можно на первом
|
||||
# шаге, и тогда возвращаться к настройке ещё есть зачем.
|
||||
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Создание организации человеком из интерфейса.
|
||||
|
||||
Кнопка «Добавить организацию» в переключателе (дизайн-базлайн v2, A1) ведёт
|
||||
на страницу с полями организации; тот, кто её заполнил, становится владельцем
|
||||
новой организации и сразу в неё переключается. Это второй путь появления
|
||||
организации рядом с платформенным провижинингом (platform.provisioning_service):
|
||||
там оператор заводит организацию для чужого владельца по e-mail, здесь человек
|
||||
заводит её себе.
|
||||
|
||||
Кто может: администратор установки и любой, у кого есть роль владельца или
|
||||
администратора хотя бы в одной организации. Сотрудник, работающий только в
|
||||
чате, чужую установку организациями не засевает.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.audience import customer_language
|
||||
from chatballs.identity.administration_services import (
|
||||
OrganizationSettingsInput,
|
||||
validate_organization_settings,
|
||||
)
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
from chatballs.tenancy.lookup import organization_route_by_slug, reserve_organization_id
|
||||
|
||||
MANAGER_ROLES = frozenset({EmployeeRole.OWNER, EmployeeRole.ADMIN})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreatedOrganization:
|
||||
organization: Organization
|
||||
membership: OrganizationMembership
|
||||
|
||||
|
||||
def can_create_organization(user: HumanUser) -> bool:
|
||||
"""Администратор установки или менеджер (владелец/администратор) где-либо."""
|
||||
|
||||
if not user.is_active:
|
||||
return False
|
||||
if user.is_instance_admin:
|
||||
return True
|
||||
for route in membership_routes_for_user(user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
role = (
|
||||
OrganizationMembership.objects.filter(
|
||||
id=route.resource_id, user=user, blocked_at__isnull=True
|
||||
)
|
||||
.values_list("role", flat=True)
|
||||
.first()
|
||||
)
|
||||
if role in MANAGER_ROLES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def unique_organization_slug(name: str) -> str:
|
||||
"""Слаг из имени, уникальный среди организаций установки.
|
||||
|
||||
Проверка идёт через каталог организаций: роль app без контекста строк
|
||||
организаций не видит (tenancy/0033).
|
||||
"""
|
||||
|
||||
base = slugify(name)[:40].strip("-") or "organization"
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while organization_route_by_slug(candidate) is not None:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def create_organization(
|
||||
*, data: OrganizationSettingsInput, owner: HumanUser
|
||||
) -> CreatedOrganization:
|
||||
"""Создать организацию и сделать человека её владельцем — одной транзакцией.
|
||||
|
||||
Порядок тот же, что у мастера первого запуска (identity.setup): id
|
||||
выделяется заранее, строка вставляется уже в контексте этого id — иначе
|
||||
роль app не увидит собственную вставку (tenancy/0033, политика 0035).
|
||||
"""
|
||||
|
||||
clean = validate_organization_settings(data)
|
||||
with transaction.atomic():
|
||||
organization_id = reserve_organization_id()
|
||||
with tenant_atomic(organization_id):
|
||||
organization = Organization(
|
||||
id=organization_id,
|
||||
name=clean.name,
|
||||
slug=unique_organization_slug(clean.name),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
timezone=clean.timezone,
|
||||
language=clean.language,
|
||||
)
|
||||
organization.save(force_insert=True)
|
||||
ensure_uncategorized_category(organization)
|
||||
membership = OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
organization=organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
# Должность — текстом на языке организации, как в провижининге.
|
||||
position_title=t("setup.owner_position", language=customer_language(organization)),
|
||||
totp_required=False,
|
||||
)
|
||||
record_audit_event(
|
||||
action="organization.created",
|
||||
actor=owner,
|
||||
organization=organization,
|
||||
object_type="Organization",
|
||||
object_id=str(organization.public_id),
|
||||
payload={"organizationName": organization.name},
|
||||
)
|
||||
enqueue_event(
|
||||
DomainEvent(
|
||||
aggregate_type="Organization",
|
||||
aggregate_id=str(organization.public_id),
|
||||
event_type="organization.provisioned",
|
||||
payload={},
|
||||
tenant_context=TenantContext.for_resource(
|
||||
organization,
|
||||
actor_kind=TenantActorKind.SYSTEM,
|
||||
actor_user=owner,
|
||||
),
|
||||
)
|
||||
)
|
||||
return CreatedOrganization(organization=organization, membership=membership)
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.identity import organization_views
|
||||
|
||||
urlpatterns = [
|
||||
path("", organization_views.OrganizationCreateView.as_view(), name="organization-create"),
|
||||
path(
|
||||
"options/",
|
||||
organization_views.OrganizationCreateOptionsView.as_view(),
|
||||
name="organization-create-options",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Создание организации из интерфейса: /api/v1/organizations/ без uuid в адресе.
|
||||
|
||||
Организации ещё нет, поэтому tenant middleware этот путь не трогает: контекст
|
||||
открывает сам сервис вокруг вставки. Ответ повторяет форму ответа приглашения
|
||||
(auth.invitations): обновлённая учётная запись со списком членств и публичный
|
||||
id организации, в которую интерфейсу переключиться.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.administration_payloads import (
|
||||
administration_languages,
|
||||
administration_timezones,
|
||||
)
|
||||
from chatballs.identity.administration_services import OrganizationSettingsInput
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.organization_creation import (
|
||||
can_create_organization,
|
||||
create_organization,
|
||||
)
|
||||
|
||||
|
||||
def _forbidden() -> Response:
|
||||
return Response({"detail": t("identity.organization_create_forbidden")}, status=403)
|
||||
|
||||
|
||||
class OrganizationCreateOptionsView(APIView):
|
||||
"""Справочники для формы: часовые пояса и языки, как в «Настройках»."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
if not can_create_organization(request.user):
|
||||
return _forbidden()
|
||||
return Response(
|
||||
{
|
||||
"timezones": administration_timezones(),
|
||||
"languages": administration_languages(),
|
||||
"currencies": ["RUB"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OrganizationCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
if not can_create_organization(request.user):
|
||||
return _forbidden()
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
try:
|
||||
created = create_organization(
|
||||
data=OrganizationSettingsInput(
|
||||
name=str(body.get("name", "")),
|
||||
timezone=str(body.get("timezone", "") or "Europe/Moscow"),
|
||||
language=str(body.get("language", "")),
|
||||
),
|
||||
owner=request.user,
|
||||
)
|
||||
except ValidationError as error:
|
||||
return validation_response(error)
|
||||
return Response(
|
||||
{
|
||||
"user": _user_payload(request.user),
|
||||
"organizationPublicId": str(created.organization.public_id),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
|
||||
name="Example",
|
||||
slug="administration",
|
||||
timezone="Europe/Moscow",
|
||||
currency="RUB",
|
||||
)
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@administration.test",
|
||||
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
|
||||
{
|
||||
"name": "Example",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
# Пустой язык — «как в установке»: организация своего не выбрала.
|
||||
"language": "",
|
||||
"logoUrl": None,
|
||||
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
|
||||
{
|
||||
"name": "Новая компания",
|
||||
"timezone": "Asia/Yekaterinburg",
|
||||
"currency": "rub",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
|
||||
self.organization.refresh_from_db()
|
||||
self.assertEqual(self.organization.name, "Новая компания")
|
||||
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
|
||||
self.assertEqual(self.organization.currency, "RUB")
|
||||
|
||||
def test_invalid_timezone_is_rejected(self) -> None:
|
||||
response = self.client.patch(
|
||||
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("timezone", response.json()["errors"])
|
||||
|
||||
def test_only_ruble_currency_is_accepted(self) -> None:
|
||||
response = self.client.patch(
|
||||
"/api/v1/company/administration/",
|
||||
{"currency": "USD"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("российский рубль", response.json()["errors"]["currency"])
|
||||
|
||||
def test_logo_upload_download_and_delete(self) -> None:
|
||||
png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
uploaded = self.client.post(
|
||||
@@ -172,16 +158,16 @@ class AdministrationApiTests(TestCase):
|
||||
"/api/v1/company/administration/logo/",
|
||||
{
|
||||
"file": SimpleUploadedFile(
|
||||
"logo.svg",
|
||||
b"<svg></svg>",
|
||||
content_type="image/svg+xml",
|
||||
"logo.txt",
|
||||
b"not an image at all",
|
||||
content_type="text/plain",
|
||||
)
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("PNG, JPEG и WebP", response.json()["detail"])
|
||||
self.assertIn("PNG, JPEG, WebP и SVG", response.json()["detail"])
|
||||
|
||||
def test_audit_returns_readable_label_next_to_the_action_code(self) -> None:
|
||||
record_audit_event(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""SVG-логотип организации: принимается чистый, отклоняется опасный.
|
||||
|
||||
Логотип отдаётся с адреса приложения, поэтому SVG проверяется при загрузке
|
||||
(скрипты, обработчики, внешние ссылки), а при отдаче получает защитные
|
||||
заголовки как второй рубеж.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.logo_svg import svg_is_safe
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
CLEAN_SVG = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64">
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="#1677ff"/><stop offset="1" stop-color="#003eb3"/></linearGradient></defs>
|
||||
<style>.mark { fill: url(#g); }</style>
|
||||
<circle class="mark" cx="32" cy="32" r="30"/>
|
||||
<use xlink:href="#mark"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
class SvgSafetyTests(TestCase):
|
||||
def test_clean_svg_is_accepted(self) -> None:
|
||||
self.assertTrue(svg_is_safe(CLEAN_SVG))
|
||||
|
||||
def test_dangerous_svg_is_rejected(self) -> None:
|
||||
samples = {
|
||||
"script": b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
"handler": b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>',
|
||||
"javascript href": b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href="javascript:alert(1)"><rect/></a></svg>',
|
||||
"external image": b'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/t.png"/></svg>',
|
||||
"foreignObject": b'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>x</div></foreignObject></svg>',
|
||||
"external css": b'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(https://evil.example/a.css);</style></svg>',
|
||||
"style url": b'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(http://evil.example/x)"/></svg>',
|
||||
"doctype entity": b'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
"not svg": b'<html><body>hi</body></html>',
|
||||
"broken xml": b'<svg xmlns="http://www.w3.org/2000/svg"><rect></svg>',
|
||||
}
|
||||
for name, sample in samples.items():
|
||||
with self.subTest(name):
|
||||
self.assertFalse(svg_is_safe(sample))
|
||||
|
||||
|
||||
class SvgLogoApiTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
result = bootstrap_owner(email="svg-owner@example.com", password="temporary-password")
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(result.owner)
|
||||
|
||||
def _upload(self, data: bytes):
|
||||
return self.client.post(
|
||||
"/api/v1/company/administration/logo/",
|
||||
{"file": SimpleUploadedFile("logo.svg", data, content_type="image/svg+xml")},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
def test_svg_logo_round_trip_with_protective_headers(self) -> None:
|
||||
uploaded = self._upload(CLEAN_SVG)
|
||||
|
||||
self.assertEqual(uploaded.status_code, 200, uploaded.content)
|
||||
downloaded = self.client.get("/api/v1/company/administration/logo/")
|
||||
self.assertEqual(downloaded.status_code, 200)
|
||||
self.assertEqual(downloaded["Content-Type"], "image/svg+xml")
|
||||
self.assertIn("sandbox", downloaded["Content-Security-Policy"])
|
||||
self.assertEqual(downloaded["X-Content-Type-Options"], "nosniff")
|
||||
self.assertEqual(b"".join(downloaded.streaming_content), CLEAN_SVG)
|
||||
|
||||
def test_svg_with_script_is_refused_with_a_field_error(self) -> None:
|
||||
response = self._upload(b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("file", response.json()["errors"])
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Создание организации из интерфейса: кнопка «Добавить организацию» (A1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.models import KnowledgeCategory
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
URL = "/api/v1/organizations/"
|
||||
|
||||
|
||||
@override_settings(ROOT_URLCONF="chatballs_backend.urls_app")
|
||||
class OrganizationCreationTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.first = Organization.objects.create(name="Ателье Норд", slug="atelie-nord")
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@example.test", password="Owner-pass-123!", full_name="Елена"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.owner,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Владелец",
|
||||
)
|
||||
self.employee = HumanUser.objects.create_user(
|
||||
email="employee@example.test", password="Emp-pass-1234!", full_name="Иван"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=self.first,
|
||||
user=self.employee,
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
position_title="Оператор",
|
||||
)
|
||||
|
||||
def _post(self, user: HumanUser, **body):
|
||||
client = TenantAPIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
URL,
|
||||
data=json.dumps({"name": "Вторая компания", "timezone": "Europe/Moscow", **body}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_owner_creates_organization_and_becomes_its_owner(self) -> None:
|
||||
response = self._post(self.owner, language="en")
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
payload = response.json()
|
||||
created = Organization.objects.get(public_id=payload["organizationPublicId"])
|
||||
self.assertEqual(created.name, "Вторая компания")
|
||||
self.assertEqual(created.language, "en")
|
||||
self.assertEqual(created.status, "ACTIVE")
|
||||
membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
|
||||
self.assertEqual(membership.role, EmployeeRole.OWNER)
|
||||
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
|
||||
# Список членств в ответе уже содержит новую организацию: интерфейсу
|
||||
# есть куда переключиться без повторного запроса сессии.
|
||||
self.assertEqual(
|
||||
{item["organizationPublicId"] for item in payload["user"]["memberships"]},
|
||||
{str(self.first.public_id), str(created.public_id)},
|
||||
)
|
||||
self.assertTrue(
|
||||
AuditEvent.objects.filter(
|
||||
organization=created, action="organization.created", actor=self.owner
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_same_name_gets_a_distinct_slug(self) -> None:
|
||||
first = self._post(self.owner).json()["organizationPublicId"]
|
||||
second = self._post(self.owner).json()["organizationPublicId"]
|
||||
|
||||
slugs = set(Organization.objects.filter(public_id__in=[first, second]).values_list("slug", flat=True))
|
||||
self.assertEqual(len(slugs), 2)
|
||||
|
||||
def test_employee_cannot_create_organizations(self) -> None:
|
||||
response = self._post(self.employee)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(Organization.objects.count(), 1)
|
||||
|
||||
def test_instance_admin_without_memberships_can_create(self) -> None:
|
||||
admin = HumanUser.objects.create_user(
|
||||
email="admin@example.test", password="Admin-pass-123!", is_instance_admin=True
|
||||
)
|
||||
|
||||
response = self._post(admin)
|
||||
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
created = Organization.objects.get(public_id=response.json()["organizationPublicId"])
|
||||
self.assertTrue(OrganizationMembership.objects.filter(organization=created, user=admin, role=EmployeeRole.OWNER).exists())
|
||||
|
||||
def test_empty_name_is_a_field_error(self) -> None:
|
||||
response = self._post(self.owner, name=" ")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("name", response.json()["errors"])
|
||||
self.assertEqual(Organization.objects.count(), 1)
|
||||
|
||||
def test_anonymous_is_rejected(self) -> None:
|
||||
response = TenantAPIClient().post(URL, data=json.dumps({"name": "X"}), content_type="application/json")
|
||||
|
||||
self.assertIn(response.status_code, {401, 403})
|
||||
|
||||
def test_options_list_timezones_and_languages(self) -> None:
|
||||
client = TenantAPIClient()
|
||||
client.force_login(self.owner)
|
||||
|
||||
response = client.get(f"{URL}options/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Europe/Moscow", response.json()["timezones"])
|
||||
self.assertTrue(response.json()["languages"])
|
||||
@@ -50,6 +50,8 @@ COVERAGE_EXEMPT = {
|
||||
# Настройки установки (адрес, по которому её открывают) — тоже одна
|
||||
# строка на инстанс: их пишет мастер первого запуска, а не демо.
|
||||
("identity", "instancesettings"),
|
||||
# Состояние обновлений установки — одна строка на инстанс (updates/0001).
|
||||
("updates", "updatestate"),
|
||||
}
|
||||
COVERAGE_EXEMPT_APPS = {"platform", "events"}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations import transports
|
||||
@@ -39,7 +40,11 @@ def notifier_integrations(context):
|
||||
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
|
||||
config__purpose=NOTIFIER_PURPOSE,
|
||||
).exclude(secret="")
|
||||
return qs.filter(organization=context.organization)
|
||||
# Демо-подключения из демо-набора не опрашиваются: токены ненастоящие.
|
||||
# Отсутствие ключа в JSON — тоже «не демо», поэтому isnull, а не exclude.
|
||||
return qs.filter(organization=context.organization).filter(
|
||||
Q(config__demoSeed__isnull=True) | Q(config__demoSeed=False)
|
||||
)
|
||||
|
||||
|
||||
def deep_link(integration: Integration, code: str) -> str:
|
||||
|
||||
@@ -24,7 +24,6 @@ def provisioning_result_payload(
|
||||
"slug": organization.slug,
|
||||
"status": organization.status,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
},
|
||||
"provisioning": {
|
||||
"status": provisioning.status,
|
||||
|
||||
@@ -21,7 +21,6 @@ class ProvisioningCommand:
|
||||
source: str
|
||||
idempotency_key: str
|
||||
timezone: str = "Europe/Moscow"
|
||||
currency: str = "RUB"
|
||||
locale: str = ""
|
||||
legal_name: str = ""
|
||||
tax_profile: dict[str, Any] | None = None
|
||||
|
||||
@@ -158,7 +158,6 @@ def _create_organization(
|
||||
slug=command.organization_slug.strip(),
|
||||
status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER,
|
||||
timezone=command.timezone,
|
||||
currency=command.currency.upper(),
|
||||
)
|
||||
except ValidationError as error:
|
||||
raise ProvisioningValidation(str(error), code="organization_invalid") from error
|
||||
|
||||
@@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase):
|
||||
"slug": "acme",
|
||||
"owner_email": "owner-acme@example.test",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY="idem-api-1",
|
||||
@@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase):
|
||||
"slug": "acme",
|
||||
"owner_email": "owner-acme@example.test",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY="idem-api-2",
|
||||
|
||||
@@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase):
|
||||
"slug": slug,
|
||||
"owner_email": owner_email,
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY=key,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ _REQUIRED_FIELDS = (
|
||||
"slug",
|
||||
"owner_email",
|
||||
"timezone",
|
||||
"currency",
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +31,6 @@ def parse_provisioning_body(
|
||||
source=source,
|
||||
idempotency_key=idempotency_key,
|
||||
timezone=str(body.get("timezone", "Europe/Moscow")),
|
||||
currency=str(body.get("currency", "RUB")),
|
||||
locale=str(body.get("locale", "")),
|
||||
legal_name=str(body.get("legal_name", "")),
|
||||
tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None,
|
||||
|
||||
@@ -64,6 +64,16 @@ def call_invite_route(token_hash: str) -> IngressRoute | None:
|
||||
return _unique_route("call_invite_directory", token_hash)
|
||||
|
||||
|
||||
def invitation_route(token_hash: str) -> IngressRoute | None:
|
||||
"""Приглашение в организацию по хэшу токена из письма (/join).
|
||||
|
||||
Ссылка открывается без контекста — токен и есть единственный ключ. Каталог
|
||||
(tenancy/0035) отдаёт организацию, а само приглашение читается уже в ней.
|
||||
"""
|
||||
|
||||
return _unique_route("invitation_directory", token_hash)
|
||||
|
||||
|
||||
def call_session_route(call_session_id: str) -> IngressRoute | None:
|
||||
return _unique_route("call_session_directory", call_session_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Состояние обновлений установки (updates_updatestate): одна строка на
|
||||
# инсталляцию, не тенантная таблица, без RLS — как настройки установки (0028).
|
||||
# Пишут её приложение (запрос установки, синхронизация статуса) и воркер
|
||||
# (периодическая проверка канала релизов) — оба ролью app.
|
||||
from django.db import migrations
|
||||
|
||||
GRANTS = """
|
||||
GRANT SELECT, INSERT, UPDATE ON updates_updatestate
|
||||
TO chatballs_runtime_app, chatballs_runtime_platform;
|
||||
GRANT USAGE, SELECT ON SEQUENCE updates_updatestate_id_seq
|
||||
TO chatballs_runtime_app, chatballs_runtime_platform;
|
||||
"""
|
||||
|
||||
REVOKE = """
|
||||
REVOKE ALL ON updates_updatestate
|
||||
FROM chatballs_runtime_app, chatballs_runtime_platform;
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tenancy", "0033_organization_select_scope"),
|
||||
("updates", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunSQL(GRANTS, REVOKE)]
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Две дыры мультиорганизационности, обе на роли app.
|
||||
#
|
||||
# 1. Организации создаёт человек из интерфейса (кнопка «Добавить организацию»
|
||||
# в переключателе, дизайн-базлайн v2, A1), а не только оператор платформы.
|
||||
# Политика bootstrap из 0032 пускала INSERT роли app лишь до первой
|
||||
# организации. Теперь строка вставляется в контексте своего же id: сервис
|
||||
# заранее берёт id из последовательности (tenancy.lookup.reserve_organization_id),
|
||||
# открывает tenant_atomic(id) и уже в нём пишет строку — ровно так, как
|
||||
# это делал мастер первого запуска. Без контекста INSERT по-прежнему закрыт.
|
||||
#
|
||||
# 2. Ссылка-приглашение /join открывается без tenant-контекста: токен из
|
||||
# письма — единственное, что есть. Таблица приглашений под RLS, и роль app
|
||||
# без контекста не находила приглашение вовсе. Security-barrier каталог
|
||||
# invitation_directory отдаёт по хэшу токена id организации и приглашения —
|
||||
# по образцу call_invite_directory (0004).
|
||||
from django.db import migrations
|
||||
|
||||
BOOTSTRAP_POLICY = "chatballs_organization_app_bootstrap"
|
||||
CREATE_POLICY = "chatballs_organization_app_create"
|
||||
|
||||
FORWARD_SQL = f"""
|
||||
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
|
||||
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
|
||||
CREATE POLICY {CREATE_POLICY} ON identity_organization
|
||||
FOR INSERT TO chatballs_runtime_app
|
||||
WITH CHECK (id = chatballs.current_organization_id());
|
||||
|
||||
CREATE OR REPLACE VIEW chatballs.invitation_directory
|
||||
WITH (security_barrier = true) AS
|
||||
SELECT invitation.id AS resource_id,
|
||||
invitation.organization_id,
|
||||
invitation.token_hash AS lookup_key
|
||||
FROM identity_organizationinvitation invitation;
|
||||
ALTER VIEW chatballs.invitation_directory OWNER TO chatballs_schema;
|
||||
REVOKE ALL ON chatballs.invitation_directory FROM PUBLIC;
|
||||
GRANT SELECT ON chatballs.invitation_directory
|
||||
TO chatballs_runtime_app, chatballs_runtime_platform;
|
||||
"""
|
||||
|
||||
REVERSE_SQL = f"""
|
||||
DROP VIEW IF EXISTS chatballs.invitation_directory;
|
||||
DROP POLICY IF EXISTS {CREATE_POLICY} ON identity_organization;
|
||||
DROP POLICY IF EXISTS {BOOTSTRAP_POLICY} ON identity_organization;
|
||||
CREATE POLICY {BOOTSTRAP_POLICY} ON identity_organization
|
||||
FOR INSERT TO chatballs_runtime_app
|
||||
WITH CHECK (NOT chatballs.instance_has_organizations());
|
||||
"""
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tenancy", "0034_update_state_grants"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunSQL(FORWARD_SQL, REVERSE_SQL)]
|
||||
@@ -1,11 +1,20 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
from django.test import TransactionTestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.ai.models import AIAgent, Knowledge
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.group_models import EmployeeGroup
|
||||
from chatballs.identity.invitation_models import OrganizationInvitation
|
||||
from chatballs.identity.invitation_service import (
|
||||
invitation_preview,
|
||||
issue_invitation,
|
||||
pending_invitation_for_token,
|
||||
)
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
AuditResult,
|
||||
@@ -14,7 +23,8 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
|
||||
from chatballs.tenancy.database import current_tenant_id, set_local_tenant, tenant_atomic
|
||||
from chatballs.tenancy.lookup import reserve_organization_id
|
||||
from chatballs.tenancy.models import StorageReservation
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
@@ -429,7 +439,7 @@ class RowLevelSecurityTests(TransactionTestCase):
|
||||
|
||||
|
||||
|
||||
def test_app_role_reads_ingress_directory_and_cannot_add_organizations(self) -> None:
|
||||
def test_app_role_reads_ingress_directory_and_adds_organizations_only_in_context(self) -> None:
|
||||
# Каталоги входа доступны роли app (tenancy/0032): backend-app
|
||||
# обходится без platform-соединения.
|
||||
with transaction.atomic():
|
||||
@@ -441,10 +451,45 @@ class RowLevelSecurityTests(TransactionTestCase):
|
||||
[self.user.id],
|
||||
)
|
||||
self.assertEqual(cursor.fetchone()[0], self.first.id)
|
||||
# Организации уже есть — INSERT для app закрыт политикой bootstrap.
|
||||
# Без контекста INSERT организации для app закрыт (tenancy/0035).
|
||||
with self.assertRaises(DatabaseError), transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
Organization.objects.create(name="Third", slug="rls-third")
|
||||
# В контексте заранее выделенного id — открыт: так работает кнопка
|
||||
# «Добавить организацию» (identity.organization_creation).
|
||||
with transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
organization_id = reserve_organization_id()
|
||||
with tenant_atomic(organization_id):
|
||||
Organization(id=organization_id, name="Third", slug="rls-third").save(force_insert=True)
|
||||
self.assertEqual(Organization.objects.get(pk=organization_id).slug, "rls-third")
|
||||
self.assertTrue(Organization.objects.filter(slug="rls-third").exists())
|
||||
# Чужой контекст не подходит: id строки обязан совпасть с контекстом.
|
||||
with self.assertRaises(DatabaseError), transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
with tenant_atomic(self.first.id):
|
||||
Organization(id=reserve_organization_id(), name="Fourth", slug="rls-fourth").save(force_insert=True)
|
||||
|
||||
def test_app_role_finds_invitation_by_token_without_context(self) -> None:
|
||||
# Ссылка /join открывается без контекста: приглашение находит каталог
|
||||
# invitation_directory (tenancy/0035), иначе роль app видела бы пустоту.
|
||||
issued = issue_invitation(
|
||||
organization=self.second,
|
||||
email="invited@example.test",
|
||||
role=EmployeeRole.EMPLOYEE,
|
||||
expires_at=timezone.now() + timedelta(days=1),
|
||||
created_by=None,
|
||||
)
|
||||
with transaction.atomic():
|
||||
self._set_role("chatballs_runtime_app")
|
||||
self.assertEqual(OrganizationInvitation.objects.count(), 0)
|
||||
invitation = pending_invitation_for_token(issued.token)
|
||||
self.assertIsNotNone(invitation)
|
||||
self.assertEqual(invitation.id, issued.invitation.id)
|
||||
self.assertEqual(invitation.organization.slug, "rls-second")
|
||||
self.assertIsNone(pending_invitation_for_token("wrong-token"))
|
||||
preview = invitation_preview(issued.token)
|
||||
self.assertEqual(preview["organizationName"], "Second")
|
||||
|
||||
def test_platform_role_can_only_use_ingress_directory(self) -> None:
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UpdatesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "chatballs.updates"
|
||||
verbose_name = "Обновления установки"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Канал релизов: страница релизов GitHub.
|
||||
|
||||
Установка спрашивает у GitHub последний релиз репозитория продукта и берёт
|
||||
из него версию, заметки и ссылку на compose.yaml. Никаких токенов: релизы
|
||||
публичные, лимита анонимных запросов хватает при проверке раз в несколько
|
||||
часов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?$")
|
||||
_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Release:
|
||||
version: str
|
||||
name: str
|
||||
notes: str
|
||||
published_at: datetime | None
|
||||
compose_url: str
|
||||
page_url: str
|
||||
|
||||
|
||||
class ReleaseChannelError(Exception):
|
||||
"""Канал релизов недоступен или ответил не тем, что ожидалось."""
|
||||
|
||||
|
||||
def parse_version(tag: str) -> str | None:
|
||||
version = tag.strip()
|
||||
if version.startswith(("v", "V")):
|
||||
version = version[1:]
|
||||
return version if VERSION_RE.match(version) else None
|
||||
|
||||
|
||||
def version_key(version: str) -> tuple:
|
||||
"""Ключ сравнения: числовая тройка, затем метка (без метки — новее)."""
|
||||
|
||||
core, _, label = version.partition("-")
|
||||
core = core.split(".")[:3]
|
||||
numbers = tuple(int(part) for part in core if part.isdigit())
|
||||
return (numbers, label == "", label)
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str) -> bool:
|
||||
if not VERSION_RE.match(candidate) or not VERSION_RE.match(current or ""):
|
||||
# Dev-сборка без версии: обновлять нечего и не с чем сравнивать.
|
||||
return False
|
||||
return version_key(candidate) > version_key(current)
|
||||
|
||||
|
||||
def expected_compose_url(version: str) -> str:
|
||||
return f"https://github.com/{settings.CHATBALLS_UPDATE_REPO}/releases/download/v{version}/compose.yaml"
|
||||
|
||||
|
||||
def fetch_latest_release() -> Release:
|
||||
url = f"https://api.github.com/repos/{settings.CHATBALLS_UPDATE_REPO}/releases/latest"
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": f"chatballs/{settings.CHATBALLS_VERSION}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as error:
|
||||
raise ReleaseChannelError(str(error)) from error
|
||||
version = parse_version(str(payload.get("tag_name", "")))
|
||||
if version is None:
|
||||
raise ReleaseChannelError(f"unexpected tag {payload.get('tag_name')!r}")
|
||||
compose_url = next(
|
||||
(
|
||||
str(asset.get("browser_download_url", ""))
|
||||
for asset in payload.get("assets", [])
|
||||
if asset.get("name") == "compose.yaml"
|
||||
),
|
||||
"",
|
||||
)
|
||||
if compose_url != expected_compose_url(version):
|
||||
raise ReleaseChannelError("release has no compose.yaml at the expected address")
|
||||
published = None
|
||||
raw_published = str(payload.get("published_at", "")).strip()
|
||||
if raw_published:
|
||||
try:
|
||||
published = datetime.fromisoformat(raw_published.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
published = None
|
||||
return Release(
|
||||
version=version,
|
||||
name=str(payload.get("name") or f"v{version}"),
|
||||
notes=str(payload.get("body") or ""),
|
||||
published_at=published,
|
||||
compose_url=compose_url,
|
||||
page_url=str(payload.get("html_url") or ""),
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UpdateState",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("latest_version", models.CharField(blank=True, default="", max_length=32)),
|
||||
("latest_name", models.CharField(blank=True, default="", max_length=128)),
|
||||
("latest_notes", models.TextField(blank=True, default="")),
|
||||
("latest_published_at", models.DateTimeField(blank=True, null=True)),
|
||||
("latest_compose_url", models.URLField(blank=True, default="", max_length=512)),
|
||||
("latest_page_url", models.URLField(blank=True, default="", max_length=512)),
|
||||
("checked_at", models.DateTimeField(blank=True, null=True)),
|
||||
("check_error", models.CharField(blank=True, default="", max_length=500)),
|
||||
("install_version", models.CharField(blank=True, default="", max_length=32)),
|
||||
(
|
||||
"install_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("IDLE", "Не запускалась"),
|
||||
("REQUESTED", "Запрошена"),
|
||||
("RUNNING", "Идёт"),
|
||||
("DONE", "Завершена"),
|
||||
("FAILED", "Ошибка"),
|
||||
],
|
||||
default="IDLE",
|
||||
max_length=12,
|
||||
),
|
||||
),
|
||||
("install_message", models.CharField(blank=True, default="", max_length=1000)),
|
||||
("install_requested_at", models.DateTimeField(blank=True, null=True)),
|
||||
("install_updated_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"install_requested_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Обновления установки"},
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,60 @@
|
||||
"""Состояние обновлений установки: одна строка на инсталляцию.
|
||||
|
||||
Не тенантная таблица: версия у установки одна на всех. Проверка релизов
|
||||
живёт в воркере, установку запрашивает администратор установки, а выполняет
|
||||
сервис updater вне приложения (ADR-CHATBALLS-0049).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class InstallStatus(models.TextChoices):
|
||||
IDLE = "IDLE", "Не запускалась"
|
||||
REQUESTED = "REQUESTED", "Запрошена"
|
||||
RUNNING = "RUNNING", "Идёт"
|
||||
DONE = "DONE", "Завершена"
|
||||
FAILED = "FAILED", "Ошибка"
|
||||
|
||||
|
||||
class UpdateState(models.Model):
|
||||
SINGLETON_PK = 1
|
||||
|
||||
# Что известно о последнем релизе на канале.
|
||||
latest_version = models.CharField(max_length=32, blank=True, default="")
|
||||
latest_name = models.CharField(max_length=128, blank=True, default="")
|
||||
latest_notes = models.TextField(blank=True, default="")
|
||||
latest_published_at = models.DateTimeField(null=True, blank=True)
|
||||
latest_compose_url = models.URLField(max_length=512, blank=True, default="")
|
||||
latest_page_url = models.URLField(max_length=512, blank=True, default="")
|
||||
checked_at = models.DateTimeField(null=True, blank=True)
|
||||
check_error = models.CharField(max_length=500, blank=True, default="")
|
||||
|
||||
# Последняя запрошенная установка.
|
||||
install_version = models.CharField(max_length=32, blank=True, default="")
|
||||
install_status = models.CharField(
|
||||
max_length=12, choices=InstallStatus.choices, default=InstallStatus.IDLE
|
||||
)
|
||||
install_message = models.CharField(max_length=1000, blank=True, default="")
|
||||
install_requested_at = models.DateTimeField(null=True, blank=True)
|
||||
install_updated_at = models.DateTimeField(null=True, blank=True)
|
||||
# Кто нажал кнопку — для аудита и подписи в интерфейсе.
|
||||
install_requested_by = models.ForeignKey(
|
||||
"identity.HumanUser", on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Обновления установки"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"updates:{self.latest_version or 'нет данных'}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = self.SINGLETON_PK
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> UpdateState:
|
||||
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
|
||||
return obj
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Проверка релизов и запрос установки обновления.
|
||||
|
||||
Приложение не перезапускает само себя: у публичного процесса нет и не должно
|
||||
быть доступа к Docker. Оно лишь кладёт запрос в общий том
|
||||
``CHATBALLS_UPDATES_DIR``, а сервис updater (deploy/updater) забирает его,
|
||||
проверяет и выполняет ``docker compose pull && up`` уже снаружи. Состояние
|
||||
установки updater пишет в тот же том файлом ``status``; приложение
|
||||
переносит его в UpdateState при каждом чтении.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.updates.github import (
|
||||
ReleaseChannelError,
|
||||
fetch_latest_release,
|
||||
is_newer,
|
||||
)
|
||||
from chatballs.updates.models import InstallStatus, UpdateState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_FILE = "request.json"
|
||||
STATUS_FILE = "status.json"
|
||||
HEARTBEAT_FILE = "heartbeat"
|
||||
# Пока updater не отметился дольше этого, считаем, что установки из интерфейса нет.
|
||||
HEARTBEAT_STALE_SECONDS = 90
|
||||
_STATUS_MAP = {
|
||||
"running": InstallStatus.RUNNING,
|
||||
"done": InstallStatus.DONE,
|
||||
"failed": InstallStatus.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def updates_dir() -> Path:
|
||||
return Path(settings.CHATBALLS_UPDATES_DIR)
|
||||
|
||||
|
||||
def check_for_updates(*, force: bool = False) -> UpdateState:
|
||||
"""Спросить канал релизов, если пора (или если попросили явно)."""
|
||||
|
||||
state = UpdateState.load()
|
||||
interval = timedelta(seconds=settings.CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS)
|
||||
if not force and state.checked_at and timezone.now() - state.checked_at < interval:
|
||||
return state
|
||||
try:
|
||||
release = fetch_latest_release()
|
||||
except ReleaseChannelError as error:
|
||||
state.check_error = str(error)[:500]
|
||||
state.checked_at = timezone.now()
|
||||
state.save(update_fields=["check_error", "checked_at"])
|
||||
logger.warning("Update check failed: %s", error)
|
||||
return state
|
||||
state.latest_version = release.version
|
||||
state.latest_name = release.name
|
||||
state.latest_notes = release.notes
|
||||
state.latest_published_at = release.published_at
|
||||
state.latest_compose_url = release.compose_url
|
||||
state.latest_page_url = release.page_url
|
||||
state.check_error = ""
|
||||
state.checked_at = timezone.now()
|
||||
state.save()
|
||||
return state
|
||||
|
||||
|
||||
def update_available(state: UpdateState) -> bool:
|
||||
return bool(state.latest_version) and is_newer(state.latest_version, settings.CHATBALLS_VERSION)
|
||||
|
||||
|
||||
def updater_online() -> bool:
|
||||
"""Жив ли сервис updater: он отмечается в томе каждые несколько секунд."""
|
||||
|
||||
path = updates_dir() / HEARTBEAT_FILE
|
||||
try:
|
||||
age = timezone.now().timestamp() - path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
return age < HEARTBEAT_STALE_SECONDS
|
||||
|
||||
|
||||
def sync_install_status(state: UpdateState) -> UpdateState:
|
||||
"""Перенести статус, который пишет updater, в состояние приложения."""
|
||||
|
||||
if state.install_status not in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
|
||||
return state
|
||||
path = updates_dir() / STATUS_FILE
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return state
|
||||
if str(payload.get("version", "")) != state.install_version:
|
||||
return state
|
||||
status = _STATUS_MAP.get(str(payload.get("status", "")).lower())
|
||||
if status is None:
|
||||
return state
|
||||
message = str(payload.get("message", ""))[:1000]
|
||||
if status != state.install_status or message != state.install_message:
|
||||
state.install_status = status
|
||||
state.install_message = message
|
||||
state.install_updated_at = timezone.now()
|
||||
state.save(update_fields=["install_status", "install_message", "install_updated_at"])
|
||||
return state
|
||||
|
||||
|
||||
class InstallNotPossible(Exception):
|
||||
def __init__(self, message: str, *, code: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def request_install(*, actor, state: UpdateState | None = None) -> UpdateState:
|
||||
"""Положить запрос на установку последней версии в том updater'а."""
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
state = sync_install_status(state or UpdateState.load())
|
||||
if not update_available(state):
|
||||
raise InstallNotPossible(t("updates.nothing_to_install"), code="nothing_to_install")
|
||||
if not updater_online():
|
||||
raise InstallNotPossible(t("updates.updater_offline"), code="updater_offline")
|
||||
if state.install_status in {InstallStatus.REQUESTED, InstallStatus.RUNNING}:
|
||||
raise InstallNotPossible(t("updates.install_in_progress"), code="install_in_progress")
|
||||
directory = updates_dir()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
request_path = directory / REQUEST_FILE
|
||||
now = timezone.now()
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": state.latest_version,
|
||||
"compose_url": state.latest_compose_url,
|
||||
"requested_at": now.isoformat(),
|
||||
"requested_by": getattr(actor, "email", ""),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Прежний статус относится к прошлой установке: убрать, чтобы не спутать.
|
||||
try:
|
||||
(directory / STATUS_FILE).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
state.install_version = state.latest_version
|
||||
state.install_status = InstallStatus.REQUESTED
|
||||
state.install_message = ""
|
||||
state.install_requested_at = now
|
||||
state.install_updated_at = now
|
||||
state.install_requested_by = actor if getattr(actor, "pk", None) else None
|
||||
state.save()
|
||||
return state
|
||||
|
||||
|
||||
def update_payload(state: UpdateState) -> dict[str, object]:
|
||||
return {
|
||||
"currentVersion": settings.CHATBALLS_VERSION,
|
||||
"latestVersion": state.latest_version or None,
|
||||
"latestName": state.latest_name,
|
||||
"latestNotes": state.latest_notes,
|
||||
"latestPublishedAt": state.latest_published_at.isoformat() if state.latest_published_at else None,
|
||||
"latestPageUrl": state.latest_page_url,
|
||||
"available": update_available(state),
|
||||
"checkedAt": state.checked_at.isoformat() if state.checked_at else None,
|
||||
"checkError": state.check_error,
|
||||
"updaterOnline": updater_online(),
|
||||
"install": {
|
||||
"version": state.install_version or None,
|
||||
"status": state.install_status,
|
||||
"message": state.install_message,
|
||||
"requestedAt": state.install_requested_at.isoformat() if state.install_requested_at else None,
|
||||
"updatedAt": state.install_updated_at.isoformat() if state.install_updated_at else None,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Обновления из интерфейса: канал релизов, запрос установки, статус, права."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.testing import TenantAPIClient
|
||||
from chatballs.updates import github, services
|
||||
from chatballs.updates.models import InstallStatus, UpdateState
|
||||
|
||||
PASSWORD = "Owner-Password-2026!"
|
||||
|
||||
RELEASE = {
|
||||
"tag_name": "v1.5.0",
|
||||
"name": "v1.5.0",
|
||||
"body": "## Что нового\n- тест",
|
||||
"published_at": "2026-09-13T10:00:00Z",
|
||||
"html_url": "https://github.com/dartdavros/chatballs/releases/tag/v1.5.0",
|
||||
"assets": [
|
||||
{"name": "compose.yaml", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/compose.yaml"},
|
||||
{"name": "release.env", "browser_download_url": "https://github.com/dartdavros/chatballs/releases/download/v1.5.0/release.env"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _response(payload: dict) -> mock.MagicMock:
|
||||
"""urlopen отдаёт контекстный менеджер с read(): подделываем ровно это."""
|
||||
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value = io.BytesIO(json.dumps(payload).encode("utf-8"))
|
||||
return response
|
||||
|
||||
|
||||
class VersionTests(TestCase):
|
||||
def test_semantic_comparison(self) -> None:
|
||||
self.assertTrue(github.is_newer("1.5.0", "1.4.0"))
|
||||
self.assertTrue(github.is_newer("1.10.0", "1.9.9"))
|
||||
self.assertTrue(github.is_newer("1.5.0", "1.5.0-rc1"))
|
||||
self.assertFalse(github.is_newer("1.4.0", "1.4.0"))
|
||||
self.assertFalse(github.is_newer("1.3.9", "1.4.0"))
|
||||
# Dev-сборка: сравнивать не с чем.
|
||||
self.assertFalse(github.is_newer("1.5.0", "dev"))
|
||||
|
||||
def test_tag_parsing(self) -> None:
|
||||
self.assertEqual(github.parse_version("v1.5.0"), "1.5.0")
|
||||
self.assertEqual(github.parse_version("1.5.0-rc1"), "1.5.0-rc1")
|
||||
self.assertIsNone(github.parse_version("release-1"))
|
||||
self.assertIsNone(github.parse_version("v1.5"))
|
||||
|
||||
|
||||
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
|
||||
class UpdateCheckTests(TestCase):
|
||||
def test_check_records_latest_release(self) -> None:
|
||||
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
|
||||
state = services.check_for_updates(force=True)
|
||||
|
||||
self.assertEqual(state.latest_version, "1.5.0")
|
||||
self.assertEqual(state.latest_compose_url, RELEASE["assets"][0]["browser_download_url"])
|
||||
self.assertEqual(state.check_error, "")
|
||||
self.assertTrue(services.update_available(state))
|
||||
|
||||
def test_check_respects_interval_unless_forced(self) -> None:
|
||||
state = UpdateState.load()
|
||||
state.checked_at = timezone.now() - timedelta(minutes=5)
|
||||
state.save()
|
||||
with mock.patch("urllib.request.urlopen") as fetch:
|
||||
services.check_for_updates()
|
||||
fetch.assert_not_called()
|
||||
|
||||
def test_channel_error_is_recorded_not_raised(self) -> None:
|
||||
with mock.patch("urllib.request.urlopen", side_effect=OSError("offline")):
|
||||
state = services.check_for_updates(force=True)
|
||||
self.assertIn("offline", state.check_error)
|
||||
self.assertFalse(services.update_available(state))
|
||||
|
||||
def test_release_without_compose_asset_is_refused(self) -> None:
|
||||
release = {**RELEASE, "assets": []}
|
||||
with mock.patch("urllib.request.urlopen", return_value=_response(release)):
|
||||
state = services.check_for_updates(force=True)
|
||||
self.assertIn("compose.yaml", state.check_error)
|
||||
self.assertEqual(state.latest_version, "")
|
||||
|
||||
|
||||
@override_settings(CHATBALLS_VERSION="1.4.0", CHATBALLS_UPDATE_REPO="dartdavros/chatballs")
|
||||
class InstallRequestTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.override = override_settings(CHATBALLS_UPDATES_DIR=self.tmp.name)
|
||||
self.override.enable()
|
||||
self.addCleanup(self.override.disable)
|
||||
self.result = bootstrap_owner(email="update-owner@example.com", password=PASSWORD)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.result.owner)
|
||||
with mock.patch("urllib.request.urlopen", return_value=_response(RELEASE)):
|
||||
services.check_for_updates(force=True)
|
||||
|
||||
def _heartbeat(self) -> None:
|
||||
(services.updates_dir() / services.HEARTBEAT_FILE).write_text("", encoding="utf-8")
|
||||
|
||||
def test_install_needs_a_live_updater(self) -> None:
|
||||
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["code"], "updater_offline")
|
||||
|
||||
def test_install_writes_request_and_tracks_status_from_updater(self) -> None:
|
||||
self._heartbeat()
|
||||
|
||||
response = self.client.post("/api/v1/instance/update/install/", {}, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, 202, response.content)
|
||||
request = json.loads((services.updates_dir() / services.REQUEST_FILE).read_text(encoding="utf-8"))
|
||||
self.assertEqual(request["version"], "1.5.0")
|
||||
self.assertEqual(request["compose_url"], RELEASE["assets"][0]["browser_download_url"])
|
||||
self.assertEqual(response.json()["update"]["install"]["status"], InstallStatus.REQUESTED)
|
||||
|
||||
# Повторный запрос, пока идёт установка, отклоняется.
|
||||
again = self.client.post("/api/v1/instance/update/install/", {}, format="json")
|
||||
self.assertEqual(again.status_code, 409)
|
||||
self.assertEqual(again.json()["code"], "install_in_progress")
|
||||
|
||||
# Updater пишет статус в том — приложение подхватывает его при чтении.
|
||||
(services.updates_dir() / services.STATUS_FILE).write_text(
|
||||
json.dumps({"status": "running", "version": "1.5.0", "message": "pulling"}), encoding="utf-8"
|
||||
)
|
||||
state = self.client.get("/api/v1/instance/update/").json()["update"]
|
||||
self.assertEqual(state["install"]["status"], InstallStatus.RUNNING)
|
||||
self.assertEqual(state["install"]["message"], "pulling")
|
||||
|
||||
(services.updates_dir() / services.STATUS_FILE).write_text(
|
||||
json.dumps({"status": "done", "version": "1.5.0", "message": "updated"}), encoding="utf-8"
|
||||
)
|
||||
state = self.client.get("/api/v1/instance/update/").json()["update"]
|
||||
self.assertEqual(state["install"]["status"], InstallStatus.DONE)
|
||||
|
||||
def test_status_for_another_version_is_ignored(self) -> None:
|
||||
self._heartbeat()
|
||||
self.client.post("/api/v1/instance/update/install/", {}, format="json")
|
||||
(services.updates_dir() / services.STATUS_FILE).write_text(
|
||||
json.dumps({"status": "done", "version": "1.4.9", "message": "stale"}), encoding="utf-8"
|
||||
)
|
||||
state = self.client.get("/api/v1/instance/update/").json()["update"]
|
||||
self.assertEqual(state["install"]["status"], InstallStatus.REQUESTED)
|
||||
|
||||
def test_stale_heartbeat_means_offline(self) -> None:
|
||||
path = services.updates_dir() / services.HEARTBEAT_FILE
|
||||
path.write_text("", encoding="utf-8")
|
||||
old = time.time() - services.HEARTBEAT_STALE_SECONDS - 5
|
||||
os.utime(path, (old, old))
|
||||
self.assertFalse(services.updater_online())
|
||||
|
||||
def test_organization_owner_reads_but_cannot_install_or_check(self) -> None:
|
||||
other = Organization.objects.create(name="Other", slug="other-upd")
|
||||
owner = HumanUser.objects.create_user(email="other-owner@example.com", password=PASSWORD)
|
||||
OrganizationMembership.objects.create(organization=other, user=owner, role=EmployeeRole.OWNER, position_title="Owner")
|
||||
client = TenantAPIClient()
|
||||
client.force_authenticate(owner)
|
||||
|
||||
self.assertEqual(client.get("/api/v1/instance/update/").status_code, 200)
|
||||
self.assertEqual(client.post("/api/v1/instance/update/check/", {}, format="json").status_code, 403)
|
||||
self.assertEqual(client.post("/api/v1/instance/update/install/", {}, format="json").status_code, 403)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Обновления установки в интерфейсе: состояние, проверка, установка.
|
||||
|
||||
Читает менеджер любой организации (баннер показывается только администратору
|
||||
установки, но версия видна всем менеджерам в «Платформе»), проверять канал и
|
||||
запускать установку может только администратор установки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.instance_access import InstanceSettingsPermission, IsInstanceAdmin
|
||||
from chatballs.updates.models import UpdateState
|
||||
from chatballs.updates.services import (
|
||||
InstallNotPossible,
|
||||
check_for_updates,
|
||||
request_install,
|
||||
sync_install_status,
|
||||
update_payload,
|
||||
)
|
||||
|
||||
|
||||
class UpdateStateView(APIView):
|
||||
permission_classes = [InstanceSettingsPermission]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
state = sync_install_status(UpdateState.load())
|
||||
return Response({"update": update_payload(state)})
|
||||
|
||||
|
||||
class UpdateCheckView(APIView):
|
||||
permission_classes = [IsInstanceAdmin]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
state = sync_install_status(check_for_updates(force=True))
|
||||
return Response({"update": update_payload(state)})
|
||||
|
||||
|
||||
class UpdateInstallView(APIView):
|
||||
permission_classes = [IsInstanceAdmin]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
state = request_install(actor=request.user)
|
||||
except InstallNotPossible as error:
|
||||
return Response({"detail": str(error), "code": error.code}, status=409)
|
||||
record_audit_event(
|
||||
action="updates.install_requested",
|
||||
actor=request.user,
|
||||
organization=None,
|
||||
object_type="UpdateState",
|
||||
object_id=state.install_version,
|
||||
payload={"version": state.install_version},
|
||||
request=request,
|
||||
)
|
||||
return Response({"update": update_payload(state)}, status=202)
|
||||
@@ -33,6 +33,17 @@ if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
|
||||
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
|
||||
)
|
||||
CHATBALLS_DELIVERY_MODE = _delivery_mode
|
||||
|
||||
# Версия установки: CI передаёт её в образ при сборке релиза; в сборке из
|
||||
# исходников остаётся «dev», и тогда обновлений из интерфейса нет — сравнивать
|
||||
# не с чем.
|
||||
CHATBALLS_VERSION = os.environ.get("CHATBALLS_VERSION", "dev").strip() or "dev"
|
||||
# Канал релизов и обмен с сервисом updater (ADR-CHATBALLS-0049).
|
||||
CHATBALLS_UPDATE_REPO = os.environ.get("CHATBALLS_UPDATE_REPO", "dartdavros/chatballs").strip()
|
||||
CHATBALLS_UPDATES_DIR = os.environ.get("CHATBALLS_UPDATES_DIR", "/run/chatballs/updates")
|
||||
CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS = int(
|
||||
os.environ.get("CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS", str(15 * 60))
|
||||
)
|
||||
ALLOWED_HOSTS = env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1"])
|
||||
if TESTING:
|
||||
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
|
||||
@@ -67,6 +78,7 @@ INSTALLED_APPS = [
|
||||
"chatballs.events",
|
||||
"chatballs.support_portals",
|
||||
"chatballs.calls",
|
||||
"chatballs.updates",
|
||||
# django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels»
|
||||
# конфликтует с доменным chatballs.channels, а без runserver-оверрайда
|
||||
# (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS.
|
||||
@@ -184,10 +196,6 @@ if TESTING and not CHATBALLS_AI_PROVIDER:
|
||||
CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
||||
CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30"))
|
||||
CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2"))
|
||||
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int(
|
||||
os.environ.get("CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0")
|
||||
) # 0 = без лимита
|
||||
CHATBALLS_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели
|
||||
CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
|
||||
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ urlpatterns = [
|
||||
path("api/v1/auth/", include("chatballs.identity.auth_urls")),
|
||||
path("api/v1/setup/", include("chatballs.identity.setup_urls")),
|
||||
path("api/v1/instance/", include("chatballs.identity.instance_urls")),
|
||||
# Создание организации из интерфейса: адрес без uuid, контекст открывает
|
||||
# сам сервис. Маршруты с uuid ниже его не перехватывают.
|
||||
path("api/v1/organizations/", include("chatballs.identity.organization_urls")),
|
||||
path(
|
||||
"api/v1/demo-media/avatars/<str:name>",
|
||||
DemoMediaView.as_view(),
|
||||
|
||||
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
|
||||
import { fetchAgentDirectory } from "./features/agents/model";
|
||||
import { canAccess, defaultRoute, isManager } from "./auth/access";
|
||||
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
|
||||
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
|
||||
import { AuthChangePassword, AuthChooseOrganization, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
|
||||
import { Shell } from "./layout/Shell";
|
||||
import { pathFromRoute, routeFromPath } from "./router";
|
||||
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
|
||||
@@ -40,6 +40,9 @@ export function App() {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [organizationPublicId, setOrganizationPublicId] = useState<string | null>(initialRoute.organizationPublicId);
|
||||
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
|
||||
// Учётная запись с несколькими организациями после входа выбирает, с какой
|
||||
// начать. Ссылка на конкретную организацию экран выбора минует.
|
||||
const [organizationChoice, setOrganizationChoice] = useState<AuthenticatedUser | null>(null);
|
||||
const [recovering, setRecovering] = useState(false);
|
||||
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
|
||||
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
|
||||
@@ -132,6 +135,11 @@ export function App() {
|
||||
}, [loadData, user]);
|
||||
|
||||
const landAfterAuth = useCallback((nextIdentity: AuthenticatedUser) => {
|
||||
if (!initialRoute.organizationPublicId && nextIdentity.memberships.length > 1) {
|
||||
acceptServerLanguage(nextIdentity.language);
|
||||
setOrganizationChoice(nextIdentity);
|
||||
return;
|
||||
}
|
||||
const activeUser = useIdentity(nextIdentity, initialRoute.organizationPublicId);
|
||||
if (activeUser) {
|
||||
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
|
||||
@@ -163,6 +171,24 @@ export function App() {
|
||||
|
||||
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
|
||||
|
||||
const chooseOrganization = useCallback((organizationId: string) => {
|
||||
if (!organizationChoice) return;
|
||||
setOrganizationChoice(null);
|
||||
const activeUser = useIdentity(organizationChoice, organizationId);
|
||||
if (activeUser) {
|
||||
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
|
||||
}
|
||||
}, [navigate, organizationChoice, useIdentity]);
|
||||
|
||||
// Новая организация создана: сервер вернул учётную запись с обновлённым
|
||||
// списком членств — переключаемся в неё сразу, как после приглашения.
|
||||
const finishOrganizationCreate = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
|
||||
const activeUser = useIdentity(nextIdentity, organizationId);
|
||||
if (activeUser) {
|
||||
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
|
||||
}
|
||||
}, [navigate, useIdentity]);
|
||||
|
||||
const cancelJoin = useCallback(() => {
|
||||
setJoinToken(null);
|
||||
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
|
||||
@@ -170,6 +196,7 @@ export function App() {
|
||||
|
||||
async function logout() {
|
||||
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
|
||||
setOrganizationChoice(null);
|
||||
setIdentity(null);
|
||||
setUser(null);
|
||||
setOrganizationPublicId(null);
|
||||
@@ -194,6 +221,8 @@ export function App() {
|
||||
<ConfigProvider theme={antdTheme} locale={antdLocale}>
|
||||
{totpChallenge ? (
|
||||
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
|
||||
) : organizationChoice ? (
|
||||
<AuthChooseOrganization identity={organizationChoice} onChoose={chooseOrganization} onLogout={logout} />
|
||||
) : !identity ? (
|
||||
joinToken && !joinNeedsLogin && !needsSetup ? (
|
||||
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
|
||||
@@ -217,7 +246,7 @@ export function App() {
|
||||
) : !canAccess(user, navigation.route) ? (
|
||||
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
|
||||
) : (
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} />
|
||||
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} onOrganizationCreated={finishOrganizationCreate} />
|
||||
)}
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -17,10 +17,18 @@ export function hasCapability(user: SessionUser, capability: string): boolean {
|
||||
return user.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
/** Кто заводит новые организации: администратор установки и тот, кто где-либо
|
||||
* владелец или администратор. Роль в текущей организации не решает —
|
||||
* сотрудник здесь может быть владельцем в другой. Сервер проверяет то же. */
|
||||
export function canCreateOrganization(user: SessionUser): boolean {
|
||||
return user.isInstanceAdmin || user.memberships.some((membership) => membership.role === "OWNER" || membership.role === "ADMIN");
|
||||
}
|
||||
|
||||
export function canAccess(user: SessionUser, route: RouteKey): boolean {
|
||||
// «Настройки» — настройки организации: только владелец и админ. Личные
|
||||
// параметры сотрудника живут на странице «Профиль» (дизайн-базлайн v2).
|
||||
if (route === "profile") return true;
|
||||
if (route === "organizationCreate") return canCreateOrganization(user);
|
||||
if (isManager(user)) return true;
|
||||
return EMPLOYEE_ROUTES.has(route);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function OrganizationLogoField({
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
@@ -72,43 +72,13 @@ export function OrganizationSettingsForm({
|
||||
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
|
||||
/>
|
||||
<SelectField
|
||||
label={t("admin.currency")}
|
||||
value={organization.currency}
|
||||
label={t("settings.language")}
|
||||
value={organization.language}
|
||||
disabled={!canManage}
|
||||
onChange={(currency) => onChange({ ...organization, currency })}
|
||||
options={[["RUB", t("admin.russian_rouble_rub")]]}
|
||||
onChange={(language) => onChange({ ...organization, language })}
|
||||
options={[["", t("settings.language_as_installation")], ...languages.map((item): [string, string] => [item.code, item.label])]}
|
||||
/>
|
||||
</div>
|
||||
{/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов
|
||||
три, и выбор лучше видеть целиком, чем разворачивать список. В сетку
|
||||
полей он не встаёт — она выравнивает поля по нижнему краю, и строка с
|
||||
подписью ломала бы ряд. */}
|
||||
<div className="appearance-row administration-language">
|
||||
<span>{t("settings.language")}</span>
|
||||
<div className="appearance-theme-options">
|
||||
<button
|
||||
className={organization.language === "" ? "active" : ""}
|
||||
disabled={!canManage}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...organization, language: "" })}
|
||||
>
|
||||
{t("settings.language_as_installation")}
|
||||
</button>
|
||||
{languages.map((item) => (
|
||||
<button
|
||||
className={organization.language === item.code ? "active" : ""}
|
||||
disabled={!canManage}
|
||||
key={item.code}
|
||||
lang={item.code}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...organization, language: item.code })}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="settings-section-note">{t("settings.language_org_hint")}</p>
|
||||
{error && <div className="administration-message error" role="alert">{error}</div>}
|
||||
{canManage && (
|
||||
<div className="administration-actions">
|
||||
|
||||
@@ -25,7 +25,6 @@ export async function saveOrganizationSettings(
|
||||
body: JSON.stringify({
|
||||
name: settings.name,
|
||||
timezone: settings.timezone,
|
||||
currency: settings.currency,
|
||||
language: settings.language,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export type AdministrationSection = "organization";
|
||||
export type OrganizationSettings = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
|
||||
language: string;
|
||||
logoUrl: string | null;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
overflow: hidden;
|
||||
color: var(--n-1);
|
||||
background: var(--n-9);
|
||||
border: 1px dashed var(--n-6);
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: var(--radius-4xl);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
|
||||
|
||||
import { api, ApiError } from "../../api/client";
|
||||
import { ChannelGlyph } from "../../shared/badges";
|
||||
import { SelectField } from "../../shared/form-controls";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
|
||||
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
|
||||
@@ -21,8 +22,6 @@ import {
|
||||
connectionStatusMeta,
|
||||
connectionSubtitle,
|
||||
createdLabel,
|
||||
dailyCostCents,
|
||||
dailyCostInput,
|
||||
deleteAgent,
|
||||
fetchAgent,
|
||||
knowledgeLine,
|
||||
@@ -509,25 +508,16 @@ function AssignmentCard({ card, groups, canManage, busy, apply }: {
|
||||
? <input value={name} onChange={(event) => setName(event.target.value)} onBlur={() => { if (dirty) void apply({ name: name.trim() }); }} />
|
||||
: <span className="agent-field-static">{card.name}</span>}
|
||||
</label>
|
||||
<label className="agent-field is-select">
|
||||
<span>{t("common.group")}</span>
|
||||
<span className="agent-field-control">
|
||||
<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />
|
||||
{canManage ? (
|
||||
<select
|
||||
disabled={busy}
|
||||
value={card.groupId === null ? "" : String(card.groupId)}
|
||||
onChange={(event) => void apply({ groupId: event.target.value ? Number(event.target.value) : null })}
|
||||
>
|
||||
<option value="">{t("common.no_group")}</option>
|
||||
{groups.map((group) => <option value={String(group.id)} key={group.id}>{group.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="agent-field-static">{card.groupName ?? t("common.no_group")}</span>
|
||||
)}
|
||||
<Icon name="chevron" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
</label>
|
||||
<SelectField
|
||||
adornment={<i className="agent-group-dot" style={{ background: card.groupId === null ? "var(--n-5)" : groupColorOf(card.groupId, card.groupColor) }} />}
|
||||
disabled={busy}
|
||||
label={t("common.group")}
|
||||
readOnly={!canManage}
|
||||
readOnlyText={card.groupName ?? t("common.no_group")}
|
||||
value={card.groupId === null ? "" : String(card.groupId)}
|
||||
onChange={(next) => void apply({ groupId: next ? Number(next) : null })}
|
||||
options={[["", t("common.no_group")], ...groups.map((group) => [String(group.id), group.name] as [string, string])]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -542,40 +532,24 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
busy: boolean;
|
||||
apply: (patch: AgentPatch) => Promise<boolean>;
|
||||
}) {
|
||||
const [limit, setLimit] = useState(dailyCostInput(card.limits));
|
||||
useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]);
|
||||
const missingProvider = card.providerIntegrationId === null;
|
||||
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
|
||||
|
||||
function saveLimit() {
|
||||
const cents = dailyCostCents(limit);
|
||||
if (cents === Number(card.limits.dailyCostUsd ?? 0)) return;
|
||||
void apply({ limits: { ...card.limits, dailyCostUsd: cents } });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="agent-card is-side">
|
||||
<h3>{t("common.model")}</h3>
|
||||
<p>{t("ai.provider_key_lives_under_settings")}</p>
|
||||
<div className="agent-side-fields">
|
||||
<label className={`agent-field is-select ${missingProvider ? "is-invalid" : ""}`}>
|
||||
<span>{t("ai.provider")}</span>
|
||||
<span className="agent-field-control">
|
||||
{canManage ? (
|
||||
<select
|
||||
disabled={busy}
|
||||
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
|
||||
onChange={(event) => void apply({ providerIntegrationId: event.target.value ? Number(event.target.value) : null })}
|
||||
>
|
||||
<option value="">{t("ai.not_selected")}</option>
|
||||
{providers.map((item) => <option value={String(item.id)} key={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="agent-field-static">{providerName || t("ai.not_selected")}</span>
|
||||
)}
|
||||
<Icon name="chevron" size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
</label>
|
||||
<SelectField
|
||||
disabled={busy}
|
||||
invalid={missingProvider}
|
||||
label={t("ai.provider")}
|
||||
readOnly={!canManage}
|
||||
readOnlyText={providerName || t("ai.not_selected")}
|
||||
value={card.providerIntegrationId ? String(card.providerIntegrationId) : ""}
|
||||
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
|
||||
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
<label className="agent-field is-model">
|
||||
<span>{t("common.model")}</span>
|
||||
<span className="agent-field-control">
|
||||
@@ -583,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
<Icon name="search" size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
</label>
|
||||
<div className="agent-limit">
|
||||
<span>{t("ai.daily_limit")}</span>
|
||||
<span>
|
||||
{canManage
|
||||
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
|
||||
: <span className="agent-limit-value">{limit || "0.00"}</span>}
|
||||
<small>USD</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,6 @@ export type AgentCard = {
|
||||
model: string;
|
||||
providerIntegrationId: number | null;
|
||||
modelParams: Record<string, unknown>;
|
||||
limits: Record<string, unknown>;
|
||||
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
|
||||
answerLanguage: string;
|
||||
persona: string;
|
||||
@@ -63,7 +62,6 @@ export type AgentPatch = Partial<{
|
||||
tone: string;
|
||||
instructions: string;
|
||||
knowledgeIds: number[];
|
||||
limits: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
// Страница списка агентов (кадр G1): группа, поиск и страница — на сервере.
|
||||
@@ -255,15 +253,3 @@ export function knowledgeLine(card: AgentCard): string {
|
||||
return `${head} · ${parts.join(" + ")}`;
|
||||
}
|
||||
|
||||
// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd,
|
||||
// ADR-CHATBALLS-0023); на экране — доллары с двумя знаками.
|
||||
export function dailyCostInput(limits: Record<string, unknown>): string {
|
||||
const cents = Number(limits.dailyCostUsd ?? 0);
|
||||
return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : "";
|
||||
}
|
||||
|
||||
export function dailyCostCents(value: string): number {
|
||||
const amount = Number(value.replace(",", ".").trim());
|
||||
if (!Number.isFinite(amount) || amount <= 0) return 0;
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
@@ -352,23 +352,6 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agent-create-modal .readonly-field span,
|
||||
.agent-knowledge-modal .readonly-field span {
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.agent-create-modal .readonly-field input,
|
||||
.agent-create-modal .select-like select {
|
||||
height: 36px;
|
||||
background: var(--n-10);
|
||||
font-size: 13.5px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.agent-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -800,7 +783,6 @@
|
||||
|
||||
.agent-field textarea,
|
||||
.agent-field input,
|
||||
.agent-field select,
|
||||
.agent-field .agent-field-static {
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
@@ -814,7 +796,6 @@
|
||||
}
|
||||
|
||||
.agent-field input,
|
||||
.agent-field select,
|
||||
.agent-field > .agent-field-static {
|
||||
height: 36px;
|
||||
line-height: 34px;
|
||||
@@ -859,15 +840,15 @@
|
||||
/* Фокус поля — как в кадре G2: акцентная рамка, кольцо 3px и подложка карточки. */
|
||||
.agent-field textarea:focus,
|
||||
.agent-field input:focus,
|
||||
.agent-field select:focus,
|
||||
.agent-create-modal .form-field.is-editable input:focus,
|
||||
.agent-create-modal .select-like select:focus {
|
||||
.agent-create-modal .select-box:focus-visible {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
}
|
||||
|
||||
.agent-field-control:focus-within {
|
||||
/* Тот же фокус для общего селекта карточки. */
|
||||
.agent-side-fields .select-box:focus-visible {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
@@ -1028,23 +1009,6 @@
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.agent-field.is-invalid .agent-field-control {
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.agent-field-control select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13.5px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-field-control .agent-field-static {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
@@ -1080,54 +1044,6 @@
|
||||
color: var(--n-5);
|
||||
}
|
||||
|
||||
.agent-limit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.agent-limit > span:first-child {
|
||||
color: var(--n-3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-limit > span:last-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--n-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.agent-limit input,
|
||||
.agent-limit-value {
|
||||
display: inline-flex;
|
||||
width: 56px;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 7px;
|
||||
outline: none;
|
||||
background: var(--n-10);
|
||||
color: var(--n-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.agent-limit input:focus {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-card);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
}
|
||||
|
||||
.agent-limit small {
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
/* Подключения. */
|
||||
|
||||
.agent-connection {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type MarkdownTool,
|
||||
} from "../../../shared/markdown/markdownTools";
|
||||
import { EmptyState, LoadingState, Segmented } from "../../../shared/ui";
|
||||
import { SelectField } from "../../../shared/form-controls";
|
||||
import { Button } from "../../../shared/ui-controls";
|
||||
import type { RouteKey } from "../../../types";
|
||||
import type { AgentRef } from "../../agents/model";
|
||||
@@ -432,19 +433,12 @@ export function KnowledgeEditorPage({
|
||||
|
||||
<aside className="knowledge-editor-rail">
|
||||
<div className="knowledge-rail-fields">
|
||||
<label>
|
||||
<span>{t("ai.category")}</span>
|
||||
<span className="knowledge-select">
|
||||
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
|
||||
{catalog.categories.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{knowledgeCategoryPath(catalog.categories, item.id) || item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Icon name="chevron" size={13} strokeWidth={2} />
|
||||
</span>
|
||||
</label>
|
||||
<SelectField
|
||||
label={t("ai.category")}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
options={catalog.categories.map((item) => [String(item.id), knowledgeCategoryPath(catalog.categories, item.id) || item.name])}
|
||||
/>
|
||||
<label>
|
||||
<span>{t("ai.short_description")}<small>{t("ai.helps_search_agent")}</small></span>
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
|
||||
@@ -518,36 +518,6 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.knowledge-select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
color: var(--n-1);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.knowledge-select select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--n-1);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-select svg { position: relative; margin-left: auto; color: var(--n-4); }
|
||||
|
||||
.knowledge-rail-fields textarea {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { roleLabel } from "../../shared/ui";
|
||||
import { AuthFrame } from "./AuthFrame";
|
||||
import { t } from "../../i18n";
|
||||
import type { AuthenticatedUser } from "../../types";
|
||||
|
||||
// Выбор организации после входа — для тех, кто состоит в нескольких. Экран
|
||||
// в той же матовой рамке, что вход и приглашение: список организаций
|
||||
// строками «логотип · название · роль». Ссылка на конкретную организацию
|
||||
// этот экран минует: адрес уже сказал, куда идти.
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.trim().split(/\s+/).map((part) => part[0] ?? "").join("").slice(0, 2).toUpperCase() || "CB";
|
||||
}
|
||||
|
||||
export function AuthChooseOrganization({ identity, onChoose, onLogout }: {
|
||||
identity: AuthenticatedUser;
|
||||
onChoose: (organizationPublicId: string) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const logoutLink = (
|
||||
<button type="button" className="auth-back-login" onClick={onLogout}><Icon name="logout" size={14} />{t("auth.choose_organization_logout")}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthFrame
|
||||
title={t("auth.choose_organization_title")}
|
||||
subtitle={t("auth.choose_organization_subtitle", { name: identity.fullName || identity.email })}
|
||||
logo="pulse"
|
||||
width={420}
|
||||
note={logoutLink}
|
||||
>
|
||||
<ul className="auth-card auth-org-list">
|
||||
{identity.memberships.map((membership) => (
|
||||
<li key={membership.organizationPublicId}>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-org-item"
|
||||
onClick={() => onChoose(membership.organizationPublicId)}
|
||||
>
|
||||
<span className={`auth-org-mark ${membership.organizationLogoUrl ? "has-logo" : ""}`}>
|
||||
{membership.organizationLogoUrl
|
||||
? <img src={membership.organizationLogoUrl} alt="" />
|
||||
: initials(membership.organizationName)}
|
||||
</span>
|
||||
<span className="auth-org-copy">
|
||||
<strong>{membership.organizationName}</strong>
|
||||
<small>{membership.positionTitle || roleLabel(membership.role)}</small>
|
||||
</span>
|
||||
<Icon name="chevronRight" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AuthFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AuthChangePassword } from "./AuthChangePassword";
|
||||
export { AuthChooseOrganization } from "./AuthChooseOrganization";
|
||||
export { AuthJoin } from "./AuthJoin";
|
||||
export { AuthJoinGuest } from "./AuthJoinGuest";
|
||||
export { AuthLogin } from "./AuthLogin";
|
||||
|
||||
@@ -603,6 +603,102 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Выбор организации после входа: строки «логотип · название · роль» в той же
|
||||
матовой рамке, что и вход. Плитка логотипа — как знак в сайдбаре (28px),
|
||||
только крупнее; строка целиком — кнопка. */
|
||||
.auth-org-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth-org-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px 10px 10px;
|
||||
color: var(--n-1);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--n-8);
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.auth-org-item:hover,
|
||||
.auth-org-item:focus-visible {
|
||||
background: var(--primary-bg);
|
||||
border-color: var(--primary-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-org-item > svg {
|
||||
flex: none;
|
||||
color: var(--n-5);
|
||||
}
|
||||
|
||||
.auth-org-item:hover > svg {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
.auth-org-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
color: var(--primary-text);
|
||||
background: var(--primary-bg);
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.auth-org-mark.has-logo {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--n-7);
|
||||
}
|
||||
|
||||
.auth-org-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.auth-org-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.auth-org-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--n-1);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-org-copy small {
|
||||
overflow: hidden;
|
||||
color: var(--n-4);
|
||||
font-size: 12.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-back-login {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CallOverlay } from "./CallOverlay";
|
||||
import { Composer } from "./Composer";
|
||||
import { ConversationThread } from "./ConversationThread";
|
||||
import { DialogList } from "./DialogList";
|
||||
import { IconButton } from "../../shared/ui-controls";
|
||||
import {
|
||||
claimConversation,
|
||||
closeConversation,
|
||||
@@ -227,7 +228,16 @@ export function ConversationWorkspace({ isOwner = false, viewerId = null, listTi
|
||||
hint={hint}
|
||||
/>
|
||||
{!selectedDialog && (
|
||||
<section className="sales-conversation"><div className="sales-conversation-empty">{t("conversations.pick_conversation")}</div></section>
|
||||
<section className="sales-conversation">
|
||||
{/* Свёрнутый список без выбранного диалога: вернуть его больше неоткуда —
|
||||
шапки переписки, где живёт та же кнопка, здесь нет. */}
|
||||
{listCollapsed && (
|
||||
<div className="sales-conversation-head">
|
||||
<IconButton bare icon="collapseLeft" iconSize={17} label={t("conversations.show_list")} className="list-expand" onClick={() => setListCollapsed(false)} />
|
||||
</div>
|
||||
)}
|
||||
<div className="sales-conversation-empty">{t("conversations.pick_conversation")}</div>
|
||||
</section>
|
||||
)}
|
||||
{selectedDialog && (
|
||||
<section className="sales-conversation enter-surface" key={selectedDialog.id}>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function DialogList({ title = t("common.conversations"), searchPlaceholde
|
||||
// Ширина списка: тянется за правый край (280–520px), запоминается в браузере.
|
||||
const listWidth = useResizableWidth("dialogList", { fallback: 323, min: 280, max: 520 });
|
||||
return (
|
||||
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
|
||||
<section className={`sales-dialog-list ${listWidth.dragging ? "is-resizing" : ""}`} data-onboarding-target="chat-list" style={{ "--dialog-list-width": `${listWidth.width}px` } as CSSProperties}>
|
||||
<div className="pane-resizer" role="separator" aria-orientation="vertical" aria-label={t("conversations.conversation_list_width")} title={t("profile.drag_resize_double_click_reset")} onPointerDown={listWidth.onPointerDown} onDoubleClick={listWidth.reset} />
|
||||
{mobileHeader?.({ total })}
|
||||
<div className="sales-dialog-list-head">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api/client";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { SelectField } from "../../shared/form-controls";
|
||||
import { Avatar } from "../../shared/ui";
|
||||
import { SearchInput } from "../../shared/ui-controls";
|
||||
import { useDebounced } from "../../shared/useDebounced";
|
||||
@@ -100,20 +101,18 @@ export function OwnershipTransferModal({ onClose }: { onClose: () => void }) {
|
||||
<p className="employee-create-note">{t("admin.there_no_active_administrators_transfer")}</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="employee-field is-single">
|
||||
<span>{t("admin.new_owner_administrators_only")}</span>
|
||||
<span className="employee-transfer-select">
|
||||
<select value={targetId === null ? "" : String(targetId)} onChange={(event) => setTargetId(Number(event.target.value))}>
|
||||
{candidates.map((employee) => (
|
||||
<option value={employee.id} key={employee.id}>
|
||||
{employee.fullName || employee.email} · {roleBadge(employee.role).text} · {employee.positionTitle}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Icon name="chevron" size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className="employee-field is-single">
|
||||
<SelectField
|
||||
label={t("admin.new_owner_administrators_only")}
|
||||
value={targetId === null ? "" : String(targetId)}
|
||||
onChange={(value) => setTargetId(Number(value))}
|
||||
options={candidates.map((employee) => [
|
||||
String(employee.id),
|
||||
`${employee.fullName || employee.email} · ${roleBadge(employee.role).text} · ${employee.positionTitle}`,
|
||||
])}
|
||||
/>
|
||||
<small className="employee-transfer-note">{t("admin.list_holds_active_administrators_only")}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="employee-transfer-pair">
|
||||
<div>
|
||||
|
||||
@@ -440,39 +440,6 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.employee-transfer-select {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--n-7);
|
||||
border-radius: 8px;
|
||||
background: var(--n-10);
|
||||
color: var(--n-1);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.employee-transfer-select select {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.employee-transfer-select > svg {
|
||||
flex: none;
|
||||
color: var(--n-4);
|
||||
}
|
||||
|
||||
.employee-transfer-note {
|
||||
color: var(--n-4);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { t } from "../../i18n";
|
||||
import { useOnboarding } from "./useOnboarding";
|
||||
|
||||
// Пилюля возврата в правом нижнем углу рабочей области. Появляется, когда
|
||||
// окно закрыто, тур не идёт, а настроено ещё не всё: закрыть онбординг можно
|
||||
// одним кликом, но дорогу назад стоит оставить на виду.
|
||||
|
||||
export function OnboardingLauncher() {
|
||||
const onboarding = useOnboarding();
|
||||
if (!onboarding?.showLauncher) return null;
|
||||
return (
|
||||
<button className="ob-launcher" type="button" onClick={onboarding.open}>
|
||||
<Icon name="sparkles" size={17} strokeWidth={1.9} />
|
||||
{t("onboarding.title")}
|
||||
<small>{t("onboarding.progress_short", { done: onboarding.doneCount, total: onboarding.steps.length })}</small>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user