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."""
import hashlib
import secrets
import uuid
from datetime import timedelta
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.contrib.auth.views import redirect_to_login
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.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from apps.accounts.models import TicketMagicLink
from . import policies
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')
if receipt is not None:
notice = receipt.notice
token = secrets.token_urlsafe(32)
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 + reverse('payments:open_link', args=[token])
for attempt in range(5):
token = TicketMagicLink.generate_secure_token(10)
try:
with transaction.atomic():
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():
+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_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
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)
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
submission_key = forms.UUIDField(widget=forms.HiddenInput)
+2 -1
View File
@@ -15,7 +15,8 @@ from .models import PaymentAccessLink
@never_cache
@require_http_methods(['GET', 'POST'])
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:
return access.unavailable_link(request)
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.dateparse import parse_date
from .models import Ticket
from .models import Ticket, TicketReadState
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 staff_ticket_rows(user):
@@ -56,7 +56,8 @@ def filter_staff_tickets(qs, user, params):
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")))
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()
if tab in ("", "waiting_staff"):
return qs.annotate(
+20 -8
View File
@@ -3,6 +3,8 @@ import mimetypes
from django.conf import settings
from django.contrib import messages
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import FileResponse, JsonResponse
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.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 .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):
@@ -66,18 +70,26 @@ def shared_message(request, share_id):
@staff_required
@require_POST
@never_cache
@transaction.atomic
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)
ids = request.POST.getlist("message_ids")[:100]
# Filter strings without parsing attacker-controlled UUIDs in the ORM.
visible = {str(value) for value in allowed.values_list("public_id", flat=True)}
accepted = set(ids) & visible
try:
ids = message_read_ids(request)
except ValidationError:
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)
for grant in grants:
if grant.first_viewed_at is None:
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
+22 -21
View File
@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied,ValidationError
from django.http import FileResponse, HttpResponse, JsonResponse
from django.template.loader import render_to_string
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.urls import reverse
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.staff.models import OrganizationalUnit, StaffProfile
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 .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
@@ -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)
def mark_messages_read(conversation,user):
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,
)
def customer_required(view):
@@ -118,14 +118,18 @@ def customer_ticket_actor(request,ticket):
return request.user,False
link=get_ticket_magic_access(request,ticket)
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 ''}"
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):
qs=ticket.ticket_messages.all()
if not include_internal: qs=qs.filter(is_internal=False)
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", "")
latest_cursor = str(latest.public_id) if latest else ""
# 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"
return response
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")
if not replace:
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))
else:
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
if include_internal: mark_messages_read(conversation,actor)
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
template="tickets/_messages.html"
if include_internal:
context.update(message_context(ticket,actor,conversation))
template="tickets/_staff_messages.html"
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"
return response
@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"))
return conversation_poll_response(request,ticket,actor,include_internal=False)
@require_POST
@transaction.atomic
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)
if actor is None: return JsonResponse({"ok":False},status=401)
try: message_ids=json.loads(request.body or b"{}").get("message_ids",[])
except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400)
messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor)
MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True)
try: message_ids=message_read_ids(request)
except ValidationError: return JsonResponse({"ok":False,"error":"invalid_message_ids"},status=400)
acknowledged=list(ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False,deleted_at__isnull=True))
mark_messages_read(acknowledged,actor)
from apps.notifications.services import cancel_staff_reply_sms_if_read
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
def customer_reply(request,public_id):
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)
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"))
mark_messages_read(conversation,request.user)
mark_ticket_share_opened(ticket,request.user)
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]
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"))
@@ -472,7 +474,7 @@ def staff_detail(request,public_id):
customer_notices = payment_selectors.notices_with_totals(
payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user)
).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
@@ -514,7 +516,6 @@ def staff_sms_retry(request,public_id,job_id):
def staff_messages(request,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"))
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)})
@staff_required
def staff_poll(request,public_id):