From cd3e52e1e961f6d5e16bf8969738b7782181aebc Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 22 Jun 2026 15:01:28 +0300 Subject: [PATCH] feat: complete guest module --- backend/api/v1/documents/views.py | 24 + backend/api/v1/guests/permissions.py | 25 +- backend/api/v1/guests/serializers.py | 210 +- backend/api/v1/guests/views.py | 295 +- ...039_alter_chat_context_object_id_bigint.py | 22 + backend/communications/models.py | 2 +- backend/eusrr_backend/db_routers.py | 1 + backend/eusrr_backend/settings.py | 2 +- backend/guests/admin.py | 5 +- backend/guests/ldap/orm_models.py | 196 +- ...st_avatar_ldapguestuser_thumbnail_photo.py | 31 + ...004_guest_blacklist_remove_ldap_enabled.py | 56 + .../0005_guest_last_name_optional.py | 16 + .../0006_guest_document_folder_documents.py | 36 + .../0007_remove_guestvisit_admin_comment.py | 17 + ...migrate_guest_comment_to_communications.py | 101 + backend/guests/models.py | 90 +- backend/guests/services.py | 427 +- backend/guests/tasks.py | 35 +- backend/guests/тз.txt | 2 +- .../api/v1/guests/test_guest_visits_api.py | 1072 ++++- backend/tests/guests/test_guest_ldap.py | 560 ++- backend/tests/guests/test_guest_models.py | 54 +- frontend/src/app/globals.css | 9 + frontend/src/app/guests/page.tsx | 4037 +++++++++++++++++ frontend/src/components/AppShell.tsx | 160 +- .../components/shared/SearchableSelect.tsx | 131 +- frontend/src/components/ui/Modal.tsx | 16 +- frontend/src/lib/api/guests.ts | 137 + frontend/src/lib/api/index.ts | 2 + frontend/src/lib/notifications/actionUrl.ts | 22 + frontend/src/lib/permissions.ts | 37 + frontend/src/lib/verbTranslations.ts | 25 + frontend/src/types/api.ts | 121 + 34 files changed, 7760 insertions(+), 216 deletions(-) create mode 100644 backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py create mode 100644 backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py create mode 100644 backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py create mode 100644 backend/guests/migrations/0005_guest_last_name_optional.py create mode 100644 backend/guests/migrations/0006_guest_document_folder_documents.py create mode 100644 backend/guests/migrations/0007_remove_guestvisit_admin_comment.py create mode 100644 backend/guests/migrations/0008_migrate_guest_comment_to_communications.py create mode 100644 frontend/src/app/guests/page.tsx create mode 100644 frontend/src/lib/api/guests.ts diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index f39d18b9..a1a07a34 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -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: diff --git a/backend/api/v1/guests/permissions.py b/backend/api/v1/guests/permissions.py index edd805c9..d327defa 100644 --- a/backend/api/v1/guests/permissions.py +++ b/backend/api/v1/guests/permissions.py @@ -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) - diff --git a/backend/api/v1/guests/serializers.py b/backend/api/v1/guests/serializers.py index 9def6b2c..62c554a4 100644 --- a/backend/api/v1/guests/serializers.py +++ b/backend/api/v1/guests/serializers.py @@ -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 diff --git a/backend/api/v1/guests/views.py b/backend/api/v1/guests/views.py index 56b49555..2cc3aa90 100644 --- a/backend/api/v1/guests/views.py +++ b/backend/api/v1/guests/views.py @@ -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[^/.]+)", + ) + 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[^/.]+)", + ) + 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) diff --git a/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py b/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py new file mode 100644 index 00000000..3639a5b4 --- /dev/null +++ b/backend/communications/migrations/0039_alter_chat_context_object_id_bigint.py @@ -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", + ), + ), + ] diff --git a/backend/communications/models.py b/backend/communications/models.py index a318d1fd..0efd6f7d 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -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( diff --git a/backend/eusrr_backend/db_routers.py b/backend/eusrr_backend/db_routers.py index cbcec177..b0c38797 100644 --- a/backend/eusrr_backend/db_routers.py +++ b/backend/eusrr_backend/db_routers.py @@ -29,6 +29,7 @@ class LdapRouter: "ldapgroup", "ldaporganizationalunit", "ldaporganizationalunitgroup", + "ldapguestuser", } @property diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index f75f6b18..8e575b9f 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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}" diff --git a/backend/guests/admin.py b/backend/guests/admin.py index 430b1391..08028b00 100644 --- a/backend/guests/admin.py +++ b/backend/guests/admin.py @@ -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", ) - diff --git a/backend/guests/ldap/orm_models.py b/backend/guests/ldap/orm_models.py index 206a611f..eb00b6af 100644 --- a/backend/guests/ldap/orm_models.py +++ b/backend/guests/ldap/orm_models.py @@ -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", + ] diff --git a/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py b/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py new file mode 100644 index 00000000..27f9a028 --- /dev/null +++ b/backend/guests/migrations/0003_guest_avatar_ldapguestuser_thumbnail_photo.py @@ -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, + ), + ), + ] diff --git a/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py b/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py new file mode 100644 index 00000000..10710364 --- /dev/null +++ b/backend/guests/migrations/0004_guest_blacklist_remove_ldap_enabled.py @@ -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", + ), + ] diff --git a/backend/guests/migrations/0005_guest_last_name_optional.py b/backend/guests/migrations/0005_guest_last_name_optional.py new file mode 100644 index 00000000..87353ab0 --- /dev/null +++ b/backend/guests/migrations/0005_guest_last_name_optional.py @@ -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="Фамилия"), + ), + ] diff --git a/backend/guests/migrations/0006_guest_document_folder_documents.py b/backend/guests/migrations/0006_guest_document_folder_documents.py new file mode 100644 index 00000000..01d37feb --- /dev/null +++ b/backend/guests/migrations/0006_guest_document_folder_documents.py @@ -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="Документы гостя", + ), + ), + ] diff --git a/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py b/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py new file mode 100644 index 00000000..0d8fd1b5 --- /dev/null +++ b/backend/guests/migrations/0007_remove_guestvisit_admin_comment.py @@ -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', + ), + ] diff --git a/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py b/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py new file mode 100644 index 00000000..59cbe68a --- /dev/null +++ b/backend/guests/migrations/0008_migrate_guest_comment_to_communications.py @@ -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", + ), + ] diff --git a/backend/guests/models.py b/backend/guests/models.py index 6f7fb85c..df4ee3a4 100644 --- a/backend/guests/models.py +++ b/backend/guests/models.py @@ -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() diff --git a/backend/guests/services.py b/backend/guests/services.py index 58489bd4..80f68899 100644 --- a/backend/guests/services.py +++ b/backend/guests/services.py @@ -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, ) diff --git a/backend/guests/tasks.py b/backend/guests/tasks.py index 097fde49..38049a88 100644 --- a/backend/guests/tasks.py +++ b/backend/guests/tasks.py @@ -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: diff --git a/backend/guests/тз.txt b/backend/guests/тз.txt index ef3db172..28f8cf31 100644 --- a/backend/guests/тз.txt +++ b/backend/guests/тз.txt @@ -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`; diff --git a/backend/tests/api/v1/guests/test_guest_visits_api.py b/backend/tests/api/v1/guests/test_guest_visits_api.py index 3ba25be6..5774610c 100644 --- a/backend/tests/api/v1/guests/test_guest_visits_api.py +++ b/backend/tests/api/v1/guests/test_guest_visits_api.py @@ -3,13 +3,21 @@ import pytest from datetime import timedelta from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType +from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone from documents.models import Document +from filer.models import Folder from guests.constants import GuestVisitStatus from guests.models import Guest, GuestVisit +TINY_PNG_BASE64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ" + "AAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +) + + def _results(payload): return payload["results"] if isinstance(payload, dict) and "results" in payload else payload @@ -28,24 +36,332 @@ def test_user_can_create_and_submit_guest_visit(auth_client_factory, user_factor "organization": "Vendor", }, "purpose": "Wi-Fi access", - "date_from": "2026-06-15", - "date_to": "2026-06-18", + "date_from": "2035-06-15", + "date_to": "2035-06-18", "all_day": True, }, format="json", ) assert response.status_code == 201 + assert response.json()["status"] == GuestVisitStatus.PENDING visit_id = response.json()["id"] visit = GuestVisit.objects.get(pk=visit_id) assert visit.inviter == user - assert timezone.localtime(visit.access_expires_at).date().isoformat() == "2026-06-19" + assert visit.submitted_at is not None + assert timezone.localtime(visit.access_expires_at).date().isoformat() == "2035-06-19" submit = client.post(f"/api/v1/guests/visits/{visit_id}/submit/") assert submit.status_code == 200 assert submit.json()["status"] == GuestVisitStatus.PENDING +@pytest.mark.django_db +def test_user_can_create_guest_visit_without_guest_last_name( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + + response = client.post( + "/api/v1/guests/visits/", + { + "guest": { + "first_name": "SingleName", + }, + "purpose": "Visitor access", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + "all_day": True, + }, + format="json", + ) + + assert response.status_code == 201 + visit = GuestVisit.objects.get(pk=response.json()["id"]) + assert visit.guest.first_name == "SingleName" + assert visit.guest.last_name == "" + assert response.json()["guest"]["full_name"] == "SingleName" + + +@pytest.mark.django_db +def test_user_can_create_guest_visit_with_guest_avatar( + settings, + tmp_path, + auth_client_factory, + user_factory, +): + settings.MEDIA_ROOT = tmp_path + user = user_factory() + client = auth_client_factory(user) + + response = client.post( + "/api/v1/guests/visits/", + { + "guest": { + "first_name": "Avatar", + "last_name": "Guest", + "avatar": f"data:image/png;base64,{TINY_PNG_BASE64}", + }, + "purpose": "Photo for downstream services", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + "all_day": True, + }, + format="json", + ) + + assert response.status_code == 201 + visit = GuestVisit.objects.get(pk=response.json()["id"]) + assert visit.guest.avatar + assert response.json()["guest"]["avatar"].startswith("data:image/") + + +@pytest.mark.django_db +def test_cannot_create_overlapping_guest_visit_for_same_guest( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Overlap", last_name="Guest") + + first = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "First access", + "date_from": "2035-06-15", + "date_to": "2035-06-18", + "all_day": True, + }, + format="json", + ) + overlapping = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Overlapping access", + "date_from": "2035-06-18", + "date_to": "2035-06-20", + "all_day": True, + }, + format="json", + ) + + assert first.status_code == 201 + assert overlapping.status_code == 400 + assert "пересекающимся периодом" in str(overlapping.json()) + + +@pytest.mark.django_db +def test_adjacent_guest_visit_period_is_allowed( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Adjacent", last_name="Guest") + + first = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "First access", + "date_from": "2035-06-15", + "date_to": "2035-06-18", + "all_day": True, + }, + format="json", + ) + adjacent = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Next access", + "date_from": "2035-06-19", + "date_to": "2035-06-20", + "all_day": True, + }, + format="json", + ) + + assert first.status_code == 201 + assert adjacent.status_code == 201 + + +@pytest.mark.django_db +def test_closed_guest_visit_does_not_block_overlapping_new_visit( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Closed", last_name="Guest") + + first = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "No longer needed", + "date_from": "2035-06-15", + "date_to": "2035-06-18", + "all_day": True, + }, + format="json", + ) + cancel = client.post(f"/api/v1/guests/visits/{first.json()['id']}/cancel/") + next_visit = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Replacement access", + "date_from": "2035-06-16", + "date_to": "2035-06-17", + "all_day": True, + }, + format="json", + ) + + assert first.status_code == 201 + assert cancel.status_code == 200 + assert next_visit.status_code == 201 + + +@pytest.mark.django_db +def test_cannot_create_guest_visit_with_expired_period( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + yesterday = timezone.localdate() - timedelta(days=1) + + response = client.post( + "/api/v1/guests/visits/", + { + "guest": {"first_name": "Past", "last_name": "Guest"}, + "purpose": "Expired access", + "date_from": yesterday.isoformat(), + "date_to": yesterday.isoformat(), + "all_day": True, + }, + format="json", + ) + + assert response.status_code == 400 + assert "уже истек" in str(response.json()) + + +@pytest.mark.django_db +def test_today_all_day_guest_visit_is_allowed( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + today = timezone.localdate() + + response = client.post( + "/api/v1/guests/visits/", + { + "guest": {"first_name": "Today", "last_name": "Guest"}, + "purpose": "Today access", + "date_from": today.isoformat(), + "date_to": today.isoformat(), + "all_day": True, + }, + format="json", + ) + + assert response.status_code == 201 + + +@pytest.mark.django_db +def test_existing_unlimited_guest_visit_blocks_new_visit_for_same_guest( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Unlimited", last_name="Guest") + + unlimited = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Permanent access", + "unlimited": True, + }, + format="json", + ) + next_visit = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Extra access", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + "all_day": True, + }, + format="json", + ) + + assert unlimited.status_code == 201 + assert next_visit.status_code == 400 + assert "пересекающимся периодом" in str(next_visit.json()) + + +@pytest.mark.django_db +def test_cannot_update_guest_visit_to_overlapping_period( + auth_client_factory, + user_factory, +): + user = user_factory() + admin = user_factory(staff=True) + client = auth_client_factory(user) + admin_client = auth_client_factory(admin) + guest = Guest.objects.create(first_name="UpdateOverlap", last_name="Guest") + first = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "First access", + "date_from": "2035-06-15", + "date_to": "2035-06-16", + "all_day": True, + }, + format="json", + ) + second = client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Second access", + "date_from": "2035-06-17", + "date_to": "2035-06-18", + "all_day": True, + }, + format="json", + ) + + response = admin_client.patch( + f"/api/v1/guests/visits/{second.json()['id']}/", + { + "date_from": "2035-06-16", + "date_to": "2035-06-18", + "all_day": True, + }, + format="json", + ) + + assert first.status_code == 201 + assert second.status_code == 201 + assert response.status_code == 400 + assert "пересекающимся периодом" in str(response.json()) + + @pytest.mark.django_db def test_admin_can_approve_guest_visit(auth_client_factory, user_factory): user = user_factory() @@ -58,8 +374,8 @@ def test_admin_can_approve_guest_visit(auth_client_factory, user_factory): { "guest": {"first_name": "Alex", "last_name": "Guest"}, "purpose": "Directory access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -88,8 +404,8 @@ def test_admin_can_reject_approved_guest_visit(auth_client_factory, user_factory { "guest": {"first_name": "Reject", "last_name": "Approved"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -125,8 +441,8 @@ def test_admin_can_request_info_author_can_provide_info( { "guest": {"first_name": "Need", "last_name": "Info"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -140,6 +456,17 @@ def test_admin_can_request_info_author_can_provide_info( ) assert request_info.status_code == 200 assert request_info.json()["status"] == GuestVisitStatus.NEEDS_INFO + assert "admin_comment" not in request_info.json() + + comments_after_request = user_client.get( + f"/api/v1/guests/visits/{visit_id}/comments/" + ) + assert comments_after_request.status_code == 200 + assert comments_after_request.json()[0]["text"] == "Add sponsor details" + assert ( + comments_after_request.json()[0]["metadata"]["guest_visit_comment_type"] + == "info_request" + ) provide_info = user_client.post( f"/api/v1/guests/visits/{visit_id}/provide-info/", @@ -148,9 +475,57 @@ def test_admin_can_request_info_author_can_provide_info( ) assert provide_info.status_code == 200 assert provide_info.json()["status"] == GuestVisitStatus.PENDING + admin_detail_before_read = admin_client.get( + f"/api/v1/guests/visits/{visit_id}/" + ) + assert admin_detail_before_read.status_code == 200 + assert admin_detail_before_read.json()["has_unread_info_response"] is True visit = GuestVisit.objects.get(pk=visit_id) assert visit.events.filter(event_type="needs_info_requested").exists() assert visit.events.filter(event_type="info_provided").exists() + comments_after_response = admin_client.get( + f"/api/v1/guests/visits/{visit_id}/comments/" + ) + assert [comment["text"] for comment in comments_after_response.json()] == [ + "Add sponsor details", + "Sponsor added", + ] + assert ( + comments_after_response.json()[1]["metadata"]["guest_visit_comment_type"] + == "info_response" + ) + admin_detail_after_read = admin_client.get( + f"/api/v1/guests/visits/{visit_id}/" + ) + assert admin_detail_after_read.status_code == 200 + assert admin_detail_after_read.json()["has_unread_info_response"] is False + + +@pytest.mark.django_db +def test_author_cannot_provide_info_without_active_request( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + created = client.post( + "/api/v1/guests/visits/", + { + "guest": {"first_name": "No", "last_name": "Question"}, + "purpose": "Access", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + }, + format="json", + ) + + response = client.post( + f"/api/v1/guests/visits/{created.json()['id']}/provide-info/", + {"comment": "answer without question"}, + format="json", + ) + + assert response.status_code == 400 @pytest.mark.django_db @@ -165,8 +540,8 @@ def test_admin_can_revoke_guest_visit(auth_client_factory, user_factory): { "guest": {"first_name": "Revoke", "last_name": "Guest"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -194,8 +569,8 @@ def test_author_can_cancel_guest_visit(auth_client_factory, user_factory): { "guest": {"first_name": "Cancel", "last_name": "Guest"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -209,29 +584,232 @@ def test_author_can_cancel_guest_visit(auth_client_factory, user_factory): assert response.status_code == 200 assert response.json()["status"] == GuestVisitStatus.CANCELLED + assert response.json()["can_cancel"] is False + assert response.json()["can_return_to_work"] is True + + +@pytest.mark.django_db +def test_author_can_return_cancelled_visit_to_work(auth_client_factory, user_factory): + user = user_factory() + client = auth_client_factory(user) + created = client.post( + "/api/v1/guests/visits/", + { + "guest": {"first_name": "Return", "last_name": "Guest"}, + "purpose": "Access", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + }, + format="json", + ) + visit_id = created.json()["id"] + client.post( + f"/api/v1/guests/visits/{visit_id}/cancel/", + {"comment": "pause"}, + format="json", + ) + + response = client.post( + f"/api/v1/guests/visits/{visit_id}/return-to-work/", + {"comment": "needed again"}, + format="json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == GuestVisitStatus.PENDING + assert payload["cancelled_at"] is None + assert payload["cancel_reason"] == "" + visit = GuestVisit.objects.get(pk=visit_id) + assert visit.events.filter( + event_type="decision_changed", + from_status=GuestVisitStatus.CANCELLED, + to_status=GuestVisitStatus.PENDING, + ).exists() + + +@pytest.mark.django_db +def test_author_can_delete_visit_and_recalculate_guest_access( + settings, + auth_client_factory, + user_factory, +): + settings.LDAP_ENABLED = False + settings.LDAP_WRITE_ENABLED = False + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Delete", last_name="Access", is_active=True) + visit = GuestVisit.objects.create( + guest=guest, + inviter=user, + purpose="Temporary access", + status=GuestVisitStatus.APPROVED, + access_starts_at=timezone.now() - timedelta(hours=1), + access_expires_at=timezone.now() + timedelta(hours=1), + ) + + response = client.delete(f"/api/v1/guests/visits/{visit.id}/") + + assert response.status_code == 204 + assert not GuestVisit.objects.filter(pk=visit.id).exists() + guest.refresh_from_db() + assert guest.is_active is False + assert guest.ldap_last_error == "" + assert guest.ldap_last_synced_at is not None + + +@pytest.mark.django_db +def test_deleting_one_visit_keeps_guest_access_when_another_active_visit_exists( + settings, + auth_client_factory, + user_factory, +): + settings.LDAP_ENABLED = False + settings.LDAP_WRITE_ENABLED = False + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Keep", last_name="Access", is_active=True) + first = GuestVisit.objects.create( + guest=guest, + inviter=user, + purpose="First access", + status=GuestVisitStatus.APPROVED, + access_starts_at=timezone.now() - timedelta(hours=1), + access_expires_at=timezone.now() + timedelta(hours=1), + ) + second = GuestVisit.objects.create( + guest=guest, + inviter=user, + purpose="Second access", + status=GuestVisitStatus.APPROVED, + access_starts_at=timezone.now() - timedelta(hours=1), + access_expires_at=timezone.now() + timedelta(hours=1), + ) + + response = client.delete(f"/api/v1/guests/visits/{first.id}/") + + assert response.status_code == 204 + assert GuestVisit.objects.filter(pk=second.id).exists() + guest.refresh_from_db() + assert guest.is_active is True + + +@pytest.mark.django_db +def test_other_user_cannot_delete_guest_visit(auth_client_factory, user_factory): + author = user_factory() + other = user_factory() + other_client = auth_client_factory(other) + guest = Guest.objects.create(first_name="No", last_name="Delete") + visit = GuestVisit.objects.create( + guest=guest, + inviter=author, + purpose="Protected access", + status=GuestVisitStatus.PENDING, + ) + + response = other_client.delete(f"/api/v1/guests/visits/{visit.id}/") + + assert response.status_code in {403, 404} + assert GuestVisit.objects.filter(pk=visit.id).exists() + + +@pytest.mark.django_db +def test_guest_visit_action_flags_match_valid_transitions( + auth_client_factory, + user_factory, +): + admin = user_factory(staff=True) + inviter = user_factory() + client = auth_client_factory(admin) + guest = Guest.objects.create(first_name="Flags", last_name="Guest") + + def read_flags(status): + visit = GuestVisit.objects.create( + guest=guest, + inviter=inviter, + purpose=f"{status} flags", + status=status, + access_starts_at=timezone.now() - timedelta(days=1), + access_expires_at=timezone.now() + timedelta(days=1), + ) + response = client.get(f"/api/v1/guests/visits/{visit.id}/") + assert response.status_code == 200 + payload = response.json() + return { + "approve": payload["can_approve"], + "reject": payload["can_reject"], + "request_info": payload["can_request_info"], + "cancel": payload["can_cancel"], + "revoke": payload["can_revoke"], + "return_to_work": payload["can_return_to_work"], + "delete": payload["can_delete"], + } + + assert read_flags(GuestVisitStatus.DRAFT) == { + "approve": False, + "reject": False, + "request_info": False, + "cancel": True, + "revoke": False, + "return_to_work": False, + "delete": True, + } + assert read_flags(GuestVisitStatus.PENDING) == { + "approve": True, + "reject": True, + "request_info": True, + "cancel": True, + "revoke": False, + "return_to_work": False, + "delete": True, + } + assert read_flags(GuestVisitStatus.APPROVED) == { + "approve": False, + "reject": True, + "request_info": False, + "cancel": True, + "revoke": True, + "return_to_work": False, + "delete": True, + } + assert read_flags(GuestVisitStatus.REJECTED) == { + "approve": True, + "reject": False, + "request_info": False, + "cancel": False, + "revoke": False, + "return_to_work": False, + "delete": True, + } + assert read_flags(GuestVisitStatus.CANCELLED) == { + "approve": False, + "reject": False, + "request_info": False, + "cancel": False, + "revoke": False, + "return_to_work": True, + "delete": True, + } @pytest.mark.django_db def test_invalid_api_transitions_return_400(auth_client_factory, user_factory): user = user_factory() admin = user_factory(staff=True) - client = auth_client_factory(user) admin_client = auth_client_factory(admin) - created = client.post( - "/api/v1/guests/visits/", - { - "guest": {"first_name": "Bad", "last_name": "Transition"}, - "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", - }, - format="json", + guest = Guest.objects.create(first_name="Bad", last_name="Transition") + visit = GuestVisit.objects.create( + guest=guest, + inviter=user, + purpose="Access", + status=GuestVisitStatus.DRAFT, + access_starts_at=timezone.now() + timedelta(days=1), + access_expires_at=timezone.now() + timedelta(days=2), ) - visit_id = created.json()["id"] - approve = admin_client.post(f"/api/v1/guests/visits/{visit_id}/approve/") + approve = admin_client.post(f"/api/v1/guests/visits/{visit.id}/approve/") request_info = admin_client.post( - f"/api/v1/guests/visits/{visit_id}/request-info/", + f"/api/v1/guests/visits/{visit.id}/request-info/", {"comment": "details"}, format="json", ) @@ -252,8 +830,8 @@ def test_non_admin_cannot_approve_guest_visit(auth_client_factory, user_factory) { "guest": {"first_name": "No", "last_name": "Approve"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -289,8 +867,8 @@ def test_view_all_permission_does_not_allow_decision( { "guest": {"first_name": "Perm", "last_name": "Guest"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -314,8 +892,8 @@ def test_guest_visit_comments(auth_client_factory, user_factory): { "guest": {"first_name": "Comment", "last_name": "Guest"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -333,6 +911,75 @@ def test_guest_visit_comments(auth_client_factory, user_factory): assert listed.json()[0]["text"] == "Need badge too" +@pytest.mark.django_db +def test_guest_profile_comments_use_communications( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + + created = client.post( + "/api/v1/guests/visits/", + { + "guest": {"first_name": "Profile", "last_name": "Comment"}, + "guest_comment": "Bring printed pass", + "purpose": "Access", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + }, + format="json", + ) + + assert created.status_code == 201 + assert "comment" not in created.json()["guest"] + guest_id = created.json()["guest"]["id"] + + comments = client.get(f"/api/v1/guests/{guest_id}/comments/") + assert comments.status_code == 200 + assert comments.json()[0]["text"] == "Bring printed pass" + + guests = _results(client.get("/api/v1/guests/").json()) + guest_payload = next(item for item in guests if item["id"] == guest_id) + assert guest_payload["comments_count"] == 1 + assert guest_payload["visits_count"] == 1 + + +@pytest.mark.django_db +def test_guest_profile_comment_delete_permissions( + auth_client_factory, + user_factory, +): + author = user_factory() + other = user_factory() + author_client = auth_client_factory(author) + other_client = auth_client_factory(other) + guest = Guest.objects.create( + first_name="Delete", + last_name="GuestComment", + created_by=author, + ) + comment = author_client.post( + f"/api/v1/guests/{guest.id}/comments/", + {"text": "Temporary"}, + format="json", + ).json() + + listed = other_client.get(f"/api/v1/guests/{guest.id}/comments/") + denied = other_client.delete( + f"/api/v1/guests/{guest.id}/comments/{comment['id']}/" + ) + deleted = author_client.delete( + f"/api/v1/guests/{guest.id}/comments/{comment['id']}/" + ) + + assert listed.status_code == 200 + assert listed.json()[0]["text"] == "Temporary" + assert denied.status_code == 403 + assert deleted.status_code == 204 + assert author_client.get(f"/api/v1/guests/{guest.id}/comments/").json() == [] + + @pytest.mark.django_db def test_guest_visit_comment_delete_permissions(auth_client_factory, user_factory): author = user_factory() @@ -344,8 +991,8 @@ def test_guest_visit_comment_delete_permissions(auth_client_factory, user_factor { "guest": {"first_name": "Delete", "last_name": "Comment"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -382,8 +1029,8 @@ def test_guest_visit_documents_can_be_attached_and_removed( { "guest": {"first_name": "Doc", "last_name": "Guest"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) @@ -410,7 +1057,9 @@ def test_guest_visit_documents_can_be_attached_and_removed( @pytest.mark.django_db def test_guest_visit_create_and_update_document_ids(auth_client_factory, user_factory): user = user_factory() + admin = user_factory(staff=True) client = auth_client_factory(user) + admin_client = auth_client_factory(admin) first = Document.objects.create(title="First document", uploaded_by=user) second = Document.objects.create(title="Second document", uploaded_by=user) @@ -419,14 +1068,19 @@ def test_guest_visit_create_and_update_document_ids(auth_client_factory, user_fa { "guest": {"first_name": "DocIds", "last_name": "Guest"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", "document_ids": [first.id], }, format="json", ) visit_id = created.json()["id"] assert [doc["id"] for doc in created.json()["documents"]] == [first.id] + admin_client.post( + f"/api/v1/guests/visits/{visit_id}/request-info/", + {"comment": "replace document"}, + format="json", + ) updated = client.patch( f"/api/v1/guests/visits/{visit_id}/", @@ -439,6 +1093,95 @@ def test_guest_visit_create_and_update_document_ids(auth_client_factory, user_fa assert updated.json()["purpose"] == "Updated" +@pytest.mark.django_db +def test_regular_user_can_reuse_existing_guest_from_registry( + auth_client_factory, + user_factory, +): + owner = user_factory() + other = user_factory() + owner_client = auth_client_factory(owner) + other_client = auth_client_factory(other) + guest = Guest.objects.create( + first_name="Reusable", + last_name="Guest", + organization="Known vendor", + ) + GuestVisit.objects.create( + guest=guest, + inviter=owner, + purpose="Previous visit", + status=GuestVisitStatus.DRAFT, + ) + + allowed = owner_client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Reuse own guest", + "date_from": "2035-06-15", + "date_to": "2035-06-15", + }, + format="json", + ) + other_allowed = other_client.post( + "/api/v1/guests/visits/", + { + "guest_id": guest.id, + "purpose": "Reuse registry guest", + "date_from": "2035-06-16", + "date_to": "2035-06-16", + }, + format="json", + ) + + assert allowed.status_code == 201 + assert other_allowed.status_code == 201 + + +@pytest.mark.django_db +def test_author_can_attach_documents_while_visit_is_pending( + auth_client_factory, + user_factory, +): + author = user_factory() + admin = user_factory(staff=True) + author_client = auth_client_factory(author) + admin_client = auth_client_factory(admin) + document = Document.objects.create(title="Editable status doc", uploaded_by=author) + guest = Guest.objects.create(first_name="Document", last_name="Guard") + visit = GuestVisit.objects.create( + guest=guest, + inviter=author, + purpose="Pending visit", + status=GuestVisitStatus.PENDING, + access_starts_at=timezone.now(), + access_expires_at=timezone.now() + timedelta(days=1), + ) + + allowed = author_client.post( + f"/api/v1/guests/visits/{visit.id}/documents/", + {"document_id": document.id}, + format="json", + ) + visit.status = GuestVisitStatus.REJECTED + visit.save(update_fields=["status", "updated_at"]) + denied = author_client.post( + f"/api/v1/guests/visits/{visit.id}/documents/", + {"document_id": document.id}, + format="json", + ) + admin_allowed = admin_client.post( + f"/api/v1/guests/visits/{visit.id}/documents/", + {"document_id": document.id}, + format="json", + ) + + assert allowed.status_code == 200 + assert denied.status_code == 403 + assert admin_allowed.status_code == 200 + + @pytest.mark.django_db def test_required_document_setting_blocks_submit_without_document( settings, @@ -448,24 +1191,23 @@ def test_required_document_setting_blocks_submit_without_document( settings.GUESTS_REQUIRE_ID_DOCUMENT = True user = user_factory() client = auth_client_factory(user) - created = client.post( + response = client.post( "/api/v1/guests/visits/", { "guest": {"first_name": "Required", "last_name": "Document"}, "purpose": "Access", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) - response = client.post(f"/api/v1/guests/visits/{created.json()['id']}/submit/") - assert response.status_code == 400 + assert "document_ids" in response.json() @pytest.mark.django_db -def test_author_can_update_only_draft_or_needs_info(auth_client_factory, user_factory): +def test_author_can_update_only_needs_info(auth_client_factory, user_factory): user = user_factory() admin = user_factory(staff=True) client = auth_client_factory(user) @@ -475,19 +1217,13 @@ def test_author_can_update_only_draft_or_needs_info(auth_client_factory, user_fa { "guest": {"first_name": "Update", "last_name": "Guest"}, "purpose": "Original", - "date_from": "2026-06-15", - "date_to": "2026-06-15", + "date_from": "2035-06-15", + "date_to": "2035-06-15", }, format="json", ) visit_id = created.json()["id"] - draft_update = client.patch( - f"/api/v1/guests/visits/{visit_id}/", - {"purpose": "Draft update"}, - format="json", - ) - client.post(f"/api/v1/guests/visits/{visit_id}/submit/") pending_update = client.patch( f"/api/v1/guests/visits/{visit_id}/", {"purpose": "Pending update"}, @@ -504,7 +1240,7 @@ def test_author_can_update_only_draft_or_needs_info(auth_client_factory, user_fa format="json", ) - assert draft_update.status_code == 200 + assert created.json()["status"] == GuestVisitStatus.PENDING assert pending_update.status_code == 403 assert needs_info_update.status_code == 200 @@ -575,28 +1311,216 @@ def test_guest_admin_endpoints_permissions_and_actions( organization="Acme", ) - denied = regular_client.get("/api/v1/guests/") - listed = admin_client.get("/api/v1/guests/?q=Acme") - searched = admin_client.get("/api/v1/guests/search/?q=Admin") + listed = regular_client.get("/api/v1/guests/") + retrieved = regular_client.get(f"/api/v1/guests/{guest.id}/") + searched = regular_client.get("/api/v1/guests/search/?q=Admin") + denied_update = regular_client.patch( + f"/api/v1/guests/{guest.id}/", + {"comment": "regular edit"}, + format="json", + ) + denied_blacklist = regular_client.post(f"/api/v1/guests/{guest.id}/blacklist/") + admin_listed = admin_client.get("/api/v1/guests/?q=Acme") updated = admin_client.patch( f"/api/v1/guests/{guest.id}/", {"comment": "checked", "organization": "Updated Org"}, format="json", ) - deactivated = admin_client.post(f"/api/v1/guests/{guest.id}/deactivate/") + blacklisted = admin_client.post(f"/api/v1/guests/{guest.id}/blacklist/") + restored = admin_client.post(f"/api/v1/guests/{guest.id}/unblacklist/") - assert denied.status_code == 403 + assert listed.status_code == 200 assert [item["id"] for item in _results(listed.json())] == [guest.id] + assert retrieved.status_code == 200 + assert retrieved.json()["id"] == guest.id assert searched.json()[0]["id"] == guest.id + assert denied_update.status_code == 403 + assert denied_blacklist.status_code == 403 + assert [item["id"] for item in _results(admin_listed.json())] == [guest.id] assert updated.status_code == 200 assert updated.json()["organization"] == "Updated Org" - assert deactivated.status_code == 200 - assert deactivated.json()["is_active"] is False + assert "ldap_enabled" not in _results(listed.json())[0] + assert blacklisted.status_code == 200 + assert blacklisted.json()["is_blacklisted"] is True + assert blacklisted.json()["is_active"] is False + assert restored.status_code == 200 + assert restored.json()["is_blacklisted"] is False + + +@pytest.mark.django_db +def test_guest_update_triggers_ldap_sync( + settings, + tmp_path, + monkeypatch, + auth_client_factory, + user_factory, +): + settings.MEDIA_ROOT = tmp_path + admin = user_factory(staff=True) + client = auth_client_factory(admin) + guest = Guest.objects.create(first_name="Photo", last_name="Guest") + synced_guest_ids = [] + + def fake_sync(self, guest, **kwargs): + synced_guest_ids.append(guest.id) + + monkeypatch.setattr("api.v1.guests.views.GuestLdapService.sync_guest", fake_sync) + + response = client.patch( + f"/api/v1/guests/{guest.id}/", + { + "avatar": f"data:image/png;base64,{TINY_PNG_BASE64}", + "phone": "+70000000000", + }, + format="json", + ) + + assert response.status_code == 200 + guest.refresh_from_db() + assert guest.avatar + assert response.json()["avatar"].startswith("data:image/") + assert synced_guest_ids == [guest.id] + + +@pytest.mark.django_db +def test_guest_admin_list_filters_and_ordering(auth_client_factory, user_factory): + admin = user_factory(staff=True) + client = auth_client_factory(admin) + Guest.objects.create( + first_name="First", + last_name="Alpha", + organization="AlphaOrg", + is_active=True, + is_blacklisted=False, + ) + second = Guest.objects.create( + first_name="Second", + last_name="Zeta", + organization="ZetaOrg", + is_active=False, + is_blacklisted=True, + ) + + inactive = client.get("/api/v1/guests/?is_active=false") + blacklisted = client.get("/api/v1/guests/?is_blacklisted=true") + ordered = client.get("/api/v1/guests/?ordering=-last_name") + + assert [item["id"] for item in _results(inactive.json())] == [second.id] + assert [item["id"] for item in _results(blacklisted.json())] == [second.id] + assert _results(ordered.json())[0]["id"] == second.id + + +@pytest.mark.django_db +def test_guest_document_upload_creates_guest_folder_and_relation( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Doc", last_name="Guest") + upload = SimpleUploadedFile( + "passport.txt", + b"passport payload", + content_type="text/plain", + ) + + response = client.post( + f"/api/v1/guests/{guest.id}/documents/", + {"file": upload, "title": "Passport"}, + format="multipart", + ) + + assert response.status_code == 201 + guest.refresh_from_db() + document_id = response.json()["document"]["id"] + document = Document.objects.get(pk=document_id) + assert guest.document_folder is not None + assert guest.document_folder.parent.name == "guests" + assert guest.document_folder.name == "Guest Doc" + assert document.folder_id == guest.document_folder_id + assert document.sent_to_all is False + assert guest.documents.filter(pk=document.id).exists() + + +@pytest.mark.django_db +def test_guest_document_folder_names_are_unique(auth_client_factory, user_factory): + user = user_factory() + client = auth_client_factory(user) + first = Guest.objects.create(first_name="Same", last_name="Name") + second = Guest.objects.create(first_name="Same", last_name="Name") + + for guest in (first, second): + client.post( + f"/api/v1/guests/{guest.id}/documents/", + { + "file": SimpleUploadedFile( + f"{guest.id}.txt", + b"payload", + content_type="text/plain", + ), + }, + format="multipart", + ) + + first.refresh_from_db() + second.refresh_from_db() + assert first.document_folder.name == "Name Same" + assert second.document_folder.name == "Name Same (2)" + + +@pytest.mark.django_db +def test_guest_managed_folder_cannot_be_edited_or_deleted_directly( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Protected", last_name="Guest") + guest.ensure_document_folder(owner=user) + root_id = guest.document_folder.parent_id + + renamed = client.patch( + f"/api/v1/folders/{guest.document_folder_id}/", + {"name": "Manual rename"}, + format="json", + ) + deleted_root = client.delete(f"/api/v1/folders/{root_id}/") + + assert renamed.status_code == 400 + assert deleted_root.status_code == 400 + assert Folder.objects.filter(pk=guest.document_folder_id).exists() + + +@pytest.mark.django_db +def test_guest_visit_document_attach_also_links_document_to_guest( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + guest = Guest.objects.create(first_name="Attach", last_name="Guest") + visit = GuestVisit.objects.create( + guest=guest, + inviter=user, + purpose="Attach document", + status=GuestVisitStatus.DRAFT, + ) + document = Document.objects.create(title="Existing document", uploaded_by=user) + + response = client.post( + f"/api/v1/guests/visits/{visit.id}/documents/", + {"document_id": document.id}, + format="json", + ) + + assert response.status_code == 200 + assert guest.documents.filter(pk=document.id).exists() @pytest.mark.django_db def test_manage_guestaccount_permission_can_sync_guest_ldap( auth_client_factory, + monkeypatch, user_factory, ): manager = user_factory() @@ -619,9 +1543,37 @@ def test_manage_guestaccount_permission_can_sync_guest_ldap( ) manager_client = auth_client_factory(manager) viewer_client = auth_client_factory(viewer) + monkeypatch.setattr( + "api.v1.guests.views.GuestLdapService.sync_guest_for_visit", + lambda *args, **kwargs: None, + ) allowed = manager_client.post(f"/api/v1/guests/visits/{visit.id}/sync-ldap/") denied = viewer_client.post(f"/api/v1/guests/visits/{visit.id}/sync-ldap/") assert allowed.status_code == 200 assert denied.status_code == 403 + + +@pytest.mark.django_db +def test_guest_sync_ldap_action_returns_error_on_ldap_failure( + auth_client_factory, + monkeypatch, + user_factory, +): + admin = user_factory(staff=True) + guest = Guest.objects.create(first_name="Sync", last_name="Failure") + client = auth_client_factory(admin) + + def fail_sync(*args, **kwargs): + raise RuntimeError("LDAP failed") + + monkeypatch.setattr( + "api.v1.guests.views.GuestLdapService.sync_guest", + fail_sync, + ) + + response = client.post(f"/api/v1/guests/{guest.id}/sync-ldap/") + + assert response.status_code == 400 + assert "LDAP failed" in str(response.json()) diff --git a/backend/tests/guests/test_guest_ldap.py b/backend/tests/guests/test_guest_ldap.py index 6746513d..bce0731e 100644 --- a/backend/tests/guests/test_guest_ldap.py +++ b/backend/tests/guests/test_guest_ldap.py @@ -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") diff --git a/backend/tests/guests/test_guest_models.py b/backend/tests/guests/test_guest_models.py index a43a92ca..5aa799c4 100644 --- a/backend/tests/guests/test_guest_models.py +++ b/backend/tests/guests/test_guest_models.py @@ -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 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 9400758b..8c9e05b3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); diff --git a/frontend/src/app/guests/page.tsx b/frontend/src/app/guests/page.tsx new file mode 100644 index 00000000..a301d74e --- /dev/null +++ b/frontend/src/app/guests/page.tsx @@ -0,0 +1,4037 @@ +"use client"; + +import { Suspense, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + AlertTriangle, + ArrowUpDown, + Ban, + BriefcaseBusiness, + Building2, + CalendarDays, + Camera, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + FileText, + Filter, + History, + IdCard, + Info, + Loader2, + Mail, + MessageSquare, + Pencil, + Phone, + PlayCircle, + Plus, + RefreshCcw, + Search, + Send, + ShieldAlert, + ThumbsDown, + ThumbsUp, + Trash2, + UserRoundPlus, + XCircle, +} from "lucide-react"; + +import { AppShell } from "@/components/AppShell"; +import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; +import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; +import { Modal } from "@/components/ui"; +import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; +import { canManageGuestAccounts, canViewAllGuestVisits } from "@/lib/permissions"; +import { displayUserName, extractNextPage, formatDateTime } from "@/lib/shared"; +import { resolveMediaUrl } from "@/lib/url"; +import type { Document, Guest, GuestVisit, GuestVisitComment, GuestVisitStatus } from "@/types/api"; + +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + +type PaginatedResponse = { + count?: number; + next?: string | null; + results?: T[]; +}; + +type VisitScope = "all" | "mine" | "pending_decision" | "active" | "expired" | "risk"; +type ActionKind = "approve" | "reject" | "request-info" | "provide-info" | "cancel" | "revoke" | "return-to-work" | "delete"; +type TabKey = "visits" | "guests"; + +type GuestVisitFormState = { + guestMode: "new" | "existing"; + guest_id: string; + last_name: string; + first_name: string; + patronymic: string; + birth_date: string; + phone: string; + email: string; + avatar: string; + organization: string; + position: string; + guest_comment: string; + purpose: string; + visit_comment: string; + all_day: boolean; + unlimited: boolean; + date_from: string; + date_to: string; + access_starts_at: string; + access_expires_at: string; + document_ids: number[]; + pending_documents: PendingGuestDocument[]; +}; + +type PendingGuestDocument = { + localId: string; + file: File; + title: string; +}; + +type GuestEditState = { + last_name: string; + first_name: string; + patronymic: string; + birth_date: string; + phone: string; + email: string; + avatar: string; + organization: string; + position: string; +}; + +const statusMeta: Record = { + draft: { label: "Черновик", className: "app-badge" }, + pending: { label: "На рассмотрении", className: "app-feedback-warning" }, + needs_info: { label: "Требуется информация", className: "app-feedback-approval" }, + approved: { label: "Одобрено", className: "app-feedback-success" }, + rejected: { label: "Отклонено", className: "app-feedback-danger" }, + cancelled: { label: "Отменено", className: "app-badge" }, + expired: { label: "Истекло", className: "app-badge" }, + revoked: { label: "Отозвано", className: "app-feedback-danger" }, +}; + +const statusOptions: { value: "" | GuestVisitStatus; label: string }[] = [ + { value: "", label: "Все статусы" }, + { value: "draft", label: "Черновик" }, + { value: "pending", label: "На рассмотрении" }, + { value: "needs_info", label: "Требуется информация" }, + { value: "approved", label: "Одобрено" }, + { value: "rejected", label: "Отклонено" }, + { value: "cancelled", label: "Отменено" }, + { value: "expired", label: "Истекло" }, + { value: "revoked", label: "Отозвано" }, +]; + +const scopeOptions: { value: VisitScope; label: string; adminOnly?: boolean }[] = [ + { value: "all", label: "Все", adminOnly: true }, + { value: "mine", label: "Мои" }, + { value: "pending_decision", label: "На решение", adminOnly: true }, + { value: "active", label: "Активные", adminOnly: true }, + { value: "expired", label: "Истекшие", adminOnly: true }, + { value: "risk", label: "Риски", adminOnly: true }, +]; + +const orderingOptions = [ + { value: "-created_at", label: "Сначала новые" }, + { value: "created_at", label: "Сначала старые" }, + { value: "access_starts_at", label: "Начало доступа ↑" }, + { value: "-access_starts_at", label: "Начало доступа ↓" }, + { value: "access_expires_at", label: "Окончание доступа ↑" }, + { value: "-access_expires_at", label: "Окончание доступа ↓" }, +]; + +const guestOrderingOptions = [ + { value: "last_name", label: "По фамилии ↑" }, + { value: "-last_name", label: "По фамилии ↓" }, + { value: "-created_at", label: "Сначала новые" }, + { value: "created_at", label: "Сначала старые" }, + { value: "-updated_at", label: "Недавно обновленные" }, + { value: "-ldap_last_synced_at", label: "Недавно синхронизированные" }, +]; + +const formatDateOnlyInput = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const getDefaultAllDayPeriod = (): Pick => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const dayAfterTomorrow = new Date(today); + dayAfterTomorrow.setDate(today.getDate() + 2); + + return { + date_from: formatDateOnlyInput(today), + date_to: formatDateOnlyInput(dayAfterTomorrow), + }; +}; + +const emptyForm = (): GuestVisitFormState => ({ + guestMode: "new", + guest_id: "", + last_name: "", + first_name: "", + patronymic: "", + birth_date: "", + phone: "", + email: "", + avatar: "", + organization: "", + position: "", + guest_comment: "", + purpose: "", + visit_comment: "", + all_day: true, + unlimited: false, + ...getDefaultAllDayPeriod(), + access_starts_at: "", + access_expires_at: "", + document_ids: [], + pending_documents: [], +}); + +const toResults = (payload: PaginatedResponse | T[]): T[] => ( + Array.isArray(payload) ? payload : payload.results || [] +); + +const getPaginatedCount = (payload: PaginatedResponse | T[]): number => { + if (Array.isArray(payload)) return payload.length; + return typeof payload.count === "number" ? payload.count : (payload.results || []).length; +}; + +const getReadableError = (error: unknown, fallback: string): string => { + const raw = String((error as Error)?.message || fallback); + const jsonStart = raw.indexOf("{"); + const payload = jsonStart >= 0 ? raw.slice(jsonStart) : raw; + try { + const parsed = JSON.parse(payload) as Record; + if (typeof parsed.detail === "string") return parsed.detail; + const firstValue = Object.values(parsed)[0]; + if (Array.isArray(firstValue) && firstValue[0]) return String(firstValue[0]); + if (typeof firstValue === "string") return firstValue; + } catch {} + return raw.replace(/^API Error:\s*/i, "") || fallback; +}; + +const formatLocalDateInput = (value?: string | null): string => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const formatLocalDateTimeInput = (value?: string | null): string => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + +const formatAllDayEndDate = (value?: string | null): string => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + date.setDate(date.getDate() - 1); + return formatLocalDateInput(date.toISOString()); +}; + +const toApiDateTime = (value: string): string | null => { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +}; + +const guestName = (guest?: Guest | null): string => ( + guest?.full_name || [guest?.last_name, guest?.first_name, guest?.patronymic].filter(Boolean).join(" ") || "Гость" +); + +const guestInitials = (name: string): string => { + const parts = name.trim().split(/\s+/).filter(Boolean); + return (parts[0]?.[0] || "Г") + (parts[1]?.[0] || ""); +}; + +const normalizeGuestMeta = (value?: string | null): string => { + const normalized = String(value || "").trim(); + const lower = normalized.toLowerCase(); + return normalized && !["не указан", "не указана", "не указано", "-", "—"].includes(lower) + ? normalized + : ""; +}; + +const formatGuestDate = (value?: string | null): string => { + if (!value) return ""; + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) return `${match[3]}.${match[2]}.${match[1]}`; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString("ru-RU"); +}; + +const readFileAsDataUrl = (file: File): Promise => ( + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }) +); + +const guestOptionName = (guest: Guest): string => { + const meta = [guest.organization, guest.position, guest.email, guest.phone] + .map(normalizeGuestMeta) + .filter(Boolean) + .join(" · "); + return `${guestName(guest)}${meta ? ` — ${meta}` : ""}`; +}; + +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} Б`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`; + return `${(bytes / 1024 / 1024).toFixed(2)} МБ`; +}; + +const createPendingGuestDocument = (file: File): PendingGuestDocument => ({ + localId: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`, + file, + title: file.name.replace(/\.[^/.]+$/, "").trim() || file.name, +}); + +const makeGuestEditState = (guest: Guest): GuestEditState => ({ + last_name: guest.last_name || "", + first_name: guest.first_name || "", + patronymic: guest.patronymic || "", + birth_date: guest.birth_date || "", + phone: guest.phone || "", + email: guest.email || "", + avatar: guest.avatar || "", + organization: guest.organization || "", + position: guest.position || "", +}); + +export default function GuestsPage() { + return ( + }> + + + ); +} + +function GuestsPageFallback() { + return ( + +
+ +

Загрузка гостевой системы...

+
+
+ ); +} + +function GuestsPageContent() { + const { user, loading: userLoading } = useUser(); + const router = useRouter(); + const searchParams = useSearchParams(); + const canAdminGuests = canViewAllGuestVisits(user); + const canManageLdap = canManageGuestAccounts(user); + + const [activeTab, setActiveTab] = useState("visits"); + const [visits, setVisits] = useState([]); + const [guests, setGuests] = useState([]); + const [documents, setDocuments] = useState([]); + const [comments, setComments] = useState>({}); + const [guestComments, setGuestComments] = useState>({}); + const [scopeCounts, setScopeCounts] = useState>({ + all: 0, + mine: 0, + pending_decision: 0, + active: 0, + expired: 0, + risk: 0, + }); + + const [scope, setScope] = useState("all"); + const [status, setStatus] = useState<"" | GuestVisitStatus>(""); + const [search, setSearch] = useState(""); + const [accessFrom, setAccessFrom] = useState(""); + const [accessTo, setAccessTo] = useState(""); + const [unlimitedFilter, setUnlimitedFilter] = useState(""); + const [placementFilter, setPlacementFilter] = useState(""); + const [guestIdFilter, setGuestIdFilter] = useState(null); + const [guestFilterLabel, setGuestFilterLabel] = useState(""); + const [ordering, setOrdering] = useState("-created_at"); + const [filtersOpen, setFiltersOpen] = useState(false); + + const [guestSearch, setGuestSearch] = useState(""); + const [guestBlacklistFilter, setGuestBlacklistFilter] = useState(""); + const [guestPlacementFilter, setGuestPlacementFilter] = useState(""); + const [guestOrdering, setGuestOrdering] = useState("last_name"); + const [guestFiltersOpen, setGuestFiltersOpen] = useState(false); + + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [guestsLoading, setGuestsLoading] = useState(false); + const [documentsLoading, setDocumentsLoading] = useState(false); + const [nextPage, setNextPage] = useState(null); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [formWarning, setFormWarning] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + const [busyKey, setBusyKey] = useState(null); + + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingVisit, setEditingVisit] = useState(null); + const [form, setForm] = useState(emptyForm); + const [detailsVisit, setDetailsVisit] = useState(null); + const [actionDialog, setActionDialog] = useState<{ kind: ActionKind; visit: GuestVisit } | null>(null); + const [actionComment, setActionComment] = useState(""); + const [detailsGuest, setDetailsGuest] = useState(null); + const [photoGuest, setPhotoGuest] = useState(null); + const [editingGuest, setEditingGuest] = useState(null); + const [guestEditForm, setGuestEditForm] = useState(null); + const [visitMenuOpenId, setVisitMenuOpenId] = useState(null); + const [visitCommentsOpenId, setVisitCommentsOpenId] = useState(null); + const [visitCommentDrafts, setVisitCommentDrafts] = useState>({}); + const [guestCommentsOpenId, setGuestCommentsOpenId] = useState(null); + const [guestCommentDrafts, setGuestCommentDrafts] = useState>({}); + + const openedVisitParamRef = useRef(null); + const visitMenuRef = useRef(null); + + useEffect(() => { + if (visitMenuOpenId === null) return; + + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target; + if (target instanceof Node && visitMenuRef.current?.contains(target)) return; + setVisitMenuOpenId(null); + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("touchstart", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("touchstart", handlePointerDown); + }; + }, [visitMenuOpenId]); + + useEffect(() => { + if (userLoading) return; + if (!canAdminGuests && scope !== "mine") { + setScope("mine"); + } + }, [canAdminGuests, scope, userLoading]); + + const buildVisitParams = useCallback((page: number): Record => { + const params: Record = { page, limit: 20, ordering }; + if (canAdminGuests && scope !== "all") params.scope = scope; + if (!canAdminGuests) params.scope = "mine"; + if (status) params.status = status; + if (search.trim()) params.q = search.trim(); + if (accessFrom) params.access_from = accessFrom; + if (accessTo) params.access_to = accessTo; + if (unlimitedFilter) params.unlimited = unlimitedFilter; + if (placementFilter) params.is_active = placementFilter; + if (guestIdFilter) params.guest_id = guestIdFilter; + return params; + }, [accessFrom, accessTo, canAdminGuests, guestIdFilter, ordering, placementFilter, scope, search, status, unlimitedFilter]); + + const buildScopeCountParams = useCallback((targetScope: VisitScope): Record => { + const params: Record = { page: 1, limit: 1 }; + if (canAdminGuests && targetScope !== "all") params.scope = targetScope; + if (!canAdminGuests) params.scope = "mine"; + if (status) params.status = status; + if (search.trim()) params.q = search.trim(); + if (accessFrom) params.access_from = accessFrom; + if (accessTo) params.access_to = accessTo; + if (unlimitedFilter) params.unlimited = unlimitedFilter; + if (placementFilter) params.is_active = placementFilter; + if (guestIdFilter) params.guest_id = guestIdFilter; + return params; + }, [accessFrom, accessTo, canAdminGuests, guestIdFilter, placementFilter, search, status, unlimitedFilter]); + + const loadVisits = useCallback(async (page = 1, append = false) => { + try { + if (append) setLoadingMore(true); + else setLoading(true); + setError(null); + const response = await apiClient.getGuestVisits(buildVisitParams(page)) as PaginatedResponse | GuestVisit[]; + const nextVisits = toResults(response); + setVisits((current) => append ? [...current, ...nextVisits] : nextVisits); + setNextPage(Array.isArray(response) ? null : extractNextPage(response.next)); + } catch (err) { + setError(getReadableError(err, "Не удалось загрузить гостевые визиты")); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, [buildVisitParams]); + + const loadGuests = useCallback(async () => { + try { + setGuestsLoading(true); + const response = await apiClient.getGuests({ + q: guestSearch.trim(), + is_blacklisted: guestBlacklistFilter, + is_active: guestPlacementFilter, + ordering: guestOrdering, + page: 1, + limit: 50, + }) as PaginatedResponse | Guest[]; + setGuests(toResults(response)); + } catch (err) { + setActionError(getReadableError(err, "Не удалось загрузить гостей")); + } finally { + setGuestsLoading(false); + } + }, [guestBlacklistFilter, guestOrdering, guestPlacementFilter, guestSearch]); + + const loadDocuments = useCallback(async () => { + try { + setDocumentsLoading(true); + const response = await apiClient.getDocuments({ + page: 1, + page_size: 50, + }) as PaginatedResponse | Document[]; + setDocuments(toResults(response)); + } catch { + setDocuments([]); + } finally { + setDocumentsLoading(false); + } + }, []); + + useEffect(() => { + void loadVisits(); + }, [loadVisits]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const visibleScopes = scopeOptions + .filter((option) => !option.adminOnly || canAdminGuests) + .map((option) => option.value); + const results = await Promise.all( + visibleScopes.map(async (scopeKey) => { + const response = await apiClient.getGuestVisits(buildScopeCountParams(scopeKey)) as PaginatedResponse | GuestVisit[]; + return [scopeKey, getPaginatedCount(response)] as const; + }), + ); + if (!cancelled) { + setScopeCounts((current) => ({ ...current, ...Object.fromEntries(results) })); + } + } catch { + if (!cancelled) { + setScopeCounts({ + all: 0, + mine: 0, + pending_decision: 0, + active: 0, + expired: 0, + risk: 0, + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [buildScopeCountParams, canAdminGuests]); + + useEffect(() => { + void loadDocuments(); + }, [loadDocuments]); + + useEffect(() => { + if (activeTab === "guests") { + void loadGuests(); + } + }, [activeTab, loadGuests]); + + const refreshVisit = useCallback(async (id: number): Promise => { + const visit = await apiClient.getGuestVisit(id) as GuestVisit; + setVisits((current) => { + const exists = current.some((item) => item.id === visit.id); + if (!exists) return [visit, ...current]; + return current.map((item) => item.id === visit.id ? visit : item); + }); + setDetailsVisit((current) => current?.id === visit.id ? visit : current); + return visit; + }, []); + + const loadComments = useCallback(async (visitId: number) => { + const response = await apiClient.getGuestVisitComments(visitId) as GuestVisitComment[]; + setComments((current) => ({ ...current, [visitId]: response })); + await refreshVisit(visitId); + }, [refreshVisit]); + + const loadGuestComments = useCallback(async (guestId: number) => { + const response = await apiClient.getGuestComments(guestId) as GuestVisitComment[]; + setGuestComments((current) => ({ ...current, [guestId]: response })); + const commentsCount = response.length; + setGuests((current) => current.map((guest) => ( + guest.id === guestId ? { ...guest, comments_count: commentsCount } : guest + ))); + setVisits((current) => current.map((visit) => ( + visit.guest.id === guestId + ? { ...visit, guest: { ...visit.guest, comments_count: commentsCount } } + : visit + ))); + setDetailsVisit((current) => ( + current?.guest.id === guestId + ? { ...current, guest: { ...current.guest, comments_count: commentsCount } } + : current + )); + return response; + }, []); + + useEffect(() => { + const visitParam = searchParams.get("visit"); + if (!visitParam || openedVisitParamRef.current === visitParam) return; + const visitId = Number(visitParam); + if (!Number.isFinite(visitId) || visitId <= 0) return; + openedVisitParamRef.current = visitParam; + (async () => { + try { + setBusyKey(`open-${visitId}`); + const visit = await refreshVisit(visitId); + setDetailsVisit(visit); + await loadComments(visit.id); + } catch (err) { + setActionError(getReadableError(err, "Не удалось открыть гостевой визит")); + } finally { + setBusyKey(null); + } + })(); + }, [loadComments, refreshVisit, searchParams]); + + const documentOptions = useMemo( + () => { + const map = new Map(); + documents.forEach((doc) => map.set(doc.id, doc)); + editingVisit?.documents?.forEach((doc) => map.set(doc.id, doc)); + detailsVisit?.documents?.forEach((doc) => map.set(doc.id, doc)); + editingGuest?.documents?.forEach((doc) => map.set(doc.id, doc)); + return Array.from(map.values()).map((doc) => ({ + id: doc.id, + name: doc.title || doc.file_name || `Документ #${doc.id}`, + })); + }, + [detailsVisit?.documents, documents, editingGuest?.documents, editingVisit?.documents], + ); + + const guestOptions = useMemo( + () => guests.map((guest) => ({ id: guest.id, name: guestOptionName(guest) })), + [guests], + ); + + const activeFiltersCount = [ + status, + search.trim(), + accessFrom, + accessTo, + unlimitedFilter, + placementFilter, + guestIdFilter ? "guest" : "", + canAdminGuests && scope !== "all" ? scope : "", + ].filter(Boolean).length; + const activeGuestFiltersCount = [ + guestBlacklistFilter, + guestPlacementFilter, + ].filter(Boolean).length; + + const openCreate = () => { + setEditingVisit(null); + setForm(emptyForm()); + setActionError(null); + setFormWarning(null); + setIsFormOpen(true); + void loadGuests(); + }; + + const openEdit = (visit: GuestVisit) => { + setEditingVisit(visit); + setForm({ + ...emptyForm(), + guestMode: "existing", + guest_id: String(visit.guest.id), + purpose: visit.purpose || "", + visit_comment: visit.visit_comment || "", + all_day: visit.all_day, + unlimited: visit.unlimited, + date_from: formatLocalDateInput(visit.access_starts_at), + date_to: formatAllDayEndDate(visit.access_expires_at) || formatLocalDateInput(visit.access_expires_at), + access_starts_at: formatLocalDateTimeInput(visit.access_starts_at), + access_expires_at: formatLocalDateTimeInput(visit.access_expires_at), + document_ids: (visit.documents || []).map((doc) => doc.id), + }); + setActionError(null); + setFormWarning(null); + setIsFormOpen(true); + }; + + const closeDetails = () => { + setDetailsVisit(null); + if (searchParams.get("visit")) { + router.replace("/guests"); + openedVisitParamRef.current = null; + } + }; + + const openDetails = async (visit: GuestVisit) => { + setDetailsVisit(visit); + if (!comments[visit.id] || visit.has_unread_info_response) { + try { + await loadComments(visit.id); + } catch { + setComments((current) => ({ ...current, [visit.id]: [] })); + } + } + }; + + const toggleVisitComments = async (visit: GuestVisit) => { + setVisitMenuOpenId(null); + if (visitCommentsOpenId === visit.id) { + setVisitCommentsOpenId(null); + return; + } + setVisitCommentsOpenId(visit.id); + if (!comments[visit.id] || visit.has_unread_info_response) { + try { + await loadComments(visit.id); + } catch { + setComments((current) => ({ ...current, [visit.id]: [] })); + } + } + }; + + const updateVisitCommentDraft = (visitId: number, value: string) => { + setVisitCommentDrafts((current) => ({ ...current, [visitId]: value })); + }; + + const addListComment = async (visit: GuestVisit) => { + const text = (visitCommentDrafts[visit.id] || "").trim(); + if (!text) return; + await addComment(visit, text); + setVisitCommentDrafts((current) => ({ ...current, [visit.id]: "" })); + }; + + const buildVisitPayload = (): Record => { + const payload: Record = { + purpose: form.purpose.trim(), + visit_comment: form.visit_comment.trim(), + all_day: form.all_day, + unlimited: form.unlimited, + document_ids: form.document_ids, + }; + + if (!editingVisit) { + if (form.guestMode === "existing" && form.guest_id) { + payload.guest_id = Number(form.guest_id); + } else { + payload.guest = { + last_name: form.last_name.trim(), + first_name: form.first_name.trim(), + patronymic: form.patronymic.trim(), + birth_date: form.birth_date || null, + phone: form.phone.trim(), + email: form.email.trim(), + ...(form.avatar ? { avatar: form.avatar } : {}), + organization: form.organization.trim(), + position: form.position.trim(), + }; + payload.guest_comment = form.guest_comment.trim(); + } + } + + if (form.unlimited) { + payload.access_expires_at = null; + } else if (form.all_day) { + payload.date_from = form.date_from; + payload.date_to = form.date_to; + } else { + payload.access_starts_at = toApiDateTime(form.access_starts_at); + payload.access_expires_at = toApiDateTime(form.access_expires_at); + } + return payload; + }; + + const validateForm = (): string | null => { + if (!editingVisit) { + if (form.guestMode === "existing" && !form.guest_id) return "Выберите гостя."; + if (form.guestMode === "new" && !form.first_name.trim()) { + return "Укажите имя гостя."; + } + } + if (!form.purpose.trim()) return "Укажите цель приглашения."; + if (!form.unlimited) { + if (form.all_day) { + if (!form.date_from || !form.date_to) return "Укажите даты доступа."; + if (form.date_to < formatDateOnlyInput(new Date())) { + return "Нельзя создать заявку с периодом доступа, который уже истек."; + } + } + if (!form.all_day) { + if (!form.access_starts_at || !form.access_expires_at) return "Укажите время доступа."; + if (new Date(form.access_expires_at).getTime() <= Date.now()) { + return "Нельзя создать заявку с периодом доступа, который уже истек."; + } + } + } + return null; + }; + + const getIncompleteFormWarning = (): string | null => { + if (editingVisit || form.guestMode !== "new") return null; + + const hasMissingGuestProfileField = [ + form.avatar, + form.birth_date, + form.phone.trim(), + form.email.trim(), + form.organization.trim(), + form.position.trim(), + ].some((value) => !value); + + return hasMissingGuestProfileField + ? "В заявке указаны не все поля. Можно вернуться и заполнить данные или продолжить создание заявки." + : null; + }; + + const submitForm = async ({ skipIncompleteWarning = false } = {}) => { + const formError = validateForm(); + if (formError) { + setActionError(formError); + setFormWarning(null); + return; + } + + if (!skipIncompleteWarning) { + const incompleteWarning = getIncompleteFormWarning(); + if (incompleteWarning) { + setFormWarning(incompleteWarning); + return; + } + } + + try { + setBusyKey(editingVisit ? `update-${editingVisit.id}` : "create"); + setActionError(null); + setFormWarning(null); + const payload = buildVisitPayload(); + let saved = editingVisit + ? await apiClient.updateGuestVisit(editingVisit.id, payload) as GuestVisit + : await apiClient.createGuestVisit(payload) as GuestVisit; + for (const pendingDocument of form.pending_documents) { + const uploadResult = await apiClient.uploadGuestDocument(saved.guest.id, { + file: pendingDocument.file, + title: pendingDocument.title, + }) as { document: Document; guest: Guest }; + saved = await apiClient.attachGuestVisitDocument( + saved.id, + uploadResult.document.id, + ) as GuestVisit; + setGuests((current) => current.map((guest) => ( + guest.id === uploadResult.guest.id ? uploadResult.guest : guest + ))); + } + setVisits((current) => { + if (editingVisit) return current.map((item) => item.id === saved.id ? saved : item); + return [saved, ...current]; + }); + setDetailsVisit((current) => current?.id === saved.id ? saved : current); + setGuests((current) => { + if (!saved.guest?.id) return current; + const exists = current.some((item) => item.id === saved.guest.id); + if (exists) { + return current.map((item) => item.id === saved.guest.id ? { + ...item, + ...saved.guest, + document_folder: saved.guest.document_folder ?? item.document_folder, + documents: saved.guest.documents ?? item.documents, + } : item); + } + return [saved.guest, ...current]; + }); + setIsFormOpen(false); + setEditingVisit(null); + setForm(emptyForm()); + setActionSuccess(editingVisit ? "Визит обновлен" : "Заявка создана и отправлена на рассмотрение"); + if (form.pending_documents.length > 0) { + void loadDocuments(); + } + } catch (err) { + setActionError(getReadableError(err, "Не удалось сохранить визит")); + } finally { + setBusyKey(null); + } + }; + + const runVisitAction = async (visit: GuestVisit, action: "submit" | "sync") => { + const key = `${action}-${visit.id}`; + try { + setBusyKey(key); + setActionError(null); + const updated = action === "submit" + ? await apiClient.submitGuestVisit(visit.id) as GuestVisit + : await apiClient.syncGuestVisitLdap(visit.id) as GuestVisit; + setVisits((current) => current.map((item) => item.id === updated.id ? updated : item)); + setDetailsVisit((current) => current?.id === updated.id ? updated : current); + setActionSuccess(action === "submit" ? "Визит отправлен на рассмотрение" : "Синхронизация учетки запущена"); + } catch (err) { + setActionError(getReadableError(err, "Не удалось выполнить действие")); + } finally { + setBusyKey(null); + } + }; + + const executeDialogAction = async () => { + if (!actionDialog) return; + if (["request-info", "provide-info"].includes(actionDialog.kind) && !actionComment.trim()) { + setActionError("Комментарий обязателен."); + return; + } + const { visit, kind } = actionDialog; + const key = `${kind}-${visit.id}`; + try { + setBusyKey(key); + setActionError(null); + const payload = { comment: actionComment.trim() }; + if (kind === "delete") { + await apiClient.deleteGuestVisit(visit.id); + setVisits((current) => current.filter((item) => item.id !== visit.id)); + setDetailsVisit((current) => current?.id === visit.id ? null : current); + setComments((current) => { + const next = { ...current }; + delete next[visit.id]; + return next; + }); + try { + const updatedGuest = await apiClient.getGuest(visit.guest.id) as Guest; + setGuests((current) => current.map((item) => item.id === updatedGuest.id ? updatedGuest : item)); + setVisits((current) => current.map((item) => ( + item.guest.id === updatedGuest.id ? { ...item, guest: updatedGuest } : item + ))); + } catch { + // Deleting the visit already succeeded; stale guest badges will refresh on the next load. + } + setActionDialog(null); + setActionComment(""); + setActionSuccess("Заявка удалена, доступ гостя пересчитан"); + return; + } + let updated: GuestVisit; + if (kind === "approve") updated = await apiClient.approveGuestVisit(visit.id, payload) as GuestVisit; + else if (kind === "reject") updated = await apiClient.rejectGuestVisit(visit.id, payload) as GuestVisit; + else if (kind === "request-info") updated = await apiClient.requestGuestVisitInfo(visit.id, payload) as GuestVisit; + else if (kind === "provide-info") updated = await apiClient.provideGuestVisitInfo(visit.id, payload) as GuestVisit; + else if (kind === "cancel") updated = await apiClient.cancelGuestVisit(visit.id, payload) as GuestVisit; + else if (kind === "revoke") updated = await apiClient.revokeGuestVisit(visit.id, payload) as GuestVisit; + else updated = await apiClient.returnGuestVisitToWork(visit.id, payload) as GuestVisit; + setVisits((current) => current.map((item) => item.id === updated.id ? updated : item)); + setDetailsVisit((current) => current?.id === updated.id ? updated : current); + if (kind === "provide-info") await loadComments(visit.id); + setActionDialog(null); + setActionComment(""); + setActionSuccess("Статус визита обновлен"); + } catch (err) { + setActionError(getReadableError(err, "Не удалось выполнить действие")); + } finally { + setBusyKey(null); + } + }; + + const addComment = async (visit: GuestVisit, text: string) => { + try { + setBusyKey(`comment-${visit.id}`); + setActionError(null); + const comment = await apiClient.addGuestVisitComment(visit.id, text) as GuestVisitComment; + setComments((current) => ({ ...current, [visit.id]: [...(current[visit.id] || []), comment] })); + await refreshVisit(visit.id); + } catch (err) { + setActionError(getReadableError(err, "Не удалось добавить комментарий")); + } finally { + setBusyKey(null); + } + }; + + const deleteComment = async (visit: GuestVisit, commentId: number) => { + try { + setBusyKey(`comment-delete-${commentId}`); + setActionError(null); + await apiClient.deleteGuestVisitComment(visit.id, commentId); + setComments((current) => ({ ...current, [visit.id]: (current[visit.id] || []).filter((item) => item.id !== commentId) })); + await refreshVisit(visit.id); + } catch (err) { + setActionError(getReadableError(err, "Не удалось удалить комментарий")); + } finally { + setBusyKey(null); + } + }; + + const toggleGuestComments = async (guest: Guest) => { + if (guestCommentsOpenId === guest.id) { + setGuestCommentsOpenId(null); + return; + } + setGuestCommentsOpenId(guest.id); + if (!guestComments[guest.id]) { + try { + await loadGuestComments(guest.id); + } catch { + setGuestComments((current) => ({ ...current, [guest.id]: [] })); + } + } + }; + + const updateGuestCommentDraft = (guestId: number, value: string) => { + setGuestCommentDrafts((current) => ({ ...current, [guestId]: value })); + }; + + const addGuestComment = async (guest: Guest) => { + const text = (guestCommentDrafts[guest.id] || "").trim(); + if (!text) return; + try { + setBusyKey(`guest-comment-${guest.id}`); + setActionError(null); + const comment = await apiClient.addGuestComment(guest.id, text) as GuestVisitComment; + const nextComments = [...(guestComments[guest.id] || []), comment]; + setGuestComments((current) => ({ + ...current, + [guest.id]: [...(current[guest.id] || []), comment], + })); + setGuestCommentDrafts((current) => ({ ...current, [guest.id]: "" })); + const nextCount = nextComments.length; + setGuests((current) => current.map((item) => ( + item.id === guest.id ? { ...item, comments_count: nextCount } : item + ))); + setVisits((current) => current.map((visit) => ( + visit.guest.id === guest.id + ? { ...visit, guest: { ...visit.guest, comments_count: nextCount } } + : visit + ))); + setDetailsVisit((current) => ( + current?.guest.id === guest.id + ? { ...current, guest: { ...current.guest, comments_count: nextCount } } + : current + )); + } catch (err) { + setActionError(getReadableError(err, "Не удалось добавить комментарий по гостю")); + } finally { + setBusyKey(null); + } + }; + + const deleteGuestComment = async (guest: Guest, commentId: number) => { + try { + setBusyKey(`guest-comment-delete-${commentId}`); + setActionError(null); + await apiClient.deleteGuestComment(guest.id, commentId); + const nextComments = (guestComments[guest.id] || []).filter((item) => item.id !== commentId); + const nextCount = nextComments.length; + setGuestComments((current) => ({ ...current, [guest.id]: nextComments })); + setGuests((current) => current.map((item) => ( + item.id === guest.id ? { ...item, comments_count: nextCount } : item + ))); + setVisits((current) => current.map((visit) => ( + visit.guest.id === guest.id + ? { ...visit, guest: { ...visit.guest, comments_count: nextCount } } + : visit + ))); + setDetailsVisit((current) => ( + current?.guest.id === guest.id + ? { ...current, guest: { ...current.guest, comments_count: nextCount } } + : current + )); + } catch (err) { + setActionError(getReadableError(err, "Не удалось удалить комментарий по гостю")); + } finally { + setBusyKey(null); + } + }; + + const openGuestEdit = (guest: Guest) => { + setEditingGuest(guest); + setGuestEditForm(makeGuestEditState(guest)); + setActionError(null); + }; + + const openGuestDetails = (guest: Guest) => { + setDetailsGuest(guest); + setActionError(null); + }; + + const openGuestVisits = (guest: Guest) => { + setActiveTab("visits"); + setGuestIdFilter(guest.id); + setGuestFilterLabel(guestName(guest)); + setSearch(""); + setStatus(""); + setAccessFrom(""); + setAccessTo(""); + setUnlimitedFilter(""); + setPlacementFilter(""); + setScope(canAdminGuests ? "all" : "mine"); + setFiltersOpen(false); + }; + + const applyGuestUpdate = (updated: Guest) => { + setGuests((current) => current.map((item) => item.id === updated.id ? updated : item)); + setVisits((current) => current.map((visit) => visit.guest.id === updated.id ? { ...visit, guest: updated } : visit)); + setDetailsVisit((current) => current?.guest.id === updated.id ? { ...current, guest: updated } : current); + setDetailsGuest((current) => current?.id === updated.id ? updated : current); + setEditingGuest((current) => current?.id === updated.id ? updated : current); + }; + + const saveGuestEdit = async () => { + if (!editingGuest || !guestEditForm) return; + try { + setBusyKey(`guest-update-${editingGuest.id}`); + setActionError(null); + const payload: Record = { + last_name: guestEditForm.last_name, + first_name: guestEditForm.first_name, + patronymic: guestEditForm.patronymic, + birth_date: guestEditForm.birth_date || null, + phone: guestEditForm.phone, + email: guestEditForm.email, + organization: guestEditForm.organization, + position: guestEditForm.position, + }; + if (guestEditForm.avatar) { + payload.avatar = guestEditForm.avatar; + } + const updated = await apiClient.updateGuest(editingGuest.id, payload) as Guest; + applyGuestUpdate(updated); + setEditingGuest(null); + setGuestEditForm(null); + setActionSuccess("Данные гостя обновлены"); + } catch (err) { + setActionError(getReadableError(err, "Не удалось обновить гостя")); + } finally { + setBusyKey(null); + } + }; + + const attachGuestProfileDocument = async (guest: Guest, documentId?: number | null) => { + if (!documentId) return; + try { + setBusyKey(`guest-attach-doc-${guest.id}`); + setActionError(null); + const response = await apiClient.attachGuestDocument(guest.id, documentId) as { guest: Guest; document: Document }; + applyGuestUpdate(response.guest); + } catch (err) { + setActionError(getReadableError(err, "Не удалось прикрепить документ к гостю")); + } finally { + setBusyKey(null); + } + }; + + const uploadGuestProfileDocument = async (guest: Guest, file: File) => { + try { + setBusyKey(`guest-upload-doc-${guest.id}`); + setActionError(null); + const response = await apiClient.uploadGuestDocument(guest.id, { + file, + title: file.name.replace(/\.[^/.]+$/, "").trim() || file.name, + }) as { guest: Guest; document: Document }; + applyGuestUpdate(response.guest); + void loadDocuments(); + } catch (err) { + setActionError(getReadableError(err, "Не удалось загрузить документ гостя")); + } finally { + setBusyKey(null); + } + }; + + const removeGuestProfileDocument = async (guest: Guest, documentId: number) => { + try { + setBusyKey(`guest-remove-doc-${documentId}`); + setActionError(null); + const response = await apiClient.removeGuestDocument(guest.id, documentId) as { guest: Guest; document: Document }; + applyGuestUpdate(response.guest); + } catch (err) { + setActionError(getReadableError(err, "Не удалось убрать документ гостя")); + } finally { + setBusyKey(null); + } + }; + + const runGuestAdminAction = async (guest: Guest, action: "sync" | "blacklist" | "unblacklist") => { + try { + setBusyKey(`${action}-guest-${guest.id}`); + setActionError(null); + const updated = action === "sync" + ? await apiClient.syncGuestLdap(guest.id) as Guest + : action === "blacklist" + ? await apiClient.blacklistGuest(guest.id) as Guest + : await apiClient.unblacklistGuest(guest.id) as Guest; + applyGuestUpdate(updated); + setActionSuccess( + action === "sync" + ? "Учетка синхронизирована" + : action === "blacklist" + ? "Гость добавлен в черный список" + : "Блокировка гостя снята", + ); + } catch (err) { + setActionError(getReadableError(err, "Не удалось выполнить действие с гостем")); + } finally { + setBusyKey(null); + } + }; + + const clearFilters = () => { + setStatus(""); + setSearch(""); + setAccessFrom(""); + setAccessTo(""); + setUnlimitedFilter(""); + setPlacementFilter(""); + setGuestIdFilter(null); + setGuestFilterLabel(""); + setOrdering("-created_at"); + setScope(canAdminGuests ? "all" : "mine"); + }; + + return ( + +
+
+
+

ГОСТИ

+ +
+ +
+ + +
+
+ + {actionSuccess ? ( +
+ {actionSuccess} + +
+ ) : null} + + {activeTab === "visits" ? ( +
+ { + setGuestIdFilter(null); + setGuestFilterLabel(""); + }} + onPlacementFilterChange={setPlacementFilter} + onOrderingChange={setOrdering} + onScopeChange={setScope} + onSearchChange={setSearch} + onStatusChange={setStatus} + onToggleFilters={() => setFiltersOpen((value) => !value)} + onUnlimitedFilterChange={setUnlimitedFilter} + /> + + {loading ? ( +
+ +

Загрузка визитов...

+
+ ) : error ? ( +
{error}
+ ) : visits.length === 0 ? ( +
+ +

Гостевые визиты не найдены

+

Создайте новый визит или измените фильтры.

+
+ ) : ( +
+ {visits.map((visit) => ( + { + setVisitMenuOpenId(null); + setActionDialog({ kind, visit }); + setActionComment(""); + }} + onEdit={openEdit} + onOpen={openDetails} + onAddComment={addListComment} + onCommentDraftChange={updateVisitCommentDraft} + onDeleteComment={deleteComment} + onRunAction={runVisitAction} + onOpenGuestPhoto={setPhotoGuest} + onToggleComments={toggleVisitComments} + onToggleMenu={(visitId) => setVisitMenuOpenId(visitId)} + /> + ))} + {nextPage ? ( +
+ +
+ ) : null} +
+ )} +
+ ) : ( + { + setGuestBlacklistFilter(""); + setGuestPlacementFilter(""); + setGuestOrdering("last_name"); + }} + onOrderingChange={setGuestOrdering} + onPlacementFilterChange={setGuestPlacementFilter} + onAddComment={addGuestComment} + onCommentDraftChange={updateGuestCommentDraft} + onDeleteComment={deleteGuestComment} + onGuestSearchChange={setGuestSearch} + onToggleComments={toggleGuestComments} + onToggleFilters={() => setGuestFiltersOpen((value) => !value)} + onRunGuestAction={runGuestAdminAction} + /> + )} +
+ + { + setIsFormOpen(false); + setEditingVisit(null); + setForm(emptyForm()); + setFormWarning(null); + }} + onReloadDocuments={loadDocuments} + onSubmit={submitForm} + setForm={setForm} + /> + + { + setActionDialog({ kind, visit }); + setActionComment(""); + }} + onAddComment={addComment} + onClose={closeDetails} + onDeleteComment={deleteComment} + onEdit={openEdit} + onRunAction={runVisitAction} + /> + + { + setActionDialog(null); + setActionComment(""); + }} + onCommentChange={setActionComment} + onSubmit={executeDialogAction} + /> + + setDetailsGuest(null)} + onEdit={(guest) => { + setDetailsGuest(null); + openGuestEdit(guest); + }} + /> + + setPhotoGuest(null)} + /> + + { + setEditingGuest(null); + setGuestEditForm(null); + }} + onReloadDocuments={loadDocuments} + onRemoveDocument={removeGuestProfileDocument} + onSubmit={saveGuestEdit} + onUploadDocument={uploadGuestProfileDocument} + setForm={setGuestEditForm} + /> + + setActionError(null)} + /> + + setFormWarning(null)} + onContinue={() => { + setFormWarning(null); + void submitForm({ skipIncompleteWarning: true }); + }} + /> +
+ ); +} + +function GuestVisitControls({ + accessFrom, + accessTo, + activeFiltersCount, + canAdminGuests, + filtersOpen, + guestFilterLabel, + ordering, + placementFilter, + scope, + scopeCounts, + search, + status, + unlimitedFilter, + onAccessFromChange, + onAccessToChange, + onClearFilters, + onClearGuestFilter, + onOrderingChange, + onPlacementFilterChange, + onScopeChange, + onSearchChange, + onStatusChange, + onToggleFilters, + onUnlimitedFilterChange, +}: { + accessFrom: string; + accessTo: string; + activeFiltersCount: number; + canAdminGuests: boolean; + filtersOpen: boolean; + guestFilterLabel: string; + ordering: string; + placementFilter: string; + scope: VisitScope; + scopeCounts: Record; + search: string; + status: "" | GuestVisitStatus; + unlimitedFilter: string; + onAccessFromChange: (value: string) => void; + onAccessToChange: (value: string) => void; + onClearFilters: () => void; + onClearGuestFilter: () => void; + onOrderingChange: (value: string) => void; + onPlacementFilterChange: (value: string) => void; + onScopeChange: (value: VisitScope) => void; + onSearchChange: (value: string) => void; + onStatusChange: (value: "" | GuestVisitStatus) => void; + onToggleFilters: () => void; + onUnlimitedFilterChange: (value: string) => void; +}) { + const visibleScopes = scopeOptions.filter((option) => !option.adminOnly || canAdminGuests); + + return ( +
+
+
+ + onSearchChange(event.target.value)} + placeholder="Поиск по заявкам..." + className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" + /> +
+ +
+ + + +
+
+ +
+ {visibleScopes.map((option) => ( + + ))} +
+ + {guestFilterLabel ? ( +
+ +
+ ) : null} + + {filtersOpen ? ( +
+ + + + + +
+ +
+
+ ) : null} +
+ ); +} + +function GuestVisitRow({ + busyKey, + canDeleteAllComments, + commentDraft, + comments, + commentsOpen, + currentUserId, + isMenuOpen, + menuRef, + visit, + onActionDialog, + onAddComment, + onCommentDraftChange, + onDeleteComment, + onEdit, + onOpen, + onOpenGuestPhoto, + onRunAction, + onToggleComments, + onToggleMenu, +}: { + busyKey: string | null; + canDeleteAllComments: boolean; + commentDraft: string; + comments: GuestVisitComment[]; + commentsOpen: boolean; + currentUserId?: number | null; + isMenuOpen: boolean; + menuRef: RefObject | null; + visit: GuestVisit; + onActionDialog: (kind: ActionKind) => void; + onAddComment: (visit: GuestVisit) => void | Promise; + onCommentDraftChange: (visitId: number, value: string) => void; + onDeleteComment: (visit: GuestVisit, commentId: number) => void | Promise; + onEdit: (visit: GuestVisit) => void; + onOpen: (visit: GuestVisit) => void | Promise; + onOpenGuestPhoto: (guest: Guest) => void; + onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; + onToggleComments: (visit: GuestVisit) => void | Promise; + onToggleMenu: (visitId: number | null) => void; +}) { + const period = visit.unlimited + ? "Бессрочно" + : `${formatDateTime(visit.access_starts_at) || "—"} - ${formatDateTime(visit.access_expires_at) || "—"}`; + + return ( +
+
+
+ + +
+
+
+ + +
+ + {visit.documents?.length ? ( +
+ {visit.documents.map((document) => { + const label = document.title || document.file_name || `Документ #${document.id}`; + const content = ( + <> + + {label} + + ); + + return document.file_url ? ( + + {content} + + ) : ( + + {content} + + ); + })} +
+ ) : null} + +
+
+ {commentsOpen ? ( + + ) : null} +
+ ); +} + +function GuestVisitCommentsPanel({ + busyKey, + canDeleteAllComments, + commentDraft, + comments, + currentUserId, + visit, + onAddComment, + onCommentDraftChange, + onDeleteComment, +}: { + busyKey: string | null; + canDeleteAllComments: boolean; + commentDraft: string; + comments: GuestVisitComment[]; + currentUserId?: number | null; + visit: GuestVisit; + onAddComment: (visit: GuestVisit) => void | Promise; + onCommentDraftChange: (visitId: number, value: string) => void; + onDeleteComment: (visit: GuestVisit, commentId: number) => void | Promise; +}) { + return ( +
+
+ {comments.length === 0 ? ( +

Комментариев пока нет

+ ) : comments.map((comment) => { + const canDelete = canDeleteAllComments || comment.author?.id === currentUserId; + return ( +
+
+ {displayUserName(comment.author)} +
+ {formatDateTime(comment.created_at)} + {canDelete ? ( + onDeleteComment(visit, comment.id)} + /> + ) : null} +
+
+

{comment.text}

+
+ ); + })} +
+
+ onCommentDraftChange(visit.id, value)} + onSubmit={() => onAddComment(visit)} + disabled={busyKey === `comment-${visit.id}`} + /> +
+
+ ); +} + +function VisitQuickActionButtons({ + busyKey, + visit, + onActionDialog, + onRunAction, +}: { + busyKey: string | null; + visit: GuestVisit; + onActionDialog: (kind: ActionKind) => void; + onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; +}) { + const canSubmitDirectly = visit.can_submit && visit.status !== "needs_info"; + const canProvideInfo = visit.status === "needs_info" && visit.can_submit; + if (!canSubmitDirectly && !canProvideInfo && !visit.can_approve && !visit.can_reject) return null; + + return ( +
+ {canSubmitDirectly ? ( + + ) : null} + {canProvideInfo ? ( + + ) : null} + {visit.can_approve ? ( + + ) : null} + {visit.can_reject ? ( + + ) : null} +
+ ); +} + +function VisitActionButtons({ + busyKey, + isMenuOpen = false, + menuRef = null, + showLabels = false, + variant = "full", + visit, + onActionDialog, + onEdit, + onRunAction, + onToggleMenu, +}: { + busyKey: string | null; + isMenuOpen?: boolean; + menuRef?: RefObject | null; + showLabels?: boolean; + variant?: "full" | "list"; + visit: GuestVisit; + onActionDialog: (kind: ActionKind) => void; + onEdit: (visit: GuestVisit) => void; + onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; + onToggleMenu?: (visitId: number | null) => void; +}) { + const buttonClass = (variantClass: string) => + `${variantClass} inline-flex h-9 items-center justify-center rounded-lg disabled:opacity-60 ${ + showLabels ? "gap-1.5 px-3 text-xs font-medium" : "w-9" + }`; + const label = (text: string) => (showLabels ? {text} : null); + const approvalIconSize = showLabels ? 14 : 18; + + if (variant === "full") { + return ( +
+ {visit.can_approve ? ( + + ) : null} + {visit.can_reject ? ( + + ) : null} + {visit.can_request_info ? ( + + ) : null} +
+ ); + } + + const meta = statusMeta[visit.status] || { label: visit.status, className: "app-badge" }; + const hasSecondaryActions = visit.can_edit + || visit.can_request_info + || visit.can_revoke + || visit.can_cancel + || visit.can_return_to_work + || visit.can_delete + || visit.can_sync_ldap; + + return ( +
+
+ {meta.label} + {hasSecondaryActions ? ( +
+ + {isMenuOpen ? ( +
+ {visit.can_edit ? ( + + ) : null} + {visit.can_request_info ? ( + + ) : null} + {visit.can_revoke ? ( + + ) : null} + {visit.can_cancel ? ( + + ) : null} + {visit.can_return_to_work ? ( + + ) : null} + {visit.can_delete ? ( + + ) : null} + {visit.can_sync_ldap ? ( + + ) : null} +
+ ) : null} +
+ ) : null} +
+ +
+ ); +} + +function VisitSecondaryActionsMenu({ + busyKey, + visit, + onActionDialog, + onEdit, + onRunAction, +}: { + busyKey: string | null; + visit: GuestVisit; + onActionDialog: (kind: ActionKind) => void; + onEdit: (visit: GuestVisit) => void; + onRunAction: (visit: GuestVisit, action: "submit" | "sync") => void | Promise; +}) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const hasActions = visit.can_submit + || visit.can_edit + || (visit.status === "needs_info" && visit.can_submit) + || visit.can_revoke + || visit.can_return_to_work + || visit.can_cancel + || visit.can_delete + || visit.can_sync_ldap; + + useEffect(() => { + if (!open) return; + + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + const target = event.target; + if (target instanceof Node && menuRef.current?.contains(target)) return; + setOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("touchstart", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("touchstart", handlePointerDown); + }; + }, [open]); + + if (!hasActions) return null; + + return ( +
+ + {open ? ( +
+ {visit.can_submit ? ( + + ) : null} + {visit.can_edit ? ( + + ) : null} + {visit.status === "needs_info" && visit.can_submit ? ( + + ) : null} + {visit.can_revoke ? ( + + ) : null} + {visit.can_return_to_work ? ( + + ) : null} + {visit.can_cancel ? ( + + ) : null} + {visit.can_delete ? ( + + ) : null} + {visit.can_sync_ldap ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +function guestAccessStatusMeta(guest: Guest): { label: string; className: string } { + if (guest.is_blacklisted) { + return { label: "Черный список", className: "app-feedback-danger" }; + } + if (guest.is_active) { + return { label: "Доступ есть", className: "app-feedback-success" }; + } + return { label: "Нет доступа", className: "app-badge" }; +} + +function GuestAccessStatusBadge({ guest }: { guest: Guest }) { + const meta = guestAccessStatusMeta(guest); + return ( + + {meta.label} + + ); +} + +function GuestAvatar({ + guest, + name, + size = "md", + src, +}: { + guest?: Guest | null; + name?: string; + size?: "sm" | "md" | "lg"; + src?: string; +}) { + const displayName = name || guestName(guest); + const imageSrc = resolveMediaUrl(src ?? guest?.avatar ?? ""); + const sizeClass = size === "lg" ? "h-16 w-16 text-lg" : size === "sm" ? "h-10 w-10 text-xs" : "h-12 w-12 text-sm"; + const pixels = size === "lg" ? 64 : size === "sm" ? 40 : 48; + + return ( +
+ {imageSrc ? ( + {displayName} + ) : ( + guestInitials(displayName) + )} +
+ ); +} + +function GuestAvatarPicker({ + name, + onSelectImage, + value, +}: { + name: string; + onSelectImage: (value: string) => void; + value: string; +}) { + const handleChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + try { + onSelectImage(await readFileAsDataUrl(file)); + } finally { + event.target.value = ""; + } + }; + + return ( +
+ +
+

Фото гостя

+

Используется в учетке и связанных сервисах.

+ +
+
+ ); +} + +function GuestVisitFormModal({ + busy, + documents, + documentsLoading, + editingVisit, + form, + guestOptions, + isOpen, + onClose, + onReloadDocuments, + onSubmit, + setForm, +}: { + busy: boolean; + documents: { id: number; name: string }[]; + documentsLoading: boolean; + editingVisit: GuestVisit | null; + form: GuestVisitFormState; + guestOptions: { id: number; name: string }[]; + isOpen: boolean; + onClose: () => void; + onReloadDocuments: () => void | Promise; + onSubmit: () => void | Promise; + setForm: (updater: GuestVisitFormState | ((current: GuestVisitFormState) => GuestVisitFormState)) => void; +}) { + const [step, setStep] = useState(0); + const [guestAvatarCropperImage, setGuestAvatarCropperImage] = useState(null); + const [documentToAddId, setDocumentToAddId] = useState(null); + const documentFileInputRef = useRef(null); + const setField = (key: K, value: GuestVisitFormState[K]) => { + setForm((current) => ({ ...current, [key]: value })); + }; + const handleClose = () => { + setGuestAvatarCropperImage(null); + onClose(); + }; + + const addExistingDocument = (id: number | null) => { + if (!id) return; + setDocumentToAddId(null); + setForm((current) => current.document_ids.includes(id) + ? current + : { ...current, document_ids: [...current.document_ids, id] }); + }; + const removeExistingDocument = (id: number) => { + setForm((current) => ({ + ...current, + document_ids: current.document_ids.filter((item) => item !== id), + })); + }; + const addPendingDocuments = (files: FileList | null) => { + if (!files?.length) return; + const nextDocuments = Array.from(files).map(createPendingGuestDocument); + setForm((current) => ({ + ...current, + pending_documents: [...current.pending_documents, ...nextDocuments], + })); + }; + const updatePendingDocumentTitle = (localId: string, title: string) => { + setForm((current) => ({ + ...current, + pending_documents: current.pending_documents.map((item) => ( + item.localId === localId ? { ...item, title } : item + )), + })); + }; + const removePendingDocument = (localId: string) => { + setForm((current) => ({ + ...current, + pending_documents: current.pending_documents.filter((item) => item.localId !== localId), + })); + }; + + const isCreateMode = !editingVisit; + const isLastStep = !isCreateMode || step === 2; + const stepLabels = ["Гость", "Период", "Цель"]; + const [guestExtraOpen, setGuestExtraOpen] = useState(false); + const filledGuestExtraCount = [ + form.patronymic, + form.birth_date, + form.phone, + form.email, + form.organization, + form.position, + form.guest_comment, + ].filter((value) => value.trim()).length; + + const documentsSection = ( +
+
+ Документы + + { + addPendingDocuments(event.target.files); + event.target.value = ""; + }} + /> +
+
+
+ !form.document_ids.includes(doc.id))} + selectedId={documentToAddId} + onSelect={(id) => { + setDocumentToAddId(id); + addExistingDocument(id); + }} + placeholder="Найти документ" + /> +
+ +
+
+ {form.document_ids.map((documentId) => { + const document = documents.find((item) => item.id === documentId); + return ( +
+ + +
+ ); + })} + {form.pending_documents.map((document) => ( +
+ updatePendingDocumentTitle(document.localId, event.target.value)} + className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm" + placeholder="Название документа" + /> + {formatFileSize(document.file.size)} + +
+ ))} + {form.document_ids.length === 0 && form.pending_documents.length === 0 ? ( +

Документы можно выбрать из реестра или загрузить новым файлом.

+ ) : null} +
+
+ ); + + const guestSection = !editingVisit ? ( +
+
+ + +
+ {form.guestMode === "existing" ? ( + setField("guest_id", id ? String(id) : "")} + placeholder="Выберите гостя" + /> + ) : ( +
+ +
+ setField("last_name", value)} /> + setField("first_name", value)} required /> +
+
+ )} + {documentsSection} + {form.guestMode === "new" ? ( +
+ + {guestExtraOpen ? ( +
+ setField("patronymic", value)} /> + setField("birth_date", value)} type="date" /> + setField("phone", value)} /> + setField("email", value)} type="email" /> + setField("organization", value)} /> + setField("position", value)} /> +