Initial commit — ACORE pharmacy network management system
Django project: employees, locations, visit_manager, it_manager, mattermost_connector and stub connectors (ad, mail, portal).
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
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}'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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',)}
|
||||
|
||||
list_display = ['address_display', 'location_type', 'location_region',
|
||||
'coordinates_display', 'is_active', 'updated_at']
|
||||
list_filter = ['location_type', 'is_active']
|
||||
search_fields = ['address', 'street', 'house_number', 'comments']
|
||||
list_select_related = ['location_region']
|
||||
ordering = ['location_region', 'address']
|
||||
|
||||
readonly_fields = ['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 '—'
|
||||
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',)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List view
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
list_display = [
|
||||
'pharmacy_display',
|
||||
'organization',
|
||||
'location_region',
|
||||
'phone_numbers_display',
|
||||
'status_display',
|
||||
'license_status_display',
|
||||
'head_pharmacist',
|
||||
'employees_count_display',
|
||||
'working_hours',
|
||||
'is_active',
|
||||
]
|
||||
list_editable = ['working_hours', 'is_active']
|
||||
list_filter = ['organization', 'status', 'is_active', LicenseStatusFilter]
|
||||
search_fields = ['pharmacy_number', 'address', 'phone', 'license_number',
|
||||
'head_pharmacist__last_name']
|
||||
list_select_related = ['organization', 'location_region', 'head_pharmacist']
|
||||
ordering = ['organization', 'pharmacy_number']
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detail view — tabs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
readonly_fields = [
|
||||
'employees_count_display', 'license_status_display',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
(_('Основное'), {
|
||||
'classes': ('tab',),
|
||||
'fields': (
|
||||
'organization', 'pharmacy_number',
|
||||
'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', 'pharmacists_count',
|
||||
'employees_count_display',
|
||||
),
|
||||
}),
|
||||
(_('Примечания'), {
|
||||
'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'
|
||||
|
||||
STATUS_COLORS = {
|
||||
'open': '#16a34a',
|
||||
'closed': '#dc2626',
|
||||
'suspended': '#ca8a04',
|
||||
'preparing': '#2563eb',
|
||||
}
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display(),
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
|
||||
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 = _('Телефоны')
|
||||
Reference in New Issue
Block a user