From 089db0afa477c09727163181330bac512fbc123d Mon Sep 17 00:00:00 2001 From: javad Date: Tue, 13 Oct 2026 16:09:45 +0330 Subject: [PATCH] link view ticket for staff --- apps/accounts/views.py | 16 +- .../commands/process_notifications.py | 2 + apps/staff/admin.py | 4 +- ...unit_supervisor_sms_ticket_link_enabled.py | 18 ++ apps/staff/models.py | 5 + .../migrations/0019_staffticketshortlink.py | 30 +++ apps/tickets/models.py | 25 +++ apps/tickets/short_links.py | 38 ++++ config/urls.py | 2 + docs/STAFF_TICKET_SHORT_LINKS.md | 31 +++ tests/e2e/pages/staff_short_link.py | 15 ++ .../e2e/test_staff_ticket_short_links_e2e.py | 36 ++++ tests/test_staff_ticket_short_links.py | 193 ++++++++++++++++++ 13 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 apps/staff/migrations/0013_organizationalunit_supervisor_sms_ticket_link_enabled.py create mode 100644 apps/tickets/migrations/0019_staffticketshortlink.py create mode 100644 apps/tickets/short_links.py create mode 100644 docs/STAFF_TICKET_SHORT_LINKS.md create mode 100644 tests/e2e/pages/staff_short_link.py create mode 100644 tests/e2e/test_staff_ticket_short_links_e2e.py create mode 100644 tests/test_staff_ticket_short_links.py diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 9edeefe..79a7541 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -105,14 +105,28 @@ def customer_profile(request): messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile") return render(request,"accounts/profile.html",{"form":form}) +def _staff_login_destination(request): + target = request.GET.get("next") or request.POST.get("next") or request.session.get("staff_login_next", "") + if target.startswith(("/vt/", "/staff/tickets/")) and url_has_allowed_host_and_scheme( + target, allowed_hosts={request.get_host()}, require_https=request.is_secure(), + ): + request.session["staff_login_next"] = target + return target + request.session.pop("staff_login_next", None) + return reverse("staff_tickets:list") + + @require_http_methods(["GET", "POST"]) def staff_login(request): + destination = _staff_login_destination(request) if response:=_authenticated_home(request): return response form = StaffLoginForm(request.POST or None) if request.method == "POST" and form.is_valid(): user = form.authenticate(request) if user and user.is_active and user.account_type == User.AccountType.STAFF: - login(request, user); return redirect("staff_tickets:list") + login(request, user) + request.session.pop("staff_login_next", None) + return redirect(destination) form.add_error(None, "اطلاعات ورود صحیح نیست.") return render(request, "accounts/staff_login.html", {"form": form}) diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index a6939e3..4032500 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -99,10 +99,12 @@ def _send_sms(job): return send_payment_sms(job, provider_factory=get_sms_provider) issued = None if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": + from apps.tickets.short_links import append_supervisor_ticket_link recipient = job.payload.get("recipient_mobile", "") if not recipient: raise ValueError("Unit supervisor mobile is not configured") message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit) + message = append_supervisor_ticket_link(job.ticket, message) return get_sms_provider().send_message(recipient, message), issued if job.event_type == "CUSTOMER_TICKET_CREATED": issued, url = _magic_link(job, "customer_created_ticket") diff --git a/apps/staff/admin.py b/apps/staff/admin.py index cdce5c5..66114c0 100644 --- a/apps/staff/admin.py +++ b/apps/staff/admin.py @@ -14,14 +14,14 @@ class UnitMembershipInline(admin.TabularInline): @admin.register(OrganizationalUnit) class OrganizationalUnitAdmin(admin.ModelAdmin): list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at") - list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active") + list_filter = ("supervisor_sms_ticket_link_enabled", "show_in_customer_form", "accepts_external_tickets", "is_active") search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5") list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active") filter_horizontal = ("permissions",) inlines = (UnitMembershipInline,) fieldsets = ( ("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}), - ("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")}), + ("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5", "supervisor_sms_ticket_link_enabled")}), ("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}), ) diff --git a/apps/staff/migrations/0013_organizationalunit_supervisor_sms_ticket_link_enabled.py b/apps/staff/migrations/0013_organizationalunit_supervisor_sms_ticket_link_enabled.py new file mode 100644 index 0000000..e1e20e8 --- /dev/null +++ b/apps/staff/migrations/0013_organizationalunit_supervisor_sms_ticket_link_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2026-10-13 12:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0012_organizationalunit_supervisor_mobile_5'), + ] + + operations = [ + migrations.AddField( + model_name='organizationalunit', + name='supervisor_sms_ticket_link_enabled', + field=models.BooleanField(default=False, help_text='لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.', verbose_name='افزودن لینک تیکت به پیامک سرپرستان'), + ), + ] diff --git a/apps/staff/models.py b/apps/staff/models.py index fb09a4f..cea3d58 100644 --- a/apps/staff/models.py +++ b/apps/staff/models.py @@ -38,6 +38,11 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel): supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم") supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم") supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم") + supervisor_sms_ticket_link_enabled = models.BooleanField( + default=False, + verbose_name="افزودن لینک تیکت به پیامک سرپرستان", + help_text="لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.", + ) class Meta: ordering = ["sort_order", "name", "pk"] diff --git a/apps/tickets/migrations/0019_staffticketshortlink.py b/apps/tickets/migrations/0019_staffticketshortlink.py new file mode 100644 index 0000000..5f18c62 --- /dev/null +++ b/apps/tickets/migrations/0019_staffticketshortlink.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.1 on 2026-10-13 12:22 + +import apps.tickets.models +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0018_dispatch_response_opening'), + ] + + operations = [ + migrations.CreateModel( + name='StaffTicketShortLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('token', models.CharField(default=apps.tickets.models.staff_ticket_link_token, editable=False, max_length=10, unique=True)), + ('expires_at', models.DateTimeField(default=apps.tickets.models.staff_ticket_link_expiry)), + ('ticket', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_short_link', to='tickets.ticket')), + ], + options={ + 'verbose_name': 'لینک کوتاه تیکت کارشناسان', + 'verbose_name_plural': 'لینک\u200cهای کوتاه تیکت کارشناسان', + }, + ), + ] diff --git a/apps/tickets/models.py b/apps/tickets/models.py index 6af1c6e..efc92cd 100644 --- a/apps/tickets/models.py +++ b/apps/tickets/models.py @@ -1,10 +1,35 @@ import uuid +import secrets +import string +from datetime import timedelta from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models from django.db.models import Q +from django.utils import timezone from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel + +def staff_ticket_link_token(): + return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10)) + + +def staff_ticket_link_expiry(): + return timezone.now() + timedelta(days=7) + + +class StaffTicketShortLink(TimeStampedModel): + """A short navigation URL; it never authenticates or grants ticket access.""" + + ticket = models.OneToOneField("Ticket", on_delete=models.CASCADE, related_name="staff_short_link") + token = models.CharField(max_length=10, unique=True, default=staff_ticket_link_token, editable=False) + expires_at = models.DateTimeField(default=staff_ticket_link_expiry) + + class Meta: + verbose_name = "لینک کوتاه تیکت کارشناسان" + verbose_name_plural = "لینک‌های کوتاه تیکت کارشناسان" + + class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel): persian_text_fields = {"title": False, "description": True, "search_keywords": True} unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.") diff --git a/apps/tickets/short_links.py b/apps/tickets/short_links.py new file mode 100644 index 0000000..470427d --- /dev/null +++ b/apps/tickets/short_links.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse +from django.utils import timezone +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_GET + +from apps.accounts.models import User +from .models import StaffTicketShortLink +from .policies import TicketPolicy + + +def append_supervisor_ticket_link(ticket, message): + unit = ticket.owning_unit + if not unit or not unit.supervisor_sms_ticket_link_enabled: + return message + link, _ = StaffTicketShortLink.objects.get_or_create(ticket=ticket) + if link.expires_at <= timezone.now(): + return message + url = settings.PUBLIC_BASE_URL.rstrip("/") + reverse("staff_ticket_short_link", args=[link.token]) + return f"{message}\nمشاهده و پاسخ: {url}" + + +@never_cache +@require_GET +@login_required(login_url="/staff/login/") +def open_staff_ticket_link(request, token): + if not request.user.is_active or request.user.account_type != User.AccountType.STAFF: + raise PermissionDenied + link = get_object_or_404( + StaffTicketShortLink.objects.select_related("ticket"), + token=token, expires_at__gt=timezone.now(), + ) + if not TicketPolicy.can_view(request.user, link.ticket): + raise PermissionDenied + return redirect("staff_tickets:detail", public_id=link.ticket.public_id) diff --git a/config/urls.py b/config/urls.py index b2dc384..4ea11ff 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,6 +10,7 @@ from django.views.static import serve from apps.core.models import SiteBranding from apps.payments import link_views as payment_link_views +from apps.tickets.short_links import open_staff_ticket_link def healthz(request): @@ -49,6 +50,7 @@ def web_manifest(request): return response urlpatterns = [ + path("vt/", open_staff_ticket_link, name="staff_ticket_short_link"), path("p//", payment_link_views.open_link, name="payment_short_link"), path("healthz/", healthz, name="healthz"), path("media/branding/", public_branding, name="public_branding"), diff --git a/docs/STAFF_TICKET_SHORT_LINKS.md b/docs/STAFF_TICKET_SHORT_LINKS.md new file mode 100644 index 0000000..dd2211f --- /dev/null +++ b/docs/STAFF_TICKET_SHORT_LINKS.md @@ -0,0 +1,31 @@ +# لینک کوتاه تیکت در پیامک سرپرستان + +در ادمین Django، بخش واحدهای سازمانی، گزینه «افزودن لینک تیکت به پیامک +سرپرستان» را در قسمت «اطلاع‌رسانی بله و سرپرستان» فعال کنید. پیش‌فرض خاموش +است و تنظیم هر واحد مستقل است. پیامک به شماره‌های سرپرست اول تا پنجم آن +واحد ارسال می‌شود؛ در صورت فعال بودن گزینه، لینک به انتهای الگوی فعلی +پیامک اضافه می‌شود. + +لینک به شکل `https://your-domain/vt/Ab12Cd34Ef` است. دامنه از تنظیم موجود +`PUBLIC_BASE_URL` خوانده می‌شود و باید دامنه عمومی و قابل دسترس سامانه باشد. +لینک هنگام اولین ارسال پیامک ساخته می‌شود و دقیقاً ۷ روز اعتبار دارد. تمام +سرپرستان یک تیکت و تلاش‌های مجدد ارسال، همان لینک و همان تاریخ انقضا را +استفاده می‌کنند؛ ارسال مجدد اعتبار را تمدید نمی‌کند. پس از انقضا، پیامک +فقط متن اعلان را دارد. + +این لینک صرفاً میانبر صفحه واقعی `/staff/tickets//` است. هیچ ورود +خودکار، مجوز جدید، نشست magic link یا دسترسی اشتراکی ایجاد نمی‌کند. کاربران +بدون ورود به `/staff/login/?next=/vt/...` هدایت می‌شوند و پس از ورود معتبر +staff به تیکت بازمی‌گردند. مجوز مشاهده و پاسخ همان مجوزهای عادی تیکت است؛ +حساب مشتری و کارشناس بدون دسترسی با خطای ۴۰۳ مواجه می‌شوند. لینک نامعتبر، +منقضی یا متعلق به تیکت حذف‌شده پس از ورود خطای ۴۰۴ دارد. پاسخ‌های میانبر +قابل کش کردن نیستند. + +خاموش کردن گزینه، افزودن لینک به پیامک‌های بعدی را متوقف می‌کند؛ لینک‌های +قبلاً ارسال‌شده تا پایان اعتبار خود معتبرند و همچنان ورود و مجوز لازم دارند. + +برای اعمال ساختار پایگاه داده: + +```powershell +.\.venv\Scripts\python.exe manage.py migrate +``` diff --git a/tests/e2e/pages/staff_short_link.py b/tests/e2e/pages/staff_short_link.py new file mode 100644 index 0000000..c103af9 --- /dev/null +++ b/tests/e2e/pages/staff_short_link.py @@ -0,0 +1,15 @@ +from django.urls import reverse +from playwright.sync_api import expect + +from tests.e2e.pages.base import BasePage + + +class StaffShortLinkPage(BasePage): + def open_and_login(self, path, staff, password, ticket): + self.goto(path) + expect(self.page.get_by_role("heading", name="ورود کارشناسان")).to_be_visible() + self.page.locator('input[name="username"]').fill(staff.username) + self.page.locator('input[name="password"]').fill(password) + self.page.get_by_role("button", name="ورود به میز کار").click() + expect(self.page).to_have_url(self.url(reverse("staff_tickets:detail", args=[ticket.public_id]))) + expect(self.page.get_by_text(ticket.subject, exact=True).first).to_be_visible() diff --git a/tests/e2e/test_staff_ticket_short_links_e2e.py b/tests/e2e/test_staff_ticket_short_links_e2e.py new file mode 100644 index 0000000..341898c --- /dev/null +++ b/tests/e2e/test_staff_ticket_short_links_e2e.py @@ -0,0 +1,36 @@ +import pytest +from django.urls import reverse + +from apps.accounts.models import User +from apps.staff.models import OrganizationalUnit, StaffProfile +from apps.tickets.models import StaffTicketShortLink, TicketCategory +from apps.tickets.services import create_ticket +from apps.tickets.short_links import append_supervisor_ticket_link +from tests.e2e.pages.staff_short_link import StaffShortLinkPage +from tests.e2e.pages.tickets import TicketConversationPage + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] + + +def test_supervisor_short_link_login_and_reply(live_server, browser_page): + password = "E2e-short-link-password-2026" + unit = OrganizationalUnit.objects.create( + name="واحد لینک مرورگر", slug="e2e-short-unit", supervisor_sms_ticket_link_enabled=True, + ) + category = TicketCategory.objects.create(unit=unit, title="درخواست مرورگر", slug="e2e-short-category") + customer = User.objects.create_user(username="e2e-short-customer", mobile="09121112340", account_type="CUSTOMER") + staff = User.objects.create_user( + username="e2e-short-staff", mobile="09121112341", password=password, account_type="STAFF", + ) + StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس مرورگر") + ticket = create_ticket( + customer=customer, category=category, selected_expert=staff, + subject="درخواست پاسخ از لینک کوتاه", body="پیام مشتری در مرورگر", + ) + append_supervisor_ticket_link(ticket, "اعلان") + link = StaffTicketShortLink.objects.get(ticket=ticket) + path = reverse("staff_ticket_short_link", args=[link.token]) + with browser_page() as page: + StaffShortLinkPage(page, live_server.url).open_and_login(path, staff, password, ticket) + TicketConversationPage(page, live_server.url).send_reply("پاسخ کارشناس از لینک پیامک") diff --git a/tests/test_staff_ticket_short_links.py b/tests/test_staff_ticket_short_links.py new file mode 100644 index 0000000..1650152 --- /dev/null +++ b/tests/test_staff_ticket_short_links.py @@ -0,0 +1,193 @@ +import re +from datetime import timedelta +from urllib.parse import parse_qs, urlsplit +from unittest.mock import Mock + +import pytest +from django.contrib import admin +from django.contrib.auth.models import Permission +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import TicketMagicLink, User +from apps.integrations.sms.base import SMSResult +from apps.notifications.management.commands.process_notifications import _send_sms +from apps.notifications.models import NotificationJob, SMSTemplate +from apps.staff.models import OrganizationalUnit, StaffProfile +from apps.tickets.models import StaffTicketShortLink, TicketCategory +from apps.tickets.services import create_ticket, delete_ticket +from apps.tickets.short_links import append_supervisor_ticket_link + + +@pytest.fixture +def short_link_case(db): + unit = OrganizationalUnit.objects.create( + name="واحد لینک کوتاه", slug="short-link-unit", supervisor_mobile_1="09121112233", + supervisor_mobile_2="09121112234", supervisor_mobile_3="09121112235", + supervisor_mobile_4="09121112236", supervisor_mobile_5="09121112237", + supervisor_sms_ticket_link_enabled=True, + ) + 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="درخواست لینک", slug="short-link-category") + customer = User.objects.create_user(username="short-customer", mobile="09121112238", account_type="CUSTOMER") + staff = User.objects.create_user( + username="short-staff", password="Staff-password-123", mobile="09121112239", account_type="STAFF", + ) + StaffProfile.objects.create(user=staff, primary_unit=unit, display_name="کارشناس لینک") + ticket = create_ticket(customer=customer, category=category, subject="موضوع لینک کوتاه", body="متن درخواست") + return unit, customer, staff, ticket + + +def issue_link(ticket): + append_supervisor_ticket_link(ticket, "اعلان") + link = StaffTicketShortLink.objects.get(ticket=ticket) + return link, reverse("staff_ticket_short_link", args=[link.token]) + + +@pytest.mark.django_db +def test_all_five_supervisors_receive_same_short_link_without_magic_access(short_link_case, monkeypatch, settings): + unit, _, _, ticket = short_link_case + settings.PUBLIC_BASE_URL = "https://tickets.example.com/" + provider = Mock() + provider.send_message.return_value = SMSResult(True, "short-link-test") + monkeypatch.setattr("apps.notifications.management.commands.process_notifications.get_sms_provider", lambda: provider) + SMSTemplate.objects.create( + unit=unit, event_type=SMSTemplate.Event.UNIT_SUPERVISOR_NEW_TICKET, + title="الگوی لینک کوتاه", body="تیکت {ticket_number}", + ) + jobs = list(NotificationJob.objects.filter(ticket=ticket, event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("pk")) + before = timezone.now() + for job in jobs: + result, magic_link = _send_sms(job) + assert result.success and magic_link is None + sent = [call.args for call in provider.send_message.call_args_list] + link, path = issue_link(ticket) + assert before + timedelta(days=7) <= link.expires_at <= timezone.now() + timedelta(days=7) + assert re.fullmatch(r"/vt/[a-zA-Z0-9]{10}", path) + assert [mobile for mobile, _ in sent] == list(unit.supervisor_mobiles) + assert {message for _, message in sent} == { + f"تیکت {ticket.ticket_number}\nمشاهده و پاسخ: https://tickets.example.com{path}" + } + _send_sms(jobs[0]) + link.refresh_from_db() + assert StaffTicketShortLink.objects.filter(ticket=ticket).count() == 1 + assert not TicketMagicLink.objects.filter(ticket=ticket).exists() + assert not ticket.access_grants.exists() + + +@pytest.mark.django_db +def test_unit_toggle_preserves_plain_sms_and_does_not_issue_link(short_link_case): + unit, _, _, ticket = short_link_case + unit.supervisor_sms_ticket_link_enabled = False + unit.save() + ticket.refresh_from_db() + assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان" + assert not StaffTicketShortLink.objects.filter(ticket=ticket).exists() + assert OrganizationalUnit._meta.get_field("supervisor_sms_ticket_link_enabled").default is False + + +@pytest.mark.django_db +def test_anonymous_login_returns_to_real_ticket_and_can_reply(short_link_case, client): + _, _, staff, ticket = short_link_case + _, path = issue_link(ticket) + response = client.get(path) + assert response.status_code == 302 + assert urlsplit(response.url).path == "/staff/login/" + assert parse_qs(urlsplit(response.url).query)["next"] == [path] + assert "no-store" in response["Cache-Control"] + client.get(response.url) + # Destination also survives a failed attempt and a POST without query params. + response = client.post("/staff/login/", {"username": staff.username, "password": "incorrect"}) + assert response.status_code == 200 + response = client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"}) + assert response.url == path + response = client.get(path) + detail = reverse("staff_tickets:detail", args=[ticket.public_id]) + assert response.url == detail + assert client.get(detail).status_code == 200 + response = client.post(reverse("staff_tickets:reply", args=[ticket.public_id]), {"body": "پاسخ از لینک کوتاه"}) + assert response.status_code == 302 + assert ticket.ticket_messages.filter(author=staff, body="پاسخ از لینک کوتاه").exists() + assert "ticket_magic_link_id" not in client.session + assert not ticket.access_grants.exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize("expired", [False, True]) +def test_authenticated_staff_redirect_or_expired_link(short_link_case, client, expired): + _, _, staff, ticket = short_link_case + link, path = issue_link(ticket) + if expired: + StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1)) + client.force_login(staff) + response = client.get(path) + assert response.status_code == (404 if expired else 302) + assert "no-store" in response["Cache-Control"] + if not expired: + assert response.url == reverse("staff_tickets:detail", args=[ticket.public_id]) + else: + assert append_supervisor_ticket_link(ticket, "اعلان") == "اعلان" + link.refresh_from_db() + assert link.expires_at <= timezone.now() + + +@pytest.mark.django_db +def test_expiry_is_rechecked_after_login(short_link_case, client): + _, _, staff, ticket = short_link_case + link, path = issue_link(ticket) + client.get(client.get(path).url) + StaffTicketShortLink.objects.filter(pk=link.pk).update(expires_at=timezone.now()) + client.post("/staff/login/", {"username": staff.username, "password": "Staff-password-123"}) + assert client.get(path).status_code == 404 + + +@pytest.mark.django_db +def test_short_link_cannot_bypass_customer_or_other_unit_permissions(short_link_case, client): + _, customer, _, ticket = short_link_case + _, path = issue_link(ticket) + client.force_login(customer) + assert client.get(path).status_code == 403 + outsider = User.objects.create_user(username="outsider", mobile="09121112240", account_type="STAFF") + other_unit = OrganizationalUnit.objects.create(name="واحد دیگر", slug="short-other-unit") + StaffProfile.objects.create(user=outsider, primary_unit=other_unit, display_name="کارشناس دیگر") + client.force_login(outsider) + assert client.get(path).status_code == 403 + assert not ticket.access_grants.exists() + + +@pytest.mark.django_db +def test_missing_link_deleted_ticket_and_post_are_rejected(short_link_case, client): + _, _, staff, ticket = short_link_case + _, path = issue_link(ticket) + client.force_login(staff) + assert client.get("/vt/unknown").status_code == 404 + assert client.post(path).status_code == 405 + staff.is_superuser = True + staff.save() + delete_ticket(ticket_id=ticket.pk, user=staff) + assert client.get(path).status_code == 404 + + +@pytest.mark.django_db +@pytest.mark.parametrize("target", ["https://evil.example/vt/abc", "//evil.example/vt/abc", "/\\evil.example/vt/abc", "/login/", "/staff/login/"]) +def test_staff_login_rejects_unsafe_or_unrelated_next(short_link_case, client, target): + _, _, staff, _ = short_link_case + response = client.post("/staff/login/", { + "username": staff.username, "password": "Staff-password-123", "next": target, + }) + assert response.url == reverse("staff_tickets:list") + + +@pytest.mark.django_db +def test_unit_admin_exposes_toggle(short_link_case, client): + unit, _, staff, _ = short_link_case + staff.is_superuser = staff.is_staff = True + staff.save() + client.force_login(staff) + response = client.get(reverse("admin:staff_organizationalunit_change", args=[unit.pk])) + assert response.status_code == 200 + assert b'name="supervisor_sms_ticket_link_enabled"' in response.content + assert "supervisor_sms_ticket_link_enabled" in admin.site._registry[OrganizationalUnit].list_filter