fix queue

This commit is contained in:
javad committed 2026-09-11 18:04:51 +03:30
1 parent 049cc41afc
commit 9a1c99cbd3
14 files changed
+237 -32

No files matched your search

+13 -1
View File
@@ -1,4 +1,16 @@
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [path("login/", views.customer_login, name="login"),path("login/password/",views.customer_password_login,name="password_login"), path("login/otp/", views.verify_otp, name="otp"),path("profile/",views.customer_profile,name="profile"), path("logout/", views.logout_view, name="logout"), path("staff/login/", views.staff_login, name="staff_login"), path("staff/logout/", views.logout_view, name="staff_logout"), path("t/<str:token>/", views.consume_magic_link, name="magic")]
urlpatterns = [
path("", views.home, name="home"),
path("login/", views.customer_login, name="login"),
path("login/password/", views.customer_password_login, name="password_login"),
path("login/otp/", views.verify_otp, name="otp"),
path("profile/", views.customer_profile, name="profile"),
path("logout/", views.logout_view, name="logout"),
path("staff/login/", views.staff_login, name="staff_login"),
path("staff/logout/", views.logout_view, name="staff_logout"),
path("t/<str:token>/", views.consume_magic_link, name="magic"),
]
+6 -2
View File
@@ -3,8 +3,7 @@ from django.conf import settings
from django.contrib.auth import login, logout
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
from apps.customers.models import CustomerProfile
from django.contrib.auth.decorators import login_required
@@ -21,6 +20,11 @@ def _authenticated_home(request):
if not request.user.is_authenticated: return None
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list")
@require_GET
def home(request):
"""Send every visitor to the correct login or authenticated workspace."""
return _authenticated_home(request) or redirect("accounts:login")
@require_http_methods(["GET", "POST"])
def customer_login(request):
if response:=_authenticated_home(request): return response
@@ -8,10 +8,12 @@ from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from django.db.models import F
from apps.accounts.models import User
from apps.core.models import ProductionSeedState
from apps.core.production_seed import sha256_file
from apps.tickets.models import Ticket
class Command(BaseCommand):
@@ -82,6 +84,27 @@ class Command(BaseCommand):
# the authoritative SQLite snapshot; schema/migration history remains.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
# Old SQLite snapshots may predate unit-scoped queues. A
# category already belongs to exactly one unit, so it is a
# deterministic and safe source for repairing those rows.
repaired = []
for ticket in Ticket.objects.filter(
owning_unit__isnull=True,
category__unit__isnull=False,
).select_related("category"):
ticket.owning_unit_id = ticket.category.unit_id
repaired.append(ticket)
if repaired:
Ticket.objects.bulk_update(repaired, ["owning_unit"])
Ticket.objects.filter(
assigned_unit__isnull=True,
owning_unit__isnull=False,
).update(assigned_unit_id=F("owning_unit_id"))
if Ticket.objects.filter(owning_unit__isnull=True).exists():
raise CommandError(
"Seed contains tickets without an organizational unit; "
"assign their categories to units before exporting again."
)
media_root.mkdir(parents=True, exist_ok=True)
for source in sorted(staged_media.rglob("*")):
if not source.is_file():
@@ -0,0 +1,32 @@
from django.db import migrations
from django.db.models import F
def scope_legacy_tickets(apps, schema_editor):
Ticket = apps.get_model("tickets", "Ticket")
repaired = []
for ticket in Ticket.objects.filter(
owning_unit__isnull=True,
category__unit__isnull=False,
).select_related("category"):
ticket.owning_unit_id = ticket.category.unit_id
repaired.append(ticket)
if repaired:
Ticket.objects.bulk_update(repaired, ["owning_unit"])
Ticket.objects.filter(
assigned_unit__isnull=True,
owning_unit__isnull=False,
).update(assigned_unit_id=F("owning_unit_id"))
if Ticket.objects.filter(owning_unit__isnull=True).exists():
raise RuntimeError(
"Cannot create unit-scoped queues: some tickets and their categories "
"have no organizational unit."
)
class Migration(migrations.Migration):
dependencies = [("tickets", "0015_seed_customer_category_catalog")]
operations = [migrations.RunPython(scope_legacy_tickets, migrations.RunPython.noop)]
+14 -7
View File
@@ -75,7 +75,7 @@ class TicketPolicy:
scope = cls.unit_scope(user, unit)
if scope == "UNIT": return True
return bool(ticket.origin_visibility == "PUBLIC" and ticket.owner_id is None and ticket.selected_expert_id is None)
return bool(not ticket.owning_unit_id and ticket.origin_visibility == "PUBLIC" and ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("tickets.view_ticket"))
return False
@classmethod
def viewable_q(cls, user):
"""Database equivalent of can_view; use it for every staff ticket collection."""
@@ -88,14 +88,22 @@ class TicketPolicy:
if cls.has_unit_permission(user, item.unit, "tickets.view_ticket")
}
unit_wide_ids = set(memberships.filter(ticket_scope="UNIT", unit_id__in=permitted_ids).values_list("unit_id", flat=True))
unit_wide = Q(owning_unit_id__in=unit_wide_ids) | Q(assigned_unit_id__in=unit_wide_ids)
unit_wide = Q(assigned_unit_id__in=unit_wide_ids) | Q(
assigned_unit__isnull=True,
owning_unit_id__in=unit_wide_ids,
)
limited_ids = permitted_ids - unit_wide_ids
public_unassigned = (
(Q(owning_unit_id__in=limited_ids) | Q(assigned_unit_id__in=limited_ids))
& Q(origin_visibility="PUBLIC", selected_expert__isnull=True)
(
Q(assigned_unit_id__in=limited_ids)
| Q(assigned_unit__isnull=True, owning_unit_id__in=limited_ids)
)
& Q(origin_visibility="PUBLIC", owner__isnull=True, selected_expert__isnull=True)
)
legacy = Q(owning_unit__isnull=True, origin_visibility="PUBLIC", selected_expert__isnull=True) if user.has_perm("tickets.view_ticket") else Q(pk__in=[])
return direct | unit_wide | public_unassigned | legacy
# There is deliberately no global/unitless public queue. Legacy tickets
# must be assigned to a unit by the data migration before staff can see
# them through a unit queue.
return direct | unit_wide | public_unassigned
@classmethod
def can_reply(cls,user,ticket):
if not cls.can_view(user,ticket): return False
@@ -103,7 +111,6 @@ class TicketPolicy:
if cls.has_unit_permission(user,ticket.assigned_unit or ticket.owning_unit,"messaging.add_message"): return True
if user.has_perm("tickets.view_all_tickets") or ticket.owner_id==user.id or ticket.selected_expert_id==user.id or ticket.created_by_id==user.id: return True
if ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("messaging.add_message"): return True
if not ticket.owning_unit_id and ticket.owner_id is None and ticket.selected_expert_id is None and user.has_perm("tickets.view_ticket"): return True
return ticket.access_grants.filter(user=user,revoked_at__isnull=True,permission_level__in=[TicketAccessGrant.Level.REPLY,TicketAccessGrant.Level.FULL]).exists()
@classmethod
def can_claim(cls,user,ticket):
+1 -1
View File
@@ -52,7 +52,7 @@ urlpatterns = [
path("media/branding/<path:path>", public_branding, name="public_branding"),
path("service-worker.js", service_worker, name="service_worker"),
path("manifest.webmanifest", web_manifest, name="web_manifest"),
path("admin/", admin.site.urls),
path("x5admin/", admin.site.urls),
path("", include("apps.accounts.urls")),
path("tickets/", include("apps.tickets.customer_urls")),
path("staff/", include("apps.tickets.staff_urls")),
+9
View File
@@ -137,6 +137,15 @@ SECURE_HSTS_PRELOAD=false
6. log سرویس `notification-worker` را بررسی کنید؛ container باید Running بماند.
7. با superuser منتقل‌شده از SQLite وارد شوید و با گیرنده آزمایشی مجاز یک SMS و یک پیام Bale واقعی بفرستید.
Migration شماره `tickets.0016_scope_legacy_public_tickets_to_units` فقط یک‌بار اجرا می‌شود و تیکت‌های قدیمی بدون واحد را از روی واحد قطعی دسته‌بندی به `owning_unit` و `assigned_unit` صحیح متصل می‌کند. این migration همراه deploy عادی و دستور `migrate` خودکار اجرا می‌شود و اجرای دستی SQL لازم نیست. برای کنترل نتیجه در Terminal سرویس `web`:
```bash
python manage.py showmigrations tickets
python manage.py shell -c "from apps.tickets.models import Ticket; print(Ticket.objects.filter(owning_unit__isnull=True).count(), Ticket.objects.filter(assigned_unit__isnull=True).count())"
```
هر دو عدد باید صفر باشند.
## 8. کنترل سلامت و عیب‌یابی
- `GET /healthz/` هم web server و هم اتصال دیتابیس را بررسی می‌کند.
-1
View File
@@ -32,7 +32,6 @@
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span>◇</span><b>اشتراک‌ها</b></a>
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان و گروه‌ها</b></a>{% endif %}
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span>⇶</span><b>ارسال گروهی</b></a>{% endif %}
{% if staff_operations_access.templates %}<a class="{% if active_staff_section == 'templates' %}active{% endif %}" href="{% url 'staff_tickets:sms_templates' %}" {% if active_staff_section == 'templates' %}aria-current="page"{% endif %}><span>✉</span><b>الگوهای پیامک</b></a>{% endif %}
{% if staff_operations_access.audit %}<a class="{% if active_staff_section == 'audit' %}active{% endif %}" href="{% url 'staff_tickets:magic_audit' %}" {% if active_staff_section == 'audit' %}aria-current="page"{% endif %}><span>⌁</span><b>ممیزی لینک‌ها</b></a>{% endif %}
</nav>
</div>
@@ -132,7 +132,7 @@ def test_e2e_03_warehouse_staff_cannot_see_sales_tickets(live_server, browser_pa
users, units = org_data
buyer = make_customer("e2e-scope-buyer", "09120008101")
create_ticket(customer=buyer, unit=units["sales"], category=TicketCategory.objects.get(slug="sales-general"), subject="تیکت محرمانه فروش E2E", body="شرح")
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت مجاز بارگیری E2E", body="شرح")
create_ticket(customer=buyer, created_by=users["ms_sabzali"], unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت مجاز بارگیری E2E", body="شرح")
with browser_page() as page:
login_staff(page, live_server, users["ms_sabzali"])
content = page.locator("body")
@@ -144,7 +144,7 @@ def test_e2e_04_accounting_sees_only_finance_scope(live_server, browser_page, or
users, units = org_data
buyer = make_customer("e2e-finance-buyer", "09120008102")
create_ticket(customer=buyer, unit=units["finance"], category=TicketCategory.objects.get(slug="finance-general"), subject="تیکت مالی مجاز E2E", body="شرح")
create_ticket(customer=buyer, unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت انبار نامجاز E2E", body="شرح")
create_ticket(customer=buyer, created_by=users["ms_sabzali"], unit=units["warehouse-loading"], category=TicketCategory.objects.get(slug="warehouse-loading-general"), subject="تیکت انبار نامجاز E2E", body="شرح")
with browser_page() as page:
login_staff(page, live_server, users["fereshteh_yavari"])
expect(page.locator("body")).to_contain_text("تیکت مالی مجاز E2E")
@@ -238,8 +238,8 @@ def test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue(l
page.route("**/tickets/new/options/**", remove_queue_from_response)
page.goto(f"{live_server.url}{reverse('tickets:new')}", wait_until="domcontentloaded")
ticket_page = PublicTicketCreatePage(page, live_server.url)
payload = ticket_page.select_unit(units["warehouse-loading"].pk)
payload = ticket_page.select_unit(units["finance"].pk)
assert payload.get("queue") is None
expect(ticket_page.expert_picker).to_contain_text("خانم سبزعلی")
expect(ticket_page.expert_picker).to_contain_text("آقای مهدی عظمی")
expect(ticket_page.expert_picker).to_contain_text("خانم فرشته یاوری")
expect(ticket_page.expert_picker).not_to_contain_text("کیمیا غفاری")
expect(page.get_by_role("alert")).to_have_count(0)
+14 -4
View File
@@ -9,14 +9,19 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from apps.attachments.models import Attachment, AttachmentPolicy
from apps.accounts.models import User, normalize_mobile
from apps.notifications.models import NotificationJob
from apps.staff.models import StaffProfile
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import Ticket, TicketCategory
from apps.tickets.policies import TicketPolicy
from apps.tickets.services import claim_ticket, create_ticket, delete_ticket, reply_ticket
@pytest.fixture
def category(db):
return TicketCategory.objects.create(title="قیمت", slug="price")
unit = OrganizationalUnit.objects.create(name="واحد تست دامنه", slug="domain-test-unit")
unit.permissions.add(
Permission.objects.get(codename="view_ticket", content_type__app_label="tickets"),
Permission.objects.get(codename="add_message", content_type__app_label="messaging"),
)
return TicketCategory.objects.create(unit=unit, title="قیمت", slug="domain-test-price")
@pytest.fixture
def customer(db):
@@ -25,6 +30,8 @@ def customer(db):
def make_staff(username):
mobile = "09120000001" if username == "a" else "09120000002"
user = User.objects.create_user(username=username, mobile=mobile, account_type="STAFF")
unit = OrganizationalUnit.objects.filter(slug="domain-test-unit").first()
StaffProfile.objects.create(user=user, primary_unit=unit, display_name=username)
for code in ["view_ticket", "claim_ticket", "change_ticket"]:
user.user_permissions.add(Permission.objects.get(codename=code, content_type__app_label="tickets"))
return user
@@ -122,14 +129,17 @@ def test_bale_ticket_excerpt_is_compact_and_whitespace_normalized(category,custo
def test_ticket_full_delete_requires_manager_permission_and_denies_sales_expert(category, customer):
ticket = create_ticket(customer=customer, category=category, subject="حذف پرونده", body="متن اولیه")
expert = User.objects.create_user(username="delete-expert", mobile="09120000003", account_type="STAFF")
StaffProfile.objects.create(user=expert, display_name="کارشناس", role=StaffProfile.Role.SALES_EXPERT)
StaffProfile.objects.create(user=expert, primary_unit=category.unit, display_name="کارشناس", role=StaffProfile.Role.SALES_EXPERT)
expert.user_permissions.add(Permission.objects.get(codename="delete_ticket", content_type__app_label="tickets"))
with pytest.raises(PermissionDenied):
delete_ticket(ticket_id=ticket.pk, user=expert)
manager = User.objects.create_user(username="delete-manager", mobile="09120000004", account_type="STAFF")
StaffProfile.objects.create(user=manager, display_name="مدیر", role=StaffProfile.Role.SALES_MANAGER)
StaffProfile.objects.create(user=manager, primary_unit=category.unit, display_name="مدیر", role=StaffProfile.Role.SALES_MANAGER)
for codename in ("delete_ticket", "view_all_tickets"):
manager.user_permissions.add(Permission.objects.get(codename=codename, content_type__app_label="tickets"))
manager.unit_memberships.get(unit=category.unit).permissions.add(
Permission.objects.get(codename="delete_ticket", content_type__app_label="tickets")
)
delete_ticket(ticket_id=ticket.pk, user=manager)
assert not Ticket.objects.filter(pk=ticket.pk).exists()
assert not NotificationJob.objects.filter(ticket_id=ticket.pk).exists()
+3 -3
View File
@@ -28,7 +28,7 @@ def make_contact(username, mobile):
@pytest.mark.django_db
def test_unit_permission_isolates_ticket_queries_and_object_access():
sales = OrganizationalUnit.objects.create(name="فروش", slug="sales")
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)
@@ -114,7 +114,7 @@ def test_magic_link_records_every_open_with_ip_and_user_agent(client):
@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")
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)
@@ -123,7 +123,7 @@ def test_dispatch_pages_are_available_only_to_the_dispatch_unit(client):
customer = make_contact("warehouse-contact", "09120000131")
group = ContactGroup.objects.create(name="تامین‌کنندگان", unit=unit)
customer.customer_profile.groups.add(group)
category = TicketCategory.objects.create(title="موجودی", slug="inventory")
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
+53
View File
@@ -135,6 +135,59 @@ def test_ticket_visibility_obeys_self_unit_and_global_scopes(organization):
assert set(staff_ticket_rows(ceo)) == {public_sales, selected_sales, kimia_owned, warehouse_ticket, finance_ticket}
def test_each_public_queue_and_staff_message_endpoint_is_strictly_unit_scoped(client, organization):
sales, finance = organization["sales"], organization["finance"]
sales_staff = User.objects.get(username="kimiya_ghaffari")
other_sales_staff = User.objects.get(username="zahra_jamali")
finance_staff = User.objects.get(username="fereshteh_yavari")
buyer = customer("isolated-queue-buyer", "09120007111")
sales_ticket = create_ticket(
customer=buyer,
unit=sales,
category=TicketCategory.objects.get(slug="sales-general"),
subject="صف عمومی فقط فروش",
body="پیام فروش",
)
finance_ticket = create_ticket(
customer=buyer,
unit=finance,
category=TicketCategory.objects.get(slug="finance-general"),
subject="صف عمومی فقط مالی",
body="پیام مالی",
)
assert set(staff_ticket_rows(sales_staff)) == {sales_ticket}
assert finance_ticket not in staff_ticket_rows(sales_staff)
assert set(staff_ticket_rows(finance_staff)) == {finance_ticket}
assert sales_ticket not in staff_ticket_rows(finance_staff)
client.force_login(sales_staff)
assert client.get(reverse("staff_tickets:detail", args=[finance_ticket.public_id])).status_code == 403
assert client.get(reverse("staff_tickets:messages", args=[finance_ticket.public_id])).status_code == 403
assert client.get(reverse("staff_tickets:poll", args=[finance_ticket.public_id])).status_code == 403
routed_ticket = create_ticket(
customer=buyer,
unit=sales,
category=TicketCategory.objects.get(slug="sales-general"),
subject="صف منتقل‌شده از فروش به مالی",
body="پیام منتقل‌شده",
)
routed_ticket.assigned_unit = finance
routed_ticket.save(update_fields=["assigned_unit", "updated_at"])
assert routed_ticket not in staff_ticket_rows(sales_staff)
assert not TicketPolicy.can_view(sales_staff, routed_ticket)
assert routed_ticket in staff_ticket_rows(finance_staff)
assert TicketPolicy.can_view(finance_staff, routed_ticket)
# Once a SELF-scope queue item is claimed, it is no longer visible to a
# different expert merely because both experts belong to the same unit.
sales_ticket.owner = other_sales_staff
sales_ticket.save(update_fields=["owner", "updated_at"])
assert sales_ticket not in staff_ticket_rows(sales_staff)
assert not TicketPolicy.can_view(sales_staff, sales_ticket)
def test_staff_ticket_form_locks_primary_unit_and_rejects_tampered_unit_category_and_group(client, organization):
kimia = User.objects.get(username="kimiya_ghaffari")
sales, finance = organization["sales"], organization["finance"]
+19
View File
@@ -5,11 +5,14 @@ import pytest
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.db import connection
from django.utils import timezone
from apps.accounts.models import User
from apps.customers.models import CustomerProfile
from apps.core.models import ProductionSeedState
from apps.notifications.models import NotificationJob
from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import Ticket, TicketCategory
@pytest.mark.django_db(transaction=True)
@@ -26,6 +29,19 @@ def test_seed_bundle_round_trip_is_complete_idempotent_and_excludes_outbox(tmp_p
account_type=User.AccountType.STAFF,
)
StaffProfile.objects.create(user=user, primary_unit=unit, display_name="کارشناس Seed")
customer = User.objects.create_user(
username="seed-customer", mobile="09120009991", account_type=User.AccountType.CUSTOMER
)
CustomerProfile.objects.create(user=customer, first_name="مشتری Seed")
category = TicketCategory.objects.create(
unit=unit, title="دسته Seed", slug="seed-category"
)
legacy_ticket = Ticket.objects.create(
customer=customer,
category=category,
subject="تیکت قدیمی بدون واحد",
last_activity_at=timezone.now(),
)
permission = Permission.objects.order_by("pk").first()
permission_key = (permission.content_type.app_label, permission.codename)
unit.permissions.add(permission)
@@ -69,4 +85,7 @@ def test_seed_bundle_round_trip_is_complete_idempotent_and_excludes_outbox(tmp_p
assert restored_membership.unit.permissions.filter(**permission_filter).exists()
assert ProductionSeedState.objects.count() == 1
assert NotificationJob.objects.count() == 0
restored_ticket = Ticket.objects.get(pk=legacy_ticket.pk)
assert restored_ticket.owning_unit.slug == "seed-unit"
assert restored_ticket.assigned_unit.slug == "seed-unit"
assert (target_media / "branding" / "logo" / "current.png").read_bytes() == b"seed-media"
+45 -8
View File
@@ -23,12 +23,16 @@ def test_customer_detail_is_forbidden_to_other_customer(client):
@pytest.mark.django_db
def test_locked_public_ticket_only_leaks_metadata(client):
category = TicketCategory.objects.create(title="قیمت", slug="test-locked-price")
unit = OrganizationalUnit.objects.create(name="فروش قفل‌شده", slug="locked-sales")
unit.permissions.add(
Permission.objects.get(content_type__app_label="tickets", codename="view_ticket")
)
category = TicketCategory.objects.create(unit=unit, title="قیمت", slug="test-locked-price")
customer = User.objects.create_user(username="customer", mobile="09123333333", account_type="CUSTOMER")
expert = User.objects.create_user(username="expert", mobile="09124444444", account_type="STAFF")
observer = User.objects.create_user(username="observer", mobile="09125555555", account_type="STAFF")
StaffProfile.objects.create(user=expert, display_name="کارشناس اول")
StaffProfile.objects.create(user=observer, display_name="کارشناس دوم")
StaffProfile.objects.create(user=expert, primary_unit=unit, display_name="کارشناس اول")
StaffProfile.objects.create(user=observer, primary_unit=unit, display_name="کارشناس دوم")
for user in (expert, observer):
for code in ("view_ticket", "claim_ticket"):
user.user_permissions.add(Permission.objects.get(content_type__app_label="tickets", codename=code))
@@ -36,7 +40,7 @@ def test_locked_public_ticket_only_leaks_metadata(client):
claim_ticket(ticket.pk, expert)
client.force_login(observer)
listing = client.get(reverse("staff_tickets:list"))
assert normalize_persian_text("موضوع قابل مشاهده") in listing.content.decode()
assert normalize_persian_text("موضوع قابل مشاهده") not in listing.content.decode()
assert "محتوای محرمانه" not in listing.content.decode()
assert client.get(reverse("staff_tickets:detail", args=[ticket.public_id])).status_code == 403
@@ -70,11 +74,13 @@ def test_staff_filters_keep_selected_values_and_active_tab(client):
@pytest.mark.django_db
def test_staff_desk_prioritizes_fresh_actionable_activity_and_renders_distinct_statuses(client):
category=TicketCategory.objects.create(title="پیگیری فروش",slug="staff-priority-order")
unit=OrganizationalUnit.objects.create(name="فروش اولویت",slug="priority-sales")
unit.permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
category=TicketCategory.objects.create(unit=unit,title="پیگیری فروش",slug="staff-priority-order")
customer=User.objects.create_user(username="priority-customer",mobile="09130000010",account_type="CUSTOMER")
CustomerProfile.objects.create(user=customer,first_name="مشتری",last_name="نمونه")
expert=User.objects.create_user(username="priority-expert",mobile="09130000011",account_type="STAFF")
StaffProfile.objects.create(user=expert,display_name="کارشناس اولویت")
StaffProfile.objects.create(user=expert,primary_unit=unit,display_name="کارشناس اولویت")
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
waiting_customer=create_ticket(customer=customer,category=category,subject="منتظر پاسخ مشتری",body="شرح اول")
fresh_customer_reply=create_ticket(customer=customer,category=category,subject="پاسخ تازه مشتری",body="شرح دوم")
@@ -224,6 +230,32 @@ def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client):
assert "برنامه نویسی و توسعه: محمد جواد فصاحت" in content
assert "09202202129" in content
assert "واحد فناوری اطلاعات" not in content
assert "الگوهای پیامک" not in content
assert reverse("staff_tickets:sms_templates") not in content
@pytest.mark.django_db
def test_root_redirects_to_login_or_authenticated_workspace(client):
response = client.get("/")
assert response.status_code == 302
assert response.url == reverse("accounts:login")
customer = User.objects.create_user(
username="root-customer", mobile="09120000991", account_type="CUSTOMER"
)
client.force_login(customer)
assert client.get("/").url == reverse("tickets:list")
staff = User.objects.create_user(
username="root-staff", mobile="09120000992", account_type="STAFF"
)
StaffProfile.objects.create(user=staff, display_name="کارشناس ریشه")
client.force_login(staff)
assert client.get("/").url == reverse("staff_tickets:list")
@pytest.mark.django_db
def test_admin_is_only_mounted_at_private_path(client):
assert reverse("admin:index") == "/x5admin/"
assert client.get("/admin/").status_code == 404
@pytest.mark.django_db
def test_customer_can_set_password_and_login(client):
@@ -266,10 +298,15 @@ def test_magic_link_is_reusable_for_seven_days_and_only_grants_one_ticket(client
@pytest.mark.django_db
def test_ticket_polling_is_versioned_incremental_and_permission_safe(client):
category=TicketCategory.objects.create(title="Polling",slug="polling")
unit=OrganizationalUnit.objects.create(name="فروش Polling",slug="polling-sales")
unit.permissions.add(
Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"),
Permission.objects.get(content_type__app_label="messaging",codename="add_message"),
)
category=TicketCategory.objects.create(unit=unit,title="Polling",slug="polling")
customer=User.objects.create_user(username="poll-customer",mobile="09129990001",account_type="CUSTOMER")
expert=User.objects.create_user(username="poll-expert",mobile="09129990002",account_type="STAFF")
StaffProfile.objects.create(user=expert,display_name="کارشناس پولینگ")
StaffProfile.objects.create(user=expert,primary_unit=unit,display_name="کارشناس پولینگ")
expert.user_permissions.add(Permission.objects.get(content_type__app_label="tickets",codename="view_ticket"))
ticket=create_ticket(customer=customer,category=category,subject="بررسی پاسخ تازه",body="پیام نخست")
client.force_login(customer)