Files
ACORE/apps/locations/admin.py
T
lokses 3be444b05e Carbonio CE: API-слой синхронизации ящиков аптек
Серверная часть интеграции по эталону AD-коннектора (драйвер — Pharmacy):

- carbonio_service.py: клиент admin API Carbonio/Zimbra (JSON-SOAP на 6071):
  auth + get/create/modify(status)/delete account, ping для read-only проверки
- services/sync_service.py: sync_pharmacy_to_carbonio (идемпотентно),
  deactivate_pharmacy_carbonio; логирование в CarbonioSyncLog
- signals.py: post_save/pre_save на Pharmacy — создание ящика при включённой
  синхронизации, закрытие/открытие при смене статуса аптеки; защита от рекурсии
  (account_id гасит повторный вызов), sync_enabled=False по умолчанию не трогает прод
- tasks.py: Celery-обёртки; CarbonioSyncLog + read-only админка
- команда sync_pharmacies_to_carbonio (dry-run по умолчанию, --apply)
- Carbonio-поля в админке Pharmacy; убран конфликт models.py vs models/

Без живого сервера деградирует мягко: CarbonioConfigError не роняет сохранение.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:02:35 +03:00

390 lines
16 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
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
# ---------------------------------------------------------------------------
# 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',
]
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_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 = _('Телефоны')