Files
chatballs/apps/backend/hub_platform/support/models.py
T
Andrey caa51f4e29 test(support): cover token verify, session start and denials
SPEC §13.15 / §16.14 acceptance criteria.

- test_helpers.make_support_token: HS256-токен для тестов (зеркало token.py).
- SupportSessionTests: happy path (snapshot+conversation+audit SUCCESS), invalid
  signature, expired, schema error, missing subject, disabled contract, wrong
  channel (CHANNEL_NOT_SUPPORT), product mismatch, missing token, continue session
  (reuses open conversation), raw token/jti не попадает в audit.
- SupportContractApiTests: OWNER регистрирует контракт (version из code), OPERATOR
  forbidden (403), code должен соответствовать продукту.
- Багфиксы по ходу тестов: code=CharField (точки в foxray.support.v1), jsonpath
  return current, audit-deny вне write-транзакции (откат не стирает audit),
  DRAFT/DISABLED → CONTRACT_DISABLED, extraction вынесен в extract.py (NO GOD FILES).
- 109 passed (95 baseline + 14 support).
2026-07-09 01:20:04 +03:00

140 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from django.core.exceptions import ValidationError
from django.db import models
# Отдел поддержки — authenticated in-product чат (ADR-HUB-0022, SPEC-HUB-0010/0011).
# Hub не владеет бизнес-моделью клиента продукта: каждый продукт публикует
# версионированный ProductSupportIdentityContract, а Hub хранит проверенный
# SupportIdentitySnapshot после проверки Product Support Token. Raw token нигде
# не сохраняется — только короткий хэш jti для audit/replay.
# Формат code контракта: <product_code>.support.v<major> (SPEC-HUB-0011 §3).
_CONTRACT_CODE_RE = re.compile(
r"^(?P<product_code>[a-z0-9-]+)\.support\.v(?P<version>[1-9][0-9]*)$"
)
class ContractStatus(models.TextChoices):
DRAFT = "DRAFT", "Черновик"
ACTIVE = "ACTIVE", "Активен"
DEPRECATED = "DEPRECATED", "Устаревший"
DISABLED = "DISABLED", "Отключён"
class ProductSupportContract(models.Model):
"""Версионированный контракт идентификации клиента поддержки продукта.
Описывает JSON Schema payload токена, mapping identity, карточки оператора,
AI-visible context, search и sensitive fields (SPEC-HUB-0011 §5).
"""
organization = models.ForeignKey(
"identity.Organization",
on_delete=models.PROTECT,
related_name="support_contracts",
)
product = models.ForeignKey(
"products.Product",
on_delete=models.PROTECT,
related_name="support_contracts",
)
# Формат <product_code>.support.v<major> содержит точки, поэтому CharField,
# а не SlugField (SlugField не допускает '.'). Формат проверяется в clean().
code = models.CharField(max_length=64)
version = models.PositiveSmallIntegerField()
status = models.CharField(
max_length=16,
choices=ContractStatus.choices,
default=ContractStatus.DRAFT,
)
allowed_channels = models.ManyToManyField(
"channels.Channel",
related_name="allowed_support_contracts",
blank=True,
)
schema_json = models.JSONField(default=dict, blank=True)
identity_mapping_json = models.JSONField(default=dict, blank=True)
operator_ui_json = models.JSONField(default=dict, blank=True)
ai_context_json = models.JSONField(default=dict, blank=True)
search_mapping_json = models.JSONField(default=dict, blank=True)
sensitive_fields_json = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["organization", "code"],
name="uniq_support_contract_org_code",
),
]
indexes = [models.Index(fields=["product", "status"])]
def __str__(self) -> str:
return f"{self.organization.slug}/{self.code}"
def clean(self) -> None:
super().clean()
if self.product.organization_id != self.organization_id:
raise ValidationError({"product": "Product must belong to the same organization"})
match = _CONTRACT_CODE_RE.match(self.code)
if match is None:
raise ValidationError(
{"code": "Contract code must be '<product_code>.support.v<major>'"}
)
if match.group("product_code") != self.product.code:
raise ValidationError({"code": "Contract code product part must match product code"})
if self.version != int(match.group("version")):
raise ValidationError({"version": "Version must match the major in contract code"})
class SupportIdentitySnapshot(models.Model):
"""Проверенный снимок identity/context клиента поддержки (SPEC-HUB-0011 §13).
Исторический снимок: если продукт позже изменил данные или структуру payload,
старый диалог остаётся читаемым в контексте того обращения. Raw token не
сохраняется — только хэш jti для audit/replay-обнаружения.
"""
organization = models.ForeignKey(
"identity.Organization",
on_delete=models.PROTECT,
related_name="support_identity_snapshots",
)
product = models.ForeignKey(
"products.Product",
on_delete=models.PROTECT,
related_name="support_identity_snapshots",
)
contract = models.ForeignKey(
ProductSupportContract,
on_delete=models.PROTECT,
related_name="snapshots",
)
contract_code = models.CharField(max_length=64)
subject_key = models.CharField(max_length=128, db_index=True)
account_key = models.CharField(max_length=128, blank=True, null=True, db_index=True)
display_name = models.CharField(max_length=255, blank=True)
display_email = models.CharField(max_length=255, blank=True)
payload_json = models.JSONField(default=dict, blank=True)
operator_context_json = models.JSONField(default=dict, blank=True)
ai_context_json = models.JSONField(default=dict, blank=True)
search_text = models.TextField(blank=True)
verified_at = models.DateTimeField(auto_now_add=True, db_index=True)
token_issued_at = models.DateTimeField()
token_expires_at = models.DateTimeField()
token_jti_hash = models.CharField(max_length=64, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["product", "subject_key"]),
models.Index(fields=["product", "account_key"]),
]
def __str__(self) -> str:
return f"snapshot:{self.id}/{self.subject_key}"