diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 9372312..8c251b2 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -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//", 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//", views.consume_magic_link, name="magic"), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 6500000..155772e 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -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 diff --git a/apps/core/management/commands/import_production_seed.py b/apps/core/management/commands/import_production_seed.py index f5fb685..066fb38 100644 --- a/apps/core/management/commands/import_production_seed.py +++ b/apps/core/management/commands/import_production_seed.py @@ -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(): diff --git a/apps/tickets/migrations/0016_scope_legacy_public_tickets_to_units.py b/apps/tickets/migrations/0016_scope_legacy_public_tickets_to_units.py new file mode 100644 index 0000000..d64706a --- /dev/null +++ b/apps/tickets/migrations/0016_scope_legacy_public_tickets_to_units.py @@ -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)] diff --git a/apps/tickets/policies.py b/apps/tickets/policies.py index 278150f..7cc4341 100644 --- a/apps/tickets/policies.py +++ b/apps/tickets/policies.py @@ -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): diff --git a/config/urls.py b/config/urls.py index 43e6386..9d484cb 100644 --- a/config/urls.py +++ b/config/urls.py @@ -52,7 +52,7 @@ urlpatterns = [ path("media/branding/", 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")), diff --git a/docs/DEPLOYMENT_COOLIFY.md b/docs/DEPLOYMENT_COOLIFY.md index ec7662b..a1e144f 100644 --- a/docs/DEPLOYMENT_COOLIFY.md +++ b/docs/DEPLOYMENT_COOLIFY.md @@ -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 و هم اتصال دیتابیس را بررسی می‌کند. diff --git a/templates/base.html b/templates/base.html index da63836..b82e163 100644 --- a/templates/base.html +++ b/templates/base.html @@ -32,7 +32,6 @@ ◇اشتراک‌ها {% if staff_operations_access.contacts %}♧مخاطبان و گروه‌ها{% endif %} {% if staff_operations_access.dispatches %}⇶ارسال گروهی{% endif %} - {% if staff_operations_access.templates %}✉الگوهای پیامک{% endif %} {% if staff_operations_access.audit %}⌁ممیزی لینک‌ها{% endif %} diff --git a/tests/e2e/test_organization_permissions_e2e.py b/tests/e2e/test_organization_permissions_e2e.py index 05827cf..30939c6 100644 --- a/tests/e2e/test_organization_permissions_e2e.py +++ b/tests/e2e/test_organization_permissions_e2e.py @@ -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) diff --git a/tests/test_domain.py b/tests/test_domain.py index 0702764..bb76e72 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -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() diff --git a/tests/test_organizational_ticketing.py b/tests/test_organizational_ticketing.py index 606396e..81e80eb 100644 --- a/tests/test_organizational_ticketing.py +++ b/tests/test_organizational_ticketing.py @@ -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 diff --git a/tests/test_permission_architecture.py b/tests/test_permission_architecture.py index 339a872..6c284f0 100644 --- a/tests/test_permission_architecture.py +++ b/tests/test_permission_architecture.py @@ -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"] diff --git a/tests/test_production_seed.py b/tests/test_production_seed.py index 79cbc9d..1063853 100644 --- a/tests/test_production_seed.py +++ b/tests/test_production_seed.py @@ -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" diff --git a/tests/test_views.py b/tests/test_views.py index fbf4686..b5bb05e 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -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)