diff --git a/apps/ad_connector/tests.py b/apps/ad_connector/tests.py index 57456f0..9d0a7d0 100644 --- a/apps/ad_connector/tests.py +++ b/apps/ad_connector/tests.py @@ -323,17 +323,19 @@ class ADSignalTests(TestCase): class ADMappingViewTests(TestCase): def setUp(self): + from django.contrib.auth.models import Group self.staff = make_employee(is_staff=True) + self.staff.groups.add(Group.objects.get(name='IT')) self.svc = make_mock_ad() patcher = mock.patch.object(views, 'ADService', lambda: self.svc) patcher.start() self.addCleanup(patcher.stop) - def test_requires_staff(self): + def test_requires_ad_mapping_perm(self): regular = make_employee() - self.client.force_login(regular) - response = self.client.get('/ad-mapping/') - self.assertEqual(response.status_code, 302) + login_2fa(self.client, regular) + response = self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 403) def test_page_renders_with_ad_users(self): self.svc.create_user(f'CN=Вільний Аккаунт,{USERS_OU}', { diff --git a/apps/hiring/migrations/0005_seed_intern_accounts.py b/apps/hiring/migrations/0005_seed_intern_accounts.py index 7cb6e7d..9f6d282 100644 --- a/apps/hiring/migrations/0005_seed_intern_accounts.py +++ b/apps/hiring/migrations/0005_seed_intern_accounts.py @@ -1,4 +1,9 @@ -"""Seed pool of 5 shared intern accounts for hiring trial period.""" +"""Seed pool of 5 shared intern accounts for hiring trial period. + +Passwords are random per environment — set/rotate real ones in admin after migrate. +""" +import secrets + from django.db import migrations @@ -11,7 +16,7 @@ def seed_intern_accounts(apps, schema_editor): label=f'Intern-{i}', defaults={ 'username': f'intern{i}', - 'password': f'InternPass{i}!', + 'password': secrets.token_urlsafe(12), 'is_active': True, }, ) diff --git a/apps/hiring/migrations/0007_rotate_seed_intern_passwords.py b/apps/hiring/migrations/0007_rotate_seed_intern_passwords.py new file mode 100644 index 0000000..3b99566 --- /dev/null +++ b/apps/hiring/migrations/0007_rotate_seed_intern_passwords.py @@ -0,0 +1,29 @@ +"""Rotate passwords for seeded Intern-1..5 slots (replaces known InternPass* seeds).""" +import secrets + +from django.db import migrations + + +def rotate_seed_passwords(apps, schema_editor): + from apps.hiring.models import InternAccount + + for account in InternAccount.objects.filter( + label__in=[f'Intern-{i}' for i in range(1, 6)], + ): + account.password = secrets.token_urlsafe(12) + account.save(update_fields=['password', 'updated_at']) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('hiring', '0006_alter_internaccount_options_and_more'), + ] + + operations = [ + migrations.RunPython(rotate_seed_passwords, noop_reverse), + ] diff --git a/apps/hiring/services.py b/apps/hiring/services.py index 4930d17..8c738dd 100644 --- a/apps/hiring/services.py +++ b/apps/hiring/services.py @@ -100,7 +100,9 @@ def _build_ticket_description(hiring_request, intern): f"{_('Локация / отдел')}: {loc_detail}", f"{_('Дата выхода')}: {hiring_request.hire_date:%d.%m.%Y}", f"{_('Испытательный срок')}: {hiring_request.trial_days} {_('дн.')}", - f"{_('Интерн-аккаунт')}: {intern.label} / {_('логин')} «{intern.username}»", + f"{_('Интерн-аккаунт')}: {intern.label}", + f"{_('Логин')}: {intern.username}", + f"{_('Пароль')}: {intern.password}", '', f"{_('Требуемые доступы')}:", ] @@ -209,62 +211,88 @@ def _office_email_for(username: str) -> str: @transaction.atomic def approve_hiring_request(hiring_request, decided_by, note=''): - if hiring_request.status != 'trial': - return hiring_request + locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk) + if locked.status != 'trial': + return locked - username = _generate_username(hiring_request.last_name, hiring_request.first_name) + username = _generate_username(locked.last_name, locked.first_name) password = _generate_password() employee = Employee( username=username, - last_name=hiring_request.last_name, - first_name=hiring_request.first_name, - middle_name=hiring_request.middle_name, - personal_phone=hiring_request.candidate_phone or '', - personal_email=hiring_request.candidate_email or '', + last_name=locked.last_name, + first_name=locked.first_name, + middle_name=locked.middle_name, + personal_phone=locked.candidate_phone or '', + personal_email=locked.candidate_email or '', is_active=True, - hire_date=hiring_request.hire_date, + hire_date=locked.hire_date, probation_end_date=timezone.localdate(), - user_location_type=hiring_request.user_location_type, - department_id=hiring_request.department_id, - user_pharmacy_id=hiring_request.user_pharmacy_id, - user_location_id=hiring_request.user_location_id, - post_id=hiring_request.post_id, - ad_sync_enabled=(hiring_request.user_location_type == 'office'), + user_location_type=locked.user_location_type, + department_id=locked.department_id, + user_pharmacy_id=locked.user_pharmacy_id, + user_location_id=locked.user_location_id, + post_id=locked.post_id, + ad_sync_enabled=(locked.user_location_type == 'office'), ) - if _should_assign_office_email(hiring_request.user_location_type): + if _should_assign_office_email(locked.user_location_type): employee.email = _office_email_for(username) employee.set_password(password) employee.full_clean() employee.save() - hiring_request.employee = employee - hiring_request.issued_password = password - hiring_request.status = 'approved' - hiring_request.decision_by = decided_by - hiring_request.decision_at = timezone.now() - hiring_request.decision_note = note - hiring_request.save(update_fields=[ + locked.employee = employee + locked.issued_password = password + locked.status = 'approved' + locked.decision_by = decided_by + locked.decision_at = timezone.now() + locked.decision_note = note + locked.save(update_fields=[ 'employee', 'issued_password', 'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at', ]) - hiring_request.release_intern() - return hiring_request + locked.release_intern() + _close_ticket(locked.granted_ticket) + return locked @transaction.atomic def reject_hiring_request(hiring_request, decided_by, note=''): - if hiring_request.status != 'trial': - return hiring_request + locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk) + if locked.status != 'trial': + return locked - hiring_request.release_intern() - _close_ticket(hiring_request.granted_ticket) + locked.release_intern() + _close_ticket(locked.granted_ticket) - hiring_request.status = 'rejected' - hiring_request.decision_by = decided_by - hiring_request.decision_at = timezone.now() - hiring_request.decision_note = note - hiring_request.save(update_fields=[ + locked.status = 'rejected' + locked.decision_by = decided_by + locked.decision_at = timezone.now() + locked.decision_note = note + locked.save(update_fields=[ 'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at', ]) - return hiring_request + return locked + + +@transaction.atomic +def consume_issued_password_for_pdf(hiring_request): + """Повертає PDF-байти й одразу очищає issued_password (one-time download).""" + from apps.hiring.pdf import build_credentials_pdf + + locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk) + if ( + locked.status != 'approved' + or locked.employee_id is None + or not locked.issued_password + ): + return None + # Related для PDF — окремим запитом без FOR UPDATE (nullable FK + Postgres). + locked = ( + HiringRequest.objects.select_related('employee', 'post') + .prefetch_related('requested_access') + .get(pk=locked.pk) + ) + content = build_credentials_pdf(locked) + HiringRequest.objects.filter(pk=locked.pk).update(issued_password='') + return content diff --git a/apps/hiring/tests.py b/apps/hiring/tests.py index 2210b96..dedb283 100644 --- a/apps/hiring/tests.py +++ b/apps/hiring/tests.py @@ -37,7 +37,7 @@ def make_employee(**overrides): 'last_name': 'HR-Тестова', 'hire_date': date(2026, 1, 1), 'user_location_type': 'office', - 'is_staff': True, + 'is_staff': False, 'mattermost_sync_enabled': False, 'ad_sync_enabled': False, } @@ -76,7 +76,9 @@ class HiringServiceTests(TestCase): ) def setUp(self): + from django.contrib.auth.models import Group self.hr_user = make_employee() + self.hr_user.groups.add(Group.objects.get(name='HR')) # Сид-миграция 0005 наполняет пул Intern-1..5 — очищаем, чтобы тесты сами # управляли составом стажёров (иначе конфликт лейблов и «свободные» из сида). InternAccount.objects.all().delete() @@ -209,6 +211,7 @@ class HiringServiceTests(TestCase): last_name='Кандидат', first_name='Марія', hire_date=date(2026, 6, 1), trial_days=5, )) + ticket = hr.granted_ticket approved = approve_hiring_request(hr, decided_by=self.hr_user, note='ok') self.assertEqual(approved.status, 'approved') @@ -220,6 +223,8 @@ class HiringServiceTests(TestCase): self.intern.refresh_from_db() self.assertIsNone(self.intern.assigned_to_id) + ticket.refresh_from_db() + self.assertEqual(ticket.status.code, 'closed') def test_approve_office_assigns_corporate_email(self): from apps.employees.models import EmployeeDefaults @@ -325,6 +330,11 @@ class HiringServiceTests(TestCase): self.assertEqual(response['Content-Type'], 'application/pdf') self.assertTrue(response.content.startswith(b'%PDF')) + hr.refresh_from_db() + self.assertEqual(hr.issued_password, '') + second = client.get(url) + self.assertEqual(second.status_code, 403) + def test_credentials_pdf_forbidden_while_trial(self): hr = create_hiring_request(**self._pharmacy_kwargs( last_name='PDF', first_name='Trial', @@ -333,3 +343,11 @@ class HiringServiceTests(TestCase): login_2fa(client, self.hr_user) response = client.get(reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk})) self.assertEqual(response.status_code, 403) + + def test_hr_portal_shows_hiring_ui_without_staff(self): + client = Client() + login_2fa(client, self.hr_user) + response = client.get('/hr-portal/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['can_manage_hiring']) + self.assertContains(response, 'Новая заявка на найм') diff --git a/apps/rro/tests.py b/apps/rro/tests.py index 178d176..8f0dcf1 100644 --- a/apps/rro/tests.py +++ b/apps/rro/tests.py @@ -132,3 +132,180 @@ class CashRegisterPortalTests(TestCase): 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() + 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') + + +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, + ) + + 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): + 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)) diff --git a/apps/rro/urls.py b/apps/rro/urls.py index 865bda7..5421dd9 100644 --- a/apps/rro/urls.py +++ b/apps/rro/urls.py @@ -4,4 +4,6 @@ from . import views urlpatterns = [ path('rro/', views.rro_list_view, name='rro_list'), + path('rro/create/', views.rro_create_view, name='rro_create'), + path('rro//update/', views.rro_update_view, name='rro_update'), ] diff --git a/apps/rro/views.py b/apps/rro/views.py index e325af7..3d33dc6 100644 --- a/apps/rro/views.py +++ b/apps/rro/views.py @@ -1,5 +1,14 @@ +from datetime import datetime +import json + from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.core.serializers.json import DjangoJSONEncoder +from django.db import IntegrityError, transaction from django.db.models import Q +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_POST from apps.locations.models.pharmacies import Pharmacy from core.models.directories import Directory @@ -8,10 +17,112 @@ from core.utils.htmx import render_page from .models import CashRegister -@login_required -def rro_list_view(request): - """Реестр РРО/ПРРО по аптекам. Staff видит все; сотрудник аптеки — свою.""" +def _can_edit_rro(user) -> bool: + return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser)) + + +def _parse_date(raw): + raw = (raw or '').strip() + if not raw: + return None + try: + return datetime.strptime(raw, '%Y-%m-%d').date() + except ValueError: + raise ValidationError({'__all__': ['Некорректная дата.']}) + + +def _parse_register_form(post, *, instance=None): + """Збирає поля з POST у dict для CashRegister + ValidationError map.""" + errors = {} + + pharmacy_id = post.get('pharmacy') or post.get('pharmacy_id') or None + if not pharmacy_id: + errors['pharmacy'] = ['Выберите аптеку.'] + else: + try: + pharmacy_id = int(pharmacy_id) + except (TypeError, ValueError): + errors['pharmacy'] = ['Некорректная аптека.'] + + kind = (post.get('kind') or '').strip() + if kind not in {CashRegister.Kind.RRO, CashRegister.Kind.PRRO}: + errors['kind'] = ['Выберите тип (РРО / ПРРО).'] + + fiscal_number = (post.get('fiscal_number') or '').strip() + if not fiscal_number: + errors['fiscal_number'] = ['Укажите фискальный номер.'] + + status_id = post.get('status') or post.get('status_id') or None + if not status_id: + errors['status'] = ['Выберите статус.'] + else: + try: + status_id = int(status_id) + except (TypeError, ValueError): + errors['status'] = ['Некорректный статус.'] + + vendor_raw = post.get('vendor') or post.get('vendor_id') or '' + vendor_id = None + if str(vendor_raw).strip(): + try: + vendor_id = int(vendor_raw) + except (TypeError, ValueError): + errors['vendor'] = ['Некорректный вендор.'] + + try: + registered_at = _parse_date(post.get('registered_at')) + cert_expires_at = _parse_date(post.get('cert_expires_at')) + except ValidationError as exc: + errors.update(exc.message_dict if hasattr(exc, 'message_dict') else {'__all__': list(exc.messages)}) + registered_at = None + cert_expires_at = None + + data = { + 'pharmacy_id': pharmacy_id, + 'kind': kind, + 'fiscal_number': fiscal_number, + 'serial_number': (post.get('serial_number') or '').strip(), + 'model_name': (post.get('model_name') or '').strip(), + 'vendor_id': vendor_id, + 'status_id': status_id, + 'registered_at': registered_at, + 'cert_expires_at': cert_expires_at, + 'is_primary': bool(post.get('is_primary')), + 'provider': (post.get('provider') or '').strip() if kind == CashRegister.Kind.PRRO else '', + 'notes': (post.get('notes') or '').strip(), + } + + form_data = { + **{k: ('' if v is None else v) for k, v in data.items()}, + 'registered_at': post.get('registered_at') or '', + 'cert_expires_at': post.get('cert_expires_at') or '', + 'is_primary': data['is_primary'], + 'pharmacy': data['pharmacy_id'] or '', + 'status': data['status_id'] or '', + 'vendor': data['vendor_id'] or '', + } + + if errors: + raise ValidationError(errors) + + if instance is None: + register = CashRegister(**data) + else: + register = instance + for key, value in data.items(): + setattr(register, key, value) + + try: + register.full_clean() + except ValidationError: + raise + + return register, form_data + + +def _rro_list_context(request, **extra): is_staff = request.user.is_staff + can_edit = _can_edit_rro(request.user) own_pharmacy = request.user.user_pharmacy registers = CashRegister.objects.select_related( @@ -23,6 +134,7 @@ def rro_list_view(request): filter_kind = request.GET.get('kind', '') filter_status = request.GET.get('status', '') q = (request.GET.get('q') or '').strip() + show_create = request.GET.get('new') == '1' if is_staff: if filter_pharmacy: @@ -50,17 +162,45 @@ def rro_list_view(request): statuses = Directory.objects.filter( directory_type__code='rro_status', is_active=True, ).order_by('name') + vendors = Directory.objects.filter( + directory_type__code='rro_vendor', is_active=True, + ).order_by('name') + + register_list = list(registers[:500]) + registers_js = [ + { + 'id': reg.pk, + 'pharmacy': str(reg.pharmacy_id), + 'kind': reg.kind, + 'fiscal_number': reg.fiscal_number, + 'serial_number': reg.serial_number, + 'model_name': reg.model_name, + 'vendor': str(reg.vendor_id) if reg.vendor_id else '', + 'status': str(reg.status_id), + 'registered_at': reg.registered_at.isoformat() if reg.registered_at else '', + 'cert_expires_at': reg.cert_expires_at.isoformat() if reg.cert_expires_at else '', + 'provider': reg.provider, + 'is_primary': reg.is_primary, + 'notes': reg.notes, + } + for reg in register_list + ] context = { 'is_staff': is_staff, - 'registers': list(registers[:500]), + 'can_edit_rro': can_edit, + 'registers': register_list, + 'registers_json': json.dumps(registers_js, cls=DjangoJSONEncoder, ensure_ascii=False), 'statuses': statuses, + 'vendors': vendors, 'kind_choices': CashRegister.Kind.choices, 'filter_pharmacy': filter_pharmacy, 'filter_org': filter_org, 'filter_kind': filter_kind, 'filter_status': filter_status, 'filter_q': q, + 'show_create_panel': show_create and can_edit, + 'prefill_pharmacy': filter_pharmacy if can_edit else '', 'all_pharmacies': ( Pharmacy.objects.filter(is_active=True) .select_related('organization') @@ -72,4 +212,79 @@ def rro_list_view(request): if is_staff else [] ), } - return render_page(request, 'rro.html', context) + context.update(extra) + # Explicit open create panel on validation errors + if context.get('rro_form_errors') and not context.get('rro_edit_id'): + context['show_create_panel'] = True + if context.get('rro_form_data'): + context['rro_form_data_json'] = json.dumps( + context['rro_form_data'], cls=DjangoJSONEncoder, ensure_ascii=False, + ) + return context + + +def _render_rro(request, **extra): + return render_page(request, 'rro.html', _rro_list_context(request, **extra)) + + +@login_required +def rro_list_view(request): + """Реестр РРО/ПРРО по аптекам. Staff видит все; сотрудник аптеки — свою.""" + return _render_rro(request) + + +@login_required +@require_POST +def rro_create_view(request): + if not _can_edit_rro(request.user): + return HttpResponseForbidden() + + form_data = dict(request.POST.items()) + try: + register, form_data = _parse_register_form(request.POST) + with transaction.atomic(): + register.save() + return _render_rro(request, rro_form_message='Кассовый аппарат добавлен') + except ValidationError as exc: + return _render_rro( + request, + show_create_panel=True, + rro_form_errors=exc.message_dict if hasattr(exc, 'message_dict') else {'__all__': list(exc.messages)}, + rro_form_data=form_data, + ) + except IntegrityError: + return _render_rro( + request, + show_create_panel=True, + rro_form_errors={'fiscal_number': ['Фискальный номер уже используется.']}, + rro_form_data=form_data, + ) + + +@login_required +@require_POST +def rro_update_view(request, pk): + if not _can_edit_rro(request.user): + return HttpResponseForbidden() + + register = get_object_or_404(CashRegister, pk=pk) + form_data = dict(request.POST.items()) + try: + register, form_data = _parse_register_form(request.POST, instance=register) + with transaction.atomic(): + register.save() + return _render_rro(request, rro_form_message='Изменения сохранены') + except ValidationError as exc: + return _render_rro( + request, + rro_edit_id=pk, + rro_form_errors=exc.message_dict if hasattr(exc, 'message_dict') else {'__all__': list(exc.messages)}, + rro_form_data=form_data, + ) + except IntegrityError: + return _render_rro( + request, + rro_edit_id=pk, + rro_form_errors={'fiscal_number': ['Фискальный номер уже используется.']}, + rro_form_data=form_data, + ) diff --git a/core/migrations/0010_seed_portal_section_perms.py b/core/migrations/0010_seed_portal_section_perms.py index 28856b7..28c1f5f 100644 --- a/core/migrations/0010_seed_portal_section_perms.py +++ b/core/migrations/0010_seed_portal_section_perms.py @@ -79,9 +79,7 @@ def backwards(apps, schema_editor): hr = Group.objects.filter(name='HR').first() if hr is not None: PermissionGroup.objects.filter(group=hr).delete() - # не удаляем Group HR если уже использовалась — только если создавали мы; - # для backwards удаляем - hr.delete() + # Не удаляем Group — могла существовать до миграции / иметь участников. class Migration(migrations.Migration): diff --git a/core/portal_access.py b/core/portal_access.py index 11cf0d6..bda6188 100644 --- a/core/portal_access.py +++ b/core/portal_access.py @@ -15,8 +15,7 @@ def user_can_access(user, key: str) -> bool: - не аутентифицирован → False - superuser → True - раздел без perm → True (любой залогиненный) - - is_staff → True (временный мост для офисных админов) - - иначе user.has_perm(section.perm) + - иначе user.has_perm(section.perm) (группы / overrides) """ if not user or not getattr(user, 'is_authenticated', False): return False @@ -28,8 +27,6 @@ def user_can_access(user, key: str) -> bool: return False if section.perm is None: return True - if getattr(user, 'is_staff', False): - return True return user.has_perm(section.perm) diff --git a/core/tests.py b/core/tests.py index 1c3cd1a..2dc58a3 100644 --- a/core/tests.py +++ b/core/tests.py @@ -236,6 +236,8 @@ class ITSupportViewTests(TestCase): class DatabasesViewTests(TestCase): def setUp(self): self.user = make_employee() + from django.contrib.auth.models import Group + self.user.groups.add(Group.objects.get(name='Офисный сотрудник')) # в тестовой базе уже есть типы, созданные data-миграциями (equipment_brand) self.base_types = DirectoryType.objects.count() self.base_records = Directory.objects.count() @@ -260,7 +262,9 @@ class DatabasesViewTests(TestCase): response = self.client.get('/databases/', HTTP_HX_REQUEST='true') self.assertNotContains(response, '/admin/core/directory/') + from django.contrib.auth.models import Group staff = make_employee(is_staff=True) + staff.groups.add(Group.objects.get(name='Офисный сотрудник')) login_2fa(self.client, staff) response = self.client.get('/databases/', HTTP_HX_REQUEST='true') self.assertContains(response, '/admin/core/directory/') @@ -273,8 +277,13 @@ class StructureViewTests(TestCase): ) self.dept = Directory.objects.create(directory_type=self.dtype, name='IT-відділ') + def _grant_structure(self, user): + from django.contrib.auth.models import Group + user.groups.add(Group.objects.get(name='Офисный сотрудник')) + def test_department_head_heuristic(self): head = make_employee(first_name='Керівник', last_name='Відділу', department=self.dept) + self._grant_structure(head) make_employee(department=self.dept, manager=head) make_employee(department=self.dept, manager=head) @@ -288,6 +297,7 @@ class StructureViewTests(TestCase): def test_empty_department_has_no_head(self): user = make_employee() + self._grant_structure(user) login_2fa(self.client, user) response = self.client.get('/structure/', HTTP_HX_REQUEST='true') departments = response.context['departments'] diff --git a/core/tests_portal_access.py b/core/tests_portal_access.py index 91fd66e..8ed243e 100644 --- a/core/tests_portal_access.py +++ b/core/tests_portal_access.py @@ -52,10 +52,10 @@ class PortalAccessUnitTests(TestCase): self.assertFalse(user_can_access(user, 'hr_portal')) self.assertFalse(user_can_access(user, 'it_manager')) - def test_staff_bridge(self): + def test_staff_without_perm_denied(self): user = make_employee(is_staff=True) - self.assertTrue(user_can_access(user, 'hr_portal')) - self.assertTrue(user_can_access(user, 'databases')) + self.assertFalse(user_can_access(user, 'hr_portal')) + self.assertFalse(user_can_access(user, 'databases')) def test_hr_group_perm(self): user = make_employee() @@ -118,8 +118,15 @@ class PortalAccessViewTests(TestCase): login_2fa(self.client, user) self.assertEqual(self.client.get('/it-manager/', HTTP_HX_REQUEST='true').status_code, 200) - def test_staff_bridge_views(self): + def test_staff_without_perm_views_forbidden(self): user = make_employee(is_staff=True, totp_enabled=True) login_2fa(self.client, user) + self.assertEqual(self.client.get('/databases/', HTTP_HX_REQUEST='true').status_code, 403) + self.assertEqual(self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true').status_code, 403) + + def test_it_group_can_open_databases_and_ad(self): + user = make_employee(totp_enabled=True) + user.groups.add(Group.objects.get(name='IT')) + login_2fa(self.client, user) self.assertEqual(self.client.get('/databases/', HTTP_HX_REQUEST='true').status_code, 200) self.assertEqual(self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true').status_code, 200) diff --git a/core/views.py b/core/views.py index fa9a58a..3c749f5 100644 --- a/core/views.py +++ b/core/views.py @@ -1,6 +1,6 @@ from django.core.exceptions import ValidationError from django.db.models import Q, Count, Prefetch -from django.http import HttpResponseForbidden, JsonResponse, HttpResponseNotAllowed +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse, HttpResponseNotAllowed from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required @@ -762,39 +762,40 @@ def _hr_portal_context(request): 'hr_posts': hr_posts, 'hr_post_location_types': hr_post_location_types, 'today': today, + # Доступ до розділу вже перевірено @section_required('hr_portal'). + 'can_manage_hiring': True, } - if request.user.is_staff: - from apps.locations.models import Location + from apps.locations.models import Location - hiring_requests_qs = HiringRequest.objects.select_related( - 'employee', 'post', 'department', 'user_pharmacy', 'user_location', - 'requested_by', 'decision_by', 'granted_ticket', 'intern_account', - ).order_by('-created_at') - trial_hiring_qs = hiring_requests_qs.filter(status='trial') - hr_access_systems = list( - Directory.objects.filter( - directory_type__code='access_system', is_active=True, - ).prefetch_related('location_types').order_by('name') - ) - context.update({ - 'hiring_requests': paginate( - request, hiring_requests_qs, page_param='hiring_page', size_param='hiring_per_page', - ), - 'trial_hiring_requests': list(trial_hiring_qs), - 'hr_access_systems': hr_access_systems, - 'hr_access_location_types': { - a.id: [lt.code for lt in a.location_types.all()] for a in hr_access_systems - }, - 'hr_pharmacies': Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number'), - 'hr_locations': Location.objects.filter( - location_type__in=['office', 'warehouse'], is_active=True, - ).order_by('location_type', 'address'), - 'hr_trial_days_choices': range(1, 6), - 'hr_departments_flat': Directory.objects.filter( - directory_type__code='department', is_active=True, - ).order_by('name'), - }) + hiring_requests_qs = HiringRequest.objects.select_related( + 'employee', 'post', 'department', 'user_pharmacy', 'user_location', + 'requested_by', 'decision_by', 'granted_ticket', 'intern_account', + ).order_by('-created_at') + trial_hiring_qs = hiring_requests_qs.filter(status='trial') + hr_access_systems = list( + Directory.objects.filter( + directory_type__code='access_system', is_active=True, + ).prefetch_related('location_types').order_by('name') + ) + context.update({ + 'hiring_requests': paginate( + request, hiring_requests_qs, page_param='hiring_page', size_param='hiring_per_page', + ), + 'trial_hiring_requests': list(trial_hiring_qs), + 'hr_access_systems': hr_access_systems, + 'hr_access_location_types': { + a.id: [lt.code for lt in a.location_types.all()] for a in hr_access_systems + }, + 'hr_pharmacies': Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number'), + 'hr_locations': Location.objects.filter( + location_type__in=['office', 'warehouse'], is_active=True, + ).order_by('location_type', 'address'), + 'hr_trial_days_choices': range(1, 6), + 'hr_departments_flat': Directory.objects.filter( + directory_type__code='department', is_active=True, + ).order_by('name'), + }) return context @@ -913,15 +914,16 @@ def hiring_reject_view(request, pk): @section_required('hr_portal') def hiring_credentials_pdf_view(request, pk): from apps.hiring.models import HiringRequest - from apps.hiring.pdf import credentials_pdf_response + from apps.hiring.services import consume_issued_password_for_pdf - hiring_request = get_object_or_404( - HiringRequest.objects.select_related('employee', 'post'), - pk=pk, - ) - if hiring_request.status != 'approved' or hiring_request.employee_id is None: + hiring_request = get_object_or_404(HiringRequest, pk=pk) + content = consume_issued_password_for_pdf(hiring_request) + if content is None: return HttpResponseForbidden() - return credentials_pdf_response(hiring_request) + filename = f'credentials-{hiring_request.employee.username}.pdf' + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response @login_required diff --git a/templates/pages/dashboard.html b/templates/pages/dashboard.html index 9505c3e..6519942 100644 --- a/templates/pages/dashboard.html +++ b/templates/pages/dashboard.html @@ -3,7 +3,7 @@
-
+

{% trans "Добрый день" %}, {{ request.user.first_name|default:request.user.username }}

{% trans "Добро пожаловать в корпоративный портал АКС." %} {% trans "Сегодня" %} {{ today|date:"j E Y" }} {% trans "г." %}

diff --git a/templates/pages/hr-portal.html b/templates/pages/hr-portal.html index 0e56b29..d7ab680 100644 --- a/templates/pages/hr-portal.html +++ b/templates/pages/hr-portal.html @@ -13,7 +13,8 @@
{% trans "Заявку створено" %}
{% with intern=hiring_just_created.intern_account %} {% if intern %} -
{% trans "Інтерн-слот" %}: {{ intern.label }} · {% trans "логін" %} {{ intern.username }} · {% trans "пароль" %} {{ intern.password }}
+
{% trans "Інтерн-слот" %}: {{ intern.label }} · {% trans "логін" %} {{ intern.username }}
+
{% trans "Пароль інтерн-обліку — у заявці IT (для підготовки робочого місця)." %}
{% endif %} {% endwith %} {% if hiring_just_created.granted_ticket %} @@ -102,7 +103,7 @@
- {% if request.user.is_staff %} + {% if can_manage_hiring %} + {% endif %}
+ {% if rro_form_message %} +
+ {{ rro_form_message }} +
+ {% endif %} +
- {% if is_staff %} - {% trans "Добавить в админке" %} - {% endif %}
@@ -55,7 +65,9 @@ {% trans "Модель" %} {% trans "Статус" %} {% trans "Сертификат до" %} + {% if can_edit_rro %} + {% endif %} @@ -82,16 +94,16 @@ {% if reg.cert_expires_at %}{{ reg.cert_expires_at|date:"d.m.Y" }}{% else %}—{% endif %} + {% if can_edit_rro %} - {% if is_staff %} - {% trans "Изменить" %} - {% endif %} + + {% endif %} {% empty %} - + {% trans "Кассовые аппараты не найдены" %} @@ -100,4 +112,214 @@
+ + {% if can_edit_rro %} + +
+
+ {% csrf_token %} +
+ {% trans "Новый кассовый аппарат" %} + +
+ +
+ {% if rro_form_errors %} +
    + {% for field, messages in rro_form_errors.items %} + {% for msg in messages %} +
  • {{ msg }}
  • + {% endfor %} + {% endfor %} +
+ {% endif %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ + + {% if rro_form_data_json %}{% endif %} + {% endif %}
+ +{% if can_edit_rro %} + +{% endif %}