Files
ACORE/apps/employees/admin.py
T

858 lines
36 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')
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('<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):
"""Единая форма роли: портал + PNMS/касса + PVMS + прочие права + профиль."""
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=_('Доступ к разделам портала ACORE.'),
)
pnms_permissions = forms.ModelMultipleChoiceField(
label=_('PNMS / касса (PPOS)'),
queryset=Permission.objects.none(),
required=False,
widget=admin.widgets.FilteredSelectMultiple(_('права кассы'), is_stacked=False),
help_text=_('POS can_* — синхронизируются в PNMS → PPOS как pos_permissions.'),
)
pvms_permissions = forms.ModelMultipleChoiceField(
label=_('PVMS / посещаемость'),
queryset=Permission.objects.none(),
required=False,
widget=admin.widgets.FilteredSelectMultiple(_('права посещаемости'), is_stacked=False),
help_text=_('Visit can_* — синхронизируются в PVMS как visit_permissions.'),
)
class Meta:
model = Group
fields = ('name', 'permissions')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
portal_qs = _portal_permissions_qs()
pnms_qs = _pnms_permissions_qs()
pvms_qs = _pvms_permissions_qs()
self._portal_ids = set(portal_qs.values_list('pk', flat=True))
self._pnms_ids = set(pnms_qs.values_list('pk', flat=True))
self._pvms_ids = set(pvms_qs.values_list('pk', flat=True))
self.fields['portal_permissions'].queryset = portal_qs
self.fields['pnms_permissions'].queryset = pnms_qs
self.fields['pvms_permissions'].queryset = pvms_qs
# Портал / PNMS / PVMS не дублируем в общем списке — отдельные виджеты.
managed_ids = self._portal_ids | self._pnms_ids | self._pvms_ids
self.fields['permissions'].queryset = (
self.fields['permissions'].queryset.exclude(pk__in=managed_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)
)
self.fields['pnms_permissions'].initial = list(
self.instance.permissions.filter(pk__in=self._pnms_ids)
)
self.fields['pvms_permissions'].initial = list(
self.instance.permissions.filter(pk__in=self._pvms_ids)
)
def _save_m2m(self):
# set() для permissions снимет managed-права (их нет в queryset) — вернём по виджетам.
super()._save_m2m()
self.instance.permissions.add(*(self.cleaned_data.get('portal_permissions') or []))
self.instance.permissions.add(*(self.cleaned_data.get('pnms_permissions') or []))
self.instance.permissions.add(*(self.cleaned_data.get('pvms_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)
from apps.locations.models import GroupLocationAccess
class GroupLocationAccessInline(TabularInline):
model = GroupLocationAccess
extra = 1
max_num = 1
filter_horizontal = ('locations',)
@admin.register(Group)
class GroupAdmin(ModelAdmin):
"""Роль = группа доступа: имя, портальные разделы, прочие права, описание/активна."""
form = GroupAdminForm
inlines = [GroupLocationAccessInline]
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',)}),
(_('PNMS / касса (PPOS)'), {'fields': ('pnms_permissions',)}),
(_('PVMS / посещаемость'), {'fields': ('pvms_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'