link view ticket for staff
This commit is contained in:
1 parent
6b546aad76
commit
089db0afa4
13 files changed
+412
-3
No files matched your search
+15
-1
@@ -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})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-2
@@ -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")}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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='افزودن لینک تیکت به پیامک سرپرستان'),
|
||||
),
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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های کوتاه تیکت کارشناسان',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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="برای دستههای قدیمی عمومی میتواند خالی باشد؛ دستههای جدید باید واحد داشته باشند.")
|
||||
|
||||
@@ -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)
|
||||
@@ -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/<str:token>", open_staff_ticket_link, name="staff_ticket_short_link"),
|
||||
path("p/<str:token>/", payment_link_views.open_link, name="payment_short_link"),
|
||||
path("healthz/", healthz, name="healthz"),
|
||||
path("media/branding/<path:path>", public_branding, name="public_branding"),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# لینک کوتاه تیکت در پیامک سرپرستان
|
||||
|
||||
در ادمین Django، بخش واحدهای سازمانی، گزینه «افزودن لینک تیکت به پیامک
|
||||
سرپرستان» را در قسمت «اطلاعرسانی بله و سرپرستان» فعال کنید. پیشفرض خاموش
|
||||
است و تنظیم هر واحد مستقل است. پیامک به شمارههای سرپرست اول تا پنجم آن
|
||||
واحد ارسال میشود؛ در صورت فعال بودن گزینه، لینک به انتهای الگوی فعلی
|
||||
پیامک اضافه میشود.
|
||||
|
||||
لینک به شکل `https://your-domain/vt/Ab12Cd34Ef` است. دامنه از تنظیم موجود
|
||||
`PUBLIC_BASE_URL` خوانده میشود و باید دامنه عمومی و قابل دسترس سامانه باشد.
|
||||
لینک هنگام اولین ارسال پیامک ساخته میشود و دقیقاً ۷ روز اعتبار دارد. تمام
|
||||
سرپرستان یک تیکت و تلاشهای مجدد ارسال، همان لینک و همان تاریخ انقضا را
|
||||
استفاده میکنند؛ ارسال مجدد اعتبار را تمدید نمیکند. پس از انقضا، پیامک
|
||||
فقط متن اعلان را دارد.
|
||||
|
||||
این لینک صرفاً میانبر صفحه واقعی `/staff/tickets/<uuid>/` است. هیچ ورود
|
||||
خودکار، مجوز جدید، نشست magic link یا دسترسی اشتراکی ایجاد نمیکند. کاربران
|
||||
بدون ورود به `/staff/login/?next=/vt/...` هدایت میشوند و پس از ورود معتبر
|
||||
staff به تیکت بازمیگردند. مجوز مشاهده و پاسخ همان مجوزهای عادی تیکت است؛
|
||||
حساب مشتری و کارشناس بدون دسترسی با خطای ۴۰۳ مواجه میشوند. لینک نامعتبر،
|
||||
منقضی یا متعلق به تیکت حذفشده پس از ورود خطای ۴۰۴ دارد. پاسخهای میانبر
|
||||
قابل کش کردن نیستند.
|
||||
|
||||
خاموش کردن گزینه، افزودن لینک به پیامکهای بعدی را متوقف میکند؛ لینکهای
|
||||
قبلاً ارسالشده تا پایان اعتبار خود معتبرند و همچنان ورود و مجوز لازم دارند.
|
||||
|
||||
برای اعمال ساختار پایگاه داده:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe manage.py migrate
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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("پاسخ کارشناس از لینک پیامک")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user