from django import forms from django.contrib import admin, messages from django.contrib.auth import password_validation from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin, TabularInline from core.models import Directory, PortalAccess from core.security import filter_portal_permissions_for_user, log_security_event from .models import Employee, PermissionGroup, EmployeeDefaults, EmployeePermissionOverride from .models import PasswordSetupInvite from .services.password_invite import can_offer_ad_password_sync from .models import EmployeeHrRequest, LeaveEntitlement from apps.visit_manager.models import WorkSchedule, AttendanceLog def _portal_permissions_qs(): ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False) access = Permission.objects.filter(content_type=ct, codename__startswith='access_') extra = Permission.objects.filter( content_type__app_label='reports', codename='view_all_departments', ) return (access | extra).order_by('content_type__app_label', 'codename') class HierarchicalDepartmentChoiceField(forms.ModelChoiceField): """Поле выбора отдела с отступами по иерархии в выпадающем списке.""" def __init__(self, *args, **kwargs): queryset = kwargs.get('queryset') or Directory.objects.filter( directory_type__code='department', is_active=True, ).select_related('directory_type').order_by('_path') kwargs['queryset'] = queryset super().__init__(*args, **kwargs) def label_from_instance(self, obj): depth = (getattr(obj, '_path', '') or '').count('.') indent = '---' * depth + ('--- ' if depth else '') return f'{indent}{obj.name}' class OfficeWarehouseChoiceField(forms.ModelChoiceField): def label_from_instance(self, obj): type_label = obj.get_location_type_display() addr = obj.get_full_address() or obj.address or '' return f"{type_label} — {addr}" if addr else type_label class EmployeeAdminForm(forms.ModelForm): department = HierarchicalDepartmentChoiceField( label=_('Отдел'), required=False, empty_label=_('— Выберите отдел —'), widget=forms.Select(attrs={ 'class': 'hierarchy-department-select', 'data-placeholder': 'Департамент → Отдел', }), help_text=_('Выберите из списка. Уровни вложенности отображаются отступами.'), ) user_location = OfficeWarehouseChoiceField( queryset=None, required=False, empty_label=_('— Выберите офис или склад —'), label=_('Локация (офис/склад)'), help_text=_('Заполняется для location_type=office или warehouse'), ) new_password = forms.CharField( label=_('Новый пароль'), required=False, strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}, render_value=False), help_text=_('Оставьте пустым, чтобы не менять текущий пароль портала.'), ) new_password_confirm = forms.CharField( label=_('Подтверждение пароля'), required=False, strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}, render_value=False), ) sync_ad_password = forms.BooleanField( label=_('Также сменить пароль в Active Directory'), required=False, initial=False, help_text=_('Доступно для офисных сотрудников с привязкой к AD.'), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from apps.locations.models import Location self.fields['user_location'].queryset = ( Location.objects .filter(location_type__in=['office', 'warehouse'], is_active=True) .select_related('location_region') .order_by('location_type', 'address') ) offer_ad = bool(self.instance and self.instance.pk and can_offer_ad_password_sync(self.instance)) self.fields['sync_ad_password'].disabled = not offer_ad if not offer_ad: self.fields['sync_ad_password'].help_text = _( 'Синхронизация с AD недоступна: нужен офисный сотрудник с заполненным DN.' ) def clean(self): cleaned = super().clean() password = (cleaned.get('new_password') or '') confirm = (cleaned.get('new_password_confirm') or '') if password or confirm: if password != confirm: self.add_error('new_password_confirm', _('Пароли не совпадают.')) elif password: try: password_validation.validate_password(password, self.instance) except DjangoValidationError as exc: self.add_error('new_password', exc) if cleaned.get('sync_ad_password') and password: if not (self.instance and self.instance.pk and can_offer_ad_password_sync(self.instance)): self.add_error( 'sync_ad_password', _('Смена пароля в AD недоступна для этого сотрудника.'), ) return cleaned def save(self, commit=True): obj = super().save(commit=False) password = self.cleaned_data.get('new_password') or '' if password: obj.set_password(password) if commit: obj.save() self.save_m2m() return obj class Meta: model = Employee exclude = ('password',) class WorkScheduleInline(TabularInline): model = WorkSchedule fk_name = 'employee' extra = 0 fields = ('schedule_template', 'location', 'start_date', 'end_date', 'is_primary', 'is_active') readonly_fields = ('is_active',) ordering = ('-start_date',) show_change_link = True def has_add_permission(self, request, obj=None): return False class EmployeePermissionOverrideInline(TabularInline): model = EmployeePermissionOverride extra = 0 fields = ('permission', 'override_type', 'valid_from', 'valid_until', 'is_active', 'comment') autocomplete_fields = [] verbose_name = _('Доп. право на раздел портала') verbose_name_plural = _('Доп. права на разделы портала') def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == 'permission': kwargs['queryset'] = filter_portal_permissions_for_user( _portal_permissions_qs(), request.user, ) return super().formfield_for_foreignkey(db_field, request, **kwargs) def has_add_permission(self, request, obj=None): return request.user.is_superuser def has_change_permission(self, request, obj=None): return request.user.is_superuser def has_delete_permission(self, request, obj=None): return request.user.is_superuser class AttendanceLogInline(TabularInline): model = AttendanceLog fk_name = 'employee' extra = 0 max_num = 0 fields = ('timestamp', 'direction', 'location', 'device_id', 'synced') readonly_fields = ('timestamp', 'direction', 'location', 'device_id', 'synced') ordering = ('-timestamp',) show_change_link = True def has_add_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False @admin.register(Employee) class EmployeeAdmin(ModelAdmin): form = EmployeeAdminForm inlines = [EmployeePermissionOverrideInline, WorkScheduleInline, AttendanceLogInline] class Media: css = {'all': ('users/css/employee_admin.css',)} js = ('assets/js/admin_phone_format.js',) list_display = [ 'photo_thumbnail', 'username', 'last_name', 'first_name', 'groups_display', 'user_location_type', 'user_pharmacy', 'department_display', 'post', 'is_active', 'twofa_status', 'hire_date' ] list_filter = [ 'groups', 'is_active', 'is_staff', 'user_location_type', 'department', 'totp_enabled', 'it_work_channel', ] search_fields = ['username', 'email', 'last_name', 'first_name', 'middle_name'] list_select_related = ['user_pharmacy', 'department', 'post'] readonly_fields = ('photo_preview',) actions = ['reset_2fa'] @admin.display(description='2FA', boolean=True) def twofa_status(self, obj): return obj.totp_enabled @admin.action(description='Сбросить 2FA (отключить у выбранных)') def reset_2fa(self, request, queryset): from apps.employees.services import twofa n = 0 for emp in queryset: if emp.totp_enabled or emp.totp_secret: twofa.disable(emp) n += 1 self.message_user(request, f'2FA сброшена у {n} сотрудник(ов).') fieldsets = ( (_('Аутентификация'), { 'fields': ( 'username', 'email', 'groups', 'is_active', 'new_password', 'new_password_confirm', 'sync_ad_password', ), 'classes': ('tab',), }), (_('ФИО'), { 'fields': ('last_name', 'first_name', 'middle_name'), 'classes': ('tab',), }), (_('Рабочая информация'), { 'fields': ( 'user_location_type', 'user_organization', 'user_pharmacy', 'user_location', 'department', 'post', 'manager', 'it_work_channel', ), 'classes': ('tab',), }), (_('Даты'), { 'fields': ('hire_date', 'termination_date', 'probation_end_date', 'date_joined'), 'classes': ('tab',), }), (_('Личные данные'), { 'fields': ('birth_date', 'inn_code', 'photo', 'photo_preview'), 'classes': ('tab',), }), (_('Контакты'), { 'fields': ( 'phone', 'internal_number', 'personal_phone', 'personal_email', 'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation', ), 'classes': ('tab',), }), ('Mattermost', { 'fields': ('mattermost_user_id', 'mattermost_username', 'mattermost_sync_status', 'mattermost_last_sync', 'mattermost_sync_enabled'), 'classes': ('tab', 'collapse'), }), ('Active Directory', { 'fields': ('ad_user_dn', 'ad_sync_status', 'ad_sync_error', 'ad_last_sync', 'ad_sync_enabled'), 'classes': ('tab', 'collapse'), }), (_('Графік та відвідуваність'), { 'fields': (), 'classes': ('tab',), 'description': _('Графіки роботи та записи відвідуваності відображаються нижче.'), }), ) def get_fieldsets(self, request, obj=None): fieldsets = list(super().get_fieldsets(request, obj)) if request.user.is_superuser: fieldsets.append(( _('Системный доступ'), { 'fields': ('is_staff', 'user_permissions'), 'classes': ('tab', 'collapse'), 'description': _('Только для IT-администраторов. is_staff — вход в Django admin.'), }, )) return fieldsets def get_readonly_fields(self, request, obj=None): readonly = list(super().get_readonly_fields(request, obj)) if not request.user.is_superuser: readonly.extend([ 'groups', 'ad_user_dn', 'ad_sync_status', 'ad_sync_error', 'ad_last_sync', 'ad_sync_enabled', ]) return readonly def save_model(self, request, obj, form, change): if not request.user.is_superuser and change: previous = Employee.objects.filter(pk=obj.pk).prefetch_related('groups').first() if previous: obj.groups.set(previous.groups.all()) super().save_model(request, obj, form, change) password = (form.cleaned_data or {}).get('new_password') or '' if not password: return from django.utils import timezone PasswordSetupInvite.objects.filter( employee=obj, used_at__isnull=True, ).update(used_at=timezone.now()) log_security_event( 'employee.password_set_via_admin', actor=request.user, target=str(obj.pk), details=f'username={obj.username}', ) self.message_user(request, _('Пароль портала обновлён.'), messages.SUCCESS) if form.cleaned_data.get('sync_ad_password') and can_offer_ad_password_sync(obj): from apps.ad_connector.services.sync_service import sync_ad_password try: ok = sync_ad_password(obj, password) except Exception: ok = False if ok: self.message_user(request, _('Пароль в Active Directory обновлён.'), messages.SUCCESS) else: self.message_user( request, _('Пароль портала сохранён, но сменить пароль в AD не удалось.'), messages.WARNING, ) # def permissions_summary(self, obj): # perms = obj.effective_permissions() # group = obj.permission_group.name if obj.permission_group_id else None # if not perms and not group: # return mark_safe('—') # parts = [] # if group: # parts.append(format_html( # '📁 {}', # group, # )) # for p in perms: # parts.append(format_html( # '{}', # p, # )) # return mark_safe(' '.join(parts)) # permissions_summary.short_description = _('Группа / Разрешения') def photo_thumbnail(self, obj): if obj.photo: return format_html( '', obj.photo.url ) return mark_safe('—') photo_thumbnail.short_description = '' def photo_preview(self, obj): if obj.photo: return format_html( '', obj.photo.url ) return mark_safe('Фото не загружено') photo_preview.short_description = _('Предпросмотр') def groups_display(self, obj): names = list(obj.groups.values_list('name', flat=True)) if not names: return mark_safe('—') return ', '.join(names) groups_display.short_description = _('Группы') def department_display(self, obj): # noqa: E303 """Отдел с полным путём иерархии без дубликатов (Офис → HR → Кадры).""" if not obj.department: return mark_safe('—') try: parts = [n.name for n in obj.department.get_ancestors()] + [obj.department.name] # Убираем подряд идущие дубликаты (родитель и ребёнок с одинаковым именем) unique = [] for p in parts: if not unique or unique[-1] != p: unique.append(p) path = ' → '.join(unique) return format_html( '{}', path, path ) except Exception: return obj.department.name or '—' department_display.short_description = _('Отдел') @admin.register(EmployeeDefaults) class EmployeeDefaultsAdmin(ModelAdmin): """Единственная запись настроек по умолчанию для сотрудников.""" fieldsets = ( (_('Офисные сотрудники'), { 'fields': ('office_email_domain',), 'description': _( 'При утверждении заявки на найм офисному сотруднику ' 'назначается рабочий email: логин@домен.' ), }), ) def has_add_permission(self, request): return not EmployeeDefaults.objects.exists() def has_delete_permission(self, request, obj=None): return False class PermissionGroupAdminForm(forms.ModelForm): portal_permissions = forms.ModelMultipleChoiceField( label=_('Разделы портала'), queryset=Permission.objects.none(), required=False, widget=admin.widgets.FilteredSelectMultiple(_('разделы'), is_stacked=False), help_text=_('Базовый набор доступов к разделам для этой роли. Доп. права сотруднику — через overrides.'), ) class Meta: model = PermissionGroup fields = ('group', 'description', 'is_active') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['portal_permissions'].queryset = _portal_permissions_qs() if self.instance and self.instance.pk and self.instance.group_id: portal_pks = set(_portal_permissions_qs().values_list('pk', flat=True)) self.fields['portal_permissions'].initial = [ p for p in self.instance.group.permissions.all() if p.pk in portal_pks ] def save_portal_permissions(self, obj): portal_qs = list(_portal_permissions_qs()) selected = self.cleaned_data.get('portal_permissions') if selected is None: return group = obj.group group.permissions.remove(*portal_qs) group.permissions.add(*selected) @admin.register(PermissionGroup) class PermissionGroupAdmin(ModelAdmin): form = PermissionGroupAdminForm list_display = ('group', 'is_active', 'description', 'portal_matrix_link') list_filter = ('is_active',) search_fields = ('group__name', 'description') fieldsets = ( (None, { 'fields': ('group', 'description', 'is_active', 'portal_permissions'), }), ) @admin.display(description=_('Матрица в портале')) def portal_matrix_link(self, obj): if not obj or not obj.group_id: return '—' from django.urls import reverse from django.utils.html import format_html url = reverse('user_group_access', kwargs={'group_id': obj.group_id}) return format_html('{}', url, _('Открыть')) def get_readonly_fields(self, request, obj=None): if request.user.is_superuser: return () return ('group', 'description', 'is_active', 'portal_permissions') def has_add_permission(self, request): return request.user.is_superuser def has_delete_permission(self, request, obj=None): return request.user.is_superuser def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) if request.user.is_superuser: form.save_portal_permissions(obj) @admin.register(EmployeePermissionOverride) class EmployeePermissionOverrideAdmin(ModelAdmin): list_display = ( 'employee', 'permission', 'override_type', 'valid_from', 'valid_until', 'is_active', ) list_filter = ('override_type', 'is_active') search_fields = ( 'employee__username', 'employee__last_name', 'employee__first_name', 'permission__codename', 'comment', ) list_select_related = ('employee', 'permission') autocomplete_fields = ('employee',) readonly_fields = ('created_at', 'updated_at', 'uuid') fieldsets = ( (None, { 'fields': ('employee', 'permission', 'override_type', 'is_active'), 'description': _( 'grant — выдать раздел сверх группы; ' 'revoke — запретить раздел даже если право есть в группе.' ), }), (_('Срок действия'), { 'fields': ('valid_from', 'valid_until', 'comment'), }), (_('Служебное'), { 'classes': ('collapse',), 'fields': ('uuid', 'created_at', 'updated_at'), }), ) def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == 'permission': kwargs['queryset'] = filter_portal_permissions_for_user( _portal_permissions_qs(), request.user, ) return super().formfield_for_foreignkey(db_field, request, **kwargs) def has_add_permission(self, request): return request.user.is_superuser def has_change_permission(self, request, obj=None): return request.user.is_superuser def has_delete_permission(self, request, obj=None): return request.user.is_superuser @admin.register(LeaveEntitlement) class LeaveEntitlementAdmin(ModelAdmin): list_display = ('employee', 'year', 'annual_days', 'additional_days', 'sick_days') list_filter = ('year',) search_fields = ('employee__username', 'employee__last_name', 'employee__first_name') autocomplete_fields = ('employee',) readonly_fields = ('uuid', 'created_at', 'updated_at') @admin.register(EmployeeHrRequest) class EmployeeHrRequestAdmin(ModelAdmin): list_display = ( 'employee', 'request_type', 'substitute', 'date_from', 'date_to', 'status', 'created_at', 'decided_by', ) list_filter = ('status', 'request_type', 'created_at') search_fields = ( 'employee__username', 'employee__last_name', 'employee__first_name', 'note', 'decision_note', ) list_select_related = ('employee', 'decided_by', 'substitute') autocomplete_fields = ('employee', 'decided_by', 'substitute') readonly_fields = ('uuid', 'created_at', 'updated_at', 'decided_at') date_hierarchy = 'created_at'