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

@@ -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
+1
View File
@@ -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')
@@ -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),
),
]
+1
View File
@@ -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']
+23 -1
View File
@@ -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':
+23
View File
@@ -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)
+1
View File
@@ -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/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/delete/', views.notice_delete, name='notice_delete'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
+21 -1
View File
@@ -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):
+19
View File
@@ -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 ساختگی استفاده می‌کنند و پیامک واقعی نمی‌فرستند.
+1
View File
@@ -11,6 +11,7 @@
{% if can_share_payment_link %}<section class="pay-card"><h2>لینک موقت پرداخت</h2><p class="pay-section-help">برای مشاهده اعلامیه و ارسال فیش، مشتری نیازی به ورود ندارد. پس از پایان اعتبار، می‌توانید لینک تازه بسازید.</p>{% if payment_share_url %}<p class="pay-share-url" dir="ltr">{{ payment_share_url }}</p><button type="button" class="pay-button secondary" data-copy="{{ payment_share_url }}">کپی لینک پرداخت</button>{% endif %}<form method="post" action="{% url 'staff_payments:notice_payment_link' notice.public_id %}">{% csrf_token %}<button class="pay-button" type="submit">ساخت لینک تازه پرداخت</button></form></section>{% endif %}
<section class="pay-card"><h2>فیش‌های این اعلامیه</h2>{% for receipt in receipts %}<a class="pay-linked-record" href="{% url 'staff_payments:receipt_detail' receipt.public_id %}"><bdi>{{ receipt.number }}</bdi><span class="pay-status {{ receipt.status|lower }}">{{ receipt.get_status_display }}</span>{% include 'payments/_icon.html' with name='arrow' %}</a>{% empty %}<p class="pay-section-help">هنوز فیشی برای این اعلامیه ثبت نشده است.</p>{% endfor %}</section>
<section class="pay-card"><h2>اطلاع‌رسانی پیامک</h2>{% for notification in notifications %}<div class="pay-notification-row"><strong>{{ notification.job.get_status_display }}</strong><span>وضعیت دریافت: {{ notification.job.get_delivery_status_display }}</span></div>{% empty %}<p class="pay-section-help">اطلاع‌رسانی ثبت‌شده‌ای وجود ندارد.</p>{% endfor %}</section>
{% if can_delete_notice %}<details class="pay-delete-panel"><summary>حذف اعلامیه واریز</summary><form method="post" action="{% url 'staff_payments:notice_delete' notice.public_id %}">{% csrf_token %}<p role="note">آیا از حذف اعلامیه «{{ notice.subject }}» با شماره {{ notice.number }} مطمئن هستید؟ پس از حذف، اعلامیه از فهرست‌ها خارج می‌شود و لینک‌های آن دیگر قابل استفاده نیستند. این اقدام از پنل قابل بازگشت نیست.</p><p>حذف فقط تا زمانی مجاز است که هیچ فیشی برای این اعلامیه ثبت نشده باشد.</p><label><input type="checkbox" name="confirm" value="delete" required> هشدار را خواندم و حذف این اعلامیه را تأیید می‌کنم.</label><div class="pay-actions"><button class="pay-button pay-danger" type="submit">تأیید و حذف اعلامیه</button></div></form></details>{% elif may_delete_notice %}<section class="pay-card"><h2>حذف اعلامیه</h2><p class="pay-section-help">برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.</p></section>{% endif %}
</aside>
</div>
</section>
+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'])