🚨 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:
AndreyandClaude Opus 5 committed 2026-09-09 07:39:18 +03:00
1 parent 3e62ce1ed5
commit 482fa73d95
85 files changed
+276 -283

No files matched your search

+60
View File
@@ -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
+14
View File
@@ -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
+3 -1
View File
@@ -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:
+10
View File
@@ -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.
-1
View File
@@ -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 (
+1 -1
View File
@@ -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"
+8 -23
View File
@@ -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).
+7 -1
View File
@@ -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,
+7 -2
View File
@@ -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)
+2 -2
View File
@@ -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 -4
View File
@@ -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)
+1 -2
View File
@@ -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: {
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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")
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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__)
+7 -1
View File
@@ -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 -1
View File
@@ -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):
+3 -3
View File
@@ -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-")
+2 -2
View File
@@ -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",
+9 -24
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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
+7 -4
View File
@@ -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
+6 -2
View File
@@ -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)
+4 -1
View File
@@ -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
View File
@@ -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).
+4
View 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 }