27a917e8c3
- Remove Employee.role CharField — was a display-only label with no actual permission logic - 3 migrations: create groups, migrate data, remove field - Admin: list/filter/fieldset now use groups; added groups_display method - Serializer: role → groups (SlugRelatedField returning group names) - Template: get_role_display → groups.first.name - EmployeePermissionOverride and PermissionGroup remain for fine-grained per-employee and per-group permission management
231 lines
9.0 KiB
Python
231 lines
9.0 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_staff', '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', '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 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 = _('Отдел') |