From 897ffc99c939a5dca277457f6be4763b6366305a Mon Sep 17 00:00:00 2001 From: lokses Date: Wed, 9 Sep 2026 18:03:32 +0300 Subject: [PATCH] =?UTF-8?q?locations:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=BF=D1=82=D0=B5=D0=BA=D0=B8?= =?UTF-8?q?,=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=B8=20=D0=B4=D0=B8?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=BA=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отдельное право на редактирование карточки аптеки, формы, пресеты колонок дислокации и правки видимости карточки. Co-Authored-By: Claude Opus 4.8 --- apps/locations/admin.py | 18 +- apps/locations/dislocation_columns.py | 10 + apps/locations/forms.py | 100 ++++++++ .../0013_pharmacy_edit_permission.py | 31 +++ apps/locations/models/__init__.py | 10 +- apps/locations/models/base.py | 111 ++++++++- apps/locations/models/pharmacies.py | 78 ++++++- apps/locations/models/pharmacy_visibility.py | 45 +++- apps/locations/tests.py | 185 ++++++++++++++- apps/locations/views.py | 132 ++++++++++- static/assets/js/pharmacies.js | 40 +--- templates/pages/dislocation.html | 220 ++++++++++++++---- templates/pages/pharmacies.html | 41 +--- templates/pages/pharmacy-detail.html | 148 +++++++++++- templates/partials/dislo_cell.html | 12 + 15 files changed, 1039 insertions(+), 142 deletions(-) create mode 100644 apps/locations/forms.py create mode 100644 apps/locations/migrations/0013_pharmacy_edit_permission.py diff --git a/apps/locations/admin.py b/apps/locations/admin.py index 03bce26..507385f 100644 --- a/apps/locations/admin.py +++ b/apps/locations/admin.py @@ -1,7 +1,7 @@ from django import forms from django.contrib import admin from django.db import models -from django.utils.html import format_html +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 @@ -256,7 +256,7 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): 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'] + ordering = ['organization__name', 'pharmacy_number'] # ------------------------------------------------------------------ # Detail view — tabs @@ -327,6 +327,10 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): inlines = [PharmacyPhoneInline, PharmacyHistoryInline] + def save_model(self, request, obj, form, change): + obj._history_user = request.user + super().save_model(request, obj, form, change) + # ------------------------------------------------------------------ # Queryset # ------------------------------------------------------------------ @@ -379,7 +383,6 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): 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) @@ -389,7 +392,7 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): ) if not numbers: return '—' - return mark_safe('
'.join(numbers)) + return format_html_join('
', '{}', ((n,) for n in numbers)) phone_numbers_display.short_description = _('Телефоны') @@ -425,4 +428,11 @@ class PharmacyCardVisibilityAdmin(ModelAdmin): 'show_rro', 'show_utilities', 'show_documents', ), }), + (_('Редактирование в портале'), { + 'description': _( + 'Кто может менять реквизиты аптеки на странице /pharmacies// ' + '(кнопка «Редактировать»). Суперпользователь всегда может.' + ), + 'fields': ('edit_pharmacy',), + }), ) diff --git a/apps/locations/dislocation_columns.py b/apps/locations/dislocation_columns.py index 5e4c9cc..bcae276 100644 --- a/apps/locations/dislocation_columns.py +++ b/apps/locations/dislocation_columns.py @@ -13,6 +13,8 @@ LOCKED_KEY = 'pharmacy' STANDARD_COLUMNS = [ 'pharmacy', 'organization', 'head', 'staff', 'phone', 'hours', 'status', + 'generator', 'inverter', 'mikrotik', + 'provider', 'contract', 'gpon', ] HR_COLUMNS = [ @@ -21,6 +23,8 @@ HR_COLUMNS = [ OPS_COLUMNS = [ 'pharmacy', 'region', 'phone', 'hours', 'status', 'category', + 'generator', 'inverter', 'mikrotik', + 'provider', 'contract', 'gpon', ] @@ -47,6 +51,12 @@ COLUMN_DEFS: dict[str, ColumnDef] = { 'license': ColumnDef('license', _('Лицензия'), 'string'), 'vat': ColumnDef('vat', _('ПДВ'), 'string'), 'email': ColumnDef('email', _('Email'), 'string'), + 'generator': ColumnDef('generator', _('Генератор'), 'string'), + 'inverter': ColumnDef('inverter', _('Инвертор'), 'string'), + 'mikrotik': ColumnDef('mikrotik', _('MikroTik'), 'string'), + 'provider': ColumnDef('provider', _('Провайдер'), 'string'), + 'contract': ColumnDef('contract', _('Договор'), 'string'), + 'gpon': ColumnDef('gpon', _('GPON'), 'string'), } ALLOWED_KEYS = frozenset(COLUMN_DEFS) diff --git a/apps/locations/forms.py b/apps/locations/forms.py new file mode 100644 index 0000000..c8bd544 --- /dev/null +++ b/apps/locations/forms.py @@ -0,0 +1,100 @@ +"""Формы портала для аптек.""" +from __future__ import annotations + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from core.models import Directory +from .models import Pharmacy + + +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 PortalPharmacyEditForm(forms.ModelForm): + """Редактирование реквизитов аптеки в портале.""" + + location_region = HierarchicalRegionChoiceField( + label=_('Город/район'), + empty_label=_('— Выберите регион —'), + ) + + class Meta: + model = Pharmacy + fields = ( + 'organization', 'pharmacy_number', 'status', 'category', 'vat_category', + 'working_hours', 'location_region', + 'street', 'house_number', 'building', 'apartment', 'location', + 'phone', 'license_number', 'license_expiry_date', + 'head_pharmacist', 'responsible_manager', 'curator', 'pharmacists_count', + 'comments', + ) + widgets = { + 'working_hours': forms.TextInput(attrs={'class': 'input', 'placeholder': '08:00-20:00'}), + 'street': forms.TextInput(attrs={'class': 'input'}), + 'house_number': forms.TextInput(attrs={'class': 'input'}), + 'building': forms.TextInput(attrs={'class': 'input'}), + 'apartment': forms.TextInput(attrs={'class': 'input'}), + 'phone': forms.TextInput(attrs={'class': 'input', 'placeholder': '+380...'}), + 'license_number': forms.TextInput(attrs={'class': 'input'}), + 'license_expiry_date': forms.DateInput(attrs={'class': 'input', 'type': 'date'}), + 'pharmacists_count': forms.NumberInput(attrs={'class': 'input', 'min': 1}), + 'comments': forms.Textarea(attrs={'class': 'input', 'rows': 3}), + 'location': forms.TextInput(attrs={ + 'class': 'input', + 'placeholder': '46.4825,30.7233', + }), + 'pharmacy_number': forms.NumberInput(attrs={'class': 'input', 'min': 1}), + 'organization': forms.Select(attrs={'class': 'input'}), + 'status': forms.Select(attrs={'class': 'input'}), + 'category': forms.Select(attrs={'class': 'input'}), + 'vat_category': forms.Select(attrs={'class': 'input'}), + 'head_pharmacist': forms.Select(attrs={'class': 'input'}), + 'responsible_manager': forms.Select(attrs={'class': 'input'}), + 'curator': forms.Select(attrs={'class': 'input'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + from apps.employees.models import Employee + + self.fields['organization'].queryset = Directory.objects.filter( + directory_type__code='organization', is_active=True, + ).order_by('name') + self.fields['organization'].widget.attrs['class'] = 'input' + self.fields['location_region'].widget.attrs['class'] = 'input' + + active = Employee.objects.filter(is_active=True).select_related('post').order_by( + 'last_name', 'first_name', + ) + self.fields['head_pharmacist'].queryset = active + self.fields['responsible_manager'].queryset = active + self.fields['curator'].queryset = active + for name in ('head_pharmacist', 'responsible_manager', 'curator'): + self.fields[name].required = False + self.fields[name].empty_label = '—' + + self.fields['category'].required = False + self.fields['vat_category'].required = False + self.fields['license_number'].required = False + self.fields['license_expiry_date'].required = False + self.fields['phone'].required = False + self.fields['comments'].required = False + self.fields['building'].required = False + self.fields['apartment'].required = False + self.fields['location'].required = False diff --git a/apps/locations/migrations/0013_pharmacy_edit_permission.py b/apps/locations/migrations/0013_pharmacy_edit_permission.py new file mode 100644 index 0000000..2086798 --- /dev/null +++ b/apps/locations/migrations/0013_pharmacy_edit_permission.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0012_pharmacy_card_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='pharmacycardvisibility', + name='edit_pharmacy', + field=models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='staff', + help_text=( + 'Кто может менять реквизиты аптеки в портале ' + '(не в админке). По умолчанию — только staff.' + ), + max_length=20, + verbose_name='Редактирование карточки', + ), + ), + ] diff --git a/apps/locations/models/__init__.py b/apps/locations/models/__init__.py index 7fe27bb..c904504 100644 --- a/apps/locations/models/__init__.py +++ b/apps/locations/models/__init__.py @@ -1,12 +1,18 @@ from .base import Location, LocationManager from .pharmacies import Pharmacy, PharmacyManager, PharmacyPhone, PharmacyHistory from .dislocation import DislocationColumnPreset -from .pharmacy_visibility import PharmacyCardVisibility, pharmacy_card_flags, user_can_view_pharmacy_section +from .pharmacy_visibility import ( + PharmacyCardVisibility, + pharmacy_card_flags, + user_can_edit_pharmacy, + user_can_view_pharmacy_section, +) __all__ = [ 'Location', 'LocationManager', 'Pharmacy', 'PharmacyManager', 'PharmacyPhone', 'PharmacyHistory', 'DislocationColumnPreset', - 'PharmacyCardVisibility', 'pharmacy_card_flags', 'user_can_view_pharmacy_section', + 'PharmacyCardVisibility', 'pharmacy_card_flags', + 'user_can_edit_pharmacy', 'user_can_view_pharmacy_section', ] diff --git a/apps/locations/models/base.py b/apps/locations/models/base.py index 324a6c2..7863e4f 100644 --- a/apps/locations/models/base.py +++ b/apps/locations/models/base.py @@ -3,6 +3,72 @@ from core.models import BaseModel from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from location_field.models.plain import PlainLocationField +import re + +# Буква объекта IT-журнала, если у организации нет mail_prefix в extra_data. +_ORG_LETTER_BY_NAME = ( + (re.compile(r'пентальф', re.I), 'п'), + (re.compile(r'тира|тіра', re.I), 'т'), + (re.compile(r'акс', re.I), 'а'), # ПП АКС / ТОВ АКС Одесса → А + (re.compile(r'гіппократ|гиппократ', re.I), 'г'), + (re.compile(r'лісковськ|лисковск', re.I), 'л'), +) + +_LAT_TO_CYR = str.maketrans('APTGL', 'АПТГЛ') +_CYR_TO_LAT = str.maketrans('АПТГЛ', 'APTGL') + + +def organization_object_letter(organization) -> str: + """Одна буква кода аптеки: а/п/т… из mail_prefix или по названию юрлица.""" + if organization is None: + return '' + extra = organization.extra_data + prefix = '' + if isinstance(extra, dict): + prefix = (extra.get('mail_prefix') or '').strip() + if prefix: + letter = prefix[0].lower() + return letter.translate(str.maketrans('aptgl', 'аптгл')) + name = organization.name or '' + for pattern, letter in _ORG_LETTER_BY_NAME: + if pattern.search(name): + return letter + return '' + + +def pharmacy_object_code(pharmacy) -> str: + """Код объекта как в IT-журналах: А12, П10, Т5.""" + if pharmacy is None or pharmacy.pharmacy_number is None: + return '' + letter = organization_object_letter(pharmacy.organization) + if not letter: + return f'№{pharmacy.pharmacy_number}' + letter = letter.upper().translate(_LAT_TO_CYR) + if letter not in 'АПТГЛ': + letter = letter[0].upper() + return f'{letter}{pharmacy.pharmacy_number}' + + +def pharmacy_search_text(pharmacy) -> str: + """Строка для поиска в searchable-select: А3, A3, организация, номер, адрес.""" + if pharmacy is None: + return '' + parts = [] + code = pharmacy_object_code(pharmacy) + if code: + parts.append(code) + parts.append(code.translate(_CYR_TO_LAT)) + parts.append(code.lower()) + parts.append(code.translate(_CYR_TO_LAT).lower()) + if pharmacy.pharmacy_number is not None: + parts.append(str(pharmacy.pharmacy_number)) + org = pharmacy.organization + if org is not None and org.name: + parts.append(org.name) + addr = pharmacy.get_full_address() or pharmacy.address or '' + if addr: + parts.append(addr) + return ' '.join(parts) class LocationManager(models.Manager): @@ -71,17 +137,50 @@ class Location(BaseModel): ] def __str__(self): + try: + return self._display_label() + except Exception: + return f'Локация #{self.pk}' + + def _display_label(self) -> str: addr = self.get_full_address() or self.address if self.location_type == 'pharmacy': - # Location базовий — для аптеки завжди є child-рядок Pharmacy - # (multi-table inheritance); pharmacy_number — головний орієнтир - # для пошуку/вибору аптеки, адреса сама по собі його не показує - pharmacy = getattr(self, 'pharmacy', None) + # Номер без організації неоднозначний (різні підприємства можуть мати №12). + try: + pharmacy = self.pharmacy + except Exception: + pharmacy = None if pharmacy: - return f'Аптека №{pharmacy.pharmacy_number} — {addr}' if addr else f'Аптека №{pharmacy.pharmacy_number}' + org = pharmacy.organization + org_name = org.name if org is not None else '' + code = pharmacy_object_code(pharmacy) + if code and org_name: + head = f'{code} ({org_name})' + elif code: + head = code + elif org_name: + head = f'{org_name} №{pharmacy.pharmacy_number}' + else: + head = f'Аптека №{pharmacy.pharmacy_number}' + return f'{head} — {addr}' if addr else head type_label = self.get_location_type_display() return f'{type_label} — {addr}' if addr else type_label + @property + def search_text(self) -> str: + """Текст для searchable-select (коды А3/A3, П10/P10, организация…).""" + try: + if self.location_type == 'pharmacy': + try: + pharmacy = self.pharmacy + except Exception: + pharmacy = None + if pharmacy: + return pharmacy_search_text(pharmacy) + return self._display_label() + except Exception: + return str(self.pk or '') + def save(self, *args, **kwargs): # "Полный адрес" — поле лише для читання (help_text це й обіцяв, але ніхто # ніколи насправді не проставляв значення) — перераховуємо з частин щоразу @@ -141,4 +240,4 @@ class Location(BaseModel): """True, лише якщо координати реально проставлені — не просто дефолт поля (PlainLocationField.default непорожній, тож голий bool() завжди був True).""" default = self._meta.get_field('location').default - return bool(self.location) and self.location != default \ No newline at end of file + return bool(self.location) and self.location != default diff --git a/apps/locations/models/pharmacies.py b/apps/locations/models/pharmacies.py index 3f3511c..112f3aa 100644 --- a/apps/locations/models/pharmacies.py +++ b/apps/locations/models/pharmacies.py @@ -269,6 +269,17 @@ class Pharmacy(Location): return '' return f'{prefix}{self.pharmacy_number}@{carbonio_mail_domain()}' + # Поля, по которым пишется PharmacyHistory. Служебные (пароль Carbonio, + # статусы синка, timestamps) специально не трекаем. + HISTORY_FIELDS = ( + 'pharmacy_number', 'organization', 'license_number', 'license_expiry_date', + 'head_pharmacist', 'responsible_manager', 'curator', 'pharmacists_count', + 'status', 'category', 'vat_category', 'phone', 'working_hours', + 'carbonio_email', 'carbonio_sync_enabled', + 'street', 'house_number', 'building', 'apartment', 'address', 'location', + 'location_region', 'is_active', 'comments', + ) + def _carbonio_email_clashes(self, email: str) -> bool: qs = Pharmacy.objects.filter(carbonio_email__iexact=email) if self.pk: @@ -276,6 +287,8 @@ class Pharmacy(Location): return qs.exists() def save(self, *args, **kwargs): + update_fields = kwargs.get('update_fields') + old = _pharmacy_snapshot(self, update_fields) if not self.location_type: self.location_type = 'pharmacy' # is_active — не отдельное ручное поле для аптек, а производное от @@ -289,6 +302,8 @@ class Pharmacy(Location): if candidate and not self._carbonio_email_clashes(candidate): self.carbonio_email = candidate super().save(*args, **kwargs) + if old is not None: + _record_pharmacy_history(self, old, update_fields) @property def is_license_valid(self): @@ -344,7 +359,7 @@ class PharmacyPhone(models.Model): class PharmacyHistory(models.Model): - """Аудит изменений полей аптеки""" + """Аудит изменений полей аптеки. Записи создаёт Pharmacy.save().""" pharmacy = models.ForeignKey(Pharmacy, on_delete=models.CASCADE, related_name='history', verbose_name=_('Аптека')) changed_field = models.CharField(_('Изменённое поле'), max_length=50) @@ -361,3 +376,64 @@ class PharmacyHistory(models.Model): verbose_name = _('История изменений аптеки') verbose_name_plural = _('История изменений аптек') ordering = ['-changed_at'] + + +def _history_actor(instance): + user = getattr(instance, '_history_user', None) + if user is not None and getattr(user, 'is_authenticated', False) and getattr(user, 'pk', None): + return user + return None + + +def _history_should_check(update_fields, history_fields): + if update_fields is None: + return True + update_set = set(update_fields) + return any(f in update_set or f'{f}_id' in update_set for f in history_fields) + + +def _pharmacy_snapshot(pharmacy, update_fields): + if not pharmacy.pk or not _history_should_check(update_fields, pharmacy.HISTORY_FIELDS): + return None + return Pharmacy.objects.filter(pk=pharmacy.pk).first() + + +def _field_identity(instance, field_name): + field = instance._meta.get_field(field_name) + if field.is_relation and not field.many_to_many: + return getattr(instance, field.attname) + value = getattr(instance, field_name) + return '' if value is None else value + + +def _field_display(instance, field_name): + field = instance._meta.get_field(field_name) + if field.choices: + display = getattr(instance, f'get_{field_name}_display')() + return '' if display is None else str(display) + value = getattr(instance, field_name) + return '' if value is None else str(value) + + +def _record_pharmacy_history(pharmacy, old, update_fields=None): + fields = pharmacy.HISTORY_FIELDS + if update_fields is not None: + update_set = set(update_fields) + fields = tuple( + f for f in fields + if f in update_set or f'{f}_id' in update_set + ) + user = _history_actor(pharmacy) + rows = [] + for field_name in fields: + if _field_identity(old, field_name) == _field_identity(pharmacy, field_name): + continue + rows.append(PharmacyHistory( + pharmacy=pharmacy, + changed_field=field_name, + old_value=_field_display(old, field_name), + new_value=_field_display(pharmacy, field_name), + changed_by=user, + )) + if rows: + PharmacyHistory.objects.bulk_create(rows) diff --git a/apps/locations/models/pharmacy_visibility.py b/apps/locations/models/pharmacy_visibility.py index 07d0bd8..4166dd6 100644 --- a/apps/locations/models/pharmacy_visibility.py +++ b/apps/locations/models/pharmacy_visibility.py @@ -42,6 +42,14 @@ class PharmacyCardVisibility(models.Model): _('Документы'), max_length=20, choices=Level.choices, default=Level.OWN_OR_STAFF, ) + edit_pharmacy = models.CharField( + _('Редактирование карточки'), max_length=20, + choices=Level.choices, default=Level.STAFF, + help_text=_( + 'Кто может менять реквизиты аптеки в портале ' + '(не в админке). По умолчанию — только staff.' + ), + ) class Meta: verbose_name = _('Видимость карточки аптеки') @@ -59,8 +67,25 @@ class PharmacyCardVisibility(models.Model): @classmethod def load(cls) -> 'PharmacyCardVisibility': - obj, _ = cls.objects.get_or_create(pk=1) - return obj + from django.db import connection + from django.db.utils import ProgrammingError + + try: + obj, _ = cls.objects.get_or_create(pk=1) + return obj + except ProgrammingError: + # Миграция 0013 ещё не применена — не валим карточку аптеки. + connection.rollback() + return cls( + pk=1, + show_staff=cls.Level.OWN_OR_STAFF, + show_equipment=cls.Level.OWN_OR_STAFF, + show_internet=cls.Level.OWN_OR_STAFF, + show_rro=cls.Level.OWN_OR_STAFF, + show_utilities=cls.Level.OWN_OR_STAFF, + show_documents=cls.Level.OWN_OR_STAFF, + edit_pharmacy=cls.Level.STAFF, + ) SECTIONS = ( 'staff', @@ -100,12 +125,22 @@ def user_can_view_pharmacy_section(user, pharmacy, level: str) -> bool: return False -def pharmacy_card_flags(user, pharmacy) -> dict[str, bool]: - """Словник can_view_
за поточною політикою.""" +def user_can_edit_pharmacy(user, pharmacy) -> bool: + """Чи може user редагувати реквізити аптеки в порталі.""" policy = PharmacyCardVisibility.load() - return { + return user_can_view_pharmacy_section(user, pharmacy, policy.edit_pharmacy) + + +def pharmacy_card_flags(user, pharmacy) -> dict[str, bool]: + """Словник can_view_
/ can_edit за поточною політикою.""" + policy = PharmacyCardVisibility.load() + flags = { f'can_view_{section}': user_can_view_pharmacy_section( user, pharmacy, policy.level_for(section), ) for section in PharmacyCardVisibility.SECTIONS } + flags['can_edit'] = user_can_view_pharmacy_section( + user, pharmacy, policy.edit_pharmacy, + ) + return flags diff --git a/apps/locations/tests.py b/apps/locations/tests.py index 439ddd7..a92433c 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -260,6 +260,48 @@ class DislocationColumnsTests(TestCase): self.assertContains(resp, "disloSortBy('manager'") self.assertNotContains(resp, "disloSortBy('phone'") + def test_staff_can_create_preset(self): + from apps.locations.models import DislocationColumnPreset + from core.test_utils import login_2fa + + self.user.is_staff = True + self.user.save(update_fields=['is_staff']) + login_2fa(self.client, self.user) + + resp = self.client.post( + '/dislocation/column-presets/', + { + 'action': 'create', + 'name': 'IT набор', + 'code': 'it', + 'columns': ['pharmacy', 'mikrotik', 'provider', 'gpon'], + 'is_default': '', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + preset = DislocationColumnPreset.objects.get(code='it') + self.assertEqual(preset.name, 'IT набор') + self.assertEqual(preset.columns, ['pharmacy', 'mikrotik', 'provider', 'gpon']) + + def test_non_staff_cannot_create_preset(self): + self.client.force_login(self.user) + session = self.client.session + session['2fa_verified'] = True + session.save() + + resp = self.client.post( + '/dislocation/column-presets/', + { + 'action': 'create', + 'name': 'Hack', + 'code': 'hack', + 'columns': ['pharmacy', 'phone'], + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 403) + class PharmacyCardVisibilityTests(TestCase): """Матрица видимости вкладок карточки аптеки (синглтон в админке).""" @@ -316,7 +358,8 @@ class PharmacyCardVisibilityTests(TestCase): from apps.locations.models import PharmacyCardVisibility policy = PharmacyCardVisibility.load() - setattr(policy, f'show_{section}', level) + attr = f'show_{section}' if section != 'edit' else 'edit_pharmacy' + setattr(policy, attr, level) policy.save() def test_own_or_staff_default(self): @@ -329,6 +372,88 @@ class PharmacyCardVisibilityTests(TestCase): self.assertFalse(user_can_view_pharmacy_section(self.stranger, self.pharmacy, level)) self.assertTrue(user_can_view_pharmacy_section(self.staff, self.pharmacy, level)) + def test_edit_pharmacy_default_staff_only(self): + from apps.locations.models import pharmacy_card_flags, user_can_edit_pharmacy + + self.assertFalse(user_can_edit_pharmacy(self.own, self.pharmacy)) + self.assertTrue(user_can_edit_pharmacy(self.staff, self.pharmacy)) + flags = pharmacy_card_flags(self.own, self.pharmacy) + self.assertFalse(flags['can_edit']) + flags_staff = pharmacy_card_flags(self.staff, self.pharmacy) + self.assertTrue(flags_staff['can_edit']) + + def test_edit_pharmacy_own_or_staff_allows_own(self): + from apps.locations.models import PharmacyCardVisibility, user_can_edit_pharmacy + + self._set_level('edit', PharmacyCardVisibility.Level.OWN_OR_STAFF) + self.assertTrue(user_can_edit_pharmacy(self.own, self.pharmacy)) + self.assertFalse(user_can_edit_pharmacy(self.stranger, self.pharmacy)) + self.assertTrue(user_can_edit_pharmacy(self.staff, self.pharmacy)) + + def test_portal_edit_forbidden_without_permission(self): + self.client.force_login(self.own) + session = self.client.session + session['2fa_verified'] = True + session.save() + resp = self.client.post( + f'/pharmacies/{self.pharmacy.pk}/edit/', + {'working_hours': '09:00-21:00'}, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 403) + + def test_portal_edit_saves_when_allowed(self): + from apps.locations.models import PharmacyCardVisibility + + self._set_level('edit', PharmacyCardVisibility.Level.OWN_OR_STAFF) + self.client.force_login(self.own) + session = self.client.session + session['2fa_verified'] = True + session.save() + resp = self.client.post( + f'/pharmacies/{self.pharmacy.pk}/edit/', + { + 'organization': self.pharmacy.organization_id, + 'pharmacy_number': self.pharmacy.pharmacy_number, + 'status': 'open', + 'category': 'A', + 'vat_category': '', + 'working_hours': '09:00-21:00', + 'location_region': self.pharmacy.location_region_id, + 'street': 'Нова', + 'house_number': '10', + 'building': '', + 'apartment': '', + 'location': '46.4825,30.7233', + 'phone': '', + 'license_number': '', + 'license_expiry_date': '', + 'head_pharmacist': '', + 'responsible_manager': '', + 'curator': '', + 'pharmacists_count': 2, + 'comments': 'Оновлено з порталу', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.pharmacy.refresh_from_db() + self.assertEqual(self.pharmacy.working_hours, '09:00-21:00') + self.assertEqual(self.pharmacy.street, 'Нова') + self.assertEqual(self.pharmacy.comments, 'Оновлено з порталу') + self.assertContains(resp, 'Изменения сохранены') + + def test_detail_shows_edit_button_for_staff(self): + self.client.force_login(self.staff) + session = self.client.session + session['2fa_verified'] = True + session.save() + resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.context['can_edit']) + self.assertContains(resp, 'edit=1') + self.assertNotContains(resp, 'Редактировать в админке') + def test_everyone_and_hidden(self): from apps.locations.models import ( PharmacyCardVisibility, user_can_view_pharmacy_section, @@ -401,3 +526,61 @@ class PharmacyCardVisibilityTests(TestCase): self.assertTrue(resp.context.get('can_view_rro')) self.assertContains(resp, 'FN-VIS-1') self.assertContains(resp, 'ph-panel-rro') + + +class PharmacyHistoryTests(TestCase): + """PharmacyHistory заполняется при изменении аптеки, а не при создании.""" + + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса') + cls.aks = Directory.objects.create( + directory_type=org_type, code='akc', name='АКС', extra_data={'mail_prefix': 'a'}) + + def _pharmacy(self, number=1, **kw): + return Pharmacy.objects.create( + organization=self.aks, location_region=self.region, + pharmacy_number=number, location_type='pharmacy', **kw) + + def test_create_does_not_write_history(self): + from apps.locations.models import PharmacyHistory + p = self._pharmacy(30) + self.assertEqual(PharmacyHistory.objects.filter(pharmacy=p).count(), 0) + + def test_field_change_writes_history(self): + from apps.locations.models import PharmacyHistory + p = self._pharmacy(31, phone='+380501111111') + p.phone = '+380502222222' + p.save() + rows = list(PharmacyHistory.objects.filter(pharmacy=p)) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].changed_field, 'phone') + self.assertEqual(rows[0].old_value, '+380501111111') + self.assertEqual(rows[0].new_value, '+380502222222') + self.assertIsNone(rows[0].changed_by) + + def test_unchanged_save_writes_nothing(self): + from apps.locations.models import PharmacyHistory + p = self._pharmacy(32) + p.save() + self.assertEqual(PharmacyHistory.objects.filter(pharmacy=p).count(), 0) + + def test_history_user_is_recorded(self): + from apps.locations.models import PharmacyHistory + from apps.employees.tests import make_employee + user = make_employee('hist-user') + p = self._pharmacy(33, working_hours='08:00-20:00') + p.working_hours = '09:00-21:00' + p._history_user = user + p.save() + row = PharmacyHistory.objects.get(pharmacy=p, changed_field='working_hours') + self.assertEqual(row.changed_by, user) + + def test_untracked_update_fields_skip_history(self): + from apps.locations.models import PharmacyHistory + p = self._pharmacy(34, carbonio_sync_status='pending') + p.carbonio_sync_status = 'synced' + p.save(update_fields=['carbonio_sync_status']) + self.assertEqual(PharmacyHistory.objects.filter(pharmacy=p).count(), 0) diff --git a/apps/locations/views.py b/apps/locations/views.py index 91ea44a..4ba48f3 100644 --- a/apps/locations/views.py +++ b/apps/locations/views.py @@ -1,3 +1,131 @@ -from django.shortcuts import render +"""Views портала для локаций / аптек.""" +from __future__ import annotations -# Create your views here. +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_POST + +from core.utils.htmx import render_page + +from .forms import PortalPharmacyEditForm +from .models import Pharmacy, pharmacy_card_flags, user_can_edit_pharmacy + + +def _pharmacy_detail_context(request, pharmacy, *, form=None, edit_mode=False, + form_message=None, form_error=None): + """Контекст карточки аптеки (общий для GET/POST).""" + from apps.employees.models import Employee + from apps.it_manager.models import Equipment, InternetContract + from apps.rro.models import CashRegister + from apps.utilities.models import UtilityReading + + flags = pharmacy_card_flags(request.user, pharmacy) + + staff = [] + if flags['can_view_staff']: + staff = list( + Employee.objects.filter(user_pharmacy=pharmacy, is_active=True) + .select_related('post').order_by('last_name', 'first_name') + ) + + equipment = [] + if flags['can_view_equipment']: + equipment = list( + Equipment.objects.filter(location=pharmacy) + .select_related( + 'equipment_model__equipment_type', 'equipment_model__brand', + 'status', 'assigned_to', + ) + .order_by('equipment_model__equipment_type__name', 'equipment_model__name') + ) + + internet_contracts = [] + if flags['can_view_internet']: + internet_contracts = list( + InternetContract.objects.filter(location=pharmacy) + .select_related('provider', 'service_type', 'connection_type', 'status') + .order_by('-start_date') + ) + + cash_registers = [] + if flags['can_view_rro']: + cash_registers = list( + CashRegister.objects.filter(pharmacy=pharmacy) + .select_related('vendor', 'status') + .order_by('-is_primary', 'kind', 'fiscal_number') + ) + + utility_readings = [] + if flags['can_view_utilities']: + from apps.utilities.helpers import attach_reading_display + utility_readings = list( + UtilityReading.objects.filter(pharmacy=pharmacy) + .select_related('utility_type', 'submitted_by') + .order_by('-reading_date')[:50] + ) + attach_reading_display(utility_readings) + + can_edit = flags['can_edit'] + if edit_mode and can_edit and form is None: + form = PortalPharmacyEditForm(instance=pharmacy) + + return { + 'pharmacy': pharmacy, + 'staff': staff, + 'staff_count': len(staff), + 'phones': list(pharmacy.phones.filter(is_active=True)), + 'equipment': equipment, + 'internet_contracts': internet_contracts, + 'cash_registers': cash_registers, + 'utility_readings': utility_readings, + **flags, + 'can_view_ops': any(flags[f'can_view_{s}'] for s in ( + 'staff', 'equipment', 'internet', 'rro', 'utilities', 'documents', + )), + 'pharmacy_edit_mode': bool(edit_mode and can_edit), + 'pharmacy_edit_form': form if (edit_mode and can_edit) else None, + 'pharmacy_form_message': form_message, + 'pharmacy_form_error': form_error, + } + + +@login_required +@require_POST +def pharmacy_update_view(request, pk): + pharmacy = get_object_or_404( + Pharmacy.objects.select_related( + 'organization', 'location_region', 'head_pharmacist__post', + 'responsible_manager', 'curator', + ).prefetch_related('phones'), + pk=pk, + ) + if not user_can_edit_pharmacy(request.user, pharmacy): + return HttpResponseForbidden() + + form = PortalPharmacyEditForm(request.POST, instance=pharmacy) + if form.is_valid(): + pharmacy = form.save(commit=False) + pharmacy._history_user = request.user + pharmacy.location_type = 'pharmacy' + pharmacy.save() + form.save_m2m() + pharmacy.refresh_from_db() + context = _pharmacy_detail_context( + request, pharmacy, + form_message='Изменения сохранены.', + ) + return render_page(request, 'pharmacy-detail.html', context) + + errors = [] + for field, msgs in form.errors.items(): + label = form.fields[field].label if field in form.fields else field + for msg in msgs: + errors.append(f'{label}: {msg}' if field != '__all__' else msg) + + context = _pharmacy_detail_context( + request, pharmacy, + form=form, edit_mode=True, + form_error=' '.join(errors) or 'Не удалось сохранить.', + ) + return render_page(request, 'pharmacy-detail.html', context) diff --git a/static/assets/js/pharmacies.js b/static/assets/js/pharmacies.js index 5974ffe..b933c94 100644 --- a/static/assets/js/pharmacies.js +++ b/static/assets/js/pharmacies.js @@ -132,40 +132,14 @@ function pharmaFocus(i) { } function pharmaAddBaseLayer(map) { - var sources = [ - { - url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - options: { - attribution: '© OpenStreetMap contributors © CARTO', - maxZoom: 20, - subdomains: 'abcd' - } - }, - { - url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { - attribution: '© OpenStreetMap', - maxZoom: 19 - } - } - ]; - - var idx = 0; - var layer; - - function mountLayer() { - if (idx >= sources.length) return; - var src = sources[idx]; - layer = L.tileLayer(src.url, src.options).addTo(map); - layer.on('tileerror', function() { - if (idx + 1 >= sources.length) return; - map.removeLayer(layer); - idx += 1; - mountLayer(); - }); + if (typeof acoreAddMapBaseLayer === 'function') { + acoreAddMapBaseLayer(map); + return; } - - mountLayer(); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 19 + }).addTo(map); } function pharmaInitMap() { diff --git a/templates/pages/dislocation.html b/templates/pages/dislocation.html index a6bc653..a2f61cf 100644 --- a/templates/pages/dislocation.html +++ b/templates/pages/dislocation.html @@ -87,26 +87,29 @@ -
+
- - +
+ + +
@@ -114,16 +117,16 @@
{% trans "Колонки таблицы" %}
- +
-
+ {% csrf_token %}
{% for col in panel_column_defs %} @@ -141,10 +144,46 @@ {% endfor %}
- +
+ + {% if can_manage_dislo_presets %} +
+
{% trans "Пресеты (админ)" %}
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+

{% trans "Создание и обновление берут отмеченные колонки выше. Обновление/удаление — по выбранному пресету в списке." %}

+
+ {% endif %} + {{ dislocation_presets_payload|json_script:"dislo-presets-data" }} @@ -284,6 +323,16 @@ data-name="{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}">
{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}
{{ pharmacy.address }}
+ {% if pharmacy.has_generator or pharmacy.has_inverter or pharmacy.has_mikrotik_router or pharmacy.dislo_provider or pharmacy.has_gpon %} +
+ {% if pharmacy.has_generator %}
{% trans "Генератор" %}: {% trans "да" %}
{% endif %} + {% if pharmacy.has_inverter %}
{% trans "Инвертор" %}: {% trans "да" %}
{% endif %} + {% if pharmacy.has_mikrotik_router %}
{% trans "MikroTik" %}: {% trans "да" %}
{% endif %} + {% if pharmacy.dislo_provider %}
{% trans "Провайдер" %}: {{ pharmacy.dislo_provider }}
{% endif %} + {% if pharmacy.dislo_contract %}
{% trans "Договор" %}: {{ pharmacy.dislo_contract }}
{% endif %} + {% if pharmacy.has_gpon %}
{% trans "GPON" %}: {% trans "да" %}
{% endif %} +
+ {% endif %} {% endfor %} @@ -377,6 +426,12 @@ const disloData = [ staff: {{ pharmacy.staff_count }}, active: {{ pharmacy.is_active|lower }}, hasLocation: {{ pharmacy.has_map_location|lower }}, + hasGenerator: {{ pharmacy.has_generator|yesno:"true,false" }}, + hasInverter: {{ pharmacy.has_inverter|yesno:"true,false" }}, + hasMikrotik: {{ pharmacy.has_mikrotik_router|yesno:"true,false" }}, + provider: "{{ pharmacy.dislo_provider|escapejs|default:'' }}", + contract: "{{ pharmacy.dislo_contract|escapejs|default:'' }}", + hasGpon: {{ pharmacy.has_gpon|yesno:"true,false" }}, lat: parseFloat("{{ pharmacy.latitude|default:50.5|floatformat:'6' }}".replace(',', '.')), lng: parseFloat("{{ pharmacy.longitude|default:26.5|floatformat:'6' }}".replace(',', '.')) }{% if not forloop.last %},{% endif %} @@ -522,30 +577,13 @@ function disloRenderRegionTree() { disloRenderRegionTree(); function disloAddMapBaseLayerWithFallback(map) { - const sources = [ - { - url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - options: { attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 20, subdomains: 'abcd' } - }, - { - url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { attribution: '© OpenStreetMap contributors', maxZoom: 19 } - } - ]; - let idx = 0; - let layer = null; - const mount = () => { - if (idx >= sources.length) return; - const src = sources[idx]; - layer = L.tileLayer(src.url, src.options).addTo(map); - layer.on('tileerror', () => { - if (idx + 1 >= sources.length) return; - map.removeLayer(layer); - idx += 1; - mount(); - }); - }; - mount(); + if (typeof acoreAddMapBaseLayer === 'function') { + acoreAddMapBaseLayer(map); + return; + } + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', maxZoom: 19 + }).addTo(map); } function disloCreateMarker(pharmacy) { @@ -559,6 +597,16 @@ function disloCreateMarker(pharmacy) { ${pharmacy.staff} `; const customIcon = L.divIcon({ html: markerHtml, className: '', iconSize: [30, 40], iconAnchor: [15, 40], popupAnchor: [0, -36] }); + const equipBits = []; + if (pharmacy.hasGenerator) equipBits.push('{% trans "Генератор" %}: {% trans "да" %}'); + if (pharmacy.hasInverter) equipBits.push('{% trans "Инвертор" %}: {% trans "да" %}'); + if (pharmacy.hasMikrotik) equipBits.push('{% trans "MikroTik" %}: {% trans "да" %}'); + if (pharmacy.provider) equipBits.push(`{% trans "Провайдер" %}: ${pharmacy.provider}`); + if (pharmacy.contract) equipBits.push(`{% trans "Договор" %}: ${pharmacy.contract}`); + if (pharmacy.hasGpon) equipBits.push('{% trans "GPON" %}: {% trans "да" %}'); + const equipLine = equipBits.length + ? `${equipBits.join('
')}
` + : ''; const marker = L.marker([pharmacy.lat, pharmacy.lng], { icon: customIcon }) .bindPopup(`
@@ -567,7 +615,8 @@ function disloCreateMarker(pharmacy) { 🧑 ${pharmacy.head || '-'}
☎️ ${pharmacy.phone}
🕐 ${pharmacy.hours}
- 👥 {% trans "Персонал" %}: ${pharmacy.staff}
+ 👥 {% trans "Персонал" %}: ${pharmacy.staff} + ${equipLine}
${pharmacy.active ? '{% trans "Активна" %}' : '{% trans "Неактивна" %}'}
@@ -611,6 +660,20 @@ function disloInitMap() { }); } +function disloSetColumnsButtonActive(active) { + const btn = document.getElementById('dislo-btn-columns'); + if (!btn) return; + if (active) { + btn.style.background = 'color-mix(in srgb, var(--color-primary) 12%, var(--color-card))'; + btn.style.borderColor = 'color-mix(in srgb, var(--color-primary) 45%, var(--color-border))'; + btn.style.color = 'var(--color-foreground)'; + } else { + btn.style.background = 'var(--color-card)'; + btn.style.borderColor = 'var(--color-border)'; + btn.style.color = 'var(--color-foreground)'; + } +} + function disloView(view) { const tableView = document.getElementById('dislo-view-table'); const mapView = document.getElementById('dislo-view-map'); @@ -629,6 +692,7 @@ function disloView(view) { tableView.style.display = 'none'; mapView.style.display = 'block'; if (columnsPanel) columnsPanel.style.display = 'none'; + disloSetColumnsButtonActive(false); btnTable.style.background = 'transparent'; btnTable.style.color = 'var(--color-muted-foreground)'; btnMap.style.background = 'var(--color-card)'; @@ -646,6 +710,7 @@ function disloToggleColumnsPanel() { if (!panel) return; const opening = panel.style.display === 'none' || !panel.style.display; panel.style.display = opening ? 'block' : 'none'; + disloSetColumnsButtonActive(opening); if (opening) disloView('table'); } @@ -665,6 +730,75 @@ function disloColMove(btn, dir) { } } +function disloSelectedColumns() { + const form = document.getElementById('dislo-columns-form'); + if (!form) return []; + return Array.from(form.querySelectorAll('input[name="columns"]')) + .filter((el) => el.type === 'hidden' || el.checked) + .map((el) => el.value); +} + +function disloPresetAction(action) { + const selectEl = document.getElementById('dislo-preset-select'); + const presetCode = selectEl ? selectEl.value : ''; + if ((action === 'update' || action === 'delete') && !presetCode) { + alert('{% trans "Выберите пресет в списке." %}'); + return; + } + if (action === 'delete' && !confirm('{% trans "Удалить выбранный пресет?" %}')) { + return; + } + if (action === 'create') { + const name = (document.getElementById('dislo-preset-name') || {}).value || ''; + if (!name.trim()) { + alert('{% trans "Укажите название пресета." %}'); + return; + } + } + const csrf = document.querySelector('#dislo-columns-panel input[name=csrfmiddlewaretoken]'); + const values = { + action: action, + preset_code: presetCode, + name: (document.getElementById('dislo-preset-name') || {}).value || '', + code: (document.getElementById('dislo-preset-code') || {}).value || '', + department_id: (document.getElementById('dislo-preset-department') || {}).value || '', + is_default: document.getElementById('dislo-preset-default')?.checked ? '1' : '', + csrfmiddlewaretoken: csrf ? csrf.value : '', + columns: disloSelectedColumns(), + }; + if (window.htmx) { + htmx.ajax('POST', '/dislocation/column-presets/', { + target: '#main-content', + swap: 'innerHTML', + values: values, + headers: csrf ? { 'X-CSRFToken': csrf.value } : {}, + }); + return; + } + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/dislocation/column-presets/'; + Object.entries(values).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = item; + form.appendChild(input); + }); + return; + } + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = value; + form.appendChild(input); + }); + document.body.appendChild(form); + form.submit(); +} + function disloApplyPreset(selectEl) { const code = selectEl.value; if (!code) return; diff --git a/templates/pages/pharmacies.html b/templates/pages/pharmacies.html index 7620434..591e199 100644 --- a/templates/pages/pharmacies.html +++ b/templates/pages/pharmacies.html @@ -412,40 +412,13 @@ renderRegionTree(); pharmaFilter(); function addMapBaseLayerWithFallback(map) { - const sources = [ - { - url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - options: { - attribution: '© OpenStreetMap contributors © CARTO', - maxZoom: 20, - subdomains: 'abcd', - } - }, - { - url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { - attribution: '© OpenStreetMap contributors', - maxZoom: 19, - } - } - ]; - - let idx = 0; - let layer = null; - - const mount = () => { - if (idx >= sources.length) return; - const src = sources[idx]; - layer = L.tileLayer(src.url, src.options).addTo(map); - layer.on('tileerror', () => { - if (idx + 1 >= sources.length) return; - map.removeLayer(layer); - idx += 1; - mount(); - }); - }; - - mount(); + if (typeof acoreAddMapBaseLayer === 'function') { + acoreAddMapBaseLayer(map); + return; + } + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', maxZoom: 19 + }).addTo(map); } function initPharmaciesMap() { diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html index a32f146..14b04a6 100644 --- a/templates/pages/pharmacy-detail.html +++ b/templates/pages/pharmacy-detail.html @@ -12,11 +12,29 @@ До дислокації {% if can_edit %} - {% trans "Редактировать в админке" %} +
+ {% if pharmacy_edit_mode %} + {% trans "Отмена" %} + {% else %} + {% trans "Редактировать" %} + {% endif %} +
{% endif %}
+ {% if pharmacy_form_message %} +
+ {{ pharmacy_form_message }} +
+ {% endif %} + {% if pharmacy_form_error %} +
+ {{ pharmacy_form_error }} +
+ {% endif %} +
@@ -100,6 +118,105 @@

{% trans "Основная информация" %}

+ {% if pharmacy_edit_mode and pharmacy_edit_form %} + {% with f=pharmacy_edit_form %} +
+ {% csrf_token %} +
+
+ + {{ f.organization }} +
+
+ + {{ f.pharmacy_number }} +
+
+ + {{ f.status }} +
+
+ + {{ f.working_hours }} +
+
+ + {{ f.category }} +
+
+ + {{ f.vat_category }} +
+
+ + {{ f.location_region }} +
+
+ + {{ f.street }} +
+
+ + {{ f.house_number }} +
+
+ + {{ f.building }} +
+
+ + {{ f.apartment }} +
+
+ + {{ f.location }} +
+
+ + {{ f.phone }} +
+
+ + {{ f.pharmacists_count }} +
+
+ + {{ f.license_number }} +
+
+ + {{ f.license_expiry_date }} +
+
+ + {{ f.head_pharmacist }} +
+
+ + {{ f.responsible_manager }} +
+
+ + {{ f.curator }} +
+
+ + {{ f.comments }} +
+
+
+ {% trans "Отмена" %} + +
+
+ {% endwith %} + {% else %}
{% trans "Организация" %}
{{ pharmacy.organization.name|default:"—" }}
{% trans "Регион" %}
{{ pharmacy.location_region.name|default:"—" }}
@@ -116,6 +233,7 @@
{{ pharmacy.comments }}
{% endif %} + {% endif %}
@@ -183,9 +301,13 @@ (function () { var lat = {{ pharmacy.latitude|floatformat:'6' }}, lng = {{ pharmacy.longitude|floatformat:'6' }}; var map = L.map('pharmacy-map', { zoomControl: false, dragging: false, scrollWheelZoom: false }).setView([lat, lng], 15); - L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 20, subdomains: 'abcd', - }).addTo(map); + if (typeof acoreAddMapBaseLayer === 'function') { + acoreAddMapBaseLayer(map); + } else { + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', maxZoom: 19 + }).addTo(map); + } L.marker([lat, lng]).addTo(map); })(); @@ -355,10 +477,10 @@ {% endif %}
@@ -384,11 +506,15 @@ {% for r in utility_readings %} - {{ r.utility_type.name }} + {{ r.utility_type.name }}{% if r.reading_mode == 'day_night' %} день/ніч{% endif %} {{ r.reading_date|date:"d.m.Y" }} - {{ r.value }} + {{ r.value_display }} - {% if r.consumption is None %}— + {% if r.reading_mode == 'day_night' %} + {% if r.consumption_total is not None %} + Σ {{ r.consumption_total }} + {% else %}—{% endif %} + {% elif r.consumption is None %}— {% elif r.consumption < 0 %}⚠ {{ r.consumption }} {% else %}{{ r.consumption }}{% endif %} @@ -403,7 +529,7 @@ {% endif %}
diff --git a/templates/partials/dislo_cell.html b/templates/partials/dislo_cell.html index 034409a..379d735 100644 --- a/templates/partials/dislo_cell.html +++ b/templates/partials/dislo_cell.html @@ -60,6 +60,18 @@ {% if pharmacy.vat_category %}{{ pharmacy.get_vat_category_display }}{% else %}—{% endif %} {% elif column.key == 'email' %} {{ pharmacy.carbonio_email|default:"—" }} +{% elif column.key == 'generator' %} +{% if pharmacy.has_generator %}{% trans "Да" %}{% else %}—{% endif %} +{% elif column.key == 'inverter' %} +{% if pharmacy.has_inverter %}{% trans "Да" %}{% else %}—{% endif %} +{% elif column.key == 'mikrotik' %} +{% if pharmacy.has_mikrotik_router %}{% trans "Да" %}{% else %}—{% endif %} +{% elif column.key == 'provider' %} +{% if pharmacy.dislo_provider %}{{ pharmacy.dislo_provider }}{% else %}—{% endif %} +{% elif column.key == 'contract' %} +{% if pharmacy.dislo_contract %}{{ pharmacy.dislo_contract }}{% else %}—{% endif %} +{% elif column.key == 'gpon' %} +{% if pharmacy.has_gpon %}{% trans "Да" %}{% else %}—{% endif %} {% else %} — {% endif %}