diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 1f20b45..748d467 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -72,6 +72,18 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"]) return "\n".join(lines) +def _prepare_sms(job): + if job.event_type == 'STAFF_REPLY': + return _prepare_staff_reply_sms(job) + if job.event_type == 'PAYMENT_NOTICE_CREATED' and NotificationJob.objects.filter( + pk=job.pk, payment_notification__notice__deleted_at__isnull=False).exists(): + job.status = NotificationJob.Status.CANCELLED + job.last_error = 'اعلامیه واریز حذف شده است.' + job.save(update_fields=['status', 'last_error']) + return False + return True + + def _send_sms(job): if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED"}: from apps.payments.notifications import send_payment_sms @@ -337,7 +349,7 @@ class Command(BaseCommand): for job in jobs: issued_link = None try: - if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job): + if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job): continue if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job): continue diff --git a/apps/payments/access.py b/apps/payments/access.py index 21ddfa6..9ae493b 100644 --- a/apps/payments/access.py +++ b/apps/payments/access.py @@ -46,6 +46,7 @@ def issue_payment_link(*, notice=None, receipt=None): def active_links(): return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter( + notice__deleted_at__isnull=True, revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True, customer__account_type='CUSTOMER') diff --git a/apps/payments/migrations/0005_paymentnotice_deleted_at.py b/apps/payments/migrations/0005_paymentnotice_deleted_at.py new file mode 100644 index 0000000..97568f0 --- /dev/null +++ b/apps/payments/migrations/0005_paymentnotice_deleted_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2026-10-06 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0004_paymentaccesslink_paymentnotification_access_link_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='paymentnotice', + name='deleted_at', + field=models.DateTimeField(blank=True, editable=False, null=True), + ), + ] diff --git a/apps/payments/models.py b/apps/payments/models.py index 9d65da1..2b7a070 100644 --- a/apps/payments/models.py +++ b/apps/payments/models.py @@ -85,6 +85,7 @@ class PaymentNotice(PublicIdModel, TimeStampedModel): invoice_number = models.CharField(max_length=100, blank=True) description = models.TextField(blank=True) cancelled_at = models.DateTimeField(null=True, blank=True) + deleted_at = models.DateTimeField(null=True, blank=True, editable=False) class Meta: ordering = ['-created_at', '-pk'] diff --git a/apps/payments/policies.py b/apps/payments/policies.py index 6b24868..d8c8e8e 100644 --- a/apps/payments/policies.py +++ b/apps/payments/policies.py @@ -1,5 +1,5 @@ from django.db.models import Q -from .models import PaymentAccessConfiguration +from .models import PaymentAccessConfiguration, PaymentNotice def is_staff(user): @@ -37,7 +37,29 @@ def can_manage_banks(user): return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts')) +def may_delete_notice(user, notice): + role = getattr(getattr(user, 'staff_profile', None), 'role', '') + return is_staff(user) and (notice.created_by_id == user.pk or user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'}) + + +def can_delete_notice(user, notice): + return may_delete_notice(user, notice) and not notice.deleted_at and not notice.receipts.exists() + + +def notice_actions(queryset, user): + """Allow deletion actors to reach this notice without broadening other lists.""" + queryset = queryset.filter(deleted_at__isnull=True) + if not is_staff(user): + return queryset.none() + role = getattr(getattr(user, 'staff_profile', None), 'role', '') + if user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'}: + return queryset + return queryset.filter(Q(created_by=user) | Q(pk__in=visible(queryset, user).values('pk'))) + + def visible(queryset, user): + if queryset.model == PaymentNotice: + queryset = queryset.filter(deleted_at__isnull=True) if not user.is_authenticated or not user.is_active: return queryset.none() if user.account_type == 'CUSTOMER': diff --git a/apps/payments/services.py b/apps/payments/services.py index 0e4bda0..ee386a6 100644 --- a/apps/payments/services.py +++ b/apps/payments/services.py @@ -27,6 +27,29 @@ def configuration_snapshot(obj): return json.loads(serializers.serialize('json', [obj]))[0]['fields'] +@transaction.atomic +def delete_notice(actor, notice): + # Receipt submission locks the same customer, then notice. Do not lock access + # links here: anonymous submission already holds its link before the customer. + actor = User.objects.select_related('staff_profile').get(pk=actor.pk) + User.objects.select_for_update().get(pk=notice.customer_id) + notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk) + if not policies.may_delete_notice(actor, notice): + raise PermissionDenied + if notice.receipts.exists(): + raise ValidationError('برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.') + if notice.deleted_at: + return + notice.deleted_at = notice.cancelled_at = timezone.now() + notice.save(update_fields=['deleted_at', 'cancelled_at', 'updated_at']) + from apps.notifications.models import NotificationJob + NotificationJob.objects.filter(payment_notification__notice=notice, + status__in=['PENDING', 'RETRYING', 'FAILED']).update(status='CANCELLED', + last_error='اعلامیه واریز حذف شده است.') + audit(actor, 'NOTICE_DELETED', notice, {'number': notice.number, 'customer_id': notice.customer_id, + 'subject': notice.subject, 'amount': notice.amount}) + + @transaction.atomic def issue_notice(actor, data): unit = TicketPolicy.primary_unit(actor) diff --git a/apps/payments/staff_urls.py b/apps/payments/staff_urls.py index c91a8e4..d75b232 100644 --- a/apps/payments/staff_urls.py +++ b/apps/payments/staff_urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'), path('notices/new/', views.notice_new, name='notice_new'), path('notices//', views.notice_detail, name='notice_detail'), + path('notices//delete/', views.notice_delete, name='notice_delete'), path('notices//payment-link/', views.notice_payment_link, name='notice_payment_link'), path('receipts/new//', views.staff_receipt_new, name='receipt_new'), path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'), diff --git a/apps/payments/views.py b/apps/payments/views.py index e6b2197..7656f8f 100644 --- a/apps/payments/views.py +++ b/apps/payments/views.py @@ -330,7 +330,7 @@ def notice_new(request): @financial_required(staff=True) def notice_detail(request, public_id): - notice = complete_notice(get_object_or_404(policies.visible(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id)) + notice = complete_notice(get_object_or_404(policies.notice_actions(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id)) services.audit(request.user, 'NOTICE_VIEWED', notice) share = request.session.get('payment_link_share', {}) if share.get('notice') == str(public_id): @@ -338,11 +338,31 @@ def notice_detail(request, public_id): else: share = {} return render(request, 'payments/notice_detail.html', staff_context(notice=notice, + can_delete_notice=policies.can_delete_notice(request.user, notice), + may_delete_notice=policies.may_delete_notice(request.user, notice), can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active, payment_share_url=share.get('url', ''), receipts=policies.visible(notice.receipts.all(), request.user), notifications=notice.paymentnotification_set.select_related('job'))) +@financial_required(staff=True) +@require_POST +def notice_delete(request, public_id): + notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id) + if not policies.may_delete_notice(request.user, notice): + raise PermissionDenied + if request.POST.get('confirm') != 'delete': + messages.error(request, 'برای حذف اعلامیه، ابتدا هشدار را بخوانید و حذف را تأیید کنید.') + return redirect('staff_payments:notice_detail', public_id=public_id) + try: + services.delete_notice(request.user, notice) + except ValidationError as error: + messages.error(request, '؛ '.join(error.messages)) + return redirect('staff_payments:notice_detail', public_id=public_id) + messages.success(request, 'اعلامیه واریز حذف شد و لینک‌های آن دیگر قابل استفاده نیستند.') + return redirect(reverse('staff_payments:desk') + '?tab=notices') + + @financial_required(staff=True) @require_POST def notice_payment_link(request, public_id): diff --git a/docs/PAYMENT_NOTICE_DELETION.md b/docs/PAYMENT_NOTICE_DELETION.md new file mode 100644 index 0000000..99334c9 --- /dev/null +++ b/docs/PAYMENT_NOTICE_DELETION.md @@ -0,0 +1,19 @@ +# حذف اعلامیه بدون فیش + +در جزئیات اعلامیه پنل staff، ایجادکننده، مدیرعامل (`CEO`)، مدیرکل (`GENERAL_ADMIN`) و superuser فعال می‌توانند اعلامیه فاقد فیش را حذف کنند. حساب و پروفایل کارشناس باید فعال باشند. مجوزهای ایجاد یا تأیید پرداخت به‌تنهایی مجوز حذف نمی‌دهند. + +بخش «حذف اعلامیه واریز» ابتدا هشدار را باز می‌کند. ارسال فرم به تأیید صریح checkbox، POST و CSRF نیاز دارد. وجود هر فیش مرتبط، در هر وضعیت از جمله ردشده، حذف را ممنوع می‌کند. فیش مستقلِ بدون ارتباط به اعلامیه مانع حذف نیست. + +حذف منطقی با `deleted_at` انجام می‌شود و `cancelled_at` نیز تنظیم می‌شود. سوابق مالی، پیامک و لینک‌های صادرشده برای ممیزی نگهداری می‌شوند؛ اعلامیه از فهرست‌ها و صفحات مشتری و staff خارج می‌شود و لینک‌ها و نشست‌های مربوط دیگر معتبر نیستند. رویداد `NOTICE_DELETED` مشخصات اعلامیه و عامل حذف را نگه می‌دارد. بازیابی از پنل ارائه نشده است. + +سرویس حذف نقش فعلی عامل را مجدداً از دیتابیس می‌خواند و مانند ثبت فیش، ابتدا مشتری و سپس اعلامیه را قفل می‌کند. در نتیجه ثبت هم‌زمان فیش مرتبط و حذف نمی‌توانند هر دو موفق شوند. قفل لینک در سرویس حذف گرفته نمی‌شود تا با ترتیب قفل‌های ارسال فیش ناشناس تداخل نداشته باشد. فرم‌های ثبت فیشِ بازمانده از قبل از حذف نیز در سرور رد می‌شوند. + +پیامک‌های PENDING، RETRYING و FAILED لغو می‌شوند. worker پیش از پردازش پیامک صدور اعلامیه، حذف را دوباره بررسی می‌کند؛ پیامکی که قبلاً به ارائه‌دهنده تحویل داده شده قابل بازگرداندن نیست، اما لینک آن نامعتبر می‌شود. + +تست‌های دائمی: + +```bash +.venv/Scripts/python.exe -m pytest tests/test_payment_notice_deletion.py tests/test_payment_notice_concurrency.py tests/e2e/test_notice_deletion_e2e.py -q +``` + +تست هم‌زمانی فقط با دیتابیس تست PostgreSQL اجرا می‌شود؛ SQLite صحت قفل ردیف را اثبات نمی‌کند. تست‌های دیگر و مرورگر از دیتابیس جداگانه و provider ساختگی استفاده می‌کنند و پیامک واقعی نمی‌فرستند. diff --git a/templates/payments/notice_detail.html b/templates/payments/notice_detail.html index d61a790..cac8ffd 100644 --- a/templates/payments/notice_detail.html +++ b/templates/payments/notice_detail.html @@ -11,6 +11,7 @@ {% if can_share_payment_link %}

لینک موقت پرداخت

برای مشاهده اعلامیه و ارسال فیش، مشتری نیازی به ورود ندارد. پس از پایان اعتبار، می‌توانید لینک تازه بسازید.

{% if payment_share_url %}

{{ payment_share_url }}

{% endif %}
{% csrf_token %}
{% endif %}

فیش‌های این اعلامیه

{% for receipt in receipts %}{{ receipt.number }}{{ receipt.get_status_display }}{% include 'payments/_icon.html' with name='arrow' %}{% empty %}

هنوز فیشی برای این اعلامیه ثبت نشده است.

{% endfor %}

اطلاع‌رسانی پیامک

{% for notification in notifications %}
{{ notification.job.get_status_display }}وضعیت دریافت: {{ notification.job.get_delivery_status_display }}
{% empty %}

اطلاع‌رسانی ثبت‌شده‌ای وجود ندارد.

{% endfor %}
+ {% if can_delete_notice %}
حذف اعلامیه واریز
{% csrf_token %}

آیا از حذف اعلامیه «{{ notice.subject }}» با شماره {{ notice.number }} مطمئن هستید؟ پس از حذف، اعلامیه از فهرست‌ها خارج می‌شود و لینک‌های آن دیگر قابل استفاده نیستند. این اقدام از پنل قابل بازگشت نیست.

حذف فقط تا زمانی مجاز است که هیچ فیشی برای این اعلامیه ثبت نشده باشد.

{% elif may_delete_notice %}

حذف اعلامیه

برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.

{% endif %} diff --git a/tests/e2e/pages/notice_deletion.py b/tests/e2e/pages/notice_deletion.py new file mode 100644 index 0000000..e8362d9 --- /dev/null +++ b/tests/e2e/pages/notice_deletion.py @@ -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('اعلامیه واریز حذف شد') diff --git a/tests/e2e/test_notice_deletion_e2e.py b/tests/e2e/test_notice_deletion_e2e.py new file mode 100644 index 0000000..4e075d3 --- /dev/null +++ b/tests/e2e/test_notice_deletion_e2e.py @@ -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') diff --git a/tests/test_payment_notice_concurrency.py b/tests/test_payment_notice_concurrency.py new file mode 100644 index 0000000..237a3b2 --- /dev/null +++ b/tests/test_payment_notice_concurrency.py @@ -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 diff --git a/tests/test_payment_notice_deletion.py b/tests/test_payment_notice_deletion.py new file mode 100644 index 0000000..6e1ae1d --- /dev/null +++ b/tests/test_payment_notice_deletion.py @@ -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'])