mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🔥 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:
1 parent
2976856760
commit
c01bf8aef6
8 files changed
+55
-253
No files matched your search
+21
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
+21
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user