56e2b1c8fe
Let admins set per-section access levels so operational data on pharmacy detail is no longer all-or-nothing for every logged-in user. Co-authored-by: Cursor <cursoragent@cursor.com>
429 lines
17 KiB
Python
429 lines
17 KiB
Python
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, 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', '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(
|
||
'<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):
|
||
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('<br>'.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']
|
||
|
||
|
||
@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',
|
||
),
|
||
}),
|
||
)
|