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