Files
chatballs/apps/backend/chatballs/calls/models.py
T
AndreyandClaude Opus 5 482fa73d95 🚨 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>
2026-09-09 07:39:18 +03:00

223 lines
8.5 KiB
Python

import uuid
from django.conf import settings
from django.db import models
from chatballs.tenancy.models import TenantRelationModel
class CallStatus(models.TextChoices):
REQUESTED = "REQUESTED", "Запрошен"
RINGING = "RINGING", "Ожидание ответа"
ACCEPTED = "ACCEPTED", "Принят"
CONNECTING = "CONNECTING", "Соединение"
ACTIVE = "ACTIVE", "Активен"
DECLINED = "DECLINED", "Отклонён"
CANCELLED = "CANCELLED", "Отменён"
MISSED = "MISSED", "Пропущен"
ENDED = "ENDED", "Завершён"
FAILED = "FAILED", "Ошибка"
EXPIRED = "EXPIRED", "Истёк"
UNFINISHED_CALL_STATUSES = (
CallStatus.REQUESTED,
CallStatus.RINGING,
CallStatus.ACCEPTED,
CallStatus.CONNECTING,
CallStatus.ACTIVE,
)
TERMINAL_CALL_STATUSES = (
CallStatus.DECLINED,
CallStatus.CANCELLED,
CallStatus.MISSED,
CallStatus.ENDED,
CallStatus.FAILED,
CallStatus.EXPIRED,
)
class CallEndedBy(models.TextChoices):
STAFF = "STAFF", "Сотрудник"
CUSTOMER = "CUSTOMER", "Клиент"
SYSTEM = "SYSTEM", "Система"
TIMEOUT = "TIMEOUT", "Таймаут"
class ParticipantSide(models.TextChoices):
STAFF = "STAFF", "Сотрудник"
CUSTOMER = "CUSTOMER", "Клиент"
class ParticipantConnectionState(models.TextChoices):
PENDING = "PENDING", "Ожидание"
CONNECTING = "CONNECTING", "Соединение"
CONNECTED = "CONNECTED", "Подключён"
RECONNECTING = "RECONNECTING", "Переподключение"
DISCONNECTED = "DISCONNECTED", "Отключён"
FAILED = "FAILED", "Ошибка"
class InviteDeliveryStatus(models.TextChoices):
PENDING = "PENDING", "Ожидает доставки"
SENT = "SENT", "Отправлено"
FAILED = "FAILED", "Ошибка доставки"
class CallConnectionType(models.TextChoices):
DIRECT = "DIRECT", "Прямое P2P"
RELAY = "RELAY", "Через TURN relay"
UNKNOWN = "UNKNOWN", "Неизвестно"
class CallKind(models.TextChoices):
AUDIO = "AUDIO", "Аудиозвонок"
VIDEO = "VIDEO", "Видеозвонок"
class CallSession(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="call_sessions")
conversation = models.ForeignKey("conversations.Conversation", on_delete=models.PROTECT, related_name="call_sessions")
initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="initiated_calls")
status = models.CharField(max_length=16, choices=CallStatus.choices, default=CallStatus.REQUESTED, db_index=True)
kind = models.CharField(max_length=8, choices=CallKind.choices, default=CallKind.AUDIO, db_index=True)
requested_at = models.DateTimeField(auto_now_add=True)
accepted_at = models.DateTimeField(null=True, blank=True)
connected_at = models.DateTimeField(null=True, blank=True)
ended_at = models.DateTimeField(null=True, blank=True)
ended_by = models.CharField(max_length=16, choices=CallEndedBy.choices, blank=True)
failure_code = models.CharField(max_length=64, blank=True)
delivery_connection = models.ForeignKey(
"integrations.Integration",
on_delete=models.PROTECT,
related_name="delivered_calls",
null=True,
blank=True,
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-requested_at"]
constraints = [
models.UniqueConstraint(
fields=["conversation"],
condition=models.Q(status__in=UNFINISHED_CALL_STATUSES),
name="uniq_unfinished_call_conversation",
),
models.UniqueConstraint(
fields=["initiated_by"],
condition=models.Q(status__in=UNFINISHED_CALL_STATUSES),
name="uniq_unfinished_call_initiator",
),
]
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()))
class CallInvite(TenantRelationModel):
tenant_relation_fields = ("call_session", "connection_identity")
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
call_session = models.OneToOneField(CallSession, on_delete=models.CASCADE, related_name="invite")
connection_identity = models.ForeignKey(
"conversations.ConnectionIdentity",
on_delete=models.PROTECT,
related_name="call_invites",
)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
expires_at = models.DateTimeField(db_index=True)
opened_at = models.DateTimeField(null=True, blank=True)
responded_at = models.DateTimeField(null=True, blank=True)
delivery_status = models.CharField(
max_length=16,
choices=InviteDeliveryStatus.choices,
default=InviteDeliveryStatus.PENDING,
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return f"invite:{self.id}/{self.delivery_status}"
class CallParticipant(TenantRelationModel):
tenant_relation_fields = ("call_session", "connection_identity")
call_session = models.ForeignKey(CallSession, on_delete=models.CASCADE, related_name="participants")
side = models.CharField(max_length=16, choices=ParticipantSide.choices)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="call_participations",
null=True,
blank=True,
)
connection_identity = models.ForeignKey(
"conversations.ConnectionIdentity",
on_delete=models.PROTECT,
related_name="call_participations",
null=True,
blank=True,
)
joined_at = models.DateTimeField(null=True, blank=True)
left_at = models.DateTimeField(null=True, blank=True)
last_connection_state = models.CharField(
max_length=16,
choices=ParticipantConnectionState.choices,
default=ParticipantConnectionState.PENDING,
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["call_session", "side"], name="uniq_call_participant_side"),
models.CheckConstraint(
condition=(
models.Q(side=ParticipantSide.STAFF, user__isnull=False, connection_identity__isnull=True)
| models.Q(side=ParticipantSide.CUSTOMER, user__isnull=True, connection_identity__isnull=False)
),
name="call_participant_identity_matches_side",
),
]
def __str__(self) -> str:
return f"participant:{self.call_session_id}/{self.side}"
class CallMetric(TenantRelationModel):
"""Технические метрики соединения без медиаконтента (SPEC-CHATBALLS-0013 §13).
Хранится только КАТЕГОРИЯ ICE-кандидата (host/srflx/prflx/relay) и RTT, но
никогда сам ICE candidate, его адрес, SDP или медиапоток. Позволяет считать
долю direct/relay звонков (E15) и подтверждать TURN fallback, не раскрывая
сетевые адреса участников.
"""
tenant_relation_fields = ("call_session",)
call_session = models.ForeignKey(CallSession, on_delete=models.CASCADE, related_name="metrics")
side = models.CharField(max_length=16, choices=ParticipantSide.choices)
connection_type = models.CharField(
max_length=16,
choices=CallConnectionType.choices,
default=CallConnectionType.UNKNOWN,
)
local_candidate_type = models.CharField(max_length=8, blank=True)
remote_candidate_type = models.CharField(max_length=8, blank=True)
round_trip_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["call_session", "side"], name="uniq_call_metric_side"),
]
def __str__(self) -> str:
return f"metric:{self.call_session_id}/{self.side}/{self.connection_type}"