mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-08 18:35:00 +03:00
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.
86 lines
3.0 KiB
Python
86 lines
3.0 KiB
Python
from typing import Any
|
|
|
|
from rest_framework import status
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import exception_handler as drf_exception_handler
|
|
|
|
from hub_platform.subscriptions.errors import (
|
|
EntitlementRequired,
|
|
PolicyUnavailable,
|
|
QuotaExceeded,
|
|
SubscriptionDomainError,
|
|
SubscriptionInactive,
|
|
UsageConflict,
|
|
)
|
|
|
|
|
|
def _flatten(data: Any) -> str:
|
|
if isinstance(data, str):
|
|
return data
|
|
if isinstance(data, dict):
|
|
return "; ".join(_flatten(value) for value in data.values())
|
|
if isinstance(data, (list, tuple)):
|
|
return "; ".join(_flatten(item) for item in data)
|
|
return str(data)
|
|
|
|
|
|
def _domain_error_response(exc: SubscriptionDomainError):
|
|
"""Map subscription-domain errors to the SPA contract (SPEC-HUB-0022 §13).
|
|
|
|
EntitlementRequired -> 403; QuotaExceeded HARD/period -> 409,
|
|
RATE/CONCURRENT -> 429 (with Retry-After for RATE); PolicyUnavailable /
|
|
SubscriptionInactive -> 503; UsageConflict -> 409. Other domain errors -> 409.
|
|
"""
|
|
if isinstance(exc, EntitlementRequired):
|
|
return Response(
|
|
{"detail": str(exc), "code": exc.code, "entitlement": exc.entitlement},
|
|
status=status.HTTP_403_FORBIDDEN,
|
|
)
|
|
if isinstance(exc, QuotaExceeded):
|
|
is_rate_like = exc.mode in {"RATE", "CONCURRENT"}
|
|
http_status = (
|
|
status.HTTP_429_TOO_MANY_REQUESTS if is_rate_like else status.HTTP_409_CONFLICT
|
|
)
|
|
response = Response(
|
|
{
|
|
"detail": str(exc),
|
|
"code": exc.code,
|
|
"resource": exc.resource,
|
|
"limit": exc.limit,
|
|
"used": exc.used,
|
|
"requested": exc.requested,
|
|
},
|
|
status=http_status,
|
|
)
|
|
if exc.mode == "RATE":
|
|
# Advisory only: the client may retry once the window elides.
|
|
response["Retry-After"] = "60"
|
|
return response
|
|
if isinstance(exc, PolicyUnavailable | SubscriptionInactive):
|
|
return Response({"detail": str(exc), "code": exc.code}, status=503)
|
|
if isinstance(exc, UsageConflict):
|
|
return Response({"detail": str(exc), "code": exc.code}, status=status.HTTP_409_CONFLICT)
|
|
return Response({"detail": str(exc), "code": exc.code}, status=status.HTTP_409_CONFLICT)
|
|
|
|
|
|
def api_exception_handler(exc: Exception, context: dict[str, Any]):
|
|
"""Normalize every DRF error body to the SPA contract: {"detail": "<text>"}.
|
|
|
|
Subscription-domain exceptions are mapped to explicit HTTP statuses before DRF
|
|
would otherwise render them as 500 (they are plain Exceptions, not APIException).
|
|
"""
|
|
if isinstance(exc, SubscriptionDomainError):
|
|
return _domain_error_response(exc)
|
|
response = drf_exception_handler(exc, context)
|
|
if response is None:
|
|
return None
|
|
data = response.data
|
|
if (
|
|
isinstance(data, dict)
|
|
and list(data.keys()) == ["detail"]
|
|
and isinstance(data["detail"], str)
|
|
):
|
|
return response
|
|
response.data = {"detail": _flatten(data)}
|
|
return response
|