From 4f7486152c5683ba97d132c73059d329b7401735 Mon Sep 17 00:00:00 2001 From: javad Date: Tue, 6 Oct 2026 15:06:35 +0330 Subject: [PATCH] fix resid taeid accsess --- apps/payments/policies.py | 4 +- docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md | 6 +- tests/e2e/test_payments_e2e.py | 16 +++- tests/test_payments.py | 99 +++++++++++++++++++++++- 4 files changed, 115 insertions(+), 10 deletions(-) diff --git a/apps/payments/policies.py b/apps/payments/policies.py index d8c8e8e..4a612d1 100644 --- a/apps/payments/policies.py +++ b/apps/payments/policies.py @@ -79,8 +79,8 @@ def visible(queryset, user): def can_decide(user, receipt, cycle, senior=False): - code = 'senior_approve_paymentreceipt' if senior else 'approve_paymentreceipt' + # Selection in the approval policy grants the role for this cycle. Requiring + # an additional Django permission made selected non-admin staff unable to vote. return (is_staff(user) and receipt.submitted_by_id != user.pk and - has_permission(user, code, receipt.owning_unit) and visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and cycle.assignments.filter(user=user, is_senior=senior).exists()) diff --git a/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md index dfe3719..dc2a416 100644 --- a/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md +++ b/docs/CHAHARSU_PAYMENTS_IMPLEMENTATION.md @@ -37,19 +37,17 @@ ۳. در مدیریت Django `/x5admin/`، بخش `PaymentApprovalPolicy`، یک سیاست عمومی یا سیاست واحد بسازید. همه افراد فهرست `reviewers` باید رأی بدهند؛ یک نفر یعنی تأیید تک‌نفره، چند نفر یعنی شرط تأیید همه اشخاص تعیین‌شده. افراد `seniors` می‌توانند با دلیل، تأیید نهایی ارشد انجام دهند. -۴. **انتخاب فرد در سیاست به‌تنهایی کافی نیست.** مجوزهای زیر را به افراد مجاز بدهید؛ مدیر مالی یا نقش FINANCE خودکار تأییدکننده نمی‌شود: +۴. **انتخاب کارشناس فعال در سیاست، اختیار تأیید یا رد را برای چرخه‌های آن سیاست می‌دهد.** افراد `reviewers` رأی الزامی و افراد `seniors` اختیار تأیید نهایی مستقل با دلیل دارند؛ مجوز جداگانه Django برای رأی لازم نیست. مدیر مالی یا نقش FINANCE بدون انتخاب در سیاست، خودکار تأییدکننده نمی‌شود. مجوزهای مدیریتی و مشاهده همچنان مستقل‌اند: | مجوز | کاربرد | | --- | --- | -| `payments.approve_paymentreceipt` | رأی عادی فردِ تعیین‌شده در سیاست | -| `payments.senior_approve_paymentreceipt` | تأیید ارشد فردِ تعیین‌شده در seniors | | `payments.manage_payment_approval_policy` | مدیریت سیاست‌ها و تخصیص سیاست فعال به چرخه خالی | | `payments.manage_company_bankaccounts` | مدیریت حساب‌ها | | `payments.manage_payment_access` | تنظیم باز یا محدود بودن مشاهده و ایجاد | | `payments.view_all_payments` | مشاهده مالی در همه واحدها | | `payments.view_payment_audit` | دسترسی ممیزی مالی در مدیریت | -مجوزهای تأیید می‌توانند سراسری، در عضویت فعال واحد یا در مجوزهای واحد فعال باشند. مشاهده همان فیش و تخصیص همان چرخه نیز لازم است. ثبت‌کننده کارشناس، فیشی را که خودش ثبت کرده تأیید نمی‌کند. مدیریت Django علاوه بر مجوز مالی، شرایط ورود استاندارد پنل admin را لازم دارد. +مشاهده همان فیش و تخصیص نقش عادی یا ارشد در همان چرخه لازم است. مجوزهای قدیمی `approve_paymentreceipt` و `senior_approve_paymentreceipt` به‌تنهایی اختیار رأی نمی‌دهند. ثبت‌کننده کارشناس، فیشی را که خودش ثبت کرده تأیید نمی‌کند. غیرفعال‌کردن حساب یا پروفایل کارشناس مانع رأی جدید او می‌شود. افراد هر چرخه هنگام ارسال فیش ذخیره می‌شوند؛ تغییر سیاست، افراد چرخه‌های قبلی را بازنویسی نمی‌کند. مدیریت Django شرایط ورود استاندارد پنل admin را لازم دارد. ۵. نبود سیاست فعال باعث **ماندن فیش در انتظار بررسی** می‌شود. تأیید خودکار یا تعیین فرضی مدیرعامل انجام نشده است. پس از تعریف سیاست، مدیر مجاز از جزئیات فیش، «اعمال سیاست فعال تأیید» را برای چرخه خالی بزند. سیاست چرخه دارای تخصیص/رأی بازنویسی نمی‌شود. diff --git a/tests/e2e/test_payments_e2e.py b/tests/e2e/test_payments_e2e.py index 6c80a78..eea9293 100644 --- a/tests/e2e/test_payments_e2e.py +++ b/tests/e2e/test_payments_e2e.py @@ -26,7 +26,6 @@ def payment_case(settings,tmp_path): for index in range(2): user=User.objects.create_user(username=f'payment-e2e-reviewer-{index}',password=PASSWORD,account_type='STAFF') StaffProfile.objects.create(user=user,display_name=f'تأییدکننده {index}') - user.user_permissions.add(Permission.objects.get(content_type__app_label='payments',codename='approve_paymentreceipt')) reviewers.append(user) policy=PaymentApprovalPolicy.objects.create(name='تأیید هر دو نفر') policy.reviewers.add(*reviewers) @@ -85,6 +84,21 @@ def test_form_errors_focus_and_full_copy(live_server,browser_page,payment_case): expect(page.get_by_text('کپی شد',exact=True).or_(page.get_by_role('heading',name='متن را انتخاب و کپی کنید'))).to_be_visible() +def test_policy_senior_finalizes_without_extra_permissions(live_server, browser_page, payment_case): + customer, account, reviewers = payment_case + PaymentApprovalPolicy.objects.get().seniors.add(reviewers[0]) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD) + PaymentsPage(page, live_server.url).send_receipt(account.pk, PNG) + receipt_id = page.url.rstrip('/').rsplit('/', 1)[-1] + page.get_by_role('button', name='خروج', exact=True).first.click() + AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD) + PaymentsPage(page, live_server.url).approve(receipt_id, senior=True) + expect(page.get_by_text('تأیید شده', exact=True)).to_be_visible() + expect(page.get_by_role('button', name='ثبت تأیید من')).to_have_count(0) + expect(page.get_by_role('button', name='رد فیش')).to_have_count(0) + + def test_amount_grouping_and_jalali_leap_calendar(live_server,browser_page,payment_case): customer,account,reviewers=payment_case with browser_page() as page: diff --git a/tests/test_payments.py b/tests/test_payments.py index 66b09bf..4250b7a 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -101,6 +101,68 @@ def test_senior_explicit_override_and_reason(case): assert PaymentAuditEvent.objects.filter(action='SENIOR_APPROVE').exists() +@pytest.mark.parametrize('reviewer_count', [1, 2]) +def test_policy_reviewers_can_approve_without_extra_permissions(case, client, reviewer_count): + case['policy'].reviewers.set([case['first'], case['second']][:reviewer_count]) + for key in ['first', 'second']: + case[key].user_permissions.clear() + receipt = submit(case) + endpoint = reverse('staff_payments:decision', args=[receipt.public_id]) + detail = reverse('staff_payments:receipt_detail', args=[receipt.public_id]) + for index, key in enumerate(['first', 'second'][:reviewer_count]): + client.force_login(case[key]) + page = client.get(detail) + assert page.context['can_approve'] + assert not page.context['can_senior'] + assert 'ثبت تأیید من' in page.content.decode() + assert 'رد فیش' in page.content.decode() + response = client.post(endpoint, {'version': 1, 'action': 'APPROVE'}) + assert response.status_code == 302 + receipt.refresh_from_db() + final = index == reviewer_count - 1 + assert receipt.status == ('APPROVED' if final else 'PARTIAL') + assert NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').count() == int(final) + assert ReceiptDecision.objects.filter(cycle__revision__receipt=receipt).count() == reviewer_count + + +@pytest.mark.parametrize('also_required', [False, True]) +def test_policy_senior_can_finalize_alone_without_extra_permissions(case, client, also_required): + case['senior'].user_permissions.clear() + if also_required: + case['policy'].reviewers.add(case['senior']) + receipt = submit(case) + client.force_login(case['senior']) + page = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])) + assert page.context['can_senior'] + assert page.context['can_approve'] == also_required + assert 'تأیید نهایی ارشد' in page.content.decode() + endpoint = reverse('staff_payments:decision', args=[receipt.public_id]) + client.post(endpoint, {'version': 1, 'action': 'SENIOR_APPROVE'}) + assert not ReceiptDecision.objects.exists() + response = client.post(endpoint, {'version': 1, 'action': 'SENIOR_APPROVE', 'reason': 'تطبیق انجام شد'}) + assert response.status_code == 302 + receipt.refresh_from_db() + assert receipt.status == 'APPROVED' + assert ReceiptDecision.objects.get().user == case['senior'] + assert NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').count() == 1 + + +@pytest.mark.parametrize('key', ['first', 'senior']) +def test_policy_members_can_reject_without_extra_permissions(case, client, key): + case[key].user_permissions.clear() + receipt = submit(case) + client.force_login(case[key]) + detail = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])) + assert 'رد فیش' in detail.content.decode() + response = client.post(reverse('staff_payments:decision', args=[receipt.public_id]), + {'version': 1, 'action': 'REJECT', 'reason': 'تصویر ناخوانا است'}) + assert response.status_code == 302 + receipt.refresh_from_db() + assert receipt.status == 'REJECTED' + assert ReceiptDecision.objects.get().action == 'REJECT' + assert not NotificationJob.objects.filter(event_type='PAYMENT_RECEIPT_APPROVED').exists() + + @pytest.mark.parametrize('who',['customer','creator','outsider']) def test_view_and_create_never_imply_approval(case,who): receipt = submit(case) @@ -108,6 +170,36 @@ def test_view_and_create_never_imply_approval(case,who): services.decide_receipt(case[who],receipt.public_id,1,'APPROVE') +@pytest.mark.parametrize('action', ['APPROVE', 'SENIOR_APPROVE', 'REJECT']) +def test_django_permissions_without_assignment_do_not_allow_decisions(case, client, action): + grant(case['outsider'], 'approve_paymentreceipt') + grant(case['outsider'], 'senior_approve_paymentreceipt') + receipt = submit(case) + client.force_login(case['outsider']) + page = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])) + assert not page.context['can_approve'] and not page.context['can_senior'] + response = client.post(reverse('staff_payments:decision', args=[receipt.public_id]), + {'version': 1, 'action': action, 'reason': 'بررسی انجام شد'}) + assert response.status_code == 403 + assert not ReceiptDecision.objects.exists() + + +@pytest.mark.parametrize('blocked_by', ['visibility', 'own_submission', 'inactive_user', 'inactive_profile']) +def test_policy_assignment_preserves_decision_restrictions(case, blocked_by): + receipt = submit(case) + if blocked_by == 'visibility': + PaymentAccessConfiguration.objects.update_or_create(pk=1, defaults={'open_view': False}) + elif blocked_by == 'own_submission': + PaymentReceipt.objects.filter(pk=receipt.pk).update(submitted_by=case['first']) + elif blocked_by == 'inactive_user': + User.objects.filter(pk=case['first'].pk).update(is_active=False) + else: + StaffProfile.objects.filter(user=case['first']).update(is_active=False) + with pytest.raises(PermissionDenied): + services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE') + assert not ReceiptDecision.objects.exists() + + def test_rejection_ends_cycle_and_preserves_evidence(case): receipt = submit(case) services.decide_receipt(case['first'],receipt.public_id,1,'REJECT','تصویر پرداخت خوانا نیست') @@ -341,7 +433,7 @@ def test_payment_posts_enforce_csrf_and_revoked_approval(case): client.force_login(case['first']) endpoint = reverse('staff_payments:decision',args=[receipt.public_id]) assert client.post(endpoint, {'version':1,'action':'APPROVE'}).status_code == 403 - case['first'].user_permissions.clear() + StaffProfile.objects.filter(user=case['first']).update(is_active=False) actor = User.objects.get(pk=case['first'].pk) with pytest.raises(PermissionDenied): services.decide_receipt(actor,receipt.public_id,1,'APPROVE') @@ -427,11 +519,12 @@ def test_financial_sms_model_clean_supplies_admin_readonly_tags(case): template.full_clean() -def test_cached_user_cannot_vote_after_permission_revocation(case): +def test_cached_user_cannot_vote_after_staff_deactivation(case): receipt=submit(case) actor=case['first'] assert actor.has_perm('payments.approve_paymentreceipt') - actor.user_permissions.clear() + assert actor.staff_profile.is_active + StaffProfile.objects.filter(user=actor).update(is_active=False) with pytest.raises(PermissionDenied): services.decide_receipt(actor,receipt.public_id,1,'APPROVE') assert not ReceiptDecision.objects.exists()