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}"