mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
✨ feat(ai): add category and scoped knowledge APIs
This commit is contained in:
1 parent
2196012875
commit
2e40eff807
13 files changed
+881
-207
No files matched your search
@@ -0,0 +1,14 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
def validation_error_response(error: ValidationError) -> Response:
|
||||
if hasattr(error, "message_dict"):
|
||||
detail = "; ".join(
|
||||
message
|
||||
for messages in error.message_dict.values()
|
||||
for message in messages
|
||||
)
|
||||
else:
|
||||
detail = "; ".join(error.messages)
|
||||
return Response({"detail": detail}, status=400)
|
||||
@@ -0,0 +1,148 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from hub_platform.ai.api_errors import validation_error_response
|
||||
from hub_platform.ai.knowledge_categories import (
|
||||
create_category,
|
||||
delete_category,
|
||||
update_category,
|
||||
)
|
||||
from hub_platform.ai.knowledge_policy import require_category_manage
|
||||
from hub_platform.ai.models import KnowledgeCategory
|
||||
from hub_platform.ai.selectors import category_tree_for_employee
|
||||
from hub_platform.ai.serializers import category_payload
|
||||
from hub_platform.api.permissions import HasCapability, HasEntitlement
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
|
||||
|
||||
def _integer(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError({field: "Integer required"})
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer required"}) from error
|
||||
|
||||
|
||||
def _category(request: Request, category_id: int) -> KnowledgeCategory:
|
||||
return KnowledgeCategory.objects.select_related("parent").get(
|
||||
organization_id=request.tenant_context.organization_id,
|
||||
id=category_id,
|
||||
)
|
||||
|
||||
|
||||
def _parent(
|
||||
request: Request, value: object, *, field_present: bool
|
||||
) -> KnowledgeCategory | None:
|
||||
if not field_present or value is None:
|
||||
return None
|
||||
parent_id = _integer(value, "parentId")
|
||||
try:
|
||||
return _category(request, parent_id)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"parentId": "Parent category not found"}) from error
|
||||
|
||||
|
||||
def _audit(
|
||||
request: Request,
|
||||
action: str,
|
||||
category: KnowledgeCategory,
|
||||
*,
|
||||
object_id: int | None = None,
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
action=f"ai.knowledge_category_{action}",
|
||||
actor=request.user,
|
||||
organization=request.tenant_context.organization,
|
||||
object_type="KnowledgeCategory",
|
||||
object_id=str(object_id if object_id is not None else category.id),
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
def _category_payload(request: Request, category_id: int) -> dict[str, object]:
|
||||
category = next(
|
||||
item
|
||||
for item in category_tree_for_employee(context=request.tenant_context)
|
||||
if item.id == category_id
|
||||
)
|
||||
return category_payload(category)
|
||||
|
||||
|
||||
class KnowledgeCategoryListCreateView(APIView):
|
||||
permission_classes = [HasEntitlement, HasCapability]
|
||||
required_entitlement = "knowledge_base"
|
||||
required_capabilities = {"GET": "ai.view", "POST": "ai.manage"}
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
categories = category_tree_for_employee(context=request.tenant_context)
|
||||
return Response({"items": [category_payload(item) for item in categories]})
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
require_category_manage(context=request.tenant_context)
|
||||
parent = _parent(
|
||||
request,
|
||||
request.data.get("parentId"),
|
||||
field_present="parentId" in request.data,
|
||||
)
|
||||
category = create_category(
|
||||
context=request.tenant_context,
|
||||
name=str(request.data.get("name", "")),
|
||||
parent=parent,
|
||||
sort_order=_integer(request.data.get("sortOrder", 0), "sortOrder"),
|
||||
)
|
||||
except ValidationError as error:
|
||||
return validation_error_response(error)
|
||||
_audit(request, "created", category)
|
||||
return Response(
|
||||
{"category": _category_payload(request, category.id)}, status=201
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeCategoryDetailView(APIView):
|
||||
permission_classes = [HasEntitlement, HasCapability]
|
||||
required_entitlement = "knowledge_base"
|
||||
required_capability = "ai.manage"
|
||||
|
||||
def patch(self, request: Request, category_id: int) -> Response:
|
||||
try:
|
||||
require_category_manage(context=request.tenant_context)
|
||||
category = _category(request, category_id)
|
||||
except KnowledgeCategory.DoesNotExist:
|
||||
return Response({"detail": "Category not found"}, status=404)
|
||||
try:
|
||||
parent = (
|
||||
_parent(request, request.data.get("parentId"), field_present=True)
|
||||
if "parentId" in request.data
|
||||
else category.parent
|
||||
)
|
||||
updated = update_category(
|
||||
context=request.tenant_context,
|
||||
category=category,
|
||||
name=str(request.data.get("name", category.name)),
|
||||
parent=parent,
|
||||
sort_order=_integer(
|
||||
request.data.get("sortOrder", category.sort_order), "sortOrder"
|
||||
),
|
||||
)
|
||||
except ValidationError as error:
|
||||
return validation_error_response(error)
|
||||
_audit(request, "updated", updated)
|
||||
return Response({"category": _category_payload(request, updated.id)})
|
||||
|
||||
def delete(self, request: Request, category_id: int) -> Response:
|
||||
try:
|
||||
require_category_manage(context=request.tenant_context)
|
||||
category = _category(request, category_id)
|
||||
except KnowledgeCategory.DoesNotExist:
|
||||
return Response({"detail": "Category not found"}, status=404)
|
||||
try:
|
||||
deleted_id = category.id
|
||||
delete_category(context=request.tenant_context, category=category)
|
||||
except ValidationError as error:
|
||||
return validation_error_response(error)
|
||||
_audit(request, "deleted", category, object_id=deleted_id)
|
||||
return Response(status=204)
|
||||
@@ -0,0 +1,116 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.knowledge_services import KnowledgeInput
|
||||
from hub_platform.ai.models import Knowledge
|
||||
from hub_platform.ai.selectors import KnowledgeFilters
|
||||
|
||||
|
||||
def _positive_id(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer id required"}) from error
|
||||
if parsed <= 0:
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
return parsed
|
||||
|
||||
|
||||
def _optional_query_id(value: str | None, field: str) -> int | None:
|
||||
return None if value in (None, "") else _positive_id(value, field)
|
||||
|
||||
|
||||
def _category_id(body: dict[str, object], current: Knowledge | None) -> int | None:
|
||||
if "categoryId" in body:
|
||||
return _positive_id(body["categoryId"], "category")
|
||||
if "category" not in body:
|
||||
return current.category_id if current else None
|
||||
raw = body["category"]
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("id")
|
||||
return _positive_id(raw, "category")
|
||||
|
||||
|
||||
def _department_ids(body: dict[str, object], current: Knowledge | None) -> tuple[int, ...]:
|
||||
if "departmentIds" in body:
|
||||
raw_items = body["departmentIds"]
|
||||
elif "departments" in body:
|
||||
raw_items = body["departments"]
|
||||
elif current is not None:
|
||||
return tuple(
|
||||
current.department_links.values_list("department_id", flat=True)
|
||||
)
|
||||
else:
|
||||
return ()
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValidationError({"departments": "List of department ids required"})
|
||||
normalized: list[int] = []
|
||||
for item in raw_items:
|
||||
raw_id = item.get("id") if isinstance(item, dict) else item
|
||||
department_id = _positive_id(raw_id, "departments")
|
||||
if department_id not in normalized:
|
||||
normalized.append(department_id)
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def knowledge_input(
|
||||
body: dict[str, object], *, current: Knowledge | None = None
|
||||
) -> KnowledgeInput:
|
||||
raw_enabled = body.get("isEnabled", current.is_enabled if current else True)
|
||||
if not isinstance(raw_enabled, bool):
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
visibility = str(
|
||||
body.get(
|
||||
"visibility",
|
||||
current.visibility if current else KnowledgeVisibility.ORGANIZATION,
|
||||
)
|
||||
)
|
||||
if visibility not in KnowledgeVisibility.values:
|
||||
raise ValidationError({"visibility": "Unknown knowledge visibility"})
|
||||
department_ids = _department_ids(body, current)
|
||||
if (
|
||||
visibility == KnowledgeVisibility.ORGANIZATION
|
||||
and "departmentIds" not in body
|
||||
and "departments" not in body
|
||||
):
|
||||
department_ids = ()
|
||||
return KnowledgeInput(
|
||||
title=str(body.get("title", current.title if current else "")),
|
||||
description=str(
|
||||
body.get("description", current.description if current else "")
|
||||
),
|
||||
content=str(body.get("content", current.content if current else "")),
|
||||
is_enabled=raw_enabled,
|
||||
category_id=_category_id(body, current),
|
||||
visibility=visibility,
|
||||
department_ids=department_ids,
|
||||
)
|
||||
|
||||
|
||||
def knowledge_filters(request: Request) -> KnowledgeFilters:
|
||||
visibility = request.query_params.get("visibility") or None
|
||||
if visibility is not None and visibility not in KnowledgeVisibility.values:
|
||||
raise ValidationError({"visibility": "Unknown knowledge visibility"})
|
||||
raw_enabled = request.query_params.get("isEnabled")
|
||||
if raw_enabled in (None, ""):
|
||||
is_enabled = None
|
||||
elif raw_enabled.lower() == "true":
|
||||
is_enabled = True
|
||||
elif raw_enabled.lower() == "false":
|
||||
is_enabled = False
|
||||
else:
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
return KnowledgeFilters(
|
||||
category_id=_optional_query_id(
|
||||
request.query_params.get("category"), "category"
|
||||
),
|
||||
department_id=_optional_query_id(
|
||||
request.query_params.get("department"), "department"
|
||||
),
|
||||
visibility=visibility,
|
||||
is_enabled=is_enabled,
|
||||
search=request.query_params.get("search", ""),
|
||||
)
|
||||
@@ -92,6 +92,33 @@ def move_category(
|
||||
return locked
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_category(
|
||||
*,
|
||||
context: TenantContext,
|
||||
category: KnowledgeCategory,
|
||||
name: str,
|
||||
parent: KnowledgeCategory | None,
|
||||
sort_order: int,
|
||||
) -> KnowledgeCategory:
|
||||
_validate_category_context(context=context, category=category)
|
||||
_validate_parent(context=context, parent=parent)
|
||||
if category.is_system:
|
||||
raise ValidationError({"category": "System category is immutable"})
|
||||
locked_categories = {
|
||||
item.pk: item
|
||||
for item in KnowledgeCategory.objects.select_for_update().filter(
|
||||
organization_id=context.organization_id
|
||||
)
|
||||
}
|
||||
locked = locked_categories[category.pk]
|
||||
locked.name = name
|
||||
locked.parent = locked_categories[parent.pk] if parent is not None else None
|
||||
locked.sort_order = sort_order
|
||||
locked.save(update_fields=["name", "parent", "sort_order"])
|
||||
return locked
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_category(*, context: TenantContext, category: KnowledgeCategory) -> None:
|
||||
_validate_category_context(context=context, category=category)
|
||||
|
||||
@@ -143,6 +143,11 @@ def require_knowledge_create(
|
||||
raise PermissionDenied("Knowledge scope is not manageable")
|
||||
|
||||
|
||||
def require_category_manage(*, context: TenantContext) -> None:
|
||||
if not employee_can_manage_categories(context=context):
|
||||
raise PermissionDenied("Organization-scoped ai.manage is required")
|
||||
|
||||
|
||||
def knowledge_is_available_to_agent(
|
||||
*, knowledge: Knowledge, agent: AIAgent
|
||||
) -> bool:
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
|
||||
from hub_platform.ai.extraction import extract_text
|
||||
from hub_platform.ai.indexing import reindex_knowledge
|
||||
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.knowledge_visibility import replace_knowledge_visibility
|
||||
from hub_platform.ai.models import (
|
||||
Knowledge,
|
||||
KnowledgeAttachment,
|
||||
KnowledgeCategory,
|
||||
)
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
from hub_platform.tenancy.storage import adjust_storage_usage
|
||||
from hub_platform.tenancy.storage_quota import (
|
||||
finalize_storage,
|
||||
release_storage,
|
||||
reserve_storage,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeInput:
|
||||
title: str
|
||||
description: str
|
||||
content: str
|
||||
is_enabled: bool
|
||||
category_id: int | None = None
|
||||
visibility: str | None = None
|
||||
department_ids: tuple[int, ...] | None = None
|
||||
|
||||
|
||||
def _knowledge_category(
|
||||
*, context: TenantContext, category_id: int | None
|
||||
) -> KnowledgeCategory:
|
||||
if category_id is None:
|
||||
return ensure_uncategorized_category(context.organization)
|
||||
try:
|
||||
return KnowledgeCategory.objects.get(
|
||||
organization_id=context.organization_id,
|
||||
id=category_id,
|
||||
)
|
||||
except KnowledgeCategory.DoesNotExist as error:
|
||||
raise ValidationError({"category": "Category not found"}) from error
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
|
||||
if not data.title.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
visibility = data.visibility or KnowledgeVisibility.ORGANIZATION
|
||||
department_ids = data.department_ids or ()
|
||||
knowledge = Knowledge.objects.create(
|
||||
organization=context.organization,
|
||||
category=_knowledge_category(context=context, category_id=data.category_id),
|
||||
title=data.title.strip(),
|
||||
description=data.description.strip(),
|
||||
content=data.content,
|
||||
is_enabled=data.is_enabled,
|
||||
visibility=visibility,
|
||||
)
|
||||
knowledge = replace_knowledge_visibility(
|
||||
context=context,
|
||||
knowledge=knowledge,
|
||||
visibility=visibility,
|
||||
department_ids=department_ids,
|
||||
)
|
||||
reindex_knowledge(knowledge)
|
||||
return knowledge
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
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"})
|
||||
locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk)
|
||||
content_changed = locked.content != data.content
|
||||
locked.title = data.title.strip()
|
||||
locked.description = data.description.strip()
|
||||
locked.content = data.content
|
||||
locked.is_enabled = data.is_enabled
|
||||
if data.category_id is not None:
|
||||
locked.category = _knowledge_category(
|
||||
context=context,
|
||||
category_id=data.category_id,
|
||||
)
|
||||
locked.save(
|
||||
update_fields=[
|
||||
"title",
|
||||
"description",
|
||||
"content",
|
||||
"is_enabled",
|
||||
"category",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
if data.visibility is not None or data.department_ids is not None:
|
||||
locked = replace_knowledge_visibility(
|
||||
context=context,
|
||||
knowledge=locked,
|
||||
visibility=data.visibility or locked.visibility,
|
||||
department_ids=(
|
||||
data.department_ids
|
||||
if data.department_ids is not None
|
||||
else tuple(
|
||||
locked.department_links.values_list("department_id", flat=True)
|
||||
)
|
||||
),
|
||||
)
|
||||
if content_changed:
|
||||
reindex_knowledge(locked)
|
||||
return locked
|
||||
|
||||
|
||||
def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
attachments = list(knowledge.attachments.all())
|
||||
released_bytes = sum(attachment.size for attachment in attachments)
|
||||
for attachment in attachments:
|
||||
attachment.file.delete(save=False)
|
||||
knowledge.delete()
|
||||
if released_bytes:
|
||||
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
|
||||
|
||||
|
||||
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
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"})
|
||||
if upload.size and upload.size > _MAX_ATTACHMENT_BYTES:
|
||||
raise ValidationError({"file": "File is too large (max 25 MB)"})
|
||||
existing = knowledge.attachments.filter(original_name=original_name).first()
|
||||
existing_size = existing.size if existing is not None else 0
|
||||
data = upload.read()
|
||||
reservation_key = f"attachment:{knowledge.id}:{original_name}"
|
||||
reserve_storage(
|
||||
context=context, expected_bytes=len(data), idempotency_key=reservation_key
|
||||
)
|
||||
try:
|
||||
if existing is not None:
|
||||
existing.file.delete(save=False)
|
||||
existing.delete()
|
||||
if existing_size:
|
||||
adjust_storage_usage(context=context, delta_bytes=-existing_size)
|
||||
content_type = upload.content_type or ""
|
||||
attachment = KnowledgeAttachment(
|
||||
organization=context.organization,
|
||||
knowledge=knowledge,
|
||||
original_name=original_name,
|
||||
content_type=content_type,
|
||||
size=len(data),
|
||||
extracted_text=extract_text(
|
||||
filename=original_name, content_type=content_type, data=data
|
||||
),
|
||||
)
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
attachment.file.save(original_name, ContentFile(data), save=True)
|
||||
except Exception:
|
||||
release_storage(context=context, idempotency_key=reservation_key)
|
||||
raise
|
||||
finalize_storage(
|
||||
context=context, idempotency_key=reservation_key, actual_bytes=len(data)
|
||||
)
|
||||
reindex_knowledge(knowledge)
|
||||
return attachment
|
||||
|
||||
|
||||
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
|
||||
released_bytes = attachment.size
|
||||
attachment.file.delete(save=False)
|
||||
attachment.delete()
|
||||
if released_bytes:
|
||||
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
|
||||
reindex_knowledge(knowledge)
|
||||
@@ -1,11 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Count, Q, QuerySet
|
||||
from django.db.models import Count, Prefetch, Q, QuerySet
|
||||
|
||||
from hub_platform.ai.knowledge_policy import readable_knowledge, writable_knowledge
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeCategory
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.models import Department
|
||||
from hub_platform.identity.policy import accessible_department_ids
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
@@ -67,7 +68,13 @@ def _knowledge_base(context: TenantContext) -> QuerySet[Knowledge]:
|
||||
def _with_knowledge_relations(queryset: QuerySet[Knowledge]) -> QuerySet[Knowledge]:
|
||||
return (
|
||||
queryset.select_related("category")
|
||||
.prefetch_related("attachments", "departments")
|
||||
.prefetch_related(
|
||||
"attachments",
|
||||
Prefetch(
|
||||
"departments",
|
||||
queryset=Department.objects.order_by("name", "id"),
|
||||
),
|
||||
)
|
||||
.annotate(agents_count=Count("agents", distinct=True))
|
||||
.order_by("title")
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
|
||||
from hub_platform.ai.models import (
|
||||
AIAgent,
|
||||
Knowledge,
|
||||
KnowledgeAttachment,
|
||||
KnowledgeCategory,
|
||||
)
|
||||
from hub_platform.identity.models import Department
|
||||
|
||||
|
||||
def _channel_ref(channel) -> dict[str, object]:
|
||||
@@ -23,11 +29,42 @@ def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def category_ref_payload(category: KnowledgeCategory) -> dict[str, object]:
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"parentId": category.parent_id,
|
||||
}
|
||||
|
||||
|
||||
def category_payload(category: KnowledgeCategory) -> dict[str, object]:
|
||||
return {
|
||||
**category_ref_payload(category),
|
||||
"sortOrder": category.sort_order,
|
||||
"isSystem": category.is_system,
|
||||
"knowledgeCount": getattr(category, "knowledge_count", None),
|
||||
}
|
||||
|
||||
|
||||
def department_ref_payload(department: Department) -> dict[str, object]:
|
||||
return {
|
||||
"id": department.id,
|
||||
"code": department.code,
|
||||
"name": department.name,
|
||||
}
|
||||
|
||||
|
||||
def knowledge_payload(knowledge: Knowledge, *, include_content: bool = True) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"id": knowledge.id,
|
||||
"title": knowledge.title,
|
||||
"description": knowledge.description,
|
||||
"category": category_ref_payload(knowledge.category),
|
||||
"visibility": knowledge.visibility,
|
||||
"departments": [
|
||||
department_ref_payload(department)
|
||||
for department in knowledge.departments.all()
|
||||
],
|
||||
"isEnabled": knowledge.is_enabled,
|
||||
"attachments": [attachment_payload(attachment) for attachment in knowledge.attachments.all()],
|
||||
"agentsCount": getattr(knowledge, "agents_count", None),
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
|
||||
from hub_platform.ai.extraction import extract_text
|
||||
from hub_platform.ai.indexing import reindex_knowledge
|
||||
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from hub_platform.ai.models import (
|
||||
AIAgent,
|
||||
AIAgentStatus,
|
||||
Knowledge,
|
||||
KnowledgeAttachment,
|
||||
)
|
||||
from hub_platform.ai.provider_selection import configure_agent_provider
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
from hub_platform.tenancy.storage import adjust_storage_usage
|
||||
from hub_platform.tenancy.storage_quota import (
|
||||
finalize_storage,
|
||||
release_storage,
|
||||
reserve_storage,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -153,126 +142,11 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
|
||||
)
|
||||
|
||||
|
||||
# --- Знания (ADR-HUB-0023) ---
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnowledgeInput:
|
||||
title: str
|
||||
description: str
|
||||
content: str
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
|
||||
if not data.title.strip():
|
||||
raise ValidationError({"title": "Title is required"})
|
||||
knowledge = Knowledge.objects.create(
|
||||
organization=context.organization,
|
||||
category=ensure_uncategorized_category(context.organization),
|
||||
title=data.title.strip(),
|
||||
description=data.description.strip(),
|
||||
content=data.content,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
reindex_knowledge(knowledge)
|
||||
return 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
|
||||
knowledge.title = data.title.strip()
|
||||
knowledge.description = data.description.strip()
|
||||
knowledge.content = data.content
|
||||
knowledge.is_enabled = data.is_enabled
|
||||
knowledge.save(update_fields=["title", "description", "content", "is_enabled", "updated_at"])
|
||||
if content_changed:
|
||||
reindex_knowledge(knowledge)
|
||||
return knowledge
|
||||
|
||||
|
||||
def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None:
|
||||
if knowledge.organization_id != context.organization_id:
|
||||
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
|
||||
# Файлы вложений удаляются вместе со знанием: сначала с диска, потом запись.
|
||||
attachments = list(knowledge.attachments.all())
|
||||
released_bytes = sum(attachment.size for attachment in attachments)
|
||||
for attachment in attachments:
|
||||
attachment.file.delete(save=False)
|
||||
knowledge.delete()
|
||||
if released_bytes:
|
||||
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
|
||||
|
||||
|
||||
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
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"})
|
||||
if upload.size and upload.size > _MAX_ATTACHMENT_BYTES:
|
||||
raise ValidationError({"file": "File is too large (max 25 MB)"})
|
||||
# Повторная загрузка с тем же именем заменяет файл (ADR-HUB-0023: без версий).
|
||||
existing = knowledge.attachments.filter(original_name=original_name).first()
|
||||
existing_size = existing.size if existing is not None else 0
|
||||
data = upload.read()
|
||||
# C07 storage gate (SPEC §10): reserve the expected size before the write so a
|
||||
# concurrent upload cannot exceed storage_bytes; finalize with the actual size
|
||||
# after the object is persisted, or release on failure. Reads are never gated.
|
||||
# Deterministic key makes a retried upload of the same file idempotent; a prior
|
||||
# finalized/released reservation is ignored by the active-reservation filter.
|
||||
reservation_key = f"attachment:{knowledge.id}:{original_name}"
|
||||
reserve_storage(
|
||||
context=context, expected_bytes=len(data), idempotency_key=reservation_key
|
||||
)
|
||||
try:
|
||||
if existing is not None:
|
||||
existing.file.delete(save=False)
|
||||
existing.delete()
|
||||
if existing_size:
|
||||
adjust_storage_usage(context=context, delta_bytes=-existing_size)
|
||||
content_type = upload.content_type or ""
|
||||
attachment = KnowledgeAttachment(
|
||||
organization=context.organization,
|
||||
knowledge=knowledge,
|
||||
original_name=original_name,
|
||||
content_type=content_type,
|
||||
size=len(data),
|
||||
extracted_text=extract_text(
|
||||
filename=original_name, content_type=content_type, data=data
|
||||
),
|
||||
)
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
attachment.file.save(original_name, ContentFile(data), save=True)
|
||||
except Exception:
|
||||
# Upload failed after reservation: release the reserved bytes (SPEC §10.4).
|
||||
release_storage(context=context, idempotency_key=reservation_key)
|
||||
raise
|
||||
finalize_storage(
|
||||
context=context, idempotency_key=reservation_key, actual_bytes=len(data)
|
||||
)
|
||||
reindex_knowledge(knowledge)
|
||||
return attachment
|
||||
|
||||
|
||||
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
|
||||
released_bytes = attachment.size
|
||||
attachment.file.delete(save=False)
|
||||
attachment.delete()
|
||||
if released_bytes:
|
||||
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
|
||||
reindex_knowledge(knowledge)
|
||||
from hub_platform.ai.knowledge_services import ( # noqa: E402, F401
|
||||
KnowledgeInput,
|
||||
add_attachment,
|
||||
create_knowledge,
|
||||
delete_attachment,
|
||||
delete_knowledge,
|
||||
update_knowledge,
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
|
||||
from hub_platform.ai.knowledge_categories import create_category
|
||||
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.models import Knowledge
|
||||
from hub_platform.identity.models import Department, DepartmentStatus
|
||||
from hub_platform.subscriptions.testing import create_test_subscription
|
||||
from hub_platform.testing import TenantAPIClient
|
||||
|
||||
|
||||
class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
create_test_subscription(self.organization)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.organization_manager.user)
|
||||
self.guides = create_category(
|
||||
context=self.system_context,
|
||||
parent=self.products,
|
||||
name="Guides",
|
||||
)
|
||||
self.disabled_department = Department.objects.create(
|
||||
organization=self.organization,
|
||||
code="disabled-api",
|
||||
name="Disabled",
|
||||
status=DepartmentStatus.DISABLED,
|
||||
)
|
||||
|
||||
def _post(self, payload: dict[str, object]):
|
||||
return self.client.post(
|
||||
"/api/v1/ai/knowledge/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_create_and_detail_return_complete_scope_payload(self) -> None:
|
||||
response = self._post(
|
||||
{
|
||||
"title": "Scoped guide",
|
||||
"description": "For two teams",
|
||||
"content": "Guide content",
|
||||
"categoryId": self.guides.id,
|
||||
"visibility": KnowledgeVisibility.DEPARTMENTS,
|
||||
"departmentIds": [self.support.id, self.sales.id],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()["knowledge"]
|
||||
self.assertEqual(
|
||||
payload["category"],
|
||||
{"id": self.guides.id, "name": "Guides", "parentId": self.products.id},
|
||||
)
|
||||
self.assertEqual(payload["visibility"], KnowledgeVisibility.DEPARTMENTS)
|
||||
self.assertEqual(
|
||||
[department["id"] for department in payload["departments"]],
|
||||
[self.sales.id, self.support.id],
|
||||
)
|
||||
detail = self.client.get(f"/api/v1/ai/knowledge/{payload['id']}/")
|
||||
self.assertEqual(detail.json()["knowledge"]["content"], "Guide content")
|
||||
|
||||
def test_update_scope_category_and_metadata_is_atomic(self) -> None:
|
||||
knowledge = self.sales_only
|
||||
response = self.client.patch(
|
||||
f"/api/v1/ai/knowledge/{knowledge.id}/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"title": "Moved playbook",
|
||||
"category": {"id": self.guides.id},
|
||||
"visibility": KnowledgeVisibility.DEPARTMENTS,
|
||||
"departments": [{"id": self.sales.id}, {"id": self.support.id}],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["knowledge"]
|
||||
self.assertEqual(payload["category"]["id"], self.guides.id)
|
||||
self.assertEqual(
|
||||
{item["id"] for item in payload["departments"]},
|
||||
{self.sales.id, self.support.id},
|
||||
)
|
||||
|
||||
invalid = self.client.patch(
|
||||
f"/api/v1/ai/knowledge/{knowledge.id}/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"title": "Must roll back",
|
||||
"departmentIds": [self.disabled_department.id],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
knowledge.refresh_from_db()
|
||||
self.assertEqual(knowledge.title, "Moved playbook")
|
||||
self.assertEqual(
|
||||
set(knowledge.departments.values_list("id", flat=True)),
|
||||
{self.sales.id, self.support.id},
|
||||
)
|
||||
|
||||
organization_scope = self.client.patch(
|
||||
f"/api/v1/ai/knowledge/{knowledge.id}/",
|
||||
data=json.dumps({"visibility": KnowledgeVisibility.ORGANIZATION}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(organization_scope.status_code, 200)
|
||||
self.assertEqual(
|
||||
organization_scope.json()["knowledge"]["departments"], []
|
||||
)
|
||||
|
||||
def test_department_manager_can_create_own_scope_but_cannot_expand_it(self) -> None:
|
||||
self.client.force_authenticate(self.sales_employee.user)
|
||||
created = self._post(
|
||||
{
|
||||
"title": "Sales only API",
|
||||
"categoryId": self.guides.id,
|
||||
"visibility": KnowledgeVisibility.DEPARTMENTS,
|
||||
"departmentIds": [self.sales.id],
|
||||
}
|
||||
)
|
||||
self.assertEqual(created.status_code, 201)
|
||||
knowledge_id = created.json()["knowledge"]["id"]
|
||||
|
||||
denied = self.client.patch(
|
||||
f"/api/v1/ai/knowledge/{knowledge_id}/",
|
||||
data=json.dumps({"departmentIds": [self.sales.id, self.support.id]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
self.assertEqual(
|
||||
set(
|
||||
Knowledge.objects.get(id=knowledge_id).departments.values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
),
|
||||
{self.sales.id},
|
||||
)
|
||||
|
||||
def test_list_filters_category_department_visibility_and_status(self) -> None:
|
||||
created = self._post(
|
||||
{
|
||||
"title": "Disabled filtered guide",
|
||||
"description": "Unique filter phrase",
|
||||
"categoryId": self.guides.id,
|
||||
"visibility": KnowledgeVisibility.DEPARTMENTS,
|
||||
"departmentIds": [self.sales.id],
|
||||
"isEnabled": False,
|
||||
}
|
||||
).json()["knowledge"]
|
||||
response = self.client.get(
|
||||
"/api/v1/ai/knowledge/",
|
||||
{
|
||||
"category": self.guides.id,
|
||||
"department": self.sales.id,
|
||||
"visibility": KnowledgeVisibility.DEPARTMENTS,
|
||||
"isEnabled": "false",
|
||||
"search": "unique filter",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
[item["id"] for item in response.json()["items"]], [created["id"]]
|
||||
)
|
||||
|
||||
invalid = self.client.get(
|
||||
"/api/v1/ai/knowledge/", {"visibility": "PUBLIC"}
|
||||
)
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
|
||||
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
|
||||
from hub_platform.ai.models import KnowledgeCategory
|
||||
from hub_platform.subscriptions.testing import create_test_subscription
|
||||
from hub_platform.testing import TenantAPIClient
|
||||
|
||||
|
||||
class KnowledgeCategoryApiTests(KnowledgePolicyTestBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
create_test_subscription(self.organization)
|
||||
self.client = TenantAPIClient()
|
||||
self.client.force_authenticate(self.organization_manager.user)
|
||||
|
||||
def _create(
|
||||
self, name: str, *, parent_id: int | None = None, sort_order: int = 0
|
||||
):
|
||||
payload = {"name": name, "sortOrder": sort_order}
|
||||
if parent_id is not None:
|
||||
payload["parentId"] = parent_id
|
||||
return self.client.post(
|
||||
"/api/v1/ai/knowledge/categories/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_tree_is_preordered_and_counts_only_authorized_knowledge(self) -> None:
|
||||
child = self._create("Child", parent_id=self.products.id).json()["category"]
|
||||
|
||||
response = self.client.get("/api/v1/ai/knowledge/categories/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
items = response.json()["items"]
|
||||
ids = [item["id"] for item in items]
|
||||
self.assertLess(ids.index(self.products.id), ids.index(child["id"]))
|
||||
products = next(item for item in items if item["id"] == self.products.id)
|
||||
self.assertEqual(products["knowledgeCount"], 5)
|
||||
|
||||
self.client.force_authenticate(self.sales_employee.user)
|
||||
restricted = self.client.get("/api/v1/ai/knowledge/categories/")
|
||||
products = next(
|
||||
item
|
||||
for item in restricted.json()["items"]
|
||||
if item["id"] == self.products.id
|
||||
)
|
||||
self.assertEqual(products["knowledgeCount"], 4)
|
||||
denied = self._create("Forbidden")
|
||||
self.assertEqual(denied.status_code, 403)
|
||||
|
||||
def test_create_update_move_reorder_and_cycle_validation(self) -> None:
|
||||
root = self._create("Root", sort_order=10)
|
||||
self.assertEqual(root.status_code, 201)
|
||||
root_id = root.json()["category"]["id"]
|
||||
child_id = self._create("Child", parent_id=root_id).json()["category"]["id"]
|
||||
|
||||
updated = self.client.patch(
|
||||
f"/api/v1/ai/knowledge/categories/{child_id}/",
|
||||
data=json.dumps(
|
||||
{"name": "Renamed", "parentId": None, "sortOrder": 25}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["category"]["name"], "Renamed")
|
||||
self.assertIsNone(updated.json()["category"]["parentId"])
|
||||
self.assertEqual(updated.json()["category"]["sortOrder"], 25)
|
||||
|
||||
self.client.patch(
|
||||
f"/api/v1/ai/knowledge/categories/{child_id}/",
|
||||
data=json.dumps({"parentId": root_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
cycle = self.client.patch(
|
||||
f"/api/v1/ai/knowledge/categories/{root_id}/",
|
||||
data=json.dumps({"parentId": child_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(cycle.status_code, 400)
|
||||
self.assertIsNone(KnowledgeCategory.objects.get(id=root_id).parent_id)
|
||||
|
||||
def test_delete_is_limited_to_empty_non_system_leaf(self) -> None:
|
||||
empty_id = self._create("Empty").json()["category"]["id"]
|
||||
deleted = self.client.delete(
|
||||
f"/api/v1/ai/knowledge/categories/{empty_id}/"
|
||||
)
|
||||
self.assertEqual(deleted.status_code, 204)
|
||||
|
||||
nonempty = self.client.delete(
|
||||
f"/api/v1/ai/knowledge/categories/{self.products.id}/"
|
||||
)
|
||||
self.assertEqual(nonempty.status_code, 400)
|
||||
system = KnowledgeCategory.objects.get(
|
||||
organization=self.organization, is_system=True
|
||||
)
|
||||
protected = self.client.delete(
|
||||
f"/api/v1/ai/knowledge/categories/{system.id}/"
|
||||
)
|
||||
self.assertEqual(protected.status_code, 400)
|
||||
|
||||
other_parent = KnowledgeCategory.objects.get(
|
||||
organization=self.other_organization, is_system=True
|
||||
)
|
||||
cross_tenant = self.client.post(
|
||||
"/api/v1/ai/knowledge/categories/",
|
||||
data=json.dumps({"name": "Invalid", "parentId": other_parent.id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(cross_tenant.status_code, 400)
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from hub_platform.ai import agent_views, views
|
||||
from hub_platform.ai import agent_views, category_views, views
|
||||
|
||||
urlpatterns = [
|
||||
path("agents/", agent_views.AIAgentListView.as_view(), name="ai-agent-list"),
|
||||
@@ -9,6 +9,16 @@ urlpatterns = [
|
||||
path("agents/<int:agent_id>/activate/", agent_views.AIAgentActivateView.as_view(), name="ai-agent-activate"),
|
||||
path("agents/<int:agent_id>/deactivate/", agent_views.AIAgentDeactivateView.as_view(), name="ai-agent-deactivate"),
|
||||
path("knowledge/", views.KnowledgeListCreateView.as_view(), name="ai-knowledge-list"),
|
||||
path(
|
||||
"knowledge/categories/",
|
||||
category_views.KnowledgeCategoryListCreateView.as_view(),
|
||||
name="ai-knowledge-category-list",
|
||||
),
|
||||
path(
|
||||
"knowledge/categories/<int:category_id>/",
|
||||
category_views.KnowledgeCategoryDetailView.as_view(),
|
||||
name="ai-knowledge-category-detail",
|
||||
),
|
||||
path("knowledge/import/", views.KnowledgeImportView.as_view(), name="ai-knowledge-import"),
|
||||
path("knowledge/<int:knowledge_id>/", views.KnowledgeDetailView.as_view(), name="ai-knowledge-detail"),
|
||||
path("knowledge/<int:knowledge_id>/attachments/", views.KnowledgeAttachmentUploadView.as_view(), name="ai-knowledge-attachment-upload"),
|
||||
|
||||
@@ -4,19 +4,13 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from hub_platform.ai.models import Knowledge
|
||||
from hub_platform.ai.knowledge_policy import require_knowledge_create
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.selectors import (
|
||||
KnowledgeFilters,
|
||||
apply_knowledge_filters,
|
||||
knowledge_for_context,
|
||||
knowledge_item_for_context,
|
||||
writable_knowledge_for_employee,
|
||||
writable_knowledge_item_for_employee,
|
||||
from hub_platform.ai.api_errors import validation_error_response
|
||||
from hub_platform.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
|
||||
from hub_platform.ai.knowledge_policy import (
|
||||
employee_can_write_knowledge,
|
||||
require_knowledge_create,
|
||||
)
|
||||
from hub_platform.ai.serializers import attachment_payload, knowledge_payload
|
||||
from hub_platform.ai.services import (
|
||||
from hub_platform.ai.knowledge_services import (
|
||||
KnowledgeInput,
|
||||
add_attachment,
|
||||
create_knowledge,
|
||||
@@ -24,62 +18,21 @@ from hub_platform.ai.services import (
|
||||
delete_knowledge,
|
||||
update_knowledge,
|
||||
)
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.models import Knowledge
|
||||
from hub_platform.ai.selectors import (
|
||||
apply_knowledge_filters,
|
||||
knowledge_for_context,
|
||||
knowledge_item_for_context,
|
||||
writable_knowledge_for_employee,
|
||||
writable_knowledge_item_for_employee,
|
||||
)
|
||||
from hub_platform.ai.serializers import attachment_payload, knowledge_payload
|
||||
from hub_platform.api.permissions import HasCapability, HasEntitlement
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
|
||||
|
||||
def _validation_error(error: ValidationError) -> Response:
|
||||
detail = "; ".join(message for messages in error.message_dict.values() for message in messages) if hasattr(error, "message_dict") else "; ".join(error.messages)
|
||||
return Response({"detail": detail}, status=400)
|
||||
|
||||
|
||||
# --- Знания (ADR-HUB-0023) ---
|
||||
|
||||
|
||||
def _knowledge_input(body: dict[str, object], *, current: Knowledge | None = None) -> KnowledgeInput:
|
||||
is_enabled = body.get("isEnabled", current.is_enabled if current else True)
|
||||
return KnowledgeInput(
|
||||
title=str(body.get("title", current.title if current else "")),
|
||||
description=str(body.get("description", current.description if current else "")),
|
||||
content=str(body.get("content", current.content if current else "")),
|
||||
is_enabled=bool(is_enabled),
|
||||
)
|
||||
|
||||
|
||||
def _optional_id(value: str | None, field: str) -> int | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValidationError({field: "Integer id required"}) from error
|
||||
if parsed <= 0:
|
||||
raise ValidationError({field: "Positive integer id required"})
|
||||
return parsed
|
||||
|
||||
|
||||
def _knowledge_filters(request: Request) -> KnowledgeFilters:
|
||||
visibility = request.query_params.get("visibility") or None
|
||||
if visibility is not None and visibility not in KnowledgeVisibility.values:
|
||||
raise ValidationError({"visibility": "Unknown knowledge visibility"})
|
||||
raw_enabled = request.query_params.get("isEnabled")
|
||||
if raw_enabled in (None, ""):
|
||||
is_enabled = None
|
||||
elif raw_enabled.lower() == "true":
|
||||
is_enabled = True
|
||||
elif raw_enabled.lower() == "false":
|
||||
is_enabled = False
|
||||
else:
|
||||
raise ValidationError({"isEnabled": "Boolean required"})
|
||||
return KnowledgeFilters(
|
||||
category_id=_optional_id(request.query_params.get("category"), "category"),
|
||||
department_id=_optional_id(
|
||||
request.query_params.get("department"), "department"
|
||||
),
|
||||
visibility=visibility,
|
||||
is_enabled=is_enabled,
|
||||
search=request.query_params.get("search", ""),
|
||||
)
|
||||
_validation_error = validation_error_response
|
||||
|
||||
|
||||
class _KnowledgeBaseView(APIView):
|
||||
@@ -118,7 +71,7 @@ class _KnowledgeBaseView(APIView):
|
||||
class KnowledgeListCreateView(_KnowledgeBaseView):
|
||||
def get(self, request: Request) -> Response:
|
||||
try:
|
||||
filters = _knowledge_filters(request)
|
||||
filters = knowledge_filters(request)
|
||||
except ValidationError as error:
|
||||
return _validation_error(error)
|
||||
items = apply_knowledge_filters(
|
||||
@@ -128,14 +81,15 @@ class KnowledgeListCreateView(_KnowledgeBaseView):
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
data = knowledge_input(request.data)
|
||||
require_knowledge_create(
|
||||
context=request.tenant_context,
|
||||
visibility=KnowledgeVisibility.ORGANIZATION,
|
||||
department_ids=[],
|
||||
visibility=data.visibility or KnowledgeVisibility.ORGANIZATION,
|
||||
department_ids=data.department_ids or (),
|
||||
)
|
||||
knowledge = create_knowledge(
|
||||
context=request.tenant_context,
|
||||
data=_knowledge_input(request.data),
|
||||
data=data,
|
||||
)
|
||||
except ValidationError as error:
|
||||
return _validation_error(error)
|
||||
@@ -158,10 +112,18 @@ class KnowledgeDetailView(_KnowledgeBaseView):
|
||||
except Knowledge.DoesNotExist:
|
||||
return Response({"detail": "Knowledge not found"}, status=404)
|
||||
try:
|
||||
data = knowledge_input(request.data, current=knowledge)
|
||||
if not employee_can_write_knowledge(
|
||||
context=request.tenant_context,
|
||||
knowledge=knowledge,
|
||||
visibility=data.visibility,
|
||||
department_ids=data.department_ids,
|
||||
):
|
||||
raise PermissionDenied("Knowledge scope is not manageable")
|
||||
knowledge = update_knowledge(
|
||||
context=request.tenant_context,
|
||||
knowledge=knowledge,
|
||||
data=_knowledge_input(request.data, current=knowledge),
|
||||
data=data,
|
||||
)
|
||||
except ValidationError as error:
|
||||
return _validation_error(error)
|
||||
|
||||
Reference in new issue
Block a user