from django import forms
from django.contrib import admin
from django.db import models
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from unfold.admin import ModelAdmin, TabularInline
from import_export.admin import ImportExportModelAdmin
from unfold.contrib.import_export.forms import ExportForm, ImportForm
from core.models import Directory
from .models import Location, Pharmacy, PharmacyPhone, PharmacyHistory, DislocationColumnPreset
# ---------------------------------------------------------------------------
# Shared
# ---------------------------------------------------------------------------
class HierarchicalRegionChoiceField(forms.ModelChoiceField):
"""Поле выбора региона с отступами по иерархии в выпадающем списке."""
def __init__(self, *args, **kwargs):
queryset = kwargs.get('queryset') or Directory.objects.filter(
directory_type__code__in=['region', 'district', 'city', 'city_district'],
is_active=True,
).select_related('directory_type').order_by('_path')
kwargs['queryset'] = queryset
kwargs.setdefault('required', True)
super().__init__(*args, **kwargs)
def label_from_instance(self, obj):
depth = (getattr(obj, '_path', '') or '').count('.')
indent = '\u00A0\u00A0' * depth + ('└ ' if depth else '')
return f'{indent}{obj.name}'
class HasCoordinatesFilter(admin.SimpleListFilter):
"""Записи, які так і лишились на дефолтних координатах поля (центр Одеси) —
тобто піну ніхто не ставив і геокодування по адресі не спрацювало."""
title = _('Координаты на карте')
parameter_name = 'has_coordinates'
def lookups(self, request, model_admin):
return [
('no', _('Без координат')),
('yes', _('С координатами')),
]
def queryset(self, request, queryset):
default = queryset.model._meta.get_field('location').default
if self.value() == 'no':
return queryset.filter(models.Q(location=default) | models.Q(location=''))
if self.value() == 'yes':
return queryset.exclude(location=default).exclude(location='')
return queryset
# ---------------------------------------------------------------------------
# Location
# ---------------------------------------------------------------------------
class LocationAdminForm(forms.ModelForm):
location_region = HierarchicalRegionChoiceField(
label=_('Город/район'),
empty_label=_('— Выберите регион —'),
widget=forms.Select(attrs={'class': 'hierarchy-region-select'}),
help_text=_('Выберите из списка. Уровни вложенности отображаются отступами.'),
)
class Meta:
model = Location
fields = '__all__'
@admin.register(Location)
class LocationAdmin(ModelAdmin, ImportExportModelAdmin):
form = LocationAdminForm
import_form_class = ImportForm
export_form_class = ExportForm
class Media:
css = {'all': ('locations/css/location_admin.css',)}
js = ('assets/js/admin_leaflet_resize_fix.js',)
list_display = ['address_display', 'location_type', 'location_region',
'coordinates_display', 'is_active', 'updated_at']
list_filter = ['location_type', 'is_active', HasCoordinatesFilter]
search_fields = ['address', 'street', 'house_number', 'comments']
list_select_related = ['location_region']
ordering = ['location_region', 'address']
# address тепер лише генерується в Location.save() з street/house_number/building/
# apartment — ручний ввід сюди все одно перезатреться при збереженні
readonly_fields = ['address', 'created_at', 'updated_at']
fieldsets = (
(_('Тип и регион'), {
'fields': ('location_type', 'location_region', 'is_active'),
}),
(_('Адрес'), {
'fields': ('street', 'house_number', 'building', 'apartment', 'address'),
}),
(_('Координаты'), {
'fields': ('location',),
}),
(_('Прочее'), {
'fields': ('comments',),
'classes': ('collapse',),
}),
(_('Служебное'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',),
}),
)
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
if 'location_type' in form.base_fields:
# Аптеки создаются только через «Аптеки» (там номер, лицензия и т.д.)
form.base_fields['location_type'].choices = [
(k, v) for k, v in form.base_fields['location_type'].choices
if k != 'pharmacy'
]
return form
def address_display(self, obj):
region = obj.location_region.name if obj.location_region else '—'
addr = obj.get_full_address() or obj.address or '—'
return format_html(
'{}
{}',
region, addr,
)
address_display.short_description = _('Регион / Адрес')
address_display.admin_order_field = 'address'
def coordinates_display(self, obj):
if obj.has_coordinates and obj.latitude is not None and obj.longitude is not None:
return format_html('📍 {}', f'{float(obj.latitude):.5f}, {float(obj.longitude):.5f}')
return mark_safe('⚠ немає координат')
coordinates_display.short_description = _('Координаты')
coordinates_display.admin_order_field = 'location'
# ---------------------------------------------------------------------------
# Pharmacy filters
# ---------------------------------------------------------------------------
class LicenseStatusFilter(admin.SimpleListFilter):
title = _('Статус лицензии')
parameter_name = 'license_status'
def lookups(self, request, model_admin):
return [
('valid', _('Действительна')),
('expiring', _('Истекает (30 дн.)')),
('expired', _('Истекла')),
('none', _('Нет лицензии')),
]
def queryset(self, request, queryset):
today = timezone.now().date()
soon = today + timezone.timedelta(days=30)
if self.value() == 'valid':
return queryset.filter(license_expiry_date__gt=soon)
if self.value() == 'expiring':
return queryset.filter(license_expiry_date__gte=today, license_expiry_date__lte=soon)
if self.value() == 'expired':
return queryset.filter(license_expiry_date__lt=today)
if self.value() == 'none':
return queryset.filter(license_number='') | queryset.filter(license_number__isnull=True)
return queryset
# ---------------------------------------------------------------------------
# Pharmacy inlines
# ---------------------------------------------------------------------------
class PharmacyPhoneInline(TabularInline):
model = PharmacyPhone
extra = 0
fields = ('phone', 'phone_type', 'is_primary', 'is_active', 'notes')
class PharmacyHistoryInline(TabularInline):
model = PharmacyHistory
extra = 0
readonly_fields = ('changed_field', 'old_value', 'new_value', 'changed_at', 'changed_by')
fields = ('changed_at', 'changed_by', 'changed_field', 'old_value', 'new_value')
ordering = ('-changed_at',)
def has_add_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
# ---------------------------------------------------------------------------
# Pharmacy
# ---------------------------------------------------------------------------
class PharmacyAdminForm(forms.ModelForm):
location_region = HierarchicalRegionChoiceField(
label=_('Город/район'),
required=True,
empty_label=_('— Выберите регион —'),
widget=forms.Select(attrs={'class': 'hierarchy-region-select'}),
)
class Meta:
model = Pharmacy
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'location_type' in self.fields:
self.fields['location_type'].initial = 'pharmacy'
self.fields['location_type'].widget = forms.HiddenInput()
@admin.register(Pharmacy)
class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin):
form = PharmacyAdminForm
import_form_class = ImportForm
export_form_class = ExportForm
class Media:
css = {'all': ('locations/css/location_admin.css',)}
js = ('assets/js/admin_phone_format.js', 'assets/js/admin_leaflet_resize_fix.js')
# ------------------------------------------------------------------
# List view
# ------------------------------------------------------------------
list_display = [
'pharmacy_display',
'organization',
'category',
'vat_category',
'location_region',
'coordinates_display',
'phone_numbers_display',
'status',
'license_status_display',
'head_pharmacist',
'responsible_manager',
'curator',
'employees_count_display',
'working_hours',
'is_active',
]
list_editable = ['category', 'vat_category', 'status', 'working_hours']
list_filter = ['organization', 'category', 'vat_category', 'status', 'is_active', LicenseStatusFilter, HasCoordinatesFilter]
search_fields = ['pharmacy_number', 'address', 'phone', 'license_number',
'head_pharmacist__last_name', 'responsible_manager__last_name', 'curator__last_name']
list_select_related = ['organization', 'location_region', 'head_pharmacist', 'responsible_manager', 'curator']
ordering = ['organization', 'pharmacy_number']
# ------------------------------------------------------------------
# Detail view — tabs
# ------------------------------------------------------------------
# address тепер лише генерується в Location.save() з street/house_number/building/
# apartment — ручний ввід сюди все одно перезатреться при збереженні
# is_active тепер похідне поле від status (Pharmacy.save()) — ручне
# редагування нічого не дасть, тому лише readonly-показ.
readonly_fields = [
'address', 'employees_count_display', 'license_status_display',
'is_active', 'created_at', 'updated_at',
'carbonio_account_id', 'carbonio_sync_status', 'carbonio_last_sync',
'carbonio_sync_error', 'carbonio_password',
]
fieldsets = (
(_('Основное'), {
'classes': ('tab',),
'fields': (
'organization', 'pharmacy_number', 'category', 'vat_category',
'status', 'is_active', 'working_hours',
'location_type',
),
}),
(_('Адрес'), {
'classes': ('tab',),
'fields': (
'location_region',
'street', 'house_number', 'building', 'apartment',
'address',
'location',
),
}),
(_('Контакты'), {
'classes': ('tab',),
'fields': ('phone',),
}),
(_('Лицензия'), {
'classes': ('tab',),
'fields': (
'license_number', 'license_expiry_date',
'license_status_display',
),
}),
(_('Персонал'), {
'classes': ('tab',),
'fields': (
'head_pharmacist', 'responsible_manager', 'curator',
'pharmacists_count', 'employees_count_display',
),
}),
(_('Почта Carbonio'), {
'classes': ('tab',),
'fields': (
'carbonio_sync_enabled', 'carbonio_email', 'carbonio_password',
'carbonio_account_id', 'carbonio_sync_status', 'carbonio_last_sync',
'carbonio_sync_error',
),
'description': _('Пусто в «Почта Carbonio» — адрес сгенерируется как '
'<префикс организации><номер>@домен. Можно задать вручную.'),
}),
(_('Примечания'), {
'classes': ('tab',),
'fields': ('comments', 'created_at', 'updated_at'),
}),
)
inlines = [PharmacyPhoneInline, PharmacyHistoryInline]
# ------------------------------------------------------------------
# Queryset
# ------------------------------------------------------------------
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('phones')
# ------------------------------------------------------------------
# List display methods
# ------------------------------------------------------------------
def pharmacy_display(self, obj):
org = obj.organization.name if obj.organization else ''
addr = obj.get_full_address() or obj.address or ''
short_addr = addr[:70] + '…' if len(addr) > 70 else addr
return format_html(
'№{} {}'
'
{}',
obj.pharmacy_number, org, short_addr,
)
pharmacy_display.short_description = _('Аптека')
pharmacy_display.admin_order_field = 'pharmacy_number'
def coordinates_display(self, obj):
if obj.has_coordinates and obj.latitude is not None and obj.longitude is not None:
return format_html('📍 {}', f'{float(obj.latitude):.5f}, {float(obj.longitude):.5f}')
return mark_safe('⚠ немає координат')
coordinates_display.short_description = _('Координаты')
coordinates_display.admin_order_field = 'location'
def license_status_display(self, obj):
status = obj.license_status
color = {
'Действительна': '#16a34a',
'Истекла': '#dc2626',
'Бессрочная': '#6b7280',
'Нет лицензии': '#6b7280',
}.get(status, '#ca8a04' if 'Истекает' in status else '#6b7280')
return format_html('{}', color, status)
license_status_display.short_description = _('Лицензия')
license_status_display.admin_order_field = 'license_expiry_date'
def employees_count_display(self, obj):
count = obj.employees_count
color = '#16a34a' if count else '#9ca3af'
return format_html(
'{}',
color, count or 0,
)
employees_count_display.short_description = _('Сотрудников')
def phone_numbers_display(self, obj):
from django.utils.safestring import mark_safe
numbers = []
if obj.phone:
numbers.append(obj.phone)
numbers.extend(
p.phone for p in obj.phones.all()
if p.phone and p.phone != obj.phone
)
if not numbers:
return '—'
return mark_safe('
'.join(numbers))
phone_numbers_display.short_description = _('Телефоны')
@admin.register(DislocationColumnPreset)
class DislocationColumnPresetAdmin(ModelAdmin):
list_display = ['name', 'code', 'is_default', 'department', 'updated_at']
list_filter = ['is_default']
search_fields = ['name', 'code']
list_select_related = ['department']
ordering = ['name']
readonly_fields = ['created_at', 'updated_at', 'uuid']