feat: complete guest module
This commit is contained in:
parent
dc35680156
commit
cd3e52e1e9
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from filer.models import File as FilerFile
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from communications.models import ChatReadState, Message
|
||||
from api.v1.documents.serializers import DocumentReadSerializer
|
||||
from documents.models import Document
|
||||
from guests.constants import GuestVisitEventType, GuestVisitStatus
|
||||
from guests.models import Guest, GuestVisit
|
||||
@ -40,6 +45,7 @@ class GuestVisitViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
queryset = (
|
||||
@ -119,8 +125,12 @@ class GuestVisitViewSet(
|
||||
)
|
||||
if params.get("unlimited") in {"true", "false"}:
|
||||
qs = qs.filter(unlimited=params.get("unlimited") == "true")
|
||||
if params.get("ldap_enabled") in {"true", "false"}:
|
||||
qs = qs.filter(guest__ldap_enabled=params.get("ldap_enabled") == "true")
|
||||
if params.get("is_active") in {"true", "false"}:
|
||||
qs = qs.filter(guest__is_active=params.get("is_active") == "true")
|
||||
if params.get("is_blacklisted") in {"true", "false"}:
|
||||
qs = qs.filter(
|
||||
guest__is_blacklisted=params.get("is_blacklisted") == "true"
|
||||
)
|
||||
if q := (params.get("q") or "").strip():
|
||||
qs = qs.filter(
|
||||
Q(guest__last_name__icontains=q)
|
||||
@ -171,6 +181,17 @@ class GuestVisitViewSet(
|
||||
visit = serializer.save()
|
||||
return self._read_response(visit)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
visit = self.get_object()
|
||||
guest = visit.guest
|
||||
visit.delete()
|
||||
GuestLdapService().sync_guest(
|
||||
guest,
|
||||
enqueue_on_error=True,
|
||||
raise_on_error=False,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def submit(self, request, pk=None):
|
||||
visit = self.get_object()
|
||||
@ -228,10 +249,17 @@ class GuestVisitViewSet(
|
||||
@action(detail=True, methods=["post"], url_path="provide-info")
|
||||
def provide_info(self, request, pk=None):
|
||||
visit = self.get_object()
|
||||
if visit.status != GuestVisitStatus.NEEDS_INFO:
|
||||
raise ValidationError("Ответить можно только на активный запрос информации.")
|
||||
comment = (request.data.get("comment") or "").strip()
|
||||
if not comment:
|
||||
raise ValidationError("Комментарий обязателен.")
|
||||
comments_helpers.create_comment(visit, request.user, comment)
|
||||
comments_helpers.create_comment(
|
||||
visit,
|
||||
request.user,
|
||||
comment,
|
||||
guest_visit_comment_type="info_response",
|
||||
)
|
||||
try:
|
||||
GuestVisitWorkflow.submit(visit, actor=request.user)
|
||||
except ValueError as exc:
|
||||
@ -253,6 +281,21 @@ class GuestVisitViewSet(
|
||||
raise ValidationError(str(exc)) from exc
|
||||
return self._read_response(visit)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="return-to-work")
|
||||
def return_to_work(self, request, pk=None):
|
||||
visit = self.get_object()
|
||||
if visit.inviter_id != request.user.id and not is_guest_admin(request.user):
|
||||
raise PermissionDenied("Недостаточно прав для возврата в работу.")
|
||||
try:
|
||||
GuestVisitWorkflow.return_to_work(
|
||||
visit,
|
||||
actor=request.user,
|
||||
comment=request.data.get("comment", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(str(exc)) from exc
|
||||
return self._read_response(visit)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def revoke(self, request, pk=None):
|
||||
if not can_decide_guest_visit(request.user):
|
||||
@ -273,20 +316,37 @@ class GuestVisitViewSet(
|
||||
if not can_manage_guest_account(request.user):
|
||||
raise PermissionDenied("Недостаточно прав для LDAP.")
|
||||
visit = self.get_object()
|
||||
GuestLdapService().sync_guest_for_visit(visit)
|
||||
try:
|
||||
GuestLdapService().sync_guest_for_visit(
|
||||
visit,
|
||||
enqueue_on_error=True,
|
||||
raise_on_error=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValidationError(str(exc)) from exc
|
||||
return self._read_response(visit)
|
||||
|
||||
@action(detail=True, methods=["get", "post"])
|
||||
def comments(self, request, pk=None):
|
||||
visit = self.get_object()
|
||||
if request.method == "GET":
|
||||
messages = comments_helpers.get_comments(visit)
|
||||
messages = list(comments_helpers.get_comments(visit))
|
||||
if messages:
|
||||
ChatReadState.objects.update_or_create(
|
||||
chat=messages[-1].chat,
|
||||
user=request.user,
|
||||
defaults={
|
||||
"last_read_message": messages[-1],
|
||||
"unread_count": 0,
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"id": msg.id,
|
||||
"author": EmployeeBriefSerializer(msg.author).data,
|
||||
"text": msg.content,
|
||||
"metadata": msg.system_metadata,
|
||||
"created_at": msg.created_at,
|
||||
}
|
||||
for msg in messages
|
||||
@ -298,11 +358,12 @@ class GuestVisitViewSet(
|
||||
msg = comments_helpers.create_comment(visit, request.user, text)
|
||||
return Response(
|
||||
{
|
||||
"id": msg.id,
|
||||
"author": EmployeeBriefSerializer(msg.author).data,
|
||||
"text": msg.content,
|
||||
"created_at": msg.created_at,
|
||||
},
|
||||
"id": msg.id,
|
||||
"author": EmployeeBriefSerializer(msg.author).data,
|
||||
"text": msg.content,
|
||||
"metadata": msg.system_metadata,
|
||||
"created_at": msg.created_at,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@ -326,6 +387,7 @@ class GuestVisitViewSet(
|
||||
document_id = request.data.get("document_id")
|
||||
document = get_object_or_404(Document, pk=document_id)
|
||||
visit.documents.add(document)
|
||||
visit.guest.documents.add(document)
|
||||
record_guest_event(
|
||||
visit,
|
||||
GuestVisitEventType.DOCUMENT_ATTACHED,
|
||||
@ -358,12 +420,54 @@ class GuestViewSet(
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
queryset = Guest.objects.all().order_by("last_name", "first_name", "id")
|
||||
queryset = (
|
||||
Guest.objects.select_related("document_folder")
|
||||
.prefetch_related("documents")
|
||||
.order_by("last_name", "first_name", "id")
|
||||
)
|
||||
serializer_class = GuestSerializer
|
||||
permission_classes = [GuestAdminPermission]
|
||||
parser_classes = (MultiPartParser, FormParser, JSONParser)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.pop("partial", False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(
|
||||
instance,
|
||||
data=request.data,
|
||||
partial=partial,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
guest = serializer.save()
|
||||
GuestLdapService().sync_guest(
|
||||
guest,
|
||||
enqueue_on_error=True,
|
||||
raise_on_error=False,
|
||||
)
|
||||
guest.refresh_from_db()
|
||||
return Response(self.get_serializer(guest).data)
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
guest_ct = ContentType.objects.get_for_model(Guest)
|
||||
comments_subquery = (
|
||||
Message.objects.filter(
|
||||
chat__type="comments",
|
||||
chat__context_content_type=guest_ct,
|
||||
chat__context_object_id=OuterRef("pk"),
|
||||
is_deleted=False,
|
||||
)
|
||||
.values("chat")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")
|
||||
)
|
||||
qs = qs.annotate(
|
||||
comments_count=Coalesce(
|
||||
Subquery(comments_subquery, output_field=IntegerField()),
|
||||
Value(0),
|
||||
),
|
||||
visits_count=Count("visits", distinct=True),
|
||||
)
|
||||
q = (self.request.query_params.get("q") or "").strip()
|
||||
if q:
|
||||
text_q = (
|
||||
@ -377,6 +481,27 @@ class GuestViewSet(
|
||||
if q.isdigit():
|
||||
text_q |= Q(id=int(q))
|
||||
qs = qs.filter(text_q)
|
||||
if self.request.query_params.get("is_active") in {"true", "false"}:
|
||||
qs = qs.filter(
|
||||
is_active=self.request.query_params.get("is_active") == "true"
|
||||
)
|
||||
if self.request.query_params.get("is_blacklisted") in {"true", "false"}:
|
||||
qs = qs.filter(
|
||||
is_blacklisted=self.request.query_params.get("is_blacklisted") == "true"
|
||||
)
|
||||
ordering = self.request.query_params.get("ordering")
|
||||
allowed_ordering = {
|
||||
"last_name",
|
||||
"-last_name",
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
"ldap_last_synced_at",
|
||||
"-ldap_last_synced_at",
|
||||
}
|
||||
if ordering in allowed_ordering:
|
||||
qs = qs.order_by(ordering, "id")
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
@ -388,16 +513,150 @@ class GuestViewSet(
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def deactivate(self, request, pk=None):
|
||||
def _guest_document_response(
|
||||
self,
|
||||
guest: Guest,
|
||||
document: Document,
|
||||
response_status=status.HTTP_200_OK,
|
||||
):
|
||||
guest.refresh_from_db()
|
||||
return Response(
|
||||
{
|
||||
"guest": self.get_serializer(guest).data,
|
||||
"document": DocumentReadSerializer(
|
||||
document,
|
||||
context=self.get_serializer_context(),
|
||||
).data,
|
||||
},
|
||||
status=response_status,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post"])
|
||||
def comments(self, request, pk=None):
|
||||
guest = self.get_object()
|
||||
guest.is_active = False
|
||||
guest.save(update_fields=["is_active", "updated_at"])
|
||||
GuestLdapService().disable_guest(guest)
|
||||
if request.method == "GET":
|
||||
messages = comments_helpers.get_comments(guest)
|
||||
return Response(
|
||||
[
|
||||
{
|
||||
"id": msg.id,
|
||||
"author": EmployeeBriefSerializer(msg.author).data,
|
||||
"text": msg.content,
|
||||
"metadata": msg.system_metadata,
|
||||
"created_at": msg.created_at,
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
)
|
||||
text = (request.data.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ValidationError({"text": "Это поле не может быть пустым."})
|
||||
msg = comments_helpers.create_comment(guest, request.user, text)
|
||||
return Response(
|
||||
{
|
||||
"id": msg.id,
|
||||
"author": EmployeeBriefSerializer(msg.author).data,
|
||||
"text": msg.content,
|
||||
"metadata": msg.system_metadata,
|
||||
"created_at": msg.created_at,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["delete"],
|
||||
url_path="comments/(?P<comment_id>[^/.]+)",
|
||||
)
|
||||
def delete_comment(self, request, pk=None, comment_id=None):
|
||||
guest = self.get_object()
|
||||
chat = comments_helpers.get_or_create_comments_chat(guest)
|
||||
message = get_object_or_404(Message, id=comment_id, chat=chat)
|
||||
if message.author_id != request.user.id and not is_guest_admin(request.user):
|
||||
raise PermissionDenied("Недостаточно прав для удаления комментария.")
|
||||
comments_helpers.delete_comment(message, request.user, soft_delete=True)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="documents")
|
||||
def documents(self, request, pk=None):
|
||||
guest = self.get_object()
|
||||
document_id = request.data.get("document_id")
|
||||
if document_id:
|
||||
document = get_object_or_404(Document, pk=document_id)
|
||||
guest.documents.add(document)
|
||||
return self._guest_document_response(guest, document)
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if not upload:
|
||||
raise ValidationError({"file": "Передайте файл или document_id."})
|
||||
|
||||
folder = guest.ensure_document_folder(owner=request.user)
|
||||
file_name = getattr(upload, "name", "") or "document"
|
||||
title = (request.data.get("title") or "").strip()
|
||||
if not title:
|
||||
title = os.path.splitext(os.path.basename(file_name))[0] or file_name
|
||||
filer_file = FilerFile.objects.create(
|
||||
file=upload,
|
||||
folder=folder,
|
||||
original_filename=file_name,
|
||||
name=file_name,
|
||||
owner=request.user,
|
||||
is_public=True,
|
||||
)
|
||||
document = Document.objects.create(
|
||||
title=title,
|
||||
description=(request.data.get("description") or "").strip(),
|
||||
file=filer_file,
|
||||
folder=folder,
|
||||
uploaded_by=request.user,
|
||||
modified_by=request.user,
|
||||
sent_to_all=False,
|
||||
)
|
||||
guest.documents.add(document)
|
||||
return self._guest_document_response(
|
||||
guest,
|
||||
document,
|
||||
response_status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["delete"],
|
||||
url_path="documents/(?P<document_id>[^/.]+)",
|
||||
)
|
||||
def remove_document(self, request, pk=None, document_id=None):
|
||||
if not is_guest_admin(request.user):
|
||||
raise PermissionDenied("Недостаточно прав для изменения гостя.")
|
||||
guest = self.get_object()
|
||||
document = get_object_or_404(Document, pk=document_id)
|
||||
guest.documents.remove(document)
|
||||
return self._guest_document_response(guest, document)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def blacklist(self, request, pk=None):
|
||||
guest = self.get_object()
|
||||
GuestLdapService().blacklist_guest(guest)
|
||||
guest.refresh_from_db()
|
||||
return Response(self.get_serializer(guest).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="unblacklist")
|
||||
def unblacklist(self, request, pk=None):
|
||||
guest = self.get_object()
|
||||
GuestLdapService().unblacklist_guest(guest)
|
||||
guest.refresh_from_db()
|
||||
return Response(self.get_serializer(guest).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="sync-ldap")
|
||||
def sync_ldap(self, request, pk=None):
|
||||
guest = self.get_object()
|
||||
GuestLdapService().sync_guest(guest)
|
||||
try:
|
||||
GuestLdapService().sync_guest(
|
||||
guest,
|
||||
enqueue_on_error=True,
|
||||
raise_on_error=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
guest.refresh_from_db()
|
||||
raise ValidationError(str(exc)) from exc
|
||||
guest.refresh_from_db()
|
||||
return Response(self.get_serializer(guest).data)
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -29,6 +29,7 @@ class LdapRouter:
|
||||
"ldapgroup",
|
||||
"ldaporganizationalunit",
|
||||
"ldaporganizationalunitgroup",
|
||||
"ldapguestuser",
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
16
backend/guests/migrations/0005_guest_last_name_optional.py
Normal file
16
backend/guests/migrations/0005_guest_last_name_optional.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("guests", "0004_guest_blacklist_remove_ldap_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="guest",
|
||||
name="last_name",
|
||||
field=models.CharField(blank=True, max_length=150, verbose_name="Фамилия"),
|
||||
),
|
||||
]
|
||||
@ -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="Документы гостя",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -226,7 +226,7 @@ LDAP-поля и состояние:
|
||||
Дополнительные поля:
|
||||
|
||||
- `visit_comment`: комментарий заявителя;
|
||||
- `admin_comment`: внутренний комментарий администратора;
|
||||
- запросы уточнений и ответы хранятся в комментариях `communications`, а не в отдельном поле визита;
|
||||
- `host_department`: отдел приглашающего на момент создания, nullable;
|
||||
- `cancelled_at`, `cancelled_by`, `cancel_reason`;
|
||||
- `revoked_at`, `revoked_by`, `revoke_reason`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
4037
frontend/src/app/guests/page.tsx
Normal file
4037
frontend/src/app/guests/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, ShoppingCart, Users } from "lucide-react";
|
||||
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { ReactNode, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useNotifications } from "@/hooks/useApi";
|
||||
@ -14,6 +14,9 @@ import { useCalendarModals } from "@/hooks/useCalendarModals";
|
||||
import { CalendarModals } from "@/components/layout/CalendarModals";
|
||||
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
|
||||
import { usePwa } from "@/contexts/PwaContext";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { GuestVisit, GuestVisitComment } from "@/types/api";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
@ -37,6 +40,12 @@ type LeftNavContentProps = {
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
type PaginatedResponse<T> = {
|
||||
count?: number;
|
||||
next?: string | null;
|
||||
results?: T[];
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Лента", icon: HomeIcon, category: "Новости" },
|
||||
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" },
|
||||
@ -44,6 +53,7 @@ const navItems = [
|
||||
{ href: "/departments", label: "Отделы", icon: Building2 },
|
||||
{ href: "/attendance", label: "Посещаемость", icon: CalendarCheck },
|
||||
{ href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" },
|
||||
{ href: "/guests", label: "Гости", icon: UserRoundPlus, category: "Гости" },
|
||||
// { href: "/equipment", label: "Оборудование", icon: Monitor },
|
||||
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки", autoReadOnNavigate: false },
|
||||
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
|
||||
@ -403,6 +413,151 @@ export function PageHeader({ title, subtitle, badge, eyebrow }: PageHeaderProps
|
||||
);
|
||||
}
|
||||
|
||||
const toResults = <T,>(payload: PaginatedResponse<T> | T[]): T[] => {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
return payload.results || [];
|
||||
};
|
||||
|
||||
const guestDisplayName = (visit: GuestVisit): string => (
|
||||
visit.guest.full_name
|
||||
|| [visit.guest.last_name, visit.guest.first_name, visit.guest.patronymic].filter(Boolean).join(" ")
|
||||
|| `Гость #${visit.guest.id}`
|
||||
);
|
||||
|
||||
const latestInfoRequestText = (visit: GuestVisit, comments: GuestVisitComment[]): string => {
|
||||
const requestComment = [...comments]
|
||||
.reverse()
|
||||
.find((comment) => comment.metadata?.guest_visit_comment_type === "info_request");
|
||||
if (requestComment?.text) return requestComment.text;
|
||||
|
||||
const requestEvent = [...(visit.events || [])]
|
||||
.reverse()
|
||||
.find((event) => event.event_type === "needs_info_requested" && event.comment);
|
||||
return requestEvent?.comment || "Администратор запросил дополнительную информацию по гостевой заявке.";
|
||||
};
|
||||
|
||||
function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
|
||||
const [visit, setVisit] = useState<GuestVisit | null>(null);
|
||||
const [comments, setComments] = useState<GuestVisitComment[]>([]);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPendingRequest = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.getGuestVisits({
|
||||
scope: "mine",
|
||||
status: "needs_info",
|
||||
ordering: "-updated_at",
|
||||
page: 1,
|
||||
limit: 1,
|
||||
}) as PaginatedResponse<GuestVisit> | GuestVisit[];
|
||||
const pendingVisit = toResults(response)[0] || null;
|
||||
setVisit(pendingVisit);
|
||||
if (!pendingVisit) {
|
||||
setComments([]);
|
||||
setAnswer("");
|
||||
return;
|
||||
}
|
||||
const commentResponse = await apiClient.getGuestVisitComments(pendingVisit.id) as GuestVisitComment[];
|
||||
setComments(commentResponse);
|
||||
} catch {
|
||||
setVisit(null);
|
||||
setComments([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
void loadPendingRequest();
|
||||
const interval = window.setInterval(() => void loadPendingRequest(), 30000);
|
||||
|
||||
const handleFocus = () => void loadPendingRequest();
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "visible") void loadPendingRequest();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, [loadPendingRequest, userId]);
|
||||
|
||||
const question = useMemo(
|
||||
() => visit ? latestInfoRequestText(visit, comments) : "",
|
||||
[comments, visit],
|
||||
);
|
||||
|
||||
const submitAnswer = async () => {
|
||||
if (!visit || !answer.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
await apiClient.provideGuestVisitInfo(visit.id, { comment: answer.trim() });
|
||||
setAnswer("");
|
||||
await loadPendingRequest();
|
||||
} catch (err) {
|
||||
setError(String((err as Error)?.message || "Не удалось отправить ответ"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={Boolean(visit)}
|
||||
onClose={() => undefined}
|
||||
title="Требуется информация"
|
||||
size="md"
|
||||
showCloseButton={false}
|
||||
closeOnEsc={false}
|
||||
closeOnClickOutside={false}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submitAnswer()}
|
||||
disabled={submitting || !answer.trim()}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
{submitting ? <Loader2 className="animate-spin" size={15} /> : <Send size={15} />}
|
||||
Отправить ответ
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="app-feedback-warning flex items-start gap-3 rounded-xl p-4 text-sm">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={18} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-[var(--foreground)]">{visit ? guestDisplayName(visit) : "Гостевая заявка"}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap">{loading ? "Загружаем запрос..." : question}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Ответ *</span>
|
||||
<textarea
|
||||
value={answer}
|
||||
onChange={(event) => setAnswer(event.target.value)}
|
||||
rows={5}
|
||||
className="app-input w-full resize-none rounded-lg p-3 text-sm"
|
||||
placeholder="Напишите уточнение для администратора"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{error}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const { user, loading } = useUser();
|
||||
const { mobileNavPlacement } = useMobileNavPlacement();
|
||||
@ -529,6 +684,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
/>
|
||||
|
||||
<CalendarModals {...cal} />
|
||||
<GuestInfoRequestPrompt userId={user.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,33 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type DropdownPosition = {
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
top: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
function getDropdownPosition(anchor: HTMLElement): DropdownPosition {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportPadding = 8;
|
||||
const preferredHeight = 224;
|
||||
const belowSpace = window.innerHeight - rect.bottom - viewportPadding;
|
||||
const aboveSpace = rect.top - viewportPadding;
|
||||
const openUp = belowSpace < 180 && aboveSpace > belowSpace;
|
||||
const availableHeight = Math.max(120, openUp ? aboveSpace : belowSpace);
|
||||
const maxHeight = Math.min(preferredHeight, availableHeight);
|
||||
|
||||
return {
|
||||
left: rect.left,
|
||||
maxHeight,
|
||||
top: openUp ? rect.top - maxHeight - gap : rect.bottom + gap,
|
||||
width: rect.width,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Single-select dropdown with search ── */
|
||||
export function SearchableSelectSingle<T extends string | number>({
|
||||
@ -22,18 +49,45 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const target = e.target as Node;
|
||||
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedName = items.find((i) => i.id === selectedId)?.name;
|
||||
const toggleOpen = () => {
|
||||
if (!open && ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
setOpen((value) => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
@ -41,7 +95,7 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={toggleOpen}
|
||||
className="app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<span className="truncate">
|
||||
@ -49,8 +103,17 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
</span>
|
||||
<ChevronDown size={14} className={`app-text-muted ml-2 shrink-0 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="app-menu absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg">
|
||||
{open && dropdownPosition && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="app-menu fixed z-[130] overflow-hidden rounded-lg"
|
||||
style={{
|
||||
left: dropdownPosition.left,
|
||||
maxHeight: dropdownPosition.maxHeight,
|
||||
top: dropdownPosition.top,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
<div className="app-divider border-b p-2">
|
||||
<input
|
||||
value={q}
|
||||
@ -60,7 +123,10 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
<div
|
||||
className="overflow-y-auto p-1"
|
||||
style={{ maxHeight: Math.max(72, dropdownPosition.maxHeight - 54) }}
|
||||
>
|
||||
{selectedId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
@ -85,7 +151,8 @@ export function SearchableSelectSingle<T extends string | number>({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -113,27 +180,54 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [dropdownPosition, setDropdownPosition] = useState<DropdownPosition | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const target = e.target as Node;
|
||||
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
|
||||
const selectedItems = items.filter((i) => selectedIds.includes(i.id));
|
||||
const selectedNames = selectedItems.map((i) => i.name);
|
||||
const isInline = layout === "inline";
|
||||
const toggleOpen = () => {
|
||||
if (!open && ref.current) {
|
||||
setDropdownPosition(getDropdownPosition(ref.current));
|
||||
}
|
||||
setOpen((value) => !value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`relative ${className}`}>
|
||||
{!isInline && <label className="app-text-muted mb-1 block text-xs font-medium">{label}</label>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={toggleOpen}
|
||||
className={isInline
|
||||
? "app-input flex w-full items-start gap-3 rounded-2xl px-3 py-3 text-left text-sm"
|
||||
: "app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm"}
|
||||
@ -162,8 +256,17 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
)}
|
||||
<ChevronDown size={14} className={`app-text-muted ${isInline ? "mt-1" : "ml-2"} shrink-0 transition ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={`app-menu absolute z-50 mt-1 max-h-56 overflow-hidden rounded-lg ${isInline ? "inset-x-0" : "w-full"}`}>
|
||||
{open && dropdownPosition && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="app-menu fixed z-[130] overflow-hidden rounded-lg"
|
||||
style={{
|
||||
left: dropdownPosition.left,
|
||||
maxHeight: dropdownPosition.maxHeight,
|
||||
top: dropdownPosition.top,
|
||||
width: dropdownPosition.width,
|
||||
}}
|
||||
>
|
||||
<div className="app-divider border-b p-2">
|
||||
<input
|
||||
value={q}
|
||||
@ -173,7 +276,10 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto p-1">
|
||||
<div
|
||||
className="overflow-y-auto p-1"
|
||||
style={{ maxHeight: Math.max(72, dropdownPosition.maxHeight - 54) }}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="app-text-muted px-2 py-1.5 text-xs">Ничего не найдено</p>
|
||||
) : (
|
||||
@ -190,7 +296,8 @@ export function SearchableSelectMulti<T extends { id: number; name: string }>({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
let openModalCount = 0;
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@ -38,14 +40,16 @@ export function Modal({
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
openModalCount += 1;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
openModalCount = Math.max(0, openModalCount - 1);
|
||||
if (openModalCount === 0) {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
|
||||
137
frontend/src/lib/api/guests.ts
Normal file
137
frontend/src/lib/api/guests.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { buildQuery, type RequestFn } from './utils';
|
||||
|
||||
export type GuestVisitActionPayload = {
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export type GuestDocumentMutationResponse = {
|
||||
guest: unknown;
|
||||
document: unknown;
|
||||
};
|
||||
|
||||
export function createGuestsApi(request: RequestFn) {
|
||||
return {
|
||||
getGuestVisits: (params?: Record<string, string | number | boolean | undefined | null>) =>
|
||||
request(`/api/v1/guests/visits/${buildQuery(params)}`),
|
||||
getGuestVisit: (id: number) =>
|
||||
request(`/api/v1/guests/visits/${id}/`),
|
||||
createGuestVisit: (data: Record<string, unknown>) =>
|
||||
request('/api/v1/guests/visits/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateGuestVisit: (id: number, data: Record<string, unknown>) =>
|
||||
request(`/api/v1/guests/visits/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteGuestVisit: (id: number): Promise<void> =>
|
||||
request(`/api/v1/guests/visits/${id}/`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
submitGuestVisit: (id: number) =>
|
||||
request(`/api/v1/guests/visits/${id}/submit/`, { method: 'POST' }),
|
||||
approveGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/approve/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload?.comment || '' }),
|
||||
}),
|
||||
rejectGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/reject/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload?.comment || '' }),
|
||||
}),
|
||||
requestGuestVisitInfo: (id: number, payload: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/request-info/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload.comment || '' }),
|
||||
}),
|
||||
provideGuestVisitInfo: (id: number, payload: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/provide-info/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload.comment || '' }),
|
||||
}),
|
||||
cancelGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/cancel/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload?.comment || '' }),
|
||||
}),
|
||||
revokeGuestVisit: (id: number, payload?: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/revoke/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload?.comment || '' }),
|
||||
}),
|
||||
returnGuestVisitToWork: (id: number, payload?: GuestVisitActionPayload) =>
|
||||
request(`/api/v1/guests/visits/${id}/return-to-work/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment: payload?.comment || '' }),
|
||||
}),
|
||||
syncGuestVisitLdap: (id: number) =>
|
||||
request(`/api/v1/guests/visits/${id}/sync-ldap/`, { method: 'POST' }),
|
||||
getGuestVisitComments: (visitId: number) =>
|
||||
request(`/api/v1/guests/visits/${visitId}/comments/`),
|
||||
addGuestVisitComment: (visitId: number, text: string) =>
|
||||
request(`/api/v1/guests/visits/${visitId}/comments/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
deleteGuestVisitComment: (visitId: number, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/guests/visits/${visitId}/comments/${commentId}/`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
attachGuestVisitDocument: (visitId: number, documentId: number) =>
|
||||
request(`/api/v1/guests/visits/${visitId}/documents/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ document_id: documentId }),
|
||||
}),
|
||||
removeGuestVisitDocument: (visitId: number, documentId: number) =>
|
||||
request(`/api/v1/guests/visits/${visitId}/documents/${documentId}/`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
getGuests: (params?: Record<string, string | number | boolean | undefined | null>) =>
|
||||
request(`/api/v1/guests/${buildQuery(params)}`),
|
||||
getGuest: (id: number | string) =>
|
||||
request(`/api/v1/guests/${id}/`),
|
||||
updateGuest: (id: number | string, data: Record<string, unknown>) =>
|
||||
request(`/api/v1/guests/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getGuestComments: (id: number | string) =>
|
||||
request(`/api/v1/guests/${id}/comments/`),
|
||||
addGuestComment: (id: number | string, text: string) =>
|
||||
request(`/api/v1/guests/${id}/comments/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
deleteGuestComment: (id: number | string, commentId: number): Promise<void> =>
|
||||
request(`/api/v1/guests/${id}/comments/${commentId}/`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
attachGuestDocument: (id: number | string, documentId: number) =>
|
||||
request(`/api/v1/guests/${id}/documents/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ document_id: documentId }),
|
||||
}),
|
||||
uploadGuestDocument: (id: number | string, data: { file: File; title?: string; description?: string }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', data.file);
|
||||
if (data.title?.trim()) fd.append('title', data.title.trim());
|
||||
if (data.description?.trim()) fd.append('description', data.description.trim());
|
||||
return request(`/api/v1/guests/${id}/documents/`, { method: 'POST', body: fd });
|
||||
},
|
||||
removeGuestDocument: (id: number | string, documentId: number) =>
|
||||
request(`/api/v1/guests/${id}/documents/${documentId}/`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
searchGuests: (params?: Record<string, string | number | boolean | undefined | null>) =>
|
||||
request(`/api/v1/guests/search/${buildQuery(params)}`),
|
||||
blacklistGuest: (id: number | string) =>
|
||||
request(`/api/v1/guests/${id}/blacklist/`, { method: 'POST' }),
|
||||
unblacklistGuest: (id: number | string) =>
|
||||
request(`/api/v1/guests/${id}/unblacklist/`, { method: 'POST' }),
|
||||
syncGuestLdap: (id: number | string) =>
|
||||
request(`/api/v1/guests/${id}/sync-ldap/`, { method: 'POST' }),
|
||||
};
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { createRequestsApi } from './requests';
|
||||
import { createEquipmentApi } from './equipment';
|
||||
import { createProcurementApi } from './procurement';
|
||||
import { createAttendanceApi } from './attendance';
|
||||
import { createGuestsApi } from './guests';
|
||||
|
||||
const base = new ApiClientBase();
|
||||
const req = base.request.bind(base);
|
||||
@ -32,6 +33,7 @@ export const apiClient = Object.assign(base, {
|
||||
...createEquipmentApi(req, tok),
|
||||
...createProcurementApi(req),
|
||||
...createAttendanceApi(req, tok),
|
||||
...createGuestsApi(req),
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@ -48,6 +48,7 @@ export function resolveNotificationActionUrl(
|
||||
: null;
|
||||
|
||||
const requestId = parseNumericId(data?.request_id);
|
||||
const visitId = parseNumericId(data?.visit_id);
|
||||
const documentId = parseNumericId(data?.document_id);
|
||||
const eventId = parseNumericId(data?.event_id);
|
||||
const postId = parseNumericId(data?.post_id);
|
||||
@ -63,6 +64,15 @@ export function resolveNotificationActionUrl(
|
||||
objectType,
|
||||
].join(" ").toLowerCase();
|
||||
|
||||
if (
|
||||
visitId !== null && (
|
||||
categoryHint.includes("guest")
|
||||
|| objectType === "guestvisit"
|
||||
)
|
||||
) {
|
||||
return buildUrl("/guests", { visit: visitId });
|
||||
}
|
||||
|
||||
if (requestId !== null && categoryHint.includes("procurement")) {
|
||||
return buildUrl("/procurement", { request: requestId });
|
||||
}
|
||||
@ -141,6 +151,13 @@ export function resolveNotificationActionUrl(
|
||||
return buildUrl("/requests", { request: rawLinkedRequestId });
|
||||
}
|
||||
|
||||
if (url.pathname === "/guests") {
|
||||
const rawLinkedVisitId = parseNumericId(
|
||||
url.searchParams.get("visit") ?? url.searchParams.get("id"),
|
||||
);
|
||||
return buildUrl("/guests", { visit: rawLinkedVisitId });
|
||||
}
|
||||
|
||||
if (url.pathname === "/documents") {
|
||||
const rawLinkedDocumentId = parseNumericId(
|
||||
url.searchParams.get("document") ?? url.searchParams.get("id"),
|
||||
@ -175,6 +192,11 @@ export function resolveNotificationActionUrl(
|
||||
return buildUrl("/requests", { request: Number(requestMatch[1]) });
|
||||
}
|
||||
|
||||
const guestVisitMatch = rawActionUrl.match(/^\/guests\/visits\/(\d+)\/?(?:[#?].*)?$/);
|
||||
if (guestVisitMatch) {
|
||||
return buildUrl("/guests", { visit: Number(guestVisitMatch[1]) });
|
||||
}
|
||||
|
||||
const documentMatch = rawActionUrl.match(/^\/documents\/(\d+)\/?(?:[#?].*)?$/);
|
||||
if (documentMatch) {
|
||||
return buildUrl("/documents", { document: Number(documentMatch[1]) });
|
||||
|
||||
@ -107,6 +107,43 @@ export function canViewRequestStatistics(user?: User | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function canViewAllGuestVisits(user?: User | null): boolean {
|
||||
if (!user?.auth) return false;
|
||||
if (user.auth.is_staff || user.auth.is_superuser) return true;
|
||||
const perms = user.auth.permissions || [];
|
||||
const guestPerms = user.auth.permissions_by_app?.["guests"] || [];
|
||||
return (
|
||||
perms.includes("guests.view_all_guestvisit") ||
|
||||
perms.includes("guests.decide_guestvisit") ||
|
||||
perms.includes("guests.manage_guestaccount") ||
|
||||
guestPerms.includes("view_all_guestvisit") ||
|
||||
guestPerms.includes("decide_guestvisit") ||
|
||||
guestPerms.includes("manage_guestaccount")
|
||||
);
|
||||
}
|
||||
|
||||
export function canDecideGuestVisits(user?: User | null): boolean {
|
||||
if (!user?.auth) return false;
|
||||
if (user.auth.is_staff || user.auth.is_superuser) return true;
|
||||
const perms = user.auth.permissions || [];
|
||||
const guestPerms = user.auth.permissions_by_app?.["guests"] || [];
|
||||
return (
|
||||
perms.includes("guests.decide_guestvisit") ||
|
||||
guestPerms.includes("decide_guestvisit")
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageGuestAccounts(user?: User | null): boolean {
|
||||
if (!user?.auth) return false;
|
||||
if (user.auth.is_staff || user.auth.is_superuser) return true;
|
||||
const perms = user.auth.permissions || [];
|
||||
const guestPerms = user.auth.permissions_by_app?.["guests"] || [];
|
||||
return (
|
||||
perms.includes("guests.manage_guestaccount") ||
|
||||
guestPerms.includes("manage_guestaccount")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, может ли пользователь управлять оборудованием.
|
||||
*
|
||||
|
||||
@ -16,6 +16,17 @@ export const VERB_CATEGORIES: Record<string, string> = {
|
||||
request_comment: 'Заявления',
|
||||
request_commented: 'Заявления',
|
||||
request_status_changed: 'Заявления',
|
||||
|
||||
// Гости
|
||||
guest_visit_submitted: 'Гости',
|
||||
guest_visit_info_provided: 'Гости',
|
||||
guest_visit_needs_info: 'Гости',
|
||||
guest_visit_approved: 'Гости',
|
||||
guest_visit_rejected: 'Гости',
|
||||
guest_visit_revoked: 'Гости',
|
||||
guest_visit_expired: 'Гости',
|
||||
guest_inviter_inactive: 'Гости',
|
||||
guest_ldap_failed: 'Гости',
|
||||
|
||||
// Сообщения и чат
|
||||
chat_new_message: 'Сообщения',
|
||||
@ -111,6 +122,17 @@ export const VERB_NAMES: Record<string, string> = {
|
||||
request_comment: 'Комментарий к заявлению',
|
||||
request_commented: 'Комментарий к заявлению',
|
||||
request_status_changed: 'Статус заявления изменён',
|
||||
|
||||
// Гости
|
||||
guest_visit_submitted: 'Гостевой визит на рассмотрении',
|
||||
guest_visit_info_provided: 'Информация по гостевому визиту предоставлена',
|
||||
guest_visit_needs_info: 'Нужна информация по гостевому визиту',
|
||||
guest_visit_approved: 'Гостевой визит одобрен',
|
||||
guest_visit_rejected: 'Гостевой визит отклонён',
|
||||
guest_visit_revoked: 'Гостевой доступ отозван',
|
||||
guest_visit_expired: 'Гостевой доступ истёк',
|
||||
guest_inviter_inactive: 'Приглашающий гостя неактивен',
|
||||
guest_ldap_failed: 'Ошибка синхронизации гостя',
|
||||
|
||||
// Сообщения и чат
|
||||
chat_new_message: 'Новое сообщение',
|
||||
@ -211,6 +233,8 @@ const VERB_TOKEN_NAMES: Record<string, string> = {
|
||||
employee: 'сотрудник',
|
||||
event: 'событие',
|
||||
feed: 'лента',
|
||||
guest: 'гость',
|
||||
guests: 'гости',
|
||||
hour: 'за час',
|
||||
item: 'позиция',
|
||||
maintenance: 'обслуживание',
|
||||
@ -238,6 +262,7 @@ const VERB_TOKEN_NAMES: Record<string, string> = {
|
||||
*/
|
||||
export function getVerbCategory(verb: string): string {
|
||||
if (VERB_CATEGORIES[verb]) return VERB_CATEGORIES[verb];
|
||||
if (verb.startsWith('guest_')) return 'Гости';
|
||||
if (verb.startsWith('procurement_') || verb.startsWith('equipment_')) return 'Закупки';
|
||||
return 'Общее';
|
||||
}
|
||||
|
||||
@ -312,6 +312,127 @@ export interface Document {
|
||||
is_acknowledged?: boolean;
|
||||
}
|
||||
|
||||
// Guests
|
||||
export type GuestVisitStatus =
|
||||
| "draft"
|
||||
| "pending"
|
||||
| "needs_info"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "expired"
|
||||
| "revoked";
|
||||
|
||||
export interface Guest {
|
||||
id: number;
|
||||
full_name?: string;
|
||||
last_name: string;
|
||||
first_name: string;
|
||||
patronymic?: string;
|
||||
birth_date?: string | null;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
organization?: string;
|
||||
position?: string;
|
||||
comments_count?: number;
|
||||
visits_count?: number;
|
||||
document_folder?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
documents?: Document[];
|
||||
created_by?: User | null;
|
||||
is_active: boolean;
|
||||
is_blacklisted: boolean;
|
||||
ldap_username?: string;
|
||||
ldap_upn?: string;
|
||||
ldap_last_synced_at?: string | null;
|
||||
ldap_last_error?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GuestVisitEvent {
|
||||
id: number;
|
||||
event_type: string;
|
||||
actor?: User | null;
|
||||
from_status?: string;
|
||||
to_status?: string;
|
||||
comment?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GuestVisit {
|
||||
id: number;
|
||||
guest: Guest;
|
||||
inviter: User;
|
||||
inviter_snapshot_name?: string;
|
||||
inviter_snapshot_email?: string;
|
||||
host_department?: Department | null;
|
||||
purpose: string;
|
||||
visit_comment?: string;
|
||||
status: GuestVisitStatus;
|
||||
status_display?: string;
|
||||
access_starts_at?: string | null;
|
||||
access_expires_at?: string | null;
|
||||
all_day: boolean;
|
||||
unlimited: boolean;
|
||||
documents: Document[];
|
||||
submitted_at?: string | null;
|
||||
decided_by?: User | null;
|
||||
decided_at?: string | null;
|
||||
decision_comment?: string;
|
||||
cancelled_at?: string | null;
|
||||
cancel_reason?: string;
|
||||
revoked_at?: string | null;
|
||||
revoke_reason?: string;
|
||||
expired_at?: string | null;
|
||||
inviter_inactive: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active_now: boolean;
|
||||
is_expired: boolean;
|
||||
can_edit: boolean;
|
||||
can_submit: boolean;
|
||||
can_decide: boolean;
|
||||
can_approve: boolean;
|
||||
can_reject: boolean;
|
||||
can_request_info: boolean;
|
||||
can_cancel: boolean;
|
||||
can_revoke: boolean;
|
||||
can_return_to_work: boolean;
|
||||
can_sync_ldap: boolean;
|
||||
can_delete: boolean;
|
||||
comments_count: number;
|
||||
has_unread_info_response: boolean;
|
||||
events: GuestVisitEvent[];
|
||||
}
|
||||
|
||||
export interface GuestVisitComment {
|
||||
id: number;
|
||||
author: User;
|
||||
text: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GuestVisitFormPayload {
|
||||
guest_id?: number;
|
||||
guest?: Partial<Guest>;
|
||||
guest_comment?: string;
|
||||
purpose?: string;
|
||||
visit_comment?: string;
|
||||
all_day?: boolean;
|
||||
unlimited?: boolean;
|
||||
access_starts_at?: string | null;
|
||||
access_expires_at?: string | null;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
document_ids?: number[];
|
||||
}
|
||||
|
||||
// Document Comments
|
||||
export interface DocumentComment {
|
||||
id: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user