mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🚨 fix(lint): привести код к своей же конфигурации ruff и включить её в CI
Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом pyproject.toml (`select = ["E","F","I","UP","B","DJ"]`), а запускали ruff из apps/backend — там конфигурации нет, и он молча брал правила по умолчанию. Из корня та же команда показывает 1102 нарушения вместо четырнадцати. Что с ними сделано: - E501 (968 из 1102) вынесен в ignore с объяснением: длину строки правит форматтер, а `ruff format` в этом репозитории не принят — он переписал бы 374 файла. Порога, до которого можно поднять line-length, нет: 398 строк длиннее 120 символов, 222 длиннее 140. Правило, которое всегда красное, просто перестают читать. - Миграции исключены из проверки: их пишет Django. - Остальные 104 исправлены. Содержательное: assertRaises(Exception) в тесте доставки приглашения заменён на конкретный CallInviteDeliveryError; убрана мёртвая переменная identity в демо-сиде (у Conversation нет такого поля — единственный источник identity это контакт); порядок членов у пяти моделей по Django Style Guide; __str__ двум синглтонам инстанса, которые в админке показывались бы как «object (1)»; isinstance с кортежем → оператор |. CI: ruff добавлен в .github/workflows/checks.yml (push и pull request — до этого на GitHub не проверялось ничего до первого тега), в стадию validate GitLab и в scripts/check.ps1. Везде запуск из корня репозитория, иначе повторится та же подмена правил. Версия берётся из requirements бэкенда, чтобы CI и образ не разъезжались. Из pre-commit убран ruff-format: висел хук, который при первой же установке переписал бы 374 файла — это отдельное решение, а не побочный эффект. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e62ce1ed5
commit
482fa73d95
85 files changed
+276
-283
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# Быстрые проверки на каждый push и pull request.
|
||||
#
|
||||
# До этого на GitHub был только релизный workflow по тегу, то есть до первого
|
||||
# тега ничего не проверялось вовсе. Здесь — то, что стоит секунды и ловит
|
||||
# ошибки раньше человека: линтер бэкенда и тесты deployment CLI (они держат
|
||||
# свойство «установка — один compose.yaml без host-mount'ов»).
|
||||
#
|
||||
# Бэкенд-сьют и e2e сюда не вынесены намеренно: им нужны Postgres, Redis и
|
||||
# полчаса, их место — отдельный пайплайн, а не проверка на каждый коммит.
|
||||
|
||||
name: checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Установить ruff той же версии, что и в образе
|
||||
run: |
|
||||
set -eu
|
||||
# Единственный источник версии — requirements бэкенда, чтобы CI и
|
||||
# контейнер не разъезжались.
|
||||
constraint="$(grep -E '^ruff' apps/backend/requirements.txt)"
|
||||
python -m pip install --disable-pip-version-check "$constraint"
|
||||
ruff --version
|
||||
|
||||
# Конфигурация линтера лежит в корневом pyproject.toml (src = apps/backend).
|
||||
- name: ruff check
|
||||
run: ruff check --output-format=github apps/backend
|
||||
|
||||
cli:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: python -m pip install --disable-pip-version-check pytest pyyaml
|
||||
|
||||
# Тесты гоняют настоящий bash-скрипт chatballs с замоканными docker и
|
||||
# flock, плюс проверяют, что production-манифест остаётся
|
||||
# самодостаточным (tests/cli/test_release_compose.py).
|
||||
- run: python -m pytest -q tests/cli
|
||||
@@ -1,3 +1,17 @@
|
||||
lint:python:
|
||||
stage: validate
|
||||
image: python:3.12-slim
|
||||
before_script:
|
||||
# Версия ruff — из requirements бэкенда, чтобы CI и образ не разъезжались.
|
||||
- pip install --disable-pip-version-check "$(grep -E '^ruff' $APP_DIR/apps/backend/requirements.txt)"
|
||||
script:
|
||||
- ruff --version
|
||||
# Конфигурация в корневом pyproject.toml (src = apps/backend).
|
||||
- ruff check "$APP_DIR/apps/backend"
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
frontend:build:
|
||||
stage: validate
|
||||
image: node:22-alpine
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
repos:
|
||||
# Тот же линтер и та же конфигурация, что в CI (.github/workflows/checks.yml).
|
||||
# ruff-format здесь намеренно нет: он переписал бы 374 файла разом, и это
|
||||
# отдельное решение, а не побочный эффект установки хука.
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
|
||||
@@ -147,6 +147,16 @@ cookie получают префикс `__Host-`, флаг `Secure` и HSTS; п
|
||||
|
||||
## Tests
|
||||
|
||||
Линтер бэкенда (та же конфигурация, что в CI — корневой `pyproject.toml`):
|
||||
|
||||
```bash
|
||||
ruff check apps/backend
|
||||
```
|
||||
|
||||
`ruff format` в репозитории не принят: он переписал бы 374 файла, поэтому
|
||||
длина строки (`E501`) из проверок исключена — всё остальное из `E`, `F`, `I`,
|
||||
`UP`, `B` и `DJ` обязано быть зелёным. Миграции не проверяются: их пишет Django.
|
||||
|
||||
All suites run in Docker, so no manual environment is required — the test
|
||||
runners auto-detect themselves and relax production hardening (secret-key
|
||||
fail-fast, SSL redirect, throttling) for the duration of the run.
|
||||
|
||||
@@ -13,7 +13,6 @@ from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When
|
||||
from django.utils.text import slugify
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider_selection import configure_agent_provider
|
||||
from chatballs.ai.serializers import agent_portal_article_payload
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.channels.services import (
|
||||
|
||||
@@ -30,7 +30,7 @@ def _store_fragments(*, organization, chunks: list[str], **source) -> list[Knowl
|
||||
embedding=vector,
|
||||
**source,
|
||||
)
|
||||
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
|
||||
for index, (chunk, vector) in enumerate(zip(chunks, vectors, strict=False))
|
||||
]
|
||||
return KnowledgeFragment.objects.bulk_create(fragments)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.ai.models import AIAgent, Knowledge
|
||||
from chatballs.identity.policy import has_capability_any_scope
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
AI_VIEW = "ai.view"
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import uuid
|
||||
|
||||
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.db import models
|
||||
|
||||
from pgvector.django import VectorField
|
||||
|
||||
|
||||
|
||||
from chatballs.tenancy.models import TenantRelationModel
|
||||
|
||||
|
||||
|
||||
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
|
||||
|
||||
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
|
||||
@@ -88,13 +80,9 @@ class Knowledge(models.Model):
|
||||
|
||||
|
||||
|
||||
def clean(self) -> None:
|
||||
def __str__(self) -> str:
|
||||
|
||||
super().clean()
|
||||
|
||||
if self.category_id is not None and self.category.organization_id != self.organization_id:
|
||||
|
||||
raise ValidationError({"category": "Category belongs to another organization"})
|
||||
return f"knowledge:{self.organization_id}/{self.title}"
|
||||
|
||||
|
||||
|
||||
@@ -106,9 +94,13 @@ class Knowledge(models.Model):
|
||||
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
def clean(self) -> None:
|
||||
|
||||
return f"knowledge:{self.organization_id}/{self.title}"
|
||||
super().clean()
|
||||
|
||||
if self.category_id is not None and self.category.organization_id != self.organization_id:
|
||||
|
||||
raise ValidationError({"category": "Category belongs to another organization"})
|
||||
|
||||
|
||||
|
||||
@@ -119,15 +111,10 @@ class Knowledge(models.Model):
|
||||
# models after Knowledge exists so they are registered without growing this file.
|
||||
|
||||
from chatballs.ai.knowledge_models import ( # noqa: E402, F401
|
||||
|
||||
KnowledgeCategory,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:
|
||||
|
||||
organization = instance.knowledge.organization
|
||||
@@ -198,8 +185,6 @@ class KnowledgeAttachment(TenantRelationModel):
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from chatballs.ai.provider.openrouter import OpenRouterProvider
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class CustomProvider(OpenRouterProvider):
|
||||
|
||||
"""Generic OpenAI-compatible BYOK adapter (ADR-CHATBALLS-0034).
|
||||
|
||||
@@ -15,7 +15,13 @@ import hashlib
|
||||
import math
|
||||
import re
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.base import (
|
||||
ChatMessage,
|
||||
ChatResult,
|
||||
EmbeddingResult,
|
||||
LLMProvider,
|
||||
ProviderError,
|
||||
)
|
||||
|
||||
EMBEDDING_DIM = 16
|
||||
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
|
||||
|
||||
@@ -32,26 +32,15 @@ do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
import http.client
|
||||
|
||||
import json
|
||||
|
||||
import urllib.error
|
||||
|
||||
import urllib.request
|
||||
|
||||
|
||||
|
||||
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
|
||||
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
|
||||
|
||||
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import time
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.channels.models import Channel
|
||||
@@ -17,6 +16,7 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class AgentCardTestCase(TestCase):
|
||||
|
||||
@@ -8,10 +8,17 @@ from chatballs.ai.provider.routing import _provider_from_integration
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
|
||||
from chatballs.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from chatballs.integrations.services import (
|
||||
IntegrationInput,
|
||||
create_integration,
|
||||
)
|
||||
from chatballs.integrations.services import (
|
||||
# Алиас обязателен: имя test_* на уровне модуля pytest собирает как тест
|
||||
# и падает на ненайденных фикстурах (как в integrations/tests.py).
|
||||
test_integration as run_integration_test,
|
||||
|
||||
@@ -3,13 +3,14 @@ import tempfile
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from chatballs.testing import TenantAPIClient as APIClient, system_tenant_context
|
||||
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
|
||||
|
||||
@@ -284,7 +285,11 @@ class ResilienceTests(TestCase):
|
||||
|
||||
def test_circuit_breaker_opens_after_threshold(self) -> None:
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
|
||||
from chatballs.ai.provider.resilience import (
|
||||
CircuitBreaker,
|
||||
CircuitBreakerOpen,
|
||||
call_with_resilience,
|
||||
)
|
||||
|
||||
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.ai.api_errors import validation_error_response
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.ai.indexing import reindex_knowledge
|
||||
from chatballs.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
|
||||
from chatballs.ai.knowledge_import import import_knowledge_documents
|
||||
from chatballs.ai.knowledge_policy import (
|
||||
@@ -19,7 +19,6 @@ from chatballs.ai.knowledge_services import (
|
||||
delete_knowledge,
|
||||
update_knowledge,
|
||||
)
|
||||
from chatballs.ai.indexing import reindex_knowledge
|
||||
from chatballs.ai.models import Knowledge
|
||||
from chatballs.ai.selectors import (
|
||||
apply_knowledge_filters,
|
||||
@@ -29,6 +28,7 @@ from chatballs.ai.selectors import (
|
||||
writable_knowledge_item_for_employee,
|
||||
)
|
||||
from chatballs.ai.serializers import attachment_payload, knowledge_payload
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import AuditEvent
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
|
||||
|
||||
def _flatten(data: Any) -> str:
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
return "; ".join(_flatten(value) for value in data.values())
|
||||
if isinstance(data, (list, tuple)):
|
||||
if isinstance(data, list | tuple):
|
||||
return "; ".join(_flatten(item) for item in data)
|
||||
return str(data)
|
||||
|
||||
|
||||
@@ -17,10 +17,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
|
||||
from chatballs.calls import signaling
|
||||
from chatballs.calls.errors import CallTokenError
|
||||
from chatballs.calls.models import TERMINAL_CALL_STATUSES
|
||||
from chatballs.calls.models import TERMINAL_CALL_STATUSES, ParticipantSide
|
||||
from chatballs.calls.permissions import staff_call_access_valid
|
||||
from chatballs.calls.services import authorize_call_access_context
|
||||
from chatballs.calls.models import ParticipantSide
|
||||
from chatballs.tenancy.database import run_tenant_operation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,7 +12,6 @@ from chatballs.calls.models import (
|
||||
ParticipantSide,
|
||||
)
|
||||
from chatballs.conversations.models import Message, MessageAuthor
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
CallStatus.REQUESTED: {
|
||||
|
||||
@@ -46,7 +46,7 @@ def record_call_metric(
|
||||
local = _sanitize_candidate_type(local_candidate_type)
|
||||
remote = _sanitize_candidate_type(remote_candidate_type)
|
||||
rtt: int | None = None
|
||||
if isinstance(round_trip_ms, (int, float)) and not isinstance(round_trip_ms, bool):
|
||||
if isinstance(round_trip_ms, int | float) and not isinstance(round_trip_ms, bool):
|
||||
rtt = max(0, min(_MAX_ROUND_TRIP_MS, int(round_trip_ms)))
|
||||
CallMetric.objects.update_or_create(
|
||||
call_session_id=call_session_id,
|
||||
|
||||
@@ -113,15 +113,15 @@ class CallSession(models.Model):
|
||||
]
|
||||
indexes = [models.Index(fields=["organization", "status"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"call:{self.id}/{self.status}"
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> int | None:
|
||||
if self.connected_at is None or self.ended_at is None:
|
||||
return None
|
||||
return max(0, int((self.ended_at - self.connected_at).total_seconds()))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"call:{self.id}/{self.status}"
|
||||
|
||||
|
||||
class CallInvite(TenantRelationModel):
|
||||
tenant_relation_fields = ("call_session", "connection_identity")
|
||||
|
||||
@@ -14,7 +14,6 @@ from chatballs.calls.errors import (
|
||||
CallTokenError,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.integrations.features import call_allowed
|
||||
from chatballs.calls.metrics import record_call_metric
|
||||
from chatballs.calls.models import (
|
||||
TERMINAL_CALL_STATUSES,
|
||||
@@ -53,6 +52,7 @@ from chatballs.conversations.models import (
|
||||
)
|
||||
from chatballs.conversations.services import ClaimError, claim_locked_conversation
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.integrations.features import call_allowed
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
@@ -10,16 +10,17 @@ from django.utils import timezone
|
||||
from chatballs.calls.errors import CallInvalidTransition
|
||||
from chatballs.calls.lifecycle import finish_call, transition_call
|
||||
from chatballs.calls.models import (
|
||||
TERMINAL_CALL_STATUSES,
|
||||
CallParticipant,
|
||||
CallSession,
|
||||
CallStatus,
|
||||
ParticipantConnectionState,
|
||||
TERMINAL_CALL_STATUSES,
|
||||
)
|
||||
from chatballs.calls.serializers import public_call_state_payload
|
||||
from chatballs.calls.services import record_call_metric
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def _call(context: TenantContext, call_id) -> CallSession:
|
||||
return CallSession.objects.select_related("initiated_by").get(
|
||||
id=call_id, organization=context.organization
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import json
|
||||
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.calls.models import CallKind, CallSession, CallStatus, ParticipantSide
|
||||
from chatballs.calls.tests.helpers import CallTestCase, create_call_request
|
||||
from chatballs.calls.tokens import verify_call_access_token
|
||||
from chatballs.conversations.models import ControlMode
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class InternalCallApiTests(CallTestCase):
|
||||
|
||||
@@ -3,13 +3,12 @@ TG/MAX через outbox, истечение и системные событи
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.utils import timezone
|
||||
from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
|
||||
|
||||
from chatballs.calls.event_handlers import handle_call_invite_send
|
||||
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
|
||||
from chatballs.calls.models import (
|
||||
CallEndedBy,
|
||||
CallInvite,
|
||||
@@ -18,14 +17,16 @@ from chatballs.calls.models import (
|
||||
InviteDeliveryStatus,
|
||||
)
|
||||
from chatballs.calls.services import (
|
||||
open_call_for_identity,
|
||||
decline_call_for_identity,
|
||||
open_call_for_identity,
|
||||
)
|
||||
from chatballs.calls.tests.helpers import CallTestCase, create_call_request, expire_stale_calls
|
||||
from chatballs.calls.tokens import hash_invite_token
|
||||
from chatballs.conversations.models import Conversation, Message
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
class CancelCallApiTests(CallTestCase):
|
||||
@@ -279,7 +280,7 @@ class MessengerDeliveryTests(CallTestCase):
|
||||
with mock.patch(
|
||||
"chatballs.calls.event_handlers.transports.send_call_invite", return_value=False
|
||||
):
|
||||
with self.assertRaises(Exception):
|
||||
with self.assertRaises(CallInviteDeliveryError):
|
||||
handle_call_invite_send(
|
||||
{"callSessionId": str(created.call_session.id)},
|
||||
tenant_context_for(self.owner, self.organization),
|
||||
|
||||
@@ -270,7 +270,7 @@ class AuthDeadlineTests(CallDomainMixin, TransactionTestCase):
|
||||
self.assertEqual(output["code"], consumers.AUTH_TIMEOUT_CLOSE)
|
||||
|
||||
def test_authenticated_socket_survives_the_deadline(self) -> None:
|
||||
created, customer_token = None, None
|
||||
customer_token = None
|
||||
|
||||
def prepare():
|
||||
create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -32,7 +32,7 @@ class CallAccessClaims:
|
||||
def _derived_secret(purpose: str) -> bytes:
|
||||
return hmac.new(
|
||||
(settings.SECRET_KEY or "").encode("utf-8"),
|
||||
f"hub:{purpose}:v1".encode("utf-8"),
|
||||
f"hub:{purpose}:v1".encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.api.permissions import HasCapability
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
ConversationPriority,
|
||||
ControlMode,
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.conversations.models import ConnectionIdentity, ContactMerge, Contact, Conversation
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
|
||||
# Поля карточки, которые дозаполняются из исходного контакта, если у целевого
|
||||
|
||||
@@ -4,7 +4,6 @@ import html
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"a",
|
||||
"b",
|
||||
|
||||
@@ -98,7 +98,10 @@ class TranscriptionJob:
|
||||
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
|
||||
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.routing import DEFAULT_TRANSCRIPTION_MODEL, resolve_transcription_model
|
||||
from chatballs.ai.provider.routing import (
|
||||
DEFAULT_TRANSCRIPTION_MODEL,
|
||||
resolve_transcription_model,
|
||||
)
|
||||
|
||||
if not message.audio:
|
||||
return None
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
|
||||
from chatballs.conversations.models import Conversation, LifecycleState
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
|
||||
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import Contact
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
from chatballs.conversations.stats import sales_overview_stats
|
||||
from chatballs.conversations.view_base import ConversationViewBase
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
|
||||
|
||||
class ConversationStatsView(ConversationViewBase):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.db.models import Count, Max, Q
|
||||
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Conversation,
|
||||
@@ -9,6 +8,7 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +1,21 @@
|
||||
from django.db import transaction
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
|
||||
from chatballs.conversations import transports
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
|
||||
ConnectionIdentity,
|
||||
|
||||
Conversation,
|
||||
|
||||
ControlMode,
|
||||
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
|
||||
LifecycleState,
|
||||
|
||||
Message,
|
||||
|
||||
MessageAuthor,
|
||||
|
||||
MessageKind,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.identity.models import EmployeeRole
|
||||
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
|
||||
|
||||
|
||||
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
|
||||
|
||||
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
|
||||
|
||||
@@ -15,8 +15,8 @@ from django.utils import timezone
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
from chatballs.channels.selectors import channels_in_organization
|
||||
from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
Message,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import Contact, Conversation
|
||||
@@ -10,6 +9,7 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class ConversationVisibilityTests(TestCase):
|
||||
|
||||
@@ -2,15 +2,14 @@ import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
ConversationPriority,
|
||||
ControlMode,
|
||||
LifecycleState,
|
||||
ReplyTemplate,
|
||||
)
|
||||
@@ -21,6 +20,7 @@ from chatballs.identity.models import (
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class ChatExtrasTestCase(TestCase):
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.contacts_merge import merge_contacts, revert_merge
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, ContactMerge, Conversation
|
||||
from chatballs.conversations.models import ConnectionIdentity, Contact, Conversation
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeRole,
|
||||
|
||||
@@ -3,39 +3,22 @@
|
||||
|
||||
|
||||
from email import message_from_bytes, policy
|
||||
|
||||
from email.message import EmailMessage as MimeMessage
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.http import QueryDict
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
|
||||
from chatballs.conversations.clients import client_detail, client_row, clients_queryset
|
||||
|
||||
from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
from chatballs.conversations.selectors import conversation_messages
|
||||
|
||||
from chatballs.conversations.serializers import conversation_payload, message_payload
|
||||
|
||||
from chatballs.conversations.transports import email as email_transport
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
|
||||
|
||||
|
||||
EMAIL_CONFIG = {
|
||||
|
||||
"email": "support@example.com",
|
||||
@@ -499,7 +482,6 @@ class EmailIngestThreadMetaTests(TestCase):
|
||||
def _ingest(self, *, external_id: str, subject: str, message_id: str) -> None:
|
||||
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
|
||||
|
||||
|
||||
@@ -1,57 +1,30 @@
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
from chatballs.conversations.models import (
|
||||
|
||||
ConnectionIdentity,
|
||||
|
||||
Contact,
|
||||
|
||||
ControlMode,
|
||||
|
||||
Conversation,
|
||||
|
||||
ExpectedResponder,
|
||||
|
||||
LifecycleState,
|
||||
|
||||
MessageAuthor,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import HumanUser, Organization
|
||||
|
||||
from chatballs.integrations.models import (
|
||||
|
||||
Integration,
|
||||
|
||||
IntegrationKind,
|
||||
|
||||
IntegrationProvider,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _connection(channel: Channel) -> Integration:
|
||||
|
||||
return Integration.objects.create(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import connections
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import connections
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.provider.local import LocalProvider
|
||||
from chatballs.channels.models import Channel
|
||||
@@ -20,7 +19,6 @@ from chatballs.conversations.models import (
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.tenancy.database import current_tenant_id, tenant_atomic
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
@@ -32,6 +30,8 @@ from chatballs.integrations.models import (
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
)
|
||||
from chatballs.tenancy.database import current_tenant_id, tenant_atomic
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
class VoiceFixtureMixin:
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import json
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
@@ -19,9 +17,9 @@ from chatballs.conversations.models import (
|
||||
MessageAuthor,
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.conversations.transports import max as max_transport
|
||||
from chatballs.conversations.transports import telegram as telegram_transport
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
EmployeeRole,
|
||||
@@ -31,6 +29,7 @@ from chatballs.identity.models import (
|
||||
)
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.models import Notification, NotificationAudience, NotificationType
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
|
||||
def _messenger_connection(channel):
|
||||
|
||||
@@ -13,7 +13,6 @@ from django.conf import settings
|
||||
from chatballs.integrations.outbound import ensure_downloadable
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ from email.utils import parseaddr
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.conversations.html_sanitizer import sanitize_email_html
|
||||
from chatballs.conversations.transports.base import MAX_ATTACHMENT_BYTES, InboundFile, InboundMessage, guess_content_type, safe_filename
|
||||
from chatballs.conversations.transports.base import (
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
InboundFile,
|
||||
InboundMessage,
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from chatballs.conversations import attachment_views, chat_extras_views, reporting_views, views, voice_views
|
||||
from chatballs.conversations import (
|
||||
attachment_views,
|
||||
chat_extras_views,
|
||||
reporting_views,
|
||||
views,
|
||||
voice_views,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ConversationListView.as_view(), name="conversation-list"),
|
||||
|
||||
@@ -2,6 +2,12 @@ from django.db import transaction
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.api.pagination import (
|
||||
cursor_id,
|
||||
window,
|
||||
window_payload,
|
||||
window_size,
|
||||
)
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
Conversation,
|
||||
@@ -9,12 +15,6 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
Message,
|
||||
)
|
||||
from chatballs.api.pagination import (
|
||||
cursor_id,
|
||||
window,
|
||||
window_payload,
|
||||
window_size,
|
||||
)
|
||||
from chatballs.conversations.selectors import (
|
||||
MESSAGES_NEWER_KEYS,
|
||||
MESSAGES_OLDER_KEYS,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from chatballs.events.models import OutboxEvent
|
||||
from chatballs.events.services import tenant_context_for_event
|
||||
|
||||
@@ -13,7 +13,6 @@ from chatballs.identity.audit_catalog import (
|
||||
)
|
||||
from chatballs.identity.models import AuditEvent, Organization
|
||||
|
||||
|
||||
ORGANIZATION_CHANGE_ACTIONS = (
|
||||
"administration.organization_updated",
|
||||
"administration.logo_updated",
|
||||
|
||||
@@ -18,7 +18,6 @@ from chatballs.tenancy.storage_quota import (
|
||||
reserve_storage,
|
||||
)
|
||||
|
||||
|
||||
MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ from datetime import datetime, time, timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.http import FileResponse
|
||||
from django.utils import timezone as django_timezone
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
|
||||
@@ -5,9 +5,9 @@ from chatballs.identity.auth.password_reset import (
|
||||
PasswordResetValidateView,
|
||||
)
|
||||
from chatballs.identity.auth.profile import (
|
||||
ChangeTemporaryPasswordView,
|
||||
ProfileAppearanceView,
|
||||
ProfileAvatarView,
|
||||
ChangeTemporaryPasswordView,
|
||||
ProfilePasswordView,
|
||||
ProfileRevokeOtherSessionsView,
|
||||
ProfileSessionsView,
|
||||
@@ -26,6 +26,8 @@ __all__ = [
|
||||
"PasswordResetValidateView",
|
||||
"PasswordResetConfirmView",
|
||||
"ProfileUpdateView",
|
||||
"ProfileAppearanceView",
|
||||
"ProfileAvatarView",
|
||||
"ProfilePasswordView",
|
||||
"ProfileTotpStartView",
|
||||
"ProfileTotpDisableView",
|
||||
|
||||
@@ -11,10 +11,10 @@ from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.auth.common import _revoke_other_user_sessions, _user_payload
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.models import HumanUser
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
|
||||
|
||||
class ProfileUpdateView(APIView):
|
||||
|
||||
@@ -11,8 +11,8 @@ from django.core.files.base import ContentFile
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ConversationLabel,
|
||||
Conversation,
|
||||
ConversationLabel,
|
||||
ConversationRead,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
@@ -147,10 +147,11 @@ def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, cur
|
||||
channel = refs.channels[item["agent"]]
|
||||
connection = refs.integrations[item["connection"]]
|
||||
if item.get("webGuest"):
|
||||
contact, identity, external_chat_id = _web_guest(context, refs, item)
|
||||
# Identity гостя заводит _web_guest; диалогу она не нужна — его
|
||||
# единственный источник identity это контакт (Conversation.contact).
|
||||
contact, _identity, external_chat_id = _web_guest(context, refs, item)
|
||||
else:
|
||||
contact = refs.contacts.get(item.get("contact"))
|
||||
identity = refs.identities.get(item.get("identity"))
|
||||
external_chat_id = item["externalChatId"]
|
||||
|
||||
existing = Conversation.objects.filter(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework.request import Request
|
||||
|
||||
from chatballs.identity.governance import employee_management_flags
|
||||
from chatballs.identity.avatars import user_avatar_url
|
||||
from chatballs.identity.governance import employee_management_flags
|
||||
from chatballs.identity.models import AuditEvent, OrganizationMembership
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from rest_framework.views import APIView
|
||||
from chatballs.api.pagination import page_payload, paginate
|
||||
from chatballs.events.services import DomainEvent, enqueue_event
|
||||
from chatballs.identity.audit import record_audit_event
|
||||
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
|
||||
from chatballs.identity.employee_selectors import employees_for
|
||||
from chatballs.identity.employee_support import employee_payload, get_owned_profile
|
||||
from chatballs.identity.employee_validation import (
|
||||
@@ -16,7 +17,6 @@ from chatballs.identity.employee_validation import (
|
||||
deny_employee_action,
|
||||
resolve_groups,
|
||||
)
|
||||
from chatballs.identity.employee_password import clean_password_mode, issue_initial_password
|
||||
from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED
|
||||
from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee
|
||||
from chatballs.identity.group_models import EmployeeGroupMember
|
||||
|
||||
@@ -29,17 +29,17 @@ class EmployeeGroup(models.Model):
|
||||
)
|
||||
]
|
||||
|
||||
def clean(self) -> None:
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Group name is required"})
|
||||
def __str__(self) -> str:
|
||||
return f"{self.organization.slug}/{self.name}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.organization.slug}/{self.name}"
|
||||
def clean(self) -> None:
|
||||
self.name = self.name.strip()
|
||||
if not self.name:
|
||||
raise ValidationError({"name": "Group name is required"})
|
||||
|
||||
|
||||
class EmployeeGroupMember(TenantRelationModel):
|
||||
|
||||
@@ -16,7 +16,7 @@ from chatballs.identity.crypto import EncryptedCharField
|
||||
class HumanUserManager(UserManager):
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, email: str, password: str | None, **extra_fields: object) -> "HumanUser":
|
||||
def _create_user(self, email: str, password: str | None, **extra_fields: object) -> HumanUser:
|
||||
if not email:
|
||||
raise ValueError("The email must be set")
|
||||
email = self.normalize_email(email)
|
||||
@@ -25,12 +25,12 @@ class HumanUserManager(UserManager):
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email: str, password: str | None = None, **extra_fields: object) -> "HumanUser":
|
||||
def create_user(self, email: str, password: str | None = None, **extra_fields: object) -> HumanUser:
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email: str, password: str | None = None, **extra_fields: object) -> "HumanUser":
|
||||
def create_superuser(self, email: str, password: str | None = None, **extra_fields: object) -> HumanUser:
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
|
||||
@@ -17,6 +17,8 @@ from chatballs.identity.models import (
|
||||
OrganizationMembership,
|
||||
OrganizationStatus,
|
||||
)
|
||||
|
||||
|
||||
def _pending_org(slug: str = "pending-org") -> Organization:
|
||||
return Organization.objects.create(
|
||||
name="Pending Org",
|
||||
|
||||
@@ -8,7 +8,6 @@ import tempfile
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.test import TestCase, override_settings
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment
|
||||
from chatballs.conversations.models import (
|
||||
@@ -27,6 +26,7 @@ from chatballs.identity.models import HumanUser, Organization, OrganizationMembe
|
||||
from chatballs.identity.setup import SetupInput, complete_setup
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-demo-media-")
|
||||
|
||||
|
||||
@@ -9,15 +9,14 @@ from django.core import mail
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.auth.totp_utils import (
|
||||
TOTP_CHALLENGE_TTL_SECONDS,
|
||||
TOTP_STARTED_KEY,
|
||||
_totp_code,
|
||||
)
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import (
|
||||
AuditEvent,
|
||||
EmployeeGroup,
|
||||
@@ -27,6 +26,7 @@ from chatballs.identity.models import (
|
||||
OrganizationMembership,
|
||||
)
|
||||
from chatballs.identity.policy import ResourceScope, authorize
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
|
||||
_LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
|
||||
@@ -4,30 +4,17 @@
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.integrations import checks
|
||||
|
||||
from chatballs.integrations.models import IntegrationKind, IntegrationProvider, IntegrationStatus
|
||||
|
||||
from chatballs.integrations.serializers import integration_payload
|
||||
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
|
||||
EMAIL_INPUT = {
|
||||
|
||||
"email": "Support@example.com",
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
import json
|
||||
|
||||
import urllib.request
|
||||
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
|
||||
from chatballs.integrations import checks
|
||||
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
|
||||
|
||||
from chatballs.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from chatballs.integrations.serializers import integration_payload
|
||||
|
||||
from chatballs.integrations.services import (
|
||||
|
||||
IntegrationInput,
|
||||
|
||||
create_integration,
|
||||
|
||||
update_integration,
|
||||
)
|
||||
from chatballs.integrations.services import (
|
||||
test_integration as run_integration_test,
|
||||
|
||||
update_integration,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _fake_response(status: int, body: dict):
|
||||
|
||||
response = mock.MagicMock()
|
||||
@@ -497,7 +483,6 @@ class BuildOpenerSocksTests(TestCase):
|
||||
def test_socks_without_pysocks_raises_value_error(self) -> None:
|
||||
|
||||
from chatballs.integrations import proxy
|
||||
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.identity.instance_settings import public_base_url
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from chatballs.testing import TenantAPIClient as APIClient, tenant_context_for
|
||||
|
||||
from chatballs.conversations.transports.base import InboundMessage
|
||||
from chatballs.events.handlers import dispatch
|
||||
@@ -11,9 +10,16 @@ from chatballs.identity.models import HumanUser, Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.notifications.binding import deep_link, handle_notifier_inbound, issue_binding_code
|
||||
from chatballs.notifications.delivery import NOTIFICATION_CREATED
|
||||
from chatballs.notifications.models import MessengerBinding, MessengerBindingCode, NotificationAudience, NotificationType
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
MessengerBindingCode,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.selectors import visible_for
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import tenant_context_for
|
||||
|
||||
|
||||
def _notifier(organization, provider=IntegrationProvider.TELEGRAM, username="chatballs_notify_bot"):
|
||||
|
||||
@@ -6,7 +6,6 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
|
||||
HOST_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from chatballs.support_portals.models import (
|
||||
SupportPortal,
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(
|
||||
(
|
||||
SupportPortal,
|
||||
|
||||
@@ -19,9 +19,6 @@ from chatballs.support_portals.content_services import (
|
||||
)
|
||||
from chatballs.support_portals.models import PortalArticle
|
||||
from chatballs.support_portals.portal_views import PortalBaseView
|
||||
|
||||
# Библиотека статей плотнее списка порталов — своя страница (кадр PT3).
|
||||
ARTICLES_PAGE_SIZE = 25
|
||||
from chatballs.support_portals.selectors import (
|
||||
category_article_counts,
|
||||
portal_articles_queryset,
|
||||
@@ -33,6 +30,9 @@ from chatballs.support_portals.serializers import (
|
||||
revision_payload,
|
||||
)
|
||||
|
||||
# Библиотека статей плотнее списка порталов — своя страница (кадр PT3).
|
||||
ARTICLES_PAGE_SIZE = 25
|
||||
|
||||
|
||||
def _article(portal, article_id: int) -> PortalArticle | None:
|
||||
return (
|
||||
|
||||
@@ -5,9 +5,9 @@ import dns.resolver
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.support_portals.public_address import help_public_ipv4
|
||||
from chatballs.support_portals.addressing import normalize_domain, validate_domain
|
||||
from chatballs.support_portals.models import SupportPortal
|
||||
from chatballs.support_portals.public_address import help_public_ipv4
|
||||
from chatballs.support_portals.statuses import PortalStatus
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.support_portals.content_services import record_feedback
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
|
||||
from chatballs.support_portals.addressing import portal_public_url
|
||||
from chatballs.support_portals.content_markdown import normalize_file_links
|
||||
from chatballs.support_portals.public_address import help_public_ipv4
|
||||
from chatballs.support_portals.models import (
|
||||
PortalArticle,
|
||||
PortalArticleFile,
|
||||
@@ -10,6 +9,7 @@ from chatballs.support_portals.models import (
|
||||
PortalCategory,
|
||||
SupportPortal,
|
||||
)
|
||||
from chatballs.support_portals.public_address import help_public_ipv4
|
||||
|
||||
|
||||
def portal_payload(portal: SupportPortal, *, counts: dict | None = None) -> dict:
|
||||
|
||||
@@ -2,9 +2,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.instance_settings import InstanceSettings, invalidate_cache
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.support_portals.models import PortalArticleFeedback, SupportPortal
|
||||
from chatballs.testing import TenantAPIClient
|
||||
from chatballs.webchat.testing import create_web_widget
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from django.db import DEFAULT_DB_ALIAS, connections, transaction
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.conf import settings
|
||||
from django.db import connections
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IngressRoute:
|
||||
organization_id: int
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.urls import Resolver404, resolve
|
||||
|
||||
@@ -19,6 +19,10 @@ class TenantRelationModel(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
self.validate_tenant_relations()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _related_organization_ids(self) -> set[int]:
|
||||
organization_ids: set[int] = set()
|
||||
for field_name in self.tenant_relation_fields:
|
||||
@@ -48,10 +52,6 @@ class TenantRelationModel(models.Model):
|
||||
super().clean()
|
||||
self.validate_tenant_relations()
|
||||
|
||||
def save(self, *args: object, **kwargs: object) -> None:
|
||||
self.validate_tenant_relations()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizationStorageUsage(models.Model):
|
||||
"""Authoritative storage_bytes usage counter for one organization.
|
||||
@@ -82,6 +82,9 @@ class OrganizationStorageUsage(models.Model):
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"storage:{self.organization_id}/{self.bytes_used + self.reserved_bytes}"
|
||||
|
||||
|
||||
class StorageReservation(models.Model):
|
||||
"""An in-flight storage_bytes reservation keyed by an idempotency token, so a
|
||||
|
||||
@@ -62,13 +62,16 @@ class StorageSettings(models.Model):
|
||||
class Meta:
|
||||
verbose_name = "Настройки хранилища"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"storage-settings:{self.backend.lower()}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = self.SINGLETON_PK
|
||||
super().save(*args, **kwargs)
|
||||
invalidate_cache()
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "StorageSettings":
|
||||
def load(cls) -> StorageSettings:
|
||||
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ POST company/administration/storage/migrate/ — перенести локал
|
||||
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
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
|
||||
|
||||
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.models import (
|
||||
|
||||
AuditEvent,
|
||||
|
||||
AuditResult,
|
||||
|
||||
EmployeeRole,
|
||||
|
||||
HumanUser,
|
||||
|
||||
Organization,
|
||||
|
||||
OrganizationMembership,
|
||||
|
||||
)
|
||||
|
||||
from chatballs.tenancy.database import current_tenant_id, set_local_tenant
|
||||
|
||||
from chatballs.tenancy.models import StorageReservation
|
||||
|
||||
from chatballs.testing import TenantAPIClient
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class RowLevelSecurityTests(TransactionTestCase):
|
||||
|
||||
reset_sequences = True
|
||||
|
||||
@@ -12,8 +12,8 @@ from chatballs.conversations.ingest import ingest_inbound
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
Conversation,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
MessageKind,
|
||||
)
|
||||
@@ -218,7 +218,11 @@ def post_voice(session: WebSession, *, content: bytes, content_type: str, durati
|
||||
|
||||
def post_file(session: WebSession, *, content: bytes, filename: str, content_type: str, caption: str = "") -> None:
|
||||
"""Файл из виджета: байты приходят телом запроса, подпись — текстом."""
|
||||
from chatballs.conversations.transports.base import InboundFile, guess_content_type, safe_filename
|
||||
from chatballs.conversations.transports.base import (
|
||||
InboundFile,
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
name = safe_filename(filename)
|
||||
mime = content_type or guess_content_type(name)
|
||||
|
||||
@@ -8,8 +8,11 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.conversations.attachment_views import (
|
||||
attachment_response,
|
||||
validate_upload,
|
||||
)
|
||||
from chatballs.conversations.models import Message, MessageKind
|
||||
from chatballs.conversations.attachment_views import MAX_FILE_BYTES, attachment_response, validate_upload
|
||||
from chatballs.conversations.voice_views import ALLOWED_AUDIO_TYPES, MAX_VOICE_BYTES
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.features import voice_messages_allowed
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
from chatballs.support_portals.gateway_views import HelpDomainAuthorizationView
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
+10
-1
@@ -2,10 +2,19 @@
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["apps/backend"]
|
||||
# Миграции пишет Django, а не человек: переносить в них строки и переставлять
|
||||
# импорты бессмысленно — файл всё равно перегенерируется как был.
|
||||
extend-exclude = ["**/migrations/*"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "DJ"]
|
||||
ignore = ["DJ001"]
|
||||
# DJ001 — null=True на текстовых полях: в схеме это осознанно.
|
||||
# E501 — длину строки правит форматтер, а не линтер. `ruff format` в этом
|
||||
# репозитории не принят (он переписал бы 374 файла), и пока это отдельное
|
||||
# решение, гейтить длину строки в CI нечестно: правило было бы красным всегда
|
||||
# и его просто перестали бы читать. Всё остальное из E, F, I, UP, B и DJ
|
||||
# проверяется и обязано быть зелёным.
|
||||
ignore = ["DJ001", "E501"]
|
||||
|
||||
# pytest config lives in apps/backend/pytest.ini so it is also picked up inside
|
||||
# the backend container (workdir /app/apps/backend, which does not mount this file).
|
||||
|
||||
@@ -33,6 +33,10 @@ function Invoke-Step {
|
||||
}
|
||||
|
||||
# Сначала быстрые проверки, затем длинные: backend-сьют идёт около 20 минут.
|
||||
# Линтер запускается из корня репозитория: конфигурация лежит в pyproject.toml,
|
||||
# а в контейнер смонтирован только apps/backend — без корня ruff взял бы
|
||||
# правила по умолчанию вместо проектных и молча пропускал бы половину.
|
||||
Invoke-Step "backend · ruff" { docker @compose run --rm --no-deps -v "${PWD}:/repo" -w /repo backend-app ruff check apps/backend }
|
||||
Invoke-Step "internal-ui · typecheck" { docker @compose run --rm --no-deps frontend npm run typecheck }
|
||||
Invoke-Step "web-chat · typecheck" { docker @compose run --rm --no-deps web-chat npm run typecheck }
|
||||
Invoke-Step "internal-ui · vitest" { docker @compose run --rm --no-deps frontend npm run test }
|
||||
|
||||
Reference in new issue
Block a user