import base64 from datetime import timedelta import pytest from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from django.utils import timezone from apps.accounts.models import MagicLinkAccessLog, TicketMagicLink, User from apps.attachments.models import AttachmentPolicy from apps.customers.models import ContactGroup, CustomerProfile from apps.notifications.models import NotificationJob from apps.staff.models import OrganizationalUnit, StaffProfile from apps.tickets.models import GroupDispatch, Ticket, TicketCategory, TicketEvent, TicketTag from apps.tickets.policies import TicketPolicy from apps.tickets.selectors import staff_ticket_rows from apps.tickets.services import create_ticket, dispatch_group_tickets, refresh_group_dispatch_status @pytest.mark.django_db def test_dispatch_owner_can_resolve_all_open_group_tickets_from_report_or_ticket(client): unit = OrganizationalUnit.objects.create(name="حل گروهی", slug="resolve-dispatch") unit.permissions.add(*Permission.objects.filter( content_type__app_label="tickets", codename__in=("view_ticket", "dispatch_group_tickets", "change_ticket"), )) staff = make_staff("dispatch-resolver", unit) first = make_contact("resolve-first", "09120000401") second = make_contact("resolve-second", "09120000402") group = ContactGroup.objects.create(name="گروه قابل حل", unit=unit) first.customer_profile.groups.add(group) second.customer_profile.groups.add(group) category = TicketCategory.objects.create(unit=unit, title="پیگیری گروه", slug="resolve-group") dispatch, tickets = dispatch_group_tickets( user=staff, unit=unit, contact_group=group, category=category, subject="پیگیری پاسخ گروه", body="لطفاً پاسخ دهید.", ) tickets[1].status = Ticket.Status.CLOSED tickets[1].closed_at = timezone.now() tickets[1].save(update_fields=["status", "closed_at", "updated_at"]) client.force_login(staff) report = client.get(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id])) report_text = report.content.decode() assert report.context["open_ticket_count"] == 1 assert "حل‌شدن همه تیکت‌ها (1)" in report_text ticket_page = client.get(reverse("staff_tickets:detail", args=[tickets[0].public_id])) assert "ارسال گروهی" in ticket_page.content.decode() assert dispatch.subject in ticket_page.content.decode() assert "حل‌شدن تمامی تیکت‌های گروه (1)" in ticket_page.content.decode() response = client.post( reverse("staff_tickets:dispatch_resolve", args=[dispatch.public_id]), {"return_ticket": tickets[0].public_id}, ) assert response.status_code == 302 assert response.url == reverse("staff_tickets:detail", args=[tickets[0].public_id]) tickets[0].refresh_from_db() tickets[1].refresh_from_db() assert tickets[0].status == Ticket.Status.RESOLVED assert tickets[0].resolved_at is not None assert tickets[1].status == Ticket.Status.CLOSED assert TicketEvent.objects.filter( ticket=tickets[0], event_type=TicketEvent.Type.STATUS_CHANGED, metadata__group_dispatch_id=dispatch.pk, ).exists() @pytest.mark.django_db def test_dispatch_bulk_resolve_requires_change_ticket_permission(client): unit = OrganizationalUnit.objects.create(name="گروه فقط مشاهده", slug="read-only-dispatch") unit.permissions.add(*Permission.objects.filter( content_type__app_label="tickets", codename__in=("view_ticket", "dispatch_group_tickets") )) staff = make_staff("read-only-dispatcher", unit) customer = make_contact("read-only-recipient", "09120000403") group = ContactGroup.objects.create(name="گروه فقط خواندنی", unit=unit) customer.customer_profile.groups.add(group) category = TicketCategory.objects.create(unit=unit, title="فقط مشاهده", slug="read-only-group") dispatch, tickets = dispatch_group_tickets( user=staff, unit=unit, contact_group=group, category=category, subject="عملیات فقط مشاهده", body="متن عملیات", ) client.force_login(staff) assert client.get(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id])).context["can_resolve_dispatch"] is False client.post(reverse("staff_tickets:dispatch_resolve", args=[dispatch.public_id])) tickets[0].refresh_from_db() assert tickets[0].status != Ticket.Status.RESOLVED def make_staff(username, unit): user = User.objects.create_user(username=username, account_type=User.AccountType.STAFF) StaffProfile.objects.create(user=user, display_name=username, primary_unit=unit) return user def make_contact(username, mobile): user = User.objects.create_user(username=username, mobile=mobile, account_type=User.AccountType.CUSTOMER) CustomerProfile.objects.create(user=user, first_name=username) return user @pytest.mark.django_db def test_unit_permission_isolates_ticket_queries_and_object_access(): sales = OrganizationalUnit.objects.create(name="فروش ایزوله", slug="isolated-sales") logistics = OrganizationalUnit.objects.create(name="بارگیری ایزوله", slug="isolated-logistics") view_permission = Permission.objects.get(codename="view_ticket", content_type__app_label="tickets") sales.permissions.add(view_permission) logistics.permissions.add(view_permission) sales_staff = make_staff("sales-agent", sales) logistics_staff = make_staff("logistics-agent", logistics) customer = make_contact("unit-customer", "09120000101") category = TicketCategory.objects.create(title="عمومی", slug="general") sales_ticket = create_ticket(customer=customer, created_by=sales_staff, unit=sales, category=category, subject="تیکت واحد فروش", body="شرح") assert TicketPolicy.can_view(sales_staff, sales_ticket) assert not TicketPolicy.can_view(logistics_staff, sales_ticket) assert list(staff_ticket_rows(logistics_staff)) == [] @pytest.mark.django_db def test_group_dispatch_creates_one_isolated_ticket_and_sms_job_per_contact(): unit = OrganizationalUnit.objects.create(name="لوجستیک ارسال گروهی", slug="dispatch-logistics") permission = Permission.objects.get(codename="dispatch_group_tickets", content_type__app_label="tickets") view_permission = Permission.objects.get(codename="view_ticket", content_type__app_label="tickets") unit.permissions.add(permission, view_permission) staff = make_staff("dispatcher", unit) first = make_contact("carrier-one", "09120000111") second = make_contact("carrier-two", "09120000112") group = ContactGroup.objects.create(name="باربری‌های گلپایگان", unit=unit) first.customer_profile.groups.add(group) second.customer_profile.groups.add(group) category = TicketCategory.objects.create(unit=unit,title="استعلام حمل", slug="test-shipping-quote") tag = TicketTag.objects.create(name="استعلام قیمت", slug="price-inquiry") dispatch, tickets = dispatch_group_tickets(user=staff, unit=unit, contact_group=group, category=category, subject="استعلام قیمت حمل", body="لطفاً قیمت پیشنهادی را ثبت کنید.", tags=[tag]) # Creation only queues delivery; reporting promotes it after SMS succeeds. assert dispatch.status == GroupDispatch.Status.QUEUED assert dispatch.recipient_count == 2 assert len(tickets) == 2 assert {ticket.customer_id for ticket in tickets} == {first.pk, second.pk} assert all(ticket.delivery_type == Ticket.DeliveryType.GROUP for ticket in tickets) assert NotificationJob.objects.filter(ticket__in=tickets, channel="SMS", event_type="STAFF_TICKET_CREATED").count() == 2 @pytest.mark.django_db def test_staff_created_ticket_sms_uses_its_actual_organizational_unit(monkeypatch, settings): from apps.integrations.sms.base import SMSResult from apps.notifications.management.commands import process_notifications settings.PUBLIC_BASE_URL = "https://support.example.com" finance = OrganizationalUnit.objects.create(name="حسابداری", slug="sms-finance") staff = make_staff("finance-sms-agent", finance) customer = make_contact("finance-sms-customer", "09120000501") category = TicketCategory.objects.create(unit=finance, title="اسناد مالی", slug="sms-financial-documents") ticket = create_ticket( customer=customer, created_by=staff, unit=finance, category=category, subject="ارسال سند مالی", body="لطفاً سند را بررسی کنید.", ) captured = {} class Provider: def send_message(self, mobile, message): captured.update(mobile=mobile, message=message) return SMSResult(True, "sent") monkeypatch.setattr(process_notifications, "get_sms_provider", lambda: Provider()) process_notifications._send_sms(NotificationJob.objects.get(ticket=ticket)) assert "توسط واحد حسابداری برای شما ثبت شد" in captured["message"] assert "توسط واحد فروش" not in captured["message"] @pytest.mark.django_db def test_group_dispatch_sms_uses_dispatch_organizational_unit(monkeypatch, settings): from apps.integrations.sms.base import SMSResult from apps.notifications.management.commands import process_notifications settings.PUBLIC_BASE_URL = "https://support.example.com" warehouse = OrganizationalUnit.objects.get(name="بارگیری انبار") warehouse.permissions.add(Permission.objects.get(codename="dispatch_group_tickets", content_type__app_label="tickets")) staff = make_staff("warehouse-sms-agent", warehouse) customer = make_contact("warehouse-sms-customer", "09120000502") group = ContactGroup.objects.create(name="رانندگان", unit=warehouse) customer.customer_profile.groups.add(group) category = TicketCategory.objects.create(unit=warehouse, title="هماهنگی بار", slug="sms-loading") _, tickets = dispatch_group_tickets( user=staff, unit=warehouse, contact_group=group, category=category, subject="هماهنگی بارگیری", body="زمان بارگیری را اعلام کنید.", ) captured = [] class Provider: def send_message(self, mobile, message): captured.append(message) return SMSResult(True, "sent") monkeypatch.setattr(process_notifications, "get_sms_provider", lambda: Provider()) process_notifications._send_sms(NotificationJob.objects.get(ticket=tickets[0])) assert "توسط واحد بارگیری انبار برای شما ثبت شد" in captured[0] assert "توسط واحد فروش" not in captured[0] @pytest.mark.django_db def test_group_dispatch_copies_shared_document_into_each_private_ticket(): unit=OrganizationalUnit.objects.create(name="مالی گروهی",slug="bulk-finance") unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets",content_type__app_label="tickets")) staff=make_staff("bulk-finance-user",unit) first=make_contact("bulk-supplier-one","09120000311"); second=make_contact("bulk-supplier-two","09120000312") group=ContactGroup.objects.create(name="تامین‌کنندگان اسناد",unit=unit) first.customer_profile.groups.add(group); second.customer_profile.groups.add(group) category=TicketCategory.objects.create(title="سند گروهی",slug="bulk-document") AttachmentPolicy.objects.create(attachment_type="PDF",max_file_size=1024*1024,allowed_extensions=[".pdf"]) upload=SimpleUploadedFile("receipt.pdf",b"%PDF-1.4\n%%EOF",content_type="application/pdf") _,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="ارسال سند مالی مشترک",body="سند را بررسی کنید.",files=[upload]) assert len(tickets)==2 assert all(ticket.ticket_messages.get().attachments.filter(original_name="receipt.pdf").count()==1 for ticket in tickets) paths=[ticket.ticket_messages.get().attachments.get().file.name for ticket in tickets] assert len(set(paths))==2 @pytest.mark.django_db def test_dispatch_page_supports_professional_multi_attachment_picker_and_submission(client): unit=OrganizationalUnit.objects.create(name="واحد پیوست گروهی",slug="dispatch-attachment-unit") unit.permissions.add(Permission.objects.get(codename="dispatch_group_tickets",content_type__app_label="tickets")) staff=make_staff("dispatch-attachment-staff",unit) customer=make_contact("dispatch-attachment-customer","09120000313") group=ContactGroup.objects.create(name="گروه پیوست",unit=unit) customer.customer_profile.groups.add(group) category=TicketCategory.objects.create(unit=unit,title="اسناد گروهی",slug="dispatch-attachments-category") AttachmentPolicy.objects.update_or_create(attachment_type="IMAGE",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".png"],"allowed_mime_types":["image/png"]}) AttachmentPolicy.objects.update_or_create(attachment_type="PDF",defaults={"max_file_size":1024*1024,"max_files_per_message":10,"allowed_extensions":[".pdf"],"allowed_mime_types":["application/pdf"]}) client.force_login(staff) url=reverse("staff_tickets:dispatch_create") page=client.get(url) content=page.content.decode() assert page.status_code==200 for marker in ("data-ticket-composer","data-gallery-input","data-document-input","data-file-preview","attachment-uploader.js"): assert marker in content assert "افزودن عکس از گالری" in content and "افزودن فایل" in content response=client.post(url,{ "operation_type":GroupDispatch.OperationType.BULK,"unit":unit.pk,"contact_group":group.pk, "category":category.pk,"subject":"ارسال چند پیوست گروهی","body":"تصویر و سند را بررسی کنید.", "files":[ SimpleUploadedFile("preview.png",base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="),content_type="image/png"), SimpleUploadedFile("document.pdf",b"%PDF-1.4\n%%EOF",content_type="application/pdf"), ], }) assert response.status_code==302, response.context["form"].errors.as_json() if response.context else response.content.decode() ticket=Ticket.objects.get(subject="ارسال چند پیوست گروهی") assert set(ticket.ticket_messages.get().attachments.values_list("original_name",flat=True))=={"preview.png","document.pdf"} @pytest.mark.django_db def test_magic_link_records_every_open_with_ip_and_user_agent(client): unit = OrganizationalUnit.objects.create(name="حسابداری ممیزی", slug="audit-accounting") staff = make_staff("accountant", unit) customer = make_contact("supplier", "09120000121") category = TicketCategory.objects.create(title="سند مالی", slug="financial-document") ticket = create_ticket(customer=customer, created_by=staff, unit=unit, category=category, subject="ارسال رسید مالی", body="رسید پیوست شد.") token, link = TicketMagicLink.issue_with_record(ticket, created_by=staff) response = client.get(reverse("accounts:magic", args=[token]), HTTP_USER_AGENT="Audit Browser/1.0", REMOTE_ADDR="203.0.113.7") assert response.status_code == 302 link.refresh_from_db() assert link.view_count == 1 assert link.last_used_ip == "203.0.113.7" assert link.last_user_agent == "Audit Browser/1.0" assert link.created_by == staff assert link.created_unit == unit assert MagicLinkAccessLog.objects.filter(magic_link=link, ip_address="203.0.113.7").count() == 1 @pytest.mark.django_db def test_dispatch_pages_are_available_only_to_the_dispatch_unit(client): unit = OrganizationalUnit.objects.create(name="انبار دسترسی", slug="access-warehouse") other_unit = OrganizationalUnit.objects.create(name="فروش دسترسی تست", slug="sales-2") for codename in ("view_ticket", "dispatch_group_tickets"): permission = Permission.objects.get(codename=codename, content_type__app_label="tickets") unit.permissions.add(permission) staff = make_staff("warehouse-user", unit) outsider = make_staff("sales-user", other_unit) customer = make_contact("warehouse-contact", "09120000131") group = ContactGroup.objects.create(name="تامین‌کنندگان", unit=unit) customer.customer_profile.groups.add(group) category = TicketCategory.objects.create(unit=unit, title="موجودی", slug="test-inventory-access") client.force_login(staff) assert client.get(reverse("staff_tickets:dispatch_create")).status_code == 200 response = client.post(reverse("staff_tickets:dispatch_create"), {"unit": unit.pk, "contact_group": group.pk, "category": category.pk, "subject": "استعلام موجودی کالا", "body": "موجودی را اعلام کنید."}) assert response.status_code == 302 dispatch = GroupDispatch.objects.get() assert response.url == reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id]) client.force_login(outsider) assert client.get(reverse("staff_tickets:dispatch_detail", args=[dispatch.public_id])).status_code == 403 @pytest.mark.django_db def test_staff_operations_pages_respect_unit_scoped_permissions(client): unit = OrganizationalUnit.objects.create(name="عملیات", slug="operations") other = OrganizationalUnit.objects.create(name="واحد محرمانه", slug="private-unit") codenames = [ ("customers", "view_customerprofile"), ("customers", "add_customerprofile"), ("customers", "view_contactgroup"), ("customers", "add_contactgroup"), ("accounts", "view_ticketmagiclink"), ("notifications", "view_smstemplate"), ("notifications", "add_smstemplate"), ] unit.permissions.add(*[Permission.objects.get(content_type__app_label=app, codename=code) for app, code in codenames]) staff = make_staff("operations-agent", unit) visible = make_contact("visible-contact", "09120000201") hidden = make_contact("hidden-contact", "09120000202") visible_group = ContactGroup.objects.create(name="گروه مجاز", unit=unit) hidden_group = ContactGroup.objects.create(name="گروه محرمانه", unit=other) visible.customer_profile.groups.add(visible_group) hidden.customer_profile.groups.add(hidden_group) client.force_login(staff) response = client.get(reverse("staff_tickets:contacts")) assert response.status_code == 200 assert "visible-contact" in response.content.decode() assert "hidden-contact" not in response.content.decode() assert client.get(reverse("staff_tickets:groups")).status_code == 200 assert client.get(reverse("staff_tickets:magic_audit")).status_code == 200 assert client.get(reverse("staff_tickets:sms_templates")).status_code == 200 @pytest.mark.django_db def test_superuser_without_staff_profile_can_open_all_staff_contacts(client): unit = OrganizationalUnit.objects.create(name="واحد مخاطبان مدیر", slug="admin-contacts-unit") contact = make_contact("admin-visible-contact", "09120000503") group = ContactGroup.objects.create(name="گروه قابل مشاهده مدیر", unit=unit) contact.customer_profile.groups.add(group) User.objects.create_user( username="customer-without-profile", mobile="09120000504", account_type=User.AccountType.CUSTOMER, ) admin = User.objects.create_superuser( username="contacts-admin", password="safe-password", account_type=User.AccountType.STAFF, ) client.force_login(admin) response = client.get(reverse("staff_tickets:contacts")) assert response.status_code == 200 assert "admin-visible-contact" in response.content.decode() assert "customer-without-profile" not in response.content.decode() @pytest.mark.django_db def test_staff_can_visually_add_and_remove_contact_group_memberships(client): unit = OrganizationalUnit.objects.create(name="واحد ویرایش مخاطب", slug="contact-membership-editor") unit.permissions.add(*Permission.objects.filter( content_type__app_label="customers", codename__in=("view_customerprofile", "change_customerprofile"), )) staff = make_staff("contact-membership-agent", unit) contact = make_contact("editable-contact", "09120000505") first = ContactGroup.objects.create(name="گروه اولیه", unit=unit) second = ContactGroup.objects.create(name="گروه جایگزین", unit=unit) contact.customer_profile.groups.add(first) client.force_login(staff) url = reverse("staff_tickets:contact_edit", args=[contact.public_id]) page = client.get(url) content = page.content.decode() assert page.status_code == 200 assert "data-contact-group-selector" in content assert "data-group-search" in content assert str(first.pk) in {str(value) for value in page.context["form"]["groups"].value()} assert f'value="{second.pk}"' in content response = client.post(url, { "mobile": contact.local_mobile, "first_name": "مخاطب", "last_name": "ویرایش‌شده", "organization_name": "", "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, "groups": [second.pk], "province": "", "city": "", }) assert response.status_code == 302 assert set(contact.customer_profile.groups.values_list("pk", flat=True)) == {second.pk} @pytest.mark.django_db def test_staff_can_classify_and_edit_ticket_customer_without_an_existing_group(client): unit = OrganizationalUnit.objects.create(name="واحد مشتری خودکار", slug="ungrouped-customer-editor") unit.permissions.add(*Permission.objects.filter( content_type__app_label="customers", codename__in=("view_customerprofile", "change_customerprofile"), )) staff = make_staff("ungrouped-customer-agent", unit) contact = make_contact("ungrouped-ticket-customer", "09120000515") category = TicketCategory.objects.create(unit=unit, title="درخواست مشتری خودکار", slug="ungrouped-customer-ticket") create_ticket(customer=contact, created_by=staff, unit=unit, category=category, subject="نیازمند دسته‌بندی", body="شرح") group = ContactGroup.objects.create(name="گروه انتخاب‌شده", unit=unit) client.force_login(staff) url = reverse("staff_tickets:contact_edit", args=[contact.public_id]) page = client.get(url) assert page.status_code == 200 assert f'value="{group.pk}"' in page.content.decode() missing_group = client.post(url, { "mobile": contact.local_mobile, "first_name": "مشتری", "last_name": "خودکار", "organization_name": "", "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, "groups": [], "province": "", "city": "", }) assert missing_group.status_code == 200 assert missing_group.context["form"]["groups"].errors saved = client.post(url, { "mobile": contact.local_mobile, "first_name": "مشتری", "last_name": "دسته‌بندی‌شده", "organization_name": "", "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, "groups": [group.pk], "province": "", "city": "", }) assert saved.status_code == 302 assert contact.customer_profile.groups.filter(pk=group.pk).exists() @pytest.mark.django_db def test_staff_cannot_edit_ungrouped_customer_from_an_unrelated_ticket_unit(client): ticket_unit = OrganizationalUnit.objects.create(name="واحد مالک مشتری", slug="ungrouped-owner-unit") unrelated_unit = OrganizationalUnit.objects.create(name="واحد نامرتبط مشتری", slug="ungrouped-unrelated-unit") unrelated_unit.permissions.add(*Permission.objects.filter( content_type__app_label="customers", codename__in=("view_customerprofile", "change_customerprofile"), )) owner = make_staff("ungrouped-owner-agent", ticket_unit) outsider = make_staff("ungrouped-outsider-agent", unrelated_unit) contact = make_contact("private-ungrouped-customer", "09120000516") category = TicketCategory.objects.create(unit=ticket_unit, title="درخواست محرمانه", slug="private-ungrouped-ticket") create_ticket(customer=contact, created_by=owner, unit=ticket_unit, category=category, subject="محرمانه", body="شرح") client.force_login(outsider) assert client.get(reverse("staff_tickets:contact_edit", args=[contact.public_id])).status_code == 404 @pytest.mark.django_db def test_staff_ticket_create_supports_multiple_previewable_attachments(client, monkeypatch, settings, tmp_path): settings.MEDIA_ROOT = tmp_path unit = OrganizationalUnit.objects.create(name="واحد پیوست کارشناس", slug="staff-attachment-unit") unit.permissions.add(Permission.objects.get(content_type__app_label="tickets", codename="add_ticket")) staff = make_staff("staff-attachment-agent", unit) category = TicketCategory.objects.create(unit=unit, title="مدارک", slug="staff-attachment-category") AttachmentPolicy.objects.create(attachment_type="IMAGE", max_file_size=1024 * 1024, allowed_extensions=[".jpg"]) AttachmentPolicy.objects.create(attachment_type="PDF", max_file_size=1024 * 1024, allowed_extensions=[".pdf"]) monkeypatch.setattr( "apps.attachments.services.magic.from_buffer", lambda content, mime=True: "image/jpeg" if content.startswith(b"\xff\xd8") else "application/pdf", ) client.force_login(staff) url = reverse("staff_tickets:create") page = client.get(url) content = page.content.decode() assert page.status_code == 200 assert "data-ticket-composer" in content assert "data-gallery-input" in content assert "data-document-input" in content assert "data-file-preview" in content assert "attachment-uploader.js" in content assert "ایجاد پرونده از طرف واحد پیوست کارشناس" in content response = client.post(url, { "unit": unit.pk, "customer_mobile": "09120000506", "first_name": "مشتری", "last_name": "پیوست", "stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER, "category": category.pk, "subject": "ارسال همزمان تصویر و سند", "body": "دو فایل برای این پرونده ثبت می‌شود.", "files": [ SimpleUploadedFile("photo.jpg", b"\xff\xd8\xff\xe0image", content_type="image/jpeg"), SimpleUploadedFile("document.pdf", b"%PDF-1.4\n%%EOF", content_type="application/pdf"), ], }) assert response.status_code == 302 ticket = Ticket.objects.get(subject="ارسال همزمان تصویر و سند") assert set(ticket.ticket_messages.get().attachments.values_list("original_name", flat=True)) == { "photo.jpg", "document.pdf", } @pytest.mark.django_db def test_unit_scoped_add_ticket_preserves_contact_ownership_and_stakeholder(client): unit=OrganizationalUnit.objects.create(name="حسابداری عملیاتی",slug="accounting-ops") unit.permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="add_ticket")) staff=make_staff("unit-accountant",unit) group=ContactGroup.objects.create(name="تامین‌کنندگان مالی",unit=unit) category=TicketCategory.objects.create(title="رسید مالی",slug="receipt-ops") client.force_login(staff) response=client.post(reverse("staff_tickets:create"),{ "unit":unit.pk,"customer_mobile":"09120000301","first_name":"تامین‌کننده","last_name":"نمونه", "organization_name":"شرکت نمونه","stakeholder_type":CustomerProfile.StakeholderType.SUPPLIER, "groups":[group.pk],"category":category.pk,"subject":"ارسال رسید پرداخت","body":"رسید مالی پیوست خواهد شد.", }) assert response.status_code==302 ticket=Ticket.objects.get(customer__mobile="+989120000301") assert ticket.owning_unit==unit and ticket.created_by==staff assert ticket.customer.customer_profile.stakeholder_type==CustomerProfile.StakeholderType.SUPPLIER assert ticket.customer.customer_profile.groups.filter(pk=group.pk).exists() @pytest.mark.django_db def test_dispatch_retry_is_unit_scoped_and_refreshes_persistent_status(client): from apps.core.templatetags.jalali import jalali_datetime unit=OrganizationalUnit.objects.create(name="لوجستیک retry",slug="logistics-retry") other=OrganizationalUnit.objects.create(name="فروش retry",slug="sales-retry") permission=Permission.objects.get(content_type__app_label="tickets",codename="dispatch_group_tickets") unit.permissions.add(permission,Permission.objects.get(content_type__app_label="tickets",codename="view_ticket")) staff=make_staff("retry-dispatcher",unit); outsider=make_staff("retry-outsider",other) contact=make_contact("retry-carrier","09120000302") group=ContactGroup.objects.create(name="باربری retry",unit=unit); contact.customer_profile.groups.add(group) category=TicketCategory.objects.create(title="استعلام retry",slug="quote-retry") dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="استعلام هزینه جدید",body="قیمت را ثبت کنید.") job=NotificationJob.objects.get(ticket=tickets[0],channel="SMS") job.status=NotificationJob.Status.FAILED; job.attempts=4; job.last_error="0098SMS [کد -19]: شارژ پنل برای ارسال کافی نیست."; job.provider_status_code="-19"; job.save() for _ in range(4): TicketMagicLink.issue_with_record(tickets[0],reason="staff_created_ticket",created_by=staff) # Supervisor alerts are separate from the recipient message and must not # alter dispatch metrics or become the row's displayed/retryable SMS. NotificationJob.objects.create( channel=NotificationJob.Channel.SMS,event_type="UNIT_SUPERVISOR_NEW_TICKET", ticket=tickets[0],customer=tickets[0].customer,payload={"recipient_mobile":"09120000999"}, status=NotificationJob.Status.SENT,attempts=1,next_attempt_at=timezone.now(), dedupe_key=f"test-supervisor-{tickets[0].pk}", ) refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db() assert dispatch.status==GroupDispatch.Status.PARTIAL client.force_login(staff) detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id])) content=detail.content.decode() assert detail.status_code==200 assert "قیمت را ثبت کنید." in content assert "متن اولیه کارشناس" in content assert "کد -19" in content and "شارژ پنل" in content assert "جزئیات فنی و رهگیری" in content assert jalali_datetime(dispatch.created_at) in content assert dispatch.created_at.strftime("%Y/%m/%d %H:%M") not in content assert "0 + 0" not in content assert "مشاهده نشده" in content assert detail.context["tickets"][0].sms_job.pk==job.pk assert detail.context["metrics"]["failed"]==1 ticket_detail=client.get(reverse("staff_tickets:detail",args=[tickets[0].public_id])) assert ticket_detail.status_code==200 assert "علت دقیق" in ticket_detail.content.decode() and "کد -19" in ticket_detail.content.decode() client.force_login(outsider) assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id])).status_code==403 client.force_login(staff) assert client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk}).status_code==302 job.refresh_from_db(); dispatch.refresh_from_db() assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"") assert job.provider_status_code=="" and job.delivery_status=="" assert dispatch.status==GroupDispatch.Status.QUEUED @pytest.mark.django_db def test_provisional_delivery_is_tracking_not_failure_or_manual_retry(client): unit=OrganizationalUnit.objects.create(name="پیگیری دلیوری",slug="delivery-tracking-unit") unit.permissions.add(*Permission.objects.filter(content_type__app_label="tickets",codename__in=("view_ticket","dispatch_group_tickets"))) staff=make_staff("delivery-tracking-agent",unit) contact=make_contact("delivery-tracking-contact","09120000322") group=ContactGroup.objects.create(name="گروه پیگیری",unit=unit); contact.customer_profile.groups.add(group) category=TicketCategory.objects.create(unit=unit,title="پیگیری",slug="delivery-tracking-category") dispatch,tickets=dispatch_group_tickets(user=staff,unit=unit,contact_group=group,category=category,subject="پیگیری تحویل",body="متن") job=NotificationJob.objects.get(ticket=tickets[0],event_type="STAFF_TICKET_CREATED") NotificationJob.objects.filter(pk=job.pk).update( status=NotificationJob.Status.SENT,attempts=1,sent_at=timezone.now(),provider_message_id="123456789", delivery_status=NotificationJob.DeliveryStatus.UNKNOWN,delivery_status_code="8", delivery_next_check_at=timezone.now()+timedelta(minutes=3),delivery_check_attempts=1, ) refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db() assert dispatch.status==GroupDispatch.Status.SENT client.force_login(staff) detail=client.get(reverse("staff_tickets:dispatch_detail",args=[dispatch.public_id])) assert detail.context["metrics"]["tracking"]==1 and detail.context["metrics"]["failed"]==0 assert detail.context["tickets"][0].sms_retryable is False client.post(reverse("staff_tickets:dispatch_retry",args=[dispatch.public_id]),{"job_id":job.pk}) job.refresh_from_db() assert job.status==NotificationJob.Status.SENT and job.delivery_status==NotificationJob.DeliveryStatus.UNKNOWN @pytest.mark.django_db def test_magic_link_can_be_revoked_only_with_unit_permission(client): unit=OrganizationalUnit.objects.create(name="حسابرسی",slug="audit-unit") other=OrganizationalUnit.objects.create(name="واحد دیگر audit",slug="audit-other") view=Permission.objects.get(content_type__app_label="accounts",codename="view_ticketmagiclink") change=Permission.objects.get(content_type__app_label="accounts",codename="change_ticketmagiclink") unit.permissions.add(view,change) auditor=make_staff("unit-auditor",unit); outsider=make_staff("audit-outsider",other) customer=make_contact("audit-contact","09120000303") category=TicketCategory.objects.create(title="ممیزی",slug="audit-revoke") ticket=create_ticket(customer=customer,created_by=auditor,unit=unit,category=category,subject="سند قابل ممیزی",body="سند") _,link=TicketMagicLink.issue_with_record(ticket,created_by=auditor) client.force_login(outsider) assert client.post(reverse("staff_tickets:magic_revoke",args=[link.pk])).status_code==403 client.force_login(auditor) assert client.post(reverse("staff_tickets:magic_revoke",args=[link.pk])).status_code==302 link.refresh_from_db(); assert link.revoked_at is not None