diff --git a/apps/backend/chatballs/channels/migrations/0008_remove_channel_allow_checkout_actions_and_more.py b/apps/backend/chatballs/channels/migrations/0008_remove_channel_allow_checkout_actions_and_more.py new file mode 100644 index 0000000..153b10d --- /dev/null +++ b/apps/backend/chatballs/channels/migrations/0008_remove_channel_allow_checkout_actions_and_more.py @@ -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', + ), + ] diff --git a/apps/backend/chatballs/channels/models.py b/apps/backend/chatballs/channels/models.py index 6709298..831ff1b 100644 --- a/apps/backend/chatballs/channels/models.py +++ b/apps/backend/chatballs/channels/models.py @@ -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) diff --git a/apps/backend/chatballs/channels/policy.py b/apps/backend/chatballs/channels/policy.py index ea7ebb5..c332155 100644 --- a/apps/backend/chatballs/channels/policy.py +++ b/apps/backend/chatballs/channels/policy.py @@ -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], - - } - diff --git a/apps/backend/chatballs/channels/services.py b/apps/backend/chatballs/channels/services.py index 91fd75d..66b6fbb 100644 --- a/apps/backend/chatballs/channels/services.py +++ b/apps/backend/chatballs/channels/services.py @@ -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 diff --git a/apps/backend/chatballs/channels/test_policy_migration.py b/apps/backend/chatballs/channels/test_policy_migration.py deleted file mode 100644 index 1567b9a..0000000 --- a/apps/backend/chatballs/channels/test_policy_migration.py +++ /dev/null @@ -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, - ) diff --git a/apps/backend/chatballs/identity/admin.py b/apps/backend/chatballs/identity/admin.py index ff7aed5..ccdd1b7 100644 --- a/apps/backend/chatballs/identity/admin.py +++ b/apps/backend/chatballs/identity/admin.py @@ -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"] diff --git a/apps/backend/chatballs/identity/migrations/0032_remove_organization_tax_regime_and_more.py b/apps/backend/chatballs/identity/migrations/0032_remove_organization_tax_regime_and_more.py new file mode 100644 index 0000000..baeeff3 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0032_remove_organization_tax_regime_and_more.py @@ -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', + ), + ] diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index d9edb84..e82cd00 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -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,