Files
ACORE/apps/visit_manager/admin.py
T
lokses 3e969e29dc visit_manager: события календаря
Модель и сервис событий календаря, вью и страница календаря.

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

366 lines
13 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 .models import (
Shift,
Holiday,
CalendarEvent,
ScheduleTemplate, SchedulePattern, ScheduleException,
WorkSchedule,
AttendanceLog, WorkShift,
)
# ---------------------------------------------------------------------------
# Shifts
# ---------------------------------------------------------------------------
@admin.register(Shift)
class ShiftAdmin(ModelAdmin):
list_display = ['name', 'shift_type', 'time_display', 'duration_hours',
'is_night_shift', 'has_lunch_break', 'is_active', 'color_display']
list_filter = ['shift_type', 'is_day_off', 'is_night_shift', 'is_flexible', 'is_active']
search_fields = ['name', 'description']
ordering = ['shift_type', 'start_time']
fieldsets = (
(_('Основне'), {
'fields': ('name', 'shift_type', 'is_active', 'color', 'description'),
}),
(_('Час роботи'), {
'fields': ('start_time', 'end_time', 'duration_hours',
'is_day_off', 'is_night_shift', 'is_flexible'),
}),
(_('Обідня перерва'), {
'fields': ('has_lunch_break', 'lunch_break_start', 'lunch_break_duration'),
}),
(_('Понаднормові'), {
'fields': ('min_hours', 'max_hours', 'overtime_threshold'),
'classes': ('collapse',),
}),
)
readonly_fields = ['duration_hours']
def time_display(self, obj):
if obj.is_day_off:
return '—'
if obj.start_time and obj.end_time:
return f"{obj.start_time:%H:%M} — {obj.end_time:%H:%M}"
return '—'
time_display.short_description = _('Час')
def color_display(self, obj):
return format_html(
'<span style="display:inline-block;width:16px;height:16px;'
'border-radius:3px;background:{};vertical-align:middle;"></span> {}',
obj.color, obj.color
)
color_display.short_description = _('Колір')
# ---------------------------------------------------------------------------
# Calendar events
# ---------------------------------------------------------------------------
@admin.register(CalendarEvent)
class CalendarEventAdmin(ModelAdmin):
list_display = [
'title', 'event_type', 'date_from', 'date_to',
'visibility', 'created_by',
]
list_filter = ['event_type', 'visibility', 'date_from']
search_fields = ['title', 'notes', 'location', 'created_by__last_name']
list_select_related = ['created_by']
ordering = ['-date_from']
date_hierarchy = 'date_from'
autocomplete_fields = ['created_by']
fieldsets = (
(_('Событие'), {
'fields': ('title', 'event_type', 'visibility', 'created_by'),
}),
(_('Сроки'), {
'fields': ('date_from', 'date_to', 'time_from', 'time_to'),
}),
(_('Детали'), {
'fields': ('location', 'notes'),
}),
)
# ---------------------------------------------------------------------------
# Holidays
# ---------------------------------------------------------------------------
@admin.register(Holiday)
class HolidayAdmin(ModelAdmin):
list_display = ['name', 'date', 'holiday_type', 'is_working_day',
'is_national', 'is_recurring', 'days_until_display']
list_filter = ['holiday_type', 'is_working_day', 'is_national', 'is_recurring']
search_fields = ['name', 'description']
ordering = ['date']
date_hierarchy = 'date'
fieldsets = (
(_('Основне'), {
'fields': ('name', 'date', 'holiday_type'),
}),
(_('Характеристики'), {
'fields': ('is_working_day', 'is_national', 'is_recurring',
'affects_schedule', 'overtime_rate'),
}),
(_('Відображення'), {
'fields': ('color', 'icon'),
}),
(_('Опис'), {
'fields': ('description', 'traditions'),
'classes': ('collapse',),
}),
(_('Регіони'), {
'fields': ('countries', 'regions'),
'classes': ('collapse',),
}),
)
def days_until_display(self, obj):
days = obj.days_until
if days is None:
return mark_safe('<span style="color:#9ca3af;">Пройшло</span>')
if days == 0:
return mark_safe('<span style="color:#16a34a;font-weight:bold;">Сьогодні</span>')
if days <= 7:
return format_html('<span style="color:#ca8a04;">{} дн.</span>', days)
return f"{days} дн."
days_until_display.short_description = _('Залишилось')
# ---------------------------------------------------------------------------
# Schedule templates
# ---------------------------------------------------------------------------
class SchedulePatternInline(TabularInline):
model = SchedulePattern
extra = 0
fields = ('shift', 'weekday', 'cycle_day', 'is_override', 'notes')
class ScheduleExceptionInline(TabularInline):
model = ScheduleException
extra = 0
fields = ('employee', 'date', 'exception_type', 'shift', 'reason', 'is_approved', 'approved_by')
readonly_fields = ['approved_by']
@admin.register(ScheduleTemplate)
class ScheduleTemplateAdmin(ModelAdmin):
list_display = ['name', 'template_type', 'work_days_per_week',
'total_hours_per_week', 'is_default', 'is_active']
list_filter = ['template_type', 'is_active', 'is_default', 'allow_overtime']
search_fields = ['name', 'description']
ordering = ['name']
filter_horizontal = ['suitable_departments']
fieldsets = (
(_('Основне'), {
'fields': ('name', 'template_type', 'description', 'is_active', 'is_default'),
}),
(_('Час'), {
'fields': ('default_start_time', 'default_end_time',
'min_daily_hours', 'max_daily_hours'),
}),
(_('Тиждень'), {
'fields': ('work_days_per_week', 'total_hours_per_week'),
}),
(_('Цикл (для змінного графіку)'), {
'fields': ('cycle_length_days', 'cycle_start_day'),
'classes': ('collapse',),
}),
(_('Налаштування'), {
'fields': ('allow_overtime', 'auto_lunch_break', 'lunch_break_duration'),
}),
(_('Придатність'), {
'fields': ('suitable_location_types', 'suitable_departments'),
'classes': ('collapse',),
}),
)
inlines = [SchedulePatternInline, ScheduleExceptionInline]
# ---------------------------------------------------------------------------
# Work schedules
# ---------------------------------------------------------------------------
@admin.register(WorkSchedule)
class WorkScheduleAdmin(ModelAdmin):
list_display = ['employee', 'schedule_template', 'location',
'start_date', 'end_date', 'is_primary', 'is_active', 'is_current']
list_filter = ['is_active', 'is_primary', 'schedule_template']
search_fields = ['employee__last_name', 'employee__first_name', 'notes']
list_select_related = ['employee', 'schedule_template', 'location']
ordering = ['-start_date']
date_hierarchy = 'start_date'
fieldsets = (
(_('Співробітник та шаблон'), {
'fields': ('employee', 'schedule_template', 'location'),
}),
(_('Період'), {
'fields': ('start_date', 'end_date', 'is_active', 'is_primary'),
}),
(_('Налаштування відміток'), {
'fields': ('allow_early_checkin', 'early_checkin_minutes',
'allow_late_checkout', 'late_checkout_minutes'),
}),
(_('Примітки'), {
'fields': ('notes',),
'classes': ('collapse',),
}),
)
readonly_fields = ['created_by']
def is_current(self, obj):
if obj.is_current:
return mark_safe('<span style="color:#16a34a;">●</span> Активний')
return mark_safe('<span style="color:#9ca3af;">●</span> Неактивний')
is_current.short_description = _('Статус')
def save_model(self, request, obj, form, change):
if not obj.pk:
obj.created_by = request.user
super().save_model(request, obj, form, change)
# ---------------------------------------------------------------------------
# Attendance
# ---------------------------------------------------------------------------
class LocationChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
type_label = obj.get_location_type_display()
addr = obj.get_full_address() or obj.address or ''
if obj.location_type == 'pharmacy':
try:
num = obj.pharmacy.pharmacy_number
return f"Аптека №{num} — {addr}"
except Exception:
pass
return f"{type_label} — {addr}" if addr else type_label
class AttendanceLogForm(forms.ModelForm):
location = LocationChoiceField(
queryset=None,
label=_('Локация'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from apps.locations.models import Location
self.fields['location'].queryset = (
Location.objects
.filter(is_active=True)
.select_related('location_region')
.prefetch_related('pharmacy')
.order_by('location_type', 'address')
)
class Meta:
model = AttendanceLog
fields = '__all__'
@admin.register(AttendanceLog)
class AttendanceLogAdmin(ModelAdmin):
form = AttendanceLogForm
list_display = ['employee', 'location', 'direction_display',
'timestamp', 'location_type_display', 'has_photo', 'synced']
list_filter = ['direction', 'synced', 'timestamp', 'location__location_type']
search_fields = ['employee__last_name', 'employee__first_name', 'device_id']
list_select_related = ['employee', 'location']
ordering = ['-timestamp']
date_hierarchy = 'timestamp'
readonly_fields = ['created_at', 'photo_preview']
fieldsets = (
(_('Основне'), {
'fields': ('employee', 'location', 'direction', 'timestamp'),
}),
(_('Фото'), {
'fields': ('photo', 'photo_preview'),
}),
(_('Технічне'), {
'fields': ('device_id', 'ip_address', 'synced', 'created_at'),
'classes': ('collapse',),
}),
)
def direction_display(self, obj):
color = '#16a34a' if obj.direction == 'in' else '#dc2626'
return format_html(
'<span style="color:{}; font-weight:bold;">{}</span>',
color, obj.get_direction_display()
)
direction_display.short_description = _('Направлення')
direction_display.admin_order_field = 'direction'
def location_type_display(self, obj):
icons = {'pharmacy': '🏥', 'warehouse': '🏭', 'office': '🏢'}
lt = obj.location.location_type
return f"{icons.get(lt, '')} {obj.location.get_location_type_display()}"
location_type_display.short_description = _('Тип локації')
def has_photo(self, obj):
return bool(obj.photo)
has_photo.boolean = True
has_photo.short_description = _('Фото')
def photo_preview(self, obj):
b64 = obj.photo_thumb_b64
if b64:
return format_html('<img src="{}" style="width:64px;height:64px;object-fit:cover;">', b64)
return '—'
photo_preview.short_description = _('Превью')
@admin.register(WorkShift)
class WorkShiftAdmin(ModelAdmin):
list_display = ['employee', 'location', 'date', 'arrived_at',
'left_at', 'work_hours_display', 'is_complete_display']
list_filter = ['date', 'location']
search_fields = ['employee__last_name', 'employee__first_name']
list_select_related = ['employee', 'location']
ordering = ['-date']
date_hierarchy = 'date'
readonly_fields = ['arrived_at', 'left_at', 'duration', 'log_in', 'log_out']
fieldsets = (
(_('Основне'), {
'fields': ('employee', 'location', 'date'),
}),
(_('Час'), {
'fields': ('arrived_at', 'left_at', 'duration'),
}),
(_('Записи'), {
'fields': ('log_in', 'log_out'),
'classes': ('collapse',),
}),
)
def work_hours_display(self, obj):
h = obj.work_hours
if h is None:
return '—'
color = '#dc2626' if h > 9 else '#16a34a'
return format_html('<span style="color:{};">{:.1f} год.</span>', color, h)
work_hours_display.short_description = _('Відпрацьовано')
def is_complete_display(self, obj):
return obj.is_complete
is_complete_display.boolean = True
is_complete_display.short_description = _('Повна зміна')