From 56e2b1c8fedc226a614fb4abc4210a95b53a2a70 Mon Sep 17 00:00:00 2001 From: lokses Date: Sun, 9 Aug 2026 20:43:33 +0300 Subject: [PATCH] Add configurable pharmacy card tab visibility matrix. 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 --- apps/locations/admin.py | 30 +++- .../0012_pharmacy_card_visibility.py | 81 ++++++++++ apps/locations/models/__init__.py | 2 + apps/locations/models/pharmacy_visibility.py | 111 ++++++++++++++ apps/locations/tests.py | 142 ++++++++++++++++++ apps/nextcloud_connector/access.py | 15 +- core/views.py | 79 ++++++---- templates/pages/pharmacy-detail.html | 33 +++- 8 files changed, 455 insertions(+), 38 deletions(-) create mode 100644 apps/locations/migrations/0012_pharmacy_card_visibility.py create mode 100644 apps/locations/models/pharmacy_visibility.py diff --git a/apps/locations/admin.py b/apps/locations/admin.py index 93b7592..03bce26 100644 --- a/apps/locations/admin.py +++ b/apps/locations/admin.py @@ -9,7 +9,10 @@ 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 +from .models import ( + Location, Pharmacy, PharmacyPhone, PharmacyHistory, + DislocationColumnPreset, PharmacyCardVisibility, +) # --------------------------------------------------------------------------- @@ -398,3 +401,28 @@ class DislocationColumnPresetAdmin(ModelAdmin): 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', + ), + }), + ) diff --git a/apps/locations/migrations/0012_pharmacy_card_visibility.py b/apps/locations/migrations/0012_pharmacy_card_visibility.py new file mode 100644 index 0000000..9c4cc85 --- /dev/null +++ b/apps/locations/migrations/0012_pharmacy_card_visibility.py @@ -0,0 +1,81 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0011_seed_dislocation_column_presets'), + ] + + operations = [ + migrations.CreateModel( + name='PharmacyCardVisibility', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('show_staff', models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='own_or_staff', max_length=20, verbose_name='Персонал', + )), + ('show_equipment', models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='own_or_staff', max_length=20, verbose_name='Оборудование', + )), + ('show_internet', models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='own_or_staff', max_length=20, verbose_name='Интернет-договоры', + )), + ('show_rro', models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='own_or_staff', max_length=20, verbose_name='РРО / ПРРО', + )), + ('show_utilities', models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='own_or_staff', max_length=20, verbose_name='Коммунальные показания', + )), + ('show_documents', models.CharField( + choices=[ + ('everyone', 'Все авторизованные'), + ('own_or_staff', 'Своя аптека или staff'), + ('staff', 'Только staff / суперпользователь'), + ('it', 'Группа IT / доступ IT-отдел'), + ('hidden', 'Скрыто (кроме суперпользователя)'), + ], + default='own_or_staff', max_length=20, verbose_name='Документы', + )), + ], + options={ + 'verbose_name': 'Видимость карточки аптеки', + 'verbose_name_plural': 'Видимость карточки аптеки', + }, + ), + ] diff --git a/apps/locations/models/__init__.py b/apps/locations/models/__init__.py index d5e5d02..7fe27bb 100644 --- a/apps/locations/models/__init__.py +++ b/apps/locations/models/__init__.py @@ -1,10 +1,12 @@ 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 __all__ = [ 'Location', 'LocationManager', 'Pharmacy', 'PharmacyManager', 'PharmacyPhone', 'PharmacyHistory', 'DislocationColumnPreset', + 'PharmacyCardVisibility', 'pharmacy_card_flags', 'user_can_view_pharmacy_section', ] diff --git a/apps/locations/models/pharmacy_visibility.py b/apps/locations/models/pharmacy_visibility.py new file mode 100644 index 0000000..07d0bd8 --- /dev/null +++ b/apps/locations/models/pharmacy_visibility.py @@ -0,0 +1,111 @@ +"""Политика видимости вкладок карточки аптеки (синглтон, настраивается в админке).""" +from __future__ import annotations + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class PharmacyCardVisibility(models.Model): + """Кто видит операционные блоки на /pharmacies//. + + Синглтон (pk=1). Уровни одинаковы для всех вкладок — матрица в админке. + """ + + class Level(models.TextChoices): + EVERYONE = 'everyone', _('Все авторизованные') + OWN_OR_STAFF = 'own_or_staff', _('Своя аптека или staff') + STAFF = 'staff', _('Только staff / суперпользователь') + IT = 'it', _('Группа IT / доступ IT-отдел') + HIDDEN = 'hidden', _('Скрыто (кроме суперпользователя)') + + show_staff = models.CharField( + _('Персонал'), max_length=20, + choices=Level.choices, default=Level.OWN_OR_STAFF, + ) + show_equipment = models.CharField( + _('Оборудование'), max_length=20, + choices=Level.choices, default=Level.OWN_OR_STAFF, + ) + show_internet = models.CharField( + _('Интернет-договоры'), max_length=20, + choices=Level.choices, default=Level.OWN_OR_STAFF, + ) + show_rro = models.CharField( + _('РРО / ПРРО'), max_length=20, + choices=Level.choices, default=Level.OWN_OR_STAFF, + ) + show_utilities = models.CharField( + _('Коммунальные показания'), max_length=20, + choices=Level.choices, default=Level.OWN_OR_STAFF, + ) + show_documents = models.CharField( + _('Документы'), max_length=20, + choices=Level.choices, default=Level.OWN_OR_STAFF, + ) + + class Meta: + verbose_name = _('Видимость карточки аптеки') + verbose_name_plural = _('Видимость карточки аптеки') + + def __str__(self): + return str(_('Видимость карточки аптеки')) + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + pass + + @classmethod + def load(cls) -> 'PharmacyCardVisibility': + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + SECTIONS = ( + 'staff', + 'equipment', + 'internet', + 'rro', + 'utilities', + 'documents', + ) + + def level_for(self, section: str) -> str: + return getattr(self, f'show_{section}', self.Level.OWN_OR_STAFF) + + +def user_can_view_pharmacy_section(user, pharmacy, level: str) -> bool: + """Проверка уровня видимости для user × pharmacy.""" + if not user or not getattr(user, 'is_authenticated', False): + return False + if getattr(user, 'is_superuser', False): + return True + + if level == PharmacyCardVisibility.Level.HIDDEN: + return False + if level == PharmacyCardVisibility.Level.EVERYONE: + return True + if level == PharmacyCardVisibility.Level.STAFF: + return bool(getattr(user, 'is_staff', False)) + if level == PharmacyCardVisibility.Level.OWN_OR_STAFF: + if getattr(user, 'is_staff', False): + return True + return getattr(user, 'user_pharmacy_id', None) == getattr(pharmacy, 'pk', None) + if level == PharmacyCardVisibility.Level.IT: + from core.portal_access import user_can_access + if user_can_access(user, 'it_manager'): + return True + return user.groups.filter(name='IT').exists() + return False + + +def pharmacy_card_flags(user, pharmacy) -> dict[str, bool]: + """Словник can_view_
за поточною політикою.""" + policy = PharmacyCardVisibility.load() + return { + f'can_view_{section}': user_can_view_pharmacy_section( + user, pharmacy, policy.level_for(section), + ) + for section in PharmacyCardVisibility.SECTIONS + } diff --git a/apps/locations/tests.py b/apps/locations/tests.py index dfed2f4..439ddd7 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -259,3 +259,145 @@ class DislocationColumnsTests(TestCase): self.assertContains(resp, "disloSortBy('curator'") self.assertContains(resp, "disloSortBy('manager'") self.assertNotContains(resp, "disloSortBy('phone'") + + +class PharmacyCardVisibilityTests(TestCase): + """Матрица видимости вкладок карточки аптеки (синглтон в админке).""" + + @classmethod + def setUpTestData(cls): + from datetime import date + + from django.contrib.auth.models import Group + + from apps.employees.models import Employee + from apps.locations.models import PharmacyCardVisibility + + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + region = Directory.objects.create(directory_type=region_type, code='vis', name='Вид') + org = Directory.objects.create(directory_type=org_type, code='visorg', name='ВизОрг') + cls.pharmacy = Pharmacy.objects.create( + organization=org, location_region=region, + pharmacy_number=42, location_type='pharmacy', + street='Вид', house_number='1', + ) + other = Pharmacy.objects.create( + organization=org, location_region=region, + pharmacy_number=43, location_type='pharmacy', + street='Інша', house_number='2', + ) + common = dict( + hire_date=date(2024, 1, 1), + mattermost_sync_enabled=False, + ad_sync_enabled=False, + ) + cls.own = Employee.objects.create_user( + username='vis.own', user_location_type='pharmacy', + user_pharmacy=cls.pharmacy, **common, + ) + cls.stranger = Employee.objects.create_user( + username='vis.stranger', user_location_type='pharmacy', + user_pharmacy=other, **common, + ) + cls.staff = Employee.objects.create_user( + username='vis.staff', is_staff=True, totp_enabled=True, **common, + ) + cls.superuser = Employee.objects.create_user( + username='vis.super', is_staff=True, is_superuser=True, + totp_enabled=True, **common, + ) + cls.it_user = Employee.objects.create_user(username='vis.it', **common) + it_group, _ = Group.objects.get_or_create(name='IT') + cls.it_user.groups.add(it_group) + PharmacyCardVisibility.load() + + def _set_level(self, section, level): + from apps.locations.models import PharmacyCardVisibility + + policy = PharmacyCardVisibility.load() + setattr(policy, f'show_{section}', level) + policy.save() + + def test_own_or_staff_default(self): + from apps.locations.models import ( + PharmacyCardVisibility, user_can_view_pharmacy_section, + ) + + level = PharmacyCardVisibility.Level.OWN_OR_STAFF + self.assertTrue(user_can_view_pharmacy_section(self.own, self.pharmacy, level)) + 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_everyone_and_hidden(self): + from apps.locations.models import ( + PharmacyCardVisibility, user_can_view_pharmacy_section, + ) + + everyone = PharmacyCardVisibility.Level.EVERYONE + hidden = PharmacyCardVisibility.Level.HIDDEN + self.assertTrue(user_can_view_pharmacy_section(self.stranger, self.pharmacy, everyone)) + self.assertFalse(user_can_view_pharmacy_section(self.staff, self.pharmacy, hidden)) + self.assertTrue(user_can_view_pharmacy_section(self.superuser, self.pharmacy, hidden)) + + def test_staff_and_it_levels(self): + from apps.locations.models import ( + PharmacyCardVisibility, user_can_view_pharmacy_section, + ) + + staff_lvl = PharmacyCardVisibility.Level.STAFF + it_lvl = PharmacyCardVisibility.Level.IT + self.assertFalse(user_can_view_pharmacy_section(self.own, self.pharmacy, staff_lvl)) + self.assertTrue(user_can_view_pharmacy_section(self.staff, self.pharmacy, staff_lvl)) + self.assertTrue(user_can_view_pharmacy_section(self.it_user, self.pharmacy, it_lvl)) + self.assertFalse(user_can_view_pharmacy_section(self.own, self.pharmacy, it_lvl)) + self.assertFalse(user_can_view_pharmacy_section(self.staff, self.pharmacy, it_lvl)) + + def test_pharmacy_card_flags_respect_per_section(self): + from apps.locations.models import ( + PharmacyCardVisibility, pharmacy_card_flags, + ) + + self._set_level('rro', PharmacyCardVisibility.Level.EVERYONE) + self._set_level('equipment', PharmacyCardVisibility.Level.HIDDEN) + flags = pharmacy_card_flags(self.stranger, self.pharmacy) + self.assertTrue(flags['can_view_rro']) + self.assertFalse(flags['can_view_equipment']) + + def test_detail_view_hides_tab_when_section_staff_only(self): + from apps.locations.models import PharmacyCardVisibility + from apps.rro.models import CashRegister + from core.models import Directory, DirectoryType + + status_type, _ = DirectoryType.objects.get_or_create( + code='rro_status', defaults={'name': 'Статус РРО', 'hierarchy_type': 'strict'}, + ) + status, _ = Directory.objects.get_or_create( + directory_type=status_type, code='active', + defaults={'name': 'Активный', 'is_active': True}, + ) + CashRegister.objects.create( + pharmacy=self.pharmacy, kind='prro', fiscal_number='FN-VIS-1', + status=status, is_primary=True, + ) + self._set_level('rro', PharmacyCardVisibility.Level.STAFF) + + self.client.force_login(self.own) + 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.assertFalse(resp.context['can_view_rro']) + self.assertNotContains(resp, 'FN-VIS-1') + self.assertNotContains(resp, 'ph-panel-rro') + + 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.get('can_view_rro')) + self.assertContains(resp, 'FN-VIS-1') + self.assertContains(resp, 'ph-panel-rro') diff --git a/apps/nextcloud_connector/access.py b/apps/nextcloud_connector/access.py index c68de24..800f6e9 100644 --- a/apps/nextcloud_connector/access.py +++ b/apps/nextcloud_connector/access.py @@ -19,18 +19,13 @@ def pharmacy_folder_relpath(pharmacy) -> str: def can_access_pharmacy_docs(user, pharmacy) -> bool: """Кто вправе видеть/трогать документы конкретной аптеки. - - офис/IT/руководство (is_staff) — все аптеки (позже сузить пермишеном/регионом); - - линейный персонал аптеки — ТОЛЬКО своя аптека (user_pharmacy); - - все остальные — нет. + Политика из PharmacyCardVisibility.show_documents (админка). """ - if not getattr(user, 'is_authenticated', False): - return False - if user.is_superuser or user.is_staff: - return True - return ( - getattr(user, 'user_location_type', None) == 'pharmacy' - and getattr(user, 'user_pharmacy_id', None) == pharmacy.pk + from apps.locations.models.pharmacy_visibility import ( + PharmacyCardVisibility, user_can_view_pharmacy_section, ) + policy = PharmacyCardVisibility.load() + return user_can_view_pharmacy_section(user, pharmacy, policy.show_documents) # --- Общий браузер документов (страница /documents/) --- diff --git a/core/views.py b/core/views.py index 284ace5..fa9a58a 100644 --- a/core/views.py +++ b/core/views.py @@ -611,9 +611,13 @@ help_view = _static_page_view('help.html') @login_required def pharmacy_detail_view(request, pk): - """Аптека целиком: реквизиты, лицензия, адрес/карта, персонал, - IT-обладнання, інтернет-договори, РРО/ПРРО, показання лічильників.""" + """Аптека целиком: реквизиты, лицензия, адрес/карта + операційні вкладки. + + Публічне (будь-який logged-in): основна картка, контакти, ліцензія. + Операційні блоки — за політикою PharmacyCardVisibility (адмінка). + """ from apps.it_manager.models import Equipment, InternetContract + from apps.locations.models import pharmacy_card_flags from apps.rro.models import CashRegister from apps.utilities.models import UtilityReading @@ -622,30 +626,50 @@ def pharmacy_detail_view(request, pk): .prefetch_related('phones'), pk=pk, ) - staff = list( - Employee.objects.filter(user_pharmacy=pharmacy, is_active=True) - .select_related('post').order_by('last_name', 'first_name') - ) - 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 = list( - InternetContract.objects.filter(location=pharmacy) - .select_related('provider', 'service_type', 'connection_type', 'status') - .order_by('-start_date') - ) - cash_registers = list( - CashRegister.objects.filter(pharmacy=pharmacy) - .select_related('vendor', 'status') - .order_by('-is_primary', 'kind', 'fiscal_number') - ) - utility_readings = list( - UtilityReading.objects.filter(pharmacy=pharmacy) - .select_related('utility_type', 'submitted_by') - .order_by('-reading_date')[:50] - ) + 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']: + utility_readings = list( + UtilityReading.objects.filter(pharmacy=pharmacy) + .select_related('utility_type', 'submitted_by') + .order_by('-reading_date')[:50] + ) + context = { 'pharmacy': pharmacy, 'staff': staff, @@ -655,6 +679,9 @@ def pharmacy_detail_view(request, pk): 'internet_contracts': internet_contracts, 'cash_registers': cash_registers, 'utility_readings': utility_readings, + **flags, + # зворотна сумісність з тестами / старими перевірками + 'can_view_ops': any(flags.values()), 'can_edit': request.user.is_staff, } return render_page(request, 'pharmacy-detail.html', context) diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html index 7c3b513..a32f146 100644 --- a/templates/pages/pharmacy-detail.html +++ b/templates/pages/pharmacy-detail.html @@ -42,8 +42,13 @@
{% trans "Персонал" %}
+ {% if can_view_staff %}
{{ staff_count }}
{% if staff_count == 0 %}немає співробітників{% else %}активних{% endif %}
+ {% else %} +
—
+
{% trans "недоступно" %}
+ {% endif %}
{% trans "Заведующий" %}
@@ -65,16 +70,26 @@
+ {% if can_view_equipment %} + {% endif %} + {% if can_view_internet %} + {% endif %} + {% if can_view_rro %} + {% endif %} + {% if can_view_utilities %} + {% endif %} + {% if can_view_documents %} + {% endif %}
@@ -125,6 +140,7 @@
+ {% if can_view_staff %}

Персонал · {{ staff_count }}

{% if staff %} @@ -151,6 +167,7 @@
{% trans "В этой аптеке пока нет активных сотрудников" %}
{% endif %}
+ {% endif %}
@@ -188,6 +205,7 @@ + {% if can_view_equipment %} + {% endif %} + {% if can_view_internet %} + {% endif %} + {% if can_view_rro %}