Files
customerTicket/tests/test_organizational_ticketing.py
T
2026-09-29 10:30:54 +03:30

774 lines
42 KiB
Python

import base64
from datetime import timedelta
from importlib import import_module
from types import SimpleNamespace
import pytest
from django.apps import apps as django_apps
from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection
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 ContactEditLog, 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_contacts_are_shared_while_groups_remain_unit_scoped(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" in response.content.decode()
assert client.get(reverse("staff_tickets:contact_edit", args=[hidden.public_id])).status_code == 200
assert hidden_group.name 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)
incomplete = 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 "09120000504" in response.content.decode()
searched = client.get(reverse("staff_tickets:contacts"), {"q": "09120000504"})
assert searched.status_code == 200
assert str(incomplete.public_id) in searched.content.decode()
assert client.get(reverse("staff_tickets:contact_edit", args=[incomplete.public_id])).status_code == 200
assert CustomerProfile.objects.filter(user=incomplete).exists()
@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 == 302
assert not contact.customer_profile.groups.exists()
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_legacy_ungrouped_contact_is_searchable_editable_and_audited(client):
unit = OrganizationalUnit.objects.create(name="واحد جست‌وجوی مخاطب", slug="shared-contact-search")
staff = make_staff("shared-contact-editor", unit)
contact = make_contact("legacy-ungrouped-contact", "09120000561")
incomplete = User.objects.create_user(username="legacy-without-profile", mobile="09120000564", first_name="نام قدیمی ناقص", account_type=User.AccountType.CUSTOMER)
edit_url = reverse("staff_tickets:contact_edit", args=[contact.public_id])
client.force_login(staff)
for query in ("legacy-ungrouped-contact", "09120000561"):
result = client.get(reverse("staff_tickets:contacts"), {"q": query})
assert result.status_code == 200
assert str(contact.public_id) in result.content.decode()
assert str(incomplete.public_id) in client.get(reverse("staff_tickets:contacts"), {"q": "09120000564"}).content.decode()
assert str(incomplete.public_id) in client.get(reverse("staff_tickets:contacts"), {"q": "نام قدیمی ناقص"}).content.decode()
assert client.get(reverse("staff_tickets:contact_edit", args=[incomplete.public_id])).status_code == 200
assert CustomerProfile.objects.filter(user=incomplete).exists()
assert incomplete.customer_profile.first_name == "نام قدیمی ناقص"
assert client.get(edit_url).status_code == 200
saved = client.post(edit_url, {
"mobile": "09120000562",
"first_name": "مخاطب اصلاح‌شده",
"last_name": "",
"organization_name": "",
"stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER,
"groups": [],
"province": "",
"city": "",
})
assert saved.status_code == 302
contact.refresh_from_db()
contact.customer_profile.refresh_from_db()
assert contact.mobile == "+989120000562"
assert contact.customer_profile.first_name == "مخاطب اصلاح‌شده"
assert not contact.customer_profile.groups.exists()
assert User.objects.filter(pk=contact.pk).exists()
assert CustomerProfile.objects.filter(user=contact).exists()
log = ContactEditLog.objects.get(contact=contact.customer_profile)
assert log.actor == staff
assert log.actor_name == staff.username
assert log.contact_public_id == contact.public_id
assert {change["field"] for change in log.changes} == {"شماره همراه", "نام"}
assert staff.username in client.get(edit_url).content.decode()
assert ContactEditLog.objects.count() == 1
repeated = client.post(edit_url, {
"mobile": "09120000562",
"first_name": "مخاطب اصلاح‌شده",
"last_name": "",
"organization_name": "",
"stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER,
"groups": [],
"province": "",
"city": "",
})
assert repeated.status_code == 302
assert ContactEditLog.objects.count() == 1
@pytest.mark.django_db
def test_customer_profile_backfill_is_repeatable_and_keeps_existing_contacts():
existing = make_contact("existing-profile-backfill", "09120000565")
incomplete = User.objects.create_user(username="missing-profile-backfill", mobile="09120000566", first_name="نام قدیمی", last_name="مشتری", account_type=User.AccountType.CUSTOMER)
migration = import_module("apps.customers.migrations.0006_backfill_customer_profiles")
editor = SimpleNamespace(connection=connection)
migration.backfill_customer_profiles(django_apps, editor)
migration.backfill_customer_profiles(django_apps, editor)
assert CustomerProfile.objects.filter(user=existing, first_name="existing-profile-backfill").count() == 1
assert CustomerProfile.objects.filter(user=incomplete).count() == 1
assert incomplete.customer_profile.first_name == "نام قدیمی"
assert not incomplete.customer_profile.groups.exists()
@pytest.mark.django_db
def test_shared_contact_edit_preserves_other_unit_groups_and_ticket_counts(client):
sales = OrganizationalUnit.objects.create(name="فروش مشترک", slug="shared-sales")
finance = OrganizationalUnit.objects.create(name="حسابداری مشترک", slug="shared-finance")
view_ticket = Permission.objects.get(content_type__app_label="tickets", codename="view_ticket")
sales.permissions.add(view_ticket)
finance.permissions.add(view_ticket)
sales_staff = make_staff("shared-sales-agent", sales)
finance_staff = make_staff("shared-finance-agent", finance)
contact = make_contact("shared-contact", "09120000563")
finance_group = ContactGroup.objects.create(name="مخاطبان حسابداری مشترک", unit=finance)
sales_category = TicketCategory.objects.create(unit=sales, title="فروش مشترک", slug="shared-sales-ticket")
finance_category = TicketCategory.objects.create(unit=finance, title="مالی مشترک", slug="shared-finance-ticket")
sales_ticket = create_ticket(customer=contact, created_by=sales_staff, unit=sales, category=sales_category, subject="درخواست فروش", body="شرح")
finance_ticket = create_ticket(customer=contact, created_by=finance_staff, unit=finance, category=finance_category, subject="درخواست مالی", body="شرح")
client.force_login(sales_staff)
assert client.get(reverse("staff_tickets:contact_edit", args=[contact.public_id])).status_code == 200
assert client.get(reverse("staff_tickets:detail", args=[finance_ticket.public_id])).status_code == 403
contact.customer_profile.groups.add(finance_group)
directory = client.get(reverse("staff_tickets:contacts"), {"q": "shared-contact"})
assert directory.status_code == 200
assert directory.context["contacts"][0].ticket_count == 1
assert finance_group.name not in directory.content.decode()
assert client.get(reverse("staff_tickets:detail", args=[sales_ticket.public_id])).status_code == 200
assert client.get(reverse("staff_tickets:detail", args=[finance_ticket.public_id])).status_code == 403
forged = client.post(reverse("staff_tickets:contact_edit", args=[contact.public_id]), {
"mobile": contact.local_mobile,
"first_name": "نام جعلی",
"last_name": "",
"organization_name": "",
"stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER,
"groups": [finance_group.pk],
"province": "",
"city": "",
})
assert forged.status_code == 200
assert forged.context["form"]["groups"].errors
contact.customer_profile.refresh_from_db()
assert contact.customer_profile.first_name == "shared-contact"
assert ContactEditLog.objects.count() == 0
edited = client.post(reverse("staff_tickets:contact_edit", args=[contact.public_id]), {
"mobile": contact.local_mobile,
"first_name": "نام تازه",
"last_name": "",
"organization_name": "",
"stakeholder_type": CustomerProfile.StakeholderType.CUSTOMER,
"groups": [],
"province": "",
"city": "",
})
assert edited.status_code == 302
assert contact.customer_profile.groups.filter(pk=finance_group.pk).exists()
assert ContactEditLog.objects.filter(contact=contact.customer_profile, actor=sales_staff).exists()
client.force_login(contact)
assert client.get(reverse("staff_tickets:contacts")).status_code == 403
assert client.get(reverse("staff_tickets:contact_edit", args=[contact.public_id])).status_code == 403
@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