link view ticket for staff

This commit is contained in:
javad committed 2026-10-13 16:09:45 +03:30
1 parent 6b546aad76
commit 089db0afa4
13 files changed
+412 -3

No files matched your search

+15 -1
View File
@@ -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
View File
@@ -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='افزودن لینک تیکت به پیامک سرپرستان'),
),
]
+5
View File
@@ -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های کوتاه تیکت کارشناسان',
},
),
]
+25
View File
@@ -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="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.")
+38
View File
@@ -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)
+2
View File
@@ -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"),
+31
View File
@@ -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
```
+15
View File
@@ -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("پاسخ کارشناس از لینک پیامک")
+193
View File
@@ -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