import pytest from django.contrib.auth.models import Permission from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse 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, 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 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_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_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): 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) 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=3; job.last_error="provider"; job.save() refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db() assert dispatch.status==GroupDispatch.Status.PARTIAL 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])).status_code==302 job.refresh_from_db(); dispatch.refresh_from_db() assert (job.status,job.attempts,job.last_error)==(NotificationJob.Status.PENDING,0,"") assert dispatch.status==GroupDispatch.Status.QUEUED @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