Corp/backend/guests/services.py
2026-09-07 19:43:48 +03:00

922 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import logging
from datetime import datetime, time, timedelta
from typing import Iterable
from django.conf import settings
from django.contrib.auth import get_user_model
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 GuestEmployeeNumberConflict, LdapGuestUser
from notifications.signals import notify
from .constants import GuestVisitEventType, GuestVisitStatus
from .models import Guest, GuestVisit, GuestVisitEvent
from .permissions import GUEST_ADMIN_PERMS
logger = logging.getLogger(__name__)
User = get_user_model()
def get_guest_admin_users():
return (
User.objects.filter(
Q(is_staff=True)
| Q(is_superuser=True)
| Q(
user_permissions__codename__in=[
p.split(".")[1] for p in GUEST_ADMIN_PERMS
]
)
| Q(
groups__permissions__codename__in=[
p.split(".")[1] for p in GUEST_ADMIN_PERMS
]
)
)
.filter(is_active=True)
.distinct()
)
def record_guest_event(
visit: GuestVisit,
event_type: str,
*,
actor=None,
from_status: str = "",
to_status: str = "",
comment: str = "",
metadata: dict | None = None,
) -> GuestVisitEvent:
return GuestVisitEvent.objects.create(
visit=visit,
actor=actor if actor and getattr(actor, "is_authenticated", False) else None,
event_type=event_type,
from_status=from_status or "",
to_status=to_status or "",
comment=comment or "",
metadata=metadata or {},
)
def notify_guest_visit(
visit: GuestVisit,
verb: str,
recipients: Iterable,
*,
actor=None,
title: str,
message: str,
extra: dict | None = None,
) -> None:
recipient_list = [u for u in recipients if u and getattr(u, "is_active", False)]
if not recipient_list:
return
notify.send(
sender=actor or visit.inviter,
recipient=recipient_list,
verb=verb,
action_object=visit,
target=visit.guest,
description=message,
action_url=f"/guests?visit={visit.pk}",
data={
"title": title,
"visit_id": visit.pk,
"guest_id": str(visit.guest_id),
"guest_name": visit.guest.full_name,
**(extra or {}),
},
)
def resolve_inviter_department(inviter):
departments = getattr(inviter, "departments", None)
try:
return departments.first() if departments is not None else None
except Exception:
return None
def normalize_all_day_range(date_from, date_to):
tz = timezone.get_current_timezone()
starts_at = timezone.make_aware(datetime.combine(date_from, time.min), tz)
expires_at = timezone.make_aware(
datetime.combine(date_to + timedelta(days=1), time.min),
tz,
)
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,
inviter=actor,
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(
visit,
GuestVisitEventType.CREATED,
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
def _set_status(
visit: GuestVisit,
new_status: str,
*,
actor,
event_type: str,
comment: str = "",
update_fields: list[str] | None = None,
) -> GuestVisit:
old_status = visit.status
visit.status = new_status
fields = ["status", "updated_at", *(update_fields or [])]
visit.save(update_fields=fields)
record_guest_event(
visit,
event_type,
actor=actor,
from_status=old_status,
to_status=new_status,
comment=comment,
)
return visit
@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():
raise ValueError("Укажите цель приглашения.")
if not visit.unlimited and not (
visit.access_starts_at and visit.access_expires_at
):
raise ValueError("Укажите период доступа или бессрочный доступ.")
if (
getattr(settings, "GUESTS_REQUIRE_ID_DOCUMENT", False)
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
if visit.status == GuestVisitStatus.NEEDS_INFO
else GuestVisitEventType.SUBMITTED
)
cls._set_status(
visit,
GuestVisitStatus.PENDING,
actor=actor,
event_type=event,
update_fields=["submitted_at"],
)
notify_guest_visit(
visit,
"guest_visit_submitted"
if event == GuestVisitEventType.SUBMITTED
else "guest_visit_info_provided",
get_guest_admin_users(),
actor=actor,
title="Гостевой визит на рассмотрении",
message=f"{actor} отправил гостевой визит для {visit.guest.full_name}.",
)
return visit
@classmethod
def request_info(cls, visit: GuestVisit, *, actor, comment: str) -> GuestVisit:
if visit.status != GuestVisitStatus.PENDING:
raise ValueError(
"Запросить уточнения можно только по заявке на рассмотрении."
)
comment = comment.strip()
if not comment:
raise ValueError("Комментарий обязателен.")
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,
)
notify_guest_visit(
visit,
"guest_visit_needs_info",
[visit.inviter],
actor=actor,
title="Нужна информация по гостевому визиту",
message=comment,
)
return visit
@classmethod
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()
visit.decision_comment = comment
event_type = (
GuestVisitEventType.DECISION_CHANGED
if old_status == GuestVisitStatus.REJECTED
else GuestVisitEventType.APPROVED
)
cls._set_status(
visit,
GuestVisitStatus.APPROVED,
actor=actor,
event_type=event_type,
comment=comment,
update_fields=["decided_by", "decided_at", "decision_comment"],
)
notify_guest_visit(
visit,
"guest_visit_approved",
[visit.inviter],
actor=actor,
title="Гостевой визит одобрен",
message=f"Гостевой визит для {visit.guest.full_name} одобрен.",
)
GuestLdapService().sync_guest_for_visit(visit)
return visit
@classmethod
def reject(cls, visit: GuestVisit, *, actor, comment: str = "") -> GuestVisit:
if visit.status not in {
GuestVisitStatus.PENDING,
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}:
raise ValueError(
"Отклонить можно только рассматриваемую или одобренную заявку."
)
old_status = visit.status
visit.decided_by = actor
visit.decided_at = timezone.now()
visit.decision_comment = comment
event_type = (
GuestVisitEventType.DECISION_CHANGED
if old_status == GuestVisitStatus.APPROVED
else GuestVisitEventType.REJECTED
)
cls._set_status(
visit,
GuestVisitStatus.REJECTED,
actor=actor,
event_type=event_type,
comment=comment,
update_fields=["decided_by", "decided_at", "decision_comment"],
)
notify_guest_visit(
visit,
"guest_visit_rejected",
[visit.inviter],
actor=actor,
title="Гостевой визит отклонен",
message=f"Гостевой визит для {visit.guest.full_name} отклонен.",
)
GuestLdapService().disable_guest(visit.guest, visit=visit)
return visit
@classmethod
def cancel(cls, visit: GuestVisit, *, actor, comment: str = "") -> GuestVisit:
if visit.status not in {
GuestVisitStatus.DRAFT,
GuestVisitStatus.PENDING,
GuestVisitStatus.NEEDS_INFO,
GuestVisitStatus.APPROVED,
}:
raise ValueError("Отменить можно только активную заявку.")
visit.cancelled_by = actor
visit.cancelled_at = timezone.now()
visit.cancel_reason = comment
cls._set_status(
visit,
GuestVisitStatus.CANCELLED,
actor=actor,
event_type=GuestVisitEventType.CANCELLED,
comment=comment,
update_fields=["cancelled_by", "cancelled_at", "cancel_reason"],
)
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:
raise ValueError("Отозвать можно только одобренный гостевой доступ.")
visit.revoked_by = actor
visit.revoked_at = timezone.now()
visit.revoke_reason = comment
cls._set_status(
visit,
GuestVisitStatus.REVOKED,
actor=actor,
event_type=GuestVisitEventType.REVOKED,
comment=comment,
update_fields=["revoked_by", "revoked_at", "revoke_reason"],
)
notify_guest_visit(
visit,
"guest_visit_revoked",
[visit.inviter],
actor=actor,
title="Гостевой доступ отозван",
message=f"Доступ гостя {visit.guest.full_name} отозван.",
)
GuestLdapService().disable_guest_if_no_active_visits(visit.guest, visit=visit)
return visit
@classmethod
def expire(cls, visit: GuestVisit) -> GuestVisit:
if visit.status != GuestVisitStatus.APPROVED:
raise ValueError("Истечь может только одобренный гостевой доступ.")
visit.expired_at = timezone.now()
cls._set_status(
visit,
GuestVisitStatus.EXPIRED,
actor=None,
event_type=GuestVisitEventType.EXPIRED,
update_fields=["expired_at"],
)
if getattr(settings, "GUESTS_NOTIFY_ON_EXPIRATION", True):
notify_guest_visit(
visit,
"guest_visit_expired",
[visit.inviter],
title="Гостевой доступ истек",
message=f"Доступ гостя {visit.guest.full_name} истек.",
)
GuestLdapService().disable_guest_if_no_active_visits(visit.guest, visit=visit)
return visit
class GuestLdapService:
@staticmethod
def _is_ldap_write_enabled() -> bool:
return bool(
getattr(settings, "LDAP_ENABLED", False)
and getattr(settings, "LDAP_WRITE_ENABLED", False)
)
def _should_be_in_active_ou(self, guest: Guest) -> bool:
if guest.is_blacklisted:
return False
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())
& (
Q(access_expires_at__isnull=True)
| Q(access_expires_at__gt=timezone.now())
)
)
)
.exists()
)
def _get_state_dn(self, guest: Guest) -> str:
return (
LdapSyncState.objects.filter(model="guest", object_pk=str(guest.pk))
.values_list("ldap_dn", flat=True)
.first()
or ""
)
def _touch_state(self, guest: Guest, dn: str) -> None:
state, _ = LdapSyncState.objects.get_or_create(
model="guest",
object_pk=str(guest.pk),
)
state.touch(ldap_dn=dn, sync_dir="django")
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=[
"is_active",
"ldap_last_error",
"ldap_last_synced_at",
"updated_at",
]
)
if visit:
record_guest_event(
visit,
GuestVisitEventType.LDAP_SKIPPED,
metadata={"reason": "LDAP write is disabled"},
)
def sync_guest_for_visit(
self,
visit: GuestVisit,
*,
enqueue_on_error: bool = True,
raise_on_error: bool = False,
) -> None:
self.sync_guest(
visit.guest,
visit=visit,
enqueue_on_error=enqueue_on_error,
raise_on_error=raise_on_error,
)
def sync_guest(
self,
guest: Guest,
visit: GuestVisit | None = None,
*,
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, in_active_ou=in_active_ou)
return
current_dn = self._get_state_dn(guest)
try:
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 = self._create_or_adopt_guest_account(guest, in_active_ou)
event_type = GuestVisitEventType.LDAP_CREATED
self._touch_state(guest, result.dn)
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=[
"is_active",
"ldap_username",
"ldap_upn",
"ldap_last_error",
"ldap_last_synced_at",
"updated_at",
]
)
if visit:
record_guest_event(
visit,
event_type,
metadata={
"ldap_dn": result.dn,
"in_active_ou": in_active_ou,
"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"])
if visit:
record_guest_event(
visit,
GuestVisitEventType.LDAP_FAILED,
comment=str(exc),
)
if enqueue_on_error:
self.enqueue_guest_sync(guest, operation="guest_sync")
notify_visit = visit or guest.visits.order_by("-created_at").first()
if notify_visit:
notify_guest_visit(
notify_visit,
"guest_ldap_failed",
get_guest_admin_users(),
title="Ошибка LDAP для гостя",
message=str(exc),
)
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,
visit: GuestVisit | None = None,
*,
enqueue_on_error: bool = True,
raise_on_error: bool = False,
) -> None:
self.sync_guest(
guest,
visit=visit,
enqueue_on_error=enqueue_on_error,
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,
visit: GuestVisit | None = None,
*,
enqueue_on_error: bool = True,
raise_on_error: bool = False,
) -> None:
if not self._should_be_in_active_ou(guest):
self.disable_guest(
guest,
visit=visit,
enqueue_on_error=enqueue_on_error,
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=payload,
)