fix payement link and gallery image

This commit is contained in:
javad committed 2026-10-06 11:18:31 +03:30
1 parent de3135b588
commit 1cbbbbd0dc
24 files changed
+654 -116

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
"""Bounded parsing for explicit message-read acknowledgements."""
import json
import uuid
from django.core.exceptions import ValidationError
def message_read_ids(request):
if request.content_type == 'application/json':
try:
payload = json.loads(request.body or b'{}')
except (TypeError, ValueError):
raise ValidationError('invalid_json')
if not isinstance(payload, dict):
raise ValidationError('invalid_json')
values = payload.get('message_ids', [])
else:
values = request.POST.getlist('message_ids')
if not isinstance(values, list) or len(values) > 100:
raise ValidationError('invalid_message_ids')
identifiers = set()
for value in values:
if not isinstance(value, str) or len(value) > 45:
continue
try:
identifiers.add(uuid.UUID(value))
except ValueError:
continue
return identifiers
+13 -6
View File
@@ -1,10 +1,10 @@
"""A payment link never authenticates a user or grants access to their whole account.""" """A payment link never authenticates a user or grants access to their whole account."""
import hashlib import hashlib
import secrets
import uuid import uuid
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import Q from django.db.models import Q
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access from apps.core.session_access import grant_scoped_session_access
from apps.accounts.models import TicketMagicLink
from . import policies from . import policies
from .models import PaymentAccessLink, PaymentNotice from .models import PaymentAccessLink, PaymentNotice
@@ -30,11 +31,17 @@ def issue_payment_link(*, notice=None, receipt=None):
raise ValueError('Payment link requires a notice or receipt') raise ValueError('Payment link requires a notice or receipt')
if receipt is not None: if receipt is not None:
notice = receipt.notice notice = receipt.notice
token = secrets.token_urlsafe(32) for attempt in range(5):
link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt, token = TicketMagicLink.generate_secure_token(10)
token_hash=token_digest(token), try:
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS)) with transaction.atomic():
return link, settings.PUBLIC_BASE_URL + reverse('payments:open_link', args=[token]) link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt,
token_hash=token_digest(token),
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS))
return link, settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
except IntegrityError:
if attempt == 4:
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
def active_links(): def active_links():
+1 -1
View File
@@ -81,7 +81,7 @@ class ReceiptForm(forms.Form):
paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)]) paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)])
paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']))) paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
paid_year = forms.ChoiceField(label='سال') paid_year = forms.ChoiceField(label='سال')
receipt_image = forms.FileField(label='تصویر فیش') receipt_image = forms.FileField(label='تصویر فیش', widget=forms.ClearableFileInput(attrs={'accept': 'image/*'}))
tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False) tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False)
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea) description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
submission_key = forms.UUIDField(widget=forms.HiddenInput) submission_key = forms.UUIDField(widget=forms.HiddenInput)
+2 -1
View File
@@ -15,7 +15,8 @@ from .models import PaymentAccessLink
@never_cache @never_cache
@require_http_methods(['GET', 'POST']) @require_http_methods(['GET', 'POST'])
def open_link(request, token): def open_link(request, token):
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'[A-Za-z0-9_-]{43}', token) else None # Keep previously issued long links valid until their original expiry/revocation.
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'(?:[a-z0-9]{10}|[A-Za-z0-9_-]{43})', token) else None
if link is None: if link is None:
return access.unavailable_link(request) return access.unavailable_link(request)
if request.user.is_authenticated and request.user.pk != link.customer_id: if request.user.is_authenticated and request.user.pk != link.customer_id:
+4 -3
View File
@@ -1,7 +1,7 @@
from django.db.models import Case, F, IntegerField, Q, Value, When from django.db.models import Case, Exists, IntegerField, OuterRef, Q, Value, When
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from .models import Ticket from .models import Ticket, TicketReadState
from .policies import TicketPolicy from .policies import TicketPolicy
def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile") def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile")
def staff_ticket_rows(user): def staff_ticket_rows(user):
@@ -56,7 +56,8 @@ def filter_staff_tickets(qs, user, params):
if tab == "shared": if tab == "shared":
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id"))) qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id")))
else: else:
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True)) read_by_user=TicketReadState.objects.filter(ticket_id=OuterRef('pk'),user=user,last_read_at__gte=OuterRef('last_activity_at'))
qs=qs.alias(_read_by_current_user=Exists(read_by_user)).filter(_read_by_current_user=False)
qs=qs.distinct() qs=qs.distinct()
if tab in ("", "waiting_staff"): if tab in ("", "waiting_staff"):
return qs.annotate( return qs.annotate(
+20 -8
View File
@@ -3,6 +3,8 @@ import mimetypes
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.http import FileResponse, JsonResponse from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@@ -11,10 +13,12 @@ from django.views.decorators.http import require_GET, require_POST
from apps.attachments.delivery import signed_media_redirect from apps.attachments.delivery import signed_media_redirect
from apps.attachments.models import Attachment from apps.attachments.models import Attachment
from .models import MessageShare, Ticket from .models import MessageShare, Ticket, TicketReadState
from .policies import TicketPolicy
from apps.messaging.read_receipts import message_read_ids
from .staff_conversation import received_messages, unread_shares, visible_messages from .staff_conversation import received_messages, unread_shares, visible_messages
from .sharing import mark_share_opened, revoke_staff_share from .sharing import mark_share_opened, revoke_staff_share
from .views import staff_required from .views import staff_required, mark_messages_read
def received_message_share(request, share_id): def received_message_share(request, share_id):
@@ -66,18 +70,26 @@ def shared_message(request, share_id):
@staff_required @staff_required
@require_POST @require_POST
@never_cache @never_cache
@transaction.atomic
def read_shared_messages(request, public_id): def read_shared_messages(request, public_id):
ticket = get_object_or_404(Ticket, public_id=public_id) ticket = get_object_or_404(Ticket.objects.select_for_update(), public_id=public_id)
allowed = visible_messages(ticket, request.user) allowed = visible_messages(ticket, request.user)
ids = request.POST.getlist("message_ids")[:100] try:
# Filter strings without parsing attacker-controlled UUIDs in the ORM. ids = message_read_ids(request)
visible = {str(value) for value in allowed.values_list("public_id", flat=True)} except ValidationError:
accepted = set(ids) & visible return JsonResponse({"ok": False, "error": "invalid_message_ids"}, status=400)
acknowledged = list(allowed.filter(public_id__in=ids, deleted_at__isnull=True))
mark_messages_read(acknowledged, request.user)
accepted = [item.public_id for item in acknowledged]
grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted) grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted)
for grant in grants: for grant in grants:
if grant.first_viewed_at is None: if grant.first_viewed_at is None:
mark_share_opened(grant, request.user) mark_share_opened(grant, request.user)
return JsonResponse({"unread_shares": unread_shares(request.user)}) latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first()
if latest and latest.public_id in accepted and TicketPolicy.can_view(request.user, ticket):
TicketReadState.objects.update_or_create(ticket=ticket, user=request.user,
defaults={'last_read_at': timezone.now(), 'last_read_message': latest})
return JsonResponse({"unread_shares": unread_shares(request.user), "read_ids": [str(value) for value in accepted]})
@staff_required @staff_required
+22 -21
View File
@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied,ValidationError
from django.http import FileResponse, HttpResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum from django.db.models import Count, Max, Prefetch, Q, Sum
from django.shortcuts import get_object_or_404,redirect,render from django.shortcuts import get_object_or_404,redirect,render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -32,7 +32,7 @@ from apps.core.persian_text import normalize_persian_text
from apps.integrations.voip import place_voip_call from apps.integrations.voip import place_voip_call
from apps.staff.models import OrganizationalUnit, StaffProfile from apps.staff.models import OrganizationalUnit, StaffProfile
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketReadState,TicketTag from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketTag
from .forms import StaffMessageShareForm, StaffTicketShareForm from .forms import StaffMessageShareForm, StaffTicketShareForm
from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context
from .policies import DispatchPolicy, TicketPolicy from .policies import DispatchPolicy, TicketPolicy
@@ -97,7 +97,7 @@ def conversation_page(queryset,before=None):
return conversation,has_older,(conversation[0].public_id if conversation and has_older else None) return conversation,has_older,(conversation[0].public_id if conversation and has_older else None)
def mark_messages_read(conversation,user): def mark_messages_read(conversation,user):
MessageReadReceipt.objects.bulk_create( MessageReadReceipt.objects.bulk_create(
[MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id], [MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id and not item.deleted_at],
ignore_conflicts=True, ignore_conflicts=True,
) )
def customer_required(view): def customer_required(view):
@@ -118,14 +118,18 @@ def customer_ticket_actor(request,ticket):
return request.user,False return request.user,False
link=get_ticket_magic_access(request,ticket) link=get_ticket_magic_access(request,ticket)
return (link.customer,True) if link else (None,False) return (link.customer,True) if link else (None,False)
def conversation_version(ticket,latest): def conversation_version(ticket,latest,include_internal=False):
query=ticket.ticket_messages.all()
if not include_internal: query=query.filter(is_internal=False)
edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at'))
revision=hashlib.sha256(repr(edits).encode()).hexdigest()[:20]
source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}" source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}"
return hashlib.sha256(source.encode()).hexdigest()[:20] return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision
def conversation_poll_response(request,ticket,actor,include_internal=False): def conversation_poll_response(request,ticket,actor,include_internal=False):
qs=ticket.ticket_messages.all() qs=ticket.ticket_messages.all()
if not include_internal: qs=qs.filter(is_internal=False) if not include_internal: qs=qs.filter(is_internal=False)
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first() latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
version=conversation_version(ticket,latest) version=conversation_version(ticket,latest,include_internal=include_internal)
requested_cursor = request.GET.get("cursor", "") requested_cursor = request.GET.get("cursor", "")
latest_cursor = str(latest.public_id) if latest else "" latest_cursor = str(latest.public_id) if latest else ""
# A client can have the newest version token but still miss the DOM update # A client can have the newest version token but still miss the DOM update
@@ -136,7 +140,7 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
response["Cache-Control"]="private, no-cache, no-store, must-revalidate" response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
return response return response
cursor=requested_cursor cursor=requested_cursor
replace=not cursor or not latest or str(latest.public_id)==cursor replace=not cursor or not latest or str(latest.public_id)==cursor or request.GET.get('version','').partition(':')[2]!=version.partition(':')[2]
messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile") messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
if not replace: if not replace:
cursor_message=qs.filter(public_id=cursor).only("created_at","id").first() cursor_message=qs.filter(public_id=cursor).only("created_at","id").first()
@@ -148,14 +152,14 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent)) recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent))
else: else:
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE]) conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
if include_internal: mark_messages_read(conversation,actor)
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor} context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
template="tickets/_messages.html" template="tickets/_messages.html"
if include_internal: if include_internal:
context.update(message_context(ticket,actor,conversation)) context.update(message_context(ticket,actor,conversation))
template="tickets/_staff_messages.html" template="tickets/_staff_messages.html"
html=render_to_string(template,context,request=request) html=render_to_string(template,context,request=request)
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()}) delivered = conversation[-1] if conversation else latest
response=JsonResponse({"changed":True,"version":version,"cursor":str(delivered.public_id) if delivered else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
response["Cache-Control"]="private, no-cache, no-store, must-revalidate" response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
return response return response
@customer_required @customer_required
@@ -327,17 +331,19 @@ def customer_poll(request,public_id):
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login")) if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
return conversation_poll_response(request,ticket,actor,include_internal=False) return conversation_poll_response(request,ticket,actor,include_internal=False)
@require_POST @require_POST
@transaction.atomic
def customer_read(request,public_id): def customer_read(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id) from apps.messaging.read_receipts import message_read_ids
ticket=get_object_or_404(Ticket.objects.select_for_update(),public_id=public_id)
actor,_=customer_ticket_actor(request,ticket) actor,_=customer_ticket_actor(request,ticket)
if actor is None: return JsonResponse({"ok":False},status=401) if actor is None: return JsonResponse({"ok":False},status=401)
try: message_ids=json.loads(request.body or b"{}").get("message_ids",[]) try: message_ids=message_read_ids(request)
except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400) except ValidationError: return JsonResponse({"ok":False,"error":"invalid_message_ids"},status=400)
messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor) acknowledged=list(ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False,deleted_at__isnull=True))
MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True) mark_messages_read(acknowledged,actor)
from apps.notifications.services import cancel_staff_reply_sms_if_read from apps.notifications.services import cancel_staff_reply_sms_if_read
cancel_staff_reply_sms_if_read(ticket,actor) cancel_staff_reply_sms_if_read(ticket,actor)
return JsonResponse({"ok":True,"read":messages_qs.count()}) return JsonResponse({"ok":True,"read":sum(item.author_id != actor.pk for item in acknowledged),"read_ids":[str(item.public_id) for item in acknowledged]})
@require_POST @require_POST
def customer_reply(request,public_id): def customer_reply(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES) ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES)
@@ -450,12 +456,8 @@ def staff_detail(request,public_id):
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id) ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
visible=visible_messages(ticket,request.user) visible=visible_messages(ticket,request.user)
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
mark_messages_read(conversation,request.user)
mark_ticket_share_opened(ticket,request.user) mark_ticket_share_opened(ticket,request.user)
message_sharing=message_context(ticket,request.user,conversation) message_sharing=message_context(ticket,request.user,conversation)
if TicketPolicy.can_view(request.user,ticket):
last=conversation[-1] if conversation else None
TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5] history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk")) announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk"))
@@ -472,7 +474,7 @@ def staff_detail(request,public_id):
customer_notices = payment_selectors.notices_with_totals( customer_notices = payment_selectors.notices_with_totals(
payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user) payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user)
).order_by('-created_at', '-pk')[:3] ).order_by('-created_at', '-pk')[:3]
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest,include_internal=True),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
@staff_required @staff_required
@@ -514,7 +516,6 @@ def staff_sms_retry(request,public_id,job_id):
def staff_messages(request,public_id): def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id) ticket=get_object_or_404(Ticket,public_id=public_id)
conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before")) conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
mark_messages_read(conversation,request.user)
return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)}) return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)})
@staff_required @staff_required
def staff_poll(request,public_id): def staff_poll(request,public_id):
+2
View File
@@ -9,6 +9,7 @@ from django.urls import include, path
from django.views.static import serve from django.views.static import serve
from apps.core.models import SiteBranding from apps.core.models import SiteBranding
from apps.payments import link_views as payment_link_views
def healthz(request): def healthz(request):
@@ -48,6 +49,7 @@ def web_manifest(request):
return response return response
urlpatterns = [ urlpatterns = [
path("p/<str:token>/", payment_link_views.open_link, name="payment_short_link"),
path("healthz/", healthz, name="healthz"), path("healthz/", healthz, name="healthz"),
path("media/branding/<path:path>", public_branding, name="public_branding"), path("media/branding/<path:path>", public_branding, name="public_branding"),
path("service-worker.js", service_worker, name="service_worker"), path("service-worker.js", service_worker, name="service_worker"),
+21
View File
@@ -0,0 +1,21 @@
# ثبت مشاهده پیام و polling تیکت
دریافت پیام، بازکردن صفحه، بارگذاری مکاتبات قدیمی و polling به‌تنهایی رسید مشاهده ثبت نمی‌کنند. ثبت seen از یک درخواست POST جدا انجام می‌شود؛ نمای فعلی تیکت و مدل‌های موجود حفظ شده‌اند.
مرورگر فقط در تب قابل مشاهده، پیام‌هایی را ارسال می‌کند که حداقل ۴۸ پیکسل از ارتفاع آن‌ها (یا تمام ارتفاع پیام کوتاه‌تر) در محدوده مکاتبات و نمایشگر قرار گرفته باشد. پس از ثابت‌شدن موقعیت برای ۲۵۰ میلی‌ثانیه، موقعیت دوباره بررسی می‌شود. محدوده واقعی نمایشگر در موبایل و بزرگ‌نمایی لحاظ می‌شود و مرکز بخش نمایان پیام نباید زیر پنجره یا لایه دیگری باشد. پیام حذف‌شده ارسال نمی‌شود. پاسخ موفق، شناسه‌های پذیرفته‌شده را برمی‌گرداند تا درخواست تکراری ارسال نشود؛ خطای موقت شبکه باعث تلاش مجدد می‌شود. شناسه‌ها فقط در حافظه همان صفحه نگهداری می‌شوند.
سرور در هر درخواست مجوز را دوباره بررسی می‌کند: مشتری و magic link فقط پیام‌های عمومی همان تیکت، و کارشناس فقط پیام‌های مجاز خود را می‌توانند تأیید کنند. پیام خود کاربر و پیام حذف‌شده رسید مشاهده نمی‌گیرند. ورودی نامعتبر پاسخ ۴۰۰ می‌گیرد، شناسه‌های خراب یا خارج از محدوده نادیده گرفته می‌شوند، و هر درخواست حداکثر ۱۰۰ شناسه دارد. CSRF، ثبت تکراریِ بدون اثر و کنترل ابطال حفظ شده‌اند. قفل تیکت، ثبت مشاهده را با لغو اشتراک و پردازش اعلان هماهنگ می‌کند.
پاسخ polling پس از لغو دسترسی یا جایگزین‌شدن درخواست اعمال نمی‌شود. در خطای دسترسی، پیام‌های صفحه پاک و polling متوقف می‌شود. نشانگر polling آخرین پیام تحویل‌داده‌شده است، تا صف بیش از ۵۰ پیام باعث حذف مکاتبات بین دو درخواست نشود. ویرایش و حذف پیام قدیمی، نسخه polling را تغییر می‌دهد و فهرست تازه دریافت می‌شود.
وضعیت خوانده‌شدن تیکت فقط پس از تأیید مشاهده آخرین پیام مجاز ثبت می‌شود. فیلتر «خوانده‌نشده» به رسید همان کاربر وابسته است و مشاهده همکار دیگر آن را تغییر نمی‌دهد. زمان بازشدن «اشتراک کل تیکت» در ممیزی اشتراک همچنان مستقل از رسید تک‌پیام است.
این قرارداد مشاهده در صفحه را ثبت می‌کند و اثبات خواندن انسانی متن نیست. رسیدهای قدیمی حذف یا بازنویسی نشده‌اند؛ داده‌های تاریخی به‌تنهایی امکان تشخیص seen اشتباه را نمی‌دهند. اشتراک پیام مجوز کل تیکت ایجاد نمی‌کند؛ دسترسی کامل قبلی کاربر همچنان برقرار است.
تست‌های دائمی:
```bash
.venv/Scripts/python.exe -m pytest tests/test_message_read_receipts.py tests/e2e/test_message_read_receipts_e2e.py -q
```
تست‌ها از دیتابیس و سرور جداگانه استفاده می‌کنند. تب پس‌زمینه در headless Chromium با سیگنال visibility کنترل‌شده شبیه‌سازی می‌شود؛ محدوده نمایش، اسکرول، خطای شبکه و رقابت لغو دسترسی با پاسخ polling نیز در مرورگر بررسی می‌شوند.
+2 -1
View File
@@ -1,6 +1,6 @@
# لینک موقت پرداخت # لینک موقت پرداخت
لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، اکنون یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. مسیر ورودی `/chaharsu/link/<token>/` پس از بررسی، به آدرس بدون توکن منتقل می‌شود. لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. لینک‌های جدید مسیر کوتاه `/p/<token>/` دارند؛ توکن ۱۰ کاراکتری با همان مولد تصادفی امن magic link ساخته و فقط هش آن ذخیره می‌شود. برخورد توکن حداکثر پنج بار در تراکنش مستقل تکرار می‌شود. مسیر قدیمی `/chaharsu/link/<token>/` نیز برای لینک‌های قبلی تا انقضا یا ابطال معتبر است. هر دو مسیر پس از بررسی، به آدرس بدون توکن منتقل می‌شوند.
## محدوده و عمر دسترسی ## محدوده و عمر دسترسی
@@ -9,6 +9,7 @@
- مشاهده اطلاعات بانکی، کپی تک‌فیلد و کامل، ثبت فیش مرتبط و مستقل، مشاهده وضعیت، دریافت تصویر و اصلاح فیش ردشده بدون ورود ممکن است. - مشاهده اطلاعات بانکی، کپی تک‌فیلد و کامل، ثبت فیش مرتبط و مستقل، مشاهده وضعیت، دریافت تصویر و اصلاح فیش ردشده بدون ورود ممکن است.
- فیلد «شناسه واریز که با آن پرداخت انجام داده‌اید» هنگام ارسال فیش اختیاری است؛ خالی‌بودن آن مانع ارسال نمی‌شود. هشدار ثبت شناسه در عملیات پرداخت بانکی همچنان نمایش داده می‌شود. - فیلد «شناسه واریز که با آن پرداخت انجام داده‌اید» هنگام ارسال فیش اختیاری است؛ خالی‌بودن آن مانع ارسال نمی‌شود. هشدار ثبت شناسه در عملیات پرداخت بانکی همچنان نمایش داده می‌شود.
- انقضا، ابطال و غیرفعال‌شدن مشتری در هر درخواست دوباره بررسی می‌شود. CSRF و اعتبارسنجی تصویر، مبلغ و تاریخ همچنان برقرارند. - انقضا، ابطال و غیرفعال‌شدن مشتری در هر درخواست دوباره بررسی می‌شود. CSRF و اعتبارسنجی تصویر، مبلغ و تاریخ همچنان برقرارند.
- دکمه انتخاب تصویر فیش از ورودی `accept="image/*"` بدون `capture` استفاده می‌کند تا انتخاب عکس و اسکرین‌شات از گالری ممکن باشد. نمایش دقیق انتخاب‌گر به مرورگر و سیستم‌عامل گوشی بستگی دارد؛ فرمت و اندازه فایل همچنان در مرورگر و سرور اعتبارسنجی می‌شوند.
- تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد. - تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد.
- اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود. - اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود.
+77
View File
@@ -0,0 +1,77 @@
/* Receiving a message is not evidence of seeing it. No receipts are cached on disk. */
document.querySelectorAll('[data-conversation-workspace][data-read-url]').forEach(workspace=>{
const scroller=workspace.querySelector('[data-conversation]');
if(!scroller)return;
const acknowledged=new Set(),tracked=new Set();
let running=false,timer=0,controller=null;
const cookie=name=>document.cookie.split('; ').find(row=>row.startsWith(`${name}=`))?.split('=').slice(1).join('=')||'';
const visibleIds=()=>{
if(document.visibilityState!=='visible' || workspace.dataset.accessDenied)return [];
const bounds=scroller.getBoundingClientRect();
const viewport=window.visualViewport;
const viewTop=viewport?.offsetTop||0,viewLeft=viewport?.offsetLeft||0;
const top=Math.max(bounds.top,viewTop),bottom=Math.min(bounds.bottom,viewTop+(viewport?.height||innerHeight));
const left=Math.max(bounds.left,viewLeft),right=Math.min(bounds.right,viewLeft+(viewport?.width||innerWidth));
return [...scroller.querySelectorAll('[data-message-id]')].filter(message=>{
if(acknowledged.has(message.dataset.messageId) || message.hasAttribute('data-message-deleted'))return false;
const box=message.getBoundingClientRect();
const height=Math.min(box.bottom,bottom)-Math.max(box.top,top);
if(box.height<=0 || box.width<=0 || height<Math.min(48,box.height) || Math.min(box.right,right)<=Math.max(box.left,left))return false;
const x=(Math.max(box.left,left)+Math.min(box.right,right))/2;
const y=(Math.max(box.top,top)+Math.min(box.bottom,bottom))/2;
return message.contains(document.elementFromPoint(x,y));
}).slice(0,100).map(message=>message.dataset.messageId);
};
const schedule=()=>{
clearTimeout(timer);
timer=setTimeout(send,250);
};
const denyAccess=()=>{
workspace.dataset.accessDenied='true';
delete workspace.dataset.pollUrl;
controller?.abort();clearTimeout(timer);
scroller.querySelectorAll('[data-load-older]').forEach(button=>window.htmx?.trigger(button,'htmx:abort'));
scroller.querySelectorAll('[data-message],.date-divider,.conversation-empty,[data-load-older]').forEach(item=>item.remove());
const notice=document.createElement('p');
notice.className='conversation-empty';notice.textContent='دسترسی به این مکاتبات لغو شده است.';
scroller.append(notice);
const composer=workspace.querySelector('[data-composer-shell]');if(composer)composer.hidden=true;
const jump=workspace.querySelector('[data-jump-latest]');if(jump)jump.hidden=true;
};
async function send(){
if(running)return;
const ids=visibleIds();if(!ids.length)return;
running=true;controller=new AbortController();
try{
const response=await fetch(workspace.dataset.readUrl,{method:'POST',credentials:'same-origin',cache:'no-store',signal:controller.signal,
headers:{'Content-Type':'application/json','X-CSRFToken':decodeURIComponent(cookie('csrftoken'))},body:JSON.stringify({message_ids:ids})});
if(response.redirected || [401,403,404].includes(response.status)){workspace.dispatchEvent(new Event('conversation:access-denied'));return}
if(!response.ok)throw new Error(`HTTP ${response.status}`);
const data=await response.json();
if(workspace.dataset.accessDenied)return;
(data.read_ids||[]).forEach(id=>acknowledged.add(id));
if(typeof data.unread_shares==='number')document.querySelectorAll('[data-share-unread-count]').forEach(badge=>{
badge.textContent=String(data.unread_shares);badge.hidden=!data.unread_shares;
});
}catch(error){if(error.name!=='AbortError')console.error('Message read acknowledgement failed',error)}
finally{running=false;controller=null;if(visibleIds().length)timer=setTimeout(send,3000)}
}
const observer=new IntersectionObserver(schedule,{root:scroller,threshold:[0,0.1,0.5,1]});
const observeMessages=()=>{
tracked.forEach(message=>{if(!scroller.contains(message)){observer.unobserve(message);tracked.delete(message)}});
scroller.querySelectorAll('[data-message-id]').forEach(message=>{if(!tracked.has(message)){tracked.add(message);observer.observe(message)}});
schedule();
};
new MutationObserver(observeMessages).observe(scroller,{childList:true,subtree:true});
scroller.addEventListener('scroll',schedule,{passive:true});
new ResizeObserver(schedule).observe(scroller);
document.addEventListener('click',schedule);
document.addEventListener('keydown',schedule);
window.addEventListener('resize',schedule);
window.visualViewport?.addEventListener('resize',schedule);
document.addEventListener('visibilitychange',schedule);
window.addEventListener('pageshow',schedule);
workspace.addEventListener('conversation:access-denied',denyAccess);
window.addEventListener('pagehide',()=>{clearTimeout(timer);controller?.abort()});
observeMessages();
});
+10 -18
View File
@@ -87,20 +87,8 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
form?.addEventListener('submit',()=>{form.dataset.submitting='true'}); form?.addEventListener('submit',()=>{form.dataset.submitting='true'});
window.addEventListener('pageshow',()=>{if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}}); window.addEventListener('pageshow',()=>{if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}});
const pollUrl=workspace.dataset.pollUrl;
const readUrl=workspace.dataset.readUrl;
const pollInterval=3000; const pollInterval=3000;
let pollController=null,pollRunning=false,pollGeneration=0,pollErrors=0,nextPollAt=0; let pollController=null,pollRunning=false,pollGeneration=0,pollErrors=0,nextPollAt=0;
let readRunning=false;
const cookie=name=>document.cookie.split('; ').find(row=>row.startsWith(`${name}=`))?.split('=').slice(1).join('=')||'';
const acknowledgeVisibleMessages=async()=>{
if(!readUrl||readRunning||document.visibilityState!=='visible')return;
const messageIds=[...scroller.querySelectorAll('[data-message-id]')].map(item=>item.dataset.messageId).filter(Boolean);
if(!messageIds.length)return;
readRunning=true;
try{await fetch(readUrl,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':decodeURIComponent(cookie('csrftoken'))},body:JSON.stringify({message_ids:messageIds})})}catch(error){console.error('Message read acknowledgement failed',error)}finally{readRunning=false}
};
acknowledgeVisibleMessages();
// Keep the staff/customer conversation synchronized even while another tab is // Keep the staff/customer conversation synchronized even while another tab is
// active. Browsers may throttle background timers, so visibility is used only // active. Browsers may throttle background timers, so visibility is used only
// to trigger an immediate catch-up, never as a hard polling gate. // to trigger an immediate catch-up, never as a hard polling gate.
@@ -109,7 +97,7 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
// local networks, installed PWAs and some mobile browsers). Let fetch be the // local networks, installed PWAs and some mobile browsers). Let fetch be the
// source of truth and use the existing error backoff when the origin really // source of truth and use the existing error backoff when the origin really
// is unavailable. // is unavailable.
const canPoll=()=>Boolean(pollUrl); const canPoll=()=>Boolean(workspace.dataset.pollUrl) && !workspace.dataset.accessDenied;
const schedulePoll=()=>{const delay=pollErrors?Math.min(30000,pollInterval*(2**Math.min(pollErrors,3))):pollInterval;nextPollAt=Date.now()+delay}; const schedulePoll=()=>{const delay=pollErrors?Math.min(30000,pollInterval*(2**Math.min(pollErrors,3))):pollInterval;nextPollAt=Date.now()+delay};
const applyPollUpdate=data=>{ const applyPollUpdate=data=>{
if(!data.changed)return; if(!data.changed)return;
@@ -124,7 +112,6 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
const count=workspace.querySelector('[data-conversation-count]');if(count)count.textContent=String(data.count??''); const count=workspace.querySelector('[data-conversation-count]');if(count)count.textContent=String(data.count??'');
workspace.querySelectorAll('[data-ticket-status]').forEach(item=>item.textContent=data.status||item.textContent); workspace.querySelectorAll('[data-ticket-status]').forEach(item=>item.textContent=data.status||item.textContent);
groupDates(); groupDates();
acknowledgeVisibleMessages();
scroller.querySelectorAll('img:not([data-poll-bound]),audio:not([data-poll-bound])').forEach(media=>{media.dataset.pollBound='true';const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(wasAtBottom)scrollLatest(false)},{once:true})}); scroller.querySelectorAll('img:not([data-poll-bound]),audio:not([data-poll-bound])').forEach(media=>{media.dataset.pollBound='true';const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(wasAtBottom)scrollLatest(false)},{once:true})});
if(wasAtBottom){scrollLatest(true);jump.hidden=true}else jump.hidden=false; if(wasAtBottom){scrollLatest(true);jump.hidden=true}else jump.hidden=false;
}; };
@@ -132,17 +119,22 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{
if(!canPoll()||pollRunning)return; if(!canPoll()||pollRunning)return;
const generation=++pollGeneration;pollRunning=true;const controller=new AbortController();pollController=controller; const generation=++pollGeneration;pollRunning=true;const controller=new AbortController();pollController=controller;
try{ try{
const url=new URL(pollUrl,location.origin);url.searchParams.set('version',workspace.dataset.pollVersion||'');url.searchParams.set('cursor',workspace.dataset.pollCursor||''); const url=new URL(workspace.dataset.pollUrl,location.origin);url.searchParams.set('version',workspace.dataset.pollVersion||'');url.searchParams.set('cursor',workspace.dataset.pollCursor||'');
const response=await fetch(url,{method:'GET',signal:controller.signal,cache:'no-store',headers:{Accept:'application/json'}}); const response=await fetch(url,{method:'GET',signal:controller.signal,cache:'no-store',headers:{Accept:'application/json'}});
if(response.redirected){location.assign(response.url);return} if(generation!==pollGeneration || !canPoll())return;
if(response.redirected){workspace.dispatchEvent(new Event('conversation:access-denied'));location.assign(response.url);return}
if([401,403,404].includes(response.status)){workspace.dispatchEvent(new Event('conversation:access-denied'));return}
if(!response.ok)throw new Error(`HTTP ${response.status}`); if(!response.ok)throw new Error(`HTTP ${response.status}`);
applyPollUpdate(await response.json());pollErrors=0; const data=await response.json();
if(generation!==pollGeneration || !canPoll())return;
applyPollUpdate(data);pollErrors=0;
}catch(error){if(error.name!=='AbortError'){pollErrors+=1;console.error('Ticket polling failed',error)}}finally{if(generation===pollGeneration){pollRunning=false;pollController=null;schedulePoll()}} }catch(error){if(error.name!=='AbortError'){pollErrors+=1;console.error('Ticket polling failed',error)}}finally{if(generation===pollGeneration){pollRunning=false;pollController=null;schedulePoll()}}
}; };
const startPolling=()=>{if(!canPoll()||pollRunning||Date.now()<nextPollAt)return;poll()}; const startPolling=()=>{if(!canPoll()||pollRunning||Date.now()<nextPollAt)return;poll()};
const stopPolling=()=>{pollGeneration+=1;if(pollController){pollController.abort();pollController=null}pollRunning=false;nextPollAt=0}; const stopPolling=()=>{pollGeneration+=1;if(pollController){pollController.abort();pollController=null}pollRunning=false;nextPollAt=0};
const restartPolling=()=>{stopPolling();startPolling()}; const restartPolling=()=>{stopPolling();startPolling()};
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible'){restartPolling();acknowledgeVisibleMessages()}}); document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')restartPolling()});
workspace.addEventListener('conversation:access-denied',stopPolling);
window.addEventListener('focus',startPolling); window.addEventListener('focus',startPolling);
window.addEventListener('pageshow',restartPolling); window.addEventListener('pageshow',restartPolling);
window.addEventListener('online',restartPolling); window.addEventListener('online',restartPolling);
+19 -50
View File
@@ -1,54 +1,23 @@
(() => { (() => {
const badges = document.querySelectorAll('[data-share-unread-count]'); const badges=document.querySelectorAll('[data-share-unread-count]');
if (!badges.length) return; if(!badges.length)return;
const workspace = document.querySelector('[data-share-read-url]'); const countUrl=badges[0].dataset.countUrl;
const scroller = workspace?.querySelector('[data-conversation]'); let running=false;
const countUrl = badges[0].dataset.countUrl; const refresh=async()=>{
let running = false; if(running || document.visibilityState!=='visible')return;
const cookie = name => document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=').slice(1).join('=') || ''; running=true;
const refresh = async () => { try{
if (running || document.visibilityState !== 'visible') return; const response=await fetch(countUrl,{cache:'no-store',credentials:'same-origin'});
running = true; if(!response.ok || response.redirected)return;
try { const data=await response.json();
const ids = []; badges.forEach(badge=>{badge.textContent=String(data.unread_shares);badge.hidden=!data.unread_shares});
if (scroller) { }catch(_){
const bounds = scroller.getBoundingClientRect(); // Retain the last count until the next successful refresh.
scroller.querySelectorAll('[data-message-id]').forEach(message => { }finally{running=false}
const box = message.getBoundingClientRect();
if (box.bottom > Math.max(bounds.top, 0) && box.top < Math.min(bounds.bottom, innerHeight)) ids.push(message.dataset.messageId);
});
}
const body = new URLSearchParams();
ids.forEach(id => body.append('message_ids', id));
const response = await fetch(workspace?.dataset.shareReadUrl || countUrl, workspace ? {
method: 'POST', body, headers: {'X-CSRFToken': cookie('csrftoken')}, cache: 'no-store', credentials: 'same-origin'
} : {cache: 'no-store', credentials: 'same-origin'});
if (response.status === 403 && workspace) {
scroller.querySelectorAll('[data-message], .date-divider, .conversation-empty').forEach(item => item.remove());
const notice = document.createElement('p');
notice.className = 'conversation-empty';
notice.textContent = 'دسترسی به این اشتراک لغو شده است.';
scroller.append(notice);
workspace.removeAttribute('data-poll-url');
const counts = await fetch(countUrl, {cache: 'no-store', credentials: 'same-origin'});
if (counts.ok) {
const data = await counts.json();
badges.forEach(badge => {badge.textContent = data.unread_shares; badge.hidden = !data.unread_shares;});
}
return;
}
if (!response.ok) return;
const data = await response.json();
badges.forEach(badge => {badge.textContent = data.unread_shares; badge.hidden = !data.unread_shares;});
} catch (_) {
// A failed request leaves the count and read status available for retry.
} finally { running = false; }
}; };
scroller?.addEventListener('scroll', refresh, {passive: true}); document.addEventListener('visibilitychange',refresh);
document.addEventListener('visibilitychange', refresh); window.addEventListener('pageshow',refresh);
window.addEventListener('pageshow', refresh); window.addEventListener('focus',refresh);
window.addEventListener('focus', refresh); setInterval(refresh,15000);
document.body.addEventListener('htmx:afterSwap', refresh);
setInterval(refresh, workspace ? 3000 : 15000);
refresh(); refresh();
})(); })();
+1 -1
View File
@@ -1,5 +1,5 @@
{% load jalali %}{% for item in conversation %} {% load jalali %}{% for item in conversation %}
<article class="message {% if item.is_internal %}note{% elif item.author == viewer_user %}mine{% endif %}" data-message data-message-id="{{ item.public_id }}" data-date="{{ item.created_at|date:'Y-m-d' }}" data-day-label="{{ item.created_at|jalali_date }}"{% if item.author == viewer_user and not item.is_internal and not item.deleted_at %} data-chat-coach-own data-chat-coach-at="{{ item.created_at|date:'U' }}"{% endif %}> <article class="message {% if item.is_internal %}note{% elif item.author == viewer_user %}mine{% endif %}" data-message {% if item.deleted_at %}data-message-deleted{% endif %} data-message-id="{{ item.public_id }}" data-date="{{ item.created_at|date:'Y-m-d' }}" data-day-label="{{ item.created_at|jalali_date }}"{% if item.author == viewer_user and not item.is_internal and not item.deleted_at %} data-chat-coach-own data-chat-coach-at="{{ item.created_at|date:'U' }}"{% endif %}>
<div class="message-avatar" aria-hidden="true">{{ item.author_name|slice:':1' }}</div> <div class="message-avatar" aria-hidden="true">{{ item.author_name|slice:':1' }}</div>
<div class="message-bubble"> <div class="message-bubble">
<header><b>{{ item.author_name }}</b>{% if item.is_internal %}<span class="internal-label">🔒 یادداشت داخلی</span>{% endif %}<time title="{{ item.created_at|jalali_datetime }}">{{ item.created_at|persian_time }}</time></header> <header><b>{{ item.author_name }}</b>{% if item.is_internal %}<span class="internal-label">🔒 یادداشت داخلی</span>{% endif %}<time title="{{ item.created_at|jalali_datetime }}">{{ item.created_at|persian_time }}</time></header>
+1 -1
View File
@@ -1,5 +1,5 @@
{% load jalali %}{% for item in conversation %} {% load jalali %}{% for item in conversation %}
<article class="message {% if item.is_internal %}note{% elif item.author == viewer_user %}mine{% endif %}" data-message data-message-id="{{ item.public_id }}" data-date="{{ item.created_at|date:'Y-m-d' }}" data-day-label="{{ item.created_at|jalali_date }}"{% if item.author == viewer_user and not item.is_internal and not item.deleted_at %} data-chat-coach-own data-chat-coach-at="{{ item.created_at|date:'U' }}"{% endif %}> <article class="message {% if item.is_internal %}note{% elif item.author == viewer_user %}mine{% endif %}" data-message {% if item.deleted_at %}data-message-deleted{% endif %} data-message-id="{{ item.public_id }}" data-date="{{ item.created_at|date:'Y-m-d' }}" data-day-label="{{ item.created_at|jalali_date }}"{% if item.author == viewer_user and not item.is_internal and not item.deleted_at %} data-chat-coach-own data-chat-coach-at="{{ item.created_at|date:'U' }}"{% endif %}>
<div class="message-avatar" aria-hidden="true">{{ item.author_name|slice:':1' }}</div> <div class="message-avatar" aria-hidden="true">{{ item.author_name|slice:':1' }}</div>
<div class="message-bubble"> <div class="message-bubble">
<header><b>{{ item.author_name }}</b>{% if item.is_internal %}<span class="internal-label">🔒 یادداشت داخلی</span>{% endif %}<time title="{{ item.created_at|jalali_datetime }}">{{ item.created_at|persian_time }}</time></header> <header><b>{{ item.author_name }}</b>{% if item.is_internal %}<span class="internal-label">🔒 یادداشت داخلی</span>{% endif %}<time title="{{ item.created_at|jalali_datetime }}">{{ item.created_at|persian_time }}</time></header>
+1
View File
@@ -26,6 +26,7 @@
{% if magic_link_access %}<footer class="ticket-detail-credit">{{ developer_credit }}</footer>{% endif %} {% if magic_link_access %}<footer class="ticket-detail-credit">{{ developer_credit }}</footer>{% endif %}
</div> </div>
{% include 'tickets/_image_viewer.html' %} {% include 'tickets/_image_viewer.html' %}
<script src="{% static 'js/conversation-read.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script> <script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script>
{% endblock %} {% endblock %}
{% block footer %}{% endblock %} {% block footer %}{% endblock %}
+2 -1
View File
@@ -1,7 +1,7 @@
{% extends 'base.html' %}{% load static jalali %} {% extends 'base.html' %}{% load static jalali %}
{% block title %}{{ page_title }}{% endblock %} {% block title %}{{ page_title }}{% endblock %}
{% block content %} {% block content %}
<div class="ticket-workspace staff-ticket-workspace" data-conversation-workspace data-share-read-url="{% url 'staff_tickets:read_shares' ticket.public_id %}" data-poll-url="{% url 'staff_tickets:poll' ticket.public_id %}" data-poll-version="{{ poll_version }}" data-poll-cursor="{{ poll_cursor }}" data-draft-key="ticket-{{ ticket.public_id }}-staff" data-reply-result="{{ request.GET.reply_result|default:'' }}"> <div class="ticket-workspace staff-ticket-workspace" data-conversation-workspace data-read-url="{% url 'staff_tickets:read_shares' ticket.public_id %}" data-poll-url="{% url 'staff_tickets:poll' ticket.public_id %}" data-poll-version="{{ poll_version }}" data-poll-cursor="{{ poll_cursor }}" data-draft-key="ticket-{{ ticket.public_id }}-staff" data-reply-result="{{ request.GET.reply_result|default:'' }}">
<header class="ticket-workspace-head"> <header class="ticket-workspace-head">
<div class="ticket-title"><a class="workspace-back" href="{% url 'staff_tickets:list' %}" aria-label="بازگشت">→</a><div><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span><h1>{{ ticket.subject }}</h1></div></div> <div class="ticket-title"><a class="workspace-back" href="{% url 'staff_tickets:list' %}" aria-label="بازگشت">→</a><div><span class="ticket-number" dir="ltr">{{ ticket.ticket_number }}</span><h1>{{ ticket.subject }}</h1></div></div>
<div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div> <div class="ticket-head-actions"><span class="badge" data-ticket-status>{{ ticket.get_status_display }}</span>{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}<form method="post" action="{% url 'staff_tickets:claim' ticket.public_id %}">{% csrf_token %}<button type="submit">اختصاص به من</button></form>{% endif %}<button type="button" class="icon-button" data-toggle-panel="ticket-actions" aria-label="عملیات تیکت">⚙</button><button type="button" class="icon-button" data-toggle-panel="customer-info" aria-label="اطلاعات مشتری">◉</button></div>
@@ -46,6 +46,7 @@
</div> </div>
</div> </div>
{% include 'tickets/_image_viewer.html' %} {% include 'tickets/_image_viewer.html' %}
<script src="{% static 'js/conversation-read.js' %}?v={{ static_asset_version }}"></script>
<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}">
<script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script> <script src="{% static 'vendor/compressor.min.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/attachment-uploader.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/image-viewer.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/conversation.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/voip-call.js' %}?v={{ static_asset_version }}"></script>
{% endblock %} {% endblock %}
+101
View File
@@ -0,0 +1,101 @@
from playwright.sync_api import expect
from django.urls import reverse
from .base import BasePage
class MessageReadsPage(BasePage):
def acknowledgement(self, read_path, message):
return self.page.expect_response(lambda response: response.request.method == 'POST'
and response.url.endswith(read_path)
and str(message.public_id) in (response.request.post_data_json or {}).get('message_ids', []))
def open_and_acknowledge(self, path, read_path, message):
with self.acknowledgement(read_path, message):
self.goto(path)
expect(self.message(message)).to_be_in_viewport()
def message(self, message):
return self.page.locator(f'[data-message-id="{message.public_id}"]')
def stop_initial_follow(self):
self.page.locator('[data-conversation]').hover()
self.page.mouse.wheel(0, -1)
def scroll_and_acknowledge(self, message, read_path):
self.stop_initial_follow()
with self.acknowledgement(read_path, message):
self.message(message).scroll_into_view_if_needed()
expect(self.message(message)).to_be_in_viewport()
def expect_unseen(self, message):
expect(self.message(message).locator('.message-receipts')).to_have_count(0)
def expect_seen_by(self, message, name):
self.stop_initial_follow()
self.message(message).locator('.message-receipts summary').click()
expect(self.message(message).locator('.message-receipts')).to_contain_text(name)
def emulate_background_tab(self):
# Headless Chromium can keep multiple pages visible; control this signal
# explicitly to test the same visibilitychange branch deterministically.
self.page.add_init_script("Object.defineProperty(document, 'visibilityState', {get: () => window.receiptVisibility || 'hidden'});")
def activate_and_acknowledge(self, message, read_path):
with self.acknowledgement(read_path, message):
self.page.evaluate("window.receiptVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange'));")
def open_in_background(self, path, poll_path):
with self.page.expect_response(lambda response: response.url.split('?')[0].endswith(poll_path)):
self.goto(path)
def fail_first_acknowledgement(self, read_path):
def fail(route):
route.fulfill(status=503, content_type='application/json', body='{}')
self.page.route('**' + read_path, fail, times=1)
def wait_for_successful_acknowledgement(self, read_path):
with self.page.expect_response(lambda response: response.url.endswith(read_path) and response.status == 200):
pass
class MessageReadsRacePage(MessageReadsPage):
def hold_poll_after_receiving_its_body(self):
self.page.add_init_script("""
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, options) => {
const url = new URL(input, location.href);
const polling = url.pathname.includes('/messages/poll/');
if (polling) { url.searchParams.delete('version'); url.searchParams.delete('cursor'); }
const response = await nativeFetch(polling ? url : input, options);
if (polling && response.ok && !window.blockedPoll) {
window.blockedPoll = true;
const json = response.json.bind(response);
response.json = async () => {
const data = await json();
await new Promise(resolve => { window.releaseHeldPoll = resolve; });
window.heldPollParsed = true;
return data;
};
}
return response;
};
""")
def revoke_ticket(self, ticket, recipient, session_cookie, csrf):
self.page.context.request.post(self.url(reverse('staff_tickets:revoke', args=[ticket.public_id, recipient.public_id])),
headers={'Cookie': session_cookie, 'X-CSRFToken': csrf}, max_redirects=0)
def scroll_to_revoked_message(self, message, read_path):
self.stop_initial_follow()
with self.page.expect_response(lambda response: response.url.endswith(read_path) and response.status == 403):
self.message(message).scroll_into_view_if_needed()
expect(self.page.get_by_text('دسترسی به این مکاتبات لغو شده است.', exact=True)).to_be_visible()
def release_poll_and_expect_no_messages(self):
self.page.wait_for_function('typeof window.releaseHeldPoll === "function"')
self.page.evaluate('window.releaseHeldPoll()')
self.page.wait_for_function('window.heldPollParsed === true')
self.page.evaluate('() => new Promise(requestAnimationFrame)')
expect(self.page.locator('[data-message-id]')).to_have_count(0)
expect(self.page.get_by_text('دسترسی به این مکاتبات لغو شده است.', exact=True)).to_be_visible()
+5 -1
View File
@@ -35,6 +35,10 @@ class PaymentLinkPage(PaymentsPage):
self.page.locator('#id_paid_amount').fill('۱۲۳۴۵') self.page.locator('#id_paid_amount').fill('۱۲۳۴۵')
expect(self.page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵') expect(self.page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵')
self.page.get_by_role('button', name='امروز', exact=True).click() self.page.get_by_role('button', name='امروز', exact=True).click()
self.page.locator('#id_receipt_image').set_input_files({'name': 'payment.png', 'mimeType': 'image/png', 'buffer': png}) with self.page.expect_file_chooser() as picker:
self.page.locator('label.customer-file-button').click()
picker.value.set_files({'name': 'screenshot.png', 'mimeType': 'image/png', 'buffer': png})
expect(self.page.locator('#receipt-file-status')).to_contain_text('screenshot.png')
expect(self.page.locator('#receipt-file-preview')).to_be_visible()
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click() self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.') expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.')
+108
View File
@@ -0,0 +1,108 @@
import pytest
from django.test import Client
from django.urls import reverse
from apps.customers.models import CustomerProfile
from apps.messaging.models import Message
from apps.tickets.sharing import share_staff_ticket
from tests.test_staff_sharing import make_sharing_case
from tests.e2e.pages.auth import AuthenticationPage
from tests.e2e.pages.message_reads import MessageReadsPage, MessageReadsRacePage
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
PASSWORD = 'SharingTest123!'
@pytest.fixture
def reading_case(settings):
settings.PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
case = make_sharing_case()
case['customer'].set_password(PASSWORD)
case['customer'].save(update_fields=['password'])
CustomerProfile.objects.create(user=case['customer'], first_name='مشتری', last_name='خواننده')
share_staff_ticket(case['ticket'], case['sender'], case['recipient'], 'VIEW_ONLY')
case['probes'] = [Message.objects.create(ticket=case['ticket'], author=case['sender'],
body=f'پیام آزمایشی {i}\n' + ('متن برای آزمایش محدوده مشاهده. ' * 45)) for i in range(12)]
return case
@pytest.mark.parametrize('staff,width', [(True, 1280), (False, 390)])
def test_only_on_screen_messages_receive_seen_and_scrolling_acknowledges_more(live_server, browser_page, reading_case, staff, width):
c = reading_case
namespace = 'staff_tickets' if staff else 'tickets'
detail = reverse(namespace + ':detail', args=[c['ticket'].public_id])
read = reverse(namespace + (':read_shares' if staff else ':read'), args=[c['ticket'].public_id])
reader_name = 'کارشناس recipient' if staff else 'مشتری خواننده'
oldest, latest = c['probes'][0], c['probes'][-1]
with browser_page() as page:
page.set_viewport_size({'width': width, 'height': 900})
auth = AuthenticationPage(page, live_server.url)
if staff:
auth.login_staff(c['recipient'], PASSWORD)
else:
auth.login_customer(c['customer'], PASSWORD)
MessageReadsPage(page, live_server.url).open_and_acknowledge(detail, read, latest)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.goto(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
view.expect_unseen(oldest)
view.expect_seen_by(latest, reader_name)
with browser_page() as page:
auth = AuthenticationPage(page, live_server.url)
if staff:
auth.login_staff(c['recipient'], PASSWORD)
else:
auth.login_customer(c['customer'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.open_and_acknowledge(detail, read, latest)
view.scroll_and_acknowledge(oldest, read)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.goto(reverse('staff_tickets:detail', args=[c['ticket'].public_id]))
view.expect_seen_by(oldest, reader_name)
def test_background_poll_does_not_mark_seen_and_failed_acknowledgement_retries(live_server, browser_page, reading_case):
c = reading_case
detail = reverse('staff_tickets:detail', args=[c['ticket'].public_id])
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
poll = reverse('staff_tickets:poll', args=[c['ticket'].public_id])
latest = c['probes'][-1]
with browser_page() as page:
view = MessageReadsPage(page, live_server.url)
view.emulate_background_tab()
AuthenticationPage(page, live_server.url).login_staff(c['recipient'], PASSWORD)
view.open_in_background(detail, poll)
# A second server render exposes any erroneous background receipts.
view.open_in_background(detail, poll)
view.expect_unseen(latest)
view.fail_first_acknowledgement(read)
view.activate_and_acknowledge(latest, read)
# The first response is deliberately 503; the successful retry must arrive.
view.wait_for_successful_acknowledgement(read)
with browser_page() as page:
AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD)
view = MessageReadsPage(page, live_server.url)
view.goto(detail)
view.expect_seen_by(latest, 'کارشناس recipient')
def test_revocation_wins_over_a_poll_response_already_in_flight(live_server, browser_page, reading_case):
c = reading_case
detail = reverse('staff_tickets:detail', args=[c['ticket'].public_id])
read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
sender = Client()
sender.force_login(c['sender'])
sender.get(detail)
csrf = sender.cookies['csrftoken'].value
cookie = f"sessionid={sender.cookies['sessionid'].value}; csrftoken={csrf}"
with browser_page() as reader:
view = MessageReadsRacePage(reader, live_server.url)
view.hold_poll_after_receiving_its_body()
AuthenticationPage(reader, live_server.url).login_staff(c['recipient'], PASSWORD)
view.open_and_acknowledge(detail, read, c['probes'][-1])
view.revoke_ticket(c['ticket'], c['recipient'], cookie, csrf)
view.scroll_to_revoked_message(c['probes'][0], read)
view.release_poll_and_expect_no_messages()
+153
View File
@@ -0,0 +1,153 @@
import json
import uuid
from datetime import timedelta
import pytest
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import TicketMagicLink
from apps.messaging.models import Message, MessageReadReceipt
from apps.tickets.models import TicketReadState
from apps.tickets.sharing import revoke_staff_share, share_staff_message, share_staff_ticket
from tests.test_staff_sharing import make_sharing_case
pytestmark = pytest.mark.django_db
@pytest.fixture
def seen_case(settings):
settings.PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher']
case = make_sharing_case()
case['reply'] = Message.objects.create(ticket=case['ticket'], author=case['sender'], body='پاسخ قابل مشاهده')
return case
@pytest.mark.parametrize('route', ['detail', 'messages', 'poll'])
def test_staff_get_requests_never_mark_messages_or_ticket_read(client, seen_case, route):
c = seen_case
share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY')
client.force_login(c['recipient'])
response = client.get(reverse('staff_tickets:' + route, args=[c['ticket'].public_id]))
assert response.status_code == 200
assert not MessageReadReceipt.objects.filter(user=c['recipient']).exists()
assert not TicketReadState.objects.filter(user=c['recipient']).exists()
def test_staff_acknowledgement_is_scoped_idempotent_and_rechecked_after_revocation(client, seen_case):
c = seen_case
grant = share_staff_message(c['selected'], c['sender'], c['recipient'])
client.force_login(c['recipient'])
url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
values = [str(c['selected'].public_id), str(c['reply'].public_id), str(uuid.uuid4()), 'bad-uuid']
for _ in range(2):
response = client.post(url, {'message_ids': values})
assert response.status_code == 200
assert response.json()['read_ids'] == [str(c['selected'].public_id)]
assert list(MessageReadReceipt.objects.filter(user=c['recipient']).values_list('message_id', flat=True)) == [c['selected'].pk]
assert not TicketReadState.objects.filter(user=c['recipient']).exists()
grant.refresh_from_db()
assert grant.view_count == 1
revoke_staff_share(grant, c['sender'])
assert client.post(url, {'message_ids': values}).status_code == 403
assert client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).status_code == 403
assert not MessageReadReceipt.objects.filter(message=c['reply'], user=c['recipient']).exists()
def test_staff_ticket_read_state_requires_acknowledging_the_latest_message(client, seen_case):
c = seen_case
client.force_login(c['sender'])
url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
client.post(url, {'message_ids': [str(c['selected'].public_id)]})
assert not TicketReadState.objects.filter(user=c['sender']).exists()
client.post(url, {'message_ids': [str(c['reply'].public_id)]})
assert TicketReadState.objects.get(user=c['sender']).last_read_message_id == c['reply'].pk
assert not MessageReadReceipt.objects.filter(message=c['reply'], user=c['sender']).exists()
def test_another_colleagues_read_state_does_not_hide_my_unread_ticket(seen_case):
from apps.tickets.selectors import filter_staff_tickets, staff_ticket_rows
c = seen_case
share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY')
TicketReadState.objects.create(ticket=c['ticket'], user=c['sender'], last_read_at=timezone.now(), last_read_message=c['reply'])
unread = filter_staff_tickets(staff_ticket_rows(c['recipient']), c['recipient'], {'unread': '1'})
assert unread.filter(pk=c['ticket'].pk).exists()
own_state = TicketReadState.objects.create(ticket=c['ticket'], user=c['recipient'], last_read_at=timezone.now(), last_read_message=c['reply'])
assert not unread.filter(pk=c['ticket'].pk).exists()
own_state.last_read_at = c['ticket'].last_activity_at - timedelta(seconds=1)
own_state.save(update_fields=['last_read_at'])
assert unread.filter(pk=c['ticket'].pk).exists()
def test_customer_receipts_exclude_own_deleted_internal_and_other_ticket_messages(client, seen_case):
c = seen_case
deleted = Message.objects.create(ticket=c['ticket'], author=c['sender'], body='حذف‌شده', deleted_at=timezone.now())
from apps.tickets.services import create_ticket
other = create_ticket(customer=c['customer'], category=c['ticket'].category, subject='تیکت دیگر', body='پیام تیکت دیگر')
foreign = Message.objects.create(ticket=other, author=c['sender'], body='پاسخ خصوصی تیکت دیگر')
client.force_login(c['customer'])
for route in ['detail', 'messages', 'poll']:
assert client.get(reverse('tickets:' + route, args=[c['ticket'].public_id])).status_code == 200
assert not MessageReadReceipt.objects.filter(user=c['customer']).exists()
identifiers = [str(value) for value in c['ticket'].ticket_messages.values_list('public_id', flat=True)]
identifiers += [str(foreign.public_id), str(uuid.uuid4()), 'not-a-uuid', 42, None]
url = reverse('tickets:read', args=[c['ticket'].public_id])
response = client.post(url, json.dumps({'message_ids': identifiers}), content_type='application/json')
assert response.status_code == 200
assert list(MessageReadReceipt.objects.filter(user=c['customer']).values_list('message_id', flat=True)) == [c['reply'].pk]
assert not MessageReadReceipt.objects.filter(message=deleted).exists()
@pytest.mark.parametrize('payload', ['not json', 'null', '[]', '{"message_ids":"bad"}', '{"message_ids":{}}'])
@pytest.mark.parametrize('staff', [True, False])
def test_malformed_read_payloads_return_400_without_recording_reads(client, seen_case, payload, staff):
c = seen_case
client.force_login(c['sender'] if staff else c['customer'])
url = reverse('staff_tickets:read_shares' if staff else 'tickets:read', args=[c['ticket'].public_id])
assert client.post(url, payload, content_type='application/json').status_code == 400
assert not MessageReadReceipt.objects.exists()
values = [str(c['reply'].public_id)] * 101
assert client.post(url, json.dumps({'message_ids': values}), content_type='application/json').status_code == 400
def test_read_acknowledgements_keep_csrf_and_magic_link_scope(seen_case):
c = seen_case
strict = Client(enforce_csrf_checks=True)
strict.force_login(c['sender'])
staff_url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id])
assert strict.post(staff_url, {'message_ids': [str(c['selected'].public_id)]}).status_code == 403
anonymous = Client()
token = TicketMagicLink.issue(c['ticket'])
assert anonymous.get(reverse('accounts:magic', args=[token])).status_code == 302
url = reverse('tickets:read', args=[c['ticket'].public_id])
assert anonymous.post(url, json.dumps({'message_ids': [str(c['reply'].public_id)]}), content_type='application/json').status_code == 200
assert MessageReadReceipt.objects.filter(message=c['reply'], user=c['customer']).exists()
assert '_auth_user_id' not in anonymous.session
def test_polling_backlog_does_not_skip_messages_after_the_first_batch(client, seen_case):
c = seen_case
client.force_login(c['sender'])
url = reverse('staff_tickets:poll', args=[c['ticket'].public_id])
initial = client.get(url).json()
batch = [Message.objects.create(ticket=c['ticket'], author=c['customer'], body=f'پیام صف {i}') for i in range(55)]
first = client.get(url, {'version': initial['version'], 'cursor': initial['cursor']}).json()
assert not first['replace'] and first['cursor'] == str(batch[49].public_id)
second = client.get(url, {'version': first['version'], 'cursor': first['cursor']}).json()
assert not second['replace'] and second['cursor'] == str(batch[-1].public_id)
assert batch[-1].body in second['html']
assert not MessageReadReceipt.objects.exists()
def test_polling_detects_editing_an_older_message_even_with_a_new_reply(client, seen_case):
from apps.messaging.services import edit_message
c = seen_case
client.force_login(c['customer'])
url = reverse('tickets:poll', args=[c['ticket'].public_id])
initial = client.get(url).json()
edit_message(c['selected'].pk, c['customer'], 'متن اصلاح‌شده پیام قبلی')
Message.objects.create(ticket=c['ticket'], author=c['sender'], body='پاسخ همزمان')
response = client.get(url, {'version': initial['version'], 'cursor': initial['cursor']}).json()
assert response['changed'] and response['replace']
assert 'متن اصلاح‌شده پیام قبلی' in response['html'] and 'پاسخ همزمان' in response['html']
+51 -1
View File
@@ -1,5 +1,6 @@
"""Permanent regressions for SMS payment capabilities; no real SMS is sent.""" """Permanent regressions for SMS payment capabilities; no real SMS is sent."""
import uuid import uuid
import re
from datetime import timedelta from datetime import timedelta
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -10,7 +11,7 @@ from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.accounts.models import User from apps.accounts.models import TicketMagicLink, User
from apps.core.models import SiteBranding from apps.core.models import SiteBranding
from apps.notifications.models import NotificationJob, SMSTemplate from apps.notifications.models import NotificationJob, SMSTemplate
from apps.payments import services from apps.payments import services
@@ -40,6 +41,9 @@ def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settin
notice_sms = PaymentNotification.objects.get(job=job) notice_sms = PaymentNotification.objects.get(job=job)
url = notice_sms.context['payment_link'] url = notice_sms.context['payment_link']
token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1] token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1]
assert urlsplit(url).path == f'/p/{token}/'
assert re.fullmatch(r'[a-z0-9]{10}', token)
assert url in notice_sms.rendered_body
assert notice_sms.access_link.token_hash == token_digest(token) assert notice_sms.access_link.token_hash == token_digest(token)
assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2) assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2)
first_body = notice_sms.rendered_body first_body = notice_sms.rendered_body
@@ -55,6 +59,52 @@ def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settin
assert notice_sms.access_link.view_count == 2 assert notice_sms.access_link.view_count == 2
def test_short_payment_link_retries_collision_without_breaking_transaction(case, client, monkeypatch):
tokens = iter(['ac26i9raoz', 'ac26i9raoz', 'lfp694raup'])
monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: next(tokens))
first, _ = issue_payment_link(notice=case['notice'])
second, url = issue_payment_link(notice=case['notice'])
assert first.pk != second.pk
assert urlsplit(url).path == '/p/lfp694raup/'
assert PaymentAccessLink.objects.count() == 2
assert client.get('/p/lfp694raup/', follow=True).status_code == 200
def test_payment_link_collision_retries_are_bounded(case, monkeypatch):
monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: 'ac26i9raoz')
issue_payment_link(notice=case['notice'])
with pytest.raises(RuntimeError, match='لینک یکتای پرداخت'):
issue_payment_link(notice=case['notice'])
assert PaymentAccessLink.objects.count() == 1
def test_legacy_long_payment_link_keeps_scope_and_expiry(case, client):
token = 'x_NAEA8ptOXu5msNYgNNpCM8vyWh6_VAhELPs5q8C2U'
link = PaymentAccessLink.objects.create(customer=case['customer'], notice=case['notice'],
token_hash=token_digest(token), expires_at=timezone.now() + timedelta(days=1))
path = reverse('payments:open_link', args=[token])
response = client.get(path, follow=True)
assert response.status_code == 200
assert client.session[SESSION_KEY] == link.pk
assert '_auth_user_id' not in client.session
PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
assert client.get(path).status_code == 410
@pytest.mark.parametrize('via_link', [False, True])
def test_receipt_picker_accepts_gallery_images_without_camera_capture(case, client, via_link):
if via_link:
open_notice(client, case)
else:
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'), {'notice': case['notice'].public_id})
assert response.status_code == 200
widget = response.context['form'].fields['receipt_image'].widget
assert widget.attrs['accept'] == 'image/*'
assert 'capture' not in widget.attrs
assert 'accept="image/*"' in response.content.decode()
def test_public_notice_preserves_copy_warning_and_database_signature(case, client): def test_public_notice_preserves_copy_warning_and_database_signature(case, client):
branding = SiteBranding.load() branding = SiteBranding.load()
branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس' branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس'
+5 -1
View File
@@ -206,6 +206,7 @@ def test_missing_policy_stays_unapproved_until_manager_assigns(case):
def test_sms_uses_database_template_and_attempt_history(case,monkeypatch): def test_sms_uses_database_template_and_attempt_history(case,monkeypatch):
from apps.notifications.management.commands import process_notifications from apps.notifications.management.commands import process_notifications
from apps.payments.models import PaymentNotification
template=SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED',unit__isnull=True) template=SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED',unit__isnull=True)
template.body='متن سفارشی {notice_number} {payment_link}';template.save() template.body='متن سفارشی {notice_number} {payment_link}';template.save()
captured=[] captured=[]
@@ -217,7 +218,10 @@ def test_sms_uses_database_template_and_attempt_history(case,monkeypatch):
process_notifications._send_sms(job) process_notifications._send_sms(job)
template.body='جدید';template.save() template.body='جدید';template.save()
process_notifications._send_sms(job) process_notifications._send_sms(job)
assert captured[0] == captured[1] and 'متن سفارشی' in captured[0] and '/chaharsu/' in captured[0] notification = PaymentNotification.objects.get(job=job)
assert captured[0] == captured[1] and 'متن سفارشی' in captured[0]
assert '/p/' in notification.context['payment_link']
assert notification.context['payment_link'] in captured[0]
assert PaymentNotificationAttempt.objects.filter(success=True).count() == 2 assert PaymentNotificationAttempt.objects.filter(success=True).count() == 2
assert job.ticket is None assert job.ticket is None
+4 -1
View File
@@ -490,7 +490,10 @@ def test_ticket_polling_javascript_keeps_background_sync_and_recovers_reliably()
assert "conversationEnd?.scrollIntoView" not in script assert "conversationEnd?.scrollIntoView" not in script
assert "scroller.scrollTo({top,behavior:'smooth'})" in script assert "scroller.scrollTo({top,behavior:'smooth'})" in script
assert "data-message-id" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8") assert "data-message-id" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8")
assert "acknowledgeVisibleMessages" in script and "document.visibilityState!=='visible'" in script read_script=Path('static/js/conversation-read.js').read_text(encoding='utf-8')
assert "document.visibilityState!=='visible'" in read_script and 'getBoundingClientRect' in read_script and 'elementFromPoint' in read_script
for template in ('customer_detail.html', 'staff_detail.html'):
assert "{% static 'js/conversation-read.js' %}?v={{ static_asset_version }}" in Path('templates/tickets',template).read_text(encoding='utf-8')
source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8") source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8")
assert ".staff-ticket-workspace .composer.is-collapsed{position:fixed" not in source_css assert ".staff-ticket-workspace .composer.is-collapsed{position:fixed" not in source_css
assert ".staff-ticket-workspace .composer:not(.is-collapsed){position:fixed" not in source_css assert ".staff-ticket-workspace .composer:not(.is-collapsed){position:fixed" not in source_css