diff --git a/apps/backend/hub_platform/channels/migrations/0005_enforce_policy_invariants.py b/apps/backend/hub_platform/channels/migrations/0005_enforce_policy_invariants.py new file mode 100644 index 0000000..2fe961f --- /dev/null +++ b/apps/backend/hub_platform/channels/migrations/0005_enforce_policy_invariants.py @@ -0,0 +1,62 @@ +# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3. +# +# Приводит существующие каналы в соответствие P1-P5 и меняет дефолты модели, +# которые сами по себе нарушали P1-P2: канал создаётся без продукта, а +# attribution и checkout были включены по умолчанию. +# +# Решение владельца по унаследованным непродуктовым каналам: выключить +# checkout и attribution, а не назначать продукт. Поэтому миграция чинит ровно +# P1 и P2. Нарушения P3-P5 она не трогает и останавливает выкат: их +# исправление меняет смысл канала (обязательная идентичность), и выбирать за +# владельца здесь нельзя. +from django.db import migrations, models + + +def relax_non_product_channels(apps, schema_editor): + Channel = apps.get_model("channels", "Channel") + + blocked = list( + Channel.objects.filter(requires_authenticated_product_identity=True) + .filter( + models.Q(product__isnull=True) + | models.Q(allow_anonymous_sessions=True) + | models.Q(allow_self_reported_contact=True) + ) + .values_list("id", "code") + ) + if blocked: + listed = ", ".join(f"{code} (id={channel_id})" for channel_id, code in blocked) + raise RuntimeError( + "Каналы нарушают P3-P5 и требуют решения владельца до включения " + f"инвариантов: {listed}" + ) + + Channel.objects.filter(product__isnull=True).filter( + models.Q(allow_checkout_actions=True) | models.Q(allow_sales_attribution=True) + ).update(allow_checkout_actions=False, allow_sales_attribution=False) + + +def noop(apps, schema_editor): + # Прежние значения не восстанавливаются: они были нарушением инварианта. + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('channels', '0004_remove_channel_ai_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='allow_checkout_actions', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='channel', + name='allow_sales_attribution', + field=models.BooleanField(default=False), + ), + migrations.RunPython(relax_non_product_channels, noop), + ] diff --git a/apps/backend/hub_platform/channels/models.py b/apps/backend/hub_platform/channels/models.py index c94ade6..167c899 100644 --- a/apps/backend/hub_platform/channels/models.py +++ b/apps/backend/hub_platform/channels/models.py @@ -16,13 +16,15 @@ class Channel(models.Model): # LLM-провайдер канала (ADR-HUB-0020). provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True) is_active = models.BooleanField(default=True) - # Политика канала (SPEC-HUB-0010 §4.2). Значения по умолчанию соответствуют - # поведению публичных sales-каналов; support-каналы переключают флаги при seed. + # Политика канала (SPEC-HUB-0010 §4.2, SPEC-HUB-0027 §3.2). Дефолты обязаны + # удовлетворять инвариантам P1-P5 при product = null: канал создаётся без + # продукта, поэтому коммерческие флаги по умолчанию выключены. Продуктовый + # канал включает их явно — пресетом SALES или политикой в запросе. requires_authenticated_product_identity = models.BooleanField(default=False) allow_anonymous_sessions = models.BooleanField(default=True) allow_self_reported_contact = models.BooleanField(default=True) - allow_sales_attribution = models.BooleanField(default=True) - allow_checkout_actions = 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/hub_platform/channels/services.py b/apps/backend/hub_platform/channels/services.py index c839954..e721200 100644 --- a/apps/backend/hub_platform/channels/services.py +++ b/apps/backend/hub_platform/channels/services.py @@ -170,11 +170,11 @@ def update_channel( *, context: TenantContext, channel: Channel, update: ChannelUpdate ) -> Channel: """Порядок обработки §6.5: блокировка, права по каждому полю, целевое - состояние, конфликты, одна транзакция. + состояние, инварианты, конфликты, одна транзакция. - Инварианты P1-P5 на изменении включаются этапом 3: до устранения нарушений - в существующих данных первый же PATCH имени упёрся бы в ошибку политики - (SPEC §12). На создании они действуют уже сейчас — см. create_channel. + Инварианты проверяются по итоговому состоянию, а не по переданным полям: + выключить продукт и коммерческие флаги можно одним запросом, а вот запрос, + оставляющий канал в запрещённой комбинации, отклоняется целиком. """ locked = Channel.objects.select_for_update().get( id=channel.id, organization_id=context.organization_id @@ -232,6 +232,13 @@ def update_channel( setattr(locked, name, value) changed.append(name) + # Инварианты по целевому состоянию — до записи: нарушение отклоняет запрос + # целиком, частичного применения не остаётся даже в памяти (§3.2, §6.5). + require_valid_policy( + policy=ChannelPolicy.from_channel(locked), + has_product=locked.product_id is not None, + ) + if changed: locked.save(update_fields=[*changed, "updated_at"]) return locked diff --git a/apps/backend/hub_platform/channels/test_policy_migration.py b/apps/backend/hub_platform/channels/test_policy_migration.py new file mode 100644 index 0000000..60d869a --- /dev/null +++ b/apps/backend/hub_platform/channels/test_policy_migration.py @@ -0,0 +1,94 @@ +"""SPEC-HUB-0027 §12 — приведение данных перед включением инвариантов P1-P5. + +Схема между `channels.0004` и `channels.0005` не меняется: `AlterField` правит +только Python-дефолты. Весь риск миграции сосредоточен в функции приведения +данных, поэтому она проверяется напрямую на реальном реестре моделей — это +честнее, чем поднимать историческое состояние, где `identity` откатывается +рассинхронно со схемой БД. +""" + +import importlib + +from django.apps import apps +from django.test import TestCase + +from hub_platform.channels.models import Channel +from hub_platform.identity.models import Organization +from hub_platform.products.models import Product + +migration = importlib.import_module( + "hub_platform.channels.migrations.0005_enforce_policy_invariants" +) + + +class PolicyInvariantMigrationTests(TestCase): + def setUp(self) -> None: + self.organization = Organization.objects.create(name="Edevs", slug="edevs") + self.product = Product.objects.create( + organization=self.organization, code="foxray", name="FoxRay" + ) + + 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_on_non_product_channels(self) -> None: + # Ровно случай канала edevs: непродуктовый, но с checkout и attribution. + violating = self._channel( + "edevs", 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_leaves_product_channels_untouched(self) -> None: + product_channel = self._channel( + "foxray-sales", + product=self.product, + allow_checkout_actions=True, + allow_sales_attribution=True, + ) + + self._run() + + product_channel.refresh_from_db() + self.assertTrue(product_channel.allow_checkout_actions) + self.assertTrue(product_channel.allow_sales_attribution) + + def test_stops_the_rollout_on_p3_p5_violations(self) -> None: + # Обязательная идентичность без продукта — смена смысла канала, + # выбирать за владельца нельзя, поэтому выкат останавливается. + self._channel("broken", requires_authenticated_product_identity=True) + untouched = self._channel( + "edevs", allow_checkout_actions=True, allow_sales_attribution=True + ) + + with self.assertRaises(RuntimeError) as error: + self._run() + + self.assertIn("broken", str(error.exception)) + untouched.refresh_from_db() + self.assertTrue(untouched.allow_checkout_actions) + + def test_is_idempotent(self) -> None: + self._channel("edevs", allow_checkout_actions=True) + + self._run() + self._run() + + self.assertEqual( + Channel.objects.filter( + product__isnull=True, allow_checkout_actions=True + ).count(), + 0, + ) diff --git a/apps/backend/hub_platform/channels/tests.py b/apps/backend/hub_platform/channels/tests.py index 29c0273..f4d5744 100644 --- a/apps/backend/hub_platform/channels/tests.py +++ b/apps/backend/hub_platform/channels/tests.py @@ -577,3 +577,84 @@ class ChannelCountersTests(ChannelApiTestCase): channel = _make_channel(self.organization, code="main", name="Основной") response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=1y") self.assertEqual(response.status_code, 400) + + +class ChannelPolicyInvariantTests(ChannelApiTestCase): + """§3.2 — P1-P5 на изменении (этап 3).""" + + def setUp(self) -> None: + super().setUp() + self.orphan = _make_channel( + self.organization, code="partners", name="Партнёрская линия" + ) + self.product_channel = _make_channel( + self.organization, + code="foxray-sales", + name="FoxRay — продажи", + product=self.product, + allow_sales_attribution=True, + allow_checkout_actions=True, + ) + + def test_rejects_commercial_flag_on_non_product_channel(self) -> None: + response = self.patch_channel( + self.orphan.id, policy={"allowCheckoutActions": True} + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + {item["rule"] for item in response.json()["violations"]}, {"P1"} + ) + self.orphan.refresh_from_db() + self.assertFalse(self.orphan.allow_checkout_actions) + + def test_rename_of_compliant_channel_is_not_blocked_by_policy(self) -> None: + # Ровно тот сценарий, ради которого этап 3 шёл после приведения данных. + response = self.patch_channel(self.orphan.id, name="Партнёры") + self.assertEqual(response.status_code, 200) + + def test_detaching_product_names_the_flags_to_turn_off(self) -> None: + response = self.patch_channel(self.product_channel.id, productId=None) + + self.assertEqual(response.status_code, 400) + fields = {item["field"] for item in response.json()["violations"]} + self.assertEqual(fields, {"allowCheckoutActions", "allowSalesAttribution"}) + self.product_channel.refresh_from_db() + self.assertEqual(self.product_channel.product_id, self.product.id) + + def test_detaching_product_with_flags_off_succeeds_in_one_request(self) -> None: + # Инварианты считаются по итоговому состоянию, а не по переданным полям. + response = self.patch_channel( + self.product_channel.id, + productId=None, + policy={"allowCheckoutActions": False, "allowSalesAttribution": False}, + ) + + self.assertEqual(response.status_code, 200) + self.product_channel.refresh_from_db() + self.assertIsNone(self.product_channel.product_id) + self.assertFalse(self.product_channel.allow_checkout_actions) + + def test_partial_application_is_impossible(self) -> None: + response = self.patch_channel( + self.orphan.id, + name="Новое имя", + policy={"allowSalesAttribution": True}, + ) + + self.assertEqual(response.status_code, 400) + self.orphan.refresh_from_db() + # Имя не сохранилось вместе с отклонённым флагом. + self.assertEqual(self.orphan.name, "Партнёрская линия") + self.assertFalse(self.orphan.allow_sales_attribution) + + def test_authenticated_identity_requires_disabling_anonymous_flags(self) -> None: + response = self.patch_channel( + self.product_channel.id, + policy={"requiresAuthenticatedProductIdentity": True}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + {item["rule"] for item in response.json()["violations"]}, {"P4", "P5"} + )