Files
chatballs/apps/backend/hub_platform/subscriptions/testing.py
T
Andrey a5b6ad2087 🔒 feat(subscriptions): enforce C07 entitlements and quotas across modules
Coverage-matrix operation x entitlement/quota x transport in INVENTORY-CUSTOCRM-0009.

Enforcement infrastructure:
- HasEntitlement DRF permission (org-level feature gate, orthogonal to
  HasCapability); centralized api_exception_handler mapping
  EntitlementRequired->403, QuotaExceeded HARD->409 / RATE+CONCURRENT->429
  (+Retry-After), PolicyUnavailable->503, UsageConflict->409.
- quota_service.check() advisory pre-check.
- UsageReservation lease model + reserve_usage/release_usage/
  expire_stale_reservations for CONCURRENT quotas (SPEC-HUB-0022 section 7);
  worker sweep reaps lapsed leases. QuotaExceeded now carries mode.

Cumulative quota gates (record_usage, mirror ai_agent_slots):
- products (create_product), client_connections (create_integration),
  new_dialogs_per_period (new dialog only, is_new branch in ingest + support),
  managed_ai_credits (successful LlmInvocation, best-effort accounting).

Storage gate: assert_storage_quota pre-write check blocks overage on writes,
reads never gated (PLAN section 12).

Concurrent p2p_calls: reserve on CallSession create, release on terminal
transition in calls/lifecycle.py.

Entitlement gates on views: sales_department, support_department,
product_sales_api (inline), p2p_calls, knowledge_base, managed_ai
(inline handoff on deny).

Deferred (no target module): crm_api, voice_ai, sso_saml,
concurrent_voice_sessions, customer_audit (tracks B/C/C12).

bootstrap_edevs_owner now provisions an active STARTUP subscription
(ensure_default_subscription) since post-C07 every tenant operation requires
a subscription; create_test_subscription reconciles ai_agent_slot counter for
tests that create active agents directly.

Gate (PLAN section 12): coverage-matrix exists; storage overage blocks writes
not reads; downgrade/suspension zero-limit clears entitlements without delete;
Free counts only a brand-new dialog.

Targeted tests: reservations 5, quota_service 4, enforcement mapping 6.
Full backend regression green except one pre-existing SOCKS5 test and one
pre-existing membership-migration ProtectedError (both fail on clean HEAD).
UI unchanged. Production migration/deploy and publication of production
PlanVersion are not part of this commit.
2026-07-16 03:11:55 +03:00

92 lines
3.6 KiB
Python

from hub_platform.subscriptions.keys import PlanCode
from hub_platform.subscriptions.models import (
EntitlementDefinition,
EntitlementGrant,
Plan,
PlanVersion,
QuotaDefinition,
QuotaGrant,
QuotaLimitSource,
QuotaMode,
Subscription,
)
from hub_platform.subscriptions.plan_service import publish_plan_version
from hub_platform.subscriptions.subscription_service import create_subscription
from hub_platform.testing import system_tenant_context
def create_test_subscription(organization, *, quantity: int = 1, plan_code=PlanCode.STARTUP):
# Idempotent: if bootstrap or a prior call already created a subscription,
# return it (C07 bootstrap now provisions a default subscription).
existing = Subscription.objects.filter(organization=organization).first()
if existing is not None:
# Reconcile the ai_agent_slot counter with agents that may have been
# activated directly (test fixtures bypass the gate) and align the
# subscription quantity with what the test expects.
_resync_agent_slots(organization, existing, quantity)
return existing
plan, _ = Plan.objects.get_or_create(
code=plan_code,
defaults={"name": "Test plan", "saleable": True},
)
version, created = PlanVersion.objects.get_or_create(
plan=plan,
version=1,
defaults={
"agent_unit_price_minor": 290_000,
"currency": "RUB",
"billing_period": "MONTH",
},
)
if created:
entitlement, _ = EntitlementDefinition.objects.get_or_create(
key="byok_ai",
defaults={"name": "BYOK AI"},
)
EntitlementGrant.objects.create(
plan_version=version,
definition=entitlement,
)
quota, _ = QuotaDefinition.objects.get_or_create(
key="ai_agent_slots",
defaults={"name": "AI agent slots", "unit": "slots"},
)
QuotaGrant.objects.create(
plan_version=version,
definition=quota,
mode=QuotaMode.HARD,
limit_source=QuotaLimitSource.SUBSCRIPTION_AI_AGENT_QUANTITY,
)
if version.published_at is None:
version = publish_plan_version(version)
return create_subscription(
context=system_tenant_context(organization),
plan_version=version,
ai_agent_quantity=quantity,
)
def _resync_agent_slots(organization, subscription, quantity: int) -> None:
"""Reconcile ai_agent_slots for an existing subscription so tests that create
ACTIVE agents directly (bypassing the slot gate) keep the counter consistent,
and align the subscription quantity with the requested value."""
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.subscriptions.models import QuotaDefinition, UsageCounter
active = AIAgent.objects.filter(
organization=organization, status=AIAgentStatus.ACTIVE
).count()
target_quantity = max(quantity, active)
if subscription.ai_agent_quantity != target_quantity:
subscription.ai_agent_quantity = target_quantity
subscription.save(update_fields=["ai_agent_quantity", "updated_at"])
definition = QuotaDefinition.objects.filter(key="ai_agent_slots").first()
if definition is not None:
period = subscription.usage_periods.filter(status="OPEN").first()
if period is not None:
counter, _ = UsageCounter.objects.update_or_create(
period=period,
quota_definition=definition,
defaults={"used_value": active},
)