mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
- новый агрегированный API /api/v1/agents/: мастер одного шага (имя+группа,
код из имени), объединённый PATCH канала и AI-конфигурации, удаление с
блокерами, активация AI, привязка/отвязка подключений, тест-чат;
- data-миграция ai/0015: DRAFT-агент каждому каналу без агента;
- UI: разделы «Каналы» и «AI-агенты» слиты в «Агенты» (список + карточка-центр
администрирования); legacy-URL редиректят; форма интеграции выбирает агента;
- чистка: features/channels и features/ai/{list,create,detail} удалены,
битые @import styles.css (этапы 1-2) починены — прод-сборка снова собирается;
- дизайн-базлайн v2 владельца добавлен в design/baseline/Chatbolls Baseline v2/.
Backend 582 теста OK (докер, миграции с нуля), frontend tsc + vitest 69 OK, build OK.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
3.8 KiB
Python
108 lines
3.8 KiB
Python
from __future__ import annotations
|
|
|
|
from rest_framework.test import APIClient
|
|
|
|
from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership
|
|
from hub_platform.tenancy.context import TenantActorKind, TenantContext
|
|
|
|
|
|
def tenant_context_for(
|
|
user: HumanUser,
|
|
organization: Organization | None = None,
|
|
) -> TenantContext:
|
|
memberships = OrganizationMembership.objects.select_related("organization", "user").filter(
|
|
user=user,
|
|
blocked_at__isnull=True,
|
|
)
|
|
if organization is not None:
|
|
memberships = memberships.filter(organization=organization)
|
|
return TenantContext.for_membership(memberships.get())
|
|
|
|
|
|
def system_tenant_context(organization: Organization) -> TenantContext:
|
|
return TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
|
|
|
|
|
|
class TenantAPIClient(APIClient):
|
|
"""Test client that turns legacy test literals into the C03 tenant route.
|
|
|
|
Production URL resolution is untouched. New tenancy tests call canonical URLs
|
|
directly and assert that the old runtime routes return 404.
|
|
"""
|
|
|
|
tenant_namespaces = {
|
|
"access-profiles",
|
|
"agents",
|
|
"ai",
|
|
"calls",
|
|
"channels",
|
|
"company",
|
|
"conversations",
|
|
"employees",
|
|
"integrations",
|
|
"notifications",
|
|
"support",
|
|
}
|
|
public_prefixes = (
|
|
"/api/v1/ai/files/",
|
|
"/api/v1/calls/access/",
|
|
"/api/v1/calls/invites/",
|
|
"/api/v1/support/sessions/",
|
|
"/api/v1/help/",
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self._tenant_user = None
|
|
self.organization_public_id = None
|
|
|
|
def force_authenticate(self, user=None, token=None) -> None:
|
|
self._tenant_user = user
|
|
if user is not None:
|
|
# Tenant middleware runs before DRF's force-auth hook, so test
|
|
# requests also need Django's authenticated session available.
|
|
super().force_login(user)
|
|
super().force_authenticate(user=user, token=token)
|
|
|
|
def login(self, **credentials) -> bool:
|
|
authenticated = super().login(**credentials)
|
|
if authenticated:
|
|
email = credentials.get("email") or credentials.get("username")
|
|
self._tenant_user = HumanUser.objects.filter(email=email).first()
|
|
return authenticated
|
|
|
|
def force_login(self, user, backend=None) -> None:
|
|
self._tenant_user = user
|
|
super().force_login(user, backend=backend)
|
|
|
|
def logout(self) -> None:
|
|
self._tenant_user = None
|
|
super().logout()
|
|
|
|
def set_tenant(self, organization) -> None:
|
|
self.organization_public_id = organization.public_id
|
|
|
|
def generic(self, method, path, data="", content_type="application/octet-stream", secure=False, **extra):
|
|
path = self._organization_path(path)
|
|
return super().generic(method, path, data, content_type, secure, **extra)
|
|
|
|
def _organization_path(self, path: str) -> str:
|
|
if not path.startswith("/api/v1/") or path.startswith("/api/v1/organizations/"):
|
|
return path
|
|
if any(path.startswith(prefix) for prefix in self.public_prefixes):
|
|
return path
|
|
namespace = path[len("/api/v1/") :].split("/", 1)[0]
|
|
if namespace not in self.tenant_namespaces:
|
|
return path
|
|
public_id = self.organization_public_id
|
|
if public_id is None and self._tenant_user is not None:
|
|
values = list(
|
|
self._tenant_user.memberships.filter(blocked_at__isnull=True).values_list(
|
|
"organization__public_id", flat=True
|
|
)[:2]
|
|
)
|
|
public_id = values[0] if len(values) == 1 else None
|
|
if public_id is None:
|
|
return path
|
|
return f"/api/v1/organizations/{public_id}{path[len('/api/v1') :]}"
|