fix payement link and gallery image
This commit is contained in:
1 parent
de3135b588
commit
1cbbbbd0dc
24 files changed
+654
-116
No files matched your search
@@ -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
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user