fa323d657c
Делает HEAD целостным: коммит 5366799 уже импортировал эти файлы, но они
не были добавлены в git (свежий checkout падал с ImportError, а migrate не
создавал таблицу GroupLocationAccess).
- core/location_access.py: область данных (accessible_pharmacies/locations,
scoped_queryset/employees, has_all_locations) — fail-closed
- apps/locations/models/access.py + миграция 0014: GroupLocationAccess,
право locations.view_all_locations
- core/tests_location_access.py: 15 сценариев scoping
- apps/reports/*: снятие лишнего гейта has_all_locations с IT-отчёта отдела
(руководитель не обязан иметь доступ ко всей сети) + тест
- apps/rro/tests: чужая аптека теперь 404 (единая область доступа), а не
«200 с урезанными панелями» — под новую модель
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
407 lines
17 KiB
Python
407 lines
17 KiB
Python
from core.test_utils import grant_all_locations
|
||
from datetime import date
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.contrib.auth.models import Group
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import IntegrityError, transaction
|
||
from django.test import TestCase
|
||
|
||
from core.models import Directory, DirectoryType, PortalAccess
|
||
from apps.employees.models import EmployeePermissionOverride
|
||
from apps.locations.models import Pharmacy
|
||
from apps.rro.models import CashRegister
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
def grant_office_access(user):
|
||
user.groups.add(Group.objects.get(name='Офисный сотрудник'))
|
||
for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'):
|
||
if hasattr(user, attr):
|
||
delattr(user, attr)
|
||
|
||
|
||
def grant_rro_access(user):
|
||
from django.contrib.auth.models import Permission
|
||
from django.contrib.contenttypes.models import ContentType
|
||
ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False)
|
||
perm = Permission.objects.get(content_type=ct, codename='access_rro')
|
||
EmployeePermissionOverride.objects.get_or_create(
|
||
employee=user,
|
||
permission=perm,
|
||
defaults={'override_type': 'grant'},
|
||
)
|
||
for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'):
|
||
if hasattr(user, attr):
|
||
delattr(user, attr)
|
||
|
||
|
||
class CashRegisterModelTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
org_type = DirectoryType.objects.create(code='organization', name='Организация')
|
||
region_type = DirectoryType.objects.create(code='region', name='Регион')
|
||
status_type, _ = DirectoryType.objects.get_or_create(
|
||
code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'},
|
||
)
|
||
cls.status_active, _ = Directory.objects.get_or_create(
|
||
directory_type=status_type, code='active',
|
||
defaults={'name': 'Активный', 'is_active': True},
|
||
)
|
||
cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса')
|
||
cls.org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС')
|
||
cls.pharmacy = Pharmacy.objects.create(
|
||
organization=cls.org, location_region=cls.region,
|
||
pharmacy_number=42, location_type='pharmacy',
|
||
street='Тестовая', house_number='1',
|
||
)
|
||
cls.pharmacy2 = Pharmacy.objects.create(
|
||
organization=cls.org, location_region=cls.region,
|
||
pharmacy_number=43, location_type='pharmacy',
|
||
street='Другая', house_number='2',
|
||
)
|
||
|
||
def _reg(self, **kw):
|
||
defaults = {
|
||
'pharmacy': self.pharmacy,
|
||
'kind': CashRegister.Kind.PRRO,
|
||
'fiscal_number': kw.pop('fiscal_number', f'FN-{CashRegister.objects.count()+1}'),
|
||
'status': self.status_active,
|
||
}
|
||
defaults.update(kw)
|
||
return CashRegister(**defaults)
|
||
|
||
def test_seed_statuses_exist(self):
|
||
codes = set(
|
||
Directory.objects.filter(directory_type__code='rro_status')
|
||
.values_list('code', flat=True)
|
||
)
|
||
self.assertTrue({'active', 'reserve', 'repair', 'decommissioned'} <= codes)
|
||
|
||
def test_unique_fiscal_number(self):
|
||
self._reg(fiscal_number='FN-UNIQUE').save()
|
||
with self.assertRaises(IntegrityError):
|
||
with transaction.atomic():
|
||
self._reg(fiscal_number='FN-UNIQUE', pharmacy=self.pharmacy2).save()
|
||
|
||
def test_only_one_primary_per_pharmacy(self):
|
||
a = self._reg(fiscal_number='FN-P1', is_primary=True)
|
||
a.full_clean()
|
||
a.save()
|
||
b = self._reg(fiscal_number='FN-P2', is_primary=True)
|
||
with self.assertRaises(ValidationError):
|
||
b.full_clean()
|
||
|
||
def test_primary_ok_on_different_pharmacies(self):
|
||
a = self._reg(fiscal_number='FN-A', is_primary=True)
|
||
a.full_clean()
|
||
a.save()
|
||
b = self._reg(fiscal_number='FN-B', pharmacy=self.pharmacy2, is_primary=True)
|
||
b.full_clean()
|
||
b.save()
|
||
|
||
|
||
class CashRegisterPortalTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
org_type = DirectoryType.objects.create(code='organization', name='Организация')
|
||
region_type = DirectoryType.objects.create(code='region', name='Регион')
|
||
status_type, _ = DirectoryType.objects.get_or_create(
|
||
code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'},
|
||
)
|
||
cls.status_active, _ = Directory.objects.get_or_create(
|
||
directory_type=status_type, code='active',
|
||
defaults={'name': 'Активный', 'is_active': True},
|
||
)
|
||
region = Directory.objects.create(directory_type=region_type, code='kv', name='Киев')
|
||
org = Directory.objects.create(directory_type=org_type, code='org', name='ТестОрг')
|
||
cls.pharmacy = Pharmacy.objects.create(
|
||
organization=org, location_region=region,
|
||
pharmacy_number=7, location_type='pharmacy',
|
||
street='Главная', house_number='7',
|
||
)
|
||
CashRegister.objects.create(
|
||
pharmacy=cls.pharmacy, kind=CashRegister.Kind.RRO,
|
||
fiscal_number='FN-PORTAL-1', status=cls.status_active,
|
||
model_name='Datecs FP-700', is_primary=True,
|
||
)
|
||
cls.user = Employee.objects.create(
|
||
username='rro.staff',
|
||
email='rro.staff@company.local',
|
||
first_name='РРО',
|
||
last_name='Тест',
|
||
hire_date=date(2026, 1, 1),
|
||
user_location_type='office',
|
||
is_staff=True,
|
||
mattermost_sync_enabled=False,
|
||
ad_sync_enabled=False,
|
||
totp_enabled=True,
|
||
)
|
||
grant_all_locations(cls.user)
|
||
grant_rro_access(cls.user)
|
||
|
||
def _login(self):
|
||
self.client.force_login(self.user)
|
||
session = self.client.session
|
||
session['2fa_verified'] = True
|
||
session.save()
|
||
|
||
def test_rro_list_page(self):
|
||
self._login()
|
||
resp = self.client.get('/rro/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertContains(resp, 'FN-PORTAL-1')
|
||
|
||
def test_pharmacy_detail_rro_tab(self):
|
||
self._login()
|
||
resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertContains(resp, 'FN-PORTAL-1')
|
||
self.assertContains(resp, 'ph-panel-rro')
|
||
|
||
def test_pharmacy_detail_hides_rro_for_other_pharmacy_user(self):
|
||
other = Pharmacy.objects.create(
|
||
organization=self.pharmacy.organization,
|
||
location_region=self.pharmacy.location_region,
|
||
pharmacy_number=99, location_type='pharmacy',
|
||
street='Чужа', house_number='9',
|
||
)
|
||
stranger = Employee.objects.create(
|
||
username='rro.stranger',
|
||
email='rro.stranger@company.local',
|
||
first_name='Інша',
|
||
last_name='Аптека',
|
||
hire_date=date(2026, 1, 1),
|
||
user_location_type='pharmacy',
|
||
user_pharmacy=other,
|
||
is_staff=False,
|
||
mattermost_sync_enabled=False,
|
||
ad_sync_enabled=False,
|
||
)
|
||
self.client.force_login(stranger)
|
||
session = self.client.session
|
||
session['2fa_verified'] = True
|
||
session.save()
|
||
# Чужая аптека вне области доступа: карточка недоступна целиком (404),
|
||
# операционные данные (РРО и т.п.) не утекают.
|
||
resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(resp.status_code, 404)
|
||
self.assertNotContains(resp, 'FN-PORTAL-1', status_code=404)
|
||
self.assertNotContains(resp, 'ph-panel-rro', status_code=404)
|
||
|
||
|
||
class ManagerRroScopeTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
org_type = DirectoryType.objects.create(code='organization', name='Организация')
|
||
region_type = DirectoryType.objects.create(code='region', name='Регион')
|
||
status_type, _ = DirectoryType.objects.get_or_create(
|
||
code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'},
|
||
)
|
||
cls.status_active, _ = Directory.objects.get_or_create(
|
||
directory_type=status_type, code='active',
|
||
defaults={'name': 'Активный', 'is_active': True},
|
||
)
|
||
region = Directory.objects.create(directory_type=region_type, code='kv2', name='Киев')
|
||
org = Directory.objects.create(directory_type=org_type, code='orgm', name='ТестОрг')
|
||
cls.pharmacy_a = Pharmacy.objects.create(
|
||
organization=org, location_region=region,
|
||
pharmacy_number=1, location_type='pharmacy',
|
||
street='A', house_number='1',
|
||
)
|
||
cls.pharmacy_b = Pharmacy.objects.create(
|
||
organization=org, location_region=region,
|
||
pharmacy_number=2, location_type='pharmacy',
|
||
street='B', house_number='2',
|
||
)
|
||
CashRegister.objects.create(
|
||
pharmacy=cls.pharmacy_a, kind=CashRegister.Kind.RRO,
|
||
fiscal_number='FN-MINE', status=cls.status_active,
|
||
)
|
||
CashRegister.objects.create(
|
||
pharmacy=cls.pharmacy_b, kind=CashRegister.Kind.RRO,
|
||
fiscal_number='FN-OTHER', status=cls.status_active,
|
||
)
|
||
cls.manager = Employee.objects.create(
|
||
username='rro.manager',
|
||
email='rro.manager@company.local',
|
||
first_name='Зав',
|
||
last_name='Аптекой',
|
||
hire_date=date(2026, 1, 1),
|
||
user_location_type='pharmacy',
|
||
user_pharmacy=cls.pharmacy_a,
|
||
is_staff=False,
|
||
mattermost_sync_enabled=False,
|
||
ad_sync_enabled=False,
|
||
)
|
||
cls.manager.groups.add(Group.objects.get(name='Заведующий аптекой'))
|
||
|
||
def _login(self):
|
||
self.client.force_login(self.manager)
|
||
session = self.client.session
|
||
session['2fa_verified'] = True
|
||
session.save()
|
||
|
||
def test_manager_sees_only_own_pharmacy(self):
|
||
self._login()
|
||
resp = self.client.get('/rro/', HTTP_HX_REQUEST='true')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertContains(resp, 'FN-MINE')
|
||
self.assertNotContains(resp, 'FN-OTHER')
|
||
self.assertTrue(resp.context['rro_pharmacy_scoped'])
|
||
|
||
def test_manager_cannot_bypass_with_query_param(self):
|
||
self._login()
|
||
resp = self.client.get(
|
||
f'/rro/?pharmacy={self.pharmacy_b.pk}',
|
||
HTTP_HX_REQUEST='true',
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertNotContains(resp, 'FN-OTHER')
|
||
|
||
|
||
class CashRegisterCrudTests(TestCase):
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
org_type = DirectoryType.objects.create(code='organization', name='Организация')
|
||
region_type = DirectoryType.objects.create(code='region', name='Регион')
|
||
status_type, _ = DirectoryType.objects.get_or_create(
|
||
code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'},
|
||
)
|
||
cls.status_active, _ = Directory.objects.get_or_create(
|
||
directory_type=status_type, code='active',
|
||
defaults={'name': 'Активный', 'is_active': True},
|
||
)
|
||
cls.status_repair, _ = Directory.objects.get_or_create(
|
||
directory_type=status_type, code='repair',
|
||
defaults={'name': 'В ремонте', 'is_active': True},
|
||
)
|
||
region = Directory.objects.create(directory_type=region_type, code='od2', name='Одесса2')
|
||
org = Directory.objects.create(directory_type=org_type, code='org2', name='Орг2')
|
||
cls.pharmacy = Pharmacy.objects.create(
|
||
organization=org, location_region=region,
|
||
pharmacy_number=11, location_type='pharmacy',
|
||
street='CRUD', house_number='1',
|
||
)
|
||
cls.staff = Employee.objects.create(
|
||
username='rro.crud.staff',
|
||
email='rro.crud.staff@company.local',
|
||
first_name='CRUD',
|
||
last_name='Staff',
|
||
hire_date=date(2026, 1, 1),
|
||
user_location_type='office',
|
||
is_staff=True,
|
||
mattermost_sync_enabled=False,
|
||
ad_sync_enabled=False,
|
||
totp_enabled=True,
|
||
)
|
||
cls.regular = Employee.objects.create(
|
||
username='rro.crud.user',
|
||
email='rro.crud.user@company.local',
|
||
first_name='CRUD',
|
||
last_name='User',
|
||
hire_date=date(2026, 1, 1),
|
||
user_location_type='pharmacy',
|
||
user_pharmacy=cls.pharmacy,
|
||
is_staff=False,
|
||
mattermost_sync_enabled=False,
|
||
ad_sync_enabled=False,
|
||
)
|
||
grant_all_locations(cls.staff)
|
||
grant_rro_access(cls.staff)
|
||
|
||
def _login(self, user):
|
||
self.client.force_login(user)
|
||
session = self.client.session
|
||
session['2fa_verified'] = True
|
||
session.save()
|
||
|
||
def _payload(self, **extra):
|
||
data = {
|
||
'pharmacy': self.pharmacy.pk,
|
||
'kind': CashRegister.Kind.PRRO,
|
||
'fiscal_number': 'FN-CRUD-NEW',
|
||
'status': self.status_active.pk,
|
||
'model_name': 'Checkbox',
|
||
'provider': 'checkbox',
|
||
}
|
||
data.update(extra)
|
||
return data
|
||
|
||
def test_staff_create(self):
|
||
self._login(self.staff)
|
||
resp = self.client.post('/rro/create/', self._payload(), HTTP_HX_REQUEST='true')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertTrue(CashRegister.objects.filter(fiscal_number='FN-CRUD-NEW').exists())
|
||
self.assertContains(resp, 'Кассовый аппарат добавлен')
|
||
|
||
def test_non_staff_create_forbidden(self):
|
||
# Есть просмотр РРО, но без staff — create запрещён (не редирект раздела).
|
||
grant_rro_access(self.regular)
|
||
self._login(self.regular)
|
||
resp = self.client.post('/rro/create/', self._payload(fiscal_number='FN-FORBIDDEN'))
|
||
self.assertEqual(resp.status_code, 403)
|
||
self.assertFalse(CashRegister.objects.filter(fiscal_number='FN-FORBIDDEN').exists())
|
||
|
||
def test_staff_update(self):
|
||
reg = CashRegister.objects.create(
|
||
pharmacy=self.pharmacy, kind=CashRegister.Kind.RRO,
|
||
fiscal_number='FN-UPD', status=self.status_active,
|
||
)
|
||
self._login(self.staff)
|
||
resp = self.client.post(
|
||
f'/rro/{reg.pk}/update/',
|
||
self._payload(
|
||
fiscal_number='FN-UPD-2',
|
||
kind=CashRegister.Kind.RRO,
|
||
status=self.status_repair.pk,
|
||
provider='',
|
||
),
|
||
HTTP_HX_REQUEST='true',
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
reg.refresh_from_db()
|
||
self.assertEqual(reg.fiscal_number, 'FN-UPD-2')
|
||
self.assertEqual(reg.status_id, self.status_repair.pk)
|
||
|
||
def test_duplicate_fiscal_shows_form_error(self):
|
||
CashRegister.objects.create(
|
||
pharmacy=self.pharmacy, kind=CashRegister.Kind.PRRO,
|
||
fiscal_number='FN-DUP', status=self.status_active,
|
||
)
|
||
self._login(self.staff)
|
||
resp = self.client.post(
|
||
'/rro/create/',
|
||
self._payload(fiscal_number='FN-DUP'),
|
||
HTTP_HX_REQUEST='true',
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertEqual(CashRegister.objects.filter(fiscal_number='FN-DUP').count(), 1)
|
||
self.assertTrue(resp.context['rro_form_errors'])
|
||
|
||
def test_primary_conflict_shows_form_error(self):
|
||
CashRegister.objects.create(
|
||
pharmacy=self.pharmacy, kind=CashRegister.Kind.PRRO,
|
||
fiscal_number='FN-PRIM-1', status=self.status_active, is_primary=True,
|
||
)
|
||
self._login(self.staff)
|
||
resp = self.client.post(
|
||
'/rro/create/',
|
||
self._payload(fiscal_number='FN-PRIM-2', is_primary='1'),
|
||
HTTP_HX_REQUEST='true',
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertFalse(CashRegister.objects.filter(fiscal_number='FN-PRIM-2').exists())
|
||
self.assertIn('is_primary', resp.context['rro_form_errors'])
|
||
|
||
def test_new_query_opens_create_panel(self):
|
||
self._login(self.staff)
|
||
resp = self.client.get(
|
||
f'/rro/?pharmacy={self.pharmacy.pk}&new=1',
|
||
HTTP_HX_REQUEST='true',
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertTrue(resp.context['show_create_panel'])
|
||
self.assertEqual(str(resp.context['prefill_pharmacy']), str(self.pharmacy.pk))
|