feat: complete guest module

This commit is contained in:
lizerk 2026-06-22 15:01:28 +03:00
parent dc35680156
commit cd3e52e1e9
34 changed files with 7760 additions and 216 deletions

View File

@ -20,6 +20,7 @@ import reversion
from reversion.models import Version
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -858,6 +859,28 @@ class FolderViewSet(ModelViewSet):
return document_ids
def _ensure_not_guest_managed_folders(self, folder_ids: set[int]):
from guests.models import Guest
if Guest.objects.filter(document_folder_id__in=folder_ids).exists():
raise ValidationError(
{
"detail": (
"Папка привязана к гостю. Изменяйте ее через "
"карточку гостя."
)
}
)
def update(self, request, *args, **kwargs):
folder = self.get_object()
self._ensure_not_guest_managed_folders({folder.id})
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
kwargs["partial"] = True
return self.update(request, *args, **kwargs)
def _safe_archive_part(self, value: object, fallback: str) -> str:
"""Очищает часть ZIP-пути без потери кириллицы."""
raw = str(value or fallback)
@ -988,6 +1011,7 @@ class FolderViewSet(ModelViewSet):
"""Удаляет папку вместе с подпапками и связанными документами."""
folder = self.get_object()
folder_ids = self._collect_descendant_folder_ids(folder)
self._ensure_not_guest_managed_folders(folder_ids)
document_ids = self._collect_document_ids_for_delete(folder_ids)
if document_ids:

View File

@ -1,6 +1,7 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
from guests.constants import EDITABLE_BY_AUTHOR_STATUSES
from guests.constants import GuestVisitStatus
from guests.permissions import (
can_decide_guest_visit,
can_manage_guest_account,
@ -22,7 +23,13 @@ class GuestVisitPermission(BasePermission):
if request.method in SAFE_METHODS:
return obj.inviter_id == user.id
if action in {"submit", "provide_info", "cancel"}:
if action in {
"submit",
"provide_info",
"cancel",
"return_to_work",
"destroy",
}:
return obj.inviter_id == user.id
if action in {"approve", "reject", "request_info", "revoke", "sync_ldap"}:
@ -38,7 +45,14 @@ class GuestVisitPermission(BasePermission):
return obj.inviter_id == user.id
if action in {"attach_document", "remove_document"}:
return obj.inviter_id == user.id
return (
obj.inviter_id == user.id
and obj.status
in {
*EDITABLE_BY_AUTHOR_STATUSES,
GuestVisitStatus.PENDING,
}
)
return False
@ -47,8 +61,12 @@ class GuestAdminPermission(BasePermission):
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if request.method in SAFE_METHODS:
return True
action = getattr(view, "action", "")
if action in {"sync_ldap", "deactivate"}:
if action in {"comments", "delete_comment", "documents"}:
return True
if action in {"sync_ldap", "blacklist", "unblacklist"}:
return can_manage_guest_account(request.user)
return is_guest_admin(request.user)
@ -56,4 +74,3 @@ class GuestAdminPermission(BasePermission):
class GuestDecisionPermission(BasePermission):
def has_permission(self, request, view):
return can_decide_guest_visit(request.user)

View File

@ -1,9 +1,12 @@
from __future__ import annotations
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework import serializers
from api.v1.documents.serializers import DocumentReadSerializer
from communications import comments_helpers
from api.v1.serializers import Base64ImageField
from api.v1.documents.serializers import DocumentReadSerializer, FolderBriefSerializer
from api.v1.employees.serializers import EmployeeBriefSerializer
from documents.models import Document
from guests.constants import GuestVisitStatus
@ -13,13 +16,22 @@ from guests.permissions import (
can_manage_guest_account,
is_guest_admin,
)
from guests.services import GuestVisitWorkflow, normalize_all_day_range
from guests.services import (
GUEST_VISIT_PERIOD_BLOCKING_STATUSES,
GuestVisitWorkflow,
has_unread_info_response_for_user,
normalize_all_day_range,
validate_guest_visit_period_available,
validate_guest_visit_period_not_past,
)
User = get_user_model()
class GuestBriefSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(read_only=True)
avatar = Base64ImageField(read_only=True)
visits_count = serializers.SerializerMethodField()
class Meta:
model = Guest
@ -32,14 +44,27 @@ class GuestBriefSerializer(serializers.ModelSerializer):
"organization",
"email",
"phone",
"ldap_enabled",
"avatar",
"visits_count",
"is_active",
"is_blacklisted",
)
def get_visits_count(self, obj):
annotated_count = getattr(obj, "visits_count", None)
if annotated_count is not None:
return annotated_count
return obj.visits.count()
class GuestSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(read_only=True)
created_by = EmployeeBriefSerializer(read_only=True)
avatar = Base64ImageField(required=False, allow_null=True)
document_folder = FolderBriefSerializer(read_only=True)
documents = DocumentReadSerializer(many=True, read_only=True)
comments_count = serializers.SerializerMethodField()
visits_count = serializers.SerializerMethodField()
class Meta:
model = Guest
@ -52,12 +77,16 @@ class GuestSerializer(serializers.ModelSerializer):
"birth_date",
"phone",
"email",
"avatar",
"organization",
"position",
"comment",
"comments_count",
"visits_count",
"document_folder",
"documents",
"created_by",
"is_active",
"ldap_enabled",
"is_blacklisted",
"ldap_username",
"ldap_upn",
"ldap_last_synced_at",
@ -68,8 +97,13 @@ class GuestSerializer(serializers.ModelSerializer):
read_only_fields = (
"id",
"full_name",
"comments_count",
"visits_count",
"document_folder",
"documents",
"created_by",
"ldap_enabled",
"is_active",
"is_blacklisted",
"ldap_username",
"ldap_upn",
"ldap_last_synced_at",
@ -78,6 +112,18 @@ class GuestSerializer(serializers.ModelSerializer):
"updated_at",
)
def get_comments_count(self, obj):
annotated_count = getattr(obj, "comments_count", None)
if annotated_count is not None:
return annotated_count
return comments_helpers.get_comment_count(obj)
def get_visits_count(self, obj):
annotated_count = getattr(obj, "visits_count", None)
if annotated_count is not None:
return annotated_count
return obj.visits.count()
class GuestVisitEventSerializer(serializers.ModelSerializer):
actor = EmployeeBriefSerializer(read_only=True)
@ -108,11 +154,16 @@ class GuestVisitReadSerializer(serializers.ModelSerializer):
can_edit = serializers.SerializerMethodField()
can_submit = serializers.SerializerMethodField()
can_decide = serializers.SerializerMethodField()
can_approve = serializers.SerializerMethodField()
can_reject = serializers.SerializerMethodField()
can_request_info = serializers.SerializerMethodField()
can_cancel = serializers.SerializerMethodField()
can_revoke = serializers.SerializerMethodField()
can_return_to_work = serializers.SerializerMethodField()
can_sync_ldap = serializers.SerializerMethodField()
can_delete = serializers.SerializerMethodField()
comments_count = serializers.IntegerField(read_only=True, default=0)
has_unread_info_response = serializers.SerializerMethodField()
class Meta:
model = GuestVisit
@ -125,7 +176,6 @@ class GuestVisitReadSerializer(serializers.ModelSerializer):
"host_department",
"purpose",
"visit_comment",
"admin_comment",
"status",
"status_display",
"access_starts_at",
@ -150,11 +200,16 @@ class GuestVisitReadSerializer(serializers.ModelSerializer):
"can_edit",
"can_submit",
"can_decide",
"can_approve",
"can_reject",
"can_request_info",
"can_cancel",
"can_revoke",
"can_return_to_work",
"can_sync_ldap",
"can_delete",
"comments_count",
"has_unread_info_response",
"events",
)
@ -162,6 +217,13 @@ class GuestVisitReadSerializer(serializers.ModelSerializer):
request = self.context.get("request")
return getattr(request, "user", None)
@staticmethod
def _inviter_is_active(obj):
return bool(
obj.inviter.is_active
and getattr(obj.inviter, "is_actually_active", True)
)
def get_can_edit(self, obj):
return obj.can_edit_by(self._user())
@ -176,23 +238,78 @@ class GuestVisitReadSerializer(serializers.ModelSerializer):
def get_can_decide(self, obj):
return can_decide_guest_visit(self._user())
def get_can_approve(self, obj):
return bool(
can_decide_guest_visit(self._user())
and obj.status in {GuestVisitStatus.PENDING, GuestVisitStatus.REJECTED}
and not obj.inviter_inactive
and self._inviter_is_active(obj)
)
def get_can_reject(self, obj):
return bool(
can_decide_guest_visit(self._user())
and obj.status
in {
GuestVisitStatus.PENDING,
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}
)
def get_can_request_info(self, obj):
return can_decide_guest_visit(self._user())
return bool(can_decide_guest_visit(self._user()) and obj.status == GuestVisitStatus.PENDING)
def get_can_cancel(self, obj):
user = self._user()
return bool(user and (is_guest_admin(user) or obj.inviter_id == user.id))
return bool(
user
and (is_guest_admin(user) or obj.inviter_id == user.id)
and obj.status
in {
GuestVisitStatus.DRAFT,
GuestVisitStatus.PENDING,
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}
)
def get_can_revoke(self, obj):
return can_decide_guest_visit(self._user())
return bool(
can_decide_guest_visit(self._user())
and obj.status == GuestVisitStatus.APPROVED
)
def get_can_return_to_work(self, obj):
user = self._user()
return bool(
user
and (is_guest_admin(user) or obj.inviter_id == user.id)
and obj.status
in {
GuestVisitStatus.CANCELLED,
GuestVisitStatus.REVOKED,
GuestVisitStatus.EXPIRED,
}
and not obj.inviter_inactive
and self._inviter_is_active(obj)
)
def get_can_sync_ldap(self, obj):
return can_manage_guest_account(self._user())
def get_can_delete(self, obj):
user = self._user()
return bool(user and (is_guest_admin(user) or obj.inviter_id == user.id))
def get_has_unread_info_response(self, obj):
return has_unread_info_response_for_user(obj, self._user())
class GuestVisitWriteSerializer(serializers.Serializer):
guest_id = serializers.IntegerField(required=False)
guest = GuestSerializer(required=False)
guest_comment = serializers.CharField(required=False, allow_blank=True)
purpose = serializers.CharField(required=False, allow_blank=True)
visit_comment = serializers.CharField(required=False, allow_blank=True)
all_day = serializers.BooleanField(required=False, default=True)
@ -231,13 +348,46 @@ class GuestVisitWriteSerializer(serializers.Serializer):
else:
attrs["access_expires_at"] = None
try:
validate_guest_visit_period_not_past(
expires_at=expires_at,
unlimited=unlimited,
)
except ValueError as exc:
raise serializers.ValidationError(
{"access_expires_at": str(exc)}
) from exc
if not instance and not attrs.get("guest_id") and not attrs.get("guest"):
raise serializers.ValidationError({"guest": "Укажите гостя."})
if not instance:
if not (attrs.get("purpose") or "").strip():
raise serializers.ValidationError({"purpose": "Укажите цель приглашения."})
if not unlimited and not (starts_at and expires_at):
raise serializers.ValidationError(
{"access_starts_at": "Укажите период доступа или бессрочный доступ."}
)
if getattr(settings, "GUESTS_REQUIRE_ID_DOCUMENT", False) and not attrs.get("document_ids"):
raise serializers.ValidationError(
{"document_ids": "Прикрепите документ гостя."}
)
elif instance.status in GUEST_VISIT_PERIOD_BLOCKING_STATUSES:
try:
validate_guest_visit_period_available(
guest=instance.guest,
starts_at=starts_at,
expires_at=expires_at,
unlimited=unlimited,
exclude_pk=instance.pk,
)
except ValueError as exc:
raise serializers.ValidationError(
{"access_starts_at": str(exc)}
) from exc
return attrs
def _get_or_create_guest(self, attrs):
request = self.context["request"]
guest_id = attrs.pop("guest_id", None)
guest_data = attrs.pop("guest", None)
if guest_id:
@ -247,24 +397,46 @@ class GuestVisitWriteSerializer(serializers.Serializer):
raise serializers.ValidationError(
{"guest_id": "Гость не найден."}
) from exc
request = self.context["request"]
serializer = GuestSerializer(data=guest_data)
serializer.is_valid(raise_exception=True)
return serializer.save(created_by=request.user)
def create(self, validated_data):
document_ids = validated_data.pop("document_ids", [])
guest_comment = (validated_data.pop("guest_comment", "") or "").strip()
guest = self._get_or_create_guest(validated_data)
visit = GuestVisitWorkflow.create_visit(
actor=self.context["request"].user,
guest=guest,
**validated_data,
)
try:
validate_guest_visit_period_available(
guest=guest,
starts_at=validated_data.get("access_starts_at"),
expires_at=validated_data.get("access_expires_at"),
unlimited=bool(validated_data.get("unlimited", False)),
)
visit = GuestVisitWorkflow.create_visit(
actor=self.context["request"].user,
guest=guest,
**validated_data,
)
except ValueError as exc:
raise serializers.ValidationError(
{"access_starts_at": str(exc)}
) from exc
if document_ids:
visit.documents.set(Document.objects.filter(id__in=document_ids))
documents = Document.objects.filter(id__in=document_ids)
visit.documents.set(documents)
guest.documents.add(*documents)
if guest_comment:
comments_helpers.create_comment(
guest,
self.context["request"].user,
guest_comment,
)
return visit
def update(self, instance, validated_data):
document_ids = validated_data.pop("document_ids", None)
validated_data.pop("guest_comment", None)
validated_data.pop("guest", None)
validated_data.pop("guest_id", None)
for field in (
@ -279,7 +451,9 @@ class GuestVisitWriteSerializer(serializers.Serializer):
setattr(instance, field, validated_data[field])
instance.save()
if document_ids is not None:
instance.documents.set(Document.objects.filter(id__in=document_ids))
documents = Document.objects.filter(id__in=document_ids)
instance.documents.set(documents)
instance.guest.documents.add(*documents)
return instance

View File

@ -1,16 +1,21 @@
from __future__ import annotations
import os
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from filer.models import File as FilerFile
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from communications import comments_helpers
from communications.models import Message
from communications.models import ChatReadState, Message
from api.v1.documents.serializers import DocumentReadSerializer
from documents.models import Document
from guests.constants import GuestVisitEventType, GuestVisitStatus
from guests.models import Guest, GuestVisit
@ -40,6 +45,7 @@ class GuestVisitViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
queryset = (
@ -119,8 +125,12 @@ class GuestVisitViewSet(
)
if params.get("unlimited") in {"true", "false"}:
qs = qs.filter(unlimited=params.get("unlimited") == "true")
if params.get("ldap_enabled") in {"true", "false"}:
qs = qs.filter(guest__ldap_enabled=params.get("ldap_enabled") == "true")
if params.get("is_active") in {"true", "false"}:
qs = qs.filter(guest__is_active=params.get("is_active") == "true")
if params.get("is_blacklisted") in {"true", "false"}:
qs = qs.filter(
guest__is_blacklisted=params.get("is_blacklisted") == "true"
)
if q := (params.get("q") or "").strip():
qs = qs.filter(
Q(guest__last_name__icontains=q)
@ -171,6 +181,17 @@ class GuestVisitViewSet(
visit = serializer.save()
return self._read_response(visit)
def destroy(self, request, *args, **kwargs):
visit = self.get_object()
guest = visit.guest
visit.delete()
GuestLdapService().sync_guest(
guest,
enqueue_on_error=True,
raise_on_error=False,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"])
def submit(self, request, pk=None):
visit = self.get_object()
@ -228,10 +249,17 @@ class GuestVisitViewSet(
@action(detail=True, methods=["post"], url_path="provide-info")
def provide_info(self, request, pk=None):
visit = self.get_object()
if visit.status != GuestVisitStatus.NEEDS_INFO:
raise ValidationError("Ответить можно только на активный запрос информации.")
comment = (request.data.get("comment") or "").strip()
if not comment:
raise ValidationError("Комментарий обязателен.")
comments_helpers.create_comment(visit, request.user, comment)
comments_helpers.create_comment(
visit,
request.user,
comment,
guest_visit_comment_type="info_response",
)
try:
GuestVisitWorkflow.submit(visit, actor=request.user)
except ValueError as exc:
@ -253,6 +281,21 @@ class GuestVisitViewSet(
raise ValidationError(str(exc)) from exc
return self._read_response(visit)
@action(detail=True, methods=["post"], url_path="return-to-work")
def return_to_work(self, request, pk=None):
visit = self.get_object()
if visit.inviter_id != request.user.id and not is_guest_admin(request.user):
raise PermissionDenied("Недостаточно прав для возврата в работу.")
try:
GuestVisitWorkflow.return_to_work(
visit,
actor=request.user,
comment=request.data.get("comment", ""),
)
except ValueError as exc:
raise ValidationError(str(exc)) from exc
return self._read_response(visit)
@action(detail=True, methods=["post"])
def revoke(self, request, pk=None):
if not can_decide_guest_visit(request.user):
@ -273,20 +316,37 @@ class GuestVisitViewSet(
if not can_manage_guest_account(request.user):
raise PermissionDenied("Недостаточно прав для LDAP.")
visit = self.get_object()
GuestLdapService().sync_guest_for_visit(visit)
try:
GuestLdapService().sync_guest_for_visit(
visit,
enqueue_on_error=True,
raise_on_error=True,
)
except Exception as exc:
raise ValidationError(str(exc)) from exc
return self._read_response(visit)
@action(detail=True, methods=["get", "post"])
def comments(self, request, pk=None):
visit = self.get_object()
if request.method == "GET":
messages = comments_helpers.get_comments(visit)
messages = list(comments_helpers.get_comments(visit))
if messages:
ChatReadState.objects.update_or_create(
chat=messages[-1].chat,
user=request.user,
defaults={
"last_read_message": messages[-1],
"unread_count": 0,
},
)
return Response(
[
{
"id": msg.id,
"author": EmployeeBriefSerializer(msg.author).data,
"text": msg.content,
"metadata": msg.system_metadata,
"created_at": msg.created_at,
}
for msg in messages
@ -298,11 +358,12 @@ class GuestVisitViewSet(
msg = comments_helpers.create_comment(visit, request.user, text)
return Response(
{
"id": msg.id,
"author": EmployeeBriefSerializer(msg.author).data,
"text": msg.content,
"created_at": msg.created_at,
},
"id": msg.id,
"author": EmployeeBriefSerializer(msg.author).data,
"text": msg.content,
"metadata": msg.system_metadata,
"created_at": msg.created_at,
},
status=status.HTTP_201_CREATED,
)
@ -326,6 +387,7 @@ class GuestVisitViewSet(
document_id = request.data.get("document_id")
document = get_object_or_404(Document, pk=document_id)
visit.documents.add(document)
visit.guest.documents.add(document)
record_guest_event(
visit,
GuestVisitEventType.DOCUMENT_ATTACHED,
@ -358,12 +420,54 @@ class GuestViewSet(
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
queryset = Guest.objects.all().order_by("last_name", "first_name", "id")
queryset = (
Guest.objects.select_related("document_folder")
.prefetch_related("documents")
.order_by("last_name", "first_name", "id")
)
serializer_class = GuestSerializer
permission_classes = [GuestAdminPermission]
parser_classes = (MultiPartParser, FormParser, JSONParser)
def update(self, request, *args, **kwargs):
partial = kwargs.pop("partial", False)
instance = self.get_object()
serializer = self.get_serializer(
instance,
data=request.data,
partial=partial,
)
serializer.is_valid(raise_exception=True)
guest = serializer.save()
GuestLdapService().sync_guest(
guest,
enqueue_on_error=True,
raise_on_error=False,
)
guest.refresh_from_db()
return Response(self.get_serializer(guest).data)
def get_queryset(self):
qs = super().get_queryset()
guest_ct = ContentType.objects.get_for_model(Guest)
comments_subquery = (
Message.objects.filter(
chat__type="comments",
chat__context_content_type=guest_ct,
chat__context_object_id=OuterRef("pk"),
is_deleted=False,
)
.values("chat")
.annotate(count=Count("id"))
.values("count")
)
qs = qs.annotate(
comments_count=Coalesce(
Subquery(comments_subquery, output_field=IntegerField()),
Value(0),
),
visits_count=Count("visits", distinct=True),
)
q = (self.request.query_params.get("q") or "").strip()
if q:
text_q = (
@ -377,6 +481,27 @@ class GuestViewSet(
if q.isdigit():
text_q |= Q(id=int(q))
qs = qs.filter(text_q)
if self.request.query_params.get("is_active") in {"true", "false"}:
qs = qs.filter(
is_active=self.request.query_params.get("is_active") == "true"
)
if self.request.query_params.get("is_blacklisted") in {"true", "false"}:
qs = qs.filter(
is_blacklisted=self.request.query_params.get("is_blacklisted") == "true"
)
ordering = self.request.query_params.get("ordering")
allowed_ordering = {
"last_name",
"-last_name",
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"ldap_last_synced_at",
"-ldap_last_synced_at",
}
if ordering in allowed_ordering:
qs = qs.order_by(ordering, "id")
return qs
@action(detail=False, methods=["get"])
@ -388,16 +513,150 @@ class GuestViewSet(
)
return Response(serializer.data)
@action(detail=True, methods=["post"])
def deactivate(self, request, pk=None):
def _guest_document_response(
self,
guest: Guest,
document: Document,
response_status=status.HTTP_200_OK,
):
guest.refresh_from_db()
return Response(
{
"guest": self.get_serializer(guest).data,
"document": DocumentReadSerializer(
document,
context=self.get_serializer_context(),
).data,
},
status=response_status,
)
@action(detail=True, methods=["get", "post"])
def comments(self, request, pk=None):
guest = self.get_object()
guest.is_active = False
guest.save(update_fields=["is_active", "updated_at"])
GuestLdapService().disable_guest(guest)
if request.method == "GET":
messages = comments_helpers.get_comments(guest)
return Response(
[
{
"id": msg.id,
"author": EmployeeBriefSerializer(msg.author).data,
"text": msg.content,
"metadata": msg.system_metadata,
"created_at": msg.created_at,
}
for msg in messages
]
)
text = (request.data.get("text") or "").strip()
if not text:
raise ValidationError({"text": "Это поле не может быть пустым."})
msg = comments_helpers.create_comment(guest, request.user, text)
return Response(
{
"id": msg.id,
"author": EmployeeBriefSerializer(msg.author).data,
"text": msg.content,
"metadata": msg.system_metadata,
"created_at": msg.created_at,
},
status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["delete"],
url_path="comments/(?P<comment_id>[^/.]+)",
)
def delete_comment(self, request, pk=None, comment_id=None):
guest = self.get_object()
chat = comments_helpers.get_or_create_comments_chat(guest)
message = get_object_or_404(Message, id=comment_id, chat=chat)
if message.author_id != request.user.id and not is_guest_admin(request.user):
raise PermissionDenied("Недостаточно прав для удаления комментария.")
comments_helpers.delete_comment(message, request.user, soft_delete=True)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"], url_path="documents")
def documents(self, request, pk=None):
guest = self.get_object()
document_id = request.data.get("document_id")
if document_id:
document = get_object_or_404(Document, pk=document_id)
guest.documents.add(document)
return self._guest_document_response(guest, document)
upload = request.FILES.get("file")
if not upload:
raise ValidationError({"file": "Передайте файл или document_id."})
folder = guest.ensure_document_folder(owner=request.user)
file_name = getattr(upload, "name", "") or "document"
title = (request.data.get("title") or "").strip()
if not title:
title = os.path.splitext(os.path.basename(file_name))[0] or file_name
filer_file = FilerFile.objects.create(
file=upload,
folder=folder,
original_filename=file_name,
name=file_name,
owner=request.user,
is_public=True,
)
document = Document.objects.create(
title=title,
description=(request.data.get("description") or "").strip(),
file=filer_file,
folder=folder,
uploaded_by=request.user,
modified_by=request.user,
sent_to_all=False,
)
guest.documents.add(document)
return self._guest_document_response(
guest,
document,
response_status=status.HTTP_201_CREATED,
)
@action(
detail=True,
methods=["delete"],
url_path="documents/(?P<document_id>[^/.]+)",
)
def remove_document(self, request, pk=None, document_id=None):
if not is_guest_admin(request.user):
raise PermissionDenied("Недостаточно прав для изменения гостя.")
guest = self.get_object()
document = get_object_or_404(Document, pk=document_id)
guest.documents.remove(document)
return self._guest_document_response(guest, document)
@action(detail=True, methods=["post"])
def blacklist(self, request, pk=None):
guest = self.get_object()
GuestLdapService().blacklist_guest(guest)
guest.refresh_from_db()
return Response(self.get_serializer(guest).data)
@action(detail=True, methods=["post"], url_path="unblacklist")
def unblacklist(self, request, pk=None):
guest = self.get_object()
GuestLdapService().unblacklist_guest(guest)
guest.refresh_from_db()
return Response(self.get_serializer(guest).data)
@action(detail=True, methods=["post"], url_path="sync-ldap")
def sync_ldap(self, request, pk=None):
guest = self.get_object()
GuestLdapService().sync_guest(guest)
try:
GuestLdapService().sync_guest(
guest,
enqueue_on_error=True,
raise_on_error=True,
)
except Exception as exc:
guest.refresh_from_db()
raise ValidationError(str(exc)) from exc
guest.refresh_from_db()
return Response(self.get_serializer(guest).data)

View File

@ -0,0 +1,22 @@
# Generated by Codex on 2026-06-20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("communications", "0038_alter_chat_participants_alter_chatmembership_role"),
]
operations = [
migrations.AlterField(
model_name="chat",
name="context_object_id",
field=models.PositiveBigIntegerField(
blank=True,
null=True,
verbose_name="Context object ID",
),
),
]

View File

@ -161,7 +161,7 @@ class Chat(models.Model):
"(e.g., Project, Team, Event, Document)"
),
)
context_object_id = models.PositiveIntegerField(
context_object_id = models.PositiveBigIntegerField(
null=True, blank=True, verbose_name="Context object ID"
)
context_object = GenericForeignKey(

View File

@ -29,6 +29,7 @@ class LdapRouter:
"ldapgroup",
"ldaporganizationalunit",
"ldaporganizationalunitgroup",
"ldapguestuser",
}
@property

View File

@ -608,7 +608,7 @@ LDAP_POSITIONS_BASE = os.getenv(
"LDAP_POSITIONS_BASE", "OU=Positions,DC=eusrr,DC=local"
)
LDAP_GUESTS_BASE = os.getenv(
"LDAP_GUESTS_BASE", "OU=Guests,DC=eusrr,DC=local"
"LDAP_GUESTS_BASE", f"OU=Guests,{LDAP_BASE_DN}"
)
LDAP_GUESTS_ACTIVE_BASE = os.getenv(
"LDAP_GUESTS_ACTIVE_BASE", f"OU=Active,{LDAP_GUESTS_BASE}"

View File

@ -11,10 +11,10 @@ class GuestAdmin(admin.ModelAdmin):
"organization",
"email",
"phone",
"ldap_enabled",
"is_active",
"is_blacklisted",
)
list_filter = ("ldap_enabled", "is_active", "organization")
list_filter = ("is_active", "is_blacklisted", "organization")
search_fields = (
"id",
"last_name",
@ -98,4 +98,3 @@ class GuestVisitEventAdmin(admin.ModelAdmin):
"metadata",
"created_at",
)

View File

@ -14,14 +14,13 @@ from dataclasses import dataclass
from django.conf import settings
from django.utils import timezone as _dj_tz
from ldap3 import SUBTREE
from ldapdb.models import Model as LdapModel
from ldapdb.models.fields import CharField, DateTimeField, IntegerField, ListField
from ldap3 import BASE, SUBTREE
from ldap3.utils.dn import parse_dn
from employees.ldap.infrastructure.connections import _ldap
from employees.ldap.mixins import LdapSyncStateMixin, ModifyDnMixin
from employees.ldap.repositories.ldap_repository import ensure_container_exists
from employees.ldap.services.constants import UserAccountControl
from employees.ldap.services.user_mapper_service import UserMapperService
from employees.ldap.services.user_password_service import UserPasswordService
from employees.ldap.utils.ldap_utils import cn_candidates
from employees.ldap.utils.text_utils import esc_filter, esc_rdn
@ -53,6 +52,17 @@ if not hasattr(_dj_tz, "utc") or not all(
_dj_tz.utc = _UtcCompat()
# django-ldapdb imports timezone.utc at import time; Django 5 no longer exposes it.
from ldapdb.models import Model as LdapModel # noqa: E402
from ldapdb.models.fields import ( # noqa: E402
CharField,
DateTimeField,
ImageField,
IntegerField,
ListField,
)
def get_guests_base():
return getattr(settings, "LDAP_GUESTS_BASE", "OU=Guests,DC=eusrr,DC=local")
@ -65,6 +75,15 @@ class LdapGuestSyncResult:
user_account_control: int
class GuestEmployeeNumberConflict(RuntimeError):
def __init__(self, employee_number: str, dn: str):
self.employee_number = employee_number
self.dn = dn
super().__init__(
f"employeeNumber {employee_number} already exists in LDAP: {dn}"
)
class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
"""LDAP ORM model for guest AD users.
@ -89,6 +108,7 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
display_name = CharField(db_column="displayName")
mail = CharField(db_column="mail", blank=True)
telephone_number = CharField(db_column="telephoneNumber", blank=True)
thumbnail_photo = ImageField(db_column="thumbnailPhoto", blank=True)
employee_number = CharField(db_column="employeeNumber", blank=True)
description = CharField(db_column="description", blank=True)
@ -123,6 +143,58 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
raise RuntimeError("Целевой LDAP контейнер гостей не задан.")
return target_base
@staticmethod
def _rdn_to_string(rdn: tuple[str, str, str]) -> str:
attr, value, _separator = rdn
return f"{attr}={value}"
@staticmethod
def _ou_value(value: str) -> str:
return value.replace("\\,", ",").replace("\\\\", "\\")
@classmethod
def ensure_guest_ou_chain(cls, conn, target_base_dn: str) -> None:
"""Ensure LDAP guest OU chain exists.
Example:
OU=Active,OU=Guests,DC=eusr,DC=local
Creates missing OUs from parent to child:
OU=Guests,DC=eusr,DC=local
OU=Active,OU=Guests,DC=eusr,DC=local
"""
parts = parse_dn(target_base_dn)
if not parts:
raise RuntimeError("Целевой LDAP контейнер гостей не задан.")
leading_ous: list[tuple[str, str, str]] = []
for part in parts:
if part[0].upper() != "OU":
break
leading_ous.append(part)
if not leading_ous:
return
for index in range(len(leading_ous) - 1, -1, -1):
dn = ",".join(cls._rdn_to_string(part) for part in parts[index:])
ok = conn.search(
search_base=dn,
search_filter="(objectClass=organizationalUnit)",
search_scope=BASE,
attributes=["distinguishedName"],
)
if ok and getattr(conn, "entries", []):
continue
ou_name = cls._ou_value(parts[index][1])
if not conn.add(
dn,
["top", "organizationalUnit"],
{"ou": ou_name},
):
raise RuntimeError(f"Не удалось создать OU {dn}: {conn.result}")
@staticmethod
def sam_for_guest(guest) -> str:
return f"g{guest.id}"
@ -161,8 +233,33 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
"employeeNumber": str(guest.id),
"description": cls.description_for_guest(guest),
}
avatar = cls.processed_avatar_for_guest(guest)
if avatar:
attrs["thumbnailPhoto"] = avatar
return {k: v for k, v in attrs.items() if v not in (None, "", [])}
@staticmethod
def avatar_bytes_for_guest(guest) -> bytes | None:
avatar = getattr(guest, "avatar", None)
if not avatar:
return None
try:
if hasattr(avatar, "open"):
avatar.open("rb")
data = avatar.read()
if hasattr(avatar, "seek"):
avatar.seek(0)
return data
except Exception:
return None
@classmethod
def processed_avatar_for_guest(cls, guest) -> bytes | None:
avatar_bytes = cls.avatar_bytes_for_guest(guest)
if not avatar_bytes:
return None
return UserMapperService().process_avatar(avatar_bytes)
@classmethod
def _sync_result(cls, dn: str, guest) -> LdapGuestSyncResult:
return LdapGuestSyncResult(
@ -172,15 +269,43 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
user_account_control=int(UserAccountControl.DISABLED),
)
@staticmethod
def _normalize_dn(dn: str) -> str:
return (dn or "").strip().lower()
@classmethod
def _ensure_unique_employee_number(cls, conn, guest, current_dn: str = "") -> None:
base = getattr(settings, "LDAP_BASE_DN", "") or getattr(
def _parent_dn(cls, dn: str) -> str:
parts = parse_dn(dn)
if len(parts) < 2:
return ""
return ",".join(cls._rdn_to_string(part) for part in parts[1:])
@classmethod
def is_guest_dn(cls, dn: str) -> bool:
guest_base = getattr(settings, "LDAP_GUESTS_BASE", "") or ""
guest_base_lower = cls._normalize_dn(guest_base)
dn_lower = cls._normalize_dn(dn)
return bool(
guest_base_lower
and (
dn_lower == guest_base_lower
or dn_lower.endswith(f",{guest_base_lower}")
)
)
@classmethod
def _employee_number_search_base(cls) -> str:
return getattr(settings, "LDAP_BASE_DN", "") or getattr(
settings,
"LDAP_GUESTS_BASE",
"",
)
@classmethod
def _employee_number_dns(cls, conn, guest) -> list[str]:
base = cls._employee_number_search_base()
if not base:
return
return []
conn.search(
search_base=base,
search_filter=f"(employeeNumber={esc_filter(str(guest.id))})",
@ -188,18 +313,33 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
attributes=["distinguishedName"],
size_limit=2,
)
for entry in conn.entries:
dn = str(getattr(entry, "entry_dn", ""))
if dn and dn.lower() != (current_dn or "").lower():
raise RuntimeError(
f"employeeNumber {guest.id} already exists in LDAP: {dn}"
)
return [
str(getattr(entry, "entry_dn", ""))
for entry in conn.entries
if str(getattr(entry, "entry_dn", ""))
]
@classmethod
def find_existing_dn_for_guest(cls, guest) -> str:
with _ldap() as conn:
dns = cls._employee_number_dns(conn, guest)
for dn in dns:
if cls.is_guest_dn(dn):
return dn
return ""
@classmethod
def _ensure_unique_employee_number(cls, conn, guest, current_dn: str = "") -> None:
dns = cls._employee_number_dns(conn, guest)
for dn in dns:
if dn and cls._normalize_dn(dn) != cls._normalize_dn(current_dn):
raise GuestEmployeeNumberConflict(str(guest.id), dn)
@classmethod
def create_for_guest(cls, guest, in_active_ou: bool) -> LdapGuestSyncResult:
base_dn = cls.target_base(in_active_ou)
with _ldap() as conn:
ensure_container_exists(conn, base_dn)
cls.ensure_guest_ou_chain(conn, base_dn)
cls._ensure_unique_employee_number(conn, guest)
attrs = cls._guest_attrs(guest)
display_name = cls.display_name_for_guest(guest)
@ -228,16 +368,19 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
) -> LdapGuestSyncResult:
target_base = cls.target_base(in_active_ou)
with _ldap() as conn:
ensure_container_exists(conn, target_base)
cls.ensure_guest_ou_chain(conn, target_base)
cls._ensure_unique_employee_number(conn, guest, dn)
ldap_guest = cls.objects.get(dn=dn)
ldap_guest.move_to(target_base)
ldap_guest.apply_guest(guest)
ldap_guest.save()
if cls._normalize_dn(cls._parent_dn(str(ldap_guest.dn))) != cls._normalize_dn(
target_base
):
ldap_guest.move_to(target_base)
update_fields = ldap_guest.apply_guest(guest)
ldap_guest.save(update_fields=update_fields)
return cls._sync_result(str(ldap_guest.dn), guest)
def apply_guest(self, guest) -> None:
def apply_guest(self, guest) -> list[str]:
self.sam_account_name = self.sam_for_guest(guest)
self.user_principal_name = self.upn_for_guest(guest)
self.given_name = guest.first_name or ""
@ -248,3 +391,18 @@ class LdapGuestUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
self.employee_number = str(guest.id)
self.description = self.description_for_guest(guest)
self.user_account_control = UserAccountControl.DISABLED
avatar = self.processed_avatar_for_guest(guest)
self.thumbnail_photo = avatar
return [
"sam_account_name",
"user_principal_name",
"given_name",
"sn",
"display_name",
"mail",
"telephone_number",
"employee_number",
"description",
"user_account_control",
"thumbnail_photo",
]

View File

@ -0,0 +1,31 @@
import ldapdb.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("guests", "0002_ldapguestuser"),
]
operations = [
migrations.AddField(
model_name="guest",
name="avatar",
field=models.ImageField(
blank=True,
null=True,
upload_to="guests/avatars",
verbose_name="Фото",
),
),
migrations.AddField(
model_name="ldapguestuser",
name="thumbnail_photo",
field=ldapdb.models.fields.ImageField(
blank=True,
db_column="thumbnailPhoto",
max_length=200,
),
),
]

View File

@ -0,0 +1,56 @@
from django.db import migrations, models
def migrate_guest_state(apps, schema_editor):
Guest = apps.get_model("guests", "Guest")
for guest in Guest.objects.all().only("id", "is_active", "ldap_enabled"):
old_is_active = bool(guest.is_active)
old_ldap_enabled = bool(guest.ldap_enabled)
guest.is_blacklisted = not old_is_active
guest.is_active = old_ldap_enabled
guest.save(update_fields=["is_blacklisted", "is_active"])
def reverse_guest_state(apps, schema_editor):
Guest = apps.get_model("guests", "Guest")
for guest in Guest.objects.all().only("id", "is_active", "is_blacklisted"):
guest.ldap_enabled = bool(guest.is_active)
guest.is_active = not bool(guest.is_blacklisted)
guest.save(update_fields=["ldap_enabled", "is_active"])
class Migration(migrations.Migration):
dependencies = [
("guests", "0003_guest_avatar_ldapguestuser_thumbnail_photo"),
]
operations = [
migrations.AddField(
model_name="guest",
name="is_blacklisted",
field=models.BooleanField(
db_index=True,
default=False,
verbose_name="Черный список",
),
),
migrations.RunPython(migrate_guest_state, reverse_guest_state),
migrations.RemoveIndex(
model_name="guest",
name="guest_ldap_enabled_idx",
),
migrations.AddIndex(
model_name="guest",
index=models.Index(fields=["is_active"], name="guest_active_idx"),
),
migrations.AlterField(
model_name="guest",
name="is_active",
field=models.BooleanField(default=False, verbose_name="Активен по доступу"),
),
migrations.RemoveField(
model_name="guest",
name="ldap_enabled",
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("guests", "0004_guest_blacklist_remove_ldap_enabled"),
]
operations = [
migrations.AlterField(
model_name="guest",
name="last_name",
field=models.CharField(blank=True, max_length=150, verbose_name="Фамилия"),
),
]

View File

@ -0,0 +1,36 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("documents", "0015_delete_documentcomment"),
("filer", "0018_alter_file_options"),
("guests", "0005_guest_last_name_optional"),
]
operations = [
migrations.AddField(
model_name="guest",
name="document_folder",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="guest_document_folders",
to="filer.folder",
verbose_name="Папка документов",
),
),
migrations.AddField(
model_name="guest",
name="documents",
field=models.ManyToManyField(
blank=True,
related_name="guests",
to="documents.document",
verbose_name="Документы гостя",
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.4 on 2026-06-20 18:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('guests', '0006_guest_document_folder_documents'),
]
operations = [
migrations.RemoveField(
model_name='guestvisit',
name='admin_comment',
),
]

View File

@ -0,0 +1,101 @@
# Generated by Codex on 2026-06-20
from django.conf import settings
from django.db import migrations
from django.utils import timezone
def migrate_guest_comments_forward(apps, schema_editor):
Chat = apps.get_model("communications", "Chat")
Message = apps.get_model("communications", "Message")
ContentType = apps.get_model("contenttypes", "ContentType")
Guest = apps.get_model("guests", "Guest")
user_app_label, user_model_name = settings.AUTH_USER_MODEL.split(".")
User = apps.get_model(user_app_label, user_model_name)
try:
guest_ct = ContentType.objects.get(app_label="guests", model="guest")
except ContentType.DoesNotExist:
return
fallback_author_id = (
User.objects.order_by("id").values_list("id", flat=True).first()
)
for guest in Guest.objects.exclude(comment="").iterator():
content = (guest.comment or "").strip()
if not content:
continue
author_id = guest.created_by_id or fallback_author_id
if not author_id:
continue
guest_name = " ".join(
part
for part in [
guest.last_name,
guest.first_name,
guest.patronymic,
]
if part
) or f"Гость {guest.id}"
chat, _ = Chat.objects.get_or_create(
type="comments",
context_content_type=guest_ct,
context_object_id=guest.id,
defaults={
"name": f"Комментарии: {guest_name[:50]}",
"created_by_id": author_id,
"flags": {
"allow_replies": True,
"allow_reactions": True,
"allow_attachments": True,
"allow_editing": True,
},
"can_reply": True,
"created_at": guest.created_at or timezone.now(),
},
)
if Message.objects.filter(
chat=chat,
system_metadata__legacy_field="guests.Guest.comment",
).exists():
continue
Message.objects.create(
chat=chat,
author_id=author_id,
content=content,
created_at=guest.created_at or timezone.now(),
is_system=False,
has_attachments=False,
is_deleted=False,
is_edited=False,
is_pinned=False,
is_forwarded=False,
is_cross_chat=False,
system_type="",
system_metadata={"legacy_field": "guests.Guest.comment"},
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("communications", "0039_alter_chat_context_object_id_bigint"),
("guests", "0007_remove_guestvisit_admin_comment"),
]
operations = [
migrations.RunPython(
migrate_guest_comments_forward,
migrations.RunPython.noop,
),
migrations.RemoveField(
model_name="guest",
name="comment",
),
]

View File

@ -22,15 +22,29 @@ def _setting_int(name: str, default: int) -> int:
class Guest(models.Model):
id = models.BigIntegerField(primary_key=True, editable=False)
last_name = models.CharField("Фамилия", max_length=150)
last_name = models.CharField("Фамилия", max_length=150, blank=True)
first_name = models.CharField("Имя", max_length=150)
patronymic = models.CharField("Отчество", max_length=150, blank=True)
birth_date = models.DateField("Дата рождения", null=True, blank=True)
phone = models.CharField("Телефон", max_length=64, blank=True)
email = models.EmailField("Email", blank=True)
avatar = models.ImageField("Фото", upload_to="guests/avatars", blank=True, null=True)
organization = models.CharField("Организация", max_length=255, blank=True)
position = models.CharField("Должность", max_length=255, blank=True)
comment = models.TextField("Комментарий", blank=True)
document_folder = models.ForeignKey(
"filer.Folder",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="guest_document_folders",
verbose_name="Папка документов",
)
documents = models.ManyToManyField(
"documents.Document",
blank=True,
related_name="guests",
verbose_name="Документы гостя",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@ -39,8 +53,8 @@ class Guest(models.Model):
related_name="created_guests",
verbose_name="Создал",
)
is_active = models.BooleanField("Активен", default=True)
ldap_enabled = models.BooleanField("LDAP в Active OU", default=False)
is_active = models.BooleanField("Активен по доступу", default=False)
is_blacklisted = models.BooleanField("Черный список", default=False, db_index=True)
ldap_username = models.CharField("LDAP username", max_length=150, blank=True)
ldap_upn = models.CharField("LDAP UPN", max_length=255, blank=True)
ldap_last_synced_at = models.DateTimeField(
@ -60,7 +74,7 @@ class Guest(models.Model):
models.Index(fields=["last_name", "first_name"], name="guest_name_idx"),
models.Index(fields=["email"], name="guest_email_idx"),
models.Index(fields=["phone"], name="guest_phone_idx"),
models.Index(fields=["ldap_enabled"], name="guest_ldap_enabled_idx"),
models.Index(fields=["is_active"], name="guest_active_idx"),
]
constraints = [
models.CheckConstraint(
@ -80,6 +94,67 @@ class Guest(models.Model):
if part
).strip()
def _safe_document_folder_name(self) -> str:
raw = self.full_name or f"Гость {self.pk or ''}"
cleaned = "".join(
"_" if char in {"/", "\\"} or ord(char) < 32 else char
for char in raw
).strip(" .")
return cleaned or f"Гость {self.pk}"
@staticmethod
def _unique_document_folder_name(parent, base_name: str, exclude_id=None) -> str:
from filer.models import Folder
candidate = base_name
counter = 2
while Folder.objects.filter(parent=parent, name=candidate).exclude(
id=exclude_id
).exists():
candidate = f"{base_name} ({counter})"
counter += 1
return candidate
def ensure_document_folder(self, owner=None):
from filer.models import Folder
root, _ = Folder.objects.get_or_create(
parent=None,
name="guests",
defaults={"owner": owner},
)
base_name = self._safe_document_folder_name()
if self.document_folder_id:
folder = self.document_folder
expected_name = self._unique_document_folder_name(
root,
base_name,
exclude_id=folder.id,
)
update_fields = []
if folder.parent_id != root.id:
folder.parent = root
update_fields.append("parent")
if folder.name != expected_name:
folder.name = expected_name
update_fields.append("name")
if owner and not folder.owner_id:
folder.owner = owner
update_fields.append("owner")
if update_fields:
folder.save(update_fields=update_fields)
return folder
folder = Folder.objects.create(
parent=root,
name=self._unique_document_folder_name(root, base_name),
owner=owner,
)
Guest.objects.filter(pk=self.pk).update(document_folder=folder)
self.document_folder = folder
return folder
@classmethod
def next_guest_id(cls) -> int:
start = _setting_int("GUESTS_ID_START", GUEST_ID_START)
@ -109,6 +184,8 @@ class Guest(models.Model):
self.pk = self.next_guest_id()
self.clean()
super().save(*args, **kwargs)
if self.document_folder_id:
self.ensure_document_folder(owner=self.created_by)
class GuestVisit(models.Model):
@ -143,7 +220,6 @@ class GuestVisit(models.Model):
)
purpose = models.TextField("Цель приглашения")
visit_comment = models.TextField("Комментарий заявителя", blank=True)
admin_comment = models.TextField("Комментарий администратора", blank=True)
status = models.CharField(
"Статус",
max_length=24,
@ -248,6 +324,8 @@ class GuestVisit(models.Model):
def is_active_now(self) -> bool:
if self.status != GuestVisitStatus.APPROVED:
return False
if self.inviter_inactive:
return False
if self.unlimited:
return True
now = timezone.now()

View File

@ -10,8 +10,10 @@ from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from communications import comments_helpers
from communications.models import ChatReadState
from employees.models import LdapSyncQueue, LdapSyncState
from guests.ldap.orm_models import LdapGuestUser
from guests.ldap.orm_models import GuestEmployeeNumberConflict, LdapGuestUser
from notifications.signals import notify
from .constants import GuestVisitEventType, GuestVisitStatus
@ -105,10 +107,147 @@ def normalize_all_day_range(date_from, date_to):
return starts_at, expires_at
GUEST_VISIT_PERIOD_BLOCKING_STATUSES = {
GuestVisitStatus.PENDING,
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}
def get_overlapping_guest_visit(
*,
guest: Guest,
starts_at,
expires_at,
unlimited: bool,
exclude_pk: int | None = None,
) -> GuestVisit | None:
qs = GuestVisit.objects.filter(
guest=guest,
status__in=GUEST_VISIT_PERIOD_BLOCKING_STATUSES,
)
if exclude_pk:
qs = qs.exclude(pk=exclude_pk)
if unlimited:
return qs.order_by("-created_at").first()
if not starts_at or not expires_at:
return None
return (
qs.filter(
Q(unlimited=True)
| (
(
Q(access_starts_at__isnull=True)
| Q(access_starts_at__lt=expires_at)
)
& (
Q(access_expires_at__isnull=True)
| Q(access_expires_at__gt=starts_at)
)
)
)
.order_by("-created_at")
.first()
)
def validate_guest_visit_period_available(
*,
guest: Guest,
starts_at,
expires_at,
unlimited: bool,
exclude_pk: int | None = None,
) -> None:
if get_overlapping_guest_visit(
guest=guest,
starts_at=starts_at,
expires_at=expires_at,
unlimited=unlimited,
exclude_pk=exclude_pk,
):
raise ValueError(
"У этого гостя уже есть заявка с пересекающимся периодом доступа."
)
def validate_guest_visit_period_not_past(
*,
expires_at,
unlimited: bool,
) -> None:
if unlimited:
return
if expires_at and expires_at <= timezone.now():
raise ValueError(
"Нельзя создать заявку с периодом доступа, который уже истек."
)
def has_unread_info_response_for_user(visit: GuestVisit, user) -> bool:
if not user or not getattr(user, "is_authenticated", False):
return False
chat = comments_helpers.get_comments_chat_if_exists(visit)
if not chat:
return False
latest_request = (
chat.messages.filter(
author=user,
is_deleted=False,
system_metadata__guest_visit_comment_type="info_request",
)
.order_by("-created_at", "-id")
.first()
)
if not latest_request:
return False
latest_response = (
chat.messages.filter(
is_deleted=False,
created_at__gt=latest_request.created_at,
system_metadata__guest_visit_comment_type="info_response",
)
.exclude(author=user)
.order_by("-created_at", "-id")
.first()
)
if not latest_response:
return False
read_state = ChatReadState.objects.filter(chat=chat, user=user).first()
return not (
read_state
and read_state.last_read_message_id
and read_state.last_read_message_id >= latest_response.id
)
class GuestVisitWorkflow:
@staticmethod
def _inviter_is_active(visit: GuestVisit) -> bool:
inviter = visit.inviter
return bool(inviter.is_active and getattr(inviter, "is_actually_active", True))
@staticmethod
def create_visit(*, actor, guest: Guest, **data) -> GuestVisit:
with transaction.atomic():
guest = Guest.objects.select_for_update().get(pk=guest.pk)
validate_guest_visit_period_available(
guest=guest,
starts_at=data.get("access_starts_at"),
expires_at=data.get("access_expires_at"),
unlimited=bool(data.get("unlimited", False)),
)
validate_guest_visit_period_not_past(
expires_at=data.get("access_expires_at"),
unlimited=bool(data.get("unlimited", False)),
)
department = resolve_inviter_department(actor)
visit = GuestVisit.objects.create(
guest=guest,
@ -116,6 +255,8 @@ class GuestVisitWorkflow:
inviter_snapshot_name=(actor.get_full_name() or str(actor)),
inviter_snapshot_email=getattr(actor, "email", "") or "",
host_department=department,
status=GuestVisitStatus.PENDING,
submitted_at=timezone.now(),
**data,
)
record_guest_event(
@ -124,6 +265,20 @@ class GuestVisitWorkflow:
actor=actor,
to_status=visit.status,
)
record_guest_event(
visit,
GuestVisitEventType.SUBMITTED,
actor=actor,
to_status=visit.status,
)
notify_guest_visit(
visit,
"guest_visit_submitted",
get_guest_admin_users(),
actor=actor,
title="Гостевой визит на рассмотрении",
message=f"{actor} создал гостевой визит для {visit.guest.full_name}.",
)
return visit
@staticmethod
@ -152,6 +307,8 @@ class GuestVisitWorkflow:
@classmethod
def submit(cls, visit: GuestVisit, *, actor) -> GuestVisit:
if visit.status == GuestVisitStatus.PENDING:
return visit
if visit.status not in {GuestVisitStatus.DRAFT, GuestVisitStatus.NEEDS_INFO}:
raise ValueError("Отправить можно только черновик или заявку с уточнениями.")
if not visit.purpose.strip():
@ -163,6 +320,17 @@ class GuestVisitWorkflow:
and not visit.documents.exists()
):
raise ValueError("Прикрепите документ гостя.")
validate_guest_visit_period_available(
guest=visit.guest,
starts_at=visit.access_starts_at,
expires_at=visit.access_expires_at,
unlimited=visit.unlimited,
exclude_pk=visit.pk,
)
validate_guest_visit_period_not_past(
expires_at=visit.access_expires_at,
unlimited=visit.unlimited,
)
visit.submitted_at = timezone.now()
event = (
GuestVisitEventType.INFO_PROVIDED
@ -190,16 +358,21 @@ class GuestVisitWorkflow:
def request_info(cls, visit: GuestVisit, *, actor, comment: str) -> GuestVisit:
if visit.status != GuestVisitStatus.PENDING:
raise ValueError("Запросить уточнения можно только по заявке на рассмотрении.")
if not comment.strip():
comment = comment.strip()
if not comment:
raise ValueError("Комментарий обязателен.")
visit.admin_comment = comment
comments_helpers.create_comment(
visit,
actor,
comment,
guest_visit_comment_type="info_request",
)
cls._set_status(
visit,
GuestVisitStatus.NEEDS_INFO,
actor=actor,
event_type=GuestVisitEventType.NEEDS_INFO_REQUESTED,
comment=comment,
update_fields=["admin_comment"],
)
notify_guest_visit(
visit,
@ -215,6 +388,19 @@ class GuestVisitWorkflow:
def approve(cls, visit: GuestVisit, *, actor, comment: str = "") -> GuestVisit:
if visit.status not in {GuestVisitStatus.PENDING, GuestVisitStatus.REJECTED}:
raise ValueError("Одобрить можно только заявку на рассмотрении или отклоненную заявку.")
if visit.inviter_inactive or not cls._inviter_is_active(visit):
raise ValueError("Нельзя одобрить заявку с неактивным приглашающим.")
validate_guest_visit_period_available(
guest=visit.guest,
starts_at=visit.access_starts_at,
expires_at=visit.access_expires_at,
unlimited=visit.unlimited,
exclude_pk=visit.pk,
)
validate_guest_visit_period_not_past(
expires_at=visit.access_expires_at,
unlimited=visit.unlimited,
)
old_status = visit.status
visit.decided_by = actor
visit.decided_at = timezone.now()
@ -281,12 +467,13 @@ class GuestVisitWorkflow:
@classmethod
def cancel(cls, visit: GuestVisit, *, actor, comment: str = "") -> GuestVisit:
if visit.status in {
GuestVisitStatus.CANCELLED,
GuestVisitStatus.EXPIRED,
GuestVisitStatus.REVOKED,
if visit.status not in {
GuestVisitStatus.DRAFT,
GuestVisitStatus.PENDING,
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}:
raise ValueError("Эту заявку уже нельзя отменить.")
raise ValueError("Отменить можно только активную заявку.")
visit.cancelled_by = actor
visit.cancelled_at = timezone.now()
visit.cancel_reason = comment
@ -301,6 +488,82 @@ class GuestVisitWorkflow:
GuestLdapService().disable_guest_if_no_active_visits(visit.guest, visit=visit)
return visit
@classmethod
def return_to_work(
cls,
visit: GuestVisit,
*,
actor,
comment: str = "",
) -> GuestVisit:
if visit.status not in {
GuestVisitStatus.CANCELLED,
GuestVisitStatus.REVOKED,
GuestVisitStatus.EXPIRED,
}:
raise ValueError("Вернуть в работу можно только закрытую заявку.")
if visit.inviter_inactive or not cls._inviter_is_active(visit):
raise ValueError("Нельзя вернуть в работу заявку с неактивным приглашающим.")
validate_guest_visit_period_available(
guest=visit.guest,
starts_at=visit.access_starts_at,
expires_at=visit.access_expires_at,
unlimited=visit.unlimited,
exclude_pk=visit.pk,
)
validate_guest_visit_period_not_past(
expires_at=visit.access_expires_at,
unlimited=visit.unlimited,
)
old_status = visit.status
visit.status = GuestVisitStatus.PENDING
visit.submitted_at = timezone.now()
visit.decided_by = None
visit.decided_at = None
visit.decision_comment = ""
visit.cancelled_by = None
visit.cancelled_at = None
visit.cancel_reason = ""
visit.revoked_by = None
visit.revoked_at = None
visit.revoke_reason = ""
visit.expired_at = None
visit.save(
update_fields=[
"status",
"submitted_at",
"decided_by",
"decided_at",
"decision_comment",
"cancelled_by",
"cancelled_at",
"cancel_reason",
"revoked_by",
"revoked_at",
"revoke_reason",
"expired_at",
"updated_at",
]
)
record_guest_event(
visit,
GuestVisitEventType.DECISION_CHANGED,
actor=actor,
from_status=old_status,
to_status=GuestVisitStatus.PENDING,
comment=comment or "Заявка возвращена в работу.",
)
notify_guest_visit(
visit,
"guest_visit_returned_to_work",
get_guest_admin_users(),
actor=actor,
title="Гостевая заявка возвращена в работу",
message=f"Заявка для {visit.guest.full_name} возвращена на рассмотрение.",
)
GuestLdapService().sync_guest_for_visit(visit)
return visit
@classmethod
def revoke(cls, visit: GuestVisit, *, actor, comment: str = "") -> GuestVisit:
if visit.status != GuestVisitStatus.APPROVED:
@ -360,9 +623,13 @@ class GuestLdapService:
)
def _should_be_in_active_ou(self, guest: Guest) -> bool:
if not guest.is_active:
if guest.is_blacklisted:
return False
return guest.visits.filter(status=GuestVisitStatus.APPROVED).filter(
return guest.visits.filter(
status=GuestVisitStatus.APPROVED,
inviter_inactive=False,
inviter__is_active=True,
).filter(
Q(unlimited=True)
| (
Q(access_starts_at__lte=timezone.now())
@ -388,10 +655,24 @@ class GuestLdapService:
)
state.touch(ldap_dn=dn, sync_dir="django")
def _record_skip(self, guest: Guest, visit: GuestVisit | None = None) -> None:
def _record_skip(
self,
guest: Guest,
visit: GuestVisit | None = None,
*,
in_active_ou: bool,
) -> None:
guest.is_active = in_active_ou
guest.ldap_last_error = ""
guest.ldap_last_synced_at = timezone.now()
guest.save(update_fields=["ldap_last_error", "ldap_last_synced_at", "updated_at"])
guest.save(
update_fields=[
"is_active",
"ldap_last_error",
"ldap_last_synced_at",
"updated_at",
]
)
if visit:
record_guest_event(
visit,
@ -421,37 +702,42 @@ class GuestLdapService:
enqueue_on_error: bool = True,
raise_on_error: bool = False,
) -> None:
in_active_ou = self._should_be_in_active_ou(guest)
if not self._is_ldap_write_enabled():
self._record_skip(guest, visit)
self._record_skip(guest, visit, in_active_ou=in_active_ou)
return
in_active_ou = self._should_be_in_active_ou(guest)
current_dn = self._get_state_dn(guest)
try:
if current_dn:
result = LdapGuestUser.sync_existing_for_guest(
guest,
current_dn,
in_active_ou,
)
event_type = (
GuestVisitEventType.LDAP_ENABLED
if in_active_ou
else GuestVisitEventType.LDAP_DISABLED
)
existing_dn = LdapGuestUser.find_existing_dn_for_guest(guest) or current_dn
if existing_dn:
try:
result = LdapGuestUser.sync_existing_for_guest(
guest,
existing_dn,
in_active_ou,
)
event_type = (
GuestVisitEventType.LDAP_ENABLED
if in_active_ou
else GuestVisitEventType.LDAP_DISABLED
)
except LdapGuestUser.DoesNotExist:
result = self._create_or_adopt_guest_account(guest, in_active_ou)
event_type = GuestVisitEventType.LDAP_CREATED
else:
result = LdapGuestUser.create_for_guest(guest, in_active_ou)
result = self._create_or_adopt_guest_account(guest, in_active_ou)
event_type = GuestVisitEventType.LDAP_CREATED
self._touch_state(guest, result.dn)
guest.ldap_enabled = in_active_ou
guest.is_active = in_active_ou
guest.ldap_username = result.sam_account_name
guest.ldap_upn = result.user_principal_name
guest.ldap_last_error = ""
guest.ldap_last_synced_at = timezone.now()
guest.save(
update_fields=[
"ldap_enabled",
"is_active",
"ldap_username",
"ldap_upn",
"ldap_last_error",
@ -469,6 +755,7 @@ class GuestLdapService:
"userAccountControl": str(result.user_account_control),
},
)
self.complete_pending_guest_syncs(guest)
except Exception as exc:
guest.ldap_last_error = str(exc)[:2000]
guest.save(update_fields=["ldap_last_error", "updated_at"])
@ -492,6 +779,22 @@ class GuestLdapService:
if raise_on_error:
raise
def _create_or_adopt_guest_account(
self,
guest: Guest,
in_active_ou: bool,
):
try:
return LdapGuestUser.create_for_guest(guest, in_active_ou)
except GuestEmployeeNumberConflict as exc:
if not LdapGuestUser.is_guest_dn(exc.dn):
raise
return LdapGuestUser.sync_existing_for_guest(
guest,
exc.dn,
in_active_ou,
)
def disable_guest(
self,
guest: Guest,
@ -507,6 +810,36 @@ class GuestLdapService:
raise_on_error=raise_on_error,
)
def blacklist_guest(
self,
guest: Guest,
*,
enqueue_on_error: bool = True,
raise_on_error: bool = False,
) -> None:
guest.is_blacklisted = True
guest.save(update_fields=["is_blacklisted", "updated_at"])
self.disable_guest(
guest,
enqueue_on_error=enqueue_on_error,
raise_on_error=raise_on_error,
)
def unblacklist_guest(
self,
guest: Guest,
*,
enqueue_on_error: bool = True,
raise_on_error: bool = False,
) -> None:
guest.is_blacklisted = False
guest.save(update_fields=["is_blacklisted", "updated_at"])
self.sync_guest(
guest,
enqueue_on_error=enqueue_on_error,
raise_on_error=raise_on_error,
)
def disable_guest_if_no_active_visits(
self,
guest: Guest,
@ -523,11 +856,45 @@ class GuestLdapService:
raise_on_error=raise_on_error,
)
@staticmethod
def complete_pending_guest_syncs(guest: Guest) -> None:
LdapSyncQueue.objects.filter(
operation__in=["guest_sync", "guest_disable"],
model_name="guest",
object_pk=str(guest.pk),
status=LdapSyncQueue.Status.PENDING,
).update(
status=LdapSyncQueue.Status.COMPLETED,
last_error="",
next_retry_at=None,
updated_at=timezone.now(),
)
@staticmethod
def enqueue_guest_sync(guest: Guest, *, operation: str = "guest_sync") -> None:
payload = {"guest_id": str(guest.pk), "_operation": operation}
pending = LdapSyncQueue.objects.filter(
operation=operation,
model_name="guest",
object_pk=str(guest.pk),
status=LdapSyncQueue.Status.PENDING,
).order_by("created_at")
item = pending.first()
if item:
pending.exclude(pk=item.pk).update(
status=LdapSyncQueue.Status.COMPLETED,
last_error="",
next_retry_at=None,
updated_at=timezone.now(),
)
item.payload = payload
item.next_retry_at = None
item.save(update_fields=["payload", "next_retry_at", "updated_at"])
return
LdapSyncQueue.objects.create(
operation=operation,
model_name="guest",
object_pk=str(guest.pk),
payload={"guest_id": str(guest.pk), "_operation": operation},
payload=payload,
)

View File

@ -58,20 +58,51 @@ def detect_inactive_inviters():
inviter = visit.inviter
if inviter.is_active and getattr(inviter, "is_actually_active", True):
continue
reason = "Приглашающий неактивен, гостевой доступ отозван автоматически."
old_status = visit.status
visit.inviter_inactive = True
visit.save(update_fields=["inviter_inactive", "updated_at"])
visit.status = GuestVisitStatus.REVOKED
visit.revoked_at = timezone.now()
visit.revoke_reason = reason
visit.save(
update_fields=[
"inviter_inactive",
"status",
"revoked_at",
"revoke_reason",
"updated_at",
]
)
record_guest_event(
visit,
GuestVisitEventType.INVITER_INACTIVE_DETECTED,
from_status=old_status,
to_status=GuestVisitStatus.REVOKED,
comment=reason,
metadata={"inviter_id": inviter.id},
)
record_guest_event(
visit,
GuestVisitEventType.REVOKED,
from_status=old_status,
to_status=GuestVisitStatus.REVOKED,
comment=reason,
metadata={"reason": "inviter_inactive", "inviter_id": inviter.id},
)
notify_guest_visit(
visit,
"guest_inviter_inactive",
admins,
title="Приглашающий гостя неактивен",
message=f"Приглашающий для гостя {visit.guest.full_name} неактивен.",
message=reason,
)
try:
GuestLdapService().sync_guest_for_visit(visit)
except Exception:
logger.exception(
"Failed to sync guest %s after inactive inviter revoke",
visit.guest_id,
)
def execute_guest_queue_operation(operation: str, payload: dict) -> None:

View File

@ -226,7 +226,7 @@ LDAP-поля и состояние:
Дополнительные поля:
- `visit_comment`: комментарий заявителя;
- `admin_comment`: внутренний комментарий администратора;
- запросы уточнений и ответы хранятся в комментариях `communications`, а не в отдельном поле визита;
- `host_department`: отдел приглашающего на момент создания, nullable;
- `cancelled_at`, `cancelled_by`, `cancel_reason`;
- `revoked_at`, `revoked_by`, `revoke_reason`;

File diff suppressed because it is too large Load Diff

View File

@ -2,12 +2,15 @@ from contextlib import contextmanager
from datetime import timedelta
import pytest
from django.core.files.base import ContentFile
from django.utils import timezone
from employees.ldap.services.constants import UserAccountControl
from employees.models import LdapSyncQueue, LdapSyncState
from employees.tasks import process_ldap_queue_item
from eusrr_backend.db_routers import LdapRouter
from guests.constants import GuestVisitStatus
from guests.ldap.orm_models import LdapGuestUser
from guests.models import Guest, GuestVisit
from guests.services import GuestLdapService, GuestVisitWorkflow
from guests.tasks import execute_guest_queue_operation
@ -18,14 +21,23 @@ class FakeLdapConnection:
self.entries = []
self.add_calls = []
self.modify_calls = []
self.existing_dns = set()
self.result = {}
def search(self, *args, **kwargs):
def search(self, search_base=None, search_filter=None, *args, **kwargs):
if search_filter == "(objectClass=organizationalUnit)":
self.entries = (
[FakeLdapEntry(search_base)]
if search_base in self.existing_dns
else []
)
return True
self.entries = []
return True
def add(self, dn, object_classes, attrs):
self.add_calls.append((dn, object_classes, attrs))
self.existing_dns.add(dn)
return True
def modify(self, dn, changes):
@ -47,11 +59,24 @@ class ConflictingEmployeeNumberConnection(FakeLdapConnection):
return True
class ExistingGuestEmployeeNumberConnection(FakeLdapConnection):
def __init__(self, guest_dn):
super().__init__()
self.guest_dn = guest_dn
def search(self, search_base=None, search_filter=None, *args, **kwargs):
if search_filter and search_filter.startswith("(employeeNumber="):
self.entries = [FakeLdapEntry(self.guest_dn)]
return True
return super().search(search_base, search_filter, *args, **kwargs)
class FakeLdapGuestUser:
def __init__(self, dn):
self.dn = dn
self.moves = []
self.saved = False
self.update_fields = None
def move_to(self, new_base_dn):
self.moves.append(new_base_dn)
@ -69,9 +94,43 @@ class FakeLdapGuestUser:
self.employee_number = str(guest.id)
self.description = f"Guest account for {guest.organization}".strip()
self.user_account_control = UserAccountControl.DISABLED
self.thumbnail_photo = (
b"processed-avatar" if getattr(guest, "avatar", None) else b""
)
return [
"sam_account_name",
"user_principal_name",
"given_name",
"sn",
"display_name",
"mail",
"telephone_number",
"employee_number",
"description",
"user_account_control",
"thumbnail_photo",
]
def save(self):
def save(self, *args, **kwargs):
self.saved = True
self.update_fields = kwargs.get("update_fields")
def get_guest_user_add(fake_conn):
return next(
call
for call in fake_conn.add_calls
if "user" in [item.lower() for item in call[1]]
)
def test_ldap_guest_user_is_routed_to_ldap_database(settings):
settings.DATABASES["ldap"] = {"ENGINE": "ldapdb.backends.ldap"}
router = LdapRouter()
assert router.db_for_read(LdapGuestUser) == "ldap"
assert router.db_for_write(LdapGuestUser) == "ldap"
assert router.allow_migrate("default", "guests", "ldapguestuser") is False
@pytest.mark.django_db
@ -95,10 +154,6 @@ def test_guest_ldap_service_writes_guest_id_to_employee_number(
yield fake_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.ensure_container_exists",
lambda *a, **k: None,
)
monkeypatch.setattr(
"guests.ldap.orm_models.UserPasswordService.set_password",
lambda *a, **k: None,
@ -118,15 +173,102 @@ def test_guest_ldap_service_writes_guest_id_to_employee_number(
GuestLdapService().sync_guest_for_visit(visit)
assert fake_conn.add_calls
attrs = fake_conn.add_calls[0][2]
created_ou_dns = [
dn for dn, object_classes, _attrs in fake_conn.add_calls
if "organizationalUnit" in object_classes
]
assert created_ou_dns == [
"OU=Guests,DC=example,DC=test",
"OU=Active,OU=Guests,DC=example,DC=test",
]
attrs = get_guest_user_add(fake_conn)[2]
assert attrs["employeeNumber"] == str(guest.id)
assert attrs["userAccountControl"] == UserAccountControl.DISABLED
assert not fake_conn.modify_calls
guest.refresh_from_db()
assert guest.ldap_enabled is True
assert guest.is_active is True
assert guest.ldap_username == f"g{guest.id}"
@pytest.mark.django_db
def test_guest_ldap_creation_writes_processed_avatar_to_thumbnail_photo(
settings,
monkeypatch,
tmp_path,
user_factory,
):
settings.MEDIA_ROOT = tmp_path
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.UserPasswordService.set_password",
lambda *a, **k: None,
)
monkeypatch.setattr(
"guests.ldap.orm_models.UserMapperService.process_avatar",
lambda self, avatar_bytes, **kwargs: b"processed-avatar",
)
inviter = user_factory()
guest = Guest.objects.create(first_name="Avatar", last_name="Guest")
guest.avatar.save("guest.jpg", ContentFile(b"raw-avatar"), save=True)
visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Avatar LDAP test",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
GuestLdapService().sync_guest_for_visit(visit)
attrs = get_guest_user_add(fake_conn)[2]
assert attrs["thumbnailPhoto"] == b"processed-avatar"
assert attrs["employeeNumber"] == str(guest.id)
assert attrs["userAccountControl"] == UserAccountControl.DISABLED
@pytest.mark.django_db
def test_guest_ldap_orm_apply_guest_keeps_disabled_and_updates_thumbnail_photo(
settings,
monkeypatch,
tmp_path,
):
settings.MEDIA_ROOT = tmp_path
monkeypatch.setattr(
"guests.ldap.orm_models.UserMapperService.process_avatar",
lambda self, avatar_bytes, **kwargs: b"processed-avatar",
)
guest = Guest.objects.create(
first_name="Existing",
last_name="Avatar",
email="guest@example.test",
phone="+70000000000",
)
guest.avatar.save("guest.jpg", ContentFile(b"raw-avatar"), save=True)
ldap_guest = LdapGuestUser()
ldap_guest.apply_guest(guest)
assert ldap_guest.employee_number == str(guest.id)
assert ldap_guest.thumbnail_photo == b"processed-avatar"
assert ldap_guest.user_account_control == UserAccountControl.DISABLED
@pytest.mark.django_db
def test_guest_ldap_service_disables_and_moves_to_deactivated(
settings,
@ -149,8 +291,8 @@ def test_guest_ldap_service_disables_and_moves_to_deactivated(
guest = Guest.objects.create(
first_name="Disable",
last_name="Guest",
is_active=False,
ldap_enabled=True,
is_active=True,
is_blacklisted=True,
ldap_username="g900000000000001",
)
active_dn = (
@ -164,10 +306,6 @@ def test_guest_ldap_service_disables_and_moves_to_deactivated(
fake_orm_guest = FakeLdapGuestUser(active_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.ensure_container_exists",
lambda *a, **k: None,
)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
@ -180,9 +318,13 @@ def test_guest_ldap_service_disables_and_moves_to_deactivated(
assert state.ldap_dn == (
f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
)
assert guest.ldap_enabled is False
assert guest.is_active is False
assert guest.is_blacklisted is True
assert fake_orm_guest.moves == ["OU=Deactivated,OU=Guests,DC=example,DC=test"]
assert fake_orm_guest.saved is True
assert "when_changed" not in fake_orm_guest.update_fields
assert "when_created" not in fake_orm_guest.update_fields
assert "member_of" not in fake_orm_guest.update_fields
assert fake_orm_guest.employee_number == str(guest.id)
assert fake_orm_guest.user_account_control == UserAccountControl.DISABLED
@ -226,10 +368,6 @@ def test_guest_ldap_service_moves_existing_guest_to_active_ou_without_enabling(
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.ensure_container_exists",
lambda *a, **k: None,
)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
@ -244,7 +382,357 @@ def test_guest_ldap_service_moves_existing_guest_to_active_ou_without_enabling(
assert fake_orm_guest.user_account_control == UserAccountControl.DISABLED
assert fake_conn.modify_calls == []
assert state.ldap_dn == f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
assert guest.ldap_enabled is True
assert guest.is_active is True
@pytest.mark.django_db
def test_guest_ldap_service_adopts_existing_guest_account_when_state_is_missing(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Adopt", last_name="Guest")
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
fake_conn = ExistingGuestEmployeeNumberConnection(deactivated_dn)
@contextmanager
def fake_ldap():
yield fake_conn
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Adopt existing LDAP account",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
assert not [
call
for call in fake_conn.add_calls
if "user" in [item.lower() for item in call[1]]
]
@pytest.mark.django_db
def test_guest_ldap_service_prefers_real_employee_number_dn_over_stale_state(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Stale", last_name="State")
stale_active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
real_deactivated_dn = (
f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
)
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=stale_active_dn,
)
LdapSyncQueue.objects.create(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
payload={"guest_id": str(guest.id), "_operation": "guest_sync"},
)
fake_conn = ExistingGuestEmployeeNumberConnection(real_deactivated_dn)
@contextmanager
def fake_ldap():
yield fake_conn
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Real LDAP DN wins",
status=GuestVisitStatus.REJECTED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(real_deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == real_deactivated_dn
assert guest.is_active is False
assert guest.ldap_last_error == ""
assert fake_orm_guest.moves == []
assert fake_orm_guest.saved is True
assert not LdapSyncQueue.objects.filter(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
status=LdapSyncQueue.Status.PENDING,
).exists()
@pytest.mark.django_db
def test_guest_ldap_service_recreates_account_when_state_dn_is_missing(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Missing", last_name="Ldap")
stale_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=stale_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Recreate missing LDAP account",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: (_ for _ in ()).throw(LdapGuestUser.DoesNotExist()),
)
monkeypatch.setattr(
"guests.ldap.orm_models.UserPasswordService.set_password",
lambda *a, **k: None,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
attrs = get_guest_user_add(fake_conn)[2]
assert attrs["employeeNumber"] == str(guest.id)
assert state.ldap_dn == f"CN=Ldap Missing,OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
assert guest.ldap_last_error == ""
@pytest.mark.django_db
def test_guest_ldap_service_adopts_guest_account_after_create_conflict(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_BASE = "OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
inviter = user_factory()
guest = Guest.objects.create(first_name="Conflict", last_name="Guest")
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
fake_conn = ExistingGuestEmployeeNumberConnection(deactivated_dn)
@contextmanager
def fake_ldap():
yield fake_conn
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Adopt after conflict",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.find_existing_dn_for_guest",
lambda synced_guest: "",
)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
state = LdapSyncState.objects.get(model="guest", object_pk=str(guest.id))
assert state.ldap_dn == f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
assert guest.is_active is True
assert guest.ldap_last_error == ""
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
@pytest.mark.django_db
def test_blacklisted_guest_stays_in_deactivated_ou_even_with_active_visit(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
inviter = user_factory()
guest = Guest.objects.create(
first_name="Blocked",
last_name="Guest",
is_active=True,
is_blacklisted=True,
)
active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=active_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Blocked active visit",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(active_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().sync_guest(guest)
guest.refresh_from_db()
assert fake_orm_guest.moves == ["OU=Deactivated,OU=Guests,DC=example,DC=test"]
assert guest.is_active is False
assert guest.is_blacklisted is True
@pytest.mark.django_db
def test_unblacklist_guest_allows_active_ou_when_active_visit_exists(
settings,
monkeypatch,
user_factory,
):
settings.LDAP_ENABLED = True
settings.LDAP_WRITE_ENABLED = True
settings.LDAP_GUESTS_ACTIVE_BASE = "OU=Active,OU=Guests,DC=example,DC=test"
settings.LDAP_GUESTS_DEACTIVATED_BASE = (
"OU=Deactivated,OU=Guests,DC=example,DC=test"
)
settings.LDAP_BASE_DN = "DC=example,DC=test"
fake_conn = FakeLdapConnection()
@contextmanager
def fake_ldap():
yield fake_conn
inviter = user_factory()
guest = Guest.objects.create(
first_name="Restored",
last_name="Guest",
is_blacklisted=True,
)
deactivated_dn = f"CN=g{guest.id},OU=Deactivated,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
model="guest",
object_pk=str(guest.id),
ldap_dn=deactivated_dn,
)
GuestVisit.objects.create(
guest=guest,
inviter=inviter,
purpose="Restore access",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(minutes=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
fake_orm_guest = FakeLdapGuestUser(deactivated_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
)
GuestLdapService().unblacklist_guest(guest)
guest.refresh_from_db()
assert fake_orm_guest.moves == ["OU=Active,OU=Guests,DC=example,DC=test"]
assert guest.is_active is True
assert guest.is_blacklisted is False
@pytest.mark.django_db
@ -271,7 +759,7 @@ def test_guest_ldap_service_keeps_active_ou_when_other_active_visit_exists(
guest = Guest.objects.create(
first_name="Multi",
last_name="Visit",
ldap_enabled=True,
is_active=True,
)
active_dn = f"CN=g{guest.id},OU=Active,OU=Guests,DC=example,DC=test"
LdapSyncState.objects.create(
@ -298,10 +786,6 @@ def test_guest_ldap_service_keeps_active_ou_when_other_active_visit_exists(
fake_orm_guest = FakeLdapGuestUser(active_dn)
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.ensure_container_exists",
lambda *a, **k: None,
)
monkeypatch.setattr(
"guests.ldap.orm_models.LdapGuestUser.objects.get",
lambda **kwargs: fake_orm_guest,
@ -311,7 +795,7 @@ def test_guest_ldap_service_keeps_active_ou_when_other_active_visit_exists(
guest.refresh_from_db()
assert fake_orm_guest.moves == []
assert guest.ldap_enabled is True
assert guest.is_active is True
@pytest.mark.django_db
@ -333,6 +817,26 @@ def test_execute_guest_queue_operation_dispatches_sync(monkeypatch):
assert called["raise_on_error"] is True
@pytest.mark.django_db
def test_enqueue_guest_sync_reuses_pending_item():
guest = Guest.objects.create(first_name="Queue", last_name="Dedupe")
GuestLdapService.enqueue_guest_sync(guest)
GuestLdapService.enqueue_guest_sync(guest)
pending = LdapSyncQueue.objects.filter(
operation="guest_sync",
model_name="guest",
object_pk=str(guest.id),
status=LdapSyncQueue.Status.PENDING,
)
assert pending.count() == 1
assert pending.get().payload == {
"guest_id": str(guest.id),
"_operation": "guest_sync",
}
@pytest.mark.django_db
def test_process_ldap_queue_item_completes_guest_sync(
settings,
@ -409,10 +913,6 @@ def test_guest_ldap_employee_number_conflict_queues_retry(
yield conflict_conn
monkeypatch.setattr("guests.ldap.orm_models._ldap", fake_ldap)
monkeypatch.setattr(
"guests.ldap.orm_models.ensure_container_exists",
lambda *a, **k: None,
)
inviter = user_factory()
admin = user_factory(staff=True)
guest = Guest.objects.create(first_name="Conflict", last_name="Guest")

View File

@ -129,10 +129,14 @@ def test_expire_guest_visit_records_event_and_disables_guest(settings, user_fact
@pytest.mark.django_db
def test_detect_inactive_inviters_flags_risk_without_auto_disable(user_factory):
def test_detect_inactive_inviters_revokes_visit_and_disables_access(
settings,
user_factory,
):
settings.LDAP_ENABLED = False
admin = user_factory(staff=True)
inviter = user_factory(active=False)
guest = Guest.objects.create(first_name="Risk", last_name="Guest")
guest = Guest.objects.create(first_name="Risk", last_name="Guest", is_active=True)
visit = GuestVisit.objects.create(
guest=guest,
inviter=inviter,
@ -148,8 +152,52 @@ def test_detect_inactive_inviters_flags_risk_without_auto_disable(user_factory):
guest.refresh_from_db()
assert admin.is_staff
assert visit.inviter_inactive is True
assert guest.ldap_enabled is False
assert visit.status == GuestVisitStatus.REVOKED
assert visit.revoked_at is not None
assert "Приглашающий неактивен" in visit.revoke_reason
assert guest.is_active is False
assert guest.is_blacklisted is False
assert visit.events.filter(event_type="inviter_inactive_detected").exists()
assert visit.events.filter(event_type="revoked").exists()
assert visit.events.filter(event_type="ldap_skipped").exists()
@pytest.mark.django_db
def test_inactive_inviter_revoke_keeps_access_when_other_active_visit_exists(
settings,
user_factory,
):
settings.LDAP_ENABLED = False
inactive_inviter = user_factory(active=False)
active_inviter = user_factory(active=True)
guest = Guest.objects.create(first_name="Shared", last_name="Guest")
inactive_visit = GuestVisit.objects.create(
guest=guest,
inviter=inactive_inviter,
purpose="Inactive inviter access",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(days=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
active_visit = GuestVisit.objects.create(
guest=guest,
inviter=active_inviter,
purpose="Active inviter access",
status=GuestVisitStatus.APPROVED,
access_starts_at=timezone.now() - timedelta(days=1),
access_expires_at=timezone.now() + timedelta(days=1),
)
detect_inactive_inviters.run()
inactive_visit.refresh_from_db()
active_visit.refresh_from_db()
guest.refresh_from_db()
assert inactive_visit.status == GuestVisitStatus.REVOKED
assert inactive_visit.inviter_inactive is True
assert active_visit.status == GuestVisitStatus.APPROVED
assert active_visit.inviter_inactive is False
assert guest.is_active is True
@pytest.mark.django_db

View File

@ -356,6 +356,15 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
white-space: normal;
}
.guest-status-pill {
min-height: 2rem;
flex-shrink: 0;
padding: 0.375rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.15;
}
.app-avatar-frame {
border: 1px solid var(--border-subtle);
background: var(--surface-secondary);

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,9 @@
"use client";
import { Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, ShoppingCart, Users } from "lucide-react";
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
import { ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import { useUser } from "@/contexts/UserContext";
import { useNotifications } from "@/hooks/useApi";
@ -14,6 +14,9 @@ import { useCalendarModals } from "@/hooks/useCalendarModals";
import { CalendarModals } from "@/components/layout/CalendarModals";
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
import { usePwa } from "@/contexts/PwaContext";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type { GuestVisit, GuestVisitComment } from "@/types/api";
type AppShellProps = {
children: ReactNode;
@ -37,6 +40,12 @@ type LeftNavContentProps = {
onNavigate?: () => void;
};
type PaginatedResponse<T> = {
count?: number;
next?: string | null;
results?: T[];
};
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon, category: "Новости" },
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" },
@ -44,6 +53,7 @@ const navItems = [
{ href: "/departments", label: "Отделы", icon: Building2 },
{ href: "/attendance", label: "Посещаемость", icon: CalendarCheck },
{ href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" },
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: "Гости" },
// { href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки", autoReadOnNavigate: false },
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
@ -403,6 +413,151 @@ export function PageHeader({ title, subtitle, badge, eyebrow }: PageHeaderProps
);
}
const toResults = <T,>(payload: PaginatedResponse<T> | T[]): T[] => {
if (Array.isArray(payload)) return payload;
return payload.results || [];
};
const guestDisplayName = (visit: GuestVisit): string => (
visit.guest.full_name
|| [visit.guest.last_name, visit.guest.first_name, visit.guest.patronymic].filter(Boolean).join(" ")
|| `Гость #${visit.guest.id}`
);
const latestInfoRequestText = (visit: GuestVisit, comments: GuestVisitComment[]): string => {
const requestComment = [...comments]
.reverse()
.find((comment) => comment.metadata?.guest_visit_comment_type === "info_request");
if (requestComment?.text) return requestComment.text;
const requestEvent = [...(visit.events || [])]
.reverse()
.find((event) => event.event_type === "needs_info_requested" && event.comment);
return requestEvent?.comment || "Администратор запросил дополнительную информацию по гостевой заявке.";
};
function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
const [visit, setVisit] = useState<GuestVisit | null>(null);
const [comments, setComments] = useState<GuestVisitComment[]>([]);
const [answer, setAnswer] = useState("");
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadPendingRequest = useCallback(async () => {
if (!userId) return;
try {
setLoading(true);
const response = await apiClient.getGuestVisits({
scope: "mine",
status: "needs_info",
ordering: "-updated_at",
page: 1,
limit: 1,
}) as PaginatedResponse<GuestVisit> | GuestVisit[];
const pendingVisit = toResults(response)[0] || null;
setVisit(pendingVisit);
if (!pendingVisit) {
setComments([]);
setAnswer("");
return;
}
const commentResponse = await apiClient.getGuestVisitComments(pendingVisit.id) as GuestVisitComment[];
setComments(commentResponse);
} catch {
setVisit(null);
setComments([]);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
if (!userId) return;
void loadPendingRequest();
const interval = window.setInterval(() => void loadPendingRequest(), 30000);
const handleFocus = () => void loadPendingRequest();
const handleVisibility = () => {
if (document.visibilityState === "visible") void loadPendingRequest();
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
window.clearInterval(interval);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [loadPendingRequest, userId]);
const question = useMemo(
() => visit ? latestInfoRequestText(visit, comments) : "",
[comments, visit],
);
const submitAnswer = async () => {
if (!visit || !answer.trim()) return;
try {
setSubmitting(true);
setError(null);
await apiClient.provideGuestVisitInfo(visit.id, { comment: answer.trim() });
setAnswer("");
await loadPendingRequest();
} catch (err) {
setError(String((err as Error)?.message || "Не удалось отправить ответ"));
} finally {
setSubmitting(false);
}
};
return (
<Modal
isOpen={Boolean(visit)}
onClose={() => undefined}
title="Требуется информация"
size="md"
showCloseButton={false}
closeOnEsc={false}
closeOnClickOutside={false}
footer={
<div className="flex justify-end">
<button
type="button"
onClick={() => void submitAnswer()}
disabled={submitting || !answer.trim()}
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60"
>
{submitting ? <Loader2 className="animate-spin" size={15} /> : <Send size={15} />}
Отправить ответ
</button>
</div>
}
>
<div className="space-y-4">
<div className="app-feedback-warning flex items-start gap-3 rounded-xl p-4 text-sm">
<AlertTriangle className="mt-0.5 shrink-0" size={18} />
<div className="min-w-0">
<p className="font-semibold text-[var(--foreground)]">{visit ? guestDisplayName(visit) : "Гостевая заявка"}</p>
<p className="mt-1 whitespace-pre-wrap">{loading ? "Загружаем запрос..." : question}</p>
</div>
</div>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Ответ *</span>
<textarea
value={answer}
onChange={(event) => setAnswer(event.target.value)}
rows={5}
className="app-input w-full resize-none rounded-lg p-3 text-sm"
placeholder="Напишите уточнение для администратора"
/>
</label>
{error ? <p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{error}</p> : null}
</div>
</Modal>
);
}
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const { mobileNavPlacement } = useMobileNavPlacement();
@ -529,6 +684,7 @@ export function AppShell({ children }: AppShellProps) {
/>
<CalendarModals {...cal} />
<GuestInfoRequestPrompt userId={user.id} />
</div>
);
}

View File

@ -3,6 +3,33 @@
import { ChevronDown } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
type DropdownPosition = {
left: number;
maxHeight: number;
top: number;
width: number;
};
function getDropdownPosition(anchor: HTMLElement): DropdownPosition {
const rect = anchor.getBoundingClientRect();
const gap = 4;
const viewportPadding = 8;
const preferredHeight = 224;
const belowSpace = window.innerHeight - rect.bottom - viewportPadding;
const aboveSpace = rect.top - viewportPadding;
const openUp = belowSpace < 180 && aboveSpace > belowSpace;
const availableHeight = Math.max(120, openUp ? aboveSpace : belowSpace);
const maxHeight = Math.min(preferredHeight, availableHeight);
return {
left: rect.left,
maxHeight,
top: openUp ? rect.top - maxHeight - gap : rect.bottom + gap,
width: rect.width,
};
}
/* ── Single-select dropdown with search ── */
export function SearchableSelectSingle<T extends string | number>({
@ -22,18 +49,45 @@ export function SearchableSelectSingle<T extends string | number>({
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
if (!open) return;
const updatePosition = () => {
if (ref.current) {
setDropdownPosition(getDropdownPosition(ref.current));
}
};
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open]);
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedName = items.find((i) => i.id === selectedId)?.name;
const toggleOpen = () => {
if (!open && ref.current) {
setDropdownPosition(getDropdownPosition(ref.current));
}
setOpen((value) => !value);
};
return (
<div ref={ref} className="relative">
@ -41,7 +95,7 @@ export function SearchableSelectSingle<T extends string | number>({
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
onClick={toggleOpen}
className="app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm disabled:cursor-not-allowed disabled:opacity-60"
>
<span className="truncate">
@ -49,8 +103,17 @@ export function SearchableSelectSingle<T extends string | number>({
</span>
<ChevronDown size={14} className={`app-text-muted ml-2 shrink-0 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="app-menu absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg">
{open && dropdownPosition && createPortal(
<div
ref={menuRef}
className="app-menu fixed z-[130] overflow-hidden rounded-lg"
style={{
left: dropdownPosition.left,
maxHeight: dropdownPosition.maxHeight,
top: dropdownPosition.top,
width: dropdownPosition.width,
}}
>
<div className="app-divider border-b p-2">
<input
value={q}
@ -60,7 +123,10 @@ export function SearchableSelectSingle<T extends string | number>({
autoFocus
/>
</div>
<div className="max-h-40 overflow-y-auto p-1">
<div
className="overflow-y-auto p-1"
style={{ maxHeight: Math.max(72, dropdownPosition.maxHeight - 54) }}
>
{selectedId !== null && (
<button
type="button"
@ -85,7 +151,8 @@ export function SearchableSelectSingle<T extends string | number>({
))
)}
</div>
</div>
</div>,
document.body,
)}
</div>
);
@ -113,27 +180,54 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
if (!open) return;
const updatePosition = () => {
if (ref.current) {
setDropdownPosition(getDropdownPosition(ref.current));
}
};
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open]);
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedItems = items.filter((i) => selectedIds.includes(i.id));
const selectedNames = selectedItems.map((i) => i.name);
const isInline = layout === "inline";
const toggleOpen = () => {
if (!open && ref.current) {
setDropdownPosition(getDropdownPosition(ref.current));
}
setOpen((value) => !value);
};
return (
<div ref={ref} className={`relative ${className}`}>
{!isInline && <label className="app-text-muted mb-1 block text-xs font-medium">{label}</label>}
<button
type="button"
onClick={() => setOpen((v) => !v)}
onClick={toggleOpen}
className={isInline
? "app-input flex w-full items-start gap-3 rounded-2xl px-3 py-3 text-left text-sm"
: "app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm"}
@ -162,8 +256,17 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
)}
<ChevronDown size={14} className={`app-text-muted ${isInline ? "mt-1" : "ml-2"} shrink-0 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className={`app-menu absolute z-50 mt-1 max-h-56 overflow-hidden rounded-lg ${isInline ? "inset-x-0" : "w-full"}`}>
{open && dropdownPosition && createPortal(
<div
ref={menuRef}
className="app-menu fixed z-[130] overflow-hidden rounded-lg"
style={{
left: dropdownPosition.left,
maxHeight: dropdownPosition.maxHeight,
top: dropdownPosition.top,
width: dropdownPosition.width,
}}
>
<div className="app-divider border-b p-2">
<input
value={q}
@ -173,7 +276,10 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
autoFocus
/>
</div>
<div className="max-h-40 overflow-y-auto p-1">
<div
className="overflow-y-auto p-1"
style={{ maxHeight: Math.max(72, dropdownPosition.maxHeight - 54) }}
>
{filtered.length === 0 ? (
<p className="app-text-muted px-2 py-1.5 text-xs">Ничего не найдено</p>
) : (
@ -190,7 +296,8 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
))
)}
</div>
</div>
</div>,
document.body,
)}
</div>
);

View File

@ -3,6 +3,8 @@
import { useEffect, useRef, type ReactNode } from "react";
import { X } from "lucide-react";
let openModalCount = 0;
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
@ -38,14 +40,16 @@ export function Modal({
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
if (!isOpen) return;
openModalCount += 1;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
openModalCount = Math.max(0, openModalCount - 1);
if (openModalCount === 0) {
document.body.style.overflow = "";
}
};
}, [isOpen]);

View File

@ -0,0 +1,137 @@
import { buildQuery, type RequestFn } from './utils';
export type GuestVisitActionPayload = {
comment?: string;
};
export type GuestDocumentMutationResponse = {
guest: unknown;
document: unknown;
};
export function createGuestsApi(request: RequestFn) {
return {
getGuestVisits: (params?: Record<string, string | number | boolean | undefined | null>) =>
request(`/api/v1/guests/visits/${buildQuery(params)}`),
getGuestVisit: (id: number) =>
request(`/api/v1/guests/visits/${id}/`),
createGuestVisit: (data: Record<string, unknown>) =>
request('/api/v1/guests/visits/', {
method: 'POST',
body: JSON.stringify(data),
}),
updateGuestVisit: (id: number, data: Record<string, unknown>) =>
request(`/api/v1/guests/visits/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteGuestVisit: (id: number): Promise<void> =>
request(`/api/v1/guests/visits/${id}/`, {
method: 'DELETE',
}),
submitGuestVisit: (id: number) =>
request(`/api/v1/guests/visits/${id}/submit/`, { method: 'POST' }),
approveGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/approve/`, {
method: 'POST',
body: JSON.stringify({ comment: payload?.comment || '' }),
}),
rejectGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/reject/`, {
method: 'POST',
body: JSON.stringify({ comment: payload?.comment || '' }),
}),
requestGuestVisitInfo: (id: number, payload: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/request-info/`, {
method: 'POST',
body: JSON.stringify({ comment: payload.comment || '' }),
}),
provideGuestVisitInfo: (id: number, payload: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/provide-info/`, {
method: 'POST',
body: JSON.stringify({ comment: payload.comment || '' }),
}),
cancelGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/cancel/`, {
method: 'POST',
body: JSON.stringify({ comment: payload?.comment || '' }),
}),
revokeGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/revoke/`, {
method: 'POST',
body: JSON.stringify({ comment: payload?.comment || '' }),
}),
returnGuestVisitToWork: (id: number, payload?: GuestVisitActionPayload) =>
request(`/api/v1/guests/visits/${id}/return-to-work/`, {
method: 'POST',
body: JSON.stringify({ comment: payload?.comment || '' }),
}),
syncGuestVisitLdap: (id: number) =>
request(`/api/v1/guests/visits/${id}/sync-ldap/`, { method: 'POST' }),
getGuestVisitComments: (visitId: number) =>
request(`/api/v1/guests/visits/${visitId}/comments/`),
addGuestVisitComment: (visitId: number, text: string) =>
request(`/api/v1/guests/visits/${visitId}/comments/`, {
method: 'POST',
body: JSON.stringify({ text }),
}),
deleteGuestVisitComment: (visitId: number, commentId: number): Promise<void> =>
request(`/api/v1/guests/visits/${visitId}/comments/${commentId}/`, {
method: 'DELETE',
}),
attachGuestVisitDocument: (visitId: number, documentId: number) =>
request(`/api/v1/guests/visits/${visitId}/documents/`, {
method: 'POST',
body: JSON.stringify({ document_id: documentId }),
}),
removeGuestVisitDocument: (visitId: number, documentId: number) =>
request(`/api/v1/guests/visits/${visitId}/documents/${documentId}/`, {
method: 'DELETE',
}),
getGuests: (params?: Record<string, string | number | boolean | undefined | null>) =>
request(`/api/v1/guests/${buildQuery(params)}`),
getGuest: (id: number | string) =>
request(`/api/v1/guests/${id}/`),
updateGuest: (id: number | string, data: Record<string, unknown>) =>
request(`/api/v1/guests/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
getGuestComments: (id: number | string) =>
request(`/api/v1/guests/${id}/comments/`),
addGuestComment: (id: number | string, text: string) =>
request(`/api/v1/guests/${id}/comments/`, {
method: 'POST',
body: JSON.stringify({ text }),
}),
deleteGuestComment: (id: number | string, commentId: number): Promise<void> =>
request(`/api/v1/guests/${id}/comments/${commentId}/`, {
method: 'DELETE',
}),
attachGuestDocument: (id: number | string, documentId: number) =>
request(`/api/v1/guests/${id}/documents/`, {
method: 'POST',
body: JSON.stringify({ document_id: documentId }),
}),
uploadGuestDocument: (id: number | string, data: { file: File; title?: string; description?: string }) => {
const fd = new FormData();
fd.append('file', data.file);
if (data.title?.trim()) fd.append('title', data.title.trim());
if (data.description?.trim()) fd.append('description', data.description.trim());
return request(`/api/v1/guests/${id}/documents/`, { method: 'POST', body: fd });
},
removeGuestDocument: (id: number | string, documentId: number) =>
request(`/api/v1/guests/${id}/documents/${documentId}/`, {
method: 'DELETE',
}),
searchGuests: (params?: Record<string, string | number | boolean | undefined | null>) =>
request(`/api/v1/guests/search/${buildQuery(params)}`),
blacklistGuest: (id: number | string) =>
request(`/api/v1/guests/${id}/blacklist/`, { method: 'POST' }),
unblacklistGuest: (id: number | string) =>
request(`/api/v1/guests/${id}/unblacklist/`, { method: 'POST' }),
syncGuestLdap: (id: number | string) =>
request(`/api/v1/guests/${id}/sync-ldap/`, { method: 'POST' }),
};
}

View File

@ -14,6 +14,7 @@ import { createRequestsApi } from './requests';
import { createEquipmentApi } from './equipment';
import { createProcurementApi } from './procurement';
import { createAttendanceApi } from './attendance';
import { createGuestsApi } from './guests';
const base = new ApiClientBase();
const req = base.request.bind(base);
@ -32,6 +33,7 @@ export const apiClient = Object.assign(base, {
...createEquipmentApi(req, tok),
...createProcurementApi(req),
...createAttendanceApi(req, tok),
...createGuestsApi(req),
});
export default apiClient;

View File

@ -48,6 +48,7 @@ export function resolveNotificationActionUrl(
: null;
const requestId = parseNumericId(data?.request_id);
const visitId = parseNumericId(data?.visit_id);
const documentId = parseNumericId(data?.document_id);
const eventId = parseNumericId(data?.event_id);
const postId = parseNumericId(data?.post_id);
@ -63,6 +64,15 @@ export function resolveNotificationActionUrl(
objectType,
].join(" ").toLowerCase();
if (
visitId !== null && (
categoryHint.includes("guest")
|| objectType === "guestvisit"
)
) {
return buildUrl("/guests", { visit: visitId });
}
if (requestId !== null && categoryHint.includes("procurement")) {
return buildUrl("/procurement", { request: requestId });
}
@ -141,6 +151,13 @@ export function resolveNotificationActionUrl(
return buildUrl("/requests", { request: rawLinkedRequestId });
}
if (url.pathname === "/guests") {
const rawLinkedVisitId = parseNumericId(
url.searchParams.get("visit") ?? url.searchParams.get("id"),
);
return buildUrl("/guests", { visit: rawLinkedVisitId });
}
if (url.pathname === "/documents") {
const rawLinkedDocumentId = parseNumericId(
url.searchParams.get("document") ?? url.searchParams.get("id"),
@ -175,6 +192,11 @@ export function resolveNotificationActionUrl(
return buildUrl("/requests", { request: Number(requestMatch[1]) });
}
const guestVisitMatch = rawActionUrl.match(/^\/guests\/visits\/(\d+)\/?(?:[#?].*)?$/);
if (guestVisitMatch) {
return buildUrl("/guests", { visit: Number(guestVisitMatch[1]) });
}
const documentMatch = rawActionUrl.match(/^\/documents\/(\d+)\/?(?:[#?].*)?$/);
if (documentMatch) {
return buildUrl("/documents", { document: Number(documentMatch[1]) });

View File

@ -107,6 +107,43 @@ export function canViewRequestStatistics(user?: User | null): boolean {
);
}
export function canViewAllGuestVisits(user?: User | null): boolean {
if (!user?.auth) return false;
if (user.auth.is_staff || user.auth.is_superuser) return true;
const perms = user.auth.permissions || [];
const guestPerms = user.auth.permissions_by_app?.["guests"] || [];
return (
perms.includes("guests.view_all_guestvisit") ||
perms.includes("guests.decide_guestvisit") ||
perms.includes("guests.manage_guestaccount") ||
guestPerms.includes("view_all_guestvisit") ||
guestPerms.includes("decide_guestvisit") ||
guestPerms.includes("manage_guestaccount")
);
}
export function canDecideGuestVisits(user?: User | null): boolean {
if (!user?.auth) return false;
if (user.auth.is_staff || user.auth.is_superuser) return true;
const perms = user.auth.permissions || [];
const guestPerms = user.auth.permissions_by_app?.["guests"] || [];
return (
perms.includes("guests.decide_guestvisit") ||
guestPerms.includes("decide_guestvisit")
);
}
export function canManageGuestAccounts(user?: User | null): boolean {
if (!user?.auth) return false;
if (user.auth.is_staff || user.auth.is_superuser) return true;
const perms = user.auth.permissions || [];
const guestPerms = user.auth.permissions_by_app?.["guests"] || [];
return (
perms.includes("guests.manage_guestaccount") ||
guestPerms.includes("manage_guestaccount")
);
}
/**
* Проверяет, может ли пользователь управлять оборудованием.
*

View File

@ -16,6 +16,17 @@ export const VERB_CATEGORIES: Record<string, string> = {
request_comment: 'Заявления',
request_commented: 'Заявления',
request_status_changed: 'Заявления',
// Гости
guest_visit_submitted: 'Гости',
guest_visit_info_provided: 'Гости',
guest_visit_needs_info: 'Гости',
guest_visit_approved: 'Гости',
guest_visit_rejected: 'Гости',
guest_visit_revoked: 'Гости',
guest_visit_expired: 'Гости',
guest_inviter_inactive: 'Гости',
guest_ldap_failed: 'Гости',
// Сообщения и чат
chat_new_message: 'Сообщения',
@ -111,6 +122,17 @@ export const VERB_NAMES: Record<string, string> = {
request_comment: 'Комментарий к заявлению',
request_commented: 'Комментарий к заявлению',
request_status_changed: 'Статус заявления изменён',
// Гости
guest_visit_submitted: 'Гостевой визит на рассмотрении',
guest_visit_info_provided: 'Информация по гостевому визиту предоставлена',
guest_visit_needs_info: 'Нужна информация по гостевому визиту',
guest_visit_approved: 'Гостевой визит одобрен',
guest_visit_rejected: 'Гостевой визит отклонён',
guest_visit_revoked: 'Гостевой доступ отозван',
guest_visit_expired: 'Гостевой доступ истёк',
guest_inviter_inactive: 'Приглашающий гостя неактивен',
guest_ldap_failed: 'Ошибка синхронизации гостя',
// Сообщения и чат
chat_new_message: 'Новое сообщение',
@ -211,6 +233,8 @@ const VERB_TOKEN_NAMES: Record<string, string> = {
employee: 'сотрудник',
event: 'событие',
feed: 'лента',
guest: 'гость',
guests: 'гости',
hour: 'за час',
item: 'позиция',
maintenance: 'обслуживание',
@ -238,6 +262,7 @@ const VERB_TOKEN_NAMES: Record<string, string> = {
*/
export function getVerbCategory(verb: string): string {
if (VERB_CATEGORIES[verb]) return VERB_CATEGORIES[verb];
if (verb.startsWith('guest_')) return 'Гости';
if (verb.startsWith('procurement_') || verb.startsWith('equipment_')) return 'Закупки';
return 'Общее';
}

View File

@ -312,6 +312,127 @@ export interface Document {
is_acknowledged?: boolean;
}
// Guests
export type GuestVisitStatus =
| "draft"
| "pending"
| "needs_info"
| "approved"
| "rejected"
| "cancelled"
| "expired"
| "revoked";
export interface Guest {
id: number;
full_name?: string;
last_name: string;
first_name: string;
patronymic?: string;
birth_date?: string | null;
phone?: string;
email?: string;
avatar?: string;
organization?: string;
position?: string;
comments_count?: number;
visits_count?: number;
document_folder?: {
id: number;
name: string;
} | null;
documents?: Document[];
created_by?: User | null;
is_active: boolean;
is_blacklisted: boolean;
ldap_username?: string;
ldap_upn?: string;
ldap_last_synced_at?: string | null;
ldap_last_error?: string;
created_at?: string;
updated_at?: string;
}
export interface GuestVisitEvent {
id: number;
event_type: string;
actor?: User | null;
from_status?: string;
to_status?: string;
comment?: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface GuestVisit {
id: number;
guest: Guest;
inviter: User;
inviter_snapshot_name?: string;
inviter_snapshot_email?: string;
host_department?: Department | null;
purpose: string;
visit_comment?: string;
status: GuestVisitStatus;
status_display?: string;
access_starts_at?: string | null;
access_expires_at?: string | null;
all_day: boolean;
unlimited: boolean;
documents: Document[];
submitted_at?: string | null;
decided_by?: User | null;
decided_at?: string | null;
decision_comment?: string;
cancelled_at?: string | null;
cancel_reason?: string;
revoked_at?: string | null;
revoke_reason?: string;
expired_at?: string | null;
inviter_inactive: boolean;
created_at: string;
updated_at: string;
is_active_now: boolean;
is_expired: boolean;
can_edit: boolean;
can_submit: boolean;
can_decide: boolean;
can_approve: boolean;
can_reject: boolean;
can_request_info: boolean;
can_cancel: boolean;
can_revoke: boolean;
can_return_to_work: boolean;
can_sync_ldap: boolean;
can_delete: boolean;
comments_count: number;
has_unread_info_response: boolean;
events: GuestVisitEvent[];
}
export interface GuestVisitComment {
id: number;
author: User;
text: string;
metadata?: Record<string, unknown>;
created_at: string;
}
export interface GuestVisitFormPayload {
guest_id?: number;
guest?: Partial<Guest>;
guest_comment?: string;
purpose?: string;
visit_comment?: string;
all_day?: boolean;
unlimited?: boolean;
access_starts_at?: string | null;
access_expires_at?: string | null;
date_from?: string;
date_to?: string;
document_ids?: number[];
}
// Document Comments
export interface DocumentComment {
id: number;