Files
ACORE/apps/employees/admin.py
T
lokses 6ff01c4119 Доменные модули: сотрудники, локации, найм, счётчики, посещения
- employees: last_seen, internal_number, default_page_size; управление-команды
- locations: категория/куратор/ответственный аптеки, seed должностей;
  Location.__str__ показывает номер аптеки (не только адрес)
- hiring: HiringRequest (заявки на найм) + тесты (7)
- utilities: UtilityReading (показания счётчиков по аптекам)
- visit_manager: правки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:33:16 +03:00

244 lines
9.6 KiB
Python

from django import forms
from django.contrib import admin
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
from .models import Employee, PermissionGroup
from apps.visit_manager.models import WorkSchedule, AttendanceLog
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'),
)
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')
)
class Meta:
model = Employee
fields = '__all__'
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 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 = [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', 'hire_date'
]
list_filter = ['groups', 'is_active', 'is_staff', 'user_location_type', 'department']
search_fields = ['username', 'email', 'last_name', 'first_name', 'middle_name']
list_select_related = ['user_pharmacy', 'department', 'post']
readonly_fields = ('photo_preview',)
fieldsets = (
(_('Аутентификация'), {
'fields': ('username', 'email', 'groups', 'is_active'),
'classes': ('tab',),
}),
(_('ФИО'), {
'fields': ('last_name', 'first_name', 'middle_name'),
'classes': ('tab',),
}),
(_('Рабочая информация'), {
'fields': (
'user_location_type', 'user_organization',
'user_pharmacy', 'user_location',
'department', 'post', 'manager',
),
'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 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 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 = _('Отдел')