Files
ACORE/apps/locations/admin.py
T
lokses 897ffc99c9 locations: право редактирования аптеки, формы и дислокация
Отдельное право на редактирование карточки аптеки, формы, пресеты колонок
дислокации и правки видимости карточки.

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

439 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', '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(
'<strong>{}</strong><br><span style="color:#666;font-size:12px;">{}</span>',
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('<span style="color:#dc2626;">⚠ немає координат</span>')
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(
'<strong>№{} {}</strong>'
'<br><span style="color:#6b7280;font-size:12px;">{}</span>',
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('<span style="color:#dc2626;">⚠ немає координат</span>')
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('<span style="color:{};">{}</span>', 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(
'<span style="color:{};font-weight:bold;">{}</span>',
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('<br>', '{}', ((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/<id>/ '
'(кнопка «Редактировать»). Суперпользователь всегда может.'
),
'fields': ('edit_pharmacy',),
}),
)