Corp/backend/guests/models.py
2026-06-22 15:01:28 +03:00

398 lines
14 KiB
Python

from __future__ import annotations
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import connection, models
from django.db.models import Max, Q
from django.utils import timezone
from .constants import (
EDITABLE_BY_AUTHOR_STATUSES,
GUEST_ID_MAX,
GUEST_ID_MIN,
GUEST_ID_START,
GuestVisitEventType,
GuestVisitStatus,
)
def _setting_int(name: str, default: int) -> int:
return int(getattr(settings, name, default))
class Guest(models.Model):
id = models.BigIntegerField(primary_key=True, editable=False)
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)
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,
null=True,
blank=True,
related_name="created_guests",
verbose_name="Создал",
)
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(
"Последняя LDAP синхронизация",
null=True,
blank=True,
)
ldap_last_error = models.TextField("Последняя LDAP ошибка", blank=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Гость"
verbose_name_plural = "Гости"
ordering = ["last_name", "first_name", "id"]
indexes = [
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=["is_active"], name="guest_active_idx"),
]
constraints = [
models.CheckConstraint(
name="guest_id_in_guest_range",
condition=Q(id__gte=GUEST_ID_START) & Q(id__lte=GUEST_ID_MAX),
),
]
def __str__(self) -> str:
return self.full_name
@property
def full_name(self) -> str:
return " ".join(
part
for part in [self.last_name, self.first_name, self.patronymic]
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)
max_value = _setting_int("GUESTS_ID_MAX", GUEST_ID_MAX)
if connection.vendor == "postgresql":
with connection.cursor() as cursor:
cursor.execute("SELECT nextval('guests_guest_id_seq')")
value = int(cursor.fetchone()[0])
else:
current = cls.objects.aggregate(max_id=Max("id"))["max_id"]
value = start if current is None or current < start else int(current) + 1
if value < start or value > max_value:
raise ValidationError("Диапазон идентификаторов гостей исчерпан.")
return value
def clean(self):
super().clean()
if self.id is not None and not (GUEST_ID_START <= int(self.id) <= GUEST_ID_MAX):
raise ValidationError(
{"id": "ID гостя должен находиться в гостевом диапазоне."}
)
def save(self, *args, **kwargs):
if self.pk is None:
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):
guest = models.ForeignKey(
Guest,
on_delete=models.PROTECT,
related_name="visits",
verbose_name="Гость",
)
inviter = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="guest_visits",
verbose_name="Приглашающий",
)
inviter_snapshot_name = models.CharField(
"ФИО приглашающего на момент создания",
max_length=255,
blank=True,
)
inviter_snapshot_email = models.EmailField(
"Email приглашающего на момент создания",
blank=True,
)
host_department = models.ForeignKey(
"employees.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="guest_visits",
verbose_name="Отдел приглашающего",
)
purpose = models.TextField("Цель приглашения")
visit_comment = models.TextField("Комментарий заявителя", blank=True)
status = models.CharField(
"Статус",
max_length=24,
choices=GuestVisitStatus.choices,
default=GuestVisitStatus.DRAFT,
db_index=True,
)
access_starts_at = models.DateTimeField(
"Начало доступа",
null=True,
blank=True,
)
access_expires_at = models.DateTimeField(
"Окончание доступа",
null=True,
blank=True,
)
all_day = models.BooleanField("Полные сутки", default=True)
unlimited = models.BooleanField("Бессрочно", default=False)
documents = models.ManyToManyField(
"documents.Document",
blank=True,
related_name="guest_visits",
verbose_name="Документы",
)
submitted_at = models.DateTimeField(
"Отправлено на рассмотрение",
null=True,
blank=True,
)
decided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="decided_guest_visits",
verbose_name="Решение принял",
)
decided_at = models.DateTimeField("Решение принято", null=True, blank=True)
decision_comment = models.TextField("Комментарий к решению", blank=True)
cancelled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="cancelled_guest_visits",
)
cancelled_at = models.DateTimeField(null=True, blank=True)
cancel_reason = models.TextField(blank=True)
revoked_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="revoked_guest_visits",
)
revoked_at = models.DateTimeField(null=True, blank=True)
revoke_reason = models.TextField(blank=True)
expired_at = models.DateTimeField(null=True, blank=True)
inviter_inactive = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Гостевой визит"
verbose_name_plural = "Гостевые визиты"
ordering = ["-created_at"]
permissions = [
("view_all_guestvisit", "Может просматривать все гостевые визиты"),
("decide_guestvisit", "Может принимать решения по гостевым визитам"),
("manage_guestaccount", "Может управлять гостевыми учетками"),
]
indexes = [
models.Index(
fields=["status", "created_at"],
name="guestvisit_status_created_idx",
),
models.Index(
fields=["inviter", "created_at"],
name="guestvisit_inviter_created_idx",
),
models.Index(
fields=["access_starts_at", "access_expires_at"],
name="guestvisit_access_idx",
),
models.Index(fields=["guest", "status"], name="guestvisit_guest_status_idx"),
]
constraints = [
models.CheckConstraint(
name="guest_visit_access_range_valid",
condition=Q(unlimited=True)
| Q(access_expires_at__isnull=True)
| Q(access_starts_at__isnull=True)
| Q(access_starts_at__lt=models.F("access_expires_at")),
),
]
def __str__(self) -> str:
return f"{self.guest} — {self.get_status_display()}"
@property
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()
if self.access_starts_at and self.access_starts_at > now:
return False
if self.access_expires_at and self.access_expires_at <= now:
return False
return True
@property
def is_expired(self) -> bool:
return bool(
not self.unlimited
and self.access_expires_at
and self.access_expires_at <= timezone.now()
)
def can_edit_by(self, user) -> bool:
if not user or not user.is_authenticated:
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
return (
self.inviter_id == user.id
and self.status in EDITABLE_BY_AUTHOR_STATUSES
)
class GuestVisitEvent(models.Model):
visit = models.ForeignKey(
GuestVisit,
on_delete=models.CASCADE,
related_name="events",
verbose_name="Визит",
)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="guest_visit_events",
verbose_name="Автор события",
)
event_type = models.CharField(
"Тип события",
max_length=48,
choices=GuestVisitEventType.choices,
db_index=True,
)
from_status = models.CharField(max_length=24, blank=True)
to_status = models.CharField(max_length=24, blank=True)
comment = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Событие гостевого визита"
verbose_name_plural = "События гостевых визитов"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["visit", "-created_at"], name="guestevent_visit_created_idx"),
models.Index(
fields=["event_type", "-created_at"],
name="guestevent_type_created_idx",
),
]
def __str__(self) -> str:
return f"{self.visit_id}: {self.event_type}"