🔥 chore(db): мёртвые поля отменённых доменов удалены из схемы

Колонки пережили решения, которыми были отменены:

- `Organization.tax_regime` и `vat_mode` — налоговый профиль организации,
  отменён вместе с управленческой картиной продаж (ADR-HUB-0010, отменён
  ADR-CHATBALLS-0041). Миграция identity/0032;
- `Channel.allow_sales_attribution` и `allow_checkout_actions` — коммерческие
  флаги канала; ADR-CHATBALLS-0045 §4 объявил их запрещёнными безусловно, но
  колонки остались. Миграция channels/0008.

Вместе с флагами ушёл аппарат инвариантов канала. P1-P2 были их единственным
содержимым, P3-P5 сняты с сущностью Product: после удаления полей
`policy_violations()` всегда возвращала бы пустой кортеж, `PolicyInvariantError`
не могла быть возбуждена никогда, а `require_valid_policy()` стала бы
вызовом-пустышкой. В `channels/policy.py` остались только состав полей и их
отображение в payload для двух живых флагов (`allow_anonymous_sessions`,
`allow_self_reported_contact`).

Удалён `channels/test_policy_migration.py`: он проверял приведение данных
исторической миграции channels/0005 по колонкам, которых больше нет. Отсюда
622 backend-теста вместо 624 — ровно два его метода, покрытие не потеряно.

Из `identity/admin.py` колонки убраны из `list_display`: иначе админка падает
проверкой admin.E108.

Django выполняет `DROP COLUMN ... CASCADE` и молча уносит зависящие вьюхи,
поэтому зависимости проверены до миграции: три вьюхи зависят от таблиц, но ни
одна не читает эти колонки; политик RLS, constraint'ов и индексов на них нет.
После применения все 10 вьюх схемы chatballs на месте, колонок в
information_schema нет.

Проверка: `manage.py check` и `makemigrations --check` чисты, локальная БД
смигрирована, полный check.ps1 зелёный (typecheck ×2, vitest 76, CLI 9, e2e 8,
backend 622).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-08 20:44:01 +03:00
1 parent 2976856760
commit c01bf8aef6
8 files changed
+55 -253

No files matched your search

@@ -0,0 +1,21 @@
# Generated by Django 5.2.15 on 2026-09-08 17:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('channels', '0007_channel_group'),
]
operations = [
migrations.RemoveField(
model_name='channel',
name='allow_checkout_actions',
),
migrations.RemoveField(
model_name='channel',
name='allow_sales_attribution',
),
]
+3 -5
View File
@@ -15,13 +15,11 @@ class Channel(models.Model):
# LLM-провайдер канала (ADR-CHATBALLS-0020).
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
is_active = models.BooleanField(default=True)
# Политика канала: остаток от домена продаж — коммерческие флаги всегда
# выключены (ADR-CHATBALLS-0041/0045), анонимные сессии и самозаявленный контакт
# используются веб-виджетом и порталом.
# Политика канала: анонимные сессии и самозаявленный контакт используются
# веб-виджетом и порталом. Коммерческие флаги удалены вместе с доменом
# продаж (ADR-CHATBALLS-0041) и сущностью Product (ADR-CHATBALLS-0045).
allow_anonymous_sessions = models.BooleanField(default=True)
allow_self_reported_contact = models.BooleanField(default=True)
allow_sales_attribution = models.BooleanField(default=False)
allow_checkout_actions = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+9 -173
View File
@@ -1,206 +1,42 @@
"""Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7).
Источник истины — булевы поля `Channel`. Продуктовой идентичности больше нет
(ADR-CHATBALLS-0045: сущность `Product` удалена), поэтому коммерческие действия и
attribution запрещены безусловно. Проверяются по итоговому состоянию, а не по
переданным полям, — частичное применение запрещено.
"""Политика канала: что разрешено в точке входа.
Инвариантов у политики больше нет. P3-P5 (продуктовая идентичность) сняты
вместе с сущностью `Product` (ADR-CHATBALLS-0045), P1-P2 — вместе с полями
`allow_checkout_actions` и `allow_sales_attribution`: домена продаж нет с
ADR-CHATBALLS-0041, и запрещать в них было уже нечего. Поэтому здесь остались
только состав полей и их отображение в payload; проверка целевого состояния
удалена вместе с последним правилом.
"""
from __future__ import annotations
from dataclasses import dataclass, fields, replace
from django.core.exceptions import ValidationError
from dataclasses import dataclass, fields
from chatballs.channels.models import Channel
POLICY_FIELDS = (
"allow_anonymous_sessions",
"allow_self_reported_contact",
"allow_sales_attribution",
"allow_checkout_actions",
)
# Ключ payload -> имя поля модели (SPEC §6.1).
# Ключ payload -> имя поля модели.
POLICY_API_FIELDS = {
"allowAnonymousSessions": "allow_anonymous_sessions",
"allowSelfReportedContact": "allow_self_reported_contact",
"allowSalesAttribution": "allow_sales_attribution",
"allowCheckoutActions": "allow_checkout_actions",
}
@dataclass(frozen=True, slots=True)
class ChannelPolicy:
allow_anonymous_sessions: bool
allow_self_reported_contact: bool
allow_sales_attribution: bool
allow_checkout_actions: bool
@classmethod
def from_channel(cls, channel: Channel) -> ChannelPolicy:
return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS})
def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy:
return replace(self, **changes)
def as_model_fields(self) -> dict[str, bool]:
return {field.name: getattr(self, field.name) for field in fields(self)}
def as_payload(self) -> dict[str, bool]:
return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()}
@dataclass(frozen=True, slots=True)
class PolicyViolation:
rule: str
field: str
detail: str
def payload(self) -> dict[str, str]:
return {"rule": self.rule, "field": self.field, "detail": self.detail}
def policy_violations(*, policy: ChannelPolicy) -> tuple[PolicyViolation, ...]:
"""Нарушения P1-P2 для итогового состояния канала (SPEC §3.2)."""
violations: list[PolicyViolation] = []
if policy.allow_checkout_actions:
violations.append(
PolicyViolation(
"P1",
"allowCheckoutActions",
"Коммерческие действия недоступны",
)
)
if policy.allow_sales_attribution:
violations.append(
PolicyViolation(
"P2",
"allowSalesAttribution",
"Attribution недоступна",
)
)
return tuple(violations)
def require_valid_policy(*, policy: ChannelPolicy) -> None:
violations = policy_violations(policy=policy)
if violations:
raise PolicyInvariantError(violations)
class PolicyInvariantError(ValidationError):
"""Отклонение целиком: канал не сохраняется ни в каком виде."""
def __init__(self, violations: tuple[PolicyViolation, ...]) -> None:
self.violations = violations
super().__init__("; ".join(violation.detail for violation in violations))
def payload(self) -> dict[str, object]:
return {
"detail": "; ".join(violation.detail for violation in self.violations),
"violations": [violation.payload() for violation in self.violations],
}
@@ -10,7 +10,6 @@ from django.db import transaction
from chatballs.channels import authorization
from chatballs.channels.models import Channel
from chatballs.channels.policy import ChannelPolicy, require_valid_policy
from chatballs.identity.group_models import EmployeeGroup
from chatballs.integrations.models import Integration, IntegrationKind
from chatballs.tenancy.context import TenantContext
@@ -138,10 +137,6 @@ def update_channel(
setattr(locked, name, value)
changed.append(name)
# Инварианты по целевому состоянию — до записи: нарушение отклоняет запрос
# целиком, частичного применения не остаётся даже в памяти (§3.2, §6.5).
require_valid_policy(policy=ChannelPolicy.from_channel(locked))
if changed:
locked.save(update_fields=[*changed, "updated_at"])
return locked
@@ -1,59 +0,0 @@
"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P2.
Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит
только Python-дефолты. Весь риск миграции сосредоточен в функции приведения
данных, поэтому она проверяется напрямую на реальном реестре моделей — это
честнее, чем поднимать историческое состояние, где `identity` откатывается
рассинхронно со схемой БД.
"""
import importlib
from django.apps import apps
from django.test import TestCase
from chatballs.channels.models import Channel
from chatballs.identity.models import Organization
migration = importlib.import_module(
"chatballs.channels.migrations.0005_enforce_policy_invariants"
)
class PolicyInvariantMigrationTests(TestCase):
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Acme", slug="acme")
def _channel(self, code: str, **fields) -> Channel:
return Channel.objects.create(
organization=self.organization, code=code, name=code, **fields
)
def _run(self) -> None:
migration.relax_non_product_channels(apps, None)
def test_clears_commercial_flags(self) -> None:
# Ровно случай канала acme: checkout и attribution включены.
violating = self._channel(
"acme", allow_checkout_actions=True, allow_sales_attribution=True
)
self._run()
violating.refresh_from_db()
self.assertFalse(violating.allow_checkout_actions)
self.assertFalse(violating.allow_sales_attribution)
# Остальные флаги канала не трогаются.
self.assertTrue(violating.allow_anonymous_sessions)
self.assertTrue(violating.allow_self_reported_contact)
def test_is_idempotent(self) -> None:
self._channel("acme", allow_checkout_actions=True)
self._run()
self._run()
self.assertEqual(
Channel.objects.filter(allow_checkout_actions=True).count(),
0,
)
+1 -1
View File
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "currency", "timezone", "tax_regime", "vat_mode"]
list_display = ["slug", "name", "currency", "timezone"]
search_fields = ["slug", "name"]
@@ -0,0 +1,21 @@
# Generated by Django 5.2.15 on 2026-09-08 17:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('identity', '0031_totp_last_counter'),
]
operations = [
migrations.RemoveField(
model_name='organization',
name='tax_regime',
),
migrations.RemoveField(
model_name='organization',
name='vat_mode',
),
]
-10
View File
@@ -90,14 +90,6 @@ class HumanUser(AbstractUser):
def __str__(self) -> str:
return self.email
class TaxRegime(models.TextChoices):
USN_INCOME = "USN_INCOME", "УСН доходы"
class VatMode(models.TextChoices):
WITHOUT_VAT = "WITHOUT_VAT", "Без НДС"
class OrganizationStatus(models.TextChoices):
# SPEC-HUB-0021 §6/§8: PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
ACTIVE = "ACTIVE", "Active"
@@ -123,8 +115,6 @@ class Organization(models.Model):
)
timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
tax_regime = models.CharField(max_length=32, choices=TaxRegime.choices, default=TaxRegime.USN_INCOME)
vat_mode = models.CharField(max_length=32, choices=VatMode.choices, default=VatMode.WITHOUT_VAT)
logo = models.FileField(
upload_to=organization_logo_upload_path,
max_length=512,