From fa323d657c0c4eff4b677c3b789d474c0f8339bc Mon Sep 17 00:00:00 2001 From: lokses Date: Mon, 21 Sep 2026 22:59:47 +0300 Subject: [PATCH] =?UTF-8?q?locations:=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=8E=D1=89=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20+=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=B9=D1=82=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Делает 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 --- ...er_location_options_grouplocationaccess.py | 31 +++ apps/locations/models/access.py | 14 ++ apps/reports/documents.py | 7 - apps/reports/it_monthly/builder.py | 3 - apps/reports/tests.py | 22 +++ apps/reports/views.py | 21 +- apps/rro/tests.py | 16 +- core/location_access.py | 76 ++++++++ core/tests_location_access.py | 182 ++++++++++++++++++ 9 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 apps/locations/migrations/0014_alter_location_options_grouplocationaccess.py create mode 100644 apps/locations/models/access.py create mode 100644 core/location_access.py create mode 100644 core/tests_location_access.py diff --git a/apps/locations/migrations/0014_alter_location_options_grouplocationaccess.py b/apps/locations/migrations/0014_alter_location_options_grouplocationaccess.py new file mode 100644 index 0000000..56294a8 --- /dev/null +++ b/apps/locations/migrations/0014_alter_location_options_grouplocationaccess.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.3 on 2026-09-21 17:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('locations', '0013_pharmacy_edit_permission'), + ] + + operations = [ + migrations.AlterModelOptions( + name='location', + options={'permissions': [('view_all_locations', 'Доступ к данным всех локаций портала'), ('manage_location_access', 'Назначение ответственных за локации')], 'verbose_name': 'Локация', 'verbose_name_plural': 'Локации'}, + ), + migrations.CreateModel( + name='GroupLocationAccess', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='location_access', to='auth.group', verbose_name='Группа')), + ('locations', models.ManyToManyField(blank=True, help_text='Дополняют свою локацию и закреплённые аптеки. Доступ к разделам выдаётся отдельно.', related_name='location_access_groups', to='locations.location', verbose_name='Доступные локации')), + ], + options={ + 'verbose_name': 'Область доступа группы', + 'verbose_name_plural': 'Области доступа групп', + }, + ), + ] diff --git a/apps/locations/models/access.py b/apps/locations/models/access.py new file mode 100644 index 0000000..1671926 --- /dev/null +++ b/apps/locations/models/access.py @@ -0,0 +1,14 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class GroupLocationAccess(models.Model): + group = models.OneToOneField('auth.Group', on_delete=models.CASCADE, related_name='location_access', verbose_name=_('Группа')) + locations = models.ManyToManyField('locations.Location', blank=True, related_name='location_access_groups', verbose_name=_('Доступные локации'), help_text=_('Дополняют свою локацию и закреплённые аптеки. Доступ к разделам выдаётся отдельно.')) + + class Meta: + verbose_name = _('Область доступа группы') + verbose_name_plural = _('Области доступа групп') + + def __str__(self): + return str(self.group) diff --git a/apps/reports/documents.py b/apps/reports/documents.py index ac5bac6..2c68b69 100644 --- a/apps/reports/documents.py +++ b/apps/reports/documents.py @@ -13,7 +13,6 @@ from apps.reports.it_monthly.builder import ( from apps.reports.models import ReportDocument from apps.reports.registry import visible_reports from core.portal_access import user_can_access -from core.location_access import has_all_locations REPORTS_DAV_ROOT = '06_Отчёты' ALLOWED_UPLOAD_EXTS = frozenset({'docx', 'xlsx'}) @@ -39,8 +38,6 @@ def user_can_open_reports_hub(user) -> bool: def user_can_view_report_document(user, doc: ReportDocument) -> bool: - if doc.kind == ReportDocument.KIND_SNAPSHOT and not has_all_locations(user): - return False if not user_can_open_reports_hub(user): return False # Черновики — только руководителю своего отдела. @@ -55,8 +52,6 @@ def user_can_view_report_document(user, doc: ReportDocument) -> bool: def user_can_edit_report_document(user, doc: ReportDocument) -> bool: - if doc.kind == ReportDocument.KIND_SNAPSHOT and not has_all_locations(user): - return False if not user_can_open_reports_hub(user): return False if user_can_manage_it_monthly_report(user) and getattr(user, 'department_id', None) == doc.department_id: @@ -84,8 +79,6 @@ def visible_report_documents(user): if not user_can_open_reports_hub(user): return ReportDocument.objects.none() qs = ReportDocument.objects.select_related('department', 'created_by', 'published_by') - if not has_all_locations(user): - qs = qs.exclude(kind=ReportDocument.KIND_SNAPSHOT) dept_id = getattr(user, 'department_id', None) manage_own = user_can_manage_it_monthly_report(user) and bool(dept_id) diff --git a/apps/reports/it_monthly/builder.py b/apps/reports/it_monthly/builder.py index 7828985..7a74571 100644 --- a/apps/reports/it_monthly/builder.py +++ b/apps/reports/it_monthly/builder.py @@ -10,7 +10,6 @@ from django.contrib.auth import get_user_model from django.utils import timezone from apps.it_manager.models import WorkLog -from core.location_access import has_all_locations from apps.it_manager.report_import import default_report_tool_root, load_company, normalize_name from apps.locations.models.base import pharmacy_object_code from apps.reports.it_monthly.conclusions import build_conclusions @@ -91,8 +90,6 @@ def user_can_view_all_department_reports(user) -> bool: def user_can_view_it_monthly_report(user) -> bool: """Карточка IT-отчёта: руководитель; view-all; сотрудники отдела после публикации.""" - if not has_all_locations(user): - return False if user_can_manage_it_monthly_report(user): return True if user_can_view_all_department_reports(user): diff --git a/apps/reports/tests.py b/apps/reports/tests.py index cd7c489..65c89ad 100644 --- a/apps/reports/tests.py +++ b/apps/reports/tests.py @@ -73,6 +73,28 @@ class MonthlyReportTests(TestCase): self.assertEqual([s.code for s in visible_reports(self.head)], ['it_monthly']) self.assertEqual(visible_reports(self.it_user), []) + def test_head_without_all_locations_still_manages_report(self): + """Руководитель отдела не обязан иметь view_all_locations для IT-отчёта.""" + from django.contrib.auth.models import Permission + from core.location_access import has_all_locations + + perm = Permission.objects.filter( + content_type__app_label='locations', codename='view_all_locations', + ).first() + if perm: + self.head.user_permissions.remove(perm) + for group in self.head.groups.all(): + group.permissions.remove(perm) + self.head = type(self.head).objects.get(pk=self.head.pk) + + self.assertFalse(has_all_locations(self.head)) + self.assertTrue(user_can_view_it_monthly_report(self.head)) + login_2fa(self.client, self.head) + page = self.client.get('/reports/it/monthly/?month=2026-05') + self.assertEqual(page.status_code, 200) + self.assertContains(page, 'Сформировать Word') + self.assertEqual(self.client.get('/reports/it/monthly/2026-05.docx').status_code, 200) + def test_published_visible_to_department_and_view_all(self): from django.contrib.auth.models import Permission from apps.reports.models import ReportDocument, ReportPublication diff --git a/apps/reports/views.py b/apps/reports/views.py index 8b0d1c1..e8a5ffb 100644 --- a/apps/reports/views.py +++ b/apps/reports/views.py @@ -65,19 +65,8 @@ def _onlyoffice_ready() -> bool: return onlyoffice_is_ready() -def _authorized_monthly_report(user, year, month): - from core.location_access import has_all_locations - from django.core.exceptions import PermissionDenied - if not has_all_locations(user): - raise PermissionDenied('Сводный отчёт требует доступа ко всей сети.') - return build_monthly_report(year, month) - - def _resolve_it_month(request, month_raw: str | None) -> tuple[int, int] | HttpResponse: """Выбрать месяц с учётом публикации; 403 если нет доступа к периоду.""" - from core.location_access import has_all_locations - if not has_all_locations(request.user): - return HttpResponseForbidden() months = available_months_for_user(request.user) year, month = parse_month_id(month_raw) mid = f'{year:04d}-{month:02d}' @@ -144,7 +133,7 @@ def _it_monthly_page_context(request, year: int, month: int) -> dict: 'onlyoffice_ready': _onlyoffice_ready(), } if show_live: - report = _authorized_monthly_report(request.user, year, month) + report = build_monthly_report(year, month) report['available_months'] = available_months_for_user(request.user) or [report['id']] context['report'] = report else: @@ -211,7 +200,7 @@ def _export_with_template(request, month_id, fmt: str): year, month = parse_month_id(month_id) if not user_can_access_it_monthly_month(request.user, year, month): return HttpResponseForbidden() - report = _authorized_monthly_report(request.user, year, month) + report = build_monthly_report(year, month) tpl = _default_it_template() try: payload = render_report(report, tpl, fmt) @@ -258,7 +247,7 @@ def it_monthly_preview_view(request, month_id): year, month = parse_month_id(month_id) if not user_can_access_it_monthly_month(request.user, year, month): return HttpResponseForbidden() - report = _authorized_monthly_report(request.user, year, month) + report = build_monthly_report(year, month) tpl = _default_it_template() try: html = render_report(report, tpl, 'html') @@ -277,7 +266,7 @@ def _prepare_it_monthly_word(request, year: int, month: int) -> HttpResponse: return HttpResponseForbidden() if not user_can_manage_it_monthly_report(request.user): return HttpResponseForbidden() - report = _authorized_monthly_report(request.user, year, month) + report = build_monthly_report(year, month) tpl = _default_it_template() try: payload = render_report(report, tpl, 'docx') @@ -646,7 +635,7 @@ def template_preview_draft_view(request, pk): pass year, month = parse_month_id(request.POST.get('month')) - report = _authorized_monthly_report(request.user, year, month) + report = build_monthly_report(year, month) ctx = build_template_context( report, company=load_default_company(), labels=blocks.get('labels'), ) diff --git a/apps/rro/tests.py b/apps/rro/tests.py index 88b1f32..0faa7aa 100644 --- a/apps/rro/tests.py +++ b/apps/rro/tests.py @@ -183,18 +183,12 @@ class CashRegisterPortalTests(TestCase): 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, 200) - self.assertNotContains(resp, 'FN-PORTAL-1') - self.assertNotContains(resp, 'ph-panel-rro') - self.assertFalse(resp.context['can_view_ops']) - self.assertEqual(resp.context['staff'], []) - self.assertEqual(resp.context['equipment'], []) - self.assertEqual(resp.context['internet_contracts'], []) - self.assertEqual(resp.context['utility_readings'], []) - self.assertNotContains(resp, 'ph-panel-equipment') - self.assertNotContains(resp, 'ph-panel-internet') - self.assertNotContains(resp, 'ph-panel-utilities') + 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): diff --git a/core/location_access.py b/core/location_access.py new file mode 100644 index 0000000..90826ed --- /dev/null +++ b/core/location_access.py @@ -0,0 +1,76 @@ +"""Область данных портала. Не заменяет права на разделы и действия.""" +from django.db.models import Q +from django.shortcuts import get_object_or_404 + +ALL_LOCATIONS_PERMISSION = 'locations.view_all_locations' + + +def has_all_locations(user): + if not user or not user.is_authenticated or not user.is_active: + return False + preview = getattr(user, '_access_preview_perms', None) + if preview is not None: + return ALL_LOCATIONS_PERMISSION in preview + return user.is_superuser or user.has_perm(ALL_LOCATIONS_PERMISSION) + + +def accessible_pharmacies(user): + from apps.locations.models import Pharmacy + qs = Pharmacy.objects.all() + if not user or not user.is_authenticated or not user.is_active: + return qs.none() + if has_all_locations(user): + return qs + # Preview не должен наследовать назначения администратора. + if getattr(user, '_access_preview_perms', None) is not None: + return qs.none() + return qs.filter( + Q(pk=user.user_pharmacy_id) | Q(head_pharmacist=user) + | Q(curator=user) | Q(responsible_manager=user) + | Q(location_access_groups__group__in=user.groups.all()) + ).distinct() + + +def accessible_locations(user): + from apps.locations.models import Location + qs = Location.objects.all() + if not user or not user.is_authenticated or not user.is_active: + return qs.none() + if has_all_locations(user): + return qs + if getattr(user, '_access_preview_perms', None) is not None: + return qs.none() + return qs.filter( + Q(pk__in=accessible_pharmacies(user).values('pk')) + | Q(pk=user.user_location_id) + | Q(location_access_groups__group__in=user.groups.all()) + ).distinct() + + +def scoped_queryset(user, queryset, field='location'): + """Объекты с FK на локацию/аптеку; NULL доступен только всей сети.""" + if has_all_locations(user): + return queryset + ids = accessible_locations(user).values('pk') + condition = Q(**{f'{field}__in': ids}) + if field == 'location': + employee_field = {'it_manager.ticket': 'requester', 'it_manager.equipment': 'assigned_to'}.get(queryset.model._meta.label_lower) + if employee_field: + from apps.employees.models import Employee + condition |= Q(location__isnull=True, **{f'{employee_field}__in': scoped_employees(user, Employee.objects.all())}) + return queryset.filter(condition) + + +def require_location(user, pk): + """Чужой/несуществующий ID одинаково даёт 404 до записи данных.""" + from django.http import Http404 + if not str(pk).isdigit(): + raise Http404 + return get_object_or_404(accessible_locations(user), pk=pk) + + +def scoped_employees(user, queryset): + if has_all_locations(user): + return queryset + ids = accessible_locations(user).values('pk') + return queryset.filter(Q(pk=user.pk) | Q(user_pharmacy_id__in=ids) | Q(user_location_id__in=ids)) diff --git a/core/tests_location_access.py b/core/tests_location_access.py new file mode 100644 index 0000000..c1ad1ce --- /dev/null +++ b/core/tests_location_access.py @@ -0,0 +1,182 @@ +from datetime import date +from unittest.mock import patch + +from django.contrib.auth.models import Group, Permission +from django.test import TestCase, RequestFactory +from django.urls import reverse + +from apps.employees.models import Employee +from apps.locations.models import Pharmacy, GroupLocationAccess, PharmacyCardVisibility +from core.models import Directory, DirectoryType +from core.location_access import accessible_pharmacies, accessible_locations, has_all_locations +from core.test_utils import login_2fa + + +class LocationAccessTests(TestCase): + @staticmethod + def directory(kind, code): + dtype, _ = DirectoryType.objects.get_or_create(code=kind, defaults={'name': kind}) + return Directory.objects.get_or_create(directory_type=dtype, code=code, defaults={'name': code})[0] + + @classmethod + def setUpTestData(cls): + directory = cls.directory + org, region = directory('organization', 'scope-org'), directory('region', 'scope-region') + cls.own = Pharmacy.objects.create(organization=org, location_region=region, pharmacy_number=701, location_type='pharmacy') + cls.assigned = Pharmacy.objects.create(organization=org, location_region=region, pharmacy_number=702, location_type='pharmacy') + cls.foreign = Pharmacy.objects.create(organization=org, location_region=region, pharmacy_number=703, location_type='pharmacy') + cls.user = Employee.objects.create(username='scoped', email='scope@test.local', hire_date=date(2026, 1, 1), + user_location_type='pharmacy', user_pharmacy=cls.own, is_staff=True, + ad_sync_enabled=False, mattermost_sync_enabled=False) + cls.group = Group.objects.create(name='Scoped operators') + cls.group.permissions.add(*Permission.objects.filter(codename__in=[ + 'access_pharmacies', 'access_utilities', 'access_rro', 'access_it_manager', 'access_internet_billing', 'access_reports'])) + cls.user.groups.add(cls.group) + # QuerySet update avoids unrelated provisioning hooks/position validation. + Pharmacy.objects.filter(pk=cls.assigned.pk).update(curator=cls.user) + + def setUp(self): + self.user = Employee.objects.get(pk=self.user.pk) + login_2fa(self.client, self.user) + + def request(self, path='/'): + req = RequestFactory().get(path) + req.user = self.user + return req + + def test_staff_is_not_global_and_curator_gets_assigned(self): + self.assertFalse(has_all_locations(self.user)) + self.assertSetEqual(set(accessible_pharmacies(self.user).values_list('pk', flat=True)), {self.own.pk, self.assigned.pk}) + self.assertSetEqual(set(accessible_locations(self.user).values_list('pk', flat=True)), {self.own.pk, self.assigned.pk}) + + def test_head_and_responsible_manager_assignments(self): + for field in ('head_pharmacist', 'responsible_manager'): + Pharmacy.objects.filter(pk=self.foreign.pk).update(**{field: self.user}) + self.assertTrue(accessible_pharmacies(self.user).filter(pk=self.foreign.pk).exists()) + Pharmacy.objects.filter(pk=self.foreign.pk).update(**{field: None}) + self.assertFalse(accessible_pharmacies(self.user).filter(pk=self.foreign.pk).exists()) + + def test_group_assignments_are_additive_and_revocable(self): + scope = GroupLocationAccess.objects.create(group=self.group) + scope.locations.add(self.foreign) + self.assertTrue(accessible_pharmacies(self.user).filter(pk=self.foreign.pk).exists()) + scope.locations.clear() + self.assertFalse(accessible_pharmacies(self.user).filter(pk=self.foreign.pk).exists()) + + def test_global_permission_is_explicit(self): + self.user.user_permissions.add(Permission.objects.get(content_type__app_label='locations', codename='view_all_locations')) + self.user = Employee.objects.get(pk=self.user.pk) + self.assertTrue(has_all_locations(self.user)) + self.assertEqual(accessible_pharmacies(self.user).count(), 3) + + def test_no_assignment_and_inactive_fail_closed(self): + Employee.objects.filter(pk=self.user.pk).update(user_pharmacy=None) + Pharmacy.objects.filter(curator=self.user).update(curator=None) + user = Employee.objects.get(pk=self.user.pk) + self.assertFalse(accessible_pharmacies(user).exists()) + user.is_superuser = True + user.is_active = False + self.assertFalse(has_all_locations(user)) + self.assertFalse(accessible_locations(user).exists()) + + def test_preview_does_not_inherit_admin_locations(self): + self.user.is_superuser = True + self.user._access_preview_perms = set() + self.assertFalse(accessible_locations(self.user).exists()) + + def test_lists_and_direct_urls_use_same_scope(self): + for htmx in ('', 'true'): + for path in ('/pharmacies/', '/dislocation/'): + response = self.client.get(path, HTTP_HX_REQUEST=htmx) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, f'/pharmacies/{self.foreign.pk}/') + self.assertEqual(self.client.get(f'/pharmacies/{self.own.pk}/', HTTP_HX_REQUEST=htmx).status_code, 200) + self.assertEqual(self.client.get(f'/pharmacies/{self.foreign.pk}/', HTTP_HX_REQUEST=htmx).status_code, 404) + self.assertEqual(self.client.post(reverse('pharmacy_update', args=[self.foreign.pk]), {}).status_code, 404) + + def test_documents_block_foreign_pharmacy_before_webdav(self): + with patch('apps.nextcloud_connector.views.NextcloudWebDAVService') as dav: + response = self.client.get(f'/pharmacies/{self.foreign.pk}/documents/') + self.assertEqual(response.status_code, 404) + dav.assert_not_called() + + def test_onlyoffice_token_rechecks_assignment(self): + from apps.nextcloud_connector.views import _resolve_token_relpath + payload = {'kind': 'pharmacy', 'pharmacy_id': self.assigned.pk, 'uid': self.user.pk, 'name': 'test.docx'} + self.assertIsNotNone(_resolve_token_relpath(payload)) + Pharmacy.objects.filter(pk=self.assigned.pk).update(curator=None) + self.assertIsNone(_resolve_token_relpath(payload)) + + def test_utilities_json_and_post_are_scoped(self): + from apps.utilities.models import UtilityMeterConfig, UtilityReading + from apps.utilities.views import _utilities_context + utility = self.directory('utility_type', 'scope-electricity') + for pharmacy in (self.own, self.foreign): + UtilityMeterConfig.objects.create(pharmacy=pharmacy, utility_type=utility) + context = _utilities_context(self.request()) + self.assertNotIn(f'{self.foreign.pk}:', context['meter_configs_json']) + response = self.client.post('/accounting/?tab=utilities', { + 'pharmacy': self.foreign.pk, 'utility_type': utility.pk, + 'reading_date': '2026-09-21', 'value': '10', + }) + self.assertEqual(response.status_code, 404) + self.assertFalse(UtilityReading.objects.exists()) + + def test_power_scope_covers_list_counts_and_relocation(self): + from apps.power_assets.models import PowerAsset + from apps.power_assets.views import _power_assets_context + kind, status = self.directory('power_asset_type', 'generator'), self.directory('power_asset_status', 'active') + own = PowerAsset.objects.create(location=self.own, asset_type=kind, status=status, model_name='Own') + other = PowerAsset.objects.create(location=self.foreign, asset_type=kind, status=status, model_name='Secret') + context = _power_assets_context(self.request()) + self.assertEqual(context['power_counts']['total'], 1) + self.assertEqual(list(context['power_assets_page'].object_list), [own]) + response = self.client.post(reverse('power_asset_update', args=[own.pk]), { + 'location_id': self.foreign.pk, 'asset_type': kind.pk, 'model_name': 'Moved', + }) + self.assertEqual(response.status_code, 404) + own.refresh_from_db() + self.assertEqual(own.location_id, self.own.pk) + self.assertEqual(self.client.post(reverse('power_asset_update', args=[other.pk]), {}).status_code, 404) + + def test_it_foreign_reference_rejected_before_write(self): + from apps.it_manager.models import WorkLog + response = self.client.post(reverse('it_worklog_create'), { + 'location_id': self.foreign.pk, 'description': 'Intrusion', 'work_type': 'other', + }) + self.assertEqual(response.status_code, 404) + self.assertFalse(WorkLog.objects.exists()) + + def test_network_report_export_denied(self): + """Экспорт живого отчёта — только руководителю отдела, не scoped pharmacy-user.""" + with patch('apps.reports.views.build_monthly_report') as builder: + response = self.client.get('/reports/it/monthly/2026-09.docx') + self.assertEqual(response.status_code, 403) + builder.assert_not_called() + + def test_department_snapshot_visible_foreign_hidden(self): + """Опубликованный снимок своего отдела виден; чужого отдела — нет.""" + from apps.reports.documents import user_can_view_report_document + from apps.reports.models import ReportDocument + dept = self.directory('department', 'scope-dept') + other = self.directory('department', 'other-dept') + self.user.department = dept + self.user.save(update_fields=['department']) + own = ReportDocument.objects.create( + department=dept, title='Own', filename='own.docx', + kind=ReportDocument.KIND_SNAPSHOT, status=ReportDocument.STATUS_PUBLISHED, + created_by=self.user, + ) + foreign = ReportDocument.objects.create( + department=other, title='Foreign', filename='foreign.docx', + kind=ReportDocument.KIND_SNAPSHOT, status=ReportDocument.STATUS_PUBLISHED, + created_by=self.user, + ) + self.assertTrue(user_can_view_report_document(self.user, own)) + self.assertFalse(user_can_view_report_document(self.user, foreign)) + + def test_assignment_edit_requires_separate_permission(self): + from apps.locations.forms import PortalPharmacyEditForm + form = PortalPharmacyEditForm(instance=self.own, user=self.user) + for field in ('curator', 'responsible_manager', 'head_pharmacist'): + self.assertNotIn(field, form.fields)