diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index 823b1e0..c3feb81 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -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 = [ diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 55c7dc8..4331652 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -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//company/", + include("hub_platform.identity.company_urls"), + ), + path( + "api/v1/organizations//company/", + include("hub_platform.products.urls"), + ), + path( + "api/v1/organizations//employees/", + include("hub_platform.identity.employee_urls"), + ), + path( + "api/v1/organizations//access-profiles/", + include("hub_platform.identity.access_urls"), + ), + path("api/v1/organizations//ai/", include("hub_platform.ai.urls")), + path( + "api/v1/organizations//integrations/", + include("hub_platform.integrations.urls"), + ), + path( + "api/v1/organizations//channels/", + include("hub_platform.channels.urls"), + ), + path( + "api/v1/organizations//conversations/", + include("hub_platform.conversations.urls"), + ), + path( + "api/v1/organizations//orders/", + include("hub_platform.orders.urls"), + ), + path("api/v1/organizations//sales/", include("hub_platform.sales.urls")), + path( + "api/v1/organizations//notifications/", + include("hub_platform.notifications.urls"), + ), + path( + "api/v1/organizations//support/", + include("hub_platform.support.urls"), + ), + path("api/v1/organizations//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")), ] diff --git a/apps/backend/hub_platform/ai/content_importer.py b/apps/backend/hub_platform/ai/content_importer.py index fda9b94..6d74f02 100644 --- a/apps/backend/hub_platform/ai/content_importer.py +++ b/apps/backend/hub_platform/ai/content_importer.py @@ -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[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] = [] diff --git a/apps/backend/hub_platform/ai/public_urls.py b/apps/backend/hub_platform/ai/public_urls.py new file mode 100644 index 0000000..3070336 --- /dev/null +++ b/apps/backend/hub_platform/ai/public_urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from hub_platform.ai import views + +urlpatterns = [ + path("files//", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"), +] diff --git a/apps/backend/hub_platform/ai/selectors.py b/apps/backend/hub_platform/ai/selectors.py index 033a831..969c51d 100644 --- a/apps/backend/hub_platform/ai/selectors.py +++ b/apps/backend/hub_platform/ai/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/ai/services.py b/apps/backend/hub_platform/ai/services.py index 70051f3..a6d98e6 100644 --- a/apps/backend/hub_platform/ai/services.py +++ b/apps/backend/hub_platform/ai/services.py @@ -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() diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/hub_platform/ai/tests.py index 87ce6b5..ef6caeb 100644 --- a/apps/backend/hub_platform/ai/tests.py +++ b/apps/backend/hub_platform/ai/tests.py @@ -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) diff --git a/apps/backend/hub_platform/ai/urls.py b/apps/backend/hub_platform/ai/urls.py index b3adf6a..c8d6f85 100644 --- a/apps/backend/hub_platform/ai/urls.py +++ b/apps/backend/hub_platform/ai/urls.py @@ -17,5 +17,4 @@ urlpatterns = [ views.KnowledgeAttachmentDeleteView.as_view(), name="ai-knowledge-attachment-delete", ), - path("files//", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"), ] diff --git a/apps/backend/hub_platform/ai/views.py b/apps/backend/hub_platform/ai/views.py index fe38aad..7f8788f 100644 --- a/apps/backend/hub_platform/ai/views.py +++ b/apps/backend/hub_platform/ai/views.py @@ -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) diff --git a/apps/backend/hub_platform/api/permissions.py b/apps/backend/hub_platform/api/permissions.py index da452c3..c731a17 100644 --- a/apps/backend/hub_platform/api/permissions.py +++ b/apps/backend/hub_platform/api/permissions.py @@ -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) diff --git a/apps/backend/hub_platform/calls/consumers.py b/apps/backend/hub_platform/calls/consumers.py index cfe0766..16059c9 100644 --- a/apps/backend/hub_platform/calls/consumers.py +++ b/apps/backend/hub_platform/calls/consumers.py @@ -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]}, diff --git a/apps/backend/hub_platform/calls/event_handlers.py b/apps/backend/hub_platform/calls/event_handlers.py index e7a54e1..221bee2 100644 --- a/apps/backend/hub_platform/calls/event_handlers.py +++ b/apps/backend/hub_platform/calls/event_handlers.py @@ -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: diff --git a/apps/backend/hub_platform/calls/maintenance.py b/apps/backend/hub_platform/calls/maintenance.py index 17ef43a..e70978c 100644 --- a/apps/backend/hub_platform/calls/maintenance.py +++ b/apps/backend/hub_platform/calls/maintenance.py @@ -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, ) diff --git a/apps/backend/hub_platform/calls/permissions.py b/apps/backend/hub_platform/calls/permissions.py index 6ebd43b..8af613c 100644 --- a/apps/backend/hub_platform/calls/permissions.py +++ b/apps/backend/hub_platform/calls/permissions.py @@ -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 diff --git a/apps/backend/hub_platform/calls/public_urls.py b/apps/backend/hub_platform/calls/public_urls.py new file mode 100644 index 0000000..62f93d9 --- /dev/null +++ b/apps/backend/hub_platform/calls/public_urls.py @@ -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"), +] diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/hub_platform/calls/services.py index 65711e7..3954cdc 100644 --- a/apps/backend/hub_platform/calls/services.py +++ b/apps/backend/hub_platform/calls/services.py @@ -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: diff --git a/apps/backend/hub_platform/calls/signaling.py b/apps/backend/hub_platform/calls/signaling.py index f9ac3d3..57566b2 100644 --- a/apps/backend/hub_platform/calls/signaling.py +++ b/apps/backend/hub_platform/calls/signaling.py @@ -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) diff --git a/apps/backend/hub_platform/calls/tests/helpers.py b/apps/backend/hub_platform/calls/tests/helpers.py index 08ef294..0f0dc08 100644 --- a/apps/backend/hub_platform/calls/tests/helpers.py +++ b/apps/backend/hub_platform/calls/tests/helpers.py @@ -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, diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/hub_platform/calls/tests/test_api.py index bf8d2e2..50bb924 100644 --- a/apps/backend/hub_platform/calls/tests/test_api.py +++ b/apps/backend/hub_platform/calls/tests/test_api.py @@ -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 diff --git a/apps/backend/hub_platform/calls/tests/test_flow.py b/apps/backend/hub_platform/calls/tests/test_flow.py index f17ab8a..402b916 100644 --- a/apps/backend/hub_platform/calls/tests/test_flow.py +++ b/apps/backend/hub_platform/calls/tests/test_flow.py @@ -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="Звонок пропущен: клиент не ответил" diff --git a/apps/backend/hub_platform/calls/tests/test_metrics.py b/apps/backend/hub_platform/calls/tests/test_metrics.py index d73ce77..f9d03b0 100644 --- a/apps/backend/hub_platform/calls/tests/test_metrics.py +++ b/apps/backend/hub_platform/calls/tests/test_metrics.py @@ -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}, diff --git a/apps/backend/hub_platform/calls/tests/test_services.py b/apps/backend/hub_platform/calls/tests/test_services.py index c94e0a8..fbefe9f 100644 --- a/apps/backend/hub_platform/calls/tests/test_services.py +++ b/apps/backend/hub_platform/calls/tests/test_services.py @@ -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()) diff --git a/apps/backend/hub_platform/calls/tests/test_signaling.py b/apps/backend/hub_platform/calls/tests/test_signaling.py index c37bd86..24a40e1 100644 --- a/apps/backend/hub_platform/calls/tests/test_signaling.py +++ b/apps/backend/hub_platform/calls/tests/test_signaling.py @@ -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) diff --git a/apps/backend/hub_platform/calls/tests/test_tokens.py b/apps/backend/hub_platform/calls/tests/test_tokens.py index d09c203..d7c4c5f 100644 --- a/apps/backend/hub_platform/calls/tests/test_tokens.py +++ b/apps/backend/hub_platform/calls/tests/test_tokens.py @@ -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, diff --git a/apps/backend/hub_platform/calls/urls.py b/apps/backend/hub_platform/calls/urls.py index ffcc053..1379227 100644 --- a/apps/backend/hub_platform/calls/urls.py +++ b/apps/backend/hub_platform/calls/urls.py @@ -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("/", views.CallDetailView.as_view(), name="call-detail"), path( "/cancel/", diff --git a/apps/backend/hub_platform/calls/views.py b/apps/backend/hub_platform/calls/views.py index c783e71..0097fa2 100644 --- a/apps/backend/hub_platform/calls/views.py +++ b/apps/backend/hub_platform/calls/views.py @@ -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: diff --git a/apps/backend/hub_platform/channels/management/commands/seed_channels.py b/apps/backend/hub_platform/channels/management/commands/seed_channels.py index 7c631aa..e32699d 100644 --- a/apps/backend/hub_platform/channels/management/commands/seed_channels.py +++ b/apps/backend/hub_platform/channels/management/commands/seed_channels.py @@ -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 = ( diff --git a/apps/backend/hub_platform/channels/selectors.py b/apps/backend/hub_platform/channels/selectors.py index 9b86814..035adb8 100644 --- a/apps/backend/hub_platform/channels/selectors.py +++ b/apps/backend/hub_platform/channels/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/channels/services.py b/apps/backend/hub_platform/channels/services.py index c3d8edb..bd1b163 100644 --- a/apps/backend/hub_platform/channels/services.py +++ b/apps/backend/hub_platform/channels/services.py @@ -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 diff --git a/apps/backend/hub_platform/channels/tests.py b/apps/backend/hub_platform/channels/tests.py index f402383..b5822e1 100644 --- a/apps/backend/hub_platform/channels/tests.py +++ b/apps/backend/hub_platform/channels/tests.py @@ -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, diff --git a/apps/backend/hub_platform/channels/views.py b/apps/backend/hub_platform/channels/views.py index c120414..70467b5 100644 --- a/apps/backend/hub_platform/channels/views.py +++ b/apps/backend/hub_platform/channels/views.py @@ -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) diff --git a/apps/backend/hub_platform/conversations/command.py b/apps/backend/hub_platform/conversations/command.py index 5c1e2b2..aaeab00 100644 --- a/apps/backend/hub_platform/conversations/command.py +++ b/apps/backend/hub_platform/conversations/command.py @@ -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") diff --git a/apps/backend/hub_platform/conversations/ingest.py b/apps/backend/hub_platform/conversations/ingest.py index 57b3edc..5d495e5 100644 --- a/apps/backend/hub_platform/conversations/ingest.py +++ b/apps/backend/hub_platform/conversations/ingest.py @@ -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, diff --git a/apps/backend/hub_platform/conversations/maintenance.py b/apps/backend/hub_platform/conversations/maintenance.py index 0c0f25b..791c5d3 100644 --- a/apps/backend/hub_platform/conversations/maintenance.py +++ b/apps/backend/hub_platform/conversations/maintenance.py @@ -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: diff --git a/apps/backend/hub_platform/conversations/poller.py b/apps/backend/hub_platform/conversations/poller.py index 78eaba4..2350651 100644 --- a/apps/backend/hub_platform/conversations/poller.py +++ b/apps/backend/hub_platform/conversations/poller.py @@ -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" ] diff --git a/apps/backend/hub_platform/conversations/selectors.py b/apps/backend/hub_platform/conversations/selectors.py index 00ba0e0..7b14709 100644 --- a/apps/backend/hub_platform/conversations/selectors.py +++ b/apps/backend/hub_platform/conversations/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/conversations/services.py b/apps/backend/hub_platform/conversations/services.py index aa5b77f..60ad49a 100644 --- a/apps/backend/hub_platform/conversations/services.py +++ b/apps/backend/hub_platform/conversations/services.py @@ -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"]) diff --git a/apps/backend/hub_platform/conversations/stats.py b/apps/backend/hub_platform/conversations/stats.py index f2d356c..0e69f8c 100644 --- a/apps/backend/hub_platform/conversations/stats.py +++ b/apps/backend/hub_platform/conversations/stats.py @@ -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: diff --git a/apps/backend/hub_platform/conversations/test_authorization.py b/apps/backend/hub_platform/conversations/test_authorization.py index 4cb6789..c83cecf 100644 --- a/apps/backend/hub_platform/conversations/test_authorization.py +++ b/apps/backend/hub_platform/conversations/test_authorization.py @@ -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) - diff --git a/apps/backend/hub_platform/conversations/tests.py b/apps/backend/hub_platform/conversations/tests.py index 3656820..4d97d97 100644 --- a/apps/backend/hub_platform/conversations/tests.py +++ b/apps/backend/hub_platform/conversations/tests.py @@ -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 diff --git a/apps/backend/hub_platform/conversations/views.py b/apps/backend/hub_platform/conversations/views.py index 290ca2e..aed3b93 100644 --- a/apps/backend/hub_platform/conversations/views.py +++ b/apps/backend/hub_platform/conversations/views.py @@ -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)}) diff --git a/apps/backend/hub_platform/events/handlers.py b/apps/backend/hub_platform/events/handlers.py index cc3724c..c690d6c 100644 --- a/apps/backend/hub_platform/events/handlers.py +++ b/apps/backend/hub_platform/events/handlers.py @@ -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)) diff --git a/apps/backend/hub_platform/events/management/commands/run_worker.py b/apps/backend/hub_platform/events/management/commands/run_worker.py index a7ec9b9..474f544 100644 --- a/apps/backend/hub_platform/events/management/commands/run_worker.py +++ b/apps/backend/hub_platform/events/management/commands/run_worker.py @@ -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) diff --git a/apps/backend/hub_platform/events/migrations/0003_outbox_tenant_context.py b/apps/backend/hub_platform/events/migrations/0003_outbox_tenant_context.py new file mode 100644 index 0000000..60950ca --- /dev/null +++ b/apps/backend/hub_platform/events/migrations/0003_outbox_tenant_context.py @@ -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), + ), + ] diff --git a/apps/backend/hub_platform/events/models.py b/apps/backend/hub_platform/events/models.py index 7c21180..e8cb619 100644 --- a/apps/backend/hub_platform/events/models.py +++ b/apps/backend/hub_platform/events/models.py @@ -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, diff --git a/apps/backend/hub_platform/events/services.py b/apps/backend/hub_platform/events/services.py index a93673b..a2cc6a7 100644 --- a/apps/backend/hub_platform/events/services.py +++ b/apps/backend/hub_platform/events/services.py @@ -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, ) diff --git a/apps/backend/hub_platform/identity/access_defaults.py b/apps/backend/hub_platform/identity/access_defaults.py index 0926061..4ceefa9 100644 --- a/apps/backend/hub_platform/identity/access_defaults.py +++ b/apps/backend/hub_platform/identity/access_defaults.py @@ -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, ) - diff --git a/apps/backend/hub_platform/identity/access_services.py b/apps/backend/hub_platform/identity/access_services.py index d86b231..d67935e 100644 --- a/apps/backend/hub_platform/identity/access_services.py +++ b/apps/backend/hub_platform/identity/access_services.py @@ -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") diff --git a/apps/backend/hub_platform/identity/access_views.py b/apps/backend/hub_platform/identity/access_views.py index f233d8d..ebdb19f 100644 --- a/apps/backend/hub_platform/identity/access_views.py +++ b/apps/backend/hub_platform/identity/access_views.py @@ -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( diff --git a/apps/backend/hub_platform/identity/auth/common.py b/apps/backend/hub_platform/identity/auth/common.py index b3361d1..cb39a4c 100644 --- a/apps/backend/hub_platform/identity/auth/common.py +++ b/apps/backend/hub_platform/identity/auth/common.py @@ -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, } diff --git a/apps/backend/hub_platform/identity/auth/password_reset.py b/apps/backend/hub_platform/identity/auth/password_reset.py index 91f1f0e..a0e3394 100644 --- a/apps/backend/hub_platform/identity/auth/password_reset.py +++ b/apps/backend/hub_platform/identity/auth/password_reset.py @@ -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}) diff --git a/apps/backend/hub_platform/identity/auth/profile.py b/apps/backend/hub_platform/identity/auth/profile.py index 36f9909..f3b95de 100644 --- a/apps/backend/hub_platform/identity/auth/profile.py +++ b/apps/backend/hub_platform/identity/auth/profile.py @@ -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)}) diff --git a/apps/backend/hub_platform/identity/auth/sessions.py b/apps/backend/hub_platform/identity/auth/sessions.py index 0927e51..2ffc8a6 100644 --- a/apps/backend/hub_platform/identity/auth/sessions.py +++ b/apps/backend/hub_platform/identity/auth/sessions.py @@ -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}) diff --git a/apps/backend/hub_platform/identity/auth/totp.py b/apps/backend/hub_platform/identity/auth/totp.py index e5df7a1..ec33953 100644 --- a/apps/backend/hub_platform/identity/auth/totp.py +++ b/apps/backend/hub_platform/identity/auth/totp.py @@ -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)}) diff --git a/apps/backend/hub_platform/identity/bootstrap.py b/apps/backend/hub_platform/identity/bootstrap.py index ef828f8..35e7e11 100644 --- a/apps/backend/hub_platform/identity/bootstrap.py +++ b/apps/backend/hub_platform/identity/bootstrap.py @@ -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={ diff --git a/apps/backend/hub_platform/identity/company_views.py b/apps/backend/hub_platform/identity/company_views.py index e33d83b..d7a8072 100644 --- a/apps/backend/hub_platform/identity/company_views.py +++ b/apps/backend/hub_platform/identity/company_views.py @@ -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]}) diff --git a/apps/backend/hub_platform/identity/employee_security_views.py b/apps/backend/hub_platform/identity/employee_security_views.py index fcb7e79..d55d933 100644 --- a/apps/backend/hub_platform/identity/employee_security_views.py +++ b/apps/backend/hub_platform/identity/employee_security_views.py @@ -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) diff --git a/apps/backend/hub_platform/identity/employee_support.py b/apps/backend/hub_platform/identity/employee_support.py index f14eea5..60c1c9c 100644 --- a/apps/backend/hub_platform/identity/employee_support.py +++ b/apps/backend/hub_platform/identity/employee_support.py @@ -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") diff --git a/apps/backend/hub_platform/identity/employee_validation.py b/apps/backend/hub_platform/identity/employee_validation.py index a10d368..b26b0e4 100644 --- a/apps/backend/hub_platform/identity/employee_validation.py +++ b/apps/backend/hub_platform/identity/employee_validation.py @@ -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) - diff --git a/apps/backend/hub_platform/identity/employee_views.py b/apps/backend/hub_platform/identity/employee_views.py index 0b9e5dd..b0a767b 100644 --- a/apps/backend/hub_platform/identity/employee_views.py +++ b/apps/backend/hub_platform/identity/employee_views.py @@ -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) diff --git a/apps/backend/hub_platform/identity/event_handlers.py b/apps/backend/hub_platform/identity/event_handlers.py index 121b107..e55a3ef 100644 --- a/apps/backend/hub_platform/identity/event_handlers.py +++ b/apps/backend/hub_platform/identity/event_handlers.py @@ -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) diff --git a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py index 3baf2f6..52716f6 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py @@ -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" diff --git a/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py b/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py index 883aa2d..c0e1b63 100644 --- a/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py +++ b/apps/backend/hub_platform/identity/management/commands/verify_employee_authorization.py @@ -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( diff --git a/apps/backend/hub_platform/identity/membership_context.py b/apps/backend/hub_platform/identity/membership_context.py deleted file mode 100644 index 002d687..0000000 --- a/apps/backend/hub_platform/identity/membership_context.py +++ /dev/null @@ -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 diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index e263c77..8d64450 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -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" diff --git a/apps/backend/hub_platform/identity/ownership_views.py b/apps/backend/hub_platform/identity/ownership_views.py index 623b63b..ec107cd 100644 --- a/apps/backend/hub_platform/identity/ownership_views.py +++ b/apps/backend/hub_platform/identity/ownership_views.py @@ -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): diff --git a/apps/backend/hub_platform/identity/policy.py b/apps/backend/hub_platform/identity/policy.py index 82549fd..abc4f6f 100644 --- a/apps/backend/hub_platform/identity/policy.py +++ b/apps/backend/hub_platform/identity/policy.py @@ -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]: diff --git a/apps/backend/hub_platform/identity/test_access_api.py b/apps/backend/hub_platform/identity/test_access_api.py index 4d2627f..062db75 100644 --- a/apps/backend/hub_platform/identity/test_access_api.py +++ b/apps/backend/hub_platform/identity/test_access_api.py @@ -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: diff --git a/apps/backend/hub_platform/identity/test_authorization.py b/apps/backend/hub_platform/identity/test_authorization.py index 30bc1cb..2deda0c 100644 --- a/apps/backend/hub_platform/identity/test_authorization.py +++ b/apps/backend/hub_platform/identity/test_authorization.py @@ -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: diff --git a/apps/backend/hub_platform/identity/test_membership_identity.py b/apps/backend/hub_platform/identity/test_membership_identity.py index 2d8aa52..dd4cb46 100644 --- a/apps/backend/hub_platform/identity/test_membership_identity.py +++ b/apps/backend/hub_platform/identity/test_membership_identity.py @@ -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( diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index 2b93424..454f225 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -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, ) diff --git a/apps/backend/hub_platform/integrations/selectors.py b/apps/backend/hub_platform/integrations/selectors.py index eca063f..5502d33 100644 --- a/apps/backend/hub_platform/integrations/selectors.py +++ b/apps/backend/hub_platform/integrations/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/integrations/services.py b/apps/backend/hub_platform/integrations/services.py index 6cc88be..f6af435 100644 --- a/apps/backend/hub_platform/integrations/services.py +++ b/apps/backend/hub_platform/integrations/services.py @@ -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: diff --git a/apps/backend/hub_platform/integrations/tests.py b/apps/backend/hub_platform/integrations/tests.py index 479bc40..1acb09c 100644 --- a/apps/backend/hub_platform/integrations/tests.py +++ b/apps/backend/hub_platform/integrations/tests.py @@ -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", diff --git a/apps/backend/hub_platform/integrations/views.py b/apps/backend/hub_platform/integrations/views.py index b2f10fe..16dc73f 100644 --- a/apps/backend/hub_platform/integrations/views.py +++ b/apps/backend/hub_platform/integrations/views.py @@ -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)}) diff --git a/apps/backend/hub_platform/notifications/binding.py b/apps/backend/hub_platform/notifications/binding.py index dc48758..290c3ad 100644 --- a/apps/backend/hub_platform/notifications/binding.py +++ b/apps/backend/hub_platform/notifications/binding.py @@ -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: diff --git a/apps/backend/hub_platform/notifications/delivery.py b/apps/backend/hub_platform/notifications/delivery.py index 788467d..d891023 100644 --- a/apps/backend/hub_platform/notifications/delivery.py +++ b/apps/backend/hub_platform/notifications/delivery.py @@ -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() ] diff --git a/apps/backend/hub_platform/notifications/event_handlers.py b/apps/backend/hub_platform/notifications/event_handlers.py index e6ca95e..a6b4dcf 100644 --- a/apps/backend/hub_platform/notifications/event_handlers.py +++ b/apps/backend/hub_platform/notifications/event_handlers.py @@ -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) diff --git a/apps/backend/hub_platform/notifications/selectors.py b/apps/backend/hub_platform/notifications/selectors.py index 4b5e7a9..3f1138a 100644 --- a/apps/backend/hub_platform/notifications/selectors.py +++ b/apps/backend/hub_platform/notifications/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/notifications/services.py b/apps/backend/hub_platform/notifications/services.py index 8a01e1f..a15cc71 100644 --- a/apps/backend/hub_platform/notifications/services.py +++ b/apps/backend/hub_platform/notifications/services.py @@ -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) diff --git a/apps/backend/hub_platform/notifications/tests.py b/apps/backend/hub_platform/notifications/tests.py index 72dee1e..d1a0aa1 100644 --- a/apps/backend/hub_platform/notifications/tests.py +++ b/apps/backend/hub_platform/notifications/tests.py @@ -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) diff --git a/apps/backend/hub_platform/notifications/views.py b/apps/backend/hub_platform/notifications/views.py index a818cae..9059763 100644 --- a/apps/backend/hub_platform/notifications/views.py +++ b/apps/backend/hub_platform/notifications/views.py @@ -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()}) diff --git a/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py b/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py index 3f87570..db3293f 100644 --- a/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py +++ b/apps/backend/hub_platform/orders/management/commands/issue_product_ingest_token.py @@ -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) diff --git a/apps/backend/hub_platform/orders/management/commands/seed_orders.py b/apps/backend/hub_platform/orders/management/commands/seed_orders.py index 05901a8..8d753a9 100644 --- a/apps/backend/hub_platform/orders/management/commands/seed_orders.py +++ b/apps/backend/hub_platform/orders/management/commands/seed_orders.py @@ -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")) diff --git a/apps/backend/hub_platform/orders/public_urls.py b/apps/backend/hub_platform/orders/public_urls.py new file mode 100644 index 0000000..086884a --- /dev/null +++ b/apps/backend/hub_platform/orders/public_urls.py @@ -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"), +] diff --git a/apps/backend/hub_platform/orders/selectors.py b/apps/backend/hub_platform/orders/selectors.py index 4e534be..f8f2b41 100644 --- a/apps/backend/hub_platform/orders/selectors.py +++ b/apps/backend/hub_platform/orders/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/orders/services.py b/apps/backend/hub_platform/orders/services.py index da040d7..f540a02 100644 --- a/apps/backend/hub_platform/orders/services.py +++ b/apps/backend/hub_platform/orders/services.py @@ -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: diff --git a/apps/backend/hub_platform/orders/tests.py b/apps/backend/hub_platform/orders/tests.py index 0d98faa..2a0ce03 100644 --- a/apps/backend/hub_platform/orders/tests.py +++ b/apps/backend/hub_platform/orders/tests.py @@ -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) diff --git a/apps/backend/hub_platform/orders/urls.py b/apps/backend/hub_platform/orders/urls.py index f295837..eae0f36 100644 --- a/apps/backend/hub_platform/orders/urls.py +++ b/apps/backend/hub_platform/orders/urls.py @@ -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("/", views.OrderDetailView.as_view(), name="order-detail"), path("/mark-paid/", views.OrderMarkPaidView.as_view(), name="order-mark-paid"), path("/cancel/", views.OrderCancelView.as_view(), name="order-cancel"), diff --git a/apps/backend/hub_platform/orders/views.py b/apps/backend/hub_platform/orders/views.py index fe34232..138d76b 100644 --- a/apps/backend/hub_platform/orders/views.py +++ b/apps/backend/hub_platform/orders/views.py @@ -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) diff --git a/apps/backend/hub_platform/products/management/commands/seed_catalog.py b/apps/backend/hub_platform/products/management/commands/seed_catalog.py index 7f866dd..a8d9e8c 100644 --- a/apps/backend/hub_platform/products/management/commands/seed_catalog.py +++ b/apps/backend/hub_platform/products/management/commands/seed_catalog.py @@ -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 diff --git a/apps/backend/hub_platform/products/offer_views.py b/apps/backend/hub_platform/products/offer_views.py index 7f177d4..b54bb81 100644 --- a/apps/backend/hub_platform/products/offer_views.py +++ b/apps/backend/hub_platform/products/offer_views.py @@ -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) diff --git a/apps/backend/hub_platform/products/selectors.py b/apps/backend/hub_platform/products/selectors.py index 5009275..6103f38 100644 --- a/apps/backend/hub_platform/products/selectors.py +++ b/apps/backend/hub_platform/products/selectors.py @@ -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) diff --git a/apps/backend/hub_platform/products/services.py b/apps/backend/hub_platform/products/services.py index 201c7c7..597f081 100644 --- a/apps/backend/hub_platform/products/services.py +++ b/apps/backend/hub_platform/products/services.py @@ -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) diff --git a/apps/backend/hub_platform/products/tests.py b/apps/backend/hub_platform/products/tests.py index 7b204f2..11c8641 100644 --- a/apps/backend/hub_platform/products/tests.py +++ b/apps/backend/hub_platform/products/tests.py @@ -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 diff --git a/apps/backend/hub_platform/products/views.py b/apps/backend/hub_platform/products/views.py index e9a5ff7..cf3233f 100644 --- a/apps/backend/hub_platform/products/views.py +++ b/apps/backend/hub_platform/products/views.py @@ -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)}) diff --git a/apps/backend/hub_platform/sales/analytics.py b/apps/backend/hub_platform/sales/analytics.py index 0ef0005..e896bb4 100644 --- a/apps/backend/hub_platform/sales/analytics.py +++ b/apps/backend/hub_platform/sales/analytics.py @@ -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) diff --git a/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py b/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py index b98f6ae..f415e50 100644 --- a/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py +++ b/apps/backend/hub_platform/sales/management/commands/issue_sales_source_credential.py @@ -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") diff --git a/apps/backend/hub_platform/sales/selectors.py b/apps/backend/hub_platform/sales/selectors.py index 5e4d641..8c496af 100644 --- a/apps/backend/hub_platform/sales/selectors.py +++ b/apps/backend/hub_platform/sales/selectors.py @@ -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="") diff --git a/apps/backend/hub_platform/sales/services.py b/apps/backend/hub_platform/sales/services.py index e31d43b..037da58 100644 --- a/apps/backend/hub_platform/sales/services.py +++ b/apps/backend/hub_platform/sales/services.py @@ -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) diff --git a/apps/backend/hub_platform/sales/tests.py b/apps/backend/hub_platform/sales/tests.py index 04b9572..e430027 100644 --- a/apps/backend/hub_platform/sales/tests.py +++ b/apps/backend/hub_platform/sales/tests.py @@ -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, Conversation from hub_platform.identity.bootstrap import bootstrap_edevs_owner @@ -169,7 +169,7 @@ class AttributionTests(SalesTestBase): def test_valid_token_attributes_sale(self) -> None: token, raw = issue_attribution_token( - organization=self.organization, + context=system_tenant_context(self.organization), product=self.product, contact=self.contact, conversation=self.conversation, @@ -285,7 +285,7 @@ class AnalyticsTests(SalesTestBase): ) from hub_platform.sales.analytics import sales_analytics - data = sales_analytics(self.organization.id) + data = sales_analytics(system_tenant_context(self.organization)) self.assertEqual(data["grossSalesCount"], 1) self.assertEqual(data["grossRevenueMinor"], 100000) self.assertEqual(data["refundedAmountMinor"], 40000) diff --git a/apps/backend/hub_platform/sales/views.py b/apps/backend/hub_platform/sales/views.py index eee2dd1..ab7ebc0 100644 --- a/apps/backend/hub_platform/sales/views.py +++ b/apps/backend/hub_platform/sales/views.py @@ -16,8 +16,8 @@ from hub_platform.sales.analytics import sales_analytics from hub_platform.sales.models import Sale, SaleEventType from hub_platform.sales.selectors import ( apply_sale_filters, - sale_for_organization, - sales_for_organization, + sale_for_context, + sales_for_context, ) from hub_platform.sales.serializers import sale_payload from hub_platform.sales.services import ( @@ -28,6 +28,7 @@ from hub_platform.sales.services import ( record_product_sales_event, resolve_sales_source_by_credential, ) +from hub_platform.tenancy.context import TenantContext def _api_error(error: SalesApiError) -> Response: @@ -55,9 +56,12 @@ class ProductSalesEventView(APIView): source = resolve_sales_source_by_credential(_bearer(request)) if source is None: return Response({"detail": "Invalid or revoked credential", "error": "invalid_credential"}, status=401) + context = TenantContext.for_resource(source.organization) try: - result = record_product_sales_event(source=source, payload=request.data) + result = record_product_sales_event( + context=context, source=source, payload=request.data + ) except SalesApiError as error: record_audit_event( action="sales.event_rejected", @@ -76,7 +80,7 @@ class ProductSalesEventView(APIView): class _Base(APIView): def _org(self, request: Request): - return request.user.employee_profile.organization + return request.tenant_context.organization class SaleListCreateView(_Base): @@ -84,8 +88,8 @@ class SaleListCreateView(_Base): required_capabilities = {"GET": "sales.view", "POST": "sales.operate"} def get(self, request: Request) -> Response: - qs = apply_sale_filters(sales_for_organization(self._org(request).id), request.query_params) - department_ids = accessible_department_ids(request.user, "sales.view") + qs = apply_sale_filters(sales_for_context(request.tenant_context), request.query_params) + department_ids = accessible_department_ids(request.tenant_context.membership, "sales.view") if department_ids is not None: qs = qs.filter(conversation__channel__department_id__in=department_ids) return Response({"items": [sale_payload(sale) for sale in qs]}) @@ -105,20 +109,19 @@ class SaleListCreateView(_Base): conversation = Conversation.objects.filter(id=data.get("conversationId"), organization=org).first() if conversation is None: return Response({"detail": "Conversation not found"}, status=400) - if not require_capability(request.user, "sales.operate", conversation): + if not require_capability(request.tenant_context.membership, "sales.operate", conversation): return Response({"detail": "Conversation not found"}, status=404) if data.get("contactId"): contact = Contact.objects.filter(id=data.get("contactId"), organization=org).first() if contact is None: return Response({"detail": "Contact not found"}, status=400) - if conversation is None and accessible_department_ids(request.user, "sales.operate") is not None: + if conversation is None and accessible_department_ids(request.tenant_context.membership, "sales.operate") is not None: return Response({"detail": "Department-scoped sale requires a conversation"}, status=403) occurred_at = parse_datetime(str(data.get("occurredAt", ""))) or timezone.now() try: sale = create_manual_sale( - organization=org, - actor_user=request.user, + context=request.tenant_context, product=product, amount_minor=int(data.get("amountMinor", 0)), currency=str(data.get("currency", "RUB")), @@ -153,10 +156,10 @@ class SaleDetailView(_Base): def get(self, request: Request, sale_id: int) -> Response: try: - sale = sale_for_organization(organization_id=self._org(request).id, sale_id=sale_id) + sale = sale_for_context(context=request.tenant_context, sale_id=sale_id) except Sale.DoesNotExist: return Response({"detail": "Sale not found"}, status=404) - if not require_capability(request.user, self.required_capability, sale): + if not require_capability(request.tenant_context.membership, self.required_capability, sale): return Response({"detail": "Sale not found"}, status=404) return Response({"sale": sale_payload(sale, with_events=True)}) @@ -183,10 +186,10 @@ class SaleActionView(_Base): if event_type is None: return Response({"detail": "Unknown action"}, status=404) try: - sale = sale_for_organization(organization_id=self._org(request).id, sale_id=sale_id) + sale = sale_for_context(context=request.tenant_context, sale_id=sale_id) except Sale.DoesNotExist: return Response({"detail": "Sale not found"}, status=404) - if not require_capability(request.user, self.required_capability, sale): + if not require_capability(request.tenant_context.membership, self.required_capability, sale): return Response({"detail": "Sale not found"}, status=404) data = request.data @@ -202,8 +205,8 @@ class SaleActionView(_Base): try: sale = record_manual_action( + context=request.tenant_context, sale=sale, - actor_user=request.user, event_type=event_type, reason=str(data.get("reason", "")), amount_minor=amount_minor, @@ -235,7 +238,7 @@ class AttributionTokenView(_Base): conversation = Conversation.objects.filter(id=data.get("conversationId"), organization=org).select_related("contact", "channel", "connection").first() if conversation is None: return Response({"detail": "Conversation not found"}, status=400) - if not require_capability(request.user, self.required_capability, conversation): + if not require_capability(request.tenant_context.membership, self.required_capability, conversation): return Response({"detail": "Conversation not found"}, status=404) try: product = Product.objects.get(organization=org, code=str(data.get("productCode", ""))) @@ -245,7 +248,7 @@ class AttributionTokenView(_Base): actor_type = "EMPLOYEE" try: token, raw = issue_attribution_token( - organization=org, + context=request.tenant_context, product=product, contact=conversation.contact, conversation=conversation, @@ -276,7 +279,7 @@ class SalesAnalyticsView(_Base): def get(self, request: Request) -> Response: params = request.query_params data = sales_analytics( - self._org(request).id, + request.tenant_context, date_from=params.get("from") or None, date_to=params.get("to") or None, ) diff --git a/apps/backend/hub_platform/support/messages.py b/apps/backend/hub_platform/support/messages.py index 6804de9..0d0223e 100644 --- a/apps/backend/hub_platform/support/messages.py +++ b/apps/backend/hub_platform/support/messages.py @@ -24,6 +24,7 @@ from hub_platform.conversations.models import ( ) 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__) @@ -71,12 +72,16 @@ def support_messages_since(conversation: Conversation, since: int) -> dict: @transaction.atomic -def post_support_message(*, conversation: Conversation, text: str) -> None: +def post_support_message( + *, context: TenantContext, conversation: Conversation, text: str +) -> None: """Сохраняет сообщение клиента и запускает AI-ответ (если диалог ведёт AI). ADR-HUB-0003: AI-first; handoff AI→operator. Без Contact/ConnectionIdentity и без transports.send_reply (ответ идёт через polling, не через messenger API). """ + if conversation.organization_id != context.organization_id: + raise ValueError("Support conversation is outside tenant context") Message.objects.create(conversation=conversation, author_type=MessageAuthor.CONTACT, text=text) snapshot = conversation.support_identity_snapshot client_label = (snapshot.display_name if snapshot else "") or "Клиент" @@ -112,7 +117,7 @@ def post_support_message(*, conversation: Conversation, text: str) -> None: conversation=conversation, author_type=MessageAuthor.AI, text=fallback ) notify( - organization=conversation.organization, + context=context, department=conversation.channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, @@ -146,7 +151,7 @@ def post_support_message(*, conversation: Conversation, text: str) -> None: text="AI передал диалог оператору", ) notify( - organization=conversation.organization, + context=context, department=conversation.channel.department, type=NotificationType.DIALOG_WAITING, audience=NotificationAudience.OPERATORS, diff --git a/apps/backend/hub_platform/support/public_urls.py b/apps/backend/hub_platform/support/public_urls.py new file mode 100644 index 0000000..413b626 --- /dev/null +++ b/apps/backend/hub_platform/support/public_urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from hub_platform.support import views + +urlpatterns = [ + path("sessions/", views.SupportSessionStartView.as_view(), name="support-session-start"), + path( + "sessions/messages/", + views.SupportSessionMessagesView.as_view(), + name="support-session-messages", + ), +] diff --git a/apps/backend/hub_platform/support/seed_support.py b/apps/backend/hub_platform/support/seed_support.py index ab3dff3..b29e064 100644 --- a/apps/backend/hub_platform/support/seed_support.py +++ b/apps/backend/hub_platform/support/seed_support.py @@ -19,6 +19,7 @@ from django.db import transaction from hub_platform.channels.models import Channel from hub_platform.products.models import Product from hub_platform.support.models import ContractStatus, ProductSupportContract +from hub_platform.tenancy.context import TenantContext # Пример контракта FoxRay (SPEC-HUB-0011 §5.2): врач/клиника/подписка. _FOXRAY_SUPPORT_V1 = { @@ -137,8 +138,9 @@ def _ensure_secret(*, product: Product) -> bool: return True -def seed_support_reference(*, organization) -> str: +def seed_support_reference(*, context: TenantContext) -> str: """Наполняет reference-данные поддержки. Возвращает строку-статистику.""" + organization = context.organization contracts_created = 0 secrets_created = 0 for product in Product.objects.filter(organization=organization): diff --git a/apps/backend/hub_platform/support/selectors.py b/apps/backend/hub_platform/support/selectors.py index 3862cc9..477e2de 100644 --- a/apps/backend/hub_platform/support/selectors.py +++ b/apps/backend/hub_platform/support/selectors.py @@ -7,39 +7,40 @@ from hub_platform.support.models import ( ProductSupportContract, SupportIdentitySnapshot, ) +from hub_platform.tenancy.context import TenantContext -def contracts_for_organization(organization_id: int) -> QuerySet[ProductSupportContract]: +def contracts_for_context(context: TenantContext) -> QuerySet[ProductSupportContract]: return ( - ProductSupportContract.objects.filter(organization_id=organization_id) + ProductSupportContract.objects.filter(organization_id=context.organization_id) .select_related("product") .prefetch_related("allowed_channels") .order_by("code") ) -def contract_for_organization( - *, organization_id: int, contract_id: int +def contract_for_context( + *, context: TenantContext, contract_id: int ) -> ProductSupportContract: - return contracts_for_organization(organization_id).get(id=contract_id) + return contracts_for_context(context).get(id=contract_id) -def contract_by_code(*, organization_id: int, code: str) -> ProductSupportContract | None: +def contract_by_code(*, context: TenantContext, code: str) -> ProductSupportContract | None: """Контракт по code (любой статус) — для различения CONTRACT_NOT_FOUND/DISABLED.""" return ( ProductSupportContract.objects.filter( - organization_id=organization_id, code=code + organization_id=context.organization_id, code=code ) .select_related("product") .first() ) -def active_contract_for(*, organization_id: int, code: str) -> ProductSupportContract | None: +def active_contract_for(*, context: TenantContext, code: str) -> ProductSupportContract | None: """Контракт, принимающий production traffic: ACTIVE или DEPRECATED (migration window).""" return ( ProductSupportContract.objects.filter( - organization_id=organization_id, code=code + organization_id=context.organization_id, code=code ) .filter(status__in=[ContractStatus.ACTIVE, ContractStatus.DEPRECATED]) .select_related("product") @@ -48,9 +49,13 @@ def active_contract_for(*, organization_id: int, code: str) -> ProductSupportCon def snapshots_for_subject( - *, product_id: int, subject_key: str + *, context: TenantContext, product_id: int, subject_key: str ) -> QuerySet[SupportIdentitySnapshot]: return ( - SupportIdentitySnapshot.objects.filter(product_id=product_id, subject_key=subject_key) + SupportIdentitySnapshot.objects.filter( + organization_id=context.organization_id, + product_id=product_id, + subject_key=subject_key, + ) .order_by("-verified_at") ) diff --git a/apps/backend/hub_platform/support/services.py b/apps/backend/hub_platform/support/services.py index 9d3a5b7..6a48c23 100644 --- a/apps/backend/hub_platform/support/services.py +++ b/apps/backend/hub_platform/support/services.py @@ -9,6 +9,7 @@ from hub_platform.channels.models import Channel from hub_platform.identity.models import Organization from hub_platform.products.models import Product from hub_platform.support.models import ContractStatus, ProductSupportContract +from hub_platform.tenancy.context import TenantContext @dataclass(frozen=True) @@ -49,8 +50,9 @@ def _resolve_channels(organization: Organization, ids: tuple[int, ...]) -> list[ @transaction.atomic def register_contract( - *, organization: Organization, data: ContractInput + *, context: TenantContext, data: ContractInput ) -> ProductSupportContract: + organization = context.organization try: product = Product.objects.get(id=data.product_id, organization=organization) except Product.DoesNotExist as error: @@ -75,7 +77,11 @@ def register_contract( return contract -def set_contract_status(*, contract: ProductSupportContract, status: str) -> ProductSupportContract: +def set_contract_status( + *, context: TenantContext, contract: ProductSupportContract, status: str +) -> ProductSupportContract: + if contract.organization_id != context.organization_id: + raise ValidationError({"contract": "Contract belongs to another organization"}) if status not in ContractStatus.values: raise ValidationError({"status": "Unknown contract status"}) if contract.status != status: diff --git a/apps/backend/hub_platform/support/session.py b/apps/backend/hub_platform/support/session.py index d1d40a9..2beb872 100644 --- a/apps/backend/hub_platform/support/session.py +++ b/apps/backend/hub_platform/support/session.py @@ -31,6 +31,7 @@ from hub_platform.support.models import ( from hub_platform.support.selectors import contract_by_code from hub_platform.support.token import TokenClaims, claims_datetimes, verify_support_token from hub_platform.support.widget_credential import issue_widget_credential +from hub_platform.tenancy.context import TenantContext def _deny_channel_policy(channel) -> errors.SupportSessionError | None: @@ -63,7 +64,8 @@ def verify_and_resolve( if claims.iss != product.code: raise errors.SupportSessionError(errors.CHANNEL_PRODUCT_MISMATCH) - contract = contract_by_code(organization_id=channel.organization_id, code=claims.contract) + context = TenantContext.for_resource(channel.organization) + contract = contract_by_code(context=context, code=claims.contract) if contract is None: raise errors.SupportSessionError(errors.CONTRACT_NOT_FOUND) # DRAFT и DISABLED не принимают production traffic (SPEC-HUB-0011 §3). diff --git a/apps/backend/hub_platform/support/test_widget_messages.py b/apps/backend/hub_platform/support/test_widget_messages.py index 6be4162..88eb2bb 100644 --- a/apps/backend/hub_platform/support/test_widget_messages.py +++ b/apps/backend/hub_platform/support/test_widget_messages.py @@ -5,7 +5,7 @@ from __future__ import annotations 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 diff --git a/apps/backend/hub_platform/support/tests.py b/apps/backend/hub_platform/support/tests.py index fd78a73..ceb34db 100644 --- a/apps/backend/hub_platform/support/tests.py +++ b/apps/backend/hub_platform/support/tests.py @@ -3,7 +3,7 @@ from __future__ import annotations 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.audit import AuditResult diff --git a/apps/backend/hub_platform/support/urls.py b/apps/backend/hub_platform/support/urls.py index 3ee4dc5..284bccb 100644 --- a/apps/backend/hub_platform/support/urls.py +++ b/apps/backend/hub_platform/support/urls.py @@ -14,12 +14,6 @@ urlpatterns = [ views.SupportContractStatusView.as_view(), name="support-contract-status", ), - path("sessions/", views.SupportSessionStartView.as_view(), name="support-session-start"), - path( - "sessions/messages/", - views.SupportSessionMessagesView.as_view(), - name="support-session-messages", - ), path( "snapshots/", views.SupportSnapshotsBySubjectView.as_view(), diff --git a/apps/backend/hub_platform/support/views.py b/apps/backend/hub_platform/support/views.py index 1b63b90..3afada6 100644 --- a/apps/backend/hub_platform/support/views.py +++ b/apps/backend/hub_platform/support/views.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from django.db import models from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response @@ -13,14 +14,15 @@ from hub_platform.identity.policy import accessible_department_ids from hub_platform.support import errors from hub_platform.support.messages import post_support_message, support_messages_since from hub_platform.support.models import ProductSupportContract -from hub_platform.support.selectors import contract_for_organization, contracts_for_organization +from hub_platform.support.selectors import contract_for_context, contracts_for_context from hub_platform.support.serializers import ( support_contract_payload, support_identity_snapshot_payload, ) from hub_platform.support.services import ContractInput, register_contract, set_contract_status -from hub_platform.support.session import start_support_session +from hub_platform.support.session import start_support_session, verify_and_resolve from hub_platform.support.widget_credential import verify_widget_credential +from hub_platform.tenancy.context import TenantContext def _validation_error(error: ValidationError) -> Response: @@ -44,12 +46,12 @@ class _ManagerBase(APIView): require_organization_scope = True def _org(self, request: Request): - return request.user.employee_profile.organization + return request.tenant_context.organization class SupportContractListView(_ManagerBase): def get(self, request: Request) -> Response: - contracts = contracts_for_organization(self._org(request).id) + contracts = contracts_for_context(request.tenant_context) return Response({"items": [support_contract_payload(c) for c in contracts]}) def post(self, request: Request) -> Response: @@ -82,7 +84,7 @@ class SupportContractListView(_ManagerBase): except (TypeError, ValueError): return Response({"detail": "Invalid contract payload"}, status=400) try: - contract = register_contract(organization=org, data=data) + contract = register_contract(context=request.tenant_context, data=data) except ValidationError as error: return _validation_error(error) record_audit_event( @@ -99,8 +101,8 @@ class SupportContractListView(_ManagerBase): class SupportContractDetailView(_ManagerBase): def get(self, request: Request, contract_id: int) -> Response: try: - contract = contract_for_organization( - organization_id=self._org(request).id, contract_id=contract_id + contract = contract_for_context( + context=request.tenant_context, contract_id=contract_id ) except ProductSupportContract.DoesNotExist: return Response({"detail": "Контракт не найден"}, status=404) @@ -110,14 +112,16 @@ class SupportContractDetailView(_ManagerBase): class SupportContractStatusView(_ManagerBase): def post(self, request: Request, contract_id: int) -> Response: try: - contract = contract_for_organization( - organization_id=self._org(request).id, contract_id=contract_id + contract = contract_for_context( + context=request.tenant_context, contract_id=contract_id ) except ProductSupportContract.DoesNotExist: return Response({"detail": "Контракт не найден"}, status=404) status = str(request.data.get("status", "")) try: - contract = set_contract_status(contract=contract, status=status) + contract = set_contract_status( + context=request.tenant_context, contract=contract, status=status + ) except ValidationError as error: return _validation_error(error) record_audit_event( @@ -137,11 +141,26 @@ class SupportSessionStartView(_Public): token = str(request.data.get("token", "")) if not channel_code or not token: return _denied() - channel = Channel.objects.select_related("department", "product", "organization").filter( - code=channel_code - ).first() - if channel is None: - return _denied() + candidates = list(Channel.objects.select_related( + "department", "product", "organization" + ).filter(code=channel_code, is_active=True)) + if len(candidates) == 1: + # The organization is unambiguous, so the domain service owns + # validation and records its normal denied audit when necessary. + channel = candidates[0] + else: + # A public channel code may exist in multiple organizations. Resolve + # it only by a uniquely valid product token; never pick the first. + verified = [] + for candidate in candidates: + try: + verify_and_resolve(channel=candidate, token=token) + except errors.SupportSessionError: + continue + verified.append(candidate) + if len(verified) != 1: + return _denied() + channel = verified[0] try: result = start_support_session(channel=channel, token=token, request=request) except errors.SupportSessionError: @@ -166,8 +185,15 @@ def _resolve_widget_conversation(request: Request) -> Conversation | None: if claims is None: return None conversation = ( - Conversation.objects.select_related("channel", "support_identity_snapshot") - .filter(id=claims["conversation_id"], support_identity_snapshot_id=claims["snapshot_id"]) + Conversation.objects.select_related( + "organization", "channel", "support_identity_snapshot" + ) + .filter( + id=claims["conversation_id"], + support_identity_snapshot_id=claims["snapshot_id"], + organization_id=models.F("support_identity_snapshot__organization_id"), + channel__organization_id=models.F("organization_id"), + ) .first() ) return conversation @@ -193,7 +219,11 @@ class SupportSessionMessagesView(_Public): text = str(request.data.get("text", "")).strip() if not text: return Response({"detail": "Пустое сообщение"}, status=400) - post_support_message(conversation=conversation, text=text[:4000]) + post_support_message( + context=TenantContext.for_resource(conversation.organization), + conversation=conversation, + text=text[:4000], + ) return Response({"ok": True}, status=201) @@ -212,12 +242,16 @@ class SupportSnapshotsBySubjectView(APIView): from hub_platform.products.models import Product product = Product.objects.filter( - organization=request.user.employee_profile.organization, code=product_code + organization=request.tenant_context.organization, code=product_code ).first() if product is None: return Response({"detail": "Продукт не найден"}, status=404) - snapshots = snapshots_for_subject(product_id=product.id, subject_key=subject_key) - department_ids = accessible_department_ids(request.user, self.required_capability) + snapshots = snapshots_for_subject( + context=request.tenant_context, + product_id=product.id, + subject_key=subject_key, + ) + department_ids = accessible_department_ids(request.tenant_context.membership, self.required_capability) if department_ids is not None: snapshots = snapshots.filter( conversations__channel__department_id__in=department_ids diff --git a/apps/backend/hub_platform/tenancy/__init__.py b/apps/backend/hub_platform/tenancy/__init__.py new file mode 100644 index 0000000..f2997dc --- /dev/null +++ b/apps/backend/hub_platform/tenancy/__init__.py @@ -0,0 +1,5 @@ +"""Explicit tenant context for organization-owned operations.""" + +from hub_platform.tenancy.context import TenantActorKind, TenantContext + +__all__ = ["TenantActorKind", "TenantContext"] diff --git a/apps/backend/hub_platform/tenancy/context.py b/apps/backend/hub_platform/tenancy/context.py new file mode 100644 index 0000000..bf04cb7 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/context.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING + +from hub_platform.events.context import get_correlation_id + +if TYPE_CHECKING: + from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership + + +class TenantActorKind(StrEnum): + HUMAN = "HUMAN" + MACHINE = "MACHINE" + SYSTEM = "SYSTEM" + + +@dataclass(frozen=True, slots=True) +class TenantContext: + """Canonical organization boundary carried through one operation.""" + + organization: Organization + membership: OrganizationMembership | None + actor_user: HumanUser | None + actor_kind: TenantActorKind + correlation_id: str + + @property + def organization_id(self) -> int: + return self.organization.pk + + @property + def membership_id(self) -> int | None: + return self.membership.pk if self.membership is not None else None + + @classmethod + def for_membership( + cls, + membership: OrganizationMembership, + *, + correlation_id: str | None = None, + ) -> TenantContext: + return cls( + organization=membership.organization, + membership=membership, + actor_user=membership.user, + actor_kind=TenantActorKind.HUMAN, + correlation_id=correlation_id or get_correlation_id(), + ) + + @classmethod + def for_resource( + cls, + organization: Organization, + *, + actor_kind: TenantActorKind = TenantActorKind.MACHINE, + actor_user: HumanUser | None = None, + correlation_id: str | None = None, + ) -> TenantContext: + return cls( + organization=organization, + membership=None, + actor_user=actor_user, + actor_kind=actor_kind, + correlation_id=correlation_id or get_correlation_id(), + ) diff --git a/apps/backend/hub_platform/tenancy/middleware.py b/apps/backend/hub_platform/tenancy/middleware.py new file mode 100644 index 0000000..ee0ff1a --- /dev/null +++ b/apps/backend/hub_platform/tenancy/middleware.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Callable + +from django.http import Http404, HttpRequest, HttpResponse + +from hub_platform.events.context import get_correlation_id +from hub_platform.identity.models import OrganizationMembership +from hub_platform.tenancy.context import TenantContext + + +class TenantContextMiddleware: + """Resolve an authenticated membership from the organization URL UUID.""" + + route_kwarg = "organization_public_id" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + return self.get_response(request) + + def process_view(self, request: HttpRequest, view_func, view_args, view_kwargs): + public_id = view_kwargs.get(self.route_kwarg) + if public_id is None: + return None + if not request.user.is_authenticated or not request.user.is_active: + raise Http404 + try: + membership = ( + OrganizationMembership.objects.select_related( + "organization", "user", "primary_department" + ) + .get( + organization__public_id=public_id, + user=request.user, + blocked_at__isnull=True, + ) + ) + except OrganizationMembership.DoesNotExist as error: + raise Http404 from error + if membership.totp_required and not request.user.totp_enabled: + raise Http404 + request.tenant_context = TenantContext.for_membership( + membership, + correlation_id=get_correlation_id(), + ) + del view_kwargs[self.route_kwarg] + return None diff --git a/apps/backend/hub_platform/tenancy/tests.py b/apps/backend/hub_platform/tenancy/tests.py new file mode 100644 index 0000000..620e7ee --- /dev/null +++ b/apps/backend/hub_platform/tenancy/tests.py @@ -0,0 +1,190 @@ +import json + +from django.test import TestCase +from rest_framework.test import APIClient as RawAPIClient + +from hub_platform.events.models import EventOwnership +from hub_platform.events.services import DomainEvent, enqueue_event, tenant_context_for_event +from hub_platform.identity.models import ( + Department, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from hub_platform.products.models import Product +from hub_platform.tenancy.context import TenantContext +from hub_platform.testing import TenantAPIClient + + +class TenantHttpBoundaryTests(TestCase): + def setUp(self) -> None: + self.first = Organization.objects.create(name="First", slug="first") + self.second = Organization.objects.create(name="Second", slug="second") + self.outside = Organization.objects.create(name="Outside", slug="outside") + self.user = HumanUser.objects.create_user( + email="multi@example.test", password="Password-123" + ) + self.first_membership = self._membership(self.first) + self.second_membership = self._membership(self.second) + self.first_department = Department.objects.create( + organization=self.first, code="sales", name="First Sales" + ) + self.second_department = Department.objects.create( + organization=self.second, code="sales", name="Second Sales" + ) + self.first_product = Product.objects.create( + organization=self.first, code="first-product", name="First Product" + ) + self.second_product = Product.objects.create( + organization=self.second, code="second-product", name="Second Product" + ) + self.client = TenantAPIClient() + self.client.force_authenticate(self.user) + + def _membership(self, organization: Organization) -> OrganizationMembership: + return OrganizationMembership.objects.create( + user=self.user, + organization=organization, + role=EmployeeRole.OWNER, + position_title="Owner", + ) + + def _products_path(self, organization: Organization, suffix: str = "") -> str: + return ( + f"/api/v1/organizations/{organization.public_id}/company/products/{suffix}" + ) + + def test_same_user_can_open_each_membership_without_session_singleton(self) -> None: + first = self.client.get(self._products_path(self.first)) + second = self.client.get(self._products_path(self.second)) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual( + [item["code"] for item in first.json()["items"]], ["first-product"] + ) + self.assertEqual( + [item["code"] for item in second.json()["items"]], ["second-product"] + ) + + def test_foreign_resource_id_is_not_visible_in_selected_organization(self) -> None: + response = self.client.get( + self._products_path(self.first, f"{self.second_product.id}/") + ) + self.assertEqual(response.status_code, 404) + + def test_absent_blocked_and_mfa_unsatisfied_memberships_fail_closed(self) -> None: + absent = self.client.get(self._products_path(self.outside)) + self.assertEqual(absent.status_code, 404) + + self.second_membership.block() + blocked = self.client.get(self._products_path(self.second)) + self.assertEqual(blocked.status_code, 404) + + self.second_membership.unblock() + self.second_membership.totp_required = True + self.second_membership.save(update_fields=["totp_required"]) + missing_mfa = self.client.get(self._products_path(self.second)) + self.assertEqual(missing_mfa.status_code, 404) + + self.user.totp_enabled = True + self.user.save(update_fields=["totp_enabled"]) + allowed = self.client.get(self._products_path(self.second)) + self.assertEqual(allowed.status_code, 200) + + def test_legacy_and_malformed_tenant_routes_are_not_runtime_aliases(self) -> None: + raw_client = RawAPIClient() + raw_client.force_login(self.user) + + self.assertEqual(raw_client.get("/api/v1/company/products/").status_code, 404) + self.assertEqual( + raw_client.get("/api/v1/organizations/not-a-uuid/company/products/").status_code, + 404, + ) + + def test_global_login_returns_all_memberships_without_active_tenant(self) -> None: + client = RawAPIClient() + response = client.post( + "/api/v1/auth/login/", + data=json.dumps( + {"email": self.user.email, "password": "Password-123"} + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["user"] + self.assertNotIn("organizationPublicId", payload) + self.assertNotIn("role", payload) + self.assertEqual( + {item["organizationPublicId"] for item in payload["memberships"]}, + {str(self.first.public_id), str(self.second.public_id)}, + ) + + +class TenantEventBoundaryTests(TestCase): + def setUp(self) -> None: + self.first = Organization.objects.create(name="First", slug="event-first") + self.second = Organization.objects.create(name="Second", slug="event-second") + self.user = HumanUser.objects.create_user(email="event@example.test") + self.membership = OrganizationMembership.objects.create( + organization=self.first, + user=self.user, + role=EmployeeRole.OWNER, + position_title="Owner", + ) + + def _event(self): + return enqueue_event( + DomainEvent( + aggregate_type="Example", + aggregate_id="1", + event_type="example.created", + payload={}, + tenant_context=TenantContext.for_membership(self.membership), + ) + ) + + def test_tenant_event_persists_and_revalidates_context(self) -> None: + event = self._event() + self.assertEqual(event.ownership, EventOwnership.TENANT) + self.assertEqual(event.organization, self.first) + self.assertEqual(event.membership, self.membership) + + context = tenant_context_for_event(event) + self.assertEqual(context.organization, self.first) + self.assertEqual(context.membership, self.membership) + self.assertEqual(context.actor_user, self.user) + + def test_tenant_event_rejects_cross_organization_membership(self) -> None: + event = self._event() + event.organization = self.second + event.save(update_fields=["organization"]) + + with self.assertRaises(OrganizationMembership.DoesNotExist): + tenant_context_for_event(event) + + def test_tenant_event_rejects_membership_blocked_after_enqueue(self) -> None: + event = self._event() + self.membership.block() + + with self.assertRaises(OrganizationMembership.DoesNotExist): + tenant_context_for_event(event) + + def test_platform_event_cannot_smuggle_tenant_ownership(self) -> None: + event = enqueue_event( + DomainEvent( + aggregate_type="PlatformExample", + aggregate_id="1", + event_type="platform.example", + payload={}, + ) + ) + event.organization = self.first + event.save(update_fields=["organization"]) + + with self.assertRaisesMessage( + ValueError, "Platform event cannot carry tenant ownership" + ): + tenant_context_for_event(event) diff --git a/apps/backend/hub_platform/testing.py b/apps/backend/hub_platform/testing.py new file mode 100644 index 0000000..f280337 --- /dev/null +++ b/apps/backend/hub_platform/testing.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from rest_framework.test import APIClient + +from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership +from hub_platform.tenancy.context import TenantActorKind, TenantContext + + +def tenant_context_for( + user: HumanUser, + organization: Organization | None = None, +) -> TenantContext: + memberships = OrganizationMembership.objects.select_related("organization", "user").filter( + user=user, + blocked_at__isnull=True, + ) + if organization is not None: + memberships = memberships.filter(organization=organization) + return TenantContext.for_membership(memberships.get()) + + +def system_tenant_context(organization: Organization) -> TenantContext: + return TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM) + + +class TenantAPIClient(APIClient): + """Test client that turns legacy test literals into the C03 tenant route. + + Production URL resolution is untouched. New tenancy tests call canonical URLs + directly and assert that the old runtime routes return 404. + """ + + tenant_namespaces = { + "access-profiles", + "ai", + "calls", + "channels", + "company", + "conversations", + "employees", + "integrations", + "notifications", + "orders", + "sales", + "support", + } + public_prefixes = ( + "/api/v1/ai/files/", + "/api/v1/calls/access/", + "/api/v1/calls/invites/", + "/api/v1/orders/ingest/", + "/api/v1/support/sessions/", + ) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._tenant_user = None + self.organization_public_id = None + + def force_authenticate(self, user=None, token=None) -> None: + self._tenant_user = user + if user is not None: + # Tenant middleware runs before DRF's force-auth hook, so test + # requests also need Django's authenticated session available. + super().force_login(user) + super().force_authenticate(user=user, token=token) + + def login(self, **credentials) -> bool: + authenticated = super().login(**credentials) + if authenticated: + email = credentials.get("email") or credentials.get("username") + self._tenant_user = HumanUser.objects.filter(email=email).first() + return authenticated + + def force_login(self, user, backend=None) -> None: + self._tenant_user = user + super().force_login(user, backend=backend) + + def logout(self) -> None: + self._tenant_user = None + super().logout() + + def set_tenant(self, organization) -> None: + self.organization_public_id = organization.public_id + + def generic(self, method, path, data="", content_type="application/octet-stream", secure=False, **extra): + path = self._organization_path(path) + return super().generic(method, path, data, content_type, secure, **extra) + + def _organization_path(self, path: str) -> str: + if not path.startswith("/api/v1/") or path.startswith("/api/v1/organizations/"): + return path + if any(path.startswith(prefix) for prefix in self.public_prefixes): + return path + namespace = path[len("/api/v1/") :].split("/", 1)[0] + if namespace not in self.tenant_namespaces: + return path + public_id = self.organization_public_id + if public_id is None and self._tenant_user is not None: + values = list( + self._tenant_user.memberships.filter(blocked_at__isnull=True).values_list( + "organization__public_id", flat=True + )[:2] + ) + public_id = values[0] if len(values) == 1 else None + if public_id is None: + return path + return f"/api/v1/organizations/{public_id}{path[len('/api/v1') :]}" diff --git a/apps/backend/hub_platform/webchat/services.py b/apps/backend/hub_platform/webchat/services.py index 14a9fc0..1628665 100644 --- a/apps/backend/hub_platform/webchat/services.py +++ b/apps/backend/hub_platform/webchat/services.py @@ -2,7 +2,7 @@ import hashlib import secrets import uuid -from django.db import transaction +from django.db import models, transaction from hub_platform.conversations.ingest import ingest_inbound from hub_platform.conversations.models import ( @@ -29,11 +29,16 @@ def _hash(token: str) -> str: def web_connection_for_channel(channel_code: str) -> Integration | None: - return ( + matches = list( Integration.objects.select_related("channel") - .filter(provider=IntegrationProvider.WEB, channel__code=channel_code) - .first() + .filter( + provider=IntegrationProvider.WEB, + channel__code=channel_code, + channel__is_active=True, + ) + .order_by("id")[:2] ) + return matches[0] if len(matches) == 1 else None def _host_allowed(integration: Integration, origin: str) -> bool: @@ -94,9 +99,17 @@ def resolve_session(token: str) -> WebSession | None: return None return ( WebSession.objects.select_related( - "connection", "connection__channel", "identity", "identity__contact" + "connection", + "connection__channel", + "connection__organization", + "identity", + "identity__contact", + ) + .filter( + token_hash=_hash(token), + connection__organization_id=models.F("identity__contact__organization_id"), + connection__channel__organization_id=models.F("connection__organization_id"), ) - .filter(token_hash=_hash(token)) .first() ) diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index 16a8574..0066b8a 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -3,61 +3,38 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { edevsHubTheme } from "@edevs/ui"; -import { api } from "./api/client"; +import { api, setActiveOrganization } from "./api/client"; import { canAccess, defaultRoute } from "./auth/access"; +import { activateOrganization, clearOrganizationPreference } from "./auth/session"; import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; import { Shell } from "./layout/Shell"; import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; +import { useRouteNavigation } from "./useRouteNavigation"; import type { AiAgent } from "./features/ai/model"; -import type { AppData, AuthChallenge, Department, Employee, Product, RouteKey, SessionUser } from "./types"; +import type { AppData, AuthChallenge, AuthenticatedUser, Department, Employee, Product, SessionUser } from "./types"; export function App() { const initialRoute = useMemo(() => routeFromPath(window.location.pathname, window.location.search), []); const [sessionLoading, setSessionLoading] = useState(true); + const [identity, setIdentity] = useState(null); const [user, setUser] = useState(null); + const [organizationPublicId, setOrganizationPublicId] = useState(initialRoute.organizationPublicId); const [totpChallenge, setTotpChallenge] = useState(null); const [recovering, setRecovering] = useState(false); const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password"); - const [route, setRoute] = useState(initialRoute.route); - const [selectedEmployeeId, setSelectedEmployeeId] = useState(initialRoute.employeeId); - const [selectedProductId, setSelectedProductId] = useState(initialRoute.productId); - const [selectedProductCode, setSelectedProductCode] = useState(initialRoute.productCode); - const [selectedAgentId, setSelectedAgentId] = useState(initialRoute.agentId); - const [selectedKnowledgeId, setSelectedKnowledgeId] = useState(initialRoute.knowledgeId); - const [selectedConversationId, setSelectedConversationId] = useState(null); - const [selectedClientId, setSelectedClientId] = useState(initialRoute.clientId); - const [selectedOrderId, setSelectedOrderId] = useState(initialRoute.orderId); const [data, setData] = useState({ employees: [], departments: [], products: [], agents: [] }); const [dataError, setDataError] = useState(false); + const navigation = useRouteNavigation(initialRoute, organizationPublicId); + const { navigate } = navigation; - const navigate = useCallback((nextRoute: RouteKey, entityId: number | null = null, replace = false, productCode: string | null = null) => { - const nextEmployeeId = nextRoute === "employeeDetail" ? entityId : null; - const nextProductId = nextRoute === "productDetail" ? entityId : null; - const nextProductCode = nextRoute === "aiAgentCreate" ? productCode : null; - const nextAgentId = nextRoute === "aiAgentDetail" ? entityId : null; - const nextKnowledgeId = nextRoute === "aiKnowledgeDetail" ? entityId : null; - const nextConversationId = nextRoute === "salesDialogs" || nextRoute === "supportDialogs" ? entityId : null; - const nextClientId = nextRoute === "salesClientDetail" ? entityId : null; - const nextOrderId = nextRoute === "salesOrderDetail" ? entityId : null; - const nextPath = pathFromRoute(nextRoute, entityId, nextProductCode); - setRoute(nextRoute); - setSelectedEmployeeId(nextEmployeeId); - setSelectedProductId(nextProductId); - setSelectedProductCode(nextProductCode); - setSelectedAgentId(nextAgentId); - setSelectedKnowledgeId(nextKnowledgeId); - setSelectedConversationId(nextConversationId); - setSelectedClientId(nextClientId); - setSelectedOrderId(nextOrderId); - if (`${window.location.pathname}${window.location.search}` !== nextPath) { - const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId, productCode: nextProductCode, agentId: nextAgentId, knowledgeId: nextKnowledgeId }; - if (replace) { - window.history.replaceState(state, "", nextPath); - } else { - window.history.pushState(state, "", nextPath); - } - } + const useIdentity = useCallback((nextIdentity: AuthenticatedUser, requestedId: string | null) => { + const activeUser = activateOrganization(nextIdentity, requestedId); + setIdentity(nextIdentity); + setUser(activeUser); + setOrganizationPublicId(activeUser?.organizationPublicId ?? null); + setActiveOrganization(activeUser?.organizationPublicId ?? null); + return activeUser; }, []); const loadData = useCallback(async () => { @@ -86,45 +63,60 @@ export function App() { }, [user]); useEffect(() => { - api<{ authenticated: boolean; user?: SessionUser }>("/api/v1/auth/session/") + api<{ authenticated: boolean; user?: AuthenticatedUser }>("/api/v1/auth/session/") .then((payload) => { if (payload.authenticated && payload.user) { - setUser(payload.user); + const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId); + if (activeUser && !initialRoute.organizationPublicId) { + const nextPath = pathFromRoute( + initialRoute.route, + initialRoute.employeeId || initialRoute.productId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.orderId, + initialRoute.productCode, + activeUser.organizationPublicId, + ); + window.history.replaceState({}, "", nextPath); + } } }) .finally(() => setSessionLoading(false)); - }, []); + }, [initialRoute, useIdentity]); useEffect(() => { const onPopState = () => { const nextRoute = routeFromPath(window.location.pathname, window.location.search); - setRoute(nextRoute.route); - setSelectedEmployeeId(nextRoute.employeeId); - setSelectedProductId(nextRoute.productId); - setSelectedProductCode(nextRoute.productCode); - setSelectedAgentId(nextRoute.agentId); - setSelectedKnowledgeId(nextRoute.knowledgeId); - setSelectedClientId(nextRoute.clientId); - setSelectedOrderId(nextRoute.orderId); + if (identity && nextRoute.organizationPublicId !== organizationPublicId) { + useIdentity(identity, nextRoute.organizationPublicId); + } + navigation.applyRouteState(nextRoute); }; window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); - }, []); + }, [identity, navigation.applyRouteState, organizationPublicId, useIdentity]); useEffect(() => { if (user) void loadData(); }, [loadData, user]); - const landAfterAuth = useCallback((nextUser: SessionUser) => { - setUser(nextUser); - navigate(defaultRoute(nextUser), null, true); - }, [navigate]); + const landAfterAuth = useCallback((nextIdentity: AuthenticatedUser) => { + const activeUser = useIdentity(nextIdentity, initialRoute.organizationPublicId); + if (activeUser) { + navigate(defaultRoute(activeUser), null, true, null, activeUser.organizationPublicId); + } + }, [initialRoute.organizationPublicId, navigate, useIdentity]); + + const refreshIdentity = useCallback((nextIdentity: AuthenticatedUser) => { + useIdentity(nextIdentity, organizationPublicId); + }, [organizationPublicId, useIdentity]); async function logout() { await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined); + setIdentity(null); setUser(null); + setOrganizationPublicId(null); + setActiveOrganization(null); + clearOrganizationPreference(); setTotpChallenge(null); - navigate("command", null, true); + navigate("command", null, true, null, null); setData({ employees: [], departments: [], products: [], agents: [] }); } @@ -142,22 +134,24 @@ export function App() { {totpChallenge ? ( { setTotpChallenge(null); landAfterAuth(nextUser); }} /> - ) : !user ? ( + ) : !identity ? ( recovering ? ( setRecovering(false)} /> ) : ( setRecovering(true)} /> ) + ) : !user ? ( + ) : user.mustChangePassword ? ( - + ) : user.totpRequired && !user.totpEnabled ? ( - + ) : dataError ? ( - ) : !canAccess(user, route) ? ( + ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={selectedOrderId} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} /> + navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductId={navigation.selectedProductId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={navigation.selectedOrderId} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} /> )} ); diff --git a/apps/internal-ui/src/api/client.test.ts b/apps/internal-ui/src/api/client.test.ts index 6e78bdf..1546d28 100644 --- a/apps/internal-ui/src/api/client.test.ts +++ b/apps/internal-ui/src/api/client.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { api } from "./client"; +import { api, setActiveOrganization } from "./client"; + +const organizationPublicId = "123e4567-e89b-12d3-a456-426614174000"; function jsonResponse(body: unknown, init: { status?: number; ok?: boolean } = {}): Response { const status = init.status ?? 200; @@ -19,6 +21,7 @@ const originalFetch = globalThis.fetch; const originalDocumentCookie = (globalThis as { document?: { cookie?: string } }).document?.cookie; afterEach(() => { + setActiveOrganization(null); globalThis.fetch = originalFetch; if (originalDocumentCookie === undefined) { delete (globalThis as { document?: { cookie?: string } }).document; @@ -28,6 +31,7 @@ afterEach(() => { describe("api client", () => { it("resolves 204 No Content to undefined without parsing the body", async () => { + setActiveOrganization(organizationPublicId); (globalThis as { document?: { cookie?: string } }).document = { cookie: "csrftoken=abc" }; globalThis.fetch = vi.fn().mockResolvedValue(emptyResponse(204)) as unknown as typeof fetch; @@ -42,6 +46,7 @@ describe("api client", () => { }); it("parses JSON for a normal 200 response", async () => { + setActiveOrganization(organizationPublicId); const payload = { integration: { id: 5 } }; globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(payload)) as unknown as typeof fetch; @@ -49,9 +54,41 @@ describe("api client", () => { }); it("throws with the server detail on an error response", async () => { + setActiveOrganization(organizationPublicId); const payload = { detail: "Интеграция не найдена" }; globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(payload, { status: 404, ok: false })) as unknown as typeof fetch; await expect(api("/api/v1/integrations/1/")).rejects.toThrow("Интеграция не найдена"); }); + + it("uses the canonical organization API route", async () => { + setActiveOrganization(organizationPublicId); + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ items: [] })) as unknown as typeof fetch; + + await api("/api/v1/company/products/"); + + expect(globalThis.fetch).toHaveBeenCalledWith( + `/api/v1/organizations/${organizationPublicId}/company/products/`, + expect.objectContaining({ credentials: "include" }), + ); + }); + + it("fails closed when a tenant request has no selected organization", async () => { + await expect(api("/api/v1/integrations/")).rejects.toThrow( + "Organization context is required", + ); + }); + + it("keeps public credential routes outside the selected organization", async () => { + setActiveOrganization(organizationPublicId); + (globalThis as { document?: { cookie?: string } }).document = { cookie: "csrftoken=abc" }; + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true })) as unknown as typeof fetch; + + await api("/api/v1/support/sessions/", { method: "POST", body: "{}" }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/v1/support/sessions/", + expect.objectContaining({ credentials: "include" }), + ); + }); }); diff --git a/apps/internal-ui/src/api/client.ts b/apps/internal-ui/src/api/client.ts index 66999fb..534f19c 100644 --- a/apps/internal-ui/src/api/client.ts +++ b/apps/internal-ui/src/api/client.ts @@ -1,6 +1,47 @@ const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, ""); +let activeOrganizationPublicId: string | null = null; + +const tenantNamespaces = [ + "access-profiles", + "ai", + "calls", + "channels", + "company", + "conversations", + "employees", + "integrations", + "notifications", + "orders", + "sales", + "support", +]; + +const publicPaths = [ + "/api/v1/ai/files/", + "/api/v1/calls/access/", + "/api/v1/calls/invites/", + "/api/v1/orders/ingest/", + "/api/v1/support/sessions/", +]; + +export function setActiveOrganization(publicId: string | null): void { + activeOrganizationPublicId = publicId; +} + +function organizationScopedPath(path: string): string { + if (!path.startsWith("/api/v1/") || publicPaths.some((item) => path.startsWith(item))) { + return path; + } + const namespace = path.slice("/api/v1/".length).split("/", 1)[0]; + if (!tenantNamespaces.includes(namespace)) return path; + if (!activeOrganizationPublicId) { + throw new Error("Organization context is required"); + } + return `/api/v1/organizations/${activeOrganizationPublicId}${path.slice("/api/v1".length)}`; +} function resolveApiUrl(path: string): string { + path = organizationScopedPath(path); if (!configuredApiBase) return path; if (configuredApiBase.endsWith("/api/v1") && path.startsWith("/api/v1/")) { return `${configuredApiBase}${path.slice("/api/v1".length)}`; diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index 8074c27..e11102e 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -3,10 +3,11 @@ import { describe, expect, it } from "vitest"; import type { SessionUser } from "../types"; import { canAccess, defaultRoute } from "./access"; -const baseUser: Omit = { +const baseUser: Omit = { id: 1, email: "employee@example.test", fullName: "Employee", + organizationPublicId: "00000000-0000-4000-8000-000000000001", positionTitle: "Specialist", organization: "example", organizationName: "Example", @@ -20,19 +21,30 @@ function userWith( capabilities: string[], departmentCode: string | null = null, ): SessionUser { - return { - ...baseUser, - role: "EMPLOYEE", + const membership = { + id: baseUser.id, + organizationPublicId: baseUser.organizationPublicId, + organization: baseUser.organization, + organizationName: baseUser.organizationName, + role: "EMPLOYEE" as const, + positionTitle: baseUser.positionTitle, + department: baseUser.department, + totpRequired: baseUser.totpRequired, capabilities, accessScopes: [ { - scopeType: departmentCode ? "DEPARTMENT" : "ORGANIZATION", + scopeType: departmentCode ? "DEPARTMENT" as const : "ORGANIZATION" as const, departmentId: departmentCode ? 10 : null, departmentCode, capabilities, }, ], }; + return { + ...baseUser, + ...membership, + memberships: [membership], + }; } describe("effective access navigation", () => { diff --git a/apps/internal-ui/src/auth/session.ts b/apps/internal-ui/src/auth/session.ts new file mode 100644 index 0000000..fbc47de --- /dev/null +++ b/apps/internal-ui/src/auth/session.ts @@ -0,0 +1,20 @@ +import type { AuthenticatedUser, SessionUser } from "../types"; + +const preferenceKey = "custocrm.organizationPublicId"; + +export function activateOrganization( + identity: AuthenticatedUser, + requestedPublicId: string | null, +): SessionUser | null { + const preferred = requestedPublicId || window.sessionStorage.getItem(preferenceKey); + const membership = + identity.memberships.find((item) => item.organizationPublicId === preferred) || + (identity.memberships.length === 1 ? identity.memberships[0] : null); + if (!membership) return null; + window.sessionStorage.setItem(preferenceKey, membership.organizationPublicId); + return { ...identity, ...membership }; +} + +export function clearOrganizationPreference(): void { + window.sessionStorage.removeItem(preferenceKey); +} diff --git a/apps/internal-ui/src/features/auth/AuthChangePassword.tsx b/apps/internal-ui/src/features/auth/AuthChangePassword.tsx index 9c7cc77..76806d4 100644 --- a/apps/internal-ui/src/features/auth/AuthChangePassword.tsx +++ b/apps/internal-ui/src/features/auth/AuthChangePassword.tsx @@ -1,14 +1,14 @@ import { type FormEvent, useState } from "react"; import { api } from "../../api/client"; -import type { SessionUser } from "../../types"; +import type { AuthenticatedUser, SessionUser } from "../../types"; import { Icon } from "../../shared/icons"; import { Button } from "../../shared/ui-controls"; import { AuthField } from "./AuthField"; import { AuthFrame } from "./AuthFrame"; import { passwordIsValid, passwordLabels, passwordScore } from "./password"; -export function AuthChangePassword({ onChanged }: { user: SessionUser; onChanged: (user: SessionUser) => void }) { +export function AuthChangePassword({ onChanged }: { user: SessionUser; onChanged: (user: AuthenticatedUser) => void }) { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -23,7 +23,7 @@ export function AuthChangePassword({ onChanged }: { user: SessionUser; onChanged setSubmitting(true); setError(""); try { - const payload = await api<{ authenticated: true; user: SessionUser }>("/api/v1/auth/change-temporary-password/", { + const payload = await api<{ authenticated: true; user: AuthenticatedUser }>("/api/v1/auth/change-temporary-password/", { method: "POST", body: JSON.stringify({ newPassword: password }), }); diff --git a/apps/internal-ui/src/features/auth/AuthLogin.tsx b/apps/internal-ui/src/features/auth/AuthLogin.tsx index 3b41eaa..e5810b4 100644 --- a/apps/internal-ui/src/features/auth/AuthLogin.tsx +++ b/apps/internal-ui/src/features/auth/AuthLogin.tsx @@ -1,13 +1,13 @@ import { type FormEvent, useState } from "react"; import { api } from "../../api/client"; -import type { AuthChallenge, LoginPayload, SessionUser } from "../../types"; +import type { AuthChallenge, AuthenticatedUser, LoginPayload } from "../../types"; import { Icon } from "../../shared/icons"; import { Button } from "../../shared/ui-controls"; import { AuthField } from "./AuthField"; import { AuthFrame } from "./AuthFrame"; -export function AuthLogin({ onLogin, onTotpChallenge, onRecover }: { onLogin: (user: SessionUser) => void; onTotpChallenge: (challenge: AuthChallenge) => void; onRecover: () => void }) { +export function AuthLogin({ onLogin, onTotpChallenge, onRecover }: { onLogin: (user: AuthenticatedUser) => void; onTotpChallenge: (challenge: AuthChallenge) => void; onRecover: () => void }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [show, setShow] = useState(false); diff --git a/apps/internal-ui/src/features/auth/AuthTotpCode.tsx b/apps/internal-ui/src/features/auth/AuthTotpCode.tsx index f45bc99..38b13f6 100644 --- a/apps/internal-ui/src/features/auth/AuthTotpCode.tsx +++ b/apps/internal-ui/src/features/auth/AuthTotpCode.tsx @@ -1,14 +1,14 @@ import { type FormEvent, useEffect, useState } from "react"; import { api } from "../../api/client"; -import type { AuthChallenge, SessionUser } from "../../types"; +import type { AuthChallenge, AuthenticatedUser } from "../../types"; import { Icon } from "../../shared/icons"; import { Button } from "../../shared/ui-controls"; import { AuthCodeInput } from "./AuthCodeInput"; import { AuthFrame } from "./AuthFrame"; import { formatCountdown } from "./time"; -export function AuthTotpCode({ challenge, onVerified }: { challenge: AuthChallenge; onVerified: (user: SessionUser) => void }) { +export function AuthTotpCode({ challenge, onVerified }: { challenge: AuthChallenge; onVerified: (user: AuthenticatedUser) => void }) { const [code, setCode] = useState(""); const [error, setError] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -24,7 +24,7 @@ export function AuthTotpCode({ challenge, onVerified }: { challenge: AuthChallen setSubmitting(true); setError(false); try { - const payload = await api<{ authenticated: true; user: SessionUser }>("/api/v1/auth/totp/verify/", { + const payload = await api<{ authenticated: true; user: AuthenticatedUser }>("/api/v1/auth/totp/verify/", { method: "POST", body: JSON.stringify({ code }), }); diff --git a/apps/internal-ui/src/features/auth/AuthTotpSetup.tsx b/apps/internal-ui/src/features/auth/AuthTotpSetup.tsx index 87b75fb..1e4cc42 100644 --- a/apps/internal-ui/src/features/auth/AuthTotpSetup.tsx +++ b/apps/internal-ui/src/features/auth/AuthTotpSetup.tsx @@ -1,14 +1,14 @@ import { type FormEvent, useEffect, useState } from "react"; import { api } from "../../api/client"; -import type { SessionUser } from "../../types"; +import type { AuthenticatedUser, SessionUser } from "../../types"; import { Icon } from "../../shared/icons"; import { Button } from "../../shared/ui-controls"; import { AuthCodeInput } from "./AuthCodeInput"; import { AuthFrame } from "./AuthFrame"; import { TotpQr } from "./TotpQr"; -export function AuthTotpSetup({ user: _user, onConfirmed }: { user: SessionUser; onConfirmed: (user: SessionUser) => void }) { +export function AuthTotpSetup({ user: _user, onConfirmed }: { user: SessionUser; onConfirmed: (user: AuthenticatedUser) => void }) { const [secret, setSecret] = useState(""); const [otpauthUrl, setOtpauthUrl] = useState(""); const [code, setCode] = useState(""); @@ -30,7 +30,7 @@ export function AuthTotpSetup({ user: _user, onConfirmed }: { user: SessionUser; setSubmitting(true); setError(false); try { - const payload = await api<{ authenticated: true; user: SessionUser }>("/api/v1/auth/totp/confirm/", { + const payload = await api<{ authenticated: true; user: AuthenticatedUser }>("/api/v1/auth/totp/confirm/", { method: "POST", body: JSON.stringify({ code }), }); diff --git a/apps/internal-ui/src/router.test.ts b/apps/internal-ui/src/router.test.ts index a7abc04..56bc320 100644 --- a/apps/internal-ui/src/router.test.ts +++ b/apps/internal-ui/src/router.test.ts @@ -2,16 +2,27 @@ import { describe, expect, it } from "vitest"; import { pathFromRoute, routeFromPath } from "./router"; +const empty = { + organizationPublicId: null, + employeeId: null, + productId: null, + productCode: null, + agentId: null, + knowledgeId: null, + clientId: null, + orderId: null, +}; + describe("employee access routes", () => { it("parses and creates the access profile URL", () => { - expect(routeFromPath("/employees/access-profiles")).toEqual({ route: "accessProfiles", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null }); + expect(routeFromPath("/employees/access-profiles")).toEqual({ route: "accessProfiles", ...empty }); expect(pathFromRoute("accessProfiles")).toBe("/employees/access-profiles"); }); }); describe("product routes", () => { it("parses a product detail URL", () => { - expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null }); + expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", ...empty, productId: 42 }); }); it("creates a product detail URL", () => { @@ -21,11 +32,11 @@ describe("product routes", () => { describe("sales detail routes", () => { it("parses a client detail URL", () => { - expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: 15, orderId: null }); + expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", ...empty, clientId: 15 }); }); it("parses an order detail URL", () => { - expect(routeFromPath("/departments/sales/orders/8")).toEqual({ route: "salesOrderDetail", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: 8 }); + expect(routeFromPath("/departments/sales/orders/8")).toEqual({ route: "salesOrderDetail", ...empty, orderId: 8 }); }); it("creates client and order detail URLs", () => { @@ -36,7 +47,7 @@ describe("sales detail routes", () => { describe("ai agent routes", () => { it("parses an AI agent detail URL", () => { - expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", employeeId: null, productId: null, productCode: null, agentId: 7, knowledgeId: null, clientId: null, orderId: null }); + expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", ...empty, agentId: 7 }); }); it("creates an AI agent detail URL", () => { @@ -44,7 +55,7 @@ describe("ai agent routes", () => { }); it("parses an AI agent creation URL", () => { - expect(routeFromPath("/ai/agents/new", "?product=academy")).toEqual({ route: "aiAgentCreate", employeeId: null, productId: null, productCode: "academy", agentId: null, knowledgeId: null, clientId: null, orderId: null }); + expect(routeFromPath("/ai/agents/new", "?product=academy")).toEqual({ route: "aiAgentCreate", ...empty, productCode: "academy" }); }); it("creates an AI agent creation URL", () => { @@ -54,8 +65,8 @@ describe("ai agent routes", () => { describe("ai knowledge routes", () => { it("parses knowledge list and detail URLs", () => { - expect(routeFromPath("/ai/knowledge")).toEqual({ route: "aiKnowledge", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null }); - expect(routeFromPath("/ai/knowledge/12")).toEqual({ route: "aiKnowledgeDetail", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: 12, clientId: null, orderId: null }); + expect(routeFromPath("/ai/knowledge")).toEqual({ route: "aiKnowledge", ...empty }); + expect(routeFromPath("/ai/knowledge/12")).toEqual({ route: "aiKnowledgeDetail", ...empty, knowledgeId: 12 }); }); it("creates knowledge URLs", () => { @@ -66,8 +77,8 @@ describe("ai knowledge routes", () => { describe("support routes", () => { it("parses support overview and dialogs URLs", () => { - expect(routeFromPath("/departments/support")).toEqual({ route: "supportOverview", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null }); - expect(routeFromPath("/departments/support/dialogs")).toEqual({ route: "supportDialogs", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null }); + expect(routeFromPath("/departments/support")).toEqual({ route: "supportOverview", ...empty }); + expect(routeFromPath("/departments/support/dialogs")).toEqual({ route: "supportDialogs", ...empty }); }); it("creates support overview and dialogs URLs", () => { @@ -75,3 +86,22 @@ describe("support routes", () => { expect(pathFromRoute("supportDialogs")).toBe("/departments/support/dialogs"); }); }); + +describe("organization routes", () => { + const organizationPublicId = "123e4567-e89b-12d3-a456-426614174000"; + + it("parses the selected organization from the URL", () => { + expect(routeFromPath(`/organizations/${organizationPublicId}/products/42`)).toEqual({ + route: "productDetail", + ...empty, + organizationPublicId, + productId: 42, + }); + }); + + it("creates navigation URLs inside the selected organization", () => { + expect(pathFromRoute("salesOrders", null, null, organizationPublicId)).toBe( + `/organizations/${organizationPublicId}/departments/sales/orders`, + ); + }); +}); diff --git a/apps/internal-ui/src/router.ts b/apps/internal-ui/src/router.ts index 4a2cbff..2547680 100644 --- a/apps/internal-ui/src/router.ts +++ b/apps/internal-ui/src/router.ts @@ -1,6 +1,7 @@ import type { RouteKey } from "./types"; export type RouteState = { + organizationPublicId: string | null; route: RouteKey; employeeId: number | null; productId: number | null; @@ -12,78 +13,83 @@ export type RouteState = { }; export function routeFromPath(pathname: string, search = ""): RouteState { - const path = pathname.replace(/\/+$/, "") || "/"; + const normalized = pathname.replace(/\/+$/, "") || "/"; + const match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i); + const organizationPublicId = match?.[1] ?? null; + const path = match ? match[2] || "/" : normalized; const base = { employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null }; - if (path === "/" || path === "/command") return { route: "command", ...base }; - if (path === "/departments") return { route: "departments", ...base }; - if (path === "/departments/sales") return { route: "salesOverview", ...base }; - if (path === "/departments/support") return { route: "supportOverview", ...base }; - if (path === "/departments/support/dialogs") return { route: "supportDialogs", ...base }; - if (path === "/departments/sales/clients") return { route: "salesClients", ...base }; + const state = { organizationPublicId, ...base }; + if (path === "/" || path === "/command") return { route: "command", ...state }; + if (path === "/departments") return { route: "departments", ...state }; + if (path === "/departments/sales") return { route: "salesOverview", ...state }; + if (path === "/departments/support") return { route: "supportOverview", ...state }; + if (path === "/departments/support/dialogs") return { route: "supportDialogs", ...state }; + if (path === "/departments/sales/clients") return { route: "salesClients", ...state }; if (path.startsWith("/departments/sales/clients/")) { const id = Number(path.split("/")[4]); - return Number.isInteger(id) && id > 0 ? { ...base, route: "salesClientDetail", clientId: id } : { route: "salesClients", ...base }; + return Number.isInteger(id) && id > 0 ? { ...state, route: "salesClientDetail", clientId: id } : { route: "salesClients", ...state }; } - if (path === "/departments/sales/dialogs") return { route: "salesDialogs", ...base }; - if (path === "/departments/sales/orders") return { route: "salesOrders", ...base }; + if (path === "/departments/sales/dialogs") return { route: "salesDialogs", ...state }; + if (path === "/departments/sales/orders") return { route: "salesOrders", ...state }; if (path.startsWith("/departments/sales/orders/")) { const id = Number(path.split("/")[4]); - return Number.isInteger(id) && id > 0 ? { ...base, route: "salesOrderDetail", orderId: id } : { route: "salesOrders", ...base }; + return Number.isInteger(id) && id > 0 ? { ...state, route: "salesOrderDetail", orderId: id } : { route: "salesOrders", ...state }; } - if (path === "/employees") return { route: "employees", ...base }; - if (path === "/employees/access-profiles") return { route: "accessProfiles", ...base }; + if (path === "/employees") return { route: "employees", ...state }; + if (path === "/employees/access-profiles") return { route: "accessProfiles", ...state }; if (path.startsWith("/employees/")) { const id = Number(path.split("/")[2]); - return Number.isInteger(id) && id > 0 ? { ...base, route: "employeeDetail", employeeId: id } : { route: "employees", ...base }; + return Number.isInteger(id) && id > 0 ? { ...state, route: "employeeDetail", employeeId: id } : { route: "employees", ...state }; } - if (path === "/products") return { route: "products", ...base }; + if (path === "/products") return { route: "products", ...state }; if (path.startsWith("/products/")) { const id = Number(path.split("/")[2]); - return Number.isInteger(id) && id > 0 ? { ...base, route: "productDetail", productId: id } : { route: "products", ...base }; + return Number.isInteger(id) && id > 0 ? { ...state, route: "productDetail", productId: id } : { route: "products", ...state }; } - if (path === "/ai" || path === "/ai/agents") return { route: "aiAgents", ...base }; + if (path === "/ai" || path === "/ai/agents") return { route: "aiAgents", ...state }; if (path === "/ai/agents/new") { const productCode = new URLSearchParams(search).get("product"); - return { route: "aiAgentCreate", ...base, productCode }; + return { route: "aiAgentCreate", ...state, productCode }; } if (path.startsWith("/ai/agents/")) { const id = Number(path.split("/")[3]); - return Number.isInteger(id) && id > 0 ? { ...base, route: "aiAgentDetail", agentId: id } : { route: "aiAgents", ...base }; + return Number.isInteger(id) && id > 0 ? { ...state, route: "aiAgentDetail", agentId: id } : { route: "aiAgents", ...state }; } - if (path === "/ai/knowledge") return { route: "aiKnowledge", ...base }; + if (path === "/ai/knowledge") return { route: "aiKnowledge", ...state }; if (path.startsWith("/ai/knowledge/")) { const id = Number(path.split("/")[3]); - return Number.isInteger(id) && id > 0 ? { ...base, route: "aiKnowledgeDetail", knowledgeId: id } : { route: "aiKnowledge", ...base }; + return Number.isInteger(id) && id > 0 ? { ...state, route: "aiKnowledgeDetail", knowledgeId: id } : { route: "aiKnowledge", ...state }; } - if (path === "/ai/usage") return { route: "aiUsage", ...base }; - if (path === "/integrations") return { route: "integrations", ...base }; - if (path === "/profile") return { route: "profile", ...base }; - return { route: "command", ...base }; + if (path === "/ai/usage") return { route: "aiUsage", ...state }; + if (path === "/integrations") return { route: "integrations", ...state }; + if (path === "/profile") return { route: "profile", ...state }; + return { route: "command", ...state }; } -export function pathFromRoute(route: RouteKey, entityId: number | null = null, productCode: string | null = null): string { - if (route === "command") return "/"; - if (route === "departments") return "/departments"; - if (route === "salesOverview") return "/departments/sales"; - if (route === "supportOverview") return "/departments/support"; - if (route === "supportDialogs") return "/departments/support/dialogs"; - if (route === "salesClients") return "/departments/sales/clients"; - if (route === "salesClientDetail") return entityId ? `/departments/sales/clients/${entityId}` : "/departments/sales/clients"; - if (route === "salesDialogs") return "/departments/sales/dialogs"; - if (route === "salesOrders") return "/departments/sales/orders"; - if (route === "salesOrderDetail") return entityId ? `/departments/sales/orders/${entityId}` : "/departments/sales/orders"; - if (route === "employees") return "/employees"; - if (route === "accessProfiles") return "/employees/access-profiles"; - if (route === "employeeDetail") return entityId ? `/employees/${entityId}` : "/employees"; - if (route === "products") return "/products"; - if (route === "productDetail") return entityId ? `/products/${entityId}` : "/products"; - if (route === "aiAgents") return "/ai/agents"; - if (route === "aiAgentCreate") return productCode ? `/ai/agents/new?product=${encodeURIComponent(productCode)}` : "/ai/agents/new"; - if (route === "aiUsage") return "/ai/usage"; - if (route === "aiAgentDetail") return entityId ? `/ai/agents/${entityId}` : "/ai/agents"; - if (route === "aiKnowledge") return "/ai/knowledge"; - if (route === "aiKnowledgeDetail") return entityId ? `/ai/knowledge/${entityId}` : "/ai/knowledge"; - if (route === "integrations") return "/integrations"; - if (route === "profile") return "/profile"; - return "/profile"; +export function pathFromRoute(route: RouteKey, entityId: number | null = null, productCode: string | null = null, organizationPublicId: string | null = null): string { + const prefix = organizationPublicId ? `/organizations/${organizationPublicId}` : ""; + if (route === "command") return `${prefix}/`; + if (route === "departments") return `${prefix}/departments`; + if (route === "salesOverview") return `${prefix}/departments/sales`; + if (route === "supportOverview") return `${prefix}/departments/support`; + if (route === "supportDialogs") return `${prefix}/departments/support/dialogs`; + if (route === "salesClients") return `${prefix}/departments/sales/clients`; + if (route === "salesClientDetail") return entityId ? `${prefix}/departments/sales/clients/${entityId}` : `${prefix}/departments/sales/clients`; + if (route === "salesDialogs") return `${prefix}/departments/sales/dialogs`; + if (route === "salesOrders") return `${prefix}/departments/sales/orders`; + if (route === "salesOrderDetail") return entityId ? `${prefix}/departments/sales/orders/${entityId}` : `${prefix}/departments/sales/orders`; + if (route === "employees") return `${prefix}/employees`; + if (route === "accessProfiles") return `${prefix}/employees/access-profiles`; + if (route === "employeeDetail") return entityId ? `${prefix}/employees/${entityId}` : `${prefix}/employees`; + if (route === "products") return `${prefix}/products`; + if (route === "productDetail") return entityId ? `${prefix}/products/${entityId}` : `${prefix}/products`; + if (route === "aiAgents") return `${prefix}/ai/agents`; + if (route === "aiAgentCreate") return productCode ? `${prefix}/ai/agents/new?product=${encodeURIComponent(productCode)}` : `${prefix}/ai/agents/new`; + if (route === "aiUsage") return `${prefix}/ai/usage`; + if (route === "aiAgentDetail") return entityId ? `${prefix}/ai/agents/${entityId}` : `${prefix}/ai/agents`; + if (route === "aiKnowledge") return `${prefix}/ai/knowledge`; + if (route === "aiKnowledgeDetail") return entityId ? `${prefix}/ai/knowledge/${entityId}` : `${prefix}/ai/knowledge`; + if (route === "integrations") return `${prefix}/integrations`; + if (route === "profile") return `${prefix}/profile`; + return `${prefix}/profile`; } diff --git a/apps/internal-ui/src/types.ts b/apps/internal-ui/src/types.ts index 472aab7..3656e29 100644 --- a/apps/internal-ui/src/types.ts +++ b/apps/internal-ui/src/types.ts @@ -5,22 +5,30 @@ import type { AiAgent } from "./features/ai/model"; export type Role = "OWNER" | "ADMIN" | "EMPLOYEE"; export type ProductStatus = "ACTIVE" | "DISABLED"; -export type SessionUser = { +export type OrganizationMembership = { id: number; - email: string; - fullName: string; + organizationPublicId: string; role: Role; positionTitle: string; organization: string; organizationName: string; department: string | null; - mustChangePassword: boolean; totpRequired: boolean; - totpEnabled: boolean; capabilities: string[]; accessScopes: AccessScope[]; }; +export type AuthenticatedUser = { + id: number; + email: string; + fullName: string; + mustChangePassword: boolean; + totpEnabled: boolean; + memberships: OrganizationMembership[]; +}; + +export type SessionUser = AuthenticatedUser & OrganizationMembership; + export type AccessScope = { scopeType: "ORGANIZATION" | "DEPARTMENT"; departmentId: number | null; @@ -31,11 +39,10 @@ export type AccessScope = { export type AuthChallenge = { email: string; fullName: string; - role: Role; }; export type LoginPayload = - | { authenticated: true; user: SessionUser } + | { authenticated: true; user: AuthenticatedUser } | { authenticated: false; totpRequired: true; totpEnabled: true; challenge: AuthChallenge }; // Флаги доступных действий над сотрудником для текущего пользователя. Backend — diff --git a/apps/internal-ui/src/useRouteNavigation.ts b/apps/internal-ui/src/useRouteNavigation.ts new file mode 100644 index 0000000..fabc435 --- /dev/null +++ b/apps/internal-ui/src/useRouteNavigation.ts @@ -0,0 +1,78 @@ +import { useCallback, useState } from "react"; + +import { pathFromRoute, type RouteState } from "./router"; +import type { RouteKey } from "./types"; + +export function useRouteNavigation( + initialRoute: RouteState, + organizationPublicId: string | null, +) { + const [route, setRoute] = useState(initialRoute.route); + const [selectedEmployeeId, setSelectedEmployeeId] = useState(initialRoute.employeeId); + const [selectedProductId, setSelectedProductId] = useState(initialRoute.productId); + const [selectedProductCode, setSelectedProductCode] = useState(initialRoute.productCode); + const [selectedAgentId, setSelectedAgentId] = useState(initialRoute.agentId); + const [selectedKnowledgeId, setSelectedKnowledgeId] = useState(initialRoute.knowledgeId); + const [selectedConversationId, setSelectedConversationId] = useState(null); + const [selectedClientId, setSelectedClientId] = useState(initialRoute.clientId); + const [selectedOrderId, setSelectedOrderId] = useState(initialRoute.orderId); + + const applyRouteState = useCallback((next: RouteState) => { + setRoute(next.route); + setSelectedEmployeeId(next.employeeId); + setSelectedProductId(next.productId); + setSelectedProductCode(next.productCode); + setSelectedAgentId(next.agentId); + setSelectedKnowledgeId(next.knowledgeId); + setSelectedConversationId(null); + setSelectedClientId(next.clientId); + setSelectedOrderId(next.orderId); + }, []); + + const navigate = useCallback(( + nextRoute: RouteKey, + entityId: number | null = null, + replace = false, + productCode: string | null = null, + organizationId: string | null = organizationPublicId, + ) => { + const nextState: RouteState = { + organizationPublicId: organizationId, + route: nextRoute, + employeeId: nextRoute === "employeeDetail" ? entityId : null, + productId: nextRoute === "productDetail" ? entityId : null, + productCode: nextRoute === "aiAgentCreate" ? productCode : null, + agentId: nextRoute === "aiAgentDetail" ? entityId : null, + knowledgeId: nextRoute === "aiKnowledgeDetail" ? entityId : null, + clientId: nextRoute === "salesClientDetail" ? entityId : null, + orderId: nextRoute === "salesOrderDetail" ? entityId : null, + }; + applyRouteState(nextState); + setSelectedConversationId( + nextRoute === "salesDialogs" || nextRoute === "supportDialogs" ? entityId : null, + ); + const nextPath = pathFromRoute( + nextRoute, + entityId, + nextState.productCode, + organizationId, + ); + if (`${window.location.pathname}${window.location.search}` === nextPath) return; + const method = replace ? "replaceState" : "pushState"; + window.history[method](nextState, "", nextPath); + }, [applyRouteState, organizationPublicId]); + + return { + route, + selectedEmployeeId, + selectedProductId, + selectedProductCode, + selectedAgentId, + selectedKnowledgeId, + selectedConversationId, + selectedClientId, + selectedOrderId, + applyRouteState, + navigate, + }; +} diff --git a/tests/e2e/internal-ui.spec.ts b/tests/e2e/internal-ui.spec.ts index 8ecd80a..87c98c9 100644 --- a/tests/e2e/internal-ui.spec.ts +++ b/tests/e2e/internal-ui.spec.ts @@ -1,5 +1,8 @@ import { expect, type Page, test } from "@playwright/test"; +const ORGANIZATION_PUBLIC_ID = "123e4567-e89b-12d3-a456-426614174000"; +const SECOND_ORGANIZATION_PUBLIC_ID = "223e4567-e89b-12d3-a456-426614174000"; + // Эти сценарии относятся только к internal-ui; на других проектах пропускаем. test.beforeEach(({}, testInfo) => { test.skip(testInfo.project.name !== "internal-ui", "internal-ui only"); @@ -53,6 +56,29 @@ const OPERATOR = { }], }; +const identityFor = (membership: typeof OWNER) => ({ + id: membership.id, + email: membership.email, + fullName: membership.fullName, + mustChangePassword: membership.mustChangePassword, + totpEnabled: membership.totpEnabled, + memberships: [{ + id: membership.id, + organizationPublicId: ORGANIZATION_PUBLIC_ID, + organization: membership.organization, + organizationName: membership.organizationName, + role: membership.role, + positionTitle: membership.positionTitle, + department: membership.department, + totpRequired: membership.totpRequired, + capabilities: membership.capabilities, + accessScopes: membership.accessScopes, + }], +}); + +const OWNER_IDENTITY = identityFor(OWNER); +const OPERATOR_IDENTITY = identityFor(OPERATOR); + async function mockData(page: Page) { const permissions = { canView: true, canUpdateProfile: true, canChangeRole: true, canChangePlacement: true, @@ -89,18 +115,18 @@ async function mockData(page: Page) { { code: "employees.manage_privileged", name: "Управление привилегированными сотрудниками", description: "", allowedScopes: ["ORGANIZATION"], assignable: false, protected: true }, { code: "ownership.transfer", name: "Передача владения", description: "", allowedScopes: ["ORGANIZATION"], assignable: false, protected: true }, ]; - await page.route("**/api/v1/access-profiles/capabilities/", (route) => route.fulfill({ json: { items: capabilities } })); - await page.route("**/api/v1/access-profiles/", (route) => route.fulfill({ json: { items: profiles } })); - await page.route("**/api/v1/employees/**", (route) => { + await page.route("**/api/v1/organizations/*/access-profiles/capabilities/", (route) => route.fulfill({ json: { items: capabilities } })); + await page.route("**/api/v1/organizations/*/access-profiles/", (route) => route.fulfill({ json: { items: profiles } })); + await page.route("**/api/v1/organizations/*/employees/**", (route) => { const path = new URL(route.request().url()).pathname; - if (path === "/api/v1/employees/7/") { + if (path.endsWith("/employees/7/")) { return route.fulfill({ json: { employee: { ...employee, activeSessionCount: 1, auditEvents: [{ action: "identity.employee_created", result: "SUCCESS", createdAt: "2026-05-20T10:00:00Z" }] } } }); } return route.fulfill({ json: { items: [ownerEmployee, employee] } }); }); - await page.route("**/api/v1/company/departments/**", (route) => route.fulfill({ json: { items: [{ id: 1, code: "sales", name: "Отдел продаж", status: "ACTIVE", memberCount: 1, operatorCount: 1, activeOperatorCount: 1, agentCount: 0, products: [] }] } })); - await page.route("**/api/v1/company/products/**", (route) => route.fulfill({ json: { items: [] } })); - await page.route("**/api/v1/ai/agents/**", (route) => route.fulfill({ json: { items: [] } })); + await page.route("**/api/v1/organizations/*/company/departments/**", (route) => route.fulfill({ json: { items: [{ id: 1, code: "sales", name: "Отдел продаж", status: "ACTIVE", memberCount: 1, operatorCount: 1, activeOperatorCount: 1, agentCount: 0, products: [] }] } })); + await page.route("**/api/v1/organizations/*/company/products/**", (route) => route.fulfill({ json: { items: [] } })); + await page.route("**/api/v1/organizations/*/ai/agents/**", (route) => route.fulfill({ json: { items: [] } })); } async function mockSession(page: Page, user: object | null) { @@ -120,9 +146,9 @@ async function login(page: Page, user: object) { } test("OWNER logs in and lands on the command center with the global sidebar", async ({ page }) => { - await login(page, OWNER); + await login(page, OWNER_IDENTITY); - await expect(page).toHaveURL(/\/$|\/command/); + await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/`)); await expect(page.getByRole("heading", { name: "Командный центр" })).toBeVisible(); // Глобальный sidebar уровня компании. await expect(page.getByText("Уровень компании")).toBeVisible(); @@ -130,7 +156,7 @@ test("OWNER logs in and lands on the command center with the global sidebar", as }); test("OPERATOR logs in and lands on sales dialogs with the sales sidebar", async ({ page }) => { - await login(page, OPERATOR); + await login(page, OPERATOR_IDENTITY); await expect(page).toHaveURL(/\/departments\/sales\/dialogs/); // Операторская навигация показывает только рабочее пространство продаж. @@ -140,7 +166,7 @@ test("OPERATOR logs in and lands on sales dialogs with the sales sidebar", async }); test("OPERATOR opening an owner-only route sees the 403 permission screen", async ({ page }) => { - await mockSession(page, OPERATOR); + await mockSession(page, OPERATOR_IDENTITY); await mockData(page); await page.goto("/employees"); @@ -149,9 +175,33 @@ test("OPERATOR opening an owner-only route sees the 403 permission screen", asyn await expect(page.getByRole("button", { name: "Вернуться" })).toBeVisible(); }); +test("the organization URL selects one membership without a global session tenant", async ({ page }) => { + const secondMembership = { + ...OWNER_IDENTITY.memberships[0], + id: 3, + organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID, + organization: "second", + organizationName: "Second", + }; + const requests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/api/v1/organizations/")) requests.push(request.url()); + }); + await mockSession(page, { + ...OWNER_IDENTITY, + memberships: [...OWNER_IDENTITY.memberships, secondMembership], + }); + await mockData(page); + + await page.goto(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/`); + + await expect(page.getByRole("heading", { name: "Командный центр" })).toBeVisible(); + await expect.poll(() => requests.some((url) => url.includes(SECOND_ORGANIZATION_PUBLIC_ID))).toBe(true); +}); + test("internal UI renders at the minimum supported width of 1024px", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 768 }); - await mockSession(page, OWNER); + await mockSession(page, OWNER_IDENTITY); await mockData(page); await page.goto("/"); @@ -162,7 +212,7 @@ test("internal UI renders at the minimum supported width of 1024px", async ({ pa test("employee stage 3 screens follow the approved baseline", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 940 }); - await mockSession(page, OWNER); + await mockSession(page, OWNER_IDENTITY); await mockData(page); await page.goto("/employees");