✨ feat(tenancy): enforce explicit tenant context

This commit is contained in:
Andrey committed 2026-07-15 03:28:25 +03:00
1 parent a134cedfcd
commit 30f9eb3d1b
132 files changed
+2234 -936

No files matched your search

@@ -63,6 +63,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"hub_platform.events.middleware.CorrelationIdMiddleware",
"hub_platform.tenancy.middleware.TenantContextMiddleware",
]
TEMPLATES = [
+47 -13
View File
@@ -11,20 +11,54 @@ urlpatterns = [
name="openapi-schema",
),
path("api/v1/auth/", include("hub_platform.identity.auth_urls")),
path("api/v1/company/", include("hub_platform.identity.company_urls")),
path("api/v1/company/", include("hub_platform.products.urls")),
path("api/v1/employees/", include("hub_platform.identity.employee_urls")),
path("api/v1/access-profiles/", include("hub_platform.identity.access_urls")),
path("api/v1/ai/", include("hub_platform.ai.urls")),
path("api/v1/integrations/", include("hub_platform.integrations.urls")),
path("api/v1/channels/", include("hub_platform.channels.urls")),
path("api/v1/conversations/", include("hub_platform.conversations.urls")),
path("api/v1/orders/", include("hub_platform.orders.urls")),
path("api/v1/sales/", include("hub_platform.sales.urls")),
path(
"api/v1/organizations/<uuid:organization_public_id>/company/",
include("hub_platform.identity.company_urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/company/",
include("hub_platform.products.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/employees/",
include("hub_platform.identity.employee_urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/access-profiles/",
include("hub_platform.identity.access_urls"),
),
path("api/v1/organizations/<uuid:organization_public_id>/ai/", include("hub_platform.ai.urls")),
path(
"api/v1/organizations/<uuid:organization_public_id>/integrations/",
include("hub_platform.integrations.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/channels/",
include("hub_platform.channels.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/conversations/",
include("hub_platform.conversations.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/orders/",
include("hub_platform.orders.urls"),
),
path("api/v1/organizations/<uuid:organization_public_id>/sales/", include("hub_platform.sales.urls")),
path(
"api/v1/organizations/<uuid:organization_public_id>/notifications/",
include("hub_platform.notifications.urls"),
),
path(
"api/v1/organizations/<uuid:organization_public_id>/support/",
include("hub_platform.support.urls"),
),
path("api/v1/organizations/<uuid:organization_public_id>/calls/", include("hub_platform.calls.urls")),
path("api/v1/product-sales/", include("hub_platform.sales.product_sales_urls")),
path("api/v1/notifications/", include("hub_platform.notifications.urls")),
path("api/v1/webchat/", include("hub_platform.webchat.urls")),
path("api/v1/health/", include("hub_platform.health.urls")),
path("api/v1/support/", include("hub_platform.support.urls")),
path("api/v1/calls/", include("hub_platform.calls.urls")),
path("api/v1/orders/", include("hub_platform.orders.public_urls")),
path("api/v1/ai/", include("hub_platform.ai.public_urls")),
path("api/v1/support/", include("hub_platform.support.public_urls")),
path("api/v1/calls/", include("hub_platform.calls.public_urls")),
]
@@ -19,6 +19,7 @@ from django.db import transaction
from hub_platform.ai import indexing
from hub_platform.ai.models import Knowledge
from hub_platform.tenancy.context import TenantContext
_SECTION_RE = re.compile(r"^===\s*(?P<code>[a-z0-9-]+)\s*===\s*$", re.MULTILINE)
@@ -127,7 +128,8 @@ def _assign_knowledge(*, organization, global_items: list[Knowledge], by_product
@transaction.atomic
def import_ai_content(*, base_dir: Path, organization) -> ImportResult:
def import_ai_content(*, base_dir: Path, context: TenantContext) -> ImportResult:
organization = context.organization
result = ImportResult()
prompts: dict[tuple[str | None, str], str] = {}
global_items: list[Knowledge] = []
@@ -0,0 +1,7 @@
from django.urls import path
from hub_platform.ai import views
urlpatterns = [
path("files/<uuid:public_id>/", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"),
]
+9 -8
View File
@@ -1,29 +1,30 @@
from django.db.models import Count, QuerySet
from hub_platform.ai.models import AIAgent, Knowledge
from hub_platform.tenancy.context import TenantContext
def agents_for_organization(organization_id: int) -> QuerySet[AIAgent]:
def agents_for_context(context: TenantContext) -> QuerySet[AIAgent]:
return (
AIAgent.objects.select_related("channel", "channel__product")
.prefetch_related("knowledge_items")
.filter(channel__organization_id=organization_id)
.filter(channel__organization_id=context.organization_id)
.order_by("channel__name")
)
def agent_for_organization(*, organization_id: int, agent_id: int) -> AIAgent:
return agents_for_organization(organization_id).get(id=agent_id)
def agent_for_context(*, context: TenantContext, agent_id: int) -> AIAgent:
return agents_for_context(context).get(id=agent_id)
def knowledge_for_organization(organization_id: int) -> QuerySet[Knowledge]:
def knowledge_for_context(context: TenantContext) -> QuerySet[Knowledge]:
return (
Knowledge.objects.filter(organization_id=organization_id)
Knowledge.objects.filter(organization_id=context.organization_id)
.prefetch_related("attachments")
.annotate(agents_count=Count("agents", distinct=True))
.order_by("title")
)
def knowledge_item_for_organization(*, organization_id: int, knowledge_id: int) -> Knowledge:
return knowledge_for_organization(organization_id).get(id=knowledge_id)
def knowledge_item_for_context(*, context: TenantContext, knowledge_id: int) -> Knowledge:
return knowledge_for_context(context).get(id=knowledge_id)
+29 -13
View File
@@ -8,6 +8,7 @@ from hub_platform.ai.extraction import extract_text
from hub_platform.ai.indexing import reindex_knowledge
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
from hub_platform.channels.models import Channel
from hub_platform.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -47,15 +48,16 @@ def _normalize_limits(raw: dict | None) -> dict:
return {"dailyCostUsd": cents} if cents > 0 else {}
def _knowledge_for_ids(*, organization, knowledge_ids: list[int]) -> list[Knowledge]:
items = list(Knowledge.objects.filter(organization=organization, id__in=knowledge_ids))
def _knowledge_for_ids(*, context: TenantContext, knowledge_ids: list[int]) -> list[Knowledge]:
items = list(Knowledge.objects.filter(organization=context.organization, id__in=knowledge_ids))
if len(items) != len(set(knowledge_ids)):
raise ValidationError({"knowledgeIds": "Unknown knowledge item"})
return items
@transaction.atomic
def create_agent(*, organization, data: AgentCreateInput) -> AIAgent:
def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
organization = context.organization
if not data.channel_code:
raise ValidationError({"channel": "Channel is required"})
if not data.model:
@@ -76,12 +78,14 @@ def create_agent(*, organization, data: AgentCreateInput) -> AIAgent:
tone=data.tone,
instructions=data.instructions,
)
agent.knowledge_items.set(_knowledge_for_ids(organization=organization, knowledge_ids=data.knowledge_ids))
agent.knowledge_items.set(_knowledge_for_ids(context=context, knowledge_ids=data.knowledge_ids))
return agent
@transaction.atomic
def update_agent(*, agent: AIAgent, data: AgentInput) -> AIAgent:
def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> AIAgent:
if agent.channel.organization_id != context.organization_id:
raise ValidationError({"agent": "Agent belongs to another organization"})
agent.name = data.name
agent.model = data.model
agent.model_params = data.model_params
@@ -93,12 +97,14 @@ def update_agent(*, agent: AIAgent, data: AgentInput) -> AIAgent:
agent.save(update_fields=["name", "model", "model_params", "allowed_tools", "limits", "persona", "tone", "instructions", "updated_at"])
if data.knowledge_ids is not None:
agent.knowledge_items.set(
_knowledge_for_ids(organization=agent.channel.organization, knowledge_ids=data.knowledge_ids)
_knowledge_for_ids(context=context, knowledge_ids=data.knowledge_ids)
)
return agent
def set_agent_active(*, agent: AIAgent, is_active: bool) -> AIAgent:
def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool) -> AIAgent:
if agent.channel.organization_id != context.organization_id:
raise ValidationError({"agent": "Agent belongs to another organization"})
agent.is_active = is_active
agent.save(update_fields=["is_active", "updated_at"])
return agent
@@ -115,11 +121,11 @@ class KnowledgeInput:
is_enabled: bool
def create_knowledge(*, organization, data: KnowledgeInput) -> Knowledge:
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
knowledge = Knowledge.objects.create(
organization=organization,
organization=context.organization,
title=data.title.strip(),
description=data.description.strip(),
content=data.content,
@@ -129,7 +135,9 @@ def create_knowledge(*, organization, data: KnowledgeInput) -> Knowledge:
return knowledge
def update_knowledge(*, knowledge: Knowledge, data: KnowledgeInput) -> Knowledge:
def update_knowledge(*, context: TenantContext, knowledge: Knowledge, data: KnowledgeInput) -> Knowledge:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
content_changed = knowledge.content != data.content
@@ -143,7 +151,9 @@ def update_knowledge(*, knowledge: Knowledge, data: KnowledgeInput) -> Knowledge
return knowledge
def delete_knowledge(*, knowledge: Knowledge) -> None:
def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
# Файлы вложений удаляются вместе со знанием: сначала с диска, потом запись.
for attachment in knowledge.attachments.all():
attachment.file.delete(save=False)
@@ -154,7 +164,11 @@ _MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
@transaction.atomic
def add_attachment(*, knowledge: Knowledge, upload: UploadedFile) -> KnowledgeAttachment:
def add_attachment(
*, context: TenantContext, knowledge: Knowledge, upload: UploadedFile
) -> KnowledgeAttachment:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
original_name = (upload.name or "").strip()
if not original_name:
raise ValidationError({"file": "File name is required"})
@@ -181,7 +195,9 @@ def add_attachment(*, knowledge: Knowledge, upload: UploadedFile) -> KnowledgeAt
return attachment
def delete_attachment(*, attachment: KnowledgeAttachment) -> None:
def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment) -> None:
if attachment.knowledge.organization_id != context.organization_id:
raise ValidationError({"attachment": "Attachment belongs to another organization"})
knowledge = attachment.knowledge
attachment.file.delete(save=False)
attachment.delete()
+6 -6
View File
@@ -3,12 +3,12 @@ import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient, system_tenant_context
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeFragment
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import EmployeeProfile, EmployeeRole, HumanUser, Organization
from hub_platform.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from hub_platform.products.models import Product
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
@@ -45,7 +45,7 @@ class AIAgentInvariantTests(TestCase):
)
with self.assertRaises(ValidationError):
create_agent(
organization=self.organization,
context=system_tenant_context(self.organization),
data=AgentCreateInput(channel_code=channel.code, model="gpt-4o-mini", persona="", tone="", instructions="", knowledge_ids=[]),
)
@@ -173,7 +173,7 @@ class AIAgentPermissionTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=operator,
organization=Organization.objects.get(slug="edevs"),
role=EmployeeRole.EMPLOYEE,
@@ -545,7 +545,7 @@ class KnowledgeRetrievalTests(TestCase):
from hub_platform.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
organization=self.organization,
context=system_tenant_context(self.organization),
data=KnowledgeInput(
title="FAQ",
description="Возвраты и доставка",
@@ -596,7 +596,7 @@ class AgentRuntimeTests(TestCase):
from hub_platform.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
organization=self.organization,
context=system_tenant_context(self.organization),
data=KnowledgeInput(title="FAQ", description="Возвраты", content="Refund policy details here.", is_enabled=True),
)
self.agent.knowledge_items.add(self.knowledge)
-1
View File
@@ -17,5 +17,4 @@ urlpatterns = [
views.KnowledgeAttachmentDeleteView.as_view(),
name="ai-knowledge-attachment-delete",
),
path("files/<uuid:public_id>/", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"),
]
+49 -26
View File
@@ -8,10 +8,10 @@ from rest_framework.views import APIView
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
from hub_platform.ai.selectors import (
agent_for_organization,
agents_for_organization,
knowledge_for_organization,
knowledge_item_for_organization,
agent_for_context,
agents_for_context,
knowledge_for_context,
knowledge_item_for_context,
)
from hub_platform.ai.serializers import agent_payload, attachment_payload, knowledge_payload
from hub_platform.ai.services import (
@@ -29,6 +29,7 @@ from hub_platform.ai.services import (
)
from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from hub_platform.tenancy.context import TenantContext
def _agent_input(body: dict[str, object], *, current: AIAgent) -> AgentInput:
@@ -68,7 +69,7 @@ class AIAgentListView(APIView):
require_organization_scope = True
def get(self, request: Request) -> Response:
agents = agents_for_organization(request.user.employee_profile.organization_id)
agents = agents_for_context(request.tenant_context)
return Response({"items": [agent_payload(agent) for agent in agents]})
def post(self, request: Request) -> Response:
@@ -77,7 +78,7 @@ class AIAgentListView(APIView):
return Response({"detail": "knowledgeIds must be a list of ids"}, status=400)
try:
agent = create_agent(
organization=request.user.employee_profile.organization,
context=request.tenant_context,
data=AgentCreateInput(
channel_code=str(request.data.get("channel", "")).strip(),
model=str(request.data.get("model", "")).strip(),
@@ -92,7 +93,7 @@ class AIAgentListView(APIView):
record_audit_event(
action="ai.agent_created",
actor=request.user,
organization=request.user.employee_profile.organization,
organization=request.tenant_context.organization,
object_type="AIAgent",
object_id=str(agent.id),
request=request,
@@ -107,7 +108,7 @@ class AIAgentDetailView(APIView):
def get(self, request: Request, agent_id: int) -> Response:
try:
agent = agent_for_organization(organization_id=request.user.employee_profile.organization_id, agent_id=agent_id)
agent = agent_for_context(context=request.tenant_context, agent_id=agent_id)
except AIAgent.DoesNotExist:
return Response({"detail": "Agent not found"}, status=404)
return Response({"agent": agent_payload(agent)})
@@ -119,24 +120,27 @@ class AIAgentUpdateView(APIView):
require_organization_scope = True
def patch(self, request: Request, agent_id: int) -> Response:
organization_id = request.user.employee_profile.organization_id
try:
agent = agent_for_organization(organization_id=organization_id, agent_id=agent_id)
agent = agent_for_context(context=request.tenant_context, agent_id=agent_id)
except AIAgent.DoesNotExist:
return Response({"detail": "Agent not found"}, status=404)
try:
agent = update_agent(agent=agent, data=_agent_input(request.data, current=agent))
agent = update_agent(
context=request.tenant_context,
agent=agent,
data=_agent_input(request.data, current=agent),
)
except ValidationError as error:
return _validation_error(error)
record_audit_event(
action="ai.agent_updated",
actor=request.user,
organization=request.user.employee_profile.organization,
organization=request.tenant_context.organization,
object_type="AIAgent",
object_id=str(agent.id),
request=request,
)
agent = agent_for_organization(organization_id=organization_id, agent_id=agent_id)
agent = agent_for_context(context=request.tenant_context, agent_id=agent_id)
return Response({"agent": agent_payload(agent)})
@@ -147,12 +151,16 @@ class _AIAgentStatusView(APIView):
target_active: bool
def post(self, request: Request, agent_id: int) -> Response:
organization = request.user.employee_profile.organization
organization = request.tenant_context.organization
try:
agent = agent_for_organization(organization_id=organization.id, agent_id=agent_id)
agent = agent_for_context(context=request.tenant_context, agent_id=agent_id)
except AIAgent.DoesNotExist:
return Response({"detail": "Agent not found"}, status=404)
agent = set_agent_active(agent=agent, is_active=self.target_active)
agent = set_agent_active(
context=request.tenant_context,
agent=agent,
is_active=self.target_active,
)
record_audit_event(
action="ai.agent_activated" if self.target_active else "ai.agent_deactivated",
actor=request.user,
@@ -196,10 +204,10 @@ class _KnowledgeBaseView(APIView):
require_organization_scope = True
def _org(self, request: Request):
return request.user.employee_profile.organization
return request.tenant_context.organization
def _knowledge(self, request: Request, knowledge_id: int) -> Knowledge:
return knowledge_item_for_organization(organization_id=self._org(request).id, knowledge_id=knowledge_id)
return knowledge_item_for_context(context=request.tenant_context, knowledge_id=knowledge_id)
def _audit(self, request: Request, action: str, knowledge: Knowledge) -> None:
record_audit_event(
@@ -214,12 +222,15 @@ class _KnowledgeBaseView(APIView):
class KnowledgeListCreateView(_KnowledgeBaseView):
def get(self, request: Request) -> Response:
items = knowledge_for_organization(self._org(request).id)
items = knowledge_for_context(request.tenant_context)
return Response({"items": [knowledge_payload(item, include_content=False) for item in items]})
def post(self, request: Request) -> Response:
try:
knowledge = create_knowledge(organization=self._org(request), data=_knowledge_input(request.data))
knowledge = create_knowledge(
context=request.tenant_context,
data=_knowledge_input(request.data),
)
except ValidationError as error:
return _validation_error(error)
knowledge = self._knowledge(request, knowledge.id)
@@ -241,7 +252,11 @@ class KnowledgeDetailView(_KnowledgeBaseView):
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
try:
knowledge = update_knowledge(knowledge=knowledge, data=_knowledge_input(request.data, current=knowledge))
knowledge = update_knowledge(
context=request.tenant_context,
knowledge=knowledge,
data=_knowledge_input(request.data, current=knowledge),
)
except ValidationError as error:
return _validation_error(error)
knowledge = self._knowledge(request, knowledge_id)
@@ -254,7 +269,7 @@ class KnowledgeDetailView(_KnowledgeBaseView):
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
self._audit(request, "deleted", knowledge)
delete_knowledge(knowledge=knowledge)
delete_knowledge(context=request.tenant_context, knowledge=knowledge)
return Response(status=204)
@@ -278,7 +293,7 @@ class KnowledgeImportView(_KnowledgeBaseView):
knowledge = Knowledge.objects.filter(organization=organization, title=title).first()
if knowledge is None:
create_knowledge(
organization=organization,
context=request.tenant_context,
data=KnowledgeInput(title=title, description=description, content=content, is_enabled=True),
)
created += 1
@@ -286,6 +301,7 @@ class KnowledgeImportView(_KnowledgeBaseView):
unchanged += 1
else:
update_knowledge(
context=request.tenant_context,
knowledge=knowledge,
data=KnowledgeInput(
title=title,
@@ -319,7 +335,9 @@ class KnowledgeAttachmentUploadView(_KnowledgeBaseView):
if upload is None:
return Response({"detail": "file is required (multipart/form-data)"}, status=400)
try:
attachment = add_attachment(knowledge=knowledge, upload=upload)
attachment = add_attachment(
context=request.tenant_context, knowledge=knowledge, upload=upload
)
except ValidationError as error:
return _validation_error(error)
self._audit(request, "attachment_added", knowledge)
@@ -335,7 +353,7 @@ class KnowledgeAttachmentDeleteView(_KnowledgeBaseView):
attachment = knowledge.attachments.filter(id=attachment_id).first()
if attachment is None:
return Response({"detail": "Attachment not found"}, status=404)
delete_attachment(attachment=attachment)
delete_attachment(context=request.tenant_context, attachment=attachment)
self._audit(request, "attachment_deleted", knowledge)
return Response(status=204)
@@ -347,7 +365,12 @@ class AttachmentDownloadView(APIView):
authentication_classes: list = []
def get(self, request: Request, public_id) -> FileResponse:
attachment = KnowledgeAttachment.objects.filter(public_id=public_id).first()
attachment = KnowledgeAttachment.objects.select_related(
"knowledge__organization"
).filter(public_id=public_id).first()
if attachment is None:
raise Http404
context = TenantContext.for_resource(attachment.knowledge.organization)
if attachment.knowledge.organization_id != context.organization_id:
raise Http404
return FileResponse(attachment.file.open("rb"), as_attachment=True, filename=attachment.original_name)
+5 -4
View File
@@ -21,13 +21,14 @@ class HasCapability(BasePermission):
capability = by_method.get(request.method, capability)
if not capability:
return False
profile = getattr(request.user, "employee_profile", None)
if profile is None:
context = getattr(request, "tenant_context", None)
if context is None or context.membership is None:
return False
profile = context.membership
if getattr(view, "require_organization_scope", False):
return authorize(
request.user,
profile,
capability,
ResourceScope(organization_id=profile.organization_id),
)
return has_capability_any_scope(request.user, capability)
return has_capability_any_scope(profile, capability)
+25 -13
View File
@@ -18,7 +18,7 @@ from hub_platform.calls import signaling
from hub_platform.calls.errors import CallTokenError
from hub_platform.calls.models import TERMINAL_CALL_STATUSES
from hub_platform.calls.permissions import staff_call_access_valid
from hub_platform.calls.services import authorize_call_access_token
from hub_platform.calls.services import authorize_call_access_context
from hub_platform.calls.models import ParticipantSide
logger = logging.getLogger(__name__)
@@ -33,7 +33,7 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
async def connect(self) -> None:
self.call_id = None
self.side = None
self.staff_user_id = None
self.tenant_context = None
self.group = None
self._seen_commands: set[str] = set()
await self.accept()
@@ -45,7 +45,7 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
return
if self.side == ParticipantSide.STAFF and not await database_sync_to_async(
staff_call_access_valid
)(user_id=self.staff_user_id, call_session_id=self.call_id):
)(context=self.tenant_context, call_session_id=self.call_id):
await self.send_json({"type": "error", "code": "ACCESS_REVOKED"})
await self.close(code=4403)
return
@@ -65,9 +65,13 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
await self._connection_state(content)
elif msg_type == "participant.metrics":
# Технические метрики без медиаконтента: сохраняем, не ретранслируем.
await database_sync_to_async(signaling.record_metric)(self.call_id, self.side, content)
await database_sync_to_async(signaling.record_metric)(
self.tenant_context, self.call_id, self.side, content
)
elif msg_type == "call.ended":
payload = await database_sync_to_async(signaling.end_from_signaling)(self.call_id, self.side)
payload = await database_sync_to_async(signaling.end_from_signaling)(
self.tenant_context, self.call_id, self.side
)
await self._broadcast({"type": "call.state", "call": payload}, include_self=True)
# незнакомые типы игнорируются без разрыва соединения
@@ -75,7 +79,9 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
if self.group is None:
return
await self.channel_layer.group_discard(self.group, self.channel_name)
payload = await database_sync_to_async(signaling.signaling_leave)(self.call_id, self.side)
payload = await database_sync_to_async(signaling.signaling_leave)(
self.tenant_context, self.call_id, self.side
)
if payload is not None:
await self._broadcast(
{"type": "participant.connection_state", "side": self.side, "state": "DISCONNECTED"},
@@ -89,8 +95,8 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
return
token = str(content.get("token", ""))
try:
claims, call = await database_sync_to_async(
lambda: authorize_call_access_token(token=token)
claims, call, context = await database_sync_to_async(
lambda: authorize_call_access_context(token=token)
)()
except CallTokenError:
await self.send_json({"type": "error", "code": "AUTH_FAILED"})
@@ -98,10 +104,12 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
return
self.call_id = call.id
self.side = claims.side
self.staff_user_id = int(claims.subject_id) if claims.side == ParticipantSide.STAFF else None
self.tenant_context = context
self.group = f"call.{call.id}"
await self.channel_layer.group_add(self.group, self.channel_name)
payload = await database_sync_to_async(signaling.signaling_join)(self.call_id, self.side)
payload = await database_sync_to_async(signaling.signaling_join)(
self.tenant_context, self.call_id, self.side
)
await self.send_json({"type": "call.state", "call": payload})
await self._broadcast({"type": "peer.joined", "side": self.side})
logger.info("call signaling joined: call=%s side=%s", self.call_id, self.side)
@@ -109,11 +117,15 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
async def _relay(self, msg_type: str, content: dict) -> None:
# Сервер валидирует состояние CallSession перед передачей (SPEC §9);
# payload не логируется — там SDP/ICE.
status = await database_sync_to_async(signaling.call_status)(self.call_id)
status = await database_sync_to_async(signaling.call_status)(
self.tenant_context, self.call_id
)
if status in TERMINAL_CALL_STATUSES:
return
if msg_type == "webrtc.offer":
changed = await database_sync_to_async(signaling.start_negotiation)(self.call_id)
changed = await database_sync_to_async(signaling.start_negotiation)(
self.tenant_context, self.call_id
)
if changed is not None:
await self._broadcast({"type": "call.state", "call": changed}, include_self=True)
await self._broadcast({**content, "side": self.side})
@@ -121,7 +133,7 @@ class CallSignalingConsumer(AsyncJsonWebsocketConsumer):
async def _connection_state(self, content: dict) -> None:
connected = content.get("state") == "CONNECTED"
payload, became_active = await database_sync_to_async(signaling.report_connection)(
self.call_id, self.side, connected
self.tenant_context, self.call_id, self.side, connected
)
await self._broadcast(
{"type": "participant.connection_state", "side": self.side, "state": str(content.get("state", ""))[:16]},
@@ -16,6 +16,7 @@ from hub_platform.calls.services import CALL_INVITE_SEND
from hub_platform.calls.tokens import issue_invite_token
from hub_platform.conversations import transports
from hub_platform.events.handlers import register
from hub_platform.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
@@ -27,7 +28,9 @@ class CallInviteDeliveryError(Exception):
@register(CALL_INVITE_SEND)
def handle_call_invite_send(payload: dict) -> None:
def handle_call_invite_send(payload: dict, context: TenantContext | None) -> None:
if context is None:
raise ValueError("Call invite event has no tenant context")
call_session_id = payload.get("callSessionId")
with transaction.atomic():
call = (
@@ -35,7 +38,7 @@ def handle_call_invite_send(payload: dict) -> None:
# нельзя блокировать; блокируем только строку звонка.
CallSession.objects.select_for_update(of=("self",))
.select_related("conversation", "delivery_connection")
.filter(id=call_session_id)
.filter(id=call_session_id, organization=context.organization)
.first()
)
if call is None or call.status != CallStatus.REQUESTED or call.delivery_connection is None:
@@ -11,13 +11,14 @@ from hub_platform.calls.lifecycle import transition_call
from hub_platform.calls.models import CallEndedBy, CallSession, CallStatus
def expire_stale_calls() -> int:
def expire_stale_calls(context) -> int:
now = timezone.now()
finished = 0
# Приглашение истекло: доставленное — MISSED (клиент не ответил),
# недоставленное — EXPIRED.
pending = CallSession.objects.filter(
organization=context.organization,
status__in=[CallStatus.REQUESTED, CallStatus.RINGING],
invite__expires_at__lte=now,
).values_list("id", "status")
@@ -32,6 +33,7 @@ def expire_stale_calls() -> int:
# Принятый звонок без установленного соединения дольше grace period — FAILED.
connect_deadline = now - timedelta(seconds=settings.HUB_CALL_CONNECT_GRACE_SECONDS)
stuck = CallSession.objects.filter(
organization=context.organization,
status__in=[CallStatus.ACCEPTED, CallStatus.CONNECTING],
accepted_at__lte=connect_deadline,
).values_list("id", flat=True)
@@ -52,6 +54,7 @@ def expire_stale_calls() -> int:
reconnect_deadline = now - timedelta(seconds=settings.HUB_CALL_RECONNECT_GRACE_SECONDS)
dropped = (
CallSession.objects.filter(
organization=context.organization,
status=CallStatus.ACTIVE,
participants__left_at__lte=reconnect_deadline,
)
+10 -4
View File
@@ -3,6 +3,7 @@ from hub_platform.calls.models import CallSession
from hub_platform.conversations.models import Conversation
from hub_platform.identity.models import OrganizationMembership
from hub_platform.identity.policy import require_capability
from hub_platform.tenancy.context import TenantContext
def ensure_conversation_call_access(*, user, conversation: Conversation) -> None:
@@ -14,15 +15,20 @@ def ensure_call_access(*, user, call_session: CallSession) -> None:
ensure_conversation_call_access(user=user, conversation=call_session.conversation)
def staff_call_access_valid(*, user_id: int, call_session_id) -> bool:
def staff_call_access_valid(*, context: TenantContext, call_session_id) -> bool:
if context.membership is None or context.actor_user is None:
return False
call = CallSession.objects.select_related(
"conversation", "conversation__channel"
).filter(id=call_session_id).first()
).filter(id=call_session_id, organization=context.organization).first()
if call is None:
return False
membership = OrganizationMembership.objects.select_related("user", "organization").filter(
user_id=user_id,
organization_id=call.conversation.channel.organization_id,
pk=context.membership_id,
user=context.actor_user,
organization=context.organization,
blocked_at__isnull=True,
user__is_active=True,
).first()
if membership is None:
return False
@@ -0,0 +1,10 @@
from django.urls import path
from hub_platform.calls import views
urlpatterns = [
path("invites/resolve/", views.InviteResolveView.as_view(), name="call-invite-resolve"),
path("access/state/", views.CallAccessStateView.as_view(), name="call-access-state"),
path("access/accept/", views.CallAccessAcceptView.as_view(), name="call-access-accept"),
path("access/decline/", views.CallAccessDeclineView.as_view(), name="call-access-decline"),
]
+45 -10
View File
@@ -46,6 +46,8 @@ from hub_platform.conversations.models import (
from hub_platform.conversations.services import ClaimError, claim_locked_conversation
from hub_platform.events.services import DomainEvent, enqueue_event
from hub_platform.integrations.models import IntegrationProvider
from hub_platform.identity.models import OrganizationMembership
from hub_platform.tenancy.context import TenantContext
# Outbox-событие доставки приглашения в TG/MAX (обработчик — calls.event_handlers).
CALL_INVITE_SEND = "calls.invite_send"
@@ -101,19 +103,22 @@ def _check_call_creation_conflicts(*, conversation: Conversation, initiator) ->
@transaction.atomic
def create_call_request(*, conversation_id: int, initiator) -> CreatedCall:
def create_call_request(*, context: TenantContext, conversation_id: int) -> CreatedCall:
initiator = context.actor_user
if initiator is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
conversation = (
Conversation.objects.select_for_update()
.select_related("channel")
.get(id=conversation_id)
.get(id=conversation_id, organization=context.organization)
)
ensure_conversation_call_access(user=initiator, conversation=conversation)
ensure_conversation_call_access(user=context.membership, conversation=conversation)
identity = _conversation_identity(conversation)
_check_call_creation_conflicts(conversation=conversation, initiator=initiator)
if conversation.control_mode != ControlMode.HUMAN or conversation.assigned_operator_id != initiator.id:
try:
claim_locked_conversation(conversation=conversation, operator=initiator)
claim_locked_conversation(context=context, conversation=conversation)
except ClaimError as error:
raise CallConflict(str(error)) from error
@@ -164,6 +169,7 @@ def create_call_request(*, conversation_id: int, initiator) -> CreatedCall:
aggregate_id=str(call.id),
event_type=CALL_INVITE_SEND,
payload={"callSessionId": str(call.id)},
tenant_context=context,
)
)
staff_token = issue_call_access_token(
@@ -201,8 +207,11 @@ def resolve_invite(*, token: str) -> ResolvedInvite:
return ResolvedInvite(invite=invite, customer_access_token=access_token)
def issue_staff_access_token(*, call_session: CallSession, user) -> str:
ensure_call_access(user=user, call_session=call_session)
def issue_staff_access_token(*, context: TenantContext, call_session: CallSession) -> str:
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
ensure_call_access(user=context.membership, call_session=call_session)
participant_exists = call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists()
if not participant_exists:
raise CallConflict("Сотрудник не является участником звонка")
@@ -219,7 +228,7 @@ def authorize_call_access_token(*, token: str, allow_terminal: bool = False) ->
claims = verify_call_access_token(token)
try:
call = CallSession.objects.select_related(
"conversation", "conversation__channel", "initiated_by"
"conversation", "conversation__channel", "initiated_by", "organization"
).get(id=claims.call_session_id)
except CallSession.DoesNotExist:
raise CallTokenError("Недействительный или истёкший call access token") from None
@@ -232,8 +241,13 @@ def authorize_call_access_token(*, token: str, allow_terminal: bool = False) ->
).first()
valid = participant is not None
if participant is not None:
membership = OrganizationMembership.objects.select_related("user").filter(
user=participant.user,
organization_id=call.organization_id,
blocked_at__isnull=True,
).first()
try:
ensure_call_access(user=participant.user, call_session=call)
ensure_call_access(user=membership, call_session=call)
except CallAccessDenied:
valid = False
else:
@@ -249,8 +263,29 @@ def authorize_call_access_token(*, token: str, allow_terminal: bool = False) ->
return claims, call
def cancel_call(*, call_session: CallSession, user) -> CallSession:
ensure_call_access(user=user, call_session=call_session)
def authorize_call_access_context(
*, token: str, allow_terminal: bool = False
) -> tuple[CallAccessClaims, CallSession, TenantContext]:
claims, call = authorize_call_access_token(token=token, allow_terminal=allow_terminal)
if claims.side == ParticipantSide.STAFF:
membership = OrganizationMembership.objects.select_related(
"user", "organization"
).get(
user_id=claims.subject_id,
organization=call.organization,
blocked_at__isnull=True,
)
context = TenantContext.for_membership(membership)
else:
context = TenantContext.for_resource(call.organization)
return claims, call, context
def cancel_call(*, context: TenantContext, call_session: CallSession) -> CallSession:
user = context.actor_user
if user is None or context.membership is None:
raise CallAccessDenied("Для звонка требуется контекст сотрудника")
ensure_call_access(user=context.membership, call_session=call_session)
if not call_session.participants.filter(side=ParticipantSide.STAFF, user=user).exists():
raise CallConflict("Сотрудник не является участником звонка")
try:
+43 -21
View File
@@ -20,6 +20,7 @@ from hub_platform.calls.models import (
)
from hub_platform.calls.serializers import public_call_state_payload
from hub_platform.calls.services import record_call_metric
from hub_platform.tenancy.context import TenantContext
SIDE_TO_ENDED_BY = {
ParticipantSide.STAFF: CallEndedBy.STAFF,
@@ -27,19 +28,25 @@ SIDE_TO_ENDED_BY = {
}
def _call(call_id) -> CallSession:
return CallSession.objects.select_related("initiated_by").get(id=call_id)
def _call(context: TenantContext, call_id) -> CallSession:
return CallSession.objects.select_related("initiated_by").get(
id=call_id, organization=context.organization
)
def call_state(call_id) -> dict:
return public_call_state_payload(_call(call_id))
def call_state(context: TenantContext, call_id) -> dict:
return public_call_state_payload(_call(context, call_id))
def call_status(call_id) -> str:
return CallSession.objects.values_list("status", flat=True).get(id=call_id)
def call_status(context: TenantContext, call_id) -> str:
return CallSession.objects.values_list("status", flat=True).get(
id=call_id, organization=context.organization
)
def _set_participant(call_id, side: str, *, state: str, joined: bool | None = None) -> None:
def _set_participant(
context: TenantContext, call_id, side: str, *, state: str, joined: bool | None = None
) -> None:
updates: dict = {"last_connection_state": state}
if joined is True:
updates["joined_at"] = timezone.now()
@@ -49,12 +56,16 @@ def _set_participant(call_id, side: str, *, state: str, joined: bool | None = No
if state == ParticipantConnectionState.CONNECTED:
# Восстановление после reconnect: сбрасываем таймер grace period.
updates["left_at"] = None
CallParticipant.objects.filter(call_session_id=call_id, side=side).update(**updates)
CallParticipant.objects.filter(
call_session_id=call_id,
call_session__organization=context.organization,
side=side,
).update(**updates)
def signaling_join(call_id, side: str) -> dict:
def signaling_join(context: TenantContext, call_id, side: str) -> dict:
"""Участник открыл signaling-соединение: presence + состояние звонка."""
call = _call(call_id)
call = _call(context, call_id)
if call.status not in TERMINAL_CALL_STATUSES:
participant = CallParticipant.objects.filter(call_session_id=call_id, side=side).first()
if participant is not None:
@@ -66,21 +77,27 @@ def signaling_join(call_id, side: str) -> dict:
return public_call_state_payload(call)
def signaling_leave(call_id, side: str) -> dict | None:
def signaling_leave(context: TenantContext, call_id, side: str) -> dict | None:
"""Разрыв WebSocket: participant DISCONNECTED, grace-таймер запускается.
Возвращает None для завершённого звонка — уведомлять уже некого.
"""
call = _call(call_id)
call = _call(context, call_id)
if call.status in TERMINAL_CALL_STATUSES:
return None
_set_participant(call_id, side, state=ParticipantConnectionState.DISCONNECTED, joined=False)
_set_participant(
context,
call_id,
side,
state=ParticipantConnectionState.DISCONNECTED,
joined=False,
)
return public_call_state_payload(call)
def start_negotiation(call_id) -> dict | None:
def start_negotiation(context: TenantContext, call_id) -> dict | None:
"""Первый SDP offer: ACCEPTED → CONNECTING. Возвращает payload при переходе."""
if call_status(call_id) != CallStatus.ACCEPTED:
if call_status(context, call_id) != CallStatus.ACCEPTED:
return None
try:
call = transition_call(call_session_id=call_id, target_status=CallStatus.CONNECTING)
@@ -89,13 +106,16 @@ def start_negotiation(call_id) -> dict | None:
return public_call_state_payload(call)
def report_connection(call_id, side: str, connected: bool) -> tuple[dict | None, bool]:
def report_connection(
context: TenantContext, call_id, side: str, connected: bool
) -> tuple[dict | None, bool]:
"""Участник сообщил состояние WebRTC-соединения.
ACTIVE устанавливается только когда обе стороны подтвердили соединение
(SPEC §5). Возвращает (payload при смене статуса звонка, became_active).
"""
_set_participant(
context,
call_id,
side,
state=ParticipantConnectionState.CONNECTED if connected else ParticipantConnectionState.RECONNECTING,
@@ -103,12 +123,13 @@ def report_connection(call_id, side: str, connected: bool) -> tuple[dict | None,
)
if not connected:
return None, False
status = call_status(call_id)
status = call_status(context, call_id)
if status not in {CallStatus.ACCEPTED, CallStatus.CONNECTING}:
return None, False
both_connected = (
CallParticipant.objects.filter(
call_session_id=call_id,
call_session__organization=context.organization,
last_connection_state=ParticipantConnectionState.CONNECTED,
).count()
== 2
@@ -124,12 +145,13 @@ def report_connection(call_id, side: str, connected: bool) -> tuple[dict | None,
return public_call_state_payload(call), True
def record_metric(call_id, side: str, content: dict) -> None:
def record_metric(context: TenantContext, call_id, side: str, content: dict) -> None:
"""Метрики соединения от участника: только категория маршрута и RTT.
Ничего не ретранслируется собеседнику и не логируется — payload не содержит
медиаконтента, но и типы кандидатов наружу не пересылаются.
"""
_call(context, call_id)
record_call_metric(
call_session_id=call_id,
side=side,
@@ -139,10 +161,10 @@ def record_metric(call_id, side: str, content: dict) -> None:
)
def end_from_signaling(call_id, side: str) -> dict:
def end_from_signaling(context: TenantContext, call_id, side: str) -> dict:
"""Завершение звонка стороной: идемпотентно, целевой статус — по фазе."""
ended_by = SIDE_TO_ENDED_BY.get(side, CallEndedBy.SYSTEM)
call = _call(call_id)
call = _call(context, call_id)
if call.status in TERMINAL_CALL_STATUSES:
return public_call_state_payload(call)
try:
@@ -160,5 +182,5 @@ def end_from_signaling(call_id, side: str) -> dict:
else:
call = transition_call(call_session_id=call_id, target_status=CallStatus.DECLINED, ended_by=ended_by)
except CallInvalidTransition:
call = _call(call_id)
call = _call(context, call_id)
return public_call_state_payload(call)
@@ -3,8 +3,40 @@ from django.test import TestCase
from hub_platform.channels.models import Channel
from hub_platform.conversations.models import ConnectionIdentity, Contact, Conversation
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import EmployeeProfile, EmployeeRole, HumanUser, Organization
from hub_platform.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider
from hub_platform.tenancy.context import TenantActorKind, TenantContext
def create_call_request(*, conversation_id: int, initiator: HumanUser):
"""Keep historical call fixtures concise while exercising explicit tenancy."""
conversation = Conversation.objects.only("organization_id").get(id=conversation_id)
membership = OrganizationMembership.objects.select_related("organization", "user").get(
organization_id=conversation.organization_id,
user=initiator,
)
from hub_platform.calls.services import create_call_request as create_with_context
return create_with_context(
context=TenantContext.for_membership(membership),
conversation_id=conversation_id,
)
def expire_stale_calls(organization: Organization) -> int:
"""Run one organization's maintenance pass in legacy call fixtures."""
from hub_platform.calls.maintenance import expire_stale_calls as expire_with_context
return expire_with_context(
TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM)
)
class CallDomainMixin:
@@ -50,7 +82,7 @@ class CallDomainMixin:
email="support-operator@edevs.tech",
password="support-password",
)
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
@@ -1,10 +1,9 @@
import json
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.calls.models import CallSession, CallStatus, ParticipantSide
from hub_platform.calls.services import create_call_request
from hub_platform.calls.tests.helpers import CallTestCase
from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
from hub_platform.calls.tokens import verify_call_access_token
from hub_platform.conversations.models import ControlMode
@@ -6,10 +6,9 @@ from datetime import timedelta
from unittest import mock
from django.utils import timezone
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient, tenant_context_for
from hub_platform.calls.event_handlers import handle_call_invite_send
from hub_platform.calls.maintenance import expire_stale_calls
from hub_platform.calls.models import (
CallEndedBy,
CallInvite,
@@ -18,11 +17,10 @@ from hub_platform.calls.models import (
InviteDeliveryStatus,
)
from hub_platform.calls.services import (
create_call_request,
open_call_for_identity,
decline_call_for_identity,
)
from hub_platform.calls.tests.helpers import CallTestCase
from hub_platform.calls.tests.helpers import CallTestCase, create_call_request, expire_stale_calls
from hub_platform.calls.tokens import hash_invite_token
from hub_platform.conversations.models import Conversation, Message
from hub_platform.events.models import OutboxEvent
@@ -127,7 +125,10 @@ class CustomerAccessApiTests(CallTestCase):
call = CallSession.objects.get()
from hub_platform.calls.services import cancel_call
cancel_call(call_session=call, user=self.owner)
cancel_call(
context=tenant_context_for(self.owner, self.organization),
call_session=call,
)
response = self._post("/api/v1/calls/access/state/", token)
self.assertEqual(response.status_code, 200)
body = response.json()
@@ -140,7 +141,10 @@ class CustomerAccessApiTests(CallTestCase):
token = self._customer_token()
from hub_platform.calls.services import cancel_call
cancel_call(call_session=CallSession.objects.get(), user=self.owner)
cancel_call(
context=tenant_context_for(self.owner, self.organization),
call_session=CallSession.objects.get(),
)
response = self._post("/api/v1/calls/access/accept/", token)
self.assertEqual(response.status_code, 404)
@@ -213,7 +217,10 @@ class MessengerDeliveryTests(CallTestCase):
with mock.patch(
"hub_platform.calls.event_handlers.transports.send_call_invite", side_effect=fake_send
):
handle_call_invite_send({"callSessionId": str(created.call_session.id)})
handle_call_invite_send(
{"callSessionId": str(created.call_session.id)},
tenant_context_for(self.owner, self.organization),
)
call = CallSession.objects.get()
invite = call.invite
@@ -232,7 +239,10 @@ class MessengerDeliveryTests(CallTestCase):
"hub_platform.calls.event_handlers.transports.send_call_invite", return_value=False
):
with self.assertRaises(Exception):
handle_call_invite_send({"callSessionId": str(created.call_session.id)})
handle_call_invite_send(
{"callSessionId": str(created.call_session.id)},
tenant_context_for(self.owner, self.organization),
)
call = CallSession.objects.get()
self.assertEqual(call.status, CallStatus.REQUESTED)
self.assertEqual(call.invite.delivery_status, InviteDeliveryStatus.PENDING)
@@ -243,8 +253,9 @@ class MessengerDeliveryTests(CallTestCase):
with mock.patch(
"hub_platform.calls.event_handlers.transports.send_call_invite", return_value=True
) as sender:
handle_call_invite_send({"callSessionId": str(created.call_session.id)})
handle_call_invite_send({"callSessionId": str(created.call_session.id)})
context = tenant_context_for(self.owner, self.organization)
handle_call_invite_send({"callSessionId": str(created.call_session.id)}, context)
handle_call_invite_send({"callSessionId": str(created.call_session.id)}, context)
self.assertEqual(sender.call_count, 1)
@@ -257,7 +268,7 @@ class ExpirySweepTests(CallTestCase):
def test_ringing_call_becomes_missed(self) -> None:
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
self._expire_invite(created.call_session)
expire_stale_calls()
expire_stale_calls(self.organization)
call = CallSession.objects.get()
self.assertEqual(call.status, CallStatus.MISSED)
self.assertEqual(call.ended_by, CallEndedBy.TIMEOUT)
@@ -281,7 +292,7 @@ class ExpirySweepTests(CallTestCase):
self.identity.save(update_fields=["connection"])
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
self._expire_invite(created.call_session)
expire_stale_calls()
expire_stale_calls(self.organization)
self.assertEqual(CallSession.objects.get().status, CallStatus.EXPIRED)
def test_accepted_call_without_connection_fails_after_grace(self) -> None:
@@ -291,7 +302,7 @@ class ExpirySweepTests(CallTestCase):
accept_call_by_access_token(token=token)
CallSession.objects.update(accepted_at=timezone.now() - timedelta(hours=1))
expire_stale_calls()
expire_stale_calls(self.organization)
call = CallSession.objects.get()
self.assertEqual(call.status, CallStatus.FAILED)
self.assertEqual(call.failure_code, "CONNECT_TIMEOUT")
@@ -299,8 +310,8 @@ class ExpirySweepTests(CallTestCase):
def test_sweep_is_idempotent(self) -> None:
created = create_call_request(conversation_id=self.conversation.id, initiator=self.owner)
self._expire_invite(created.call_session)
expire_stale_calls()
expire_stale_calls()
expire_stale_calls(self.organization)
expire_stale_calls(self.organization)
self.assertEqual(
Message.objects.filter(
conversation=self.conversation, text="Звонок пропущен: клиент не ответил"
@@ -3,8 +3,9 @@ import uuid
from hub_platform.calls import signaling
from hub_platform.calls.models import CallConnectionType, CallMetric, ParticipantSide
from hub_platform.calls.serializers import call_payload
from hub_platform.calls.services import create_call_request, record_call_metric
from hub_platform.calls.tests.helpers import CallTestCase
from hub_platform.calls.services import record_call_metric
from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
from hub_platform.testing import system_tenant_context
class RecordCallMetricTests(CallTestCase):
@@ -63,6 +64,7 @@ class RecordCallMetricTests(CallTestCase):
def test_signaling_helper_maps_camel_case_payload(self) -> None:
signaling.record_metric(
system_tenant_context(self.organization),
self.call.id,
ParticipantSide.CUSTOMER,
{"localCandidateType": "relay", "remoteCandidateType": "host", "roundTripMs": 88},
@@ -9,8 +9,7 @@ from hub_platform.calls.models import (
CallStatus,
ParticipantSide,
)
from hub_platform.calls.services import create_call_request
from hub_platform.calls.tests.helpers import CallTestCase
from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
from hub_platform.calls.tokens import hash_invite_token
from hub_platform.conversations.models import ControlMode, LifecycleState, Message
@@ -63,7 +62,7 @@ class CallCreationTests(CallTestCase):
self.assertEqual(self.conversation.control_mode, ControlMode.AI)
def test_blocked_operator_is_denied(self) -> None:
self.operator.employee_profile.block()
self.operator.memberships.get(organization=self.organization).block()
with self.assertRaises(CallAccessDenied):
create_call_request(conversation_id=self.conversation.id, initiator=self.operator)
self.assertFalse(CallSession.objects.exists())
@@ -10,7 +10,6 @@ from django.test import TransactionTestCase
from django.utils import timezone
from hub_backend.asgi import application
from hub_platform.calls.maintenance import expire_stale_calls
from hub_platform.calls.models import (
CallParticipant,
CallSession,
@@ -20,10 +19,9 @@ from hub_platform.calls.models import (
)
from hub_platform.calls.services import (
accept_call_by_access_token,
create_call_request,
open_call_for_identity,
)
from hub_platform.calls.tests.helpers import CallDomainMixin
from hub_platform.calls.tests.helpers import CallDomainMixin, create_call_request, expire_stale_calls
from hub_platform.conversations.models import Message
WS_PATH = "/ws/calls/"
@@ -215,7 +213,7 @@ class ReconnectSweepTests(SignalingTestCase):
CallParticipant.objects.filter(
call_session=created.call_session, side=ParticipantSide.CUSTOMER
).update(left_at=timezone.now() - timedelta(hours=1))
expire_stale_calls()
expire_stale_calls(self.organization)
call = CallSession.objects.get()
self.assertEqual(call.status, CallStatus.FAILED)
self.assertEqual(call.failure_code, "PEER_DISCONNECTED")
@@ -244,6 +242,6 @@ class ReconnectSweepTests(SignalingTestCase):
async_to_sync(scenario)()
# Обрыва не осталось (left_at сброшен reconnect'ом до дисконнекта в
# конце сценария) — недавний left_at не старше grace, звонок жив.
expire_stale_calls()
expire_stale_calls(self.organization)
call = CallSession.objects.get()
self.assertEqual(call.status, CallStatus.ACTIVE)
@@ -9,10 +9,9 @@ from hub_platform.calls.lifecycle import transition_call
from hub_platform.calls.models import CallStatus, ParticipantSide
from hub_platform.calls.services import (
authorize_call_access_token,
create_call_request,
resolve_invite,
)
from hub_platform.calls.tests.helpers import CallTestCase
from hub_platform.calls.tests.helpers import CallTestCase, create_call_request
from hub_platform.calls.tokens import (
issue_call_access_token,
verify_call_access_token,
-4
View File
@@ -13,10 +13,6 @@ urlpatterns = [
views.ConversationActiveCallView.as_view(),
name="call-conversation-active",
),
path("invites/resolve/", views.InviteResolveView.as_view(), name="call-invite-resolve"),
path("access/state/", views.CallAccessStateView.as_view(), name="call-access-state"),
path("access/accept/", views.CallAccessAcceptView.as_view(), name="call-access-accept"),
path("access/decline/", views.CallAccessDeclineView.as_view(), name="call-access-decline"),
path("<uuid:call_session_id>/", views.CallDetailView.as_view(), name="call-detail"),
path(
"<uuid:call_session_id>/cancel/",
+27 -11
View File
@@ -47,7 +47,9 @@ class CallCreateView(APIView):
def post(self, request: Request, conversation_id: int) -> Response:
try:
created = create_call_request(conversation_id=conversation_id, initiator=request.user)
created = create_call_request(
context=request.tenant_context, conversation_id=conversation_id
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
except CallAccessDenied as error:
@@ -55,7 +57,9 @@ class CallCreateView(APIView):
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
call = _call_queryset().get(id=created.call_session.id)
call = _call_queryset().get(
id=created.call_session.id, organization=request.tenant_context.organization
)
record_audit_event(
action="calls.requested",
actor=request.user,
@@ -80,11 +84,13 @@ class CallDetailView(APIView):
def get(self, request: Request, call_session_id) -> Response:
try:
call = _call_queryset().get(id=call_session_id)
call = _call_queryset().get(
id=call_session_id, organization=request.tenant_context.organization
)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
try:
ensure_call_access(user=request.user, call_session=call)
ensure_call_access(user=request.tenant_context.membership, call_session=call)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
return Response({"call": call_payload(call)})
@@ -95,8 +101,10 @@ class StaffAccessTokenView(APIView):
def post(self, request: Request, call_session_id) -> Response:
try:
call = _call_queryset().get(id=call_session_id)
token = issue_staff_access_token(call_session=call, user=request.user)
call = _call_queryset().get(
id=call_session_id, organization=request.tenant_context.organization
)
token = issue_staff_access_token(context=request.tenant_context, call_session=call)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
except CallAccessDenied as error:
@@ -111,15 +119,19 @@ class CallCancelView(APIView):
def post(self, request: Request, call_session_id) -> Response:
try:
call = _call_queryset().get(id=call_session_id)
cancel_call(call_session=call, user=request.user)
call = _call_queryset().get(
id=call_session_id, organization=request.tenant_context.organization
)
cancel_call(context=request.tenant_context, call_session=call)
except CallSession.DoesNotExist:
return Response({"detail": "Звонок не найден"}, status=404)
except CallAccessDenied as error:
return Response({"detail": str(error)}, status=403)
except CallConflict as error:
return Response({"detail": str(error)}, status=409)
call = _call_queryset().get(id=call_session_id)
call = _call_queryset().get(
id=call_session_id, organization=request.tenant_context.organization
)
record_audit_event(
action="calls.cancelled",
actor=request.user,
@@ -137,8 +149,12 @@ class ConversationActiveCallView(APIView):
def get(self, request: Request, conversation_id: int) -> Response:
try:
conversation = Conversation.objects.select_related("channel").get(id=conversation_id)
ensure_conversation_call_access(user=request.user, conversation=conversation)
conversation = Conversation.objects.select_related("channel").get(
id=conversation_id, organization=request.tenant_context.organization
)
ensure_conversation_call_access(
user=request.tenant_context.membership, conversation=conversation
)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
except CallAccessDenied as error:
@@ -48,11 +48,15 @@ CHANNELS = [
class Command(BaseCommand):
help = "Seed processing channels (edevs, foxray, firepage)."
def add_arguments(self, parser) -> None:
parser.add_argument("--organization", required=True, help="Organization public UUID")
@transaction.atomic
def handle(self, *args: object, **options: object) -> None:
organization = Organization.objects.first()
if organization is None:
self.stderr.write("no organization — run bootstrap_owner first")
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError):
self.stderr.write("organization not found")
return
sales = Department.objects.filter(organization=organization, code="sales").first()
provider = (
@@ -1,15 +1,16 @@
from django.db.models import QuerySet
from hub_platform.channels.models import Channel
from hub_platform.tenancy.context import TenantContext
def channels_for_organization(organization_id: int) -> QuerySet[Channel]:
def channels_for_context(context: TenantContext) -> QuerySet[Channel]:
return (
Channel.objects.filter(organization_id=organization_id)
Channel.objects.filter(organization_id=context.organization_id)
.select_related("product", "department", "provider_integration", "ai_agent")
.order_by("name")
)
def channel_for_organization(*, organization_id: int, channel_id: int) -> Channel:
return channels_for_organization(organization_id).get(id=channel_id)
def channel_for_context(*, context: TenantContext, channel_id: int) -> Channel:
return channels_for_context(context).get(id=channel_id)
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from hub_platform.channels.models import Channel
from hub_platform.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -8,7 +9,11 @@ class ChannelInput:
name: str
def update_channel(*, channel: Channel, data: ChannelInput) -> Channel:
def update_channel(
*, context: TenantContext, channel: Channel, data: ChannelInput
) -> Channel:
if channel.organization_id != context.organization_id:
raise ValueError("Channel belongs to another organization")
# Минимум: переименование канала. code/model/флаги политики не трогаем —
# смена code сломала бы embed-сниппеты (data-channel) и URL.
channel.name = data.name
+3 -3
View File
@@ -1,11 +1,11 @@
import json
from django.test import TestCase
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import EmployeeProfile, EmployeeRole, HumanUser, Organization
from hub_platform.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
def _make_channel(organization, *, code, name):
@@ -64,7 +64,7 @@ class ChannelRenamePermissionTests(TestCase):
organization = Organization.objects.get(slug="edevs")
self.channel = _make_channel(organization, code="firepage-sales", name="FirePage — продажи")
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=operator,
organization=organization,
role=EmployeeRole.EMPLOYEE,
+12 -8
View File
@@ -6,7 +6,7 @@ from hub_platform.ai.provider.base import ProviderError
from hub_platform.api.permissions import HasCapability
from hub_platform.channels.models import Channel
from hub_platform.channels.runtime import run_channel_turn
from hub_platform.channels.selectors import channel_for_organization, channels_for_organization
from hub_platform.channels.selectors import channel_for_context, channels_for_context
from hub_platform.channels.serializers import channel_payload
from hub_platform.channels.services import ChannelInput, update_channel
from hub_platform.identity.audit import record_audit_event
@@ -18,7 +18,7 @@ class ChannelListView(APIView):
require_organization_scope = True
def get(self, request: Request) -> Response:
items = channels_for_organization(request.user.employee_profile.organization_id)
items = channels_for_context(request.tenant_context)
return Response({"items": [channel_payload(channel) for channel in items]})
@@ -29,19 +29,23 @@ class ChannelDetailView(APIView):
def patch(self, request: Request, channel_id: int) -> Response:
try:
channel = channel_for_organization(
organization_id=request.user.employee_profile.organization_id, channel_id=channel_id
channel = channel_for_context(
context=request.tenant_context, channel_id=channel_id
)
except Channel.DoesNotExist:
return Response({"detail": "Канал не найден"}, status=404)
name = str(request.data.get("name", channel.name)).strip()
if not name:
return Response({"detail": "Название канала не может быть пустым"}, status=400)
channel = update_channel(channel=channel, data=ChannelInput(name=name))
channel = update_channel(
context=request.tenant_context,
channel=channel,
data=ChannelInput(name=name),
)
record_audit_event(
action="channels.channel_renamed",
actor=request.user,
organization=request.user.employee_profile.organization,
organization=request.tenant_context.organization,
object_type="Channel",
object_id=str(channel.id),
request=request,
@@ -56,8 +60,8 @@ class ChannelTestChatView(APIView):
def post(self, request: Request, channel_id: int) -> Response:
try:
channel = channel_for_organization(
organization_id=request.user.employee_profile.organization_id, channel_id=channel_id
channel = channel_for_context(
context=request.tenant_context, channel_id=channel_id
)
except Channel.DoesNotExist:
return Response({"detail": "Канал не найден"}, status=404)
@@ -14,9 +14,10 @@ from django.utils import timezone
from hub_platform.ai.models import AIAgent, LlmInvocation
from hub_platform.conversations.models import Conversation, ControlMode, LifecycleState
from hub_platform.conversations.stats import _ACTIVE_WINDOW, _window
from hub_platform.identity.models import Department, DepartmentStatus, EmployeeProfile
from hub_platform.identity.models import Department, DepartmentStatus, OrganizationMembership
from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus
from hub_platform.orders.models import FulfillmentStatus, Order, PaymentStatus
from hub_platform.tenancy.context import TenantContext
_DEPT_ROUTE = {"sales": "salesOverview", "support": "supportOverview"}
@@ -36,7 +37,8 @@ def _minutes_since(moment, now) -> int:
return max(0, int((now - moment).total_seconds() // 60))
def command_center_overview(organization_id: int, period: str) -> dict:
def command_center_overview(context: TenantContext, period: str) -> dict:
organization_id = context.organization_id
now = timezone.now()
start, _ = _window(period, now)
@@ -47,7 +49,7 @@ def command_center_overview(organization_id: int, period: str) -> dict:
revenue = paid.aggregate(total=Sum("amount_minor"))["total"] or 0
employees_by_dept = dict(
EmployeeProfile.objects.filter(
OrganizationMembership.objects.filter(
organization_id=organization_id, blocked_at__isnull=True, primary_department__isnull=False
)
.values_list("primary_department_id")
@@ -32,6 +32,7 @@ from hub_platform.conversations.transports.base import InboundMessage
from hub_platform.events.models import InboxEvent
from hub_platform.notifications.models import NotificationAudience, NotificationType
from hub_platform.notifications.services import notify
from hub_platform.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
@@ -64,6 +65,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if channel is None:
logger.warning("Integration %s has no channel — inbound dropped", integration.id)
return
context = TenantContext.for_resource(channel.organization)
source = f"{integration.provider.lower()}:{integration.id}"
if _already_processed(source, inbound.external_id, inbound.text):
return
@@ -130,7 +132,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if is_new:
notify(
organization=channel.organization,
context=context,
department=channel.department,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
@@ -145,7 +147,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
# Клиент написал в диалог, который ведёт оператор или который в очереди — пуш.
operator = conversation.assigned_operator
notify(
organization=channel.organization,
context=context,
department=channel.department,
type=NotificationType.DIALOG_NEW_MESSAGE,
audience=NotificationAudience.USER if operator else NotificationAudience.OPERATORS,
@@ -187,7 +189,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
fallback = "Извините, прямо сейчас не получается ответить. Я передал ваш вопрос специалисту — он скоро подключится."
Message.objects.create(conversation=conversation, author_type=MessageAuthor.AI, text=fallback)
notify(
organization=channel.organization,
context=context,
department=channel.department,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
@@ -218,7 +220,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if handoff:
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="AI передал диалог оператору")
notify(
organization=channel.organization,
context=context,
department=channel.department,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
@@ -11,9 +11,13 @@ logger = logging.getLogger(__name__)
AUTOCLOSE_DAYS = 7
def close_stale_conversations(days: int = AUTOCLOSE_DAYS) -> int:
def close_stale_conversations(context, days: int = AUTOCLOSE_DAYS) -> int:
cutoff = timezone.now() - timedelta(days=days)
closed = Conversation.objects.filter(lifecycle=LifecycleState.OPEN, last_activity_at__lte=cutoff).update(
closed = Conversation.objects.filter(
organization=context.organization,
lifecycle=LifecycleState.OPEN,
last_activity_at__lte=cutoff,
).update(
lifecycle=LifecycleState.CLOSED
)
if closed:
@@ -7,7 +7,7 @@ from hub_platform.integrations.models import Integration
logger = logging.getLogger(__name__)
def poll_all_messengers() -> int:
def poll_all_messengers(context) -> int:
"""Poll every messenger connection bound to a channel; ingest inbound. Returns count."""
# Сервисные боты уведомлений поллятся отдельно (notifications.binding).
# Фильтр по config — в Python: JSON-lookup в .exclude() отбрасывает и строки
@@ -15,7 +15,9 @@ def poll_all_messengers() -> int:
integrations = [
integration
for integration in Integration.objects.filter(
provider__in=transports.SUPPORTED_PROVIDERS, channel__isnull=False
organization=context.organization,
provider__in=transports.SUPPORTED_PROVIDERS,
channel__isnull=False,
).exclude(secret="")
if integration.config.get("purpose") != "notifications"
]
@@ -1,11 +1,12 @@
from django.db.models import F, Max, QuerySet
from hub_platform.conversations.models import Conversation
from hub_platform.tenancy.context import TenantContext
def conversations_for_organization(organization_id: int) -> QuerySet[Conversation]:
def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
return (
Conversation.objects.filter(organization_id=organization_id)
Conversation.objects.filter(organization_id=context.organization_id)
.select_related(
"channel",
"channel__product",
@@ -22,5 +23,5 @@ def conversations_for_organization(organization_id: int) -> QuerySet[Conversatio
)
def conversation_for_organization(*, organization_id: int, conversation_id: int) -> Conversation:
return conversations_for_organization(organization_id).get(id=conversation_id)
def conversation_for_context(*, context: TenantContext, conversation_id: int) -> Conversation:
return conversations_for_context(context).get(id=conversation_id)
@@ -13,6 +13,7 @@ from hub_platform.conversations.models import (
MessageKind,
)
from hub_platform.integrations.models import IntegrationProvider
from hub_platform.tenancy.context import TenantContext
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
@@ -27,14 +28,19 @@ def _operator_label(operator) -> str:
@transaction.atomic
def claim_conversation(*, conversation_id: int, operator) -> Conversation:
def claim_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
# Атомарный перехват у AI (ADR-HUB-0003): только один оператор забирает диалог.
conversation = Conversation.objects.select_for_update().get(id=conversation_id)
return claim_locked_conversation(conversation=conversation, operator=operator)
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
return claim_locked_conversation(context=context, conversation=conversation)
def claim_locked_conversation(*, conversation: Conversation, operator) -> Conversation:
def claim_locked_conversation(*, context: TenantContext, conversation: Conversation) -> Conversation:
"""Claim an already locked conversation inside the caller's transaction."""
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise ClaimError("Диалог недоступен")
if (
conversation.control_mode == ControlMode.HUMAN
and conversation.assigned_operator_id
@@ -54,8 +60,10 @@ def claim_locked_conversation(*, conversation: Conversation, operator) -> Conver
@transaction.atomic
def release_to_ai(*, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(id=conversation_id)
def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
conversation.control_mode = ControlMode.AI
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.AI
@@ -65,9 +73,11 @@ def release_to_ai(*, conversation_id: int) -> Conversation:
@transaction.atomic
def return_to_queue(*, conversation_id: int) -> Conversation:
def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversation:
# Оператор возвращает диалог в общую очередь (ADR-HUB-0003): снят с себя, ждёт оператора.
conversation = Conversation.objects.select_for_update().get(id=conversation_id)
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.OPERATOR
@@ -76,7 +86,12 @@ def return_to_queue(*, conversation_id: int) -> Conversation:
return conversation
def post_operator_message(*, conversation: Conversation, operator, text: str) -> Message:
def post_operator_message(
*, context: TenantContext, conversation: Conversation, text: str
) -> Message:
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
message = Message.objects.create(
conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
)
@@ -97,9 +112,12 @@ def post_operator_message(*, conversation: Conversation, operator, text: str) ->
return message
def request_contact(*, conversation: Conversation, operator) -> Message:
def request_contact(*, context: TenantContext, conversation: Conversation) -> Message:
"""Запрос контакта у клиента: TG/MAX — сообщение с кнопкой «Поделиться
контактом», Web — виджет рисует форму телефона по kind=contact_request."""
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
message = Message.objects.create(
@@ -126,8 +144,10 @@ def request_contact(*, conversation: Conversation, operator) -> Message:
@transaction.atomic
def close_conversation(*, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(id=conversation_id)
def close_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
conversation.lifecycle = LifecycleState.CLOSED
conversation.expected_responder = ExpectedResponder.NOBODY
conversation.save(update_fields=["lifecycle", "expected_responder"])
@@ -14,7 +14,7 @@ from django.db.models.functions import TruncDate, TruncHour
from django.utils import timezone
from hub_platform.ai.models import LlmInvocation
from hub_platform.channels.selectors import channels_for_organization
from hub_platform.channels.selectors import channels_for_context
from hub_platform.conversations.models import (
Conversation,
ControlMode,
@@ -83,10 +83,11 @@ def _ai_cost(
def sales_overview_stats(
organization_id: int,
context,
period: str,
department_ids: set[int] | None = None,
) -> dict:
organization_id = context.organization_id
now = timezone.now()
start, prev_start = _window(period, now)
@@ -156,7 +157,7 @@ def sales_overview_stats(
by_channel: list[dict] = []
by_product: dict[str, dict] = {}
channels = channels_for_organization(organization_id)
channels = channels_for_context(context)
if department_ids is not None:
channels = channels.filter(department_id__in=department_ids)
for channel in channels:
@@ -1,5 +1,5 @@
from django.test import TestCase
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.channels.models import Channel
from hub_platform.conversations.models import Contact, Conversation
@@ -8,10 +8,10 @@ from hub_platform.identity.models import (
AccessProfileCapability,
Department,
EmployeeAccessAssignment,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
@@ -53,9 +53,9 @@ class ConversationAuthorizationTests(TestCase):
def _employee(
self, email: str, role: str, department: Department | None = None
) -> EmployeeProfile:
) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return EmployeeProfile.objects.create(
return OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
@@ -96,4 +96,3 @@ class ConversationAuthorizationTests(TestCase):
self.client.force_authenticate(self.unassigned.user)
response = self.client.get("/api/v1/conversations/")
self.assertEqual(response.status_code, 403)
@@ -3,7 +3,7 @@ import json
from unittest import mock
from django.test import TestCase
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.ai.limits import LimitExceeded
from hub_platform.ai.models import AIAgent
@@ -7,8 +7,8 @@ from hub_platform.conversations.clients import client_detail, clients_overview
from hub_platform.conversations.command import command_center_overview
from hub_platform.conversations.models import Contact, ControlMode, Conversation, ConversationRead
from hub_platform.conversations.selectors import (
conversation_for_organization,
conversations_for_organization,
conversation_for_context,
conversations_for_context,
)
from hub_platform.conversations.serializers import conversation_payload, message_payload
from hub_platform.conversations.services import (
@@ -35,7 +35,7 @@ class _Base(APIView):
required_capability = "conversations.view"
def _org(self, request: Request):
return request.user.employee_profile.organization
return request.tenant_context.organization
def _conversation(
self,
@@ -43,10 +43,10 @@ class _Base(APIView):
conversation_id: int,
capability: str = "conversations.view",
) -> Conversation:
conversation = conversation_for_organization(
organization_id=self._org(request).id, conversation_id=conversation_id
conversation = conversation_for_context(
context=request.tenant_context, conversation_id=conversation_id
)
if not require_capability(request.user, capability, conversation):
if not require_capability(request.tenant_context.membership, capability, conversation):
raise Conversation.DoesNotExist
return conversation
@@ -63,8 +63,8 @@ class _Base(APIView):
class ConversationListView(_Base):
def get(self, request: Request) -> Response:
items = conversations_for_organization(self._org(request).id)
department_ids = accessible_department_ids(request.user, self.required_capability)
items = conversations_for_context(request.tenant_context)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
if department_ids is not None:
items = items.filter(channel__department_id__in=department_ids)
department = request.query_params.get("department")
@@ -110,7 +110,9 @@ class ConversationClaimView(_Base):
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
try:
conversation = claim_conversation(conversation_id=conversation_id, operator=request.user)
conversation = claim_conversation(
context=request.tenant_context, conversation_id=conversation_id
)
except ClaimError as error:
return Response({"detail": str(error)}, status=409)
self._audit(request, "claimed", conversation)
@@ -125,7 +127,9 @@ class ConversationReleaseView(_Base):
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
conversation = release_to_ai(conversation_id=conversation_id)
conversation = release_to_ai(
context=request.tenant_context, conversation_id=conversation_id
)
self._audit(request, "released_to_ai", conversation)
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
@@ -138,7 +142,9 @@ class ConversationReturnQueueView(_Base):
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
conversation = return_to_queue(conversation_id=conversation_id)
conversation = return_to_queue(
context=request.tenant_context, conversation_id=conversation_id
)
self._audit(request, "returned_to_queue", conversation)
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
@@ -148,8 +154,8 @@ class ConversationStatsView(_Base):
period = request.query_params.get("period", "today")
if period not in ("today", "d7", "d30"):
period = "today"
department_ids = accessible_department_ids(request.user, self.required_capability)
return Response(sales_overview_stats(self._org(request).id, period, department_ids))
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
return Response(sales_overview_stats(request.tenant_context, period, department_ids))
class CommandOverviewView(_Base):
@@ -160,14 +166,14 @@ class CommandOverviewView(_Base):
period = request.query_params.get("period", "today")
if period not in ("today", "d7", "d30"):
period = "today"
return Response(command_center_overview(self._org(request).id, period))
return Response(command_center_overview(request.tenant_context, period))
class ClientsView(_Base):
required_capability = "customers.view"
def get(self, request: Request) -> Response:
department_ids = accessible_department_ids(request.user, self.required_capability)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
return Response({"items": clients_overview(self._org(request).id, department_ids)})
@@ -176,7 +182,7 @@ class ClientDetailView(_Base):
def get(self, request: Request, contact_id: int) -> Response:
try:
department_ids = accessible_department_ids(request.user, self.required_capability)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
return Response(
{"client": client_detail(self._org(request).id, contact_id, department_ids)}
)
@@ -198,13 +204,15 @@ class ConversationMessageView(_Base):
if conversation.control_mode != ControlMode.HUMAN:
return Response({"detail": "Сначала перехватите диалог"}, status=409)
manager_override = authorize(
request.user,
request.tenant_context.membership,
self.required_capability,
ResourceScope(conversation.organization_id),
)
if conversation.assigned_operator_id != request.user.id and not manager_override:
return Response({"detail": "Диалог ведёт другой оператор"}, status=409)
message = post_operator_message(conversation=conversation, operator=request.user, text=text)
message = post_operator_message(
context=request.tenant_context, conversation=conversation, text=text
)
return Response({"message": message_payload(message)}, status=201)
@@ -220,7 +228,7 @@ class ConversationRequestContactView(_Base):
return Response({"detail": "У диалога нет канала для запроса контакта"}, status=409)
if conversation.contact.phone:
return Response({"detail": "Контакт уже получен"}, status=409)
message = request_contact(conversation=conversation, operator=request.user)
message = request_contact(context=request.tenant_context, conversation=conversation)
self._audit(request, "contact_requested", conversation)
return Response({"message": message_payload(message)}, status=201)
@@ -233,6 +241,8 @@ class ConversationCloseView(_Base):
self._conversation(request, conversation_id, self.required_capability)
except Conversation.DoesNotExist:
return Response({"detail": "Диалог не найден"}, status=404)
conversation = close_conversation(conversation_id=conversation_id)
conversation = close_conversation(
context=request.tenant_context, conversation_id=conversation_id
)
self._audit(request, "closed", conversation)
return Response({"conversation": conversation_payload(conversation, with_messages=True)})
+9 -5
View File
@@ -1,9 +1,13 @@
import logging
from typing import Callable
from hub_platform.events.models import OutboxEvent
from hub_platform.events.services import tenant_context_for_event
from hub_platform.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
EventHandler = Callable[[dict], None]
EventHandler = Callable[[dict, TenantContext | None], None]
_REGISTRY: dict[str, EventHandler] = {}
@@ -15,9 +19,9 @@ def register(event_type: str) -> Callable[[EventHandler], EventHandler]:
return decorator
def dispatch(event_type: str, payload: dict) -> None:
handler = _REGISTRY.get(event_type)
def dispatch(event: OutboxEvent) -> None:
handler = _REGISTRY.get(event.event_type)
if handler is None:
logger.info("No handler registered for event %s", event_type)
logger.info("No handler registered for event %s", event.event_type)
return
handler(payload)
handler(event.payload, tenant_context_for_event(event))
@@ -10,7 +10,9 @@ from hub_platform.conversations.poller import poll_all_messengers
from hub_platform.events.handlers import dispatch
from hub_platform.events.models import OutboxStatus
from hub_platform.events.services import claim_next_outbox_event, mark_retry
from hub_platform.identity.models import Organization
from hub_platform.notifications.binding import poll_notifier_bots
from hub_platform.tenancy.context import TenantActorKind, TenantContext
logger = logging.getLogger(__name__)
@@ -22,6 +24,13 @@ CALL_SWEEP_INTERVAL = 10.0 # seconds between call timeout sweeps (invite expiry
class Command(BaseCommand):
help = "Runs the local domain event worker (outbox dispatch + messenger inbound polling)."
@staticmethod
def _tenant_contexts():
for organization in Organization.objects.order_by("id").iterator():
yield TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
def handle(self, *args: object, **options: object) -> None:
self.stdout.write("Hub worker started")
last_poll = 0.0
@@ -32,7 +41,7 @@ class Command(BaseCommand):
if event is not None:
try:
logger.info("Processing outbox event %s", event.id)
dispatch(event.event_type, event.payload)
dispatch(event)
event.status = OutboxStatus.PROCESSED
event.processed_at = timezone.now()
event.save(update_fields=["status", "processed_at"])
@@ -45,23 +54,27 @@ class Command(BaseCommand):
if now - last_poll >= MESSENGER_POLL_INTERVAL:
last_poll = now
try:
poll_all_messengers()
for context in self._tenant_contexts():
poll_all_messengers(context)
except Exception: # pragma: no cover
logger.exception("Messenger polling cycle failed")
try:
poll_notifier_bots()
for context in self._tenant_contexts():
poll_notifier_bots(context)
except Exception: # pragma: no cover
logger.exception("Notifier polling cycle failed")
if now - last_call_sweep >= CALL_SWEEP_INTERVAL:
last_call_sweep = now
try:
expire_stale_calls()
for context in self._tenant_contexts():
expire_stale_calls(context)
except Exception: # pragma: no cover
logger.exception("Call sweep cycle failed")
if now - last_maintenance >= MAINTENANCE_INTERVAL:
last_maintenance = now
try:
close_stale_conversations()
for context in self._tenant_contexts():
close_stale_conversations(context)
except Exception: # pragma: no cover
logger.exception("Maintenance cycle failed")
time.sleep(1)
@@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("events", "0002_rename_events_outb_status_881ef5_idx_events_outb_status_9dabce_idx_and_more"),
("identity", "0012_membership_identity"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="outboxevent",
name="actor_kind",
field=models.CharField(blank=True, max_length=16),
),
migrations.AddField(
model_name="outboxevent",
name="actor_user",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="outbox_events", to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name="outboxevent",
name="membership",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="outbox_events", to="identity.organizationmembership"),
),
migrations.AddField(
model_name="outboxevent",
name="organization",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="outbox_events", to="identity.organization"),
),
migrations.AddField(
model_name="outboxevent",
name="ownership",
field=models.CharField(choices=[("PLATFORM", "Platform"), ("TENANT", "Tenant")], db_index=True, default="PLATFORM", max_length=16),
),
]
@@ -1,5 +1,6 @@
import uuid
from django.conf import settings
from django.db import models
from django.utils import timezone
@@ -12,12 +13,45 @@ class OutboxStatus(models.TextChoices):
DEAD_LETTER = "DEAD_LETTER", "Dead letter"
class EventOwnership(models.TextChoices):
PLATFORM = "PLATFORM", "Platform"
TENANT = "TENANT", "Tenant"
class OutboxEvent(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
aggregate_type = models.CharField(max_length=128)
aggregate_id = models.CharField(max_length=128)
event_type = models.CharField(max_length=128)
payload = models.JSONField(default=dict)
ownership = models.CharField(
max_length=16,
choices=EventOwnership.choices,
default=EventOwnership.PLATFORM,
db_index=True,
)
organization = models.ForeignKey(
"identity.Organization",
on_delete=models.PROTECT,
related_name="outbox_events",
null=True,
blank=True,
)
membership = models.ForeignKey(
"identity.OrganizationMembership",
on_delete=models.SET_NULL,
related_name="outbox_events",
null=True,
blank=True,
)
actor_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="outbox_events",
null=True,
blank=True,
)
actor_kind = models.CharField(max_length=16, blank=True)
status = models.CharField(
max_length=32,
choices=OutboxStatus.choices,
+48 -2
View File
@@ -6,7 +6,9 @@ from django.db import transaction
from django.utils import timezone
from hub_platform.events.context import get_correlation_id
from hub_platform.events.models import OutboxEvent, OutboxStatus
from hub_platform.events.models import EventOwnership, OutboxEvent, OutboxStatus
from hub_platform.identity.models import Organization, OrganizationMembership
from hub_platform.tenancy.context import TenantActorKind, TenantContext
@dataclass(frozen=True)
@@ -15,15 +17,59 @@ class DomainEvent:
aggregate_id: str
event_type: str
payload: dict[str, Any]
tenant_context: TenantContext | None = None
def enqueue_event(event: DomainEvent) -> OutboxEvent:
context = event.tenant_context
return OutboxEvent.objects.create(
aggregate_type=event.aggregate_type,
aggregate_id=event.aggregate_id,
event_type=event.event_type,
payload=event.payload,
correlation_id=get_correlation_id(),
ownership=EventOwnership.TENANT if context is not None else EventOwnership.PLATFORM,
organization=context.organization if context is not None else None,
membership=context.membership if context is not None else None,
actor_user=context.actor_user if context is not None else None,
actor_kind=context.actor_kind if context is not None else "",
correlation_id=context.correlation_id if context is not None else get_correlation_id(),
)
def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
if event.ownership == EventOwnership.PLATFORM:
if event.organization_id or event.membership_id:
raise ValueError("Platform event cannot carry tenant ownership")
return None
if event.organization_id is None:
raise ValueError("Tenant event has no organization")
organization = Organization.objects.get(pk=event.organization_id)
membership = None
actor_user = None
if event.membership_id is not None:
membership = OrganizationMembership.objects.select_related("user", "organization").get(
pk=event.membership_id,
organization=organization,
blocked_at__isnull=True,
user__is_active=True,
)
actor_user = membership.user
if event.actor_user_id not in {None, membership.user_id}:
raise ValueError("Tenant event actor does not match membership")
elif event.actor_user_id is not None:
actor_user = event.actor_user
try:
actor_kind = TenantActorKind(event.actor_kind)
except ValueError as error:
raise ValueError("Tenant event has invalid actor kind") from error
if actor_kind == TenantActorKind.HUMAN and membership is None:
raise ValueError("Human tenant event has no membership")
return TenantContext(
organization=organization,
membership=membership,
actor_user=actor_user,
actor_kind=actor_kind,
correlation_id=event.correlation_id,
)
@@ -6,7 +6,7 @@ from hub_platform.identity.models import (
AccessProfileCapability,
Department,
EmployeeAccessAssignment,
EmployeeProfile,
OrganizationMembership,
)
@@ -35,8 +35,8 @@ SYSTEM_PROFILE_CAPABILITIES = {
def ensure_system_assignment(
*,
employee: EmployeeProfile,
assigned_by: EmployeeProfile,
employee: OrganizationMembership,
assigned_by: OrganizationMembership,
department: Department,
profile_name: str,
) -> EmployeeAccessAssignment:
@@ -70,4 +70,3 @@ def ensure_system_assignment(
department=department,
assigned_by=assigned_by,
)
@@ -6,8 +6,8 @@ from hub_platform.identity.models import (
Department,
DepartmentStatus,
EmployeeAccessAssignment,
EmployeeProfile,
EmployeeRole,
OrganizationMembership,
)
@@ -19,7 +19,7 @@ def allowed_profile_scopes(capability_codes: list[str]) -> set[str]:
def create_access_assignment(
*, actor: EmployeeProfile, employee: EmployeeProfile, payload: dict
*, actor: OrganizationMembership, employee: OrganizationMembership, payload: dict
) -> EmployeeAccessAssignment:
if employee.role != EmployeeRole.EMPLOYEE:
raise ValidationError("Access assignments are only allowed for EMPLOYEE")
@@ -22,13 +22,13 @@ from hub_platform.identity.models import (
AccessProfile,
AccessProfileCapability,
EmployeeAccessAssignment,
EmployeeProfile,
OrganizationMembership,
)
from hub_platform.identity.policy import can_administer_access
def _manager_required(request: Request) -> Response | None:
if can_administer_access(request.user):
if can_administer_access(request.tenant_context.membership):
return None
return Response({"detail": "Employee access management is not allowed"}, status=403)
@@ -49,7 +49,7 @@ class AccessProfileListCreateView(APIView):
if (denied := _manager_required(request)) is not None:
return denied
profiles = AccessProfile.objects.filter(
organization=request.user.employee_profile.organization
organization=request.tenant_context.organization
).prefetch_related("capability_links").annotate(
active_assignment_count=Count(
"assignments",
@@ -66,7 +66,7 @@ class AccessProfileListCreateView(APIView):
codes, error = capability_codes(request.data.get("capabilities", []))
if error:
return Response({"detail": error}, status=400)
actor = request.user.employee_profile
actor = request.tenant_context.membership
name = str(request.data.get("name", "")).strip()
if not name:
return Response({"detail": "Access profile name is required"}, status=400)
@@ -102,7 +102,7 @@ class AccessProfileDetailView(APIView):
def _profile(self, request: Request, profile_id: int) -> AccessProfile | None:
return (
AccessProfile.objects.filter(
id=profile_id, organization=request.user.employee_profile.organization
id=profile_id, organization=request.tenant_context.organization
)
.prefetch_related("capability_links")
.first()
@@ -187,8 +187,8 @@ class EmployeeAccessAssignmentView(APIView):
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
target = EmployeeProfile.objects.filter(
actor = request.tenant_context.membership
target = OrganizationMembership.objects.filter(
user_id=user_id, organization=actor.organization
).first()
if target is None:
@@ -219,7 +219,7 @@ class EmployeeAccessAssignmentRevokeView(APIView):
permission_classes = [IsAuthenticated]
def delete(self, request: Request, user_id: int, assignment_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
assignment = EmployeeAccessAssignment.objects.select_related(
"employee", "access_profile", "department"
).filter(
@@ -1,40 +1,46 @@
from rest_framework.request import Request
from hub_platform.identity.membership_context import single_membership_for_user
from hub_platform.identity.models import HumanUser
from hub_platform.identity.policy import get_effective_access
from hub_platform.identity.sessions import revoke_user_sessions
def _user_payload(user: HumanUser) -> dict[str, object]:
profile = single_membership_for_user(user)
if profile is None:
raise ValueError("An explicit organization context is required")
payload = {
memberships = []
active_memberships = (
user.memberships.filter(blocked_at__isnull=True)
.select_related("organization", "primary_department")
.order_by("organization__name", "id")
)
for membership in active_memberships:
membership_payload = {
"id": membership.id,
"organizationPublicId": str(membership.organization.public_id),
"organization": membership.organization.slug,
"organizationName": membership.organization.name,
"role": membership.role,
"positionTitle": membership.position_title,
"department": (
membership.primary_department.code if membership.primary_department else None
),
"totpRequired": membership.totp_required,
}
membership_payload.update(get_effective_access(membership))
memberships.append(membership_payload)
return {
"id": user.id,
"email": user.email,
"fullName": user.full_name,
"role": profile.role,
"positionTitle": profile.position_title,
"organizationName": profile.organization.name,
"organization": profile.organization.slug,
"department": profile.primary_department.code if profile.primary_department else None,
"mustChangePassword": user.must_change_password,
"totpRequired": profile.totp_required,
"totpEnabled": user.totp_enabled,
"memberships": memberships,
}
payload.update(get_effective_access(profile))
return payload
def _challenge_payload(user: HumanUser) -> dict[str, object]:
profile = single_membership_for_user(user)
if profile is None:
raise ValueError("An explicit organization context is required")
return {
"email": user.email,
"fullName": user.full_name,
"role": profile.role,
}
@@ -38,13 +38,7 @@ class PasswordResetRequestView(APIView):
email = HumanUser.objects.normalize_email(str(request.data.get("email", "")).strip())
if email:
user = HumanUser.objects.filter(email__iexact=email, is_active=True).first()
active_memberships = (
user.memberships.filter(blocked_at__isnull=True).select_related("organization")
if user is not None
else None
)
profile = active_memberships.first() if active_memberships is not None else None
if user is not None and profile is not None:
if user is not None:
enqueue_event(
DomainEvent(
aggregate_type="HumanUser",
@@ -56,7 +50,6 @@ class PasswordResetRequestView(APIView):
record_audit_event(
action="identity.password_reset_requested",
actor=user,
organization=profile.organization,
request=request,
)
else:
@@ -103,12 +96,9 @@ class PasswordResetConfirmView(APIView):
user.set_password(new_password)
user.must_change_password = False
user.save(update_fields=["password", "must_change_password"])
memberships = user.memberships.select_related("organization")
profile = memberships.first() if memberships.count() == 1 else None
record_audit_event(
action="identity.password_reset_completed",
actor=user,
organization=profile.organization if profile is not None else None,
request=request,
)
return Response({"ok": True})
@@ -28,11 +28,9 @@ class ProfileUpdateView(APIView):
request.user.full_name = full_name
request.user.email = email
request.user.save(update_fields=["full_name", "email"])
profile = request.user.employee_profile
record_audit_event(
action="identity.profile_updated",
actor=request.user,
organization=profile.organization,
object_type="HumanUser",
object_id=str(request.user.id),
request=request,
@@ -57,12 +55,10 @@ class ProfilePasswordView(APIView):
request.user.set_password(new_password)
request.user.save(update_fields=["password"])
login(request, request.user)
profile = request.user.employee_profile
revoked = _revoke_other_user_sessions(request)
record_audit_event(
action="identity.profile_password_changed",
actor=request.user,
organization=profile.organization,
payload={"revoked": revoked},
request=request,
)
@@ -73,16 +69,12 @@ class ProfileTotpStartView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
profile = request.user.employee_profile
profile.totp_required = True
profile.save(update_fields=["totp_required"])
request.user.totp_enabled = False
request.user.totp_secret = ""
request.user.save(update_fields=["totp_enabled", "totp_secret"])
record_audit_event(
action="identity.profile_totp_setup_started",
actor=request.user,
organization=profile.organization,
request=request,
)
return Response({"authenticated": True, "user": _user_payload(request.user)})
@@ -96,9 +88,10 @@ class ProfileTotpDisableView(APIView):
if not request.user.check_password(current_password):
return Response({"detail": "Current password is invalid"}, status=400)
profile = request.user.employee_profile
profile.totp_required = False
profile.save(update_fields=["totp_required"])
if request.user.memberships.filter(
blocked_at__isnull=True, totp_required=True
).exists():
return Response({"detail": "TOTP is required by an organization policy"}, status=409)
request.user.totp_enabled = False
request.user.totp_secret = ""
request.user.save(update_fields=["totp_enabled", "totp_secret"])
@@ -106,7 +99,6 @@ class ProfileTotpDisableView(APIView):
record_audit_event(
action="identity.profile_totp_disabled",
actor=request.user,
organization=profile.organization,
payload={"revoked": revoked},
request=request,
)
@@ -118,11 +110,9 @@ class ProfileRevokeOtherSessionsView(APIView):
def post(self, request: Request) -> Response:
revoked = _revoke_other_user_sessions(request)
profile = request.user.employee_profile
record_audit_event(
action="identity.profile_sessions_revoked",
actor=request.user,
organization=profile.organization,
payload={"revoked": revoked},
request=request,
)
@@ -136,7 +126,6 @@ class ChangeTemporaryPasswordView(APIView):
body = request.data
current_password = str(body.get("currentPassword", ""))
new_password = str(body.get("newPassword", ""))
profile = request.user.employee_profile
if not request.user.must_change_password and not request.user.check_password(current_password):
return Response({"detail": "Current password is invalid"}, status=400)
try:
@@ -151,7 +140,6 @@ class ChangeTemporaryPasswordView(APIView):
record_audit_event(
action="identity.temporary_password_changed",
actor=request.user,
organization=profile.organization,
request=request,
)
return Response({"authenticated": True, "user": _user_payload(request.user)})
@@ -10,7 +10,6 @@ from rest_framework.views import APIView
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.auth.common import _challenge_payload, _user_payload
from hub_platform.identity.auth.totp_utils import TOTP_SESSION_KEY
from hub_platform.identity.membership_context import single_membership_for_user
from hub_platform.identity.models import AuditResult
@@ -40,20 +39,11 @@ class LoginView(APIView):
if user is None:
record_audit_event(action="identity.login_failed", result=AuditResult.DENIED, request=request)
return Response({"detail": "Invalid credentials"}, status=401)
profile = single_membership_for_user(user)
if profile is None:
record_audit_event(action="identity.login_blocked", actor=user, result=AuditResult.DENIED, request=request)
return Response({"detail": "Organization context is unavailable"}, status=403)
if profile.is_blocked:
record_audit_event(action="identity.login_blocked", actor=user, result=AuditResult.DENIED, request=request)
return Response({"detail": "Account is blocked"}, status=403)
if user.totp_enabled:
request.session[TOTP_SESSION_KEY] = user.id
record_audit_event(
action="identity.login_totp_required",
actor=user,
organization=profile.organization,
request=request,
)
return Response(
@@ -69,7 +59,6 @@ class LoginView(APIView):
record_audit_event(
action="identity.login_succeeded",
actor=user,
organization=profile.organization,
request=request,
)
return Response({"authenticated": True, "user": _user_payload(user)})
@@ -80,8 +69,6 @@ class LogoutView(APIView):
def post(self, request: Request) -> Response:
user = request.user
membership = single_membership_for_user(user)
organization = membership.organization if membership is not None else None
logout(request)
record_audit_event(action="identity.logout", actor=user, organization=organization, request=request)
record_audit_event(action="identity.logout", actor=user, request=request)
return Response({"authenticated": False})
@@ -18,7 +18,6 @@ from hub_platform.identity.auth.totp_utils import (
_ensure_totp_secret,
_verify_totp,
)
from hub_platform.identity.membership_context import single_membership_for_user
from hub_platform.identity.models import AuditResult, HumanUser
@@ -26,9 +25,6 @@ class TotpSetupView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
profile = request.user.employee_profile
if not profile.totp_required:
return Response({"detail": "TOTP is not required"}, status=400)
if request.user.totp_enabled:
return Response({"detail": "TOTP is already enabled"}, status=400)
@@ -53,16 +49,11 @@ class TotpConfirmView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request) -> Response:
profile = request.user.employee_profile
if not profile.totp_required:
return Response({"detail": "TOTP is not required"}, status=400)
secret = _ensure_totp_secret(request.user)
if not _verify_totp(secret, str(request.data.get("code", ""))):
record_audit_event(
action="identity.totp_setup_failed",
actor=request.user,
organization=profile.organization,
result=AuditResult.DENIED,
request=request,
)
@@ -73,7 +64,6 @@ class TotpConfirmView(APIView):
record_audit_event(
action="identity.totp_enabled",
actor=request.user,
organization=profile.organization,
request=request,
)
return Response({"authenticated": True, "user": _user_payload(request.user)})
@@ -99,10 +89,6 @@ class TotpVerifyView(APIView):
request.session.pop(TOTP_SESSION_KEY, None)
return Response({"detail": "TOTP challenge is not active"}, status=401)
profile = single_membership_for_user(user)
if profile is None or profile.is_blocked:
request.session.pop(TOTP_SESSION_KEY, None)
return Response({"detail": "TOTP challenge is not active"}, status=401)
if (
not user.totp_enabled
or not user.totp_secret
@@ -111,7 +97,6 @@ class TotpVerifyView(APIView):
record_audit_event(
action="identity.totp_verify_failed",
actor=user,
organization=profile.organization,
result=AuditResult.DENIED,
request=request,
)
@@ -122,7 +107,6 @@ class TotpVerifyView(APIView):
record_audit_event(
action="identity.login_succeeded",
actor=user,
organization=profile.organization,
request=request,
)
return Response({"authenticated": True, "user": _user_payload(user)})
@@ -6,10 +6,10 @@ from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.access_defaults import ensure_system_assignment
from hub_platform.identity.models import (
Department,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.products.models import Product, ProductDepartment
@@ -66,7 +66,7 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
owner.is_superuser = True
owner.save(update_fields=["is_staff", "is_superuser"])
owner_profile, _ = EmployeeProfile.objects.get_or_create(
owner_profile, _ = OrganizationMembership.objects.get_or_create(
user=owner,
organization=organization,
defaults={
@@ -90,7 +90,7 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") ->
operator.set_password("Operator-Local-2026")
operator.save(update_fields=["password"])
operator_profile, _ = EmployeeProfile.objects.get_or_create(
operator_profile, _ = OrganizationMembership.objects.get_or_create(
user=operator,
organization=organization,
defaults={
@@ -4,13 +4,13 @@ from rest_framework.views import APIView
from hub_platform.ai.models import AIAgent
from hub_platform.api.permissions import HasCapability
from hub_platform.identity.models import Department, EmployeeProfile
from hub_platform.identity.models import Department, OrganizationMembership
from hub_platform.identity.policy import ResourceScope, accessible_department_ids, authorize
def _department_payload(department: Department) -> dict[str, object]:
employees = list(
EmployeeProfile.objects.filter(organization=department.organization).select_related("user")
OrganizationMembership.objects.filter(organization=department.organization).select_related("user")
)
department_members = [
employee for employee in employees if employee.primary_department_id == department.id
@@ -19,7 +19,7 @@ def _department_payload(department: Department) -> dict[str, object]:
employee
for employee in employees
if authorize(
employee.user,
employee,
"conversations.operate",
ResourceScope(department.organization_id, department.id),
)
@@ -48,9 +48,9 @@ class DepartmentListView(APIView):
required_capability = "departments.view"
def get(self, request: Request) -> Response:
profile = request.user.employee_profile
profile = request.tenant_context.membership
departments = Department.objects.filter(organization=profile.organization).order_by("name")
department_ids = accessible_department_ids(request.user, self.required_capability)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
if department_ids is not None:
departments = departments.filter(id__in=department_ids)
return Response({"items": [_department_payload(department) for department in departments]})
@@ -15,7 +15,7 @@ class EmployeeResetPasswordView(APIView):
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
@@ -26,7 +26,7 @@ class EmployeeRevokeSessionsView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
@@ -38,7 +38,7 @@ class EmployeeBlockView(APIView):
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
@@ -61,7 +61,7 @@ class EmployeeUnblockView(APIView):
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
@@ -69,7 +69,7 @@ def employee_payload(
def get_owned_profile(request: Request, user_id: int) -> OrganizationMembership | None:
owner_profile = request.user.employee_profile
owner_profile = request.tenant_context.membership
try:
return (
OrganizationMembership.objects.select_related("user", "primary_department")
@@ -7,8 +7,8 @@ from hub_platform.identity.models import (
AuditResult,
Department,
DepartmentStatus,
EmployeeProfile,
EmployeeRole,
OrganizationMembership,
)
ASSIGNABLE_ROLES = {EmployeeRole.ADMIN, EmployeeRole.EMPLOYEE}
@@ -37,9 +37,9 @@ def resolve_department(organization, code: str) -> tuple[Department | None, str
def deny_employee_action(
request: Request, target: EmployeeProfile | None, action: str
request: Request, target: OrganizationMembership | None, action: str
) -> Response:
actor_profile = request.user.employee_profile
actor_profile = request.tenant_context.membership
record_audit_event(
action="identity.employee_privileged_action_denied",
actor=request.user,
@@ -51,4 +51,3 @@ def deny_employee_action(
request=request,
)
return Response({"detail": "You cannot perform this action on this employee"}, status=403)
@@ -17,7 +17,7 @@ from hub_platform.identity.employee_validation import (
)
from hub_platform.identity.event_handlers import INITIAL_ACCESS_REQUESTED
from hub_platform.identity.governance import EmployeeAction, can_create_role, can_manage_employee
from hub_platform.identity.models import EmployeeProfile, EmployeeRole, HumanUser
from hub_platform.identity.models import EmployeeRole, HumanUser, OrganizationMembership
from hub_platform.identity.policy import (
ResourceScope,
accessible_department_ids,
@@ -30,15 +30,15 @@ class EmployeeListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
actor = request.user.employee_profile
if not has_capability_any_scope(request.user, "employees.view"):
actor = request.tenant_context.membership
if not has_capability_any_scope(actor, "employees.view"):
return Response({"detail": "Not allowed"}, status=403)
employees = (
EmployeeProfile.objects.select_related("user", "primary_department")
OrganizationMembership.objects.select_related("user", "primary_department")
.prefetch_related("access_assignments__access_profile__capability_links")
.filter(organization=actor.organization)
)
department_ids = accessible_department_ids(request.user, "employees.view")
department_ids = accessible_department_ids(actor, "employees.view")
if department_ids is not None:
employees = employees.filter(primary_department_id__in=department_ids)
return Response(
@@ -56,7 +56,7 @@ class EmployeeCreateView(APIView):
@transaction.atomic
def post(self, request: Request) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
body = request.data
email = HumanUser.objects.normalize_email(str(body.get("email", "")))
full_name = str(body.get("fullName", "")).strip()
@@ -103,7 +103,7 @@ class EmployeeCreateView(APIView):
is_superuser=False,
must_change_password=True,
)
profile = EmployeeProfile.objects.create(
profile = OrganizationMembership.objects.create(
user=user,
organization=actor.organization,
role=requested_role,
@@ -132,6 +132,7 @@ class EmployeeCreateView(APIView):
aggregate_id=str(user.id),
event_type=INITIAL_ACCESS_REQUESTED,
payload={"userId": user.id},
tenant_context=request.tenant_context,
)
)
return Response({"employee": employee_payload(profile, actor)}, status=201)
@@ -141,12 +142,12 @@ class EmployeeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
scope = ResourceScope(profile.organization_id, profile.primary_department_id)
if not authorize(request.user, "employees.view", scope):
if not authorize(actor, "employees.view", scope):
return Response({"detail": "Employee not found"}, status=404)
return Response({"employee": employee_payload(profile, actor, include_detail=True)})
@@ -156,7 +157,7 @@ class EmployeeUpdateView(APIView):
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = request.user.employee_profile
actor = request.tenant_context.membership
profile = get_owned_profile(request, user_id)
if profile is None:
return Response({"detail": "Employee not found"}, status=404)
@@ -1,6 +1,7 @@
from hub_platform.events.handlers import register
from hub_platform.identity.emails import send_initial_access_email, send_password_reset_email
from hub_platform.identity.models import HumanUser
from hub_platform.tenancy.context import TenantContext
INITIAL_ACCESS_REQUESTED = "identity.initial_access_requested"
PASSWORD_RESET_REQUESTED = "identity.password_reset_requested"
@@ -18,14 +19,21 @@ def _active_user(payload: dict) -> HumanUser | None:
@register(INITIAL_ACCESS_REQUESTED)
def handle_initial_access_requested(payload: dict) -> None:
def handle_initial_access_requested(payload: dict, context: TenantContext | None) -> None:
user = _active_user(payload)
if user is not None:
if (
context is not None
and context.membership is not None
and user is not None
and context.membership.user_id == user.id
):
send_initial_access_email(user)
@register(PASSWORD_RESET_REQUESTED)
def handle_password_reset_requested(payload: dict) -> None:
user = _active_user(payload)
def handle_password_reset_requested(payload: dict, context: TenantContext | None) -> None:
if context is not None:
raise ValueError("Password reset is a platform event")
user = HumanUser.objects.filter(pk=payload.get("userId"), is_active=True).first()
if user is not None:
send_password_reset_email(user)
@@ -16,12 +16,13 @@ from hub_platform.channels.models import Channel
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import (
Department,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.products.models import Product, ProductDepartment
from hub_platform.tenancy.context import TenantContext
from ._seed_specs import CHANNEL_SPECS, PRODUCT_SPECS, TONE
@@ -94,7 +95,7 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor
changed_fields.append("is_superuser")
if changed_fields:
owner.save(update_fields=changed_fields)
EmployeeProfile.objects.update_or_create(
OrganizationMembership.objects.update_or_create(
user=owner,
organization=organization,
defaults={
@@ -130,7 +131,8 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor
)
def _seed_channels(*, organization: Organization) -> tuple[int, int]:
def _seed_channels(*, context: TenantContext) -> tuple[int, int]:
organization = context.organization
created = 0
agents_created = 0
for spec in CHANNEL_SPECS:
@@ -187,7 +189,7 @@ class Command(BaseCommand):
# Seed выполняется только при первичной установке: OWNER создаётся ровно
# однажды (см. _seed_core). На уже развёрнутой установке команда — no-op,
# чтобы не затирать данные, изменённые через UI/API (например цены офферов).
if EmployeeProfile.objects.filter(role=EmployeeRole.OWNER).exists():
if OrganizationMembership.objects.filter(role=EmployeeRole.OWNER).exists():
self.stdout.write(self.style.WARNING(
"Installation already initialized (OWNER exists) — seed skipped."
))
@@ -206,14 +208,22 @@ class Command(BaseCommand):
core = _seed_core(
owner_email=owner_email, owner_password=owner_password, owner_name=owner_name
)
call_command("seed_catalog", verbosity=0)
channels_created, agents_created = _seed_channels(organization=core.organization)
membership = core.organization.memberships.select_related(
"organization", "user"
).get(user=core.owner)
context = TenantContext.for_membership(membership)
call_command(
"seed_catalog",
organization=str(core.organization.public_id),
verbosity=0,
)
channels_created, agents_created = _seed_channels(context=context)
from hub_platform.support.seed_support import seed_support_reference
support_stats = seed_support_reference(organization=core.organization)
support_stats = seed_support_reference(context=context)
content_result = import_ai_content(
base_dir=Path(__file__).resolve().parents[6],
organization=core.organization,
context=context,
)
owner_state = "created" if core.created_owner else "ready"
@@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Count, Q
from hub_platform.identity.models import EmployeeProfile, EmployeeRole, Organization
from hub_platform.identity.models import EmployeeRole, Organization, OrganizationMembership
class Command(BaseCommand):
@@ -10,11 +10,11 @@ class Command(BaseCommand):
def handle(self, *args, **options):
issues: list[str] = []
legacy_roles = EmployeeProfile.objects.filter(role="OPERATOR").count()
legacy_roles = OrganizationMembership.objects.filter(role="OPERATOR").count()
if legacy_roles:
issues.append(f"active legacy role rows: {legacy_roles}")
missing_titles = EmployeeProfile.objects.filter(
missing_titles = OrganizationMembership.objects.filter(
Q(user__is_active=True) & (Q(position_title="") | Q(position_title__isnull=True))
).count()
if missing_titles:
@@ -32,13 +32,13 @@ class Command(BaseCommand):
if invalid_owner_organizations:
issues.append(f"organizations with invalid owner count: {invalid_owner_organizations}")
owners_with_department = EmployeeProfile.objects.filter(
owners_with_department = OrganizationMembership.objects.filter(
role=EmployeeRole.OWNER, primary_department__isnull=False
).count()
if owners_with_department:
issues.append(f"owners with primary department: {owners_with_department}")
employees_without_access = EmployeeProfile.objects.filter(
employees_without_access = OrganizationMembership.objects.filter(
role=EmployeeRole.EMPLOYEE,
user__is_active=True,
).exclude(
@@ -1,19 +0,0 @@
from __future__ import annotations
from hub_platform.identity.models import HumanUser, OrganizationMembership
def single_membership_for_user(user: HumanUser) -> OrganizationMembership | None:
"""C02 bridge for routes that do not yet carry an explicit tenant context.
Returning ``None`` for both zero and multiple memberships keeps legacy routes
fail-closed. C03 replaces this bridge with an explicit organization route.
"""
try:
return user.memberships.select_related("organization", "primary_department").get()
except (
OrganizationMembership.DoesNotExist,
OrganizationMembership.MultipleObjectsReturned,
):
return None
@@ -54,19 +54,6 @@ class HumanUser(AbstractUser):
def __str__(self) -> str:
return self.email
@property
def employee_profile(self) -> "OrganizationMembership":
"""C02 compatibility for legacy unscoped routes; removed by C03.
Never guesses a tenant when a user has multiple memberships.
"""
try:
return self.memberships.get()
except OrganizationMembership.DoesNotExist as error:
raise AttributeError("User has no organization membership") from error
class TaxRegime(models.TextChoices):
USN_INCOME = "USN_INCOME", "УСН доходы"
@@ -195,11 +182,6 @@ class OrganizationMembership(models.Model):
self.save(update_fields=["blocked_at"])
# Transitional import compatibility only. There is no second EmployeeProfile model/table.
# Runtime access through user.employee_profile is intentionally single-membership-only.
EmployeeProfile = OrganizationMembership
class AuditResult(models.TextChoices):
SUCCESS = "SUCCESS", "Success"
DENIED = "DENIED", "Denied"
@@ -12,7 +12,7 @@ from hub_platform.identity.employee_validation import (
resolve_department,
)
from hub_platform.identity.governance import EmployeeAction, can_manage_employee
from hub_platform.identity.models import EmployeeProfile, EmployeeRole
from hub_platform.identity.models import EmployeeRole, OrganizationMembership
class OwnershipTransferView(APIView):
@@ -21,17 +21,17 @@ class OwnershipTransferView(APIView):
@transaction.atomic
def post(self, request: Request, user_id: int) -> Response:
actor = (
EmployeeProfile.objects.select_for_update(of=("self",))
OrganizationMembership.objects.select_for_update(of=("self",))
.select_related("user", "primary_department")
.get(pk=request.user.employee_profile.pk)
.get(pk=request.tenant_context.membership.pk)
)
try:
target = (
EmployeeProfile.objects.select_for_update(of=("self",))
OrganizationMembership.objects.select_for_update(of=("self",))
.select_related("user", "primary_department")
.get(user_id=user_id, organization=actor.organization)
)
except EmployeeProfile.DoesNotExist:
except OrganizationMembership.DoesNotExist:
return Response({"detail": "Employee not found"}, status=404)
if not can_manage_employee(actor, target, EmployeeAction.TRANSFER_OWNERSHIP):
+4 -18
View File
@@ -24,26 +24,12 @@ class ResourceScope:
def _active_membership(actor) -> OrganizationMembership | None:
"""Resolve the authorization actor without guessing between organizations.
C02 services can pass a membership directly. Legacy user-based routes remain
available only while the user has exactly one membership and are removed in C03.
"""
if isinstance(actor, OrganizationMembership):
if not actor.user.is_active or actor.is_blocked:
return None
return actor
if not getattr(actor, "is_authenticated", False) or not getattr(actor, "is_active", False):
"""Validate an already-resolved membership; never infer its organization."""
if not isinstance(actor, OrganizationMembership):
return None
try:
membership = actor.memberships.get()
except (
OrganizationMembership.DoesNotExist,
OrganizationMembership.MultipleObjectsReturned,
):
if not actor.user.is_active or actor.is_blocked:
return None
return None if membership.is_blocked else membership
return actor
def _assignments(profile: OrganizationMembership) -> QuerySet[EmployeeAccessAssignment]:
@@ -1,15 +1,15 @@
from django.test import TestCase
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.events.models import OutboxEvent
from hub_platform.identity.models import (
AccessProfile,
AccessProfileCapability,
Department,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
@@ -30,9 +30,9 @@ class AccessManagementApiTests(TestCase):
def _employee(
self, email: str, role: str, department: Department | None = None
) -> EmployeeProfile:
) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return EmployeeProfile.objects.create(
return OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
@@ -220,7 +220,7 @@ class AccessManagementApiTests(TestCase):
format="json",
)
self.assertEqual(response.status_code, 201)
created = EmployeeProfile.objects.get(user__email="new@access.test")
created = OrganizationMembership.objects.get(user__email="new@access.test")
self.assertEqual(created.access_assignments.get().department, self.sales)
def test_employee_create_without_password_queues_first_access_email(self) -> None:
@@ -8,10 +8,10 @@ from hub_platform.identity.models import (
AccessProfileCapability,
Department,
EmployeeAccessAssignment,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.identity.policy import (
ResourceScope,
@@ -39,9 +39,9 @@ class CapabilityPolicyTests(TestCase):
def _employee(
self, email: str, role: str, department: Department | None = None
) -> EmployeeProfile:
) -> OrganizationMembership:
user = HumanUser.objects.create_user(email=email, password="Password-123")
return EmployeeProfile.objects.create(
return OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
@@ -73,9 +73,9 @@ class CapabilityPolicyTests(TestCase):
def test_owner_and_admin_role_policy(self) -> None:
organization_scope = ResourceScope(self.organization.id)
self.assertTrue(authorize(self.owner.user, "ownership.transfer", organization_scope))
self.assertFalse(authorize(self.admin.user, "ownership.transfer", organization_scope))
self.assertTrue(authorize(self.admin.user, "integrations.manage", organization_scope))
self.assertTrue(authorize(self.owner, "ownership.transfer", organization_scope))
self.assertFalse(authorize(self.admin, "ownership.transfer", organization_scope))
self.assertTrue(authorize(self.admin, "integrations.manage", organization_scope))
def test_department_assignment_does_not_cross_department(self) -> None:
self._assign(
@@ -83,21 +83,21 @@ class CapabilityPolicyTests(TestCase):
)
self.assertTrue(
authorize(
self.employee.user,
self.employee,
"conversations.view",
ResourceScope(self.organization.id, self.sales.id),
)
)
self.assertFalse(
authorize(
self.employee.user,
self.employee,
"conversations.view",
ResourceScope(self.organization.id, self.support.id),
)
)
self.assertFalse(
authorize(
self.employee.user,
self.employee,
"conversations.view",
ResourceScope(self.other_organization.id, self.sales.id),
)
@@ -106,7 +106,7 @@ class CapabilityPolicyTests(TestCase):
def test_primary_department_never_grants_access(self) -> None:
self.assertFalse(
authorize(
self.employee.user,
self.employee,
"conversations.view",
ResourceScope(self.organization.id, self.sales.id),
)
@@ -116,12 +116,12 @@ class CapabilityPolicyTests(TestCase):
self._assign(self._profile("Company reader", "conversations.view"))
self.assertTrue(
authorize(
self.employee.user,
self.employee,
"conversations.view",
ResourceScope(self.organization.id, self.support.id),
)
)
self.assertIsNone(accessible_department_ids(self.employee.user, "conversations.view"))
self.assertIsNone(accessible_department_ids(self.employee, "conversations.view"))
def test_multiple_assignments_are_unioned_and_exposed(self) -> None:
self._assign(
@@ -132,13 +132,13 @@ class CapabilityPolicyTests(TestCase):
self._profile("Support reader", "support.view", "conversations.view"),
department=self.support,
)
access = get_effective_access(self.employee.user)
access = get_effective_access(self.employee)
self.assertEqual(
access["capabilities"],
["conversations.view", "sales.view", "support.view"],
)
self.assertEqual(
accessible_department_ids(self.employee.user, "conversations.view"),
accessible_department_ids(self.employee, "conversations.view"),
{self.sales.id, self.support.id},
)
self.assertEqual(len(access["accessScopes"]), 2)
@@ -147,15 +147,15 @@ class CapabilityPolicyTests(TestCase):
profile = self._profile("Reader", "products.view")
assignment = self._assign(profile, department=self.sales)
scope = ResourceScope(self.organization.id, self.sales.id)
self.assertTrue(authorize(self.employee.user, "products.view", scope))
self.assertTrue(authorize(self.employee, "products.view", scope))
assignment.revoked_at = timezone.now()
assignment.save(update_fields=["revoked_at"])
self.assertFalse(authorize(self.employee.user, "products.view", scope))
self.assertFalse(authorize(self.employee, "products.view", scope))
second = self._assign(profile, department=self.sales)
profile.is_active = False
profile.save()
self.assertFalse(authorize(self.employee.user, "products.view", scope))
self.assertFalse(authorize(self.employee, "products.view", scope))
self.assertIsNotNone(second.id)
def test_unknown_and_protected_capabilities_are_rejected(self) -> None:
@@ -12,7 +12,6 @@ from hub_platform.identity.invitation_service import (
)
from hub_platform.identity.models import (
EmployeeAccessAssignment,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
@@ -90,20 +89,18 @@ class MembershipIdentityTests(TestCase):
with self.assertRaises(FieldDoesNotExist):
OrganizationMembership._meta.get_field(field_name)
def test_employee_profile_is_only_a_transitional_python_alias(self) -> None:
self.assertIs(EmployeeProfile, OrganizationMembership)
def test_membership_keeps_the_historical_table_name(self) -> None:
self.assertEqual(OrganizationMembership._meta.db_table, "identity_employeeprofile")
def test_legacy_user_lookup_refuses_to_guess_between_memberships(self) -> None:
self.assertEqual(self.user.employee_profile, self.first_membership)
def test_user_has_no_implicit_membership_lookup(self) -> None:
self.assertFalse(hasattr(self.user, "employee_profile"))
OrganizationMembership.objects.create(
user=self.user,
organization=self.second_organization,
role=EmployeeRole.EMPLOYEE,
position_title="Specialist",
)
with self.assertRaises(OrganizationMembership.MultipleObjectsReturned):
_ = self.user.employee_profile
self.assertFalse(hasattr(self.user, "employee_profile"))
def test_policy_authorizes_an_explicit_membership(self) -> None:
self.assertTrue(
+63 -55
View File
@@ -8,7 +8,7 @@ from django.core import mail
from django.test import Client, TestCase, override_settings
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from rest_framework.throttling import ScopedRateThrottle
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
@@ -16,10 +16,10 @@ from hub_platform.identity.auth.totp_utils import _totp_code
from hub_platform.identity.models import (
AuditEvent,
Department,
EmployeeProfile,
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.identity.policy import ResourceScope, authorize
from hub_platform.products.models import Product
@@ -43,16 +43,16 @@ class BootstrapOwnerTests(TestCase):
)
self.assertEqual(result.support_department.code, "support")
self.assertEqual(set(Product.objects.values_list("code", flat=True)), {"firepage", "foxray"})
self.assertEqual(result.owner.employee_profile.role, EmployeeRole.OWNER)
self.assertEqual(result.owner.memberships.get().role, EmployeeRole.OWNER)
# TOTP выключен по умолчанию (намеренно, локальная разработка).
self.assertFalse(result.owner.employee_profile.totp_required)
self.assertFalse(result.owner.memberships.get().totp_required)
self.assertTrue(result.owner.is_staff)
self.assertTrue(result.owner.is_superuser)
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
self.assertEqual(operator.full_name, "Анна Котова")
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.employee_profile.phone, "+7 916 245 14 02")
assignment = operator.employee_profile.access_assignments.get(revoked_at__isnull=True)
self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 02")
assignment = operator.memberships.get().access_assignments.get(revoked_at__isnull=True)
self.assertEqual(assignment.scope_type, "DEPARTMENT")
self.assertEqual(assignment.department.code, "sales")
self.assertIn(
@@ -92,13 +92,14 @@ class PermissionTests(TestCase):
def test_owner_can_access_global_settings_and_sales_workspace(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
membership = owner.memberships.get(organization=self.organization)
self.assertTrue(
authorize(owner, "settings.manage", ResourceScope(self.organization.id))
authorize(membership, "settings.manage", ResourceScope(self.organization.id))
)
self.assertTrue(
authorize(
owner,
membership,
"conversations.view",
ResourceScope(self.organization.id, self.sales.id),
)
@@ -106,12 +107,13 @@ class PermissionTests(TestCase):
def test_operator_cannot_access_global_settings(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
membership = operator.memberships.get(organization=self.organization)
self.assertFalse(
authorize(operator, "settings.manage", ResourceScope(self.organization.id))
authorize(membership, "settings.manage", ResourceScope(self.organization.id))
)
self.assertTrue(
authorize(
operator,
membership,
"conversations.view",
ResourceScope(self.organization.id, self.sales.id),
)
@@ -132,12 +134,15 @@ class AuthEndpointTests(TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertIn("capabilities", payload["user"])
self.assertIn("accessScopes", payload["user"])
self.assertIn("employees.manage_privileged", payload["user"]["capabilities"])
self.assertTrue(payload["authenticated"])
self.assertEqual(payload["user"]["organizationName"], "Edevs")
self.assertEqual(payload["user"]["role"], EmployeeRole.OWNER)
self.assertNotIn("organizationName", payload["user"])
self.assertNotIn("role", payload["user"])
self.assertEqual(len(payload["user"]["memberships"]), 1)
membership = payload["user"]["memberships"][0]
self.assertIn("accessScopes", membership)
self.assertIn("employees.manage_privileged", membership["capabilities"])
self.assertEqual(membership["organizationName"], "Edevs")
self.assertEqual(membership["role"], EmployeeRole.OWNER)
def test_session_sets_csrf_cookie_for_spa(self) -> None:
response = self.client.get("/api/v1/auth/session/")
@@ -188,10 +193,10 @@ class AuthEndpointTests(TestCase):
self.assertFalse(OutboxEvent.objects.filter(event_type="identity.password_reset_requested").exists())
def test_password_reset_handler_sends_email(self) -> None:
from hub_platform.events.handlers import dispatch
from hub_platform.identity.event_handlers import handle_password_reset_requested
owner = HumanUser.objects.get(email="owner@edevs.tech")
dispatch("identity.password_reset_requested", {"userId": owner.id})
handle_password_reset_requested({"userId": owner.id}, None)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["owner@edevs.tech"])
@@ -304,7 +309,7 @@ class AuthEndpointTests(TestCase):
def test_profile_totp_start_marks_setup_required(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.employee_profile
profile = owner.memberships.get()
profile.totp_required = False
profile.save(update_fields=["totp_required"])
owner.totp_enabled = False
@@ -317,13 +322,13 @@ class AuthEndpointTests(TestCase):
self.assertEqual(response.status_code, 200)
profile.refresh_from_db()
owner.refresh_from_db()
self.assertTrue(profile.totp_required)
self.assertFalse(profile.totp_required)
self.assertFalse(owner.totp_enabled)
self.assertEqual(owner.totp_secret, "")
def test_profile_totp_disable_requires_password_and_revokes_other_sessions(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.employee_profile
profile = owner.memberships.get()
owner.totp_enabled = True
owner.totp_secret = "JBSWY3DPEHPK3PXP"
owner.save(update_fields=["totp_enabled", "totp_secret"])
@@ -351,7 +356,7 @@ class AuthEndpointTests(TestCase):
def test_totp_setup_and_confirm_enables_profile_totp(self) -> None:
# TOTP по умолчанию не требуется; включаем требование, чтобы пройти setup→confirm.
owner = HumanUser.objects.get(email="owner@edevs.tech")
profile = owner.employee_profile
profile = owner.memberships.get()
profile.totp_required = True
profile.save(update_fields=["totp_required"])
self.client.login(username="owner@edevs.tech", password="temporary-password")
@@ -438,7 +443,7 @@ class EmployeeEndpointTests(TestCase):
def test_operator_cannot_create_operator(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
@@ -467,7 +472,7 @@ class EmployeeEndpointTests(TestCase):
def test_owner_blocks_operator(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
@@ -479,7 +484,7 @@ class EmployeeEndpointTests(TestCase):
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
self.assertTrue(operator.is_active)
self.assertTrue(operator.employee_profile.is_blocked)
self.assertTrue(operator.memberships.get().is_blocked)
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_blocked").exists())
def test_owner_updates_operator_card_fields(self) -> None:
@@ -503,9 +508,9 @@ class EmployeeEndpointTests(TestCase):
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
operator.employee_profile.refresh_from_db()
operator.memberships.get().refresh_from_db()
self.assertEqual(operator.email, "anna.kotova@edevs.tech")
self.assertEqual(operator.employee_profile.phone, "+7 916 245 14 03")
self.assertEqual(operator.memberships.get().phone, "+7 916 245 14 03")
self.assertFalse(operator.totp_enabled)
def test_owner_cannot_reset_operator_global_password(self) -> None:
@@ -521,15 +526,15 @@ class EmployeeEndpointTests(TestCase):
def test_owner_unblocks_operator(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
operator.employee_profile.block()
operator.memberships.get().block()
response = self.client.post(f"/api/v1/employees/{operator.id}/unblock/")
self.assertEqual(response.status_code, 200)
operator.refresh_from_db()
operator.employee_profile.refresh_from_db()
operator.memberships.get().refresh_from_db()
self.assertTrue(operator.is_active)
self.assertFalse(operator.employee_profile.is_blocked)
self.assertFalse(operator.memberships.get().is_blocked)
class CompanyEndpointTests(TestCase):
@@ -574,7 +579,7 @@ class CompanyEndpointTests(TestCase):
def test_operator_cannot_create_product(self) -> None:
operator = HumanUser.objects.create_user(email="operator@edevs.tech", password="operator-password")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=operator,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
@@ -674,15 +679,15 @@ class EmployeeModelInvariantTests(TestCase):
def test_bootstrap_owner_is_company_level_with_title(self) -> None:
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.assertEqual(owner.employee_profile.role, EmployeeRole.OWNER)
self.assertIsNone(owner.employee_profile.primary_department)
self.assertTrue(owner.employee_profile.position_title)
self.assertEqual(owner.memberships.get().role, EmployeeRole.OWNER)
self.assertIsNone(owner.memberships.get().primary_department)
self.assertTrue(owner.memberships.get().position_title)
def test_bootstrapped_operator_is_employee_in_sales(self) -> None:
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
self.assertEqual(operator.employee_profile.role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.employee_profile.primary_department, self.sales)
self.assertTrue(operator.employee_profile.position_title)
self.assertEqual(operator.memberships.get().role, EmployeeRole.EMPLOYEE)
self.assertEqual(operator.memberships.get().primary_department, self.sales)
self.assertTrue(operator.memberships.get().position_title)
def test_second_owner_is_rejected(self) -> None:
from django.db import IntegrityError, transaction
@@ -690,7 +695,7 @@ class EmployeeModelInvariantTests(TestCase):
second = HumanUser.objects.create_user(email="owner2@edevs.tech", password="temporary-password")
with self.assertRaises(IntegrityError):
with transaction.atomic():
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=second,
organization=self.organization,
role=EmployeeRole.OWNER,
@@ -705,7 +710,7 @@ class EmployeeModelInvariantTests(TestCase):
other_org = Organization.objects.create(name="Other", slug="other")
with self.assertRaises(IntegrityError):
with transaction.atomic():
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=user,
organization=other_org,
role=EmployeeRole.OWNER,
@@ -741,7 +746,7 @@ class EmployeeGovernanceTests(TestCase):
def _make(self, email: str, role: str, department: Department | None = None) -> HumanUser:
user = HumanUser.objects.create_user(email=email, password="member-password-123")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=role,
@@ -775,7 +780,7 @@ class EmployeeGovernanceTests(TestCase):
client = self._client("owner@edevs.tech", "temporary-password")
response = self._create(client, "admin@edevs.tech", EmployeeRole.ADMIN)
self.assertEqual(response.status_code, 201)
profile = HumanUser.objects.get(email="admin@edevs.tech").employee_profile
profile = HumanUser.objects.get(email="admin@edevs.tech").memberships.get()
self.assertEqual(profile.role, EmployeeRole.ADMIN)
self.assertIsNone(profile.primary_department)
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_created").exists())
@@ -793,7 +798,10 @@ class EmployeeGovernanceTests(TestCase):
self._make("admin@edevs.tech", EmployeeRole.ADMIN)
response = self._create(self._client("admin@edevs.tech"), "emp@edevs.tech", EmployeeRole.EMPLOYEE)
self.assertEqual(response.status_code, 201)
self.assertEqual(HumanUser.objects.get(email="emp@edevs.tech").employee_profile.role, EmployeeRole.EMPLOYEE)
self.assertEqual(
HumanUser.objects.get(email="emp@edevs.tech").memberships.get().role,
EmployeeRole.EMPLOYEE,
)
def test_create_owner_via_flow_is_rejected(self) -> None:
client = self._client("owner@edevs.tech", "temporary-password")
@@ -810,7 +818,7 @@ class EmployeeGovernanceTests(TestCase):
self.assertEqual(response.status_code, 200)
emp.refresh_from_db()
self.assertTrue(emp.is_active)
self.assertTrue(emp.employee_profile.is_blocked)
self.assertTrue(emp.memberships.get().is_blocked)
def test_admin_cannot_block_another_admin(self) -> None:
other = self._make("admin2@edevs.tech", EmployeeRole.ADMIN)
@@ -849,8 +857,8 @@ class EmployeeGovernanceTests(TestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
emp.employee_profile.refresh_from_db()
self.assertEqual(emp.employee_profile.role, EmployeeRole.EMPLOYEE)
emp.memberships.get().refresh_from_db()
self.assertEqual(emp.memberships.get().role, EmployeeRole.EMPLOYEE)
def test_owner_promotes_employee_to_admin(self) -> None:
emp = self._make("emp@edevs.tech", EmployeeRole.EMPLOYEE)
@@ -862,8 +870,8 @@ class EmployeeGovernanceTests(TestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
emp.employee_profile.refresh_from_db()
self.assertEqual(emp.employee_profile.role, EmployeeRole.ADMIN)
emp.memberships.get().refresh_from_db()
self.assertEqual(emp.memberships.get().role, EmployeeRole.ADMIN)
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_role_changed").exists())
def test_employee_cannot_manage(self) -> None:
@@ -876,7 +884,7 @@ class EmployeeGovernanceTests(TestCase):
def test_cross_org_target_is_not_found(self) -> None:
other_org = Organization.objects.create(name="Other", slug="other")
outsider = HumanUser.objects.create_user(email="out@other.tech", password="member-password-123")
EmployeeProfile.objects.create(
OrganizationMembership.objects.create(
user=outsider, organization=other_org, role=EmployeeRole.EMPLOYEE, position_title="X"
)
response = self._client("owner@edevs.tech", "temporary-password").post(
@@ -895,13 +903,13 @@ class EmployeeGovernanceTests(TestCase):
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
target.employee_profile.refresh_from_db()
owner.employee_profile.refresh_from_db()
self.assertEqual(target.employee_profile.role, EmployeeRole.OWNER)
self.assertIsNone(target.employee_profile.primary_department)
self.assertEqual(owner.employee_profile.role, EmployeeRole.ADMIN)
target.memberships.get().refresh_from_db()
owner.memberships.get().refresh_from_db()
self.assertEqual(target.memberships.get().role, EmployeeRole.OWNER)
self.assertIsNone(target.memberships.get().primary_department)
self.assertEqual(owner.memberships.get().role, EmployeeRole.ADMIN)
self.assertEqual(
EmployeeProfile.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(),
OrganizationMembership.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(),
1,
)
self.assertTrue(AuditEvent.objects.filter(action="identity.ownership_transferred").exists())
@@ -916,7 +924,7 @@ class EmployeeGovernanceTests(TestCase):
)
self.assertEqual(response.status_code, 403)
self.assertEqual(
EmployeeProfile.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(),
OrganizationMembership.objects.filter(organization=self.organization, role=EmployeeRole.OWNER).count(),
1,
)
@@ -1,11 +1,12 @@
from django.db.models import QuerySet
from hub_platform.integrations.models import Integration
from hub_platform.tenancy.context import TenantContext
def integrations_for_organization(organization_id: int) -> QuerySet[Integration]:
return Integration.objects.filter(organization_id=organization_id).select_related("channel").order_by("provider", "name")
def integrations_for_context(context: TenantContext) -> QuerySet[Integration]:
return Integration.objects.filter(organization_id=context.organization_id).select_related("channel").order_by("provider", "name")
def integration_for_organization(*, organization_id: int, integration_id: int) -> Integration:
return Integration.objects.get(id=integration_id, organization_id=organization_id)
def integration_for_context(*, context: TenantContext, integration_id: int) -> Integration:
return Integration.objects.get(id=integration_id, organization_id=context.organization_id)
@@ -12,6 +12,7 @@ from hub_platform.integrations.models import (
IntegrationProvider,
IntegrationStatus,
)
from hub_platform.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -67,7 +68,8 @@ def _validate_provider(provider: str) -> str:
@transaction.atomic
def create_integration(*, organization: Organization, data: IntegrationInput) -> Integration:
def create_integration(*, context: TenantContext, data: IntegrationInput) -> Integration:
organization = context.organization
provider = _validate_provider(data.provider)
name = data.name.strip()
if not name:
@@ -88,7 +90,11 @@ def create_integration(*, organization: Organization, data: IntegrationInput) ->
@transaction.atomic
def update_integration(*, integration: Integration, data: IntegrationInput) -> Integration:
def update_integration(
*, context: TenantContext, integration: Integration, data: IntegrationInput
) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": "Integration belongs to another organization"})
integration.name = data.name.strip() or integration.name
integration.config = _normalized_config(integration.provider, data.config)
integration.channel = _resolve_channel(integration.organization, data.channel_id)
@@ -103,7 +109,9 @@ def update_integration(*, integration: Integration, data: IntegrationInput) -> I
return integration
def delete_integration(*, integration: Integration) -> None:
def delete_integration(*, context: TenantContext, integration: Integration) -> None:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": "Integration belongs to another organization"})
integration.delete()
@@ -128,7 +136,9 @@ def _check_web(integration: Integration) -> tuple[bool, str, dict]:
return True, f"Web-виджет активен · канал «{integration.channel.name}»", {}
def test_integration(*, integration: Integration) -> Integration:
def test_integration(*, context: TenantContext, integration: Integration) -> Integration:
if integration.organization_id != context.organization_id:
raise ValidationError({"integration": "Integration belongs to another organization"})
if integration.provider == IntegrationProvider.WEB:
ok, detail, meta = _check_web(integration)
else:
@@ -15,6 +15,7 @@ from hub_platform.integrations.services import (
test_integration as run_integration_test,
update_integration,
)
from hub_platform.testing import system_tenant_context
def _fake_response(status: int, body: dict):
@@ -31,30 +32,37 @@ class WebIntegrationCheckTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.context = system_tenant_context(self.organization)
from hub_platform.channels.models import Channel
self.channel = Channel.objects.create(organization=self.organization, code="edevs", name="Edevs — главный сайт")
def _web(self, name: str, channel=None) -> Integration:
return create_integration(
organization=self.organization,
context=self.context,
data=IntegrationInput(provider=IntegrationProvider.WEB, name=name, channel_id=channel.id if channel else None),
)
def test_web_without_channel_fails(self) -> None:
integration = run_integration_test(integration=self._web("Виджет", channel=None))
integration = run_integration_test(
context=self.context, integration=self._web("Виджет", channel=None)
)
self.assertEqual(integration.status, IntegrationStatus.ERROR)
self.assertIn("не привязано к каналу", integration.last_error)
def test_web_bound_to_channel_is_ok(self) -> None:
integration = run_integration_test(integration=self._web("Виджет", channel=self.channel))
integration = run_integration_test(
context=self.context, integration=self._web("Виджет", channel=self.channel)
)
self.assertEqual(integration.status, IntegrationStatus.OK)
self.assertEqual(integration.last_error, "")
def test_web_shadowed_by_another_connection_fails(self) -> None:
# Два WEB-подключения на один канал: виджет обслуживает первое по сортировке.
self._web("A-виджет", channel=self.channel)
shadowed = run_integration_test(integration=self._web("B-виджет", channel=self.channel))
shadowed = run_integration_test(
context=self.context, integration=self._web("B-виджет", channel=self.channel)
)
self.assertEqual(shadowed.status, IntegrationStatus.ERROR)
self.assertIn("другое WEB-подключение", shadowed.last_error)
@@ -63,10 +71,11 @@ class ProxyConfigTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.context = system_tenant_context(self.organization)
def test_proxy_url_is_persisted_in_config(self) -> None:
integration = create_integration(
organization=self.organization,
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.OPENROUTER,
name="OpenRouter",
@@ -78,7 +87,7 @@ class ProxyConfigTests(TestCase):
def test_update_clears_proxy_when_empty(self) -> None:
integration = create_integration(
organization=self.organization,
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.OPENROUTER,
name="OpenRouter",
@@ -87,6 +96,7 @@ class ProxyConfigTests(TestCase):
),
)
updated = update_integration(
context=self.context,
integration=integration,
data=IntegrationInput(provider=IntegrationProvider.OPENROUTER, name="OpenRouter", config={"proxyUrl": ""}),
)
@@ -94,7 +104,7 @@ class ProxyConfigTests(TestCase):
def test_serializer_exposes_proxy_url(self) -> None:
integration = create_integration(
organization=self.organization,
context=self.context,
data=IntegrationInput(
provider=IntegrationProvider.OPENROUTER,
name="OpenRouter",
+21 -13
View File
@@ -8,8 +8,8 @@ from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from hub_platform.integrations.models import Integration
from hub_platform.integrations.selectors import (
integration_for_organization,
integrations_for_organization,
integration_for_context,
integrations_for_context,
)
from hub_platform.integrations.serializers import integration_payload
from hub_platform.integrations.services import (
@@ -49,7 +49,7 @@ def _audit(request: Request, action: str, integration: Integration) -> None:
record_audit_event(
action=action,
actor=request.user,
organization=request.user.employee_profile.organization,
organization=request.tenant_context.organization,
object_type="Integration",
object_id=str(integration.id),
request=request,
@@ -62,13 +62,15 @@ class IntegrationListView(APIView):
require_organization_scope = True
def get(self, request: Request) -> Response:
items = integrations_for_organization(request.user.employee_profile.organization_id)
items = integrations_for_context(request.tenant_context)
return Response({"items": [integration_payload(item) for item in items]})
def post(self, request: Request) -> Response:
profile = request.user.employee_profile
profile = request.tenant_context.membership
try:
integration = create_integration(organization=profile.organization, data=_input(request.data))
integration = create_integration(
context=request.tenant_context, data=_input(request.data)
)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
_audit(request, "integrations.integration_created", integration)
@@ -81,14 +83,18 @@ class IntegrationDetailView(APIView):
require_organization_scope = True
def _get(self, request: Request, integration_id: int) -> Integration:
return integration_for_organization(
organization_id=request.user.employee_profile.organization_id, integration_id=integration_id
return integration_for_context(
context=request.tenant_context, integration_id=integration_id
)
def patch(self, request: Request, integration_id: int) -> Response:
try:
integration = self._get(request, integration_id)
integration = update_integration(integration=integration, data=_input(request.data, current=integration))
integration = update_integration(
context=request.tenant_context,
integration=integration,
data=_input(request.data, current=integration),
)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
except (ValidationError, IntegrityError) as error:
@@ -102,7 +108,7 @@ class IntegrationDetailView(APIView):
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
_audit(request, "integrations.integration_deleted", integration)
delete_integration(integration=integration)
delete_integration(context=request.tenant_context, integration=integration)
return Response(status=204)
@@ -113,11 +119,13 @@ class IntegrationTestView(APIView):
def post(self, request: Request, integration_id: int) -> Response:
try:
integration = integration_for_organization(
organization_id=request.user.employee_profile.organization_id, integration_id=integration_id
integration = integration_for_context(
context=request.tenant_context, integration_id=integration_id
)
except Integration.DoesNotExist:
return Response({"detail": "Интеграция не найдена"}, status=404)
integration = test_integration(integration=integration)
integration = test_integration(
context=request.tenant_context, integration=integration
)
_audit(request, "integrations.integration_tested", integration)
return Response({"integration": integration_payload(integration)})
@@ -34,15 +34,13 @@ _DEEP_LINK_BASE = {
}
def notifier_integrations(organization_id: int | None = None):
def notifier_integrations(context):
qs = Integration.objects.filter(
kind=IntegrationKind.MESSENGER,
provider__in=(IntegrationProvider.TELEGRAM, IntegrationProvider.MAX),
config__purpose=NOTIFIER_PURPOSE,
).exclude(secret="")
if organization_id is not None:
qs = qs.filter(organization_id=organization_id)
return qs
return qs.filter(organization=context.organization)
def deep_link(integration: Integration, code: str) -> str:
@@ -53,7 +51,10 @@ def deep_link(integration: Integration, code: str) -> str:
return template.format(username=username, code=code)
def issue_binding_code(*, user, integration: Integration) -> MessengerBindingCode:
def issue_binding_code(*, context, integration: Integration) -> MessengerBindingCode:
if integration.organization_id != context.organization_id or context.actor_user is None:
raise ValueError("Notifier integration is outside tenant context")
user = context.actor_user
# Прошлые коды пользователя для этого бота гасим — активен только последний.
MessengerBindingCode.objects.filter(user=user, integration=integration).delete()
return MessengerBindingCode.objects.create(
@@ -78,7 +79,14 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) -
code_value = _extract_code(inbound.text)
binding_code = (
MessengerBindingCode.objects.select_related("user")
.filter(integration=integration, code=code_value, expires_at__gte=timezone.now())
.filter(
integration=integration,
code=code_value,
expires_at__gte=timezone.now(),
user__memberships__organization=integration.organization,
user__memberships__blocked_at__isnull=True,
)
.distinct()
.first()
if code_value
else None
@@ -88,7 +96,10 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) -
return
with transaction.atomic():
# Уведомления идут ровно в один мессенджер: новая привязка заменяет прежние.
MessengerBinding.objects.filter(user=binding_code.user).exclude(integration=integration).delete()
MessengerBinding.objects.filter(
user=binding_code.user,
integration__organization=integration.organization,
).exclude(integration=integration).delete()
MessengerBinding.objects.update_or_create(
user=binding_code.user,
integration=integration,
@@ -98,10 +109,10 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) -
transports.send_reply(integration, chat_id=inbound.chat_id, user_id=inbound.user_id, text=CONFIRMATION_TEXT)
def poll_notifier_bots() -> int:
def poll_notifier_bots(context) -> int:
"""Поллинг сервисных ботов: только привязочные сообщения, без ingest."""
total = 0
for integration in notifier_integrations():
for integration in notifier_integrations(context):
messages, new_marker = transports.poll(integration)
for inbound in messages:
try:
@@ -13,7 +13,7 @@ import logging
from django.conf import settings
from hub_platform.conversations import transports
from hub_platform.identity.models import EmployeeProfile
from hub_platform.identity.models import OrganizationMembership
from hub_platform.notifications.models import (
MessengerBinding,
Notification,
@@ -21,6 +21,7 @@ from hub_platform.notifications.models import (
NotificationLevel,
)
from hub_platform.notifications.selectors import visible_for
from hub_platform.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
@@ -37,7 +38,7 @@ _LEVEL_MARK = {
def _recipient_user_ids(notification: Notification) -> list[int]:
if notification.audience == NotificationAudience.USER:
return [notification.recipient_user_id] if notification.recipient_user_id else []
profiles = EmployeeProfile.objects.filter(
profiles = OrganizationMembership.objects.filter(
organization_id=notification.organization_id,
blocked_at__isnull=True,
user__is_active=True,
@@ -45,7 +46,7 @@ def _recipient_user_ids(notification: Notification) -> list[int]:
return [
profile.user_id
for profile in profiles
if visible_for(profile.user).filter(id=notification.id).exists()
if visible_for(TenantContext.for_membership(profile)).filter(id=notification.id).exists()
]
@@ -1,10 +1,15 @@
from hub_platform.events.handlers import register
from hub_platform.notifications.delivery import NOTIFICATION_CREATED, deliver_notification
from hub_platform.notifications.models import Notification
from hub_platform.tenancy.context import TenantContext
@register(NOTIFICATION_CREATED)
def handle_notification_created(payload: dict) -> None:
notification = Notification.objects.filter(pk=payload.get("notificationId")).first()
def handle_notification_created(payload: dict, context: TenantContext | None) -> None:
if context is None:
raise ValueError("Notification event has no tenant context")
notification = Notification.objects.filter(
pk=payload.get("notificationId"), organization=context.organization
).first()
if notification is not None:
deliver_notification(notification)
@@ -2,21 +2,23 @@ from django.db.models import Q, QuerySet
from hub_platform.identity.policy import ResourceScope, accessible_department_ids, authorize
from hub_platform.notifications.models import Notification, NotificationAudience
from hub_platform.tenancy.context import TenantContext
def visible_for(user) -> QuerySet[Notification]:
profile = getattr(user, "employee_profile", None)
if profile is None:
def visible_for(context: TenantContext) -> QuerySet[Notification]:
profile = context.membership
if profile is None or context.actor_user is None:
return Notification.objects.none()
user = context.actor_user
organization_scope = ResourceScope(profile.organization_id)
audiences = []
if authorize(user, "company.view", organization_scope):
if authorize(profile, "company.view", organization_scope):
audiences.append(NotificationAudience.ALL)
if authorize(user, "conversations.view", organization_scope):
if authorize(profile, "conversations.view", organization_scope):
audiences.append(NotificationAudience.OPERATORS)
if authorize(user, "employees.manage_privileged", organization_scope):
if authorize(profile, "employees.manage_privileged", organization_scope):
audiences.append(NotificationAudience.OWNER)
department_ids = accessible_department_ids(user, "conversations.view")
department_ids = accessible_department_ids(profile, "conversations.view")
operator_scope = Q(audience=NotificationAudience.OPERATORS)
if department_ids is not None:
operator_scope &= Q(department_id__in=department_ids)
@@ -28,5 +30,5 @@ def visible_for(user) -> QuerySet[Notification]:
)
def unread_for(user) -> QuerySet[Notification]:
return visible_for(user).exclude(reads__user=user)
def unread_for(context: TenantContext) -> QuerySet[Notification]:
return visible_for(context).exclude(reads__user=context.actor_user)
@@ -27,7 +27,7 @@ TYPE_META: dict[str, dict] = {
def notify(
*,
organization,
context,
department=None,
type: str,
audience: str,
@@ -40,6 +40,7 @@ def notify(
dedup_key: str = "",
level: str | None = None,
) -> Notification | None:
organization = context.organization
if dedup_key and Notification.objects.filter(
organization=organization, dedup_key=dedup_key, created_at__gte=timezone.now() - timedelta(hours=24)
).exists():
@@ -70,15 +71,16 @@ def notify(
aggregate_id=str(notification.id),
event_type=NOTIFICATION_CREATED,
payload={"notificationId": notification.id},
tenant_context=context,
)
)
return notification
def mark_read(*, user, ids: list[int] | None = None, all_unread: bool = False) -> int:
queryset = unread_for(user)
def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int:
queryset = unread_for(context)
if not all_unread:
queryset = queryset.filter(id__in=ids or [])
rows = [NotificationRead(notification=n, user=user) for n in queryset]
rows = [NotificationRead(notification=n, user=context.actor_user) for n in queryset]
NotificationRead.objects.bulk_create(rows, ignore_conflicts=True)
return len(rows)
@@ -1,7 +1,7 @@
from unittest import mock
from django.test import TestCase
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient, tenant_context_for
from hub_platform.conversations.transports.base import InboundMessage
from hub_platform.events.handlers import dispatch
@@ -32,18 +32,19 @@ class NotifierTestBase(TestCase):
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.owner = HumanUser.objects.get(email="owner@edevs.tech")
self.context = tenant_context_for(self.owner, self.organization)
self.integration = _notifier(self.organization)
class BindingTests(NotifierTestBase):
def test_deep_link_for_telegram_and_max(self) -> None:
code = issue_binding_code(user=self.owner, integration=self.integration)
code = issue_binding_code(context=self.context, integration=self.integration)
self.assertEqual(deep_link(self.integration, code.code), f"https://t.me/edevs_notify_bot?start={code.code}")
max_bot = _notifier(self.organization, provider=IntegrationProvider.MAX, username="edevs_max_bot")
self.assertEqual(deep_link(max_bot, "abc"), "https://max.ru/edevs_max_bot?start=abc")
def test_start_code_creates_binding_and_confirms(self) -> None:
code = issue_binding_code(user=self.owner, integration=self.integration)
code = issue_binding_code(context=self.context, integration=self.integration)
inbound = InboundMessage(external_id="1", user_id="777", chat_id="777", text=f"/start {code.code}", display_name="Андрей")
with mock.patch("hub_platform.notifications.binding.transports.send_reply", return_value=True) as send:
handle_notifier_inbound(self.integration, inbound)
@@ -63,7 +64,7 @@ class BindingTests(NotifierTestBase):
# Уведомления идут в один мессенджер: привязка MAX заменяет привязку TG.
MessengerBinding.objects.create(user=self.owner, integration=self.integration, external_chat_id="111")
max_bot = _notifier(self.organization, provider=IntegrationProvider.MAX, username="edevs_max_bot")
code = issue_binding_code(user=self.owner, integration=max_bot)
code = issue_binding_code(context=self.context, integration=max_bot)
inbound = InboundMessage(external_id="3", user_id="9", chat_id="9", text=f"/start {code.code}", display_name="Андрей")
with mock.patch("hub_platform.notifications.binding.transports.send_reply", return_value=True):
handle_notifier_inbound(max_bot, inbound)
@@ -72,8 +73,8 @@ class BindingTests(NotifierTestBase):
self.assertEqual(bindings[0].integration_id, max_bot.id)
def test_reissue_invalidates_previous_code(self) -> None:
first = issue_binding_code(user=self.owner, integration=self.integration)
issue_binding_code(user=self.owner, integration=self.integration)
first = issue_binding_code(context=self.context, integration=self.integration)
issue_binding_code(context=self.context, integration=self.integration)
self.assertFalse(MessengerBindingCode.objects.filter(code=first.code).exists())
@@ -84,12 +85,12 @@ class DeliveryTests(NotifierTestBase):
def _dispatch_last_event(self) -> None:
event = OutboxEvent.objects.filter(event_type=NOTIFICATION_CREATED).latest("created_at")
dispatch(event.event_type, event.payload)
dispatch(event)
def test_notify_enqueues_and_delivers_to_binding(self) -> None:
with mock.patch("hub_platform.notifications.delivery.transports.send_reply", return_value=True) as send:
notify(
organization=self.organization,
context=self.context,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title="Новый диалог · Edevs — сайт",
@@ -103,7 +104,7 @@ class DeliveryTests(NotifierTestBase):
def test_type_not_in_push_types_is_skipped(self) -> None:
with mock.patch("hub_platform.notifications.delivery.transports.send_reply", return_value=True) as send:
notify(
organization=self.organization,
context=self.context,
type=NotificationType.LIMIT_REACHED,
audience=NotificationAudience.OWNER,
title="Достигнут лимит",
@@ -115,7 +116,7 @@ class DeliveryTests(NotifierTestBase):
operator = HumanUser.objects.get(email="a.kotova@edevs.tech")
with mock.patch("hub_platform.notifications.delivery.transports.send_reply", return_value=True) as send:
notify(
organization=self.organization,
context=self.context,
type=NotificationType.DIALOG_NEW_MESSAGE,
audience=NotificationAudience.USER,
recipient_user=operator,
@@ -129,21 +130,21 @@ class DeliveryTests(NotifierTestBase):
sales = self.organization.departments.get(code="sales")
support = self.organization.departments.get(code="support")
sales_notification = notify(
organization=self.organization,
context=self.context,
department=sales,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title="Sales dialog",
)
notify(
organization=self.organization,
context=self.context,
department=support,
type=NotificationType.DIALOG_WAITING,
audience=NotificationAudience.OPERATORS,
title="Support dialog",
)
self.assertEqual(
list(visible_for(operator).values_list("id", flat=True)),
list(visible_for(tenant_context_for(operator, self.organization)).values_list("id", flat=True)),
[sales_notification.id],
)
@@ -164,7 +165,7 @@ class PollerSelectionTests(NotifierTestBase):
provider=IntegrationProvider.TELEGRAM, name="client-bot", secret="token", channel=channel,
)
with mock.patch("hub_platform.conversations.poller.transports.poll", return_value=([], "")) as poll:
poller.poll_all_messengers()
poller.poll_all_messengers(self.context)
polled_ids = [call.args[0].id for call in poll.call_args_list]
self.assertIn(client_bot.id, polled_ids)
self.assertNotIn(self.integration.id, polled_ids)
@@ -16,14 +16,14 @@ class NotificationListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
items = list(visible_for(request.user)[:_LIST_LIMIT])
items = list(visible_for(request.tenant_context)[:_LIST_LIMIT])
read_ids = set(
NotificationRead.objects.filter(user=request.user, notification__in=items).values_list("notification_id", flat=True)
)
return Response(
{
"items": [notification_payload(n, unread=n.id not in read_ids) for n in items],
"unreadCount": unread_for(request.user).count(),
"unreadCount": unread_for(request.tenant_context).count(),
}
)
@@ -34,13 +34,15 @@ class MessengerBindingListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request: Request) -> Response:
organization_id = request.user.employee_profile.organization_id
bindings = {
binding.integration_id: binding
for binding in MessengerBinding.objects.filter(user=request.user, integration__organization_id=organization_id)
for binding in MessengerBinding.objects.filter(
user=request.user,
integration__organization=request.tenant_context.organization,
)
}
items = []
for integration in notifier_integrations(organization_id):
for integration in notifier_integrations(request.tenant_context):
binding = bindings.get(integration.id)
items.append(
{
@@ -61,14 +63,16 @@ class MessengerBindingDetailView(APIView):
permission_classes = [IsAuthenticated]
def _integration(self, request: Request, integration_id: int):
return notifier_integrations(request.user.employee_profile.organization_id).filter(id=integration_id).first()
return notifier_integrations(request.tenant_context).filter(id=integration_id).first()
def post(self, request: Request, integration_id: int) -> Response:
"""Выдать одноразовый код привязки и deep-link на бота."""
integration = self._integration(request, integration_id)
if integration is None:
return Response({"detail": "Бот уведомлений не найден"}, status=404)
binding_code = issue_binding_code(user=request.user, integration=integration)
binding_code = issue_binding_code(
context=request.tenant_context, integration=integration
)
return Response(
{
"code": binding_code.code,
@@ -100,10 +104,10 @@ class NotificationReadView(APIView):
def post(self, request: Request) -> Response:
if request.data.get("all"):
mark_read(user=request.user, all_unread=True)
mark_read(context=request.tenant_context, all_unread=True)
else:
ids = request.data.get("ids")
if not isinstance(ids, list):
return Response({"detail": "ids must be a list or use all=true"}, status=400)
mark_read(user=request.user, ids=[i for i in ids if isinstance(i, int)])
return Response({"unreadCount": unread_for(request.user).count()})
mark_read(context=request.tenant_context, ids=[i for i in ids if isinstance(i, int)])
return Response({"unreadCount": unread_for(request.tenant_context).count()})
@@ -10,6 +10,7 @@ import secrets
from django.core.management.base import BaseCommand, CommandError
from hub_platform.identity.models import Organization
from hub_platform.orders.services import hash_ingest_token
from hub_platform.products.models import Product
@@ -19,10 +20,15 @@ class Command(BaseCommand):
def add_arguments(self, parser) -> None:
parser.add_argument("product_code")
parser.add_argument("--organization", required=True, help="Organization public UUID")
def handle(self, *args: object, **options: object) -> None:
code = str(options["product_code"])
product = Product.objects.filter(code=code).first()
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError) as error:
raise CommandError("Unknown organization public UUID") from error
product = Product.objects.filter(organization=organization, code=code).first()
if product is None:
raise CommandError(f"product '{code}' not found")
token = secrets.token_urlsafe(32)
@@ -14,17 +14,25 @@ from hub_platform.identity.models import Organization
from hub_platform.orders.models import Order
from hub_platform.orders.services import OrderItemInput, create_order, mark_paid
from hub_platform.products.models import Offer
from hub_platform.tenancy.context import TenantActorKind, TenantContext
class Command(BaseCommand):
help = "Seed demo orders for local development (idempotent)."
def add_arguments(self, parser) -> None:
parser.add_argument("--organization", required=True, help="Organization public UUID")
@transaction.atomic
def handle(self, *args: object, **options: object) -> None:
organization = Organization.objects.first()
if organization is None:
self.stderr.write("no organization — run bootstrap_owner first")
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError):
self.stderr.write("organization not found")
return
context = TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
if Order.objects.filter(organization=organization).exists():
self.stdout.write("orders already present — skipping")
return
@@ -37,8 +45,12 @@ class Command(BaseCommand):
created = 0
for index, contact in enumerate(contacts):
offer = offers[index % len(offers)]
order = create_order(organization=organization, contact=contact, items=[OrderItemInput(offer_id=offer.id, quantity=1)])
order = create_order(
context=context,
contact=contact,
items=[OrderItemInput(offer_id=offer.id, quantity=1)],
)
if index % 3 != 0: # часть оставляем в ожидании оплаты
mark_paid(order=order)
mark_paid(context=context, order=order)
created += 1
self.stdout.write(self.style.SUCCESS(f"seeded {created} demo orders"))
@@ -0,0 +1,7 @@
from django.urls import path
from hub_platform.orders import views
urlpatterns = [
path("ingest/", views.OrderIngestView.as_view(), name="order-ingest"),
]
@@ -1,16 +1,17 @@
from django.db.models import QuerySet
from hub_platform.orders.models import Order
from hub_platform.tenancy.context import TenantContext
def orders_for_organization(organization_id: int) -> QuerySet[Order]:
def orders_for_context(context: TenantContext) -> QuerySet[Order]:
return (
Order.objects.filter(organization_id=organization_id)
Order.objects.filter(organization_id=context.organization_id)
.select_related("contact", "product", "channel", "conversation")
.prefetch_related("items")
.order_by("-created_at")
)
def order_for_organization(*, organization_id: int, order_id: int) -> Order:
return orders_for_organization(organization_id).get(id=order_id)
def order_for_context(*, context: TenantContext, order_id: int) -> Order:
return orders_for_context(context).get(id=order_id)
+22 -5
View File
@@ -8,6 +8,7 @@ from django.utils import timezone
from hub_platform.conversations.models import Contact, Conversation
from hub_platform.orders.models import FulfillmentStatus, Order, OrderItem, PaymentStatus
from hub_platform.products.models import Offer, Price, Product
from hub_platform.tenancy.context import TenantContext
def hash_ingest_token(token: str) -> str:
@@ -26,7 +27,14 @@ def _active_price(offer: Offer) -> Price | None:
@transaction.atomic
def create_order(*, organization, contact: Contact, items: list[OrderItemInput], conversation: Conversation | None = None) -> Order:
def create_order(
*,
context: TenantContext,
contact: Contact,
items: list[OrderItemInput],
conversation: Conversation | None = None,
) -> Order:
organization = context.organization
if not items:
raise ValidationError({"items": "Order needs at least one item"})
if contact.organization_id != organization.id:
@@ -73,7 +81,9 @@ def create_order(*, organization, contact: Contact, items: list[OrderItemInput],
return order
def mark_paid(*, order: Order) -> Order:
def mark_paid(*, context: TenantContext, order: Order) -> Order:
if order.organization_id != context.organization_id:
raise ValidationError({"order": "Order belongs to another organization"})
if order.payment_status != PaymentStatus.PAID:
order.payment_status = PaymentStatus.PAID
order.paid_at = timezone.now()
@@ -83,13 +93,17 @@ def mark_paid(*, order: Order) -> Order:
return order
def cancel_order(*, order: Order) -> Order:
def cancel_order(*, context: TenantContext, order: Order) -> Order:
if order.organization_id != context.organization_id:
raise ValidationError({"order": "Order belongs to another organization"})
order.payment_status = PaymentStatus.CANCELLED
order.save(update_fields=["payment_status", "updated_at"])
return order
def set_fulfillment(*, order: Order, status: str) -> Order:
def set_fulfillment(*, context: TenantContext, order: Order, status: str) -> Order:
if order.organization_id != context.organization_id:
raise ValidationError({"order": "Order belongs to another organization"})
if status not in FulfillmentStatus.values:
raise ValidationError({"fulfillmentStatus": "Unknown status"})
order.fulfillment_status = status
@@ -115,6 +129,7 @@ def resolve_product_by_token(token: str) -> Product | None:
@transaction.atomic
def ingest_order(
*,
context: TenantContext,
product: Product,
external_id: str,
items: list[IngestItemInput],
@@ -128,7 +143,9 @@ def ingest_order(
raise ValidationError({"paymentStatus": "Unknown status"})
if not items:
raise ValidationError({"items": "Order needs at least one item"})
organization = product.organization
organization = context.organization
if product.organization_id != context.organization_id:
raise ValidationError({"product": "Product belongs to another organization"})
# Идемпотентность по (организация, продукт, внешний id).
if external_id:
+11 -10
View File
@@ -2,7 +2,7 @@ import json
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient, system_tenant_context
from hub_platform.conversations.models import Contact
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
@@ -16,6 +16,7 @@ class OrdersTestBase(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.context = system_tenant_context(self.organization)
self.contact = Contact.objects.create(organization=self.organization, name="Тестовый клиент")
product = Product.objects.get(code="firepage")
self.offer = Offer.objects.create(
@@ -31,15 +32,15 @@ class OrdersTestBase(TestCase):
class OrderServiceTests(OrdersTestBase):
def test_create_order_uses_active_price(self) -> None:
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
order = create_order(context=self.context, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
self.assertEqual(order.amount_minor, 490_000)
self.assertEqual(order.payment_status, PaymentStatus.PENDING)
self.assertEqual(order.items.count(), 1)
self.assertEqual(order.product_id, self.offer.product_id)
def test_mark_paid_sets_paid_at_and_fulfillment(self) -> None:
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(order=order)
order = create_order(context=self.context, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(context=self.context, order=order)
order.refresh_from_db()
self.assertEqual(order.payment_status, PaymentStatus.PAID)
self.assertIsNotNone(order.paid_at)
@@ -60,9 +61,9 @@ class OrderApiTests(OrdersTestBase):
self.assertEqual(paid.json()["order"]["paymentStatus"], PaymentStatus.PAID)
def test_list_filters_by_payment_status(self) -> None:
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(order=order)
create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
order = create_order(context=self.context, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(context=self.context, order=order)
create_order(context=self.context, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
response = self.client.get("/api/v1/orders/?paymentStatus=PAID")
self.assertEqual(response.status_code, 200)
@@ -73,9 +74,9 @@ class OrderApiTests(OrdersTestBase):
def test_paid_order_reflected_in_sales_stats(self) -> None:
from hub_platform.conversations.stats import sales_overview_stats
order = create_order(organization=self.organization, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(order=order)
stats = sales_overview_stats(self.organization.id, "d30")["period"]
order = create_order(context=self.context, contact=self.contact, items=[OrderItemInput(offer_id=self.offer.id)])
mark_paid(context=self.context, order=order)
stats = sales_overview_stats(self.context, "d30")["period"]
self.assertEqual(stats["sales"], 1)
self.assertEqual(stats["revenueMinor"], 490_000)
-1
View File
@@ -4,7 +4,6 @@ from hub_platform.orders import views
urlpatterns = [
path("", views.OrderListCreateView.as_view(), name="order-list"),
path("ingest/", views.OrderIngestView.as_view(), name="order-ingest"),
path("<int:order_id>/", views.OrderDetailView.as_view(), name="order-detail"),
path("<int:order_id>/mark-paid/", views.OrderMarkPaidView.as_view(), name="order-mark-paid"),
path("<int:order_id>/cancel/", views.OrderCancelView.as_view(), name="order-cancel"),
+25 -15
View File
@@ -8,7 +8,7 @@ from hub_platform.api.permissions import HasCapability
from hub_platform.conversations.models import Contact, Conversation
from hub_platform.identity.audit import record_audit_event
from hub_platform.orders.models import Order
from hub_platform.orders.selectors import order_for_organization, orders_for_organization
from hub_platform.orders.selectors import order_for_context, orders_for_context
from hub_platform.orders.serializers import order_payload
from hub_platform.orders.services import (
IngestItemInput,
@@ -21,6 +21,7 @@ from hub_platform.orders.services import (
set_fulfillment,
)
from hub_platform.identity.policy import accessible_department_ids, require_capability
from hub_platform.tenancy.context import TenantContext
def _validation_error(error: ValidationError) -> Response:
@@ -30,7 +31,7 @@ def _validation_error(error: ValidationError) -> Response:
class _Base(APIView):
def _org(self, request: Request):
return request.user.employee_profile.organization
return request.tenant_context.organization
def _audit(self, request: Request, action: str, order: Order) -> None:
record_audit_event(
@@ -48,8 +49,8 @@ class OrderListCreateView(_Base):
required_capabilities = {"GET": "sales.view", "POST": "sales.operate"}
def get(self, request: Request) -> Response:
orders = orders_for_organization(self._org(request).id)
department_ids = accessible_department_ids(request.user, "sales.view")
orders = orders_for_context(request.tenant_context)
department_ids = accessible_department_ids(request.tenant_context.membership, "sales.view")
if department_ids is not None:
orders = orders.filter(conversation__channel__department_id__in=department_ids)
status_filter = request.query_params.get("paymentStatus")
@@ -76,14 +77,14 @@ class OrderListCreateView(_Base):
contact = Contact.objects.get(id=int(request.data.get("contactId", 0)), organization=org)
except (Contact.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Contact not found"}, status=400)
department_ids = accessible_department_ids(request.user, "sales.operate")
department_ids = accessible_department_ids(request.tenant_context.membership, "sales.operate")
conversation = None
if request.data.get("conversationId"):
conversation = Conversation.objects.filter(
id=request.data.get("conversationId"), organization=org, contact=contact
).select_related("channel").first()
if conversation is None or not require_capability(
request.user, "sales.operate", conversation
request.tenant_context.membership, "sales.operate", conversation
):
return Response({"detail": "Conversation not found"}, status=404)
if department_ids is not None and conversation is None:
@@ -92,12 +93,15 @@ class OrderListCreateView(_Base):
)
try:
order = create_order(
organization=org, contact=contact, items=items, conversation=conversation
context=request.tenant_context,
contact=contact,
items=items,
conversation=conversation,
)
except ValidationError as error:
return _validation_error(error)
self._audit(request, "created", order)
order = order_for_organization(organization_id=org.id, order_id=order.id)
order = order_for_context(context=request.tenant_context, order_id=order.id)
return Response({"order": order_payload(order, with_items=True)}, status=201)
@@ -111,6 +115,7 @@ class OrderIngestView(APIView):
product = resolve_product_by_token(token)
if product is None:
return Response({"detail": "Invalid product token"}, status=401)
context = TenantContext.for_resource(product.organization)
raw_items = request.data.get("items")
if not isinstance(raw_items, list) or not raw_items:
@@ -135,6 +140,7 @@ class OrderIngestView(APIView):
try:
order, created = ingest_order(
context=context,
product=product,
external_id=str(request.data.get("externalId", "")).strip(),
items=items,
@@ -163,10 +169,10 @@ class OrderDetailView(_Base):
def get(self, request: Request, order_id: int) -> Response:
try:
order = order_for_organization(organization_id=self._org(request).id, order_id=order_id)
order = order_for_context(context=request.tenant_context, order_id=order_id)
except Order.DoesNotExist:
return Response({"detail": "Order not found"}, status=404)
if not require_capability(request.user, self.required_capability, order):
if not require_capability(request.tenant_context.membership, self.required_capability, order):
return Response({"detail": "Order not found"}, status=404)
return Response({"order": order_payload(order, with_items=True)})
@@ -176,8 +182,8 @@ class _OrderActionView(_Base):
required_capability = "sales.correct"
def _order(self, request: Request, order_id: int) -> Order:
order = order_for_organization(organization_id=self._org(request).id, order_id=order_id)
if not require_capability(request.user, self.required_capability, order):
order = order_for_context(context=request.tenant_context, order_id=order_id)
if not require_capability(request.tenant_context.membership, self.required_capability, order):
raise Order.DoesNotExist
return order
@@ -188,7 +194,7 @@ class OrderMarkPaidView(_OrderActionView):
order = self._order(request, order_id)
except Order.DoesNotExist:
return Response({"detail": "Order not found"}, status=404)
mark_paid(order=order)
mark_paid(context=request.tenant_context, order=order)
self._audit(request, "paid", order)
return Response({"order": order_payload(self._order(request, order_id), with_items=True)})
@@ -199,7 +205,7 @@ class OrderCancelView(_OrderActionView):
order = self._order(request, order_id)
except Order.DoesNotExist:
return Response({"detail": "Order not found"}, status=404)
cancel_order(order=order)
cancel_order(context=request.tenant_context, order=order)
self._audit(request, "cancelled", order)
return Response({"order": order_payload(self._order(request, order_id), with_items=True)})
@@ -211,7 +217,11 @@ class OrderFulfillmentView(_OrderActionView):
except Order.DoesNotExist:
return Response({"detail": "Order not found"}, status=404)
try:
set_fulfillment(order=order, status=str(request.data.get("fulfillmentStatus", "")))
set_fulfillment(
context=request.tenant_context,
order=order,
status=str(request.data.get("fulfillmentStatus", "")),
)
except ValidationError as error:
return _validation_error(error)
self._audit(request, "fulfillment_set", order)
@@ -13,7 +13,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from django.core.management.base import BaseCommand
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from hub_platform.products.models import (
@@ -24,6 +24,8 @@ from hub_platform.products.models import (
Price,
Product,
)
from hub_platform.identity.models import Organization
from hub_platform.tenancy.context import TenantActorKind, TenantContext
VALID_FROM = datetime(2026, 1, 1, tzinfo=timezone.utc)
@@ -94,12 +96,25 @@ CATALOG: dict[str, list[dict]] = {
class Command(BaseCommand):
help = "Seed FirePage and Foxray catalog offers and prices (idempotent)."
def add_arguments(self, parser) -> None:
parser.add_argument("--organization", required=True)
@transaction.atomic
def handle(self, *args: object, **options: object) -> None:
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError) as error:
raise CommandError("Unknown organization public UUID") from error
context = TenantContext.for_resource(
organization, actor_kind=TenantActorKind.SYSTEM
)
created_offers = 0
created_prices = 0
for product_code, offers in CATALOG.items():
product = Product.objects.filter(code=product_code).first()
product = Product.objects.filter(
organization=context.organization,
code=product_code,
).first()
if product is None:
self.stderr.write(f"product '{product_code}' not found — skipped")
continue
@@ -9,7 +9,7 @@ from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.policy import accessible_department_ids
from hub_platform.products.models import Offer, Product
from hub_platform.products.selectors import product_for_organization
from hub_platform.products.selectors import product_for_context
from hub_platform.products.serializers import product_payload
from hub_platform.products.services import (
OfferInput,
@@ -44,10 +44,10 @@ class _ProductScopedView(APIView):
required_capability = "products.manage"
def _product(self, request: Request, product_id: int) -> Product:
product = product_for_organization(
organization_id=request.user.employee_profile.organization_id, product_id=product_id
product = product_for_context(
context=request.tenant_context, product_id=product_id
)
department_ids = accessible_department_ids(request.user, self.required_capability)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
if department_ids is not None and not product.department_links.filter(
department_id__in=department_ids
).exists():
@@ -58,7 +58,7 @@ class _ProductScopedView(APIView):
record_audit_event(
action=action,
actor=request.user,
organization=request.user.employee_profile.organization,
organization=request.tenant_context.organization,
object_type=object_type,
object_id=str(object_id),
request=request,
@@ -76,7 +76,11 @@ class OfferCreateView(_ProductScopedView):
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
try:
offer = create_offer(product=product, data=_offer_input(request.data))
offer = create_offer(
context=request.tenant_context,
product=product,
data=_offer_input(request.data),
)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
self._audit(request, "products.offer_created", "Offer", offer.id)
@@ -91,7 +95,11 @@ class OfferUpdateView(_ProductScopedView):
except (Product.DoesNotExist, Offer.DoesNotExist):
return Response({"detail": "Offer not found"}, status=404)
try:
update_offer(offer=offer, data=_offer_input(request.data, current=offer))
update_offer(
context=request.tenant_context,
offer=offer,
data=_offer_input(request.data, current=offer),
)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
self._audit(request, "products.offer_updated", "Offer", offer.id)
@@ -118,7 +126,9 @@ class PriceCreateView(_ProductScopedView):
valid_from=parse_datetime(valid_from_raw) if valid_from_raw else None,
)
try:
price = add_price_version(offer=offer, data=price_input)
price = add_price_version(
context=request.tenant_context, offer=offer, data=price_input
)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
self._audit(request, "products.price_version_added", "Price", price.id)
@@ -1,16 +1,17 @@
from django.db.models import Prefetch, QuerySet
from hub_platform.products.models import Offer, Product
from hub_platform.tenancy.context import TenantContext
def products_for_organization(organization_id: int) -> QuerySet[Product]:
def products_for_context(context: TenantContext) -> QuerySet[Product]:
offers = Offer.objects.prefetch_related("prices").order_by("name")
return (
Product.objects.filter(organization_id=organization_id)
Product.objects.filter(organization_id=context.organization_id)
.prefetch_related("department_links__department", Prefetch("offers", queryset=offers))
.order_by("name")
)
def product_for_organization(*, organization_id: int, product_id: int) -> Product:
return products_for_organization(organization_id).get(id=product_id)
def product_for_context(*, context: TenantContext, product_id: int) -> Product:
return products_for_context(context).get(id=product_id)
+20 -6
View File
@@ -7,6 +7,7 @@ from django.utils import timezone
from hub_platform.identity.models import Department, Organization
from hub_platform.products.models import Offer, Price, Product, ProductDepartment, ProductStatus
from hub_platform.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -27,7 +28,8 @@ def _departments(organization: Organization, department_ids: tuple[int, ...]) ->
@transaction.atomic
def create_product(*, organization: Organization, data: ProductInput) -> Product:
def create_product(*, context: TenantContext, data: ProductInput) -> Product:
organization = context.organization
product = Product(
organization=organization,
code=data.code.strip().lower(),
@@ -43,7 +45,9 @@ def create_product(*, organization: Organization, data: ProductInput) -> Product
@transaction.atomic
def update_product(*, product: Product, data: ProductInput) -> Product:
def update_product(*, context: TenantContext, product: Product, data: ProductInput) -> Product:
if product.organization_id != context.organization_id:
raise ValidationError({"product": "Product belongs to another organization"})
product.name = data.name.strip()
product.site_url = data.site_url.strip()
product.full_clean(exclude=["code"])
@@ -55,7 +59,11 @@ def update_product(*, product: Product, data: ProductInput) -> Product:
return product
def set_product_status(*, product: Product, status: ProductStatus) -> Product:
def set_product_status(
*, context: TenantContext, product: Product, status: ProductStatus
) -> Product:
if product.organization_id != context.organization_id:
raise ValidationError({"product": "Product belongs to another organization"})
if product.status != status:
product.status = status
product.save(update_fields=["status", "updated_at"])
@@ -87,7 +95,9 @@ def _primary_box_offer(product: Product, primary_box_offer_id: int | None) -> Of
@transaction.atomic
def create_offer(*, product: Product, data: OfferInput) -> Offer:
def create_offer(*, context: TenantContext, product: Product, data: OfferInput) -> Offer:
if product.organization_id != context.organization_id:
raise ValidationError({"product": "Product belongs to another organization"})
offer = Offer(
product=product,
code=data.code.strip().lower(),
@@ -105,7 +115,9 @@ def create_offer(*, product: Product, data: OfferInput) -> Offer:
@transaction.atomic
def update_offer(*, offer: Offer, data: OfferInput) -> Offer:
def update_offer(*, context: TenantContext, offer: Offer, data: OfferInput) -> Offer:
if offer.product.organization_id != context.organization_id:
raise ValidationError({"offer": "Offer belongs to another organization"})
# Код предложения неизменяем; всё остальное редактируется.
offer.name = data.name.strip()
offer.description = data.description.strip()
@@ -131,7 +143,9 @@ class PriceInput:
@transaction.atomic
def add_price_version(*, offer: Offer, data: PriceInput) -> Price:
def add_price_version(*, context: TenantContext, offer: Offer, data: PriceInput) -> Price:
if offer.product.organization_id != context.organization_id:
raise ValidationError({"offer": "Offer belongs to another organization"})
# Новая версия цены архивирует прежнюю активную в той же валюте/периоде.
valid_from = data.valid_from or timezone.now()
same_line = Price.objects.filter(offer=offer, currency=data.currency, billing_period=data.billing_period)
+1 -1
View File
@@ -5,7 +5,7 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from hub_platform.testing import TenantAPIClient as APIClient
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Department, Organization
+21 -17
View File
@@ -7,7 +7,7 @@ from rest_framework.views import APIView
from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from hub_platform.products.models import Product, ProductStatus
from hub_platform.products.selectors import product_for_organization, products_for_organization
from hub_platform.products.selectors import product_for_context, products_for_context
from hub_platform.products.serializers import product_payload
from hub_platform.products.services import ProductInput, create_product, set_product_status, update_product
from hub_platform.identity.policy import accessible_department_ids
@@ -43,12 +43,12 @@ def _validation_error(error: Exception) -> Response:
def _departments_allowed(request: Request, capability: str, department_ids) -> bool:
accessible = accessible_department_ids(request.user, capability)
accessible = accessible_department_ids(request.tenant_context.membership, capability)
return accessible is None or set(department_ids).issubset(accessible)
def _product_allowed(request: Request, capability: str, product: Product) -> bool:
accessible = accessible_department_ids(request.user, capability)
accessible = accessible_department_ids(request.tenant_context.membership, capability)
return accessible is None or product.department_links.filter(
department_id__in=accessible
).exists()
@@ -59,8 +59,8 @@ class ProductListView(APIView):
required_capability = "products.view"
def get(self, request: Request) -> Response:
products = products_for_organization(request.user.employee_profile.organization_id)
department_ids = accessible_department_ids(request.user, self.required_capability)
products = products_for_context(request.tenant_context)
department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability)
if department_ids is not None:
products = products.filter(department_links__department_id__in=department_ids).distinct()
return Response({"items": [product_payload(product) for product in products]})
@@ -71,12 +71,12 @@ class ProductCreateView(APIView):
required_capability = "products.manage"
def post(self, request: Request) -> Response:
profile = request.user.employee_profile
profile = request.tenant_context.membership
data = _input(request.data)
if not _departments_allowed(request, self.required_capability, data.department_ids):
return Response({"detail": "Product departments are outside access scope"}, status=403)
try:
product = create_product(organization=profile.organization, data=data)
product = create_product(context=request.tenant_context, data=data)
except (ValidationError, IntegrityError) as error:
return _validation_error(error)
record_audit_event(
@@ -87,7 +87,7 @@ class ProductCreateView(APIView):
object_id=str(product.id),
request=request,
)
product = product_for_organization(organization_id=profile.organization_id, product_id=product.id)
product = product_for_context(context=request.tenant_context, product_id=product.id)
return Response({"product": product_payload(product)}, status=201)
@@ -96,9 +96,9 @@ class ProductDetailView(APIView):
required_capability = "products.view"
def get(self, request: Request, product_id: int) -> Response:
profile = request.user.employee_profile
profile = request.tenant_context.membership
try:
product = product_for_organization(organization_id=profile.organization_id, product_id=product_id)
product = product_for_context(context=request.tenant_context, product_id=product_id)
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
if not _product_allowed(request, self.required_capability, product):
@@ -111,9 +111,9 @@ class ProductUpdateView(APIView):
required_capability = "products.manage"
def patch(self, request: Request, product_id: int) -> Response:
profile = request.user.employee_profile
profile = request.tenant_context.membership
try:
product = product_for_organization(organization_id=profile.organization_id, product_id=product_id)
product = product_for_context(context=request.tenant_context, product_id=product_id)
if not _product_allowed(request, self.required_capability, product):
raise Product.DoesNotExist
data = _input(request.data, current=product)
@@ -121,7 +121,9 @@ class ProductUpdateView(APIView):
return Response(
{"detail": "Product departments are outside access scope"}, status=403
)
product = update_product(product=product, data=data)
product = update_product(
context=request.tenant_context, product=product, data=data
)
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
except (ValidationError, IntegrityError) as error:
@@ -134,7 +136,7 @@ class ProductUpdateView(APIView):
object_id=str(product.id),
request=request,
)
product = product_for_organization(organization_id=profile.organization_id, product_id=product.id)
product = product_for_context(context=request.tenant_context, product_id=product.id)
return Response({"product": product_payload(product)})
@@ -144,14 +146,16 @@ class ProductStatusView(APIView):
status_value: ProductStatus
def post(self, request: Request, product_id: int) -> Response:
profile = request.user.employee_profile
profile = request.tenant_context.membership
try:
product = Product.objects.get(id=product_id, organization=profile.organization)
except Product.DoesNotExist:
return Response({"detail": "Product not found"}, status=404)
if not _product_allowed(request, self.required_capability, product):
return Response({"detail": "Product not found"}, status=404)
set_product_status(product=product, status=self.status_value)
set_product_status(
context=request.tenant_context, product=product, status=self.status_value
)
record_audit_event(
action=f"products.product_{self.status_value.lower()}",
actor=request.user,
@@ -160,7 +164,7 @@ class ProductStatusView(APIView):
object_id=str(product.id),
request=request,
)
product = product_for_organization(organization_id=profile.organization_id, product_id=product.id)
product = product_for_context(context=request.tenant_context, product_id=product.id)
return Response({"product": product_payload(product)})
+3 -1
View File
@@ -11,6 +11,7 @@ from django.db.models import Count, F, Sum
from django.db.models.functions import Coalesce
from hub_platform.sales.models import Environment, ProcessingStatus, Sale, SaleEvent, SaleStatus
from hub_platform.tenancy.context import TenantContext
# Статусы, формирующие действующую выручку.
_REVENUE_STATUSES = (SaleStatus.CONFIRMED, SaleStatus.PARTIALLY_REFUNDED)
@@ -20,7 +21,8 @@ def _net_expr():
return F("amount_minor") - F("refunded_amount_minor")
def sales_analytics(organization_id: int, *, date_from=None, date_to=None) -> dict[str, object]:
def sales_analytics(context: TenantContext, *, date_from=None, date_to=None) -> dict[str, object]:
organization_id = context.organization_id
base = Sale.objects.filter(organization_id=organization_id, environment=Environment.PRODUCTION)
if date_from:
base = base.filter(occurred_at__gte=date_from)
@@ -9,6 +9,7 @@ from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from hub_platform.identity.models import Organization
from hub_platform.products.models import Product
from hub_platform.sales.models import Environment, SalesSource, SalesSourceType
from hub_platform.sales.services import issue_sales_source_credential
@@ -19,12 +20,19 @@ class Command(BaseCommand):
def add_arguments(self, parser) -> None:
parser.add_argument("product_code")
parser.add_argument("--organization", required=True, help="Organization public UUID")
parser.add_argument("--code", default="product-api", help="SalesSource code (unique per product)")
parser.add_argument("--environment", default=Environment.PRODUCTION, choices=Environment.values)
def handle(self, *args: object, **options: object) -> None:
code = str(options["product_code"])
product = Product.objects.filter(code=code).select_related("organization").first()
try:
organization = Organization.objects.get(public_id=options["organization"])
except (Organization.DoesNotExist, ValueError) as error:
raise CommandError("Unknown organization public UUID") from error
product = Product.objects.filter(
organization=organization, code=code
).select_related("organization").first()
if product is None:
raise CommandError(f"product '{code}' not found")
+7 -6
View File
@@ -3,18 +3,19 @@ from __future__ import annotations
from django.db.models import QuerySet
from hub_platform.sales.models import Environment, Sale, SaleEvent
from hub_platform.tenancy.context import TenantContext
def sales_for_organization(organization_id: int) -> QuerySet[Sale]:
def sales_for_context(context: TenantContext) -> QuerySet[Sale]:
return (
Sale.objects.filter(organization_id=organization_id)
Sale.objects.filter(organization_id=context.organization_id)
.select_related("product", "contact", "conversation", "sales_source", "last_event")
.order_by("-occurred_at")
)
def sale_for_organization(*, organization_id: int, sale_id: int) -> Sale:
return sales_for_organization(organization_id).get(id=sale_id)
def sale_for_context(*, context: TenantContext, sale_id: int) -> Sale:
return sales_for_context(context).get(id=sale_id)
def events_for_sale(sale: Sale) -> QuerySet[SaleEvent]:
@@ -64,5 +65,5 @@ def apply_sale_filters(qs: QuerySet[Sale], params) -> QuerySet[Sale]:
return qs
def sale_events_with_errors(organization_id: int) -> QuerySet[SaleEvent]:
return SaleEvent.objects.filter(organization_id=organization_id).exclude(processing_error="")
def sale_events_with_errors(context: TenantContext) -> QuerySet[SaleEvent]:
return SaleEvent.objects.filter(organization_id=context.organization_id).exclude(processing_error="")
+27 -6
View File
@@ -186,7 +186,11 @@ class IngestResult:
duplicate: bool
def record_product_sales_event(*, source: SalesSource, payload: dict[str, Any]) -> IngestResult:
def record_product_sales_event(
*, context, source: SalesSource, payload: dict[str, Any]
) -> IngestResult:
if source.organization_id != context.organization_id:
raise CredentialError("Sales source belongs to another organization")
if source.status == SalesSourceStatus.DISABLED:
raise CredentialError("Sales source is disabled")
@@ -405,8 +409,7 @@ def _resolve_attribution(*, sale: Sale, source: SalesSource | None, parsed: Pars
@transaction.atomic
def create_manual_sale(
*,
organization,
actor_user,
context,
product: Product,
amount_minor: int,
currency: str,
@@ -419,6 +422,13 @@ def create_manual_sale(
line_items: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
) -> Sale:
organization = context.organization
actor_user = context.actor_user
if actor_user is None:
raise InvalidPayload("Manual sale requires a human actor")
for resource in (product, contact, conversation):
if resource is not None and resource.organization_id != context.organization_id:
raise InvalidPayload("Manual sale resource belongs to another organization")
if contact is None and conversation is None:
raise InvalidPayload("Manual sale needs a contact or conversation")
if conversation is not None and contact is None:
@@ -498,8 +508,8 @@ def create_manual_sale(
@transaction.atomic
def record_manual_action(
*,
context,
sale: Sale,
actor_user,
event_type: str,
reason: str,
amount_minor: int | None = None,
@@ -514,7 +524,12 @@ def record_manual_action(
}:
raise InvalidPayload("Unsupported manual event_type")
locked = Sale.objects.select_for_update().get(pk=sale.pk)
actor_user = context.actor_user
if actor_user is None:
raise InvalidPayload("Manual action requires a human actor")
locked = Sale.objects.select_for_update().get(
pk=sale.pk, organization=context.organization
)
occurred_at = timezone.now()
meta = {"reason": reason} if reason else {}
@@ -564,7 +579,7 @@ def record_manual_action(
@transaction.atomic
def issue_attribution_token(
*,
organization,
context,
product: Product,
contact: Contact,
conversation: Conversation,
@@ -576,6 +591,12 @@ def issue_attribution_token(
ttl_hours: int = 72,
metadata: dict[str, Any] | None = None,
) -> tuple[AttributionToken, str]:
organization = context.organization
for resource in (product, contact, conversation, channel, connection):
if resource is not None and resource.organization_id != context.organization_id:
raise InvalidPayload("Attribution resource belongs to another organization")
if offer is not None and offer.product.organization_id != context.organization_id:
raise InvalidPayload("Attribution offer belongs to another organization")
if actor_type not in ActorType.values:
raise InvalidPayload("Unknown actor_type")
raw = secrets.token_urlsafe(32)
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more