From 482fa73d9532fc9144c9c93c0d0a68f5ff35517a Mon Sep 17 00:00:00 2001 From: Andrey Date: Wed, 9 Sep 2026 07:39:18 +0300 Subject: [PATCH] =?UTF-8?q?:rotating=5Flight:=20fix(lint):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20=D0=BA=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BA=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B9=20=D0=B6=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20ruff=20=D0=B8=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B5=D1=91=20=D0=B2=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Линтер никогда не проверял то, что задумано. Конфигурация лежит в корневом 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 --- .github/workflows/checks.yml | 60 +++++++++++++++++++ .gitlab/ci/validate-test.yml | 14 +++++ .pre-commit-config.yaml | 4 +- README.md | 10 ++++ apps/backend/chatballs/ai/agent_card.py | 1 - apps/backend/chatballs/ai/indexing.py | 2 +- apps/backend/chatballs/ai/knowledge_policy.py | 2 +- apps/backend/chatballs/ai/models.py | 31 +++------- apps/backend/chatballs/ai/provider/custom.py | 3 - apps/backend/chatballs/ai/provider/demo.py | 8 ++- .../chatballs/ai/provider/openai_http.py | 11 ---- .../chatballs/ai/provider/resilience.py | 2 +- apps/backend/chatballs/ai/test_agent_cards.py | 2 +- .../chatballs/ai/test_demo_provider.py | 9 ++- apps/backend/chatballs/ai/tests.py | 9 ++- apps/backend/chatballs/ai/views.py | 4 +- apps/backend/chatballs/api/exceptions.py | 5 +- apps/backend/chatballs/calls/consumers.py | 3 +- apps/backend/chatballs/calls/lifecycle.py | 1 - apps/backend/chatballs/calls/metrics.py | 2 +- apps/backend/chatballs/calls/models.py | 6 +- apps/backend/chatballs/calls/services.py | 2 +- apps/backend/chatballs/calls/signaling.py | 3 +- .../backend/chatballs/calls/tests/test_api.py | 3 +- .../chatballs/calls/tests/test_flow.py | 11 ++-- .../chatballs/calls/tests/test_signaling.py | 2 +- .../chatballs/calls/tests/test_tokens.py | 2 +- apps/backend/chatballs/calls/tokens.py | 2 +- .../conversations/chat_extras_views.py | 2 +- .../chatballs/conversations/contacts_merge.py | 2 +- .../chatballs/conversations/html_sanitizer.py | 1 - .../backend/chatballs/conversations/ingest.py | 5 +- .../chatballs/conversations/maintenance.py | 7 --- .../conversations/reporting_views.py | 5 +- .../chatballs/conversations/serializers.py | 2 +- .../chatballs/conversations/services.py | 20 +------ apps/backend/chatballs/conversations/stats.py | 2 +- .../conversations/test_authorization.py | 2 +- .../conversations/test_chat_extras.py | 4 +- .../conversations/test_contacts_merge.py | 2 +- .../conversations/test_email_transport.py | 20 +------ .../chatballs/conversations/test_lifecycle.py | 27 --------- .../conversations/test_voice_messages.py | 6 +- apps/backend/chatballs/conversations/tests.py | 7 +-- .../conversations/transports/base.py | 1 - .../conversations/transports/email.py | 8 ++- apps/backend/chatballs/conversations/urls.py | 8 ++- apps/backend/chatballs/conversations/views.py | 12 ++-- apps/backend/chatballs/events/handlers.py | 2 +- .../identity/administration_payloads.py | 1 - .../identity/administration_services.py | 1 - .../identity/administration_views.py | 2 +- .../chatballs/identity/auth/__init__.py | 4 +- .../chatballs/identity/auth/profile.py | 4 +- .../demo_seed/loaders/conversations.py | 7 ++- .../chatballs/identity/employee_support.py | 2 +- .../chatballs/identity/employee_views.py | 2 +- .../chatballs/identity/group_models.py | 12 ++-- apps/backend/chatballs/identity/models.py | 6 +- .../identity/test_invitation_accept.py | 2 + .../chatballs/identity/test_seed_demo.py | 2 +- apps/backend/chatballs/identity/tests.py | 4 +- .../chatballs/integrations/test_email.py | 13 ---- apps/backend/chatballs/integrations/tests.py | 33 +++------- .../chatballs/notifications/delivery.py | 4 +- apps/backend/chatballs/notifications/tests.py | 12 +++- .../chatballs/support_portals/addressing.py | 1 - .../chatballs/support_portals/admin.py | 1 - .../support_portals/content_views.py | 6 +- .../support_portals/domain_services.py | 2 +- .../chatballs/support_portals/public_views.py | 2 +- .../chatballs/support_portals/serializers.py | 2 +- .../support_portals/tests/test_public_api.py | 2 +- apps/backend/chatballs/tenancy/database.py | 4 +- apps/backend/chatballs/tenancy/ingress.py | 1 - apps/backend/chatballs/tenancy/middleware.py | 2 +- apps/backend/chatballs/tenancy/models.py | 11 ++-- .../chatballs/tenancy/storage_settings.py | 5 +- .../chatballs/tenancy/storage_views.py | 1 - apps/backend/chatballs/tenancy/test_rls.py | 20 ------- apps/backend/chatballs/webchat/services.py | 8 ++- apps/backend/chatballs/webchat/views.py | 5 +- .../chatballs_backend/urls_platform.py | 1 + pyproject.toml | 11 +++- scripts/check.ps1 | 4 ++ 85 files changed, 276 insertions(+), 283 deletions(-) create mode 100644 .github/workflows/checks.yml diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..15bbb6b --- /dev/null +++ b/.github/workflows/checks.yml @@ -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 diff --git a/.gitlab/ci/validate-test.yml b/.gitlab/ci/validate-test.yml index 6c1bec6..3041dd2 100644 --- a/.gitlab/ci/validate-test.yml +++ b/.gitlab/ci/validate-test.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63cfddd..1345cb6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: diff --git a/README.md b/README.md index 76aefdb..062f7c7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 2898741..3497699 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -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 ( diff --git a/apps/backend/chatballs/ai/indexing.py b/apps/backend/chatballs/ai/indexing.py index 0292e3d..eccd75c 100644 --- a/apps/backend/chatballs/ai/indexing.py +++ b/apps/backend/chatballs/ai/indexing.py @@ -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) diff --git a/apps/backend/chatballs/ai/knowledge_policy.py b/apps/backend/chatballs/ai/knowledge_policy.py index 70b250d..4f77e9a 100644 --- a/apps/backend/chatballs/ai/knowledge_policy.py +++ b/apps/backend/chatballs/ai/knowledge_policy.py @@ -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" diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index 5c7fae9..7b0b2da 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -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 diff --git a/apps/backend/chatballs/ai/provider/custom.py b/apps/backend/chatballs/ai/provider/custom.py index e737a8c..96dd90a 100644 --- a/apps/backend/chatballs/ai/provider/custom.py +++ b/apps/backend/chatballs/ai/provider/custom.py @@ -1,9 +1,6 @@ from chatballs.ai.provider.openrouter import OpenRouterProvider - - - class CustomProvider(OpenRouterProvider): """Generic OpenAI-compatible BYOK adapter (ADR-CHATBALLS-0034). diff --git a/apps/backend/chatballs/ai/provider/demo.py b/apps/backend/chatballs/ai/provider/demo.py index 4fb892e..648207c 100644 --- a/apps/backend/chatballs/ai/provider/demo.py +++ b/apps/backend/chatballs/ai/provider/demo.py @@ -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 = "Отвечай только на основе этих знаний:" diff --git a/apps/backend/chatballs/ai/provider/openai_http.py b/apps/backend/chatballs/ai/provider/openai_http.py index 4a0b2a1..358de71 100644 --- a/apps/backend/chatballs/ai/provider/openai_http.py +++ b/apps/backend/chatballs/ai/provider/openai_http.py @@ -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. diff --git a/apps/backend/chatballs/ai/provider/resilience.py b/apps/backend/chatballs/ai/provider/resilience.py index 7ae4404..704da81 100644 --- a/apps/backend/chatballs/ai/provider/resilience.py +++ b/apps/backend/chatballs/ai/provider/resilience.py @@ -1,5 +1,5 @@ import time -from typing import Callable +from collections.abc import Callable from chatballs.ai.provider.base import ProviderError diff --git a/apps/backend/chatballs/ai/test_agent_cards.py b/apps/backend/chatballs/ai/test_agent_cards.py index 58da8ff..3778eb2 100644 --- a/apps/backend/chatballs/ai/test_agent_cards.py +++ b/apps/backend/chatballs/ai/test_agent_cards.py @@ -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): diff --git a/apps/backend/chatballs/ai/test_demo_provider.py b/apps/backend/chatballs/ai/test_demo_provider.py index 67b5291..5619bc6 100644 --- a/apps/backend/chatballs/ai/test_demo_provider.py +++ b/apps/backend/chatballs/ai/test_demo_provider.py @@ -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, diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index 7d9f759..fbf41e0 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -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) diff --git a/apps/backend/chatballs/ai/views.py b/apps/backend/chatballs/ai/views.py index 88f7f53..dcd1723 100644 --- a/apps/backend/chatballs/ai/views.py +++ b/apps/backend/chatballs/ai/views.py @@ -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 diff --git a/apps/backend/chatballs/api/exceptions.py b/apps/backend/chatballs/api/exceptions.py index 92e6cfb..e61238d 100644 --- a/apps/backend/chatballs/api/exceptions.py +++ b/apps/backend/chatballs/api/exceptions.py @@ -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) diff --git a/apps/backend/chatballs/calls/consumers.py b/apps/backend/chatballs/calls/consumers.py index f1c0565..c64057e 100644 --- a/apps/backend/chatballs/calls/consumers.py +++ b/apps/backend/chatballs/calls/consumers.py @@ -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__) diff --git a/apps/backend/chatballs/calls/lifecycle.py b/apps/backend/chatballs/calls/lifecycle.py index c7b0633..8c06170 100644 --- a/apps/backend/chatballs/calls/lifecycle.py +++ b/apps/backend/chatballs/calls/lifecycle.py @@ -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: { diff --git a/apps/backend/chatballs/calls/metrics.py b/apps/backend/chatballs/calls/metrics.py index 5dc5266..9895ff4 100644 --- a/apps/backend/chatballs/calls/metrics.py +++ b/apps/backend/chatballs/calls/metrics.py @@ -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, diff --git a/apps/backend/chatballs/calls/models.py b/apps/backend/chatballs/calls/models.py index 3e7a24d..a2ca5e9 100644 --- a/apps/backend/chatballs/calls/models.py +++ b/apps/backend/chatballs/calls/models.py @@ -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") diff --git a/apps/backend/chatballs/calls/services.py b/apps/backend/chatballs/calls/services.py index 364725f..562fe86 100644 --- a/apps/backend/chatballs/calls/services.py +++ b/apps/backend/chatballs/calls/services.py @@ -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 diff --git a/apps/backend/chatballs/calls/signaling.py b/apps/backend/chatballs/calls/signaling.py index 8cba96d..51cf286 100644 --- a/apps/backend/chatballs/calls/signaling.py +++ b/apps/backend/chatballs/calls/signaling.py @@ -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 diff --git a/apps/backend/chatballs/calls/tests/test_api.py b/apps/backend/chatballs/calls/tests/test_api.py index 5e081f9..a892451 100644 --- a/apps/backend/chatballs/calls/tests/test_api.py +++ b/apps/backend/chatballs/calls/tests/test_api.py @@ -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): diff --git a/apps/backend/chatballs/calls/tests/test_flow.py b/apps/backend/chatballs/calls/tests/test_flow.py index 095ccca..74e410a 100644 --- a/apps/backend/chatballs/calls/tests/test_flow.py +++ b/apps/backend/chatballs/calls/tests/test_flow.py @@ -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), diff --git a/apps/backend/chatballs/calls/tests/test_signaling.py b/apps/backend/chatballs/calls/tests/test_signaling.py index ba772b8..7c27f0e 100644 --- a/apps/backend/chatballs/calls/tests/test_signaling.py +++ b/apps/backend/chatballs/calls/tests/test_signaling.py @@ -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) diff --git a/apps/backend/chatballs/calls/tests/test_tokens.py b/apps/backend/chatballs/calls/tests/test_tokens.py index dd57626..5cadd30 100644 --- a/apps/backend/chatballs/calls/tests/test_tokens.py +++ b/apps/backend/chatballs/calls/tests/test_tokens.py @@ -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 diff --git a/apps/backend/chatballs/calls/tokens.py b/apps/backend/chatballs/calls/tokens.py index 7ff9451..0182e5d 100644 --- a/apps/backend/chatballs/calls/tokens.py +++ b/apps/backend/chatballs/calls/tokens.py @@ -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() diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 8d8ab42..6681410 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -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, ) diff --git a/apps/backend/chatballs/conversations/contacts_merge.py b/apps/backend/chatballs/conversations/contacts_merge.py index 1d8c478..aa2b57b 100644 --- a/apps/backend/chatballs/conversations/contacts_merge.py +++ b/apps/backend/chatballs/conversations/contacts_merge.py @@ -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 # Поля карточки, которые дозаполняются из исходного контакта, если у целевого diff --git a/apps/backend/chatballs/conversations/html_sanitizer.py b/apps/backend/chatballs/conversations/html_sanitizer.py index b00d6c2..7f99d6c 100644 --- a/apps/backend/chatballs/conversations/html_sanitizer.py +++ b/apps/backend/chatballs/conversations/html_sanitizer.py @@ -4,7 +4,6 @@ import html from html.parser import HTMLParser from urllib.parse import urlsplit - _ALLOWED_TAGS = { "a", "b", diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index 6dae786..d73e846 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -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 diff --git a/apps/backend/chatballs/conversations/maintenance.py b/apps/backend/chatballs/conversations/maintenance.py index 077f181..feb1990 100644 --- a/apps/backend/chatballs/conversations/maintenance.py +++ b/apps/backend/chatballs/conversations/maintenance.py @@ -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__) diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index ed1c6b7..eb09ed4 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -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): diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 80d738e..733f7db 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -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 diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py index 4aa051a..695f388 100644 --- a/apps/backend/chatballs/conversations/services.py +++ b/apps/backend/chatballs/conversations/services.py @@ -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 = "Поделитесь, пожалуйста, номером телефона." diff --git a/apps/backend/chatballs/conversations/stats.py b/apps/backend/chatballs/conversations/stats.py index 1554655..105a39f 100644 --- a/apps/backend/chatballs/conversations/stats.py +++ b/apps/backend/chatballs/conversations/stats.py @@ -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, diff --git a/apps/backend/chatballs/conversations/test_authorization.py b/apps/backend/chatballs/conversations/test_authorization.py index 65cb8df..608b18f 100644 --- a/apps/backend/chatballs/conversations/test_authorization.py +++ b/apps/backend/chatballs/conversations/test_authorization.py @@ -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): diff --git a/apps/backend/chatballs/conversations/test_chat_extras.py b/apps/backend/chatballs/conversations/test_chat_extras.py index a5adeee..b4cc341 100644 --- a/apps/backend/chatballs/conversations/test_chat_extras.py +++ b/apps/backend/chatballs/conversations/test_chat_extras.py @@ -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): diff --git a/apps/backend/chatballs/conversations/test_contacts_merge.py b/apps/backend/chatballs/conversations/test_contacts_merge.py index bd2f817..21d17ae 100644 --- a/apps/backend/chatballs/conversations/test_contacts_merge.py +++ b/apps/backend/chatballs/conversations/test_contacts_merge.py @@ -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, diff --git a/apps/backend/chatballs/conversations/test_email_transport.py b/apps/backend/chatballs/conversations/test_email_transport.py index 6613e6e..7137c31 100644 --- a/apps/backend/chatballs/conversations/test_email_transport.py +++ b/apps/backend/chatballs/conversations/test_email_transport.py @@ -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 diff --git a/apps/backend/chatballs/conversations/test_lifecycle.py b/apps/backend/chatballs/conversations/test_lifecycle.py index f3da8eb..c61121d 100644 --- a/apps/backend/chatballs/conversations/test_lifecycle.py +++ b/apps/backend/chatballs/conversations/test_lifecycle.py @@ -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( diff --git a/apps/backend/chatballs/conversations/test_voice_messages.py b/apps/backend/chatballs/conversations/test_voice_messages.py index 37173af..f749f93 100644 --- a/apps/backend/chatballs/conversations/test_voice_messages.py +++ b/apps/backend/chatballs/conversations/test_voice_messages.py @@ -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: diff --git a/apps/backend/chatballs/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py index f92a18a..c751dc0 100644 --- a/apps/backend/chatballs/conversations/tests.py +++ b/apps/backend/chatballs/conversations/tests.py @@ -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): diff --git a/apps/backend/chatballs/conversations/transports/base.py b/apps/backend/chatballs/conversations/transports/base.py index 5e1cc9a..5d3d692 100644 --- a/apps/backend/chatballs/conversations/transports/base.py +++ b/apps/backend/chatballs/conversations/transports/base.py @@ -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 diff --git a/apps/backend/chatballs/conversations/transports/email.py b/apps/backend/chatballs/conversations/transports/email.py index 4e0a499..6f99b24 100644 --- a/apps/backend/chatballs/conversations/transports/email.py +++ b/apps/backend/chatballs/conversations/transports/email.py @@ -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__) diff --git a/apps/backend/chatballs/conversations/urls.py b/apps/backend/chatballs/conversations/urls.py index bccc4ac..66fcffa 100644 --- a/apps/backend/chatballs/conversations/urls.py +++ b/apps/backend/chatballs/conversations/urls.py @@ -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"), diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index 90fe8e7..522a602 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -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, diff --git a/apps/backend/chatballs/events/handlers.py b/apps/backend/chatballs/events/handlers.py index c5780d5..0ff8c0b 100644 --- a/apps/backend/chatballs/events/handlers.py +++ b/apps/backend/chatballs/events/handlers.py @@ -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 diff --git a/apps/backend/chatballs/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py index a082920..54548e7 100644 --- a/apps/backend/chatballs/identity/administration_payloads.py +++ b/apps/backend/chatballs/identity/administration_payloads.py @@ -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", diff --git a/apps/backend/chatballs/identity/administration_services.py b/apps/backend/chatballs/identity/administration_services.py index a6c586f..b13e3c7 100644 --- a/apps/backend/chatballs/identity/administration_services.py +++ b/apps/backend/chatballs/identity/administration_services.py @@ -18,7 +18,6 @@ from chatballs.tenancy.storage_quota import ( reserve_storage, ) - MAX_LOGO_BYTES = 2 * 1024 * 1024 diff --git a/apps/backend/chatballs/identity/administration_views.py b/apps/backend/chatballs/identity/administration_views.py index d169320..d9dec80 100644 --- a/apps/backend/chatballs/identity/administration_views.py +++ b/apps/backend/chatballs/identity/administration_views.py @@ -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 diff --git a/apps/backend/chatballs/identity/auth/__init__.py b/apps/backend/chatballs/identity/auth/__init__.py index 1b5d296..f8d344d 100644 --- a/apps/backend/chatballs/identity/auth/__init__.py +++ b/apps/backend/chatballs/identity/auth/__init__.py @@ -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", diff --git a/apps/backend/chatballs/identity/auth/profile.py b/apps/backend/chatballs/identity/auth/profile.py index 3510b07..e982963 100644 --- a/apps/backend/chatballs/identity/auth/profile.py +++ b/apps/backend/chatballs/identity/auth/profile.py @@ -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): diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py index a53662c..7f853a8 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py @@ -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( diff --git a/apps/backend/chatballs/identity/employee_support.py b/apps/backend/chatballs/identity/employee_support.py index 3c18f1a..a78c942 100644 --- a/apps/backend/chatballs/identity/employee_support.py +++ b/apps/backend/chatballs/identity/employee_support.py @@ -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 diff --git a/apps/backend/chatballs/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py index b1f9b65..cd7f40a 100644 --- a/apps/backend/chatballs/identity/employee_views.py +++ b/apps/backend/chatballs/identity/employee_views.py @@ -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 diff --git a/apps/backend/chatballs/identity/group_models.py b/apps/backend/chatballs/identity/group_models.py index 5e3913d..c04814d 100644 --- a/apps/backend/chatballs/identity/group_models.py +++ b/apps/backend/chatballs/identity/group_models.py @@ -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): diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index e82cd00..9fc9e0a 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -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: diff --git a/apps/backend/chatballs/identity/test_invitation_accept.py b/apps/backend/chatballs/identity/test_invitation_accept.py index 4b09371..a442a73 100644 --- a/apps/backend/chatballs/identity/test_invitation_accept.py +++ b/apps/backend/chatballs/identity/test_invitation_accept.py @@ -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", diff --git a/apps/backend/chatballs/identity/test_seed_demo.py b/apps/backend/chatballs/identity/test_seed_demo.py index 3c62423..963c331 100644 --- a/apps/backend/chatballs/identity/test_seed_demo.py +++ b/apps/backend/chatballs/identity/test_seed_demo.py @@ -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-") diff --git a/apps/backend/chatballs/identity/tests.py b/apps/backend/chatballs/identity/tests.py index cbabfe5..1aa9b24 100644 --- a/apps/backend/chatballs/identity/tests.py +++ b/apps/backend/chatballs/identity/tests.py @@ -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"}} diff --git a/apps/backend/chatballs/integrations/test_email.py b/apps/backend/chatballs/integrations/test_email.py index 9e460e1..4a8cf12 100644 --- a/apps/backend/chatballs/integrations/test_email.py +++ b/apps/backend/chatballs/integrations/test_email.py @@ -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", diff --git a/apps/backend/chatballs/integrations/tests.py b/apps/backend/chatballs/integrations/tests.py index 3481aa4..6f494c3 100644 --- a/apps/backend/chatballs/integrations/tests.py +++ b/apps/backend/chatballs/integrations/tests.py @@ -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 diff --git a/apps/backend/chatballs/notifications/delivery.py b/apps/backend/chatballs/notifications/delivery.py index 4da29f3..8600511 100644 --- a/apps/backend/chatballs/notifications/delivery.py +++ b/apps/backend/chatballs/notifications/delivery.py @@ -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, diff --git a/apps/backend/chatballs/notifications/tests.py b/apps/backend/chatballs/notifications/tests.py index 74ca332..df65f6d 100644 --- a/apps/backend/chatballs/notifications/tests.py +++ b/apps/backend/chatballs/notifications/tests.py @@ -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"): diff --git a/apps/backend/chatballs/support_portals/addressing.py b/apps/backend/chatballs/support_portals/addressing.py index cee37aa..7bc9e74 100644 --- a/apps/backend/chatballs/support_portals/addressing.py +++ b/apps/backend/chatballs/support_portals/addressing.py @@ -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])?$") diff --git a/apps/backend/chatballs/support_portals/admin.py b/apps/backend/chatballs/support_portals/admin.py index 26730d0..4423bd7 100644 --- a/apps/backend/chatballs/support_portals/admin.py +++ b/apps/backend/chatballs/support_portals/admin.py @@ -8,7 +8,6 @@ from chatballs.support_portals.models import ( SupportPortal, ) - admin.site.register( ( SupportPortal, diff --git a/apps/backend/chatballs/support_portals/content_views.py b/apps/backend/chatballs/support_portals/content_views.py index 1a1f4fe..324f163 100644 --- a/apps/backend/chatballs/support_portals/content_views.py +++ b/apps/backend/chatballs/support_portals/content_views.py @@ -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 ( diff --git a/apps/backend/chatballs/support_portals/domain_services.py b/apps/backend/chatballs/support_portals/domain_services.py index cbd9291..c71c562 100644 --- a/apps/backend/chatballs/support_portals/domain_services.py +++ b/apps/backend/chatballs/support_portals/domain_services.py @@ -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 diff --git a/apps/backend/chatballs/support_portals/public_views.py b/apps/backend/chatballs/support_portals/public_views.py index 551be0a..0af2129 100644 --- a/apps/backend/chatballs/support_portals/public_views.py +++ b/apps/backend/chatballs/support_portals/public_views.py @@ -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 diff --git a/apps/backend/chatballs/support_portals/serializers.py b/apps/backend/chatballs/support_portals/serializers.py index d9e2b01..2ca631d 100644 --- a/apps/backend/chatballs/support_portals/serializers.py +++ b/apps/backend/chatballs/support_portals/serializers.py @@ -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: diff --git a/apps/backend/chatballs/support_portals/tests/test_public_api.py b/apps/backend/chatballs/support_portals/tests/test_public_api.py index bb83ef7..2eb9144 100644 --- a/apps/backend/chatballs/support_portals/tests/test_public_api.py +++ b/apps/backend/chatballs/support_portals/tests/test_public_api.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/database.py b/apps/backend/chatballs/tenancy/database.py index e326a4a..a42a537 100644 --- a/apps/backend/chatballs/tenancy/database.py +++ b/apps/backend/chatballs/tenancy/database.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/ingress.py b/apps/backend/chatballs/tenancy/ingress.py index 21563b0..88aa52c 100644 --- a/apps/backend/chatballs/tenancy/ingress.py +++ b/apps/backend/chatballs/tenancy/ingress.py @@ -7,7 +7,6 @@ from django.conf import settings from django.db import connections - @dataclass(frozen=True, slots=True) class IngressRoute: organization_id: int diff --git a/apps/backend/chatballs/tenancy/middleware.py b/apps/backend/chatballs/tenancy/middleware.py index 7b04248..535714d 100644 --- a/apps/backend/chatballs/tenancy/middleware.py +++ b/apps/backend/chatballs/tenancy/middleware.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/models.py b/apps/backend/chatballs/tenancy/models.py index 6e0d67e..058bb61 100644 --- a/apps/backend/chatballs/tenancy/models.py +++ b/apps/backend/chatballs/tenancy/models.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/storage_settings.py b/apps/backend/chatballs/tenancy/storage_settings.py index 1434bc1..3ce4826 100644 --- a/apps/backend/chatballs/tenancy/storage_settings.py +++ b/apps/backend/chatballs/tenancy/storage_settings.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/storage_views.py b/apps/backend/chatballs/tenancy/storage_views.py index 3b002ce..5e3fa0f 100644 --- a/apps/backend/chatballs/tenancy/storage_views.py +++ b/apps/backend/chatballs/tenancy/storage_views.py @@ -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 diff --git a/apps/backend/chatballs/tenancy/test_rls.py b/apps/backend/chatballs/tenancy/test_rls.py index ad3f446..bd74134 100644 --- a/apps/backend/chatballs/tenancy/test_rls.py +++ b/apps/backend/chatballs/tenancy/test_rls.py @@ -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 diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index da15ecf..3f4b0fa 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -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) diff --git a/apps/backend/chatballs/webchat/views.py b/apps/backend/chatballs/webchat/views.py index c8de47c..1bc3329 100644 --- a/apps/backend/chatballs/webchat/views.py +++ b/apps/backend/chatballs/webchat/views.py @@ -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 diff --git a/apps/backend/chatballs_backend/urls_platform.py b/apps/backend/chatballs_backend/urls_platform.py index e2e481a..f841152 100644 --- a/apps/backend/chatballs_backend/urls_platform.py +++ b/apps/backend/chatballs_backend/urls_platform.py @@ -1,4 +1,5 @@ from django.urls import include, path + from chatballs.support_portals.gateway_views import HelpDomainAuthorizationView urlpatterns = [ diff --git a/pyproject.toml b/pyproject.toml index 57a4579..0144311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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). diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 22ac329..04a46d1 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -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 }