Files
ACORE/apps/locations/tests.py
T
lokses 56e2b1c8fe 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 <cursoragent@cursor.com>
2026-08-09 20:43:33 +03:00

404 lines
18 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 unittest import mock
from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection, transaction
from django.test import TestCase
from core.models import Directory, DirectoryType
from apps.locations.models import Pharmacy
class CarbonioEmailTests(TestCase):
"""Генерация адреса Carbonio (<префикс организации><номер>@домен),
ручной override и проверка уникальности."""
@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'})
cls.tira = Directory.objects.create(
directory_type=org_type, code='tira', name='Тира', extra_data={'mail_prefix': 't'})
cls.noprefix = Directory.objects.create(
directory_type=org_type, code='xx', name='Без префикса', extra_data={})
def _pharmacy(self, org, number, **kw):
return Pharmacy.objects.create(
organization=org, location_region=self.region,
pharmacy_number=number, location_type='pharmacy', **kw)
def test_build_email_format(self):
self.assertEqual(self._pharmacy(self.aks, 5).build_carbonio_email(), 'a5@aks-apteka.com.ua')
def test_build_email_uses_org_prefix(self):
self.assertEqual(self._pharmacy(self.tira, 5).build_carbonio_email(), 't5@aks-apteka.com.ua')
def test_build_email_empty_without_prefix(self):
self.assertEqual(self._pharmacy(self.noprefix, 5).build_carbonio_email(), '')
def test_autogenerate_on_save_when_enabled(self):
p = self._pharmacy(self.aks, 7, carbonio_sync_enabled=True)
self.assertEqual(p.carbonio_email, 'a7@aks-apteka.com.ua')
def test_no_autogenerate_when_disabled(self):
self.assertEqual(self._pharmacy(self.aks, 8).carbonio_email, '')
def test_manual_override_respected(self):
p = self._pharmacy(self.aks, 9, carbonio_sync_enabled=True,
carbonio_email='custom@aks-apteka.com.ua')
self.assertEqual(p.carbonio_email, 'custom@aks-apteka.com.ua')
def test_uniqueness_rejected_in_clean(self):
self._pharmacy(self.aks, 10, carbonio_sync_enabled=True) # a10@
p2 = self._pharmacy(self.tira, 11)
p2.carbonio_email = 'a10@aks-apteka.com.ua'
with self.assertRaises(ValidationError):
p2.clean()
def test_uniqueness_enforced_by_db(self):
self._pharmacy(self.aks, 12, carbonio_sync_enabled=True) # a12@
with self.assertRaises(IntegrityError):
with transaction.atomic():
self._pharmacy(self.tira, 13, carbonio_email='a12@aks-apteka.com.ua')
def test_autogenerate_skips_on_collision(self):
# ручной a15@ занят другой аптекой -> автоген для АКС №15 оставляет пусто
self._pharmacy(self.tira, 99, carbonio_sync_enabled=True,
carbonio_email='a15@aks-apteka.com.ua')
p = self._pharmacy(self.aks, 15, carbonio_sync_enabled=True)
self.assertEqual(p.carbonio_email, '')
def test_blank_emails_do_not_clash(self):
# несколько аптек без адреса — частичный уникальный индекс их не блокирует
self._pharmacy(self.aks, 16)
self._pharmacy(self.tira, 17)
self.assertEqual(Pharmacy.objects.filter(carbonio_email='').count(), 2)
class CarbonioPasswordSyncTests(TestCase):
"""sync_pharmacy_to_carbonio с замоканным CarbonioService: пароль ящика
сохраняется у нас зашифрованным и совпадает с отданным в Carbonio."""
@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, **kw):
return Pharmacy.objects.create(
organization=self.aks, location_region=self.region,
pharmacy_number=number, location_type='pharmacy', **kw)
def test_create_stores_encrypted_password(self):
from apps.mail_connector.services.sync_service import sync_pharmacy_to_carbonio
p = self._pharmacy(20) # синхронизация выключена, адрес пуст
fake = mock.Mock()
fake.get_account.return_value = None # аккаунта ещё нет → create
fake.create_account.return_value = 'acc-123'
with mock.patch(
'apps.mail_connector.services.sync_service.CarbonioService',
return_value=fake):
self.assertTrue(sync_pharmacy_to_carbonio(p))
p.refresh_from_db()
self.assertEqual(p.carbonio_email, 'a20@aks-apteka.com.ua')
self.assertEqual(p.carbonio_account_id, 'acc-123')
self.assertEqual(p.carbonio_sync_status, 'synced')
self.assertTrue(p.carbonio_password)
# тот же пароль ушёл в Carbonio, что и сохранён у нас
self.assertEqual(fake.create_account.call_args.args[1], p.carbonio_password)
# в БД пароль лежит зашифрованным
table, pk = Pharmacy._meta.db_table, Pharmacy._meta.pk.column
with connection.cursor() as cur:
cur.execute(f'SELECT carbonio_password FROM {table} WHERE {pk}=%s', [p.pk])
raw = cur.fetchone()[0]
self.assertTrue(raw.startswith('gAAAAA'))
def test_update_path_does_not_touch_password(self):
from apps.mail_connector.services.sync_service import sync_pharmacy_to_carbonio
p = self._pharmacy(21, carbonio_email='a21@aks-apteka.com.ua')
fake = mock.Mock()
fake.get_account.return_value = {'id': 'acc-existing'} # аккаунт уже есть → update
with mock.patch(
'apps.mail_connector.services.sync_service.CarbonioService',
return_value=fake):
self.assertTrue(sync_pharmacy_to_carbonio(p))
p.refresh_from_db()
self.assertEqual(p.carbonio_account_id, 'acc-existing')
self.assertEqual(p.carbonio_password, '') # на update пароль не трогаем
fake.create_account.assert_not_called()
fake.set_account_status.assert_called_once_with('acc-existing', 'active')
class DislocationColumnsTests(TestCase):
"""Whitelist, резолв пресета default и сохранение preference пользователя."""
@classmethod
def setUpTestData(cls):
from datetime import date
from django.contrib.auth import get_user_model
from apps.locations.dislocation_columns import HR_COLUMNS, OPS_COLUMNS, STANDARD_COLUMNS
from apps.locations.models import DislocationColumnPreset
Employee = get_user_model()
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='kv', name='Киев')
cls.org = Directory.objects.create(directory_type=org_type, code='org', name='ТестОрг')
cls.user = Employee.objects.create(
username='dislo.cols',
email='dislo.cols@company.local',
first_name='Коля',
last_name='Колонки',
hire_date=date(2026, 1, 1),
user_location_type='office',
mattermost_sync_enabled=False,
ad_sync_enabled=False,
)
DislocationColumnPreset.objects.update_or_create(
code='standard',
defaults={
'name': 'Стандарт',
'columns': list(STANDARD_COLUMNS),
'is_default': True,
},
)
DislocationColumnPreset.objects.update_or_create(
code='hr',
defaults={'name': 'HR', 'columns': list(HR_COLUMNS), 'is_default': False},
)
DislocationColumnPreset.objects.update_or_create(
code='ops',
defaults={'name': 'Ops', 'columns': list(OPS_COLUMNS), 'is_default': False},
)
Pharmacy.objects.create(
organization=cls.org, location_region=cls.region,
pharmacy_number=101, location_type='pharmacy', street='Тестовая', house_number='1',
)
def test_normalize_whitelist_and_locked_pharmacy(self):
from apps.locations.dislocation_columns import normalize_columns
self.assertEqual(
normalize_columns(['email', 'pharmacy', 'unknown', 'email', 'region']),
['email', 'pharmacy', 'region'],
)
self.assertEqual(
normalize_columns(['organization', 'pharmacy', 'phone']),
['organization', 'pharmacy', 'phone'],
)
self.assertEqual(normalize_columns([]), ['pharmacy'])
self.assertEqual(normalize_columns(['secret', 'password']), ['pharmacy'])
# pharmacy обязательна, но если её нет в списке — добавляется первой
self.assertEqual(
normalize_columns(['region', 'phone']),
['pharmacy', 'region', 'phone'],
)
def test_resolve_uses_default_preset(self):
from apps.locations.dislocation_columns import STANDARD_COLUMNS, resolve_columns_for_user
self.user.dislocation_columns = None
self.user.save(update_fields=['dislocation_columns'])
self.assertEqual(resolve_columns_for_user(self.user), list(STANDARD_COLUMNS))
def test_resolve_uses_custom_preference(self):
from apps.locations.dislocation_columns import resolve_columns_for_user
self.user.dislocation_columns = ['region', 'phone', 'bogus']
self.user.save(update_fields=['dislocation_columns'])
self.assertEqual(
resolve_columns_for_user(self.user),
['pharmacy', 'region', 'phone'],
)
def test_post_saves_columns_preference(self):
self.client.force_login(self.user)
session = self.client.session
session['2fa_verified'] = True
session.save()
resp = self.client.post(
'/dislocation/columns/',
{'columns': ['organization', 'pharmacy', 'staff', 'region', 'email', 'evil']},
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(
self.user.dislocation_columns,
['organization', 'pharmacy', 'staff', 'region', 'email'],
)
self.assertContains(resp, "disloSortBy('organization'")
self.assertContains(resp, "disloSortBy('region'")
self.assertContains(resp, "disloSortBy('email'")
self.assertNotContains(resp, "disloSortBy('phone'")
def test_post_preset_code_applies_hr(self):
from apps.locations.dislocation_columns import HR_COLUMNS
self.client.force_login(self.user)
session = self.client.session
session['2fa_verified'] = True
session.save()
resp = self.client.post(
'/dislocation/columns/',
{'preset_code': 'hr'},
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.dislocation_columns, list(HR_COLUMNS))
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')