mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-11 11:54:59 +03:00
Single idempotent write boundary provision_organization coordinates Organization, OWNER membership or invitation, subscription/usage, system departments sales/support, audit and transactional outbox in one transaction under set_local_tenant. - New hub_platform.platform domain: PlatformOperator, hash-only PlatformToken, OrganizationProvisioning process record with state machine PENDING/IN_PROGRESS/WAITING_FOR_OWNER/COMPLETED/FAILED. - Platform API POST platform.custocrm.ru/api/v1/organizations: machine token auth + capability platform.organizations.provision, Idempotency-Key header, replay/conflict handling, no secrets in response. - OWNER invitation accept (accept_invitation + auth view) activates organization, subscription and first usage period; idempotent re-accept does not duplicate membership or period. - DB write boundary (tenancy/0005): platform INSERT/UPDATE on identity_organization and platform DML + tenant-isolation policy on identity_department/identity_employeeprofile; Organization.status ACTIVE/PENDING_OWNER (identity/0015). - System department codes centralised in identity/system_departments. Machine token without MFA is an owner-approved deviation from ADR-HUB-0031 section 9; human+MFA platform login belongs to track D. concurrent_p2p_calls derived from membership count is a separate C07 decision (PLAN-CUSTOCRM-0003 section 12). Gate: idempotent replay creates no second tenant; operator never becomes OWNER; provisioning creates no AI-agent/product/connection/demo data. Full backend regression 379 passed (one pre-existing unrelated SOCKS5 test); 19 C06 targeted tests passed. UI unchanged. Production migration/deploy, publication of production PlanVersion and bootstrap of the production platform token are not part of this commit.
72 lines
2.3 KiB
Python
72 lines
2.3 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
from typing import Any
|
|
|
|
from hub_platform.identity.models import Organization
|
|
from hub_platform.platform.provisioning_models import (
|
|
OrganizationProvisioning,
|
|
ProvisioningSource,
|
|
ProvisioningStatus,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvisioningCommand:
|
|
organization_name: str
|
|
organization_slug: str
|
|
owner_email: str
|
|
plan_version_id: str
|
|
ai_agent_quantity: int
|
|
source: str
|
|
idempotency_key: str
|
|
timezone: str = "Europe/Moscow"
|
|
currency: str = "RUB"
|
|
locale: str = ""
|
|
legal_name: str = ""
|
|
tax_profile: dict[str, Any] | None = None
|
|
|
|
def significant_fields(self) -> dict[str, Any]:
|
|
"""Canonical normalized values for request_hash (SPEC-HUB-0021 §11):
|
|
transport metadata is excluded. Keys are sorted for a stable hash."""
|
|
data = asdict(self)
|
|
data["owner_email"] = data["owner_email"].strip().lower()
|
|
data["organization_slug"] = data["organization_slug"].strip().lower()
|
|
data["organization_name"] = data["organization_name"].strip()
|
|
# Drop empty optional fields so their absence vs default do not diverge.
|
|
return {k: v for k, v in sorted(data.items()) if not _is_empty(v)}
|
|
|
|
def request_hash(self) -> str:
|
|
payload = json.dumps(self.significant_fields(), sort_keys=True, default=str)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _is_empty(value: Any) -> bool:
|
|
return value in ("", None, {}, [])
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvisioningResult:
|
|
provisioning: OrganizationProvisioning
|
|
organization: Organization
|
|
created: bool # False on idempotent replay
|
|
|
|
|
|
def is_replay(provisioning: OrganizationProvisioning, command: ProvisioningCommand) -> bool:
|
|
"""COMPLETED/WAITING_FOR_OWNER with a matching request hash is an idempotent
|
|
replay; a differing hash is a conflict (SPEC-HUB-0021 §11)."""
|
|
return provisioning.request_hash == command.request_hash()
|
|
|
|
|
|
_TERMINAL_STATUSES = {ProvisioningStatus.COMPLETED, ProvisioningStatus.WAITING_FOR_OWNER}
|
|
|
|
|
|
def is_terminal(provisioning: OrganizationProvisioning) -> bool:
|
|
return provisioning.status in _TERMINAL_STATUSES
|
|
|
|
|
|
def allowed_source(source: str) -> bool:
|
|
return source in ProvisioningSource.values
|