from django import forms from django.contrib import admin, messages from django.contrib.auth import password_validation from django.contrib.auth.models import Group, 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 unfold.decorators import action 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') def _pnms_permissions_qs(): from apps.portal_connector.pos_permissions import ( PNMS_ACCESS_APP_LABEL, PNMS_ACCESS_MODEL, POS_PERMISSION_CODENAMES, ) return Permission.objects.filter( content_type__app_label=PNMS_ACCESS_APP_LABEL, content_type__model=PNMS_ACCESS_MODEL, codename__in=POS_PERMISSION_CODENAMES, ).order_by('codename') def _pvms_permissions_qs(): from apps.portal_connector.pvms_permissions import ( PVMS_ACCESS_APP_LABEL, PVMS_ACCESS_MODEL, VISIT_PERMISSION_CODENAMES, ) return Permission.objects.filter( content_type__app_label=PVMS_ACCESS_APP_LABEL, content_type__model=PVMS_ACCESS_MODEL, codename__in=VISIT_PERMISSION_CODENAMES, ).order_by('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 tab = True fields = ('schedule_template', 'location', 'start_date', 'end_date', 'is_primary', 'is_active') readonly_fields = ('is_active',) ordering = ('-start_date',) show_change_link = True verbose_name = _('График') verbose_name_plural = _('График работы') def has_add_permission(self, request, obj=None): return False class EmployeePermissionOverrideInline(TabularInline): model = EmployeePermissionOverride extra = 0 tab = True 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 tab = True fields = ('timestamp', 'direction', 'location', 'device_id', 'synced') readonly_fields = ('timestamp', 'direction', 'location', 'device_id', 'synced') ordering = ('-timestamp',) show_change_link = True verbose_name = _('Отметка') verbose_name_plural = _('Посещаемость') 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] filter_horizontal = ('groups', 'user_permissions') autocomplete_fields = ( 'manager', 'user_pharmacy', 'user_location', 'user_organization', 'post', ) class Media: css = {'all': ('users/css/employee_admin.css',)} js = ('assets/js/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', 'employment_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', 'effective_access_summary', 'date_joined', 'last_seen', 'mattermost_sync_status', 'mattermost_last_sync', 'ad_sync_status', 'ad_sync_error', 'ad_last_sync', 'totp_enabled', ) actions = ['reset_2fa'] actions_list = ['password_invites_action'] @action(description=_('Ссылки на установку пароля'), url_path='password-invites') def password_invites_action(self, request): """Массовая выдача одноразовых ссылок первичной установки пароля.""" from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.shortcuts import redirect from django.template.response import TemplateResponse from apps.employees.services.password_invite import ( BULK_TTL_DAYS, active_invites_by_employee, build_invite_absolute_url, bulk_create_invites_for_pending, create_password_invite, revoke_active_invites, ) if not request.user.has_perm('employees.change_employee'): raise PermissionDenied if request.method == 'POST': what = request.POST.get('action') emp_id = request.POST.get('employee_id') if what == 'generate_all': n = bulk_create_invites_for_pending(created_by=request.user) self.message_user(request, _('Создано ссылок: %d.') % n, messages.SUCCESS) elif what == 'generate_one' and emp_id: emp = Employee.objects.filter(pk=emp_id).first() if emp: create_password_invite(emp, created_by=request.user, ttl_days=BULK_TTL_DAYS) self.message_user(request, _('Ссылка создана для %s.') % emp, messages.SUCCESS) elif what == 'revoke_one' and emp_id: emp = Employee.objects.filter(pk=emp_id).first() if emp: revoke_active_invites(emp) self.message_user(request, _('Ссылка отозвана для %s.') % emp, messages.WARNING) return redirect(request.path) invites = active_invites_by_employee() employees = ( Employee.objects.filter(is_active=True) .select_related('user_pharmacy', 'department') .order_by('last_name', 'first_name') ) rows = [] for emp in employees: invite = invites.get(emp.pk) rows.append({ 'employee': emp, 'password_set': emp.has_usable_password(), 'mm_linked': bool(emp.mattermost_user_id), 'link': build_invite_absolute_url(request, invite) if invite else '', 'expires_at': invite.expires_at if invite else None, }) if request.GET.get('export') == 'csv': import csv response = HttpResponse(content_type='text/csv; charset=utf-8') response['Content-Disposition'] = 'attachment; filename="password-invites.csv"' response.write('') # BOM для Excel writer = csv.writer(response) writer.writerow(['ФИО', 'Логин', 'Email', 'Ссылка', 'Действует до']) for r in rows: if not r['link']: continue writer.writerow([ r['employee'].get_full_name(), r['employee'].username, r['employee'].email, r['link'], r['expires_at'].strftime('%d.%m.%Y') if r['expires_at'] else '', ]) return response ctx = { **self.admin_site.each_context(request), 'title': _('Ссылки на установку пароля'), 'rows': rows, 'ttl_days': BULK_TTL_DAYS, 'pending_count': sum(1 for r in rows if not r['password_set'] and not r['link']), 'with_link_count': sum(1 for r in rows if r['link']), } return TemplateResponse(request, 'admin/employee_password_invites.html', ctx) @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 = ( (_('Основное'), { 'classes': ('tab',), 'fields': ( ('last_name', 'first_name', 'middle_name'), ('username', 'email'), 'is_active', 'groups', ('photo', 'photo_preview'), ), }), (_('Работа'), { 'classes': ('tab',), 'fields': ( 'user_location_type', ('user_organization', 'user_pharmacy', 'user_location'), ('department', 'post', 'manager'), 'employment_type', ('hire_date', 'probation_end_date', 'termination_date'), 'it_work_channel', 'it_responsibility_zone', ), }), (_('Контакты и личное'), { 'classes': ('tab',), 'fields': ( ('phone', 'internal_number', 'internal_line_type'), ('personal_phone', 'personal_email'), ('emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation'), ('birth_date', 'inn_code'), ), }), (_('Пароль и 2FA'), { 'classes': ('tab',), 'fields': ( 'new_password', 'new_password_confirm', 'sync_ad_password', 'totp_enabled', ), 'description': _( 'Оставьте пароль пустым, чтобы не менять. ' '2FA сбрасывается действием в списке сотрудников.' ), }), (_('Доступ к порталу'), { 'classes': ('tab',), 'fields': ('effective_access_summary',), 'description': _( 'Итог по разделам: группы + индивидуальные доп. права. ' 'Группы — на вкладке «Основное»; точечные права — вкладка «Доп. права».' ), }), (_('Интеграции'), { 'classes': ('tab',), 'description': _( 'Mattermost: автосвязка только по email. Чтобы привязать уже ' 'существующую учётку — укажите её User ID и логин вручную, ' 'лишнюю новую учётку деактивируйте в Mattermost.' ), 'fields': ( 'mattermost_sync_enabled', ('mattermost_user_id', 'mattermost_username'), ('mattermost_sync_status', 'mattermost_last_sync'), 'ad_sync_enabled', 'ad_user_dn', ('ad_sync_status', 'ad_last_sync'), 'ad_sync_error', ), }), ) 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', ('date_joined', 'last_seen'), ), 'classes': ('tab',), 'description': _('Только для IT: вход в 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 formfield_for_dbfield(self, db_field, request, **kwargs): formfield = super().formfield_for_dbfield(db_field, request, **kwargs) if db_field.name == 'mattermost_user_id' and formfield is not None: formfield.help_text = _( 'ID пользователя в Mattermost (System Console → Users). ' 'Нужен для ручной привязки существующей учётки.' ) if db_field.name == 'mattermost_username' and formfield is not None: formfield.help_text = _('Логин в Mattermost (без @).') return formfield 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 effective_access_summary(self, obj): if obj is None or obj.pk is None: return mark_safe('Доступен после сохранения сотрудника.') from django.utils.html import escape from core.access_summary import employee_access_summary rows = employee_access_summary(obj) body = [] for r in rows: if r.allowed: mark = '✔ есть' else: mark = '— нет' src = escape(r.source_text()) if r.override == 'grant': src = f'{src}' elif r.override == 'revoke': src = f'{src}' body.append( '
| Раздел | ' 'Доступ | ' 'Источник | ' '
|---|