from django import forms from django.contrib import admin from django.db import models from django.utils.html import format_html, format_html_join 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, PharmacyCardVisibility, ) # --------------------------------------------------------------------------- # 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', 'name', 'location_type', 'location_region', 'coordinates_display', 'is_active', 'updated_at'] list_filter = ['location_type', 'is_active', HasCoordinatesFilter] search_fields = ['name', 'address', 'street', 'house_number', 'comments'] list_select_related = ['location_region'] ordering = ['location_type', 'name', 'location_region', 'address'] # address тепер лише генерується в Location.save() з street/house_number/building/ # apartment — ручний ввід сюди все одно перезатреться при збереженні readonly_fields = ['address', 'created_at', 'updated_at'] fieldsets = ( (_('Тип и регион'), { 'fields': ('location_type', 'name', 'location_region', 'is_active'), 'description': _( '«Сервис / поставщик» — обслуживающие организации (Вайтер Юг и т.п.): ' 'укажите название, адрес по желанию.' ), }), (_('Адрес'), { '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__name', '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] def save_model(self, request, obj, form, change): obj._history_user = request.user super().save_model(request, obj, form, change) # ------------------------------------------------------------------ # 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): 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 format_html_join('
', '{}', ((n,) for n in 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'] @admin.register(PharmacyCardVisibility) class PharmacyCardVisibilityAdmin(ModelAdmin): """Єдиний запис політики — хто бачить вкладки картки аптеки.""" def has_add_permission(self, request): return not PharmacyCardVisibility.objects.exists() def has_delete_permission(self, request, obj=None): return False fieldsets = ( (_('Операционные вкладки карточки аптеки'), { 'description': _( 'Для каждой вкладки: кто может видеть данные. ' '«Своя аптека или staff» — поведение по умолчанию. ' 'Суперпользователь всегда видит всё.' ), 'fields': ( 'show_staff', 'show_equipment', 'show_internet', 'show_rro', 'show_utilities', 'show_documents', ), }), (_('Редактирование в портале'), { 'description': _( 'Кто может менять реквизиты аптеки на странице /pharmacies// ' '(кнопка «Редактировать»). Суперпользователь всегда может.' ), 'fields': ('edit_pharmacy',), }), )