56e2b1c8fe
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>
404 lines
18 KiB
Python
404 lines
18 KiB
Python
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')
|