delete option staff varizi

This commit is contained in:
javad committed 2026-10-06 12:30:28 +03:30
1 parent d99e47ecce
commit b53a3e0c5c
14 files changed
+383 -3

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
from playwright.sync_api import expect
from .base import BasePage
class NoticeDeletionPage(BasePage):
def open_notice(self, path):
self.goto(path)
expect(self.page.get_by_role('heading', name='اعلامیه', exact=False).first).to_be_visible()
def review_and_cancel(self):
button = self.page.get_by_role('button', name='تأیید و حذف اعلامیه', exact=True)
expect(button).to_be_hidden()
self.page.get_by_text('حذف اعلامیه واریز', exact=True).click()
expect(self.page.get_by_text('این اقدام از پنل قابل بازگشت نیست.', exact=False)).to_be_visible()
self.page.get_by_text('حذف اعلامیه واریز', exact=True).click()
expect(button).to_be_hidden()
def confirm_deletion(self):
self.page.get_by_text('حذف اعلامیه واریز', exact=True).click()
self.page.get_by_label('هشدار را خواندم و حذف این اعلامیه را تأیید می‌کنم.', exact=True).check()
self.page.get_by_role('button', name='تأیید و حذف اعلامیه', exact=True).click()
expect(self.page.locator('.flash')).to_contain_text('اعلامیه واریز حذف شد')
+28
View File
@@ -0,0 +1,28 @@
import pytest
from django.urls import reverse
from playwright.sync_api import expect
from apps.payments import services
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.notice_deletion import NoticeDeletionPage
from tests.e2e.test_payments_e2e import payment_case, PASSWORD
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
@pytest.mark.parametrize('width', [320, 1440])
def test_notice_delete_requires_review_and_confirmation(live_server, browser_page, payment_case, width):
customer, account, reviewers = payment_case
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
'subject': 'اعلامیه قابل حذف', 'amount': 12345})
path = reverse('staff_payments:notice_detail', args=[notice.public_id])
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
view = NoticeDeletionPage(page, live_server.url)
view.open_notice(path)
view.review_and_cancel()
expect(page.get_by_text('اعلامیه قابل حذف', exact=True)).to_be_visible()
view.confirm_deletion()
expect(page.get_by_role('link', name='مشاهده اعلامیه', exact=False)).to_have_count(0)
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
+47
View File
@@ -0,0 +1,47 @@
"""PostgreSQL-only regression for simultaneous notice deletion/receipt submission."""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
from django.core.exceptions import ValidationError
from django.db import close_old_connections, connection
from apps.payments import services
from apps.payments.forms import ReceiptForm
from apps.payments.models import PaymentReceipt
from tests.test_payments import case, data, upload
pytestmark = [pytest.mark.django_db(transaction=True),
pytest.mark.skipif(connection.vendor != 'postgresql', reason='Requires PostgreSQL row locks')]
def test_simultaneous_delete_and_receipt_allow_exactly_one_operation(case):
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
barrier = Barrier(2)
def operate(operation):
close_old_connections()
try:
barrier.wait(timeout=10)
try:
if operation == 'delete':
services.delete_notice(case['creator'], case['notice'])
else:
services.submit_receipt(case['customer'], case['customer'], form.cleaned_data)
return operation
except ValidationError:
return 'blocked'
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(operate, ['delete', 'receipt']))
case['notice'].refresh_from_db()
assert outcomes.count('blocked') == 1
if case['notice'].deleted_at:
assert not PaymentReceipt.objects.filter(notice=case['notice']).exists()
assert 'delete' in outcomes
else:
assert PaymentReceipt.objects.filter(notice=case['notice']).count() == 1
assert 'receipt' in outcomes
+165
View File
@@ -0,0 +1,165 @@
"""Notice deletion regressions; SMS providers are always mocked."""
from urllib.parse import urlsplit
import pytest
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.management import call_command
from django.test import Client
from django.urls import reverse
from apps.payments import policies, services
from apps.payments.access import issue_payment_link
from apps.payments.models import PaymentAccessConfiguration, PaymentAuditEvent, PaymentNotice, PaymentReceipt
from apps.notifications.models import NotificationJob
from tests.test_payments import case, data, submit, upload
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize('actor_key,role,superuser', [
('creator', None, False), ('outsider', 'CEO', False),
('outsider', 'GENERAL_ADMIN', False), ('outsider', None, True),
])
def test_only_creator_ceo_and_admin_can_delete(case, client, actor_key, role, superuser):
actor = case[actor_key]
if role:
actor.staff_profile.role = role
actor.staff_profile.save()
actor.is_superuser = superuser
actor.save()
client.force_login(actor)
url = reverse('staff_payments:notice_delete', args=[case['notice'].public_id])
assert client.post(url, {'confirm': 'delete'}).status_code == 302
case['notice'].refresh_from_db()
assert case['notice'].deleted_at is not None
assert PaymentAuditEvent.objects.filter(action='NOTICE_DELETED', actor=actor).count() == 1
assert not policies.visible(PaymentNotice.objects.all(), case['customer']).exists()
def test_other_staff_and_customer_cannot_delete(case, client):
for actor in [case['outsider'], case['customer']]:
client.force_login(actor)
url = reverse('staff_payments:notice_delete', args=[case['notice'].public_id])
assert client.post(url, {'confirm': 'delete'}).status_code == 403
with pytest.raises(PermissionDenied):
services.delete_notice(actor, case['notice'])
case['notice'].refresh_from_db()
assert case['notice'].deleted_at is None
@pytest.mark.parametrize('status', PaymentReceipt.Status.values)
@pytest.mark.django_db(transaction=True)
def test_any_linked_receipt_blocks_deletion_even_if_hidden(case, client, status):
receipt = submit(case)
PaymentReceipt.objects.filter(pk=receipt.pk).update(status=status)
configuration = PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration()
configuration.open_view = False
configuration.save()
assert not policies.visible(PaymentReceipt.objects.filter(pk=receipt.pk), case['creator']).exists()
assert not policies.can_delete_notice(case['creator'], case['notice'])
with pytest.raises(ValidationError, match='فیش ثبت شده'):
services.delete_notice(case['creator'], case['notice'])
client.force_login(case['creator'])
response = client.post(reverse('staff_payments:notice_delete', args=[case['notice'].public_id]),
{'confirm': 'delete'}, follow=True)
assert 'حذف آن امکان‌پذیر نیست' in response.content.decode()
assert not list(response.context['receipts'])
case['notice'].refresh_from_db()
assert case['notice'].deleted_at is None
assert PaymentReceipt.objects.filter(pk=receipt.pk).exists()
def test_confirmation_post_and_csrf_are_required(case, client):
client.force_login(case['creator'])
url = reverse('staff_payments:notice_delete', args=[case['notice'].public_id])
assert client.get(url).status_code == 405
assert client.post(url).status_code == 302
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['creator'])
assert csrf_client.post(url, {'confirm': 'delete'}).status_code == 403
case['notice'].refresh_from_db()
assert case['notice'].deleted_at is None
@pytest.mark.django_db(transaction=True)
def test_deleted_notice_disappears_and_links_and_stale_forms_stop_working(case, client):
_, url = issue_payment_link(notice=case['notice'])
assert client.get(urlsplit(url).path, follow=True).status_code == 200
# Retain a valid form built before deletion to exercise the service race check.
from apps.payments.forms import ReceiptForm
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid(), form.errors
services.delete_notice(case['creator'], case['notice'])
assert client.get(urlsplit(url).path).status_code == 410
assert client.get(reverse('payments:receipt_new')).status_code == 410
with pytest.raises(ValidationError, match='لغو شده'):
services.submit_receipt(case['customer'], case['customer'], form.cleaned_data)
client.force_login(case['customer'])
assert client.get(reverse('payments:notice_detail', args=[case['notice'].public_id])).status_code == 404
client.force_login(case['creator'])
response = client.get(reverse('staff_payments:desk'), {'tab': 'notices'})
assert case['notice'].number not in response.content.decode()
assert client.get(reverse('staff_payments:notice_detail', args=[case['notice'].public_id])).status_code == 404
def test_independent_receipt_does_not_block_notice_deletion(case):
submit(case, linked=False)
services.delete_notice(case['creator'], case['notice'])
case['notice'].refresh_from_db()
assert case['notice'].deleted_at
def test_deletion_cancels_queued_sms_and_worker_skips_stale_jobs(case, monkeypatch):
services.delete_notice(case['creator'], case['notice'])
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
assert job.status == 'CANCELLED'
# Simulate a stale lease recovery or retry of a job for an already deleted notice.
NotificationJob.objects.filter(pk=job.pk).update(status='RETRYING')
from apps.notifications.management.commands import process_notifications
def forbidden_provider():
pytest.fail('Deleted notices must never initialize an SMS provider')
monkeypatch.setattr(process_notifications, 'get_sms_provider', forbidden_provider)
call_command('process_notifications', once=True, job_id=job.pk, channel='SMS')
job.refresh_from_db()
assert job.status == 'CANCELLED'
def test_role_revocation_is_rechecked_and_repeated_delete_is_idempotent(case):
actor = case['outsider']
actor.staff_profile.role = 'CEO'
actor.staff_profile.save()
assert policies.can_delete_notice(actor, case['notice'])
type(actor.staff_profile).objects.filter(pk=actor.staff_profile.pk).update(role='SALES_EXPERT')
with pytest.raises(PermissionDenied):
services.delete_notice(actor, case['notice'])
services.delete_notice(case['creator'], case['notice'])
services.delete_notice(case['creator'], case['notice'])
assert PaymentAuditEvent.objects.filter(action='NOTICE_DELETED').count() == 1
@pytest.mark.parametrize('actor_key,role', [('creator', None), ('outsider', 'CEO'), ('outsider', 'GENERAL_ADMIN')])
def test_delete_actors_can_reach_notice_when_general_view_is_closed(case, client, actor_key, role):
configuration = PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration()
configuration.open_view = False
configuration.save()
actor = case[actor_key]
if role:
actor.staff_profile.role = role
actor.staff_profile.save()
client.force_login(actor)
response = client.get(reverse('staff_payments:notice_detail', args=[case['notice'].public_id]))
assert response.status_code == 200
assert response.context['can_delete_notice']
@pytest.mark.parametrize('profile_inactive', [True, False])
def test_inactive_creator_cannot_delete(case, profile_inactive):
actor = case['creator']
if profile_inactive:
actor.staff_profile.is_active = False
actor.staff_profile.save()
else:
actor.is_active = False
actor.save()
with pytest.raises(PermissionDenied):
services.delete_notice(actor, case['notice'])