Files
ACORE/apps/employees/admin.py
T
lokses a62f023a90 employees: сводка итоговых разрешений на карточке сотрудника
На карточке Employee — вкладка «Доступ (сводка)»: по каждому
ограниченному разделу портала показывает итог (есть/нет) и источник
(какая группа дала право, individual grant/revoke override, личное право).

Считается теми же правилами, что Employee.has_perm (override имеет
приоритет над группами), поэтому сводка 1-в-1 совпадает с реальной
проверкой доступа. Править по-прежнему: поле «Группы» + инлайн
«Доп. права на разделы портала» (grant/revoke).

- core/access_summary.py: employee_access_summary()
- EmployeeAdmin: readonly effective_access_summary + вкладка
- core/tests_access_summary.py: 6 тестов (в т.ч. совпадение с has_perm)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 17:21:58 +03:00

670 lines
28 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 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')
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', 'effective_access_summary')
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': ('effective_access_summary',),
'classes': ('tab',),
'description': _(
'Итоговые разрешения на разделы портала (группы + индивидуальные '
'override). Изменить: поле «Группы» выше или блок «Доп. права на '
'разделы портала» ниже.'
),
}),
(_('ФИО'), {
'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', 'it_responsibility_zone',
),
'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('<span style="color:#999;">—</span>')
# parts = []
# if group:
# parts.append(format_html(
# '<span style="background:#1a7f37;color:#fff;'
# 'padding:2px 6px;border-radius:4px;font-size:11px;">📁 {}</span>',
# group,
# ))
# for p in perms:
# parts.append(format_html(
# '<span style="background:#6c47ff;color:#fff;'
# 'padding:2px 6px;border-radius:4px;font-size:11px;">{}</span>',
# 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('<span style="color:#999;">Доступен после сохранения сотрудника.</span>')
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 = '<span style="color:#1a7f37;font-weight:600;">✔ есть</span>'
else:
mark = '<span style="color:#999;">— нет</span>'
src = escape(r.source_text())
if r.override == 'grant':
src = f'<span style="color:#6c47ff;">{src}</span>'
elif r.override == 'revoke':
src = f'<span style="color:#c62828;">{src}</span>'
body.append(
'<tr>'
f'<td style="padding:3px 10px 3px 0;white-space:nowrap;">{escape(r.label)}</td>'
f'<td style="padding:3px 14px 3px 0;white-space:nowrap;">{mark}</td>'
f'<td style="padding:3px 0;color:#555;font-size:12px;">{src}</td>'
'</tr>'
)
table = (
'<table style="border-collapse:collapse;font-size:13px;">'
'<thead><tr style="border-bottom:1px solid #ddd;">'
'<th style="text-align:left;padding:3px 10px 3px 0;">Раздел</th>'
'<th style="text-align:left;padding:3px 14px 3px 0;">Доступ</th>'
'<th style="text-align:left;padding:3px 0;">Источник</th>'
'</tr></thead><tbody>' + ''.join(body) + '</tbody></table>'
)
return mark_safe(table)
effective_access_summary.short_description = _('Итоговые разрешения')
def photo_thumbnail(self, obj):
if obj.photo:
return format_html(
'<img src="{}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;">',
obj.photo.url
)
return mark_safe('<span style="color:#999;">—</span>')
photo_thumbnail.short_description = ''
def photo_preview(self, obj):
if obj.photo:
return format_html(
'<img src="{}" style="max-height:200px;border-radius:8px;">',
obj.photo.url
)
return mark_safe('<span style="color:#999;">Фото не загружено</span>')
photo_preview.short_description = _('Предпросмотр')
def groups_display(self, obj):
names = list(obj.groups.values_list('name', flat=True))
if not names:
return mark_safe('<span style="color:#999;">—</span>')
return ', '.join(names)
groups_display.short_description = _('Группы')
def department_display(self, obj): # noqa: E303
"""Отдел с полным путём иерархии без дубликатов (Офис → HR → Кадры)."""
if not obj.department:
return mark_safe('<span style="color:#999;">—</span>')
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(
'<span class="department-path" title="{}">{}</span>',
path, path
)
except Exception:
return obj.department.name or '—'
department_display.short_description = _('Отдел')
@admin.register(EmployeeDefaults)
class EmployeeDefaultsAdmin(ModelAdmin):
"""Единственная запись настроек по умолчанию для сотрудников."""
filter_horizontal = (
'default_groups_office', 'default_groups_pharmacy', 'default_groups_warehouse',
)
fieldsets = (
(_('Офисные сотрудники'), {
'fields': ('office_email_domain',),
'description': _(
'При утверждении заявки на найм офисному сотруднику '
'назначается рабочий email: логин@домен.'
),
}),
(_('Роли по умолчанию при найме'), {
'fields': (
'default_groups_office', 'default_groups_pharmacy', 'default_groups_warehouse',
),
'description': _(
'Эти роли (группы) автоматически выдаются новому сотруднику '
'при утверждении найма — по типу его локации.'
),
}),
)
def has_add_permission(self, request):
return not EmployeeDefaults.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
class GroupAdminForm(forms.ModelForm):
"""Единая форма роли: имя + портальный доступ + прочие права + профиль."""
description = forms.CharField(
label=_('Описание'), required=False,
widget=forms.Textarea(attrs={'rows': 2}),
)
is_active = forms.BooleanField(label=_('Активна'), required=False, initial=True)
portal_permissions = forms.ModelMultipleChoiceField(
label=_('Разделы портала'),
queryset=Permission.objects.none(),
required=False,
widget=admin.widgets.FilteredSelectMultiple(_('разделы'), is_stacked=False),
help_text=_('Доступ к разделам портала. Прочие права (кассовые и т.п.) — в списке «Права».'),
)
class Meta:
model = Group
fields = ('name', 'permissions')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
portal_qs = _portal_permissions_qs()
self._portal_ids = set(portal_qs.values_list('pk', flat=True))
self.fields['portal_permissions'].queryset = portal_qs
# Портальные права не дублируем в общем списке — ими управляет виджет выше.
self.fields['permissions'].queryset = (
self.fields['permissions'].queryset.exclude(pk__in=self._portal_ids)
)
if self.instance and self.instance.pk:
profile = getattr(self.instance, 'profile', None)
if profile:
self.fields['description'].initial = profile.description
self.fields['is_active'].initial = profile.is_active
self.fields['portal_permissions'].initial = list(
self.instance.permissions.filter(pk__in=self._portal_ids)
)
def _save_m2m(self):
# set() для permissions снимет портальные (их нет в queryset) — вернём по виджету.
super()._save_m2m()
self.instance.permissions.add(*(self.cleaned_data.get('portal_permissions') or []))
PermissionGroup.objects.update_or_create(
group=self.instance,
defaults={
'description': self.cleaned_data.get('description', ''),
'is_active': self.cleaned_data.get('is_active', True),
},
)
if admin.site.is_registered(Group):
admin.site.unregister(Group)
@admin.register(Group)
class GroupAdmin(ModelAdmin):
"""Роль = группа доступа: имя, портальные разделы, прочие права, описание/активна."""
form = GroupAdminForm
filter_horizontal = ('permissions',)
search_fields = ('name',)
list_display = ('name', 'active_display', 'description_display', 'portal_matrix_link')
actions_list = ['access_matrix_action']
@action(description=_('Матрица доступа'), url_path='access-matrix')
def access_matrix_action(self, request):
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from core.user_groups_portal import apply_full_access_matrix, full_access_matrix
if not request.user.has_perm('auth.change_group'):
raise PermissionDenied
if request.method == 'POST':
apply_full_access_matrix(request.POST)
self.message_user(request, _('Матрица доступа сохранена.'), level=messages.SUCCESS)
return redirect(request.path)
ctx = {
**self.admin_site.each_context(request),
'title': _('Матрица доступа'),
**full_access_matrix(),
}
return TemplateResponse(request, 'admin/group_access_matrix.html', ctx)
fieldsets = (
(None, {'fields': ('name', 'description', 'is_active')}),
(_('Доступ к разделам портала'), {'fields': ('portal_permissions',)}),
(_('Прочие права'), {'fields': ('permissions',), 'classes': ('collapse',)}),
)
@admin.display(description=_('Активна'), boolean=True)
def active_display(self, obj):
p = getattr(obj, 'profile', None)
return p.is_active if p else True
@admin.display(description=_('Описание'))
def description_display(self, obj):
p = getattr(obj, 'profile', None)
return (p.description if p else '') or '—'
@admin.display(description=_('Матрица доступа'))
def portal_matrix_link(self, obj):
if not obj or not obj.pk:
return '—'
from django.urls import reverse
url = reverse('user_group_access', kwargs={'group_id': obj.pk})
return format_html('<a href="{}" target="_blank">{}</a>', url, _('Открыть'))
@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'