Compare commits

...
10 Commits
Author SHA1 Message Date
javad fe7a10cb61 block user in django admin 2026-10-14 12:27:55 +03:30
javad 089db0afa4 link view ticket for staff 2026-10-13 16:09:45 +03:30
javad 6b546aad76 estelam ticket, varizha , others 2026-10-13 15:48:24 +03:30
javad 35a1ec1794 fix fish variz name, dalilrad, sms, bale 2026-10-11 15:19:58 +03:30
javad 90931a5a64 fix payment UI UX 2026-10-07 14:06:14 +03:30
javad bb3ddd1610 fix payment 2026-10-07 13:39:29 +03:30
javad 4f7486152c fix resid taeid accsess 2026-10-06 15:06:35 +03:30
javad b53a3e0c5c delete option staff varizi 2026-10-06 12:30:28 +03:30
javad d99e47ecce fix UI UX 2026-10-06 12:08:16 +03:30
javad 1cbbbbd0dc fix payement link and gallery image 2026-10-06 11:18:31 +03:30
175 changed files with 8199 additions and 414 deletions

No files matched your search

+696 -15
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -2,3 +2,6 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
name = "apps.accounts" name = "apps.accounts"
def ready(self):
from . import customer_access # Register the login-session receiver.
+81
View File
@@ -0,0 +1,81 @@
"""Restrictions for full customer logins; staff and scoped links stay independent."""
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from django.utils.cache import add_never_cache_headers
from apps.customers.models import CustomerProfile
from .models import User
ACCESS_VERSION_KEY = "customer_login_access_version"
BLOCK_REASON_KEY = "customer_login_block_reason"
DEFAULT_BLOCK_REASON = "ورود شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید."
def is_customer_login(user):
return user.account_type == User.AccountType.CUSTOMER and not (user.is_staff or user.is_superuser)
def customer_login_profile(user):
if not is_customer_login(user):
return None
return CustomerProfile.objects.filter(user_id=user.pk).first()
def blocked_login_reason(mobile):
user = User.objects.filter(mobile=mobile).first()
profile = customer_login_profile(user) if user else None
if profile and profile.login_blocked:
return profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
return ""
@receiver(user_logged_in, dispatch_uid="customer_login_access_version")
def remember_customer_access(sender, request, user, **kwargs):
if not is_customer_login(user):
return
profile = customer_login_profile(user)
request.session[ACCESS_VERSION_KEY] = profile.login_access_version if profile else 0
request.session.pop(BLOCK_REASON_KEY, None)
class CustomerLoginAccessMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
profile = customer_login_profile(request.user)
if profile and (
profile.login_blocked
or request.session.get(ACCESS_VERSION_KEY, 0) != profile.login_access_version
):
logout(request)
if profile.login_blocked:
request.session[BLOCK_REASON_KEY] = profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON
else:
messages.error(request, "نشست قبلی شما باطل شده است. لطفاً دوباره وارد شوید.")
if request.path == reverse("accounts:login_access"):
response = JsonResponse({"authenticated": False, "login_url": reverse("accounts:login")})
else:
response = redirect("accounts:login")
add_never_cache_headers(response)
return response
return self.get_response(request)
@never_cache
@require_GET
def customer_login_access(request):
return JsonResponse({
"authenticated": request.user.is_authenticated,
"login_url": reverse("accounts:login"),
})
+2
View File
@@ -1,9 +1,11 @@
from django.urls import path from django.urls import path
from . import views from . import views
from .customer_access import customer_login_access
app_name = "accounts" app_name = "accounts"
urlpatterns = [ urlpatterns = [
path("login/access/", customer_login_access, name="login_access"),
path("", views.home, name="home"), path("", views.home, name="home"),
path("login/", views.customer_login, name="login"), path("login/", views.customer_login, name="login"),
path("login/password/", views.customer_password_login, name="password_login"), path("login/password/", views.customer_password_login, name="password_login"),
+31 -3
View File
@@ -14,6 +14,7 @@ from django.db import transaction
from django.db.models import F from django.db.models import F
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
from .magic_access import grant_ticket_magic_access from .magic_access import grant_ticket_magic_access
from .customer_access import BLOCK_REASON_KEY, blocked_login_reason
from apps.integrations.sms.providers import SMSResult, get_sms_provider from apps.integrations.sms.providers import SMSResult, get_sms_provider
import logging import logging
@@ -46,7 +47,11 @@ def customer_login(request):
_remember_destination(request) _remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None) form = MobileForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
challenge = None challenge = None
try: try:
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request) challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
@@ -62,13 +67,18 @@ def customer_login(request):
challenge.delete() challenge.delete()
logger.exception("OTP SMS delivery failed") logger.exception("OTP SMS delivery failed")
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.") form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
return render(request, "accounts/customer_login.html", {"form": form}) return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def verify_otp(request): def verify_otp(request):
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id")) challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
form = OTPForm(request.POST or None) form = OTPForm(request.POST or None)
block_reason = blocked_login_reason(challenge.mobile)
if block_reason:
request.session.pop("otp_challenge_id", None)
request.session[BLOCK_REASON_KEY] = block_reason
return redirect("accounts:login")
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
if challenge.verify(form.cleaned_data["code"]): if challenge.verify(form.cleaned_data["code"]):
user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER}) user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER})
@@ -86,12 +96,16 @@ def customer_password_login(request):
_remember_destination(request) _remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None) form=CustomerPasswordLoginForm(request.POST or None)
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
if request.method=="POST" and form.is_valid(): if request.method=="POST" and form.is_valid():
block_reason = blocked_login_reason(form.cleaned_data["mobile"])
if block_reason:
return render(request, "accounts/customer_password_login.html", {"form": form, "block_reason": block_reason})
user=form.authenticate(request) user=form.authenticate(request)
if user and user.is_active: if user and user.is_active:
login(request,user); return redirect(_login_destination(request)) login(request,user); return redirect(_login_destination(request))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form}) return render(request,"accounts/customer_password_login.html",{"form":form,"block_reason":block_reason})
@login_required @login_required
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
@@ -105,14 +119,28 @@ def customer_profile(request):
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile") messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
return render(request,"accounts/profile.html",{"form":form}) return render(request,"accounts/profile.html",{"form":form})
def _staff_login_destination(request):
target = request.GET.get("next") or request.POST.get("next") or request.session.get("staff_login_next", "")
if target.startswith(("/vt/", "/staff/tickets/")) and url_has_allowed_host_and_scheme(
target, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
):
request.session["staff_login_next"] = target
return target
request.session.pop("staff_login_next", None)
return reverse("staff_tickets:list")
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def staff_login(request): def staff_login(request):
destination = _staff_login_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = StaffLoginForm(request.POST or None) form = StaffLoginForm(request.POST or None)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
user = form.authenticate(request) user = form.authenticate(request)
if user and user.is_active and user.account_type == User.AccountType.STAFF: if user and user.is_active and user.account_type == User.AccountType.STAFF:
login(request, user); return redirect("staff_tickets:list") login(request, user)
request.session.pop("staff_login_next", None)
return redirect(destination)
form.add_error(None, "اطلاعات ورود صحیح نیست.") form.add_error(None, "اطلاعات ورود صحیح نیست.")
return render(request, "accounts/staff_login.html", {"form": form}) return render(request, "accounts/staff_login.html", {"form": form})
+2 -1
View File
@@ -54,4 +54,5 @@ class AttachmentPolicyAdmin(admin.ModelAdmin):
form = AttachmentPolicyAdminForm form = AttachmentPolicyAdminForm
admin.site.register(Attachment) from apps.tickets.response_admin import RelatedResponseContentAdmin
admin.site.register(Attachment, RelatedResponseContentAdmin)
+13
View File
@@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300):
if download: if download:
query["download"] = "1" query["download"] = "1"
return HttpResponseRedirect(f"{uri}?{urlencode(query)}") return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
def attachment_response(attachment, *, download=False):
"""Deliver authorized bytes; confidential responses never use bearer URLs."""
import mimetypes
from django.http import FileResponse
if settings.USE_NGINX_MEDIA and not attachment.relative_path.startswith("dispatch-responses/"):
return signed_media_redirect(attachment.relative_path, download=download)
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response = FileResponse(attachment.file.open("rb"), content_type=content_type,
as_attachment=download, filename=attachment.original_name)
response["X-Content-Type-Options"] = "nosniff"
return response
+4
View File
@@ -5,6 +5,10 @@ from django.db import models
from apps.core.models import PublicIdModel from apps.core.models import PublicIdModel
def attachment_path(instance,filename): def attachment_path(instance,filename):
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
from apps.tickets.dispatch_responses import response_control
control = response_control(t.group_dispatch) if t.group_dispatch_id else None
if m.author_id == t.customer_id and control and control.mode == "SEALED":
return f"dispatch-responses/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}" return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
class AttachmentPolicy(models.Model): class AttachmentPolicy(models.Model):
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True) attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
+9 -16
View File
@@ -1,30 +1,23 @@
import mimetypes
from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import FileResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from apps.tickets.policies import TicketPolicy from apps.tickets.policies import TicketPolicy
from apps.accounts.magic_access import get_ticket_magic_access from apps.accounts.magic_access import get_ticket_magic_access
from .delivery import signed_media_redirect from .delivery import attachment_response
from .models import Attachment from .models import Attachment
from django.views.decorators.cache import never_cache
def _authorized_attachment(request,public_id): def _authorized_attachment(request,public_id):
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket) magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
from apps.tickets.dispatch_responses import can_read_response
actor = ticket.customer if magic_link else request.user
if not can_read_response(actor, attachment.message): raise PermissionDenied
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
return attachment return attachment
@never_cache
def view(request,public_id): def view(request,public_id):
attachment=_authorized_attachment(request,public_id) return attachment_response(_authorized_attachment(request, public_id))
if settings.USE_NGINX_MEDIA: @never_cache
return signed_media_redirect(attachment.relative_path)
content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response=FileResponse(attachment.file.open("rb"),content_type=content_type)
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
response["X-Content-Type-Options"]="nosniff"
return response
def download(request,public_id): def download(request,public_id):
attachment=_authorized_attachment(request,public_id) return attachment_response(_authorized_attachment(request, public_id), download=True)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path, download=True)
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)
+1 -1
View File
@@ -64,7 +64,7 @@ def staff_operations_access(request):
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"), "create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
"contacts": True, "contacts": True,
"contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"), "contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"),
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"), "dispatches": any(allowed(permission) for permission in ("tickets.create_bulk_dispatch", "tickets.create_inquiry", "tickets.dispatch_group_tickets", "tickets.view_all_bulk_dispatches", "tickets.view_all_inquiries")),
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"), "templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"), "audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
}} }}
@@ -16,6 +16,14 @@ from apps.core.production_seed import sha256_file
from apps.tickets.models import Ticket from apps.tickets.models import Ticket
def _load_seed_data(bundle_dir):
# Only the pristine-target guard in handle authorizes this replacement.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
from apps.notifications.dispatch_opening import ensure_opening_template
ensure_opening_template()
class Command(BaseCommand): class Command(BaseCommand):
help = "Import a verified production seed bundle into a pristine PostgreSQL database." help = "Import a verified production seed bundle into a pristine PostgreSQL database."
@@ -80,10 +88,7 @@ class Command(BaseCommand):
copied_files = [] copied_files = []
try: try:
with transaction.atomic(): with transaction.atomic():
# Migrations contain defaults. Flush those rows before loading _load_seed_data(bundle_dir)
# the authoritative SQLite snapshot; schema/migration history remains.
call_command("flush", interactive=False, verbosity=0)
call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1)
# Old SQLite snapshots may predate unit-scoped queues. A # Old SQLite snapshots may predate unit-scoped queues. A
# category already belongs to exactly one unit, so it is a # category already belongs to exactly one unit, so it is a
# deterministic and safe source for repairing those rows. # deterministic and safe source for repairing those rows.
+1
View File
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
"tickets.SLAConfig", "tickets.SLAConfig",
"tickets.TicketTag", "tickets.TicketTag",
"tickets.GroupDispatch", "tickets.GroupDispatch",
"tickets.DispatchResponseControl",
"tickets.Ticket", "tickets.Ticket",
"tickets.TicketAccessGrant", "tickets.TicketAccessGrant",
"tickets.TicketEvent", "tickets.TicketEvent",
+8
View File
@@ -46,6 +46,14 @@ def jalali_date(value):
return _persian_digits(converted.strftime("%d %B %Y")) if converted else "" return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
@register.filter
def jalali_datetime_precise(value):
converted = _jalali(value)
if not isinstance(converted, jdatetime.datetime):
return ""
return _persian_digits(converted.strftime("%d %B %Y، ساعت %H:%M:%S"))
@register.filter @register.filter
def persian_time(value): def persian_time(value):
"""Display local Iran time using Persian digits.""" """Display local Iran time using Persian digits."""
+2 -2
View File
@@ -11,8 +11,8 @@ class ContactGroupAdmin(admin.ModelAdmin):
@admin.register(CustomerProfile) @admin.register(CustomerProfile)
class CustomerProfileAdmin(admin.ModelAdmin): class CustomerProfileAdmin(admin.ModelAdmin):
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name") list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked")
list_filter = ("stakeholder_type", "groups") list_filter = ("stakeholder_type", "groups", "login_blocked")
search_fields = ("user__mobile", "organization_name", "first_name", "last_name") search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
filter_horizontal = ("groups",) filter_horizontal = ("groups",)
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2026-10-14 08:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0006_backfill_customer_profiles'),
]
operations = [
migrations.AddField(
model_name='customerprofile',
name='login_access_version',
field=models.PositiveIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='customerprofile',
name='login_block_reason',
field=models.TextField(blank=True, help_text='این متن به مشتری یا مخاطب نمایش داده می\u200cشود. ورود کارکنان و کارشناسان تحت تأثیر نیست.', verbose_name='دلیل مسدودی ورود'),
),
migrations.AddField(
model_name='customerprofile',
name='login_blocked',
field=models.BooleanField(default=False, verbose_name='مسدود کردن ورود مشتری / مخاطب'),
),
]
+30
View File
@@ -1,5 +1,7 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db import transaction
from django.core.exceptions import ValidationError
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
@@ -51,6 +53,34 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts") groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
province = models.CharField(max_length=100, blank=True) province = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True)
login_blocked = models.BooleanField("مسدود کردن ورود مشتری / مخاطب", default=False)
login_block_reason = models.TextField(
"دلیل مسدودی ورود", blank=True,
help_text="این متن به مشتری یا مخاطب نمایش داده می‌شود. ورود کارکنان و کارشناسان تحت تأثیر نیست.",
)
login_access_version = models.PositiveIntegerField(default=0, editable=False)
def clean(self):
super().clean()
if self.login_blocked and not self.login_block_reason.strip():
raise ValidationError({"login_block_reason": "برای مسدود کردن ورود، دلیل مسدودی را بنویسید."})
def save(self, *args, **kwargs):
# Keep old sessions revoked even if the administrator later unblocks access.
with transaction.atomic():
previous = type(self).objects.select_for_update().filter(pk=self.pk).first()
if previous is None and self.login_blocked:
self.login_access_version = 1
if previous is not None:
self.login_access_version = previous.login_access_version
fields = kwargs.get("update_fields")
changes_access = fields is None or "login_blocked" in fields
if changes_access and self.login_blocked and not previous.login_blocked:
self.login_access_version += 1
if fields is not None:
kwargs["update_fields"] = set(fields) | {"login_access_version"}
super().save(*args, **kwargs)
def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username) def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username)
+20
View File
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
return NotificationResult(True, "console") return NotificationResult(True, "console")
class BaleBotProvider: class BaleBotProvider:
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
token = settings.BALE_BOT_TOKEN if token is None else token
if not token or not chat_id:
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
try:
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
if not response.ok:
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
data = response.json()
result = data.get('result') if isinstance(data, dict) else None
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
return NotificationResult(True, str(result['message_id']))
except (requests.RequestException, ValueError, AttributeError) as error:
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
def send_message(self, chat_id, text): def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured") if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS) response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
+3 -1
View File
@@ -1,3 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Message,MessageMention,MessageRevision from .models import Message,MessageMention,MessageRevision
admin.site.register([Message,MessageMention,MessageRevision]) from apps.tickets.response_admin import ResponseContentAdmin, RelatedResponseContentAdmin
admin.site.register(Message, ResponseContentAdmin)
admin.site.register([MessageMention, MessageRevision], RelatedResponseContentAdmin)
+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
+4
View File
@@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy
from .models import Message,MessageRevision from .models import Message,MessageRevision
@transaction.atomic @transaction.atomic
def edit_message(message_id,user,body): def edit_message(message_id,user,body):
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied
@@ -15,6 +17,8 @@ def edit_message(message_id,user,body):
MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg
@transaction.atomic @transaction.atomic
def soft_delete_message(message_id,user): def soft_delete_message(message_id,user):
from apps.tickets.dispatch_responses import lock_dispatch_for_ticket
lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id))
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message") may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message")
if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
+2
View File
@@ -23,6 +23,8 @@ def delete(request,public_id):
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/")) msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
@login_required @login_required
def revisions(request,public_id): def revisions(request,public_id):
from apps.tickets.dispatch_responses import can_read_response
msg=get_object_or_404(Message,public_id=public_id) msg=get_object_or_404(Message,public_id=public_id)
if not can_read_response(request.user, msg): raise PermissionDenied
if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")}) return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
+42
View File
@@ -0,0 +1,42 @@
"""Isolated worker handler; existing events keep their delivery flow."""
from django.core.exceptions import ValidationError
from apps.tickets.models import DispatchResponseControl
DEFAULT_OPENING_SMS = (
"لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\n"
"از همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان"
)
def ensure_opening_template():
"""Legacy seed bundles may predate this event; never replace an existing body."""
from .models import SMSTemplate
return SMSTemplate.objects.get_or_create(unit=None, event_type="DISPATCH_RESPONSES_OPENED", defaults={
"title": "بازگشایی پاسخ‌های ارسال گروهی", "body": DEFAULT_OPENING_SMS, "allowed_tags": [], "is_active": True,
})[0]
def send_opening_sms(job, provider_factory):
control = DispatchResponseControl.objects.get(dispatch_id=job.payload.get("dispatch_id"))
permitted = (control.mode == "SEALED" and control.notify_sms
and control.recipient_count_snapshot <= 15 and control.opened_at
and job.ticket_id and job.ticket.group_dispatch_id == control.dispatch_id
and job.customer_id == job.ticket.customer_id
and str(job.customer.public_id) in control.respondents_snapshot)
if not permitted or job.payload.get("body") != control.sms_body_snapshot:
raise ValidationError("Opening notification does not match its authorized snapshot.")
return provider_factory().send_message(job.customer.mobile, control.sms_body_snapshot)
def send_dispatch_reply_bale(job, text, provider_factory):
"""Dispatch metadata goes to its creator's private destination, never a unit room."""
from apps.tickets.policies import DispatchPolicy
dispatch = job.ticket.group_dispatch
owner = dispatch.created_by
profile = getattr(owner, "staff_profile", None)
if not owner.is_active or not profile or not profile.is_active or not profile.bale_notifications_enabled:
return None
if not profile.bale_chat_id.strip() or not DispatchPolicy.can_view(owner, dispatch):
return None
return provider_factory().send_message(profile.bale_chat_id.strip(), text)
@@ -58,6 +58,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first() message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
raw_excerpt = " ".join((message.body if message else "").split()) raw_excerpt = " ".join((message.body if message else "").split())
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن" excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
from apps.tickets.dispatch_responses import response_control
if message and message.author_id == ticket.customer_id and ticket.group_dispatch_id and response_control(ticket.group_dispatch):
excerpt = "پاسخ ثبت شده است؛ محتوا فقط در پنل و با رعایت مجوز بازگشایی قابل مشاهده است."
local_created = timezone.localtime(message.created_at if message else ticket.created_at) local_created = timezone.localtime(message.created_at if message else ticket.created_at)
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M") jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
lines=[ lines=[
@@ -72,16 +75,36 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"]) lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
return "\n".join(lines) return "\n".join(lines)
def _prepare_sms(job):
if job.event_type == 'STAFF_REPLY':
return _prepare_staff_reply_sms(job)
if job.event_type == 'PAYMENT_NOTICE_CREATED' and NotificationJob.objects.filter(
pk=job.pk, payment_notification__notice__deleted_at__isnull=False).exists():
job.status = NotificationJob.Status.CANCELLED
job.last_error = 'اعلامیه واریز حذف شده است.'
job.save(update_fields=['status', 'last_error'])
return False
return True
def _send_sms(job): def _send_sms(job):
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED"}: if job.event_type == "DISPATCH_RESPONSES_OPENED":
from apps.notifications.dispatch_opening import send_opening_sms
return send_opening_sms(job, get_sms_provider), None
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED", "PAYMENT_RECEIPT_REJECTED"}:
from apps.payments.notifications import send_payment_sms from apps.payments.notifications import send_payment_sms
return send_payment_sms(job, provider_factory=get_sms_provider) return send_payment_sms(job, provider_factory=get_sms_provider)
issued = None issued = None
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
from apps.tickets.short_links import append_supervisor_ticket_link
recipient = job.payload.get("recipient_mobile", "") recipient = job.payload.get("recipient_mobile", "")
if not recipient: if not recipient:
raise ValueError("Unit supervisor mobile is not configured") raise ValueError("Unit supervisor mobile is not configured")
message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit) message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit)
message = append_supervisor_ticket_link(job.ticket, message)
return get_sms_provider().send_message(recipient, message), issued return get_sms_provider().send_message(recipient, message), issued
if job.event_type == "CUSTOMER_TICKET_CREATED": if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket") issued, url = _magic_link(job, "customer_created_ticket")
@@ -94,14 +117,28 @@ def _send_sms(job):
raise ValueError(f"Unsupported SMS event: {job.event_type}") raise ValueError(f"Unsupported SMS event: {job.event_type}")
return get_sms_provider().send_message(job.customer.mobile, message), issued return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_customer_reply_bale(job, unit_chat_id):
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
if not message:
raise ValueError("Customer reply message no longer exists")
text = _bale_ticket_text(job.ticket, message, is_reply=True,
message_count=job.payload.get("message_count", 1), reopened=job.event_type == "CUSTOMER_TICKET_REOPENED")
from apps.tickets.dispatch_responses import response_control
if job.ticket.group_dispatch_id and response_control(job.ticket.group_dispatch):
from apps.notifications.dispatch_opening import send_dispatch_reply_bale
return send_dispatch_reply_bale(job, text, get_bale_provider)
return get_bale_provider().send_message(unit_chat_id, text) if unit_chat_id else None
def _send_bale(job): def _send_bale(job):
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import send_receipt_alert
return send_receipt_alert(job)
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip() unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
if job.event_type == "NEW_CUSTOMER_TICKET": if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]: if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first() return _send_customer_reply_bale(job, unit_chat_id)
if not message: raise ValueError("Customer reply message no longer exists")
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured") if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
profile = job.staff_user.staff_profile profile = job.staff_user.staff_profile
if not profile.bale_notifications_enabled: return None if not profile.bale_notifications_enabled: return None
@@ -337,7 +374,11 @@ class Command(BaseCommand):
for job in jobs: for job in jobs:
issued_link = None issued_link = None
try: try:
if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job): if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
from apps.payments.receipt_alerts import prepare_receipt_alert
if not prepare_receipt_alert(job):
continue
if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job):
continue continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job): if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
continue continue
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-11 11:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
def seed_rejection_template(apps, schema_editor):
template = apps.get_model('notifications', 'SMSTemplate')
template.objects.using(schema_editor.connection.alias).get_or_create(
unit=None, event_type='PAYMENT_RECEIPT_REJECTED', defaults={
'title': 'رد فیش واریز', 'is_active': True,
'body': 'فیش واریزی {receipt_number} به مبلغ {amount_rial} ریال رد شد.\nدلیل کارشناس: {rejection_reason}\nاصلاح و ارسال دوباره فیش: {payment_link}\n{company_name}',
'allowed_tags': ['recipient_name', 'company_name', 'notice_number', 'receipt_number',
'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason'],
})
class Migration(migrations.Migration):
dependencies = [('notifications', '0019_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed_rejection_template, migrations.RunPython.noop)]
@@ -0,0 +1,27 @@
# Generated by Django 5.1.1 on 2026-10-13 09:53
from django.db import migrations, models
def seed_opening_template(apps, schema_editor):
apps.get_model('notifications', 'SMSTemplate').objects.using(schema_editor.connection.alias).get_or_create(
unit=None, event_type='DISPATCH_RESPONSES_OPENED', defaults={
'title': 'بازگشایی پاسخ‌های ارسال گروهی', 'is_active': True, 'allowed_tags': [],
'body': 'لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\nاز همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان',
})
class Migration(migrations.Migration):
dependencies = [
('notifications', '0020_receipt_rejection_template'),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=[migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('DISPATCH_RESPONSES_OPENED', 'بازگشایی پاسخ\u200cهای ارسال گروهی'), ('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
)], database_operations=[]),
migrations.RunPython(seed_opening_template, migrations.RunPython.noop),
]
+5 -1
View File
@@ -10,11 +10,13 @@ def sms_template_tags():
class SMSTemplate(PersianTextNormalizationMixin, models.Model): class SMSTemplate(PersianTextNormalizationMixin, models.Model):
persian_text_fields={"title":False,"body":True} persian_text_fields={"title":False,"body":True}
class Event(models.TextChoices): class Event(models.TextChoices):
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخ‌های ارسال گروهی"
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری" CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس" STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس" STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز" PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز"
PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز" PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز"
PAYMENT_RECEIPT_REJECTED="PAYMENT_RECEIPT_REJECTED","رد فیش واریز"
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد" UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.") unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices) event_type=models.CharField(max_length=50,choices=Event.choices)
@@ -28,7 +30,9 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model):
def __str__(self): return self.get_event_type_display() def __str__(self): return self.get_event_type_display()
@classmethod @classmethod
def tags_for_event(cls, event_type): def tags_for_event(cls, event_type):
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}: if event_type == "DISPATCH_RESPONSES_OPENED":
return []
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}:
from apps.payments.notifications import TAGS from apps.payments.notifications import TAGS
return list(TAGS) return list(TAGS)
tags = sms_template_tags() tags = sms_template_tags()
+33 -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,11 +13,13 @@ 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
SESSION_KEY = 'payment_access_link_id' SESSION_KEY = 'payment_access_link_id'
SHARE_SESSION_KEY = 'payment_page_share'
def token_digest(token): def token_digest(token):
@@ -30,15 +32,40 @@ 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 notice_share_url(request, notice):
"""Reuse a validated capability; only a logged-in owner can issue a new one."""
shared = request.session.get(SHARE_SESSION_KEY, {})
token = shared.get('token', '')
link = active_links().filter(pk=shared.get('link_id'), notice=notice,
token_hash=token_digest(token)).first() if token else None
grant = getattr(request, 'payment_access_link', None)
if grant and (link is None or link.pk != grant.pk):
raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.')
if link is None:
from .services import audit
link, url = issue_payment_link(notice=notice)
token = url.rstrip('/').rsplit('/', 1)[-1]
request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
audit(request.user, 'PAYMENT_LINK_CREATED', link)
return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
def active_links(): def active_links():
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter( return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
notice__deleted_at__isnull=True,
revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True, revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True,
customer__account_type='CUSTOMER') customer__account_type='CUSTOMER')
+88
View File
@@ -126,3 +126,91 @@ for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRe
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent, models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
models.PaymentNotification,models.PaymentNotificationAttempt]: models.PaymentNotification,models.PaymentNotificationAttempt]:
admin.site.register(model,EvidenceAdmin) admin.site.register(model,EvidenceAdmin)
class ReceiptAlertRecipientInline(admin.TabularInline):
model = models.PaymentReceiptAlertRecipient
extra = 1
can_delete = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts')
has_add_permission = has_view_permission
has_change_permission = has_view_permission
@admin.register(models.PaymentReceiptAlertConfiguration)
class ReceiptAlertConfigurationAdmin(AuditedConfigurationAdmin):
from .forms import ReceiptAlertConfigurationForm
form = ReceiptAlertConfigurationForm
permission = 'manage_receipt_alerts'
singleton_configuration = True
list_display = ('__str__', 'bale_enabled', 'sms_enabled', 'notify_revisions', 'updated_at')
readonly_fields = ('updated_at',)
inlines = [ReceiptAlertRecipientInline]
fieldsets = (
('بله — تصویر فیش', {'fields': ('bale_enabled', 'bale_token', 'clear_bale_token', 'bale_chat_id', 'bale_caption')}),
('پیامک مدیران', {'fields': ('sms_enabled', 'sms_template')}),
('فیش اصلاح‌شده', {'fields': ('notify_revisions', 'updated_at')}),
)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
services.audit(request.user, 'RECEIPT_ALERT_RECIPIENTS_CHANGED', form.instance,
{'recipients': list(form.instance.recipients.values('id', 'name', 'mobile', 'is_active'))})
@admin.register(models.PaymentReceiptAlertRecipient)
class ReceiptAlertRecipientAdmin(AuditedConfigurationAdmin):
permission = 'manage_receipt_alerts'
list_display = ('name', 'mobile', 'is_active')
list_filter = ('is_active',)
search_fields = ('name', 'mobile')
@admin.register(models.PaymentReceiptAlert)
class ReceiptAlertAdmin(EvidenceAdmin):
list_display = ('receipt_number', 'recipient_name', 'recipient_mobile', 'channel', 'status', 'attempts', 'delivery_status')
list_select_related = ('job', 'revision__receipt', 'recipient')
readonly_fields = ('channel', 'status', 'attempts', 'delivery_status', 'last_error', 'sent_at', 'delivery_description')
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user, 'manage_receipt_alerts') or super().has_view_permission(request, obj)
def get_queryset(self, request):
if policies.has_permission(request.user, 'manage_receipt_alerts'):
return admin.ModelAdmin.get_queryset(self, request)
return super().get_queryset(request)
@admin.display(description='فیش')
def receipt_number(self, obj):
return obj.revision.receipt.number
@admin.display(description='کانال')
def channel(self, obj):
return obj.job.get_channel_display()
@admin.display(description='وضعیت ارسال')
def status(self, obj):
return obj.job.get_status_display()
@admin.display(description='تعداد تلاش')
def attempts(self, obj):
return obj.job.attempts
@admin.display(description='دریافت پیامک')
def delivery_status(self, obj):
return obj.job.get_delivery_status_display() or 'استعلام ثبت نشده'
@admin.display(description='آخرین خطا')
def last_error(self, obj):
return obj.job.last_error
@admin.display(description='زمان ارسال')
def sent_at(self, obj):
return obj.job.sent_at
@admin.display(description='شرح دریافت')
def delivery_description(self, obj):
return obj.job.delivery_description
+22
View File
@@ -0,0 +1,22 @@
from string import Formatter
from django.core.exceptions import ValidationError
ALERT_TAGS = ('amount_rial', 'customer_name', 'customer_mobile', 'received_at', 'paid_date',
'receipt_number', 'receipt_link', 'revision_version', 'manager_name')
DEFAULT_RECEIPT_SMS = 'فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.'
DEFAULT_RECEIPT_CAPTION = ('فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\n'
'مبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\n'
'شماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}')
ALERT_TEMPLATE_HELP = 'تگ‌های مجاز: ' + '، '.join('{' + tag + '}' for tag in ALERT_TAGS)
def validate_alert_template(value):
if not value.strip():
raise ValidationError('متن اعلان نمی‌تواند خالی باشد.')
try:
for _, field, spec, conversion in Formatter().parse(value):
if field is not None and (field not in ALERT_TAGS or spec or conversion):
raise ValueError
except ValueError:
raise ValidationError('قالب معتبر نیست؛ فقط تگ‌های معرفی‌شده و بدون قالب‌بندی اضافی مجازند.')
+7 -1
View File
@@ -4,6 +4,12 @@ The input is the customer local mobile number, retaining its leading zero.
This is a deterministic identifier, not a random or unique-per-notice value. This is a deterministic identifier, not a random or unique-per-notice value.
""" """
def normalize_deposit_identifier(value):
"""Remove leading zeroes from deposit IDs only; retain internal/check digits."""
value = str(value or '').translate(str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')).strip()
return (value.lstrip('0') or '0') if value else ''
def generate_id_algorithm(id_code: str) -> str: def generate_id_algorithm(id_code: str) -> str:
""" """
Calculates the verification code based on the reverse-engineered logic: Calculates the verification code based on the reverse-engineered logic:
@@ -39,4 +45,4 @@ def generate_id_algorithm(id_code: str) -> str:
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06) # فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}" check_digit_str = f"{check_digit:02}"
return f"{id_code}{check_digit_str}" return normalize_deposit_identifier(f"{id_code}{check_digit_str}")
+34 -1
View File
@@ -6,6 +6,31 @@ from django.utils import timezone
from apps.accounts.models import User from apps.accounts.models import User
from . import validators from . import validators
from .models import BankInstitution, CompanyBankAccount, PaymentNotice from .models import BankInstitution, CompanyBankAccount, PaymentNotice
from .identity import customer_name, depositor_name
from .deposit_identifier import generate_id_algorithm
class ReceiptAlertConfigurationForm(forms.ModelForm):
clear_bale_token = forms.BooleanField(label='پاک کردن توکن ذخیره‌شده', required=False)
class Meta:
from .models import PaymentReceiptAlertConfiguration
model = PaymentReceiptAlertConfiguration
fields = '__all__'
widgets = {'bale_token': forms.PasswordInput(render_value=False)}
help_texts = {'bale_token': 'برای حفظ توکن فعلی این فیلد را خالی بگذارید؛ برای جایگزینی، توکن تازه وارد کنید.'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial['bale_token'] = ''
def clean(self):
data = super().clean()
if data.get('clear_bale_token'):
data['bale_token'] = ''
elif not data.get('bale_token') and self.instance.pk:
data['bale_token'] = self.instance.bale_token
return data
class BankAccountForm(forms.ModelForm): class BankAccountForm(forms.ModelForm):
@@ -71,6 +96,7 @@ class NoticeForm(forms.Form):
class ReceiptForm(forms.Form): class ReceiptForm(forms.Form):
depositor_name = forms.CharField(label='نام واریزکننده', max_length=150)
receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked') receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked')
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False) notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
paid_amount = forms.CharField(label='مبلغ پرداخت‌شده (ریال)') paid_amount = forms.CharField(label='مبلغ پرداخت‌شده (ریال)')
@@ -81,13 +107,14 @@ 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)
def __init__(self, *args, customer, notice_ids=None, **kwargs): def __init__(self, *args, customer, notice_ids=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.initial.setdefault('depositor_name', customer_name(customer))
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True) notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
if notice_ids is not None: if notice_ids is not None:
notices = notices.filter(pk__in=notice_ids) notices = notices.filter(pk__in=notice_ids)
@@ -95,6 +122,9 @@ class ReceiptForm(forms.Form):
self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}' self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}'
accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices) accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices)
self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct() self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct()
self.account_warnings = {str(a.pk): {'required': a.requires_deposit_id, 'bank': a.bank.name}
for a in self.fields['destination_account'].queryset.select_related('bank')}
self.customer_deposit_id = generate_id_algorithm(customer.local_mobile) if customer.local_mobile else ''
current = jdatetime.date.fromgregorian(date=timezone.localdate()) current = jdatetime.date.fromgregorian(date=timezone.localdate())
self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)] self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)]
self.initial.setdefault('submission_key', uuid.uuid4()) self.initial.setdefault('submission_key', uuid.uuid4())
@@ -104,6 +134,9 @@ class ReceiptForm(forms.Form):
def clean_paid_amount(self): def clean_paid_amount(self):
return validators.amount(self.cleaned_data['paid_amount']) return validators.amount(self.cleaned_data['paid_amount'])
def clean_depositor_name(self):
return depositor_name(self.cleaned_data['depositor_name'])
def clean_receipt_image(self): def clean_receipt_image(self):
upload = self.cleaned_data['receipt_image'] upload = self.cleaned_data['receipt_image']
self.image_metadata = validators.image(upload) self.image_metadata = validators.image(upload)
+28
View File
@@ -0,0 +1,28 @@
"""Customer identity and the payer recorded on each receipt are independent."""
from django.core.exceptions import ValidationError
from apps.core.persian_text import normalize_persian_text
def customer_name(customer):
profile = getattr(customer, 'customer_profile', None)
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
return normalize_persian_text(name or customer.get_full_name())
def depositor_name(value):
name = normalize_persian_text(value)
if not name.replace('\u200c', '').replace('\u200b', '').strip():
raise ValidationError('نام واریزکننده را وارد کنید.')
if len(name) > 150:
raise ValidationError('نام واریزکننده باید حداکثر ۱۵۰ کاراکتر باشد.')
return name
def receipt_identity(customer, payer):
name = customer_name(customer)
payer = normalize_persian_text(payer)
if not name:
return payer or 'نام ثبت نشده'
if not payer or name == payer:
return name
return f'{name} (واریزکننده: {payer})'
+4 -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:
@@ -28,6 +29,8 @@ def open_link(request, token):
logout(request) logout(request)
if not request.user.is_authenticated: if not request.user.is_authenticated:
access.grant_payment_access(request, link) access.grant_payment_access(request, link)
# Retain the token across redirects in the session; the link model still stores only its hash.
request.session[access.SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
with transaction.atomic(): with transaction.atomic():
PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now()) PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now())
services.audit(None, 'PAYMENT_LINK_OPENED', link, { services.audit(None, 'PAYMENT_LINK_OPENED', link, {
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-06 08:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0004_paymentaccesslink_paymentnotification_access_link_and_more'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='deleted_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
def normalize_existing_identifiers(apps, schema_editor):
digit_map = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
for name in ('PaymentNotice', 'PaymentReceiptRevision'):
records = apps.get_model('payments', name).objects.using(schema_editor.connection.alias)
for pk, original in records.exclude(deposit_id='').values_list('pk', 'deposit_id').iterator(chunk_size=500):
value = original.translate(digit_map).strip()
normalized = (value.lstrip('0') or '0') if value else ''
if normalized != original:
records.filter(pk=pk).update(deposit_id=normalized)
class Migration(migrations.Migration):
dependencies = [('payments', '0005_paymentnotice_deleted_at')]
operations = [migrations.RunPython(normalize_existing_identifiers, migrations.RunPython.noop)]
@@ -0,0 +1,73 @@
# Generated by Django 5.1.1 on 2026-10-07 09:49
import apps.payments.alert_templates
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0018_alter_smstemplate_body'),
('payments', '0006_normalize_deposit_identifiers'),
]
operations = [
migrations.CreateModel(
name='PaymentReceiptAlertConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bale_enabled', models.BooleanField(default=False, verbose_name='ارسال تصویر فیش به بله')),
('bale_token', models.CharField(blank=True, max_length=500, verbose_name='توکن ربات بله پرداخت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='شناسه گروه بله')),
('bale_caption', models.TextField(default='فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\nمبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\nشماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=3000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن همراه تصویر')),
('sms_enabled', models.BooleanField(default=False, verbose_name='پیامک فیش جدید به مدیران')),
('sms_template', models.TextField(default='فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=1000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن پیامک مدیران')),
('notify_revisions', models.BooleanField(default=True, verbose_name='اطلاع\u200cرسانی فیش اصلاح\u200cشده مشتری')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
],
options={
'verbose_name': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'verbose_name_plural': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
'permissions': [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')],
'constraints': [models.CheckConstraint(condition=models.Q(('pk', 1)), name='pay_receipt_alert_singleton')],
},
),
migrations.CreateModel(
name='PaymentReceiptAlertRecipient',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150, verbose_name='نام مدیر')),
('mobile', models.CharField(max_length=20, verbose_name='شماره موبایل مدیر')),
('is_active', models.BooleanField(default=True, verbose_name='دریافت پیامک فعال')),
('configuration', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='payments.paymentreceiptalertconfiguration')),
],
options={
'verbose_name': 'مدیر دریافت\u200cکننده پیامک فیش',
'verbose_name_plural': 'مدیران دریافت\u200cکننده پیامک فیش',
},
),
migrations.CreateModel(
name='PaymentReceiptAlert',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recipient_name', models.CharField(blank=True, max_length=150, verbose_name='نام گیرنده هنگام ثبت')),
('recipient_mobile', models.CharField(blank=True, max_length=20, verbose_name='موبایل گیرنده هنگام ثبت')),
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='گروه هنگام ثبت')),
('context', models.JSONField()),
('message_template', models.TextField(verbose_name='قالب هنگام ثبت')),
('rendered_body', models.TextField(blank=True, verbose_name='متن ارسال')),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_alert', to='notifications.notificationjob')),
('revision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alerts', to='payments.paymentreceiptrevision')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceiptalertrecipient')),
],
options={
'verbose_name': 'سابقه اعلان فیش مشتری',
'verbose_name_plural': 'سوابق اعلان فیش مشتری',
},
),
migrations.AddConstraint(
model_name='paymentreceiptalertrecipient',
constraint=models.UniqueConstraint(fields=('configuration', 'mobile'), name='pay_receipt_alert_mobile'),
),
]
@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('payments', '0007_receipt_alerts')]
operations = [
migrations.AddField(
model_name='paymentreceiptrevision', name='depositor_name',
field=models.CharField('نام واریزکننده', max_length=150, default=''),
preserve_default=False,
),
]
+93
View File
@@ -4,6 +4,8 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from apps.core.models import PublicIdModel, TimeStampedModel from apps.core.models import PublicIdModel, TimeStampedModel
from .deposit_identifier import normalize_deposit_identifier
from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template
MAX_AMOUNT = 9_007_199_254_740_991 MAX_AMOUNT = 9_007_199_254_740_991
@@ -85,6 +87,11 @@ class PaymentNotice(PublicIdModel, TimeStampedModel):
invoice_number = models.CharField(max_length=100, blank=True) invoice_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
cancelled_at = models.DateTimeField(null=True, blank=True) cancelled_at = models.DateTimeField(null=True, blank=True)
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta: class Meta:
ordering = ['-created_at', '-pk'] ordering = ['-created_at', '-pk']
@@ -127,6 +134,7 @@ def receipt_path(instance, filename):
class PaymentReceiptRevision(models.Model): class PaymentReceiptRevision(models.Model):
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions') receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
version = models.PositiveIntegerField() version = models.PositiveIntegerField()
depositor_name = models.CharField('نام واریزکننده', max_length=150)
amount = models.BigIntegerField() amount = models.BigIntegerField()
paid_date = models.DateField() paid_date = models.DateField()
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT) destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
@@ -144,6 +152,10 @@ class PaymentReceiptRevision(models.Model):
original_name = models.CharField(max_length=255) original_name = models.CharField(max_length=255)
file_size = models.PositiveBigIntegerField() file_size = models.PositiveBigIntegerField()
def save(self, *args, **kwargs):
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
super().save(*args, **kwargs)
class Meta: class Meta:
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')] constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
@@ -232,3 +244,84 @@ class PaymentNotificationAttempt(models.Model):
provider_code = models.CharField(max_length=50, blank=True) provider_code = models.CharField(max_length=50, blank=True)
provider_id = models.CharField(max_length=500, blank=True) provider_id = models.CharField(max_length=500, blank=True)
error = models.TextField(blank=True) error = models.TextField(blank=True)
class PaymentReceiptAlertConfiguration(models.Model):
"""Independent, initially disabled destinations for customer receipt uploads."""
bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False)
bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True)
bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True)
bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000)
sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False)
sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS,
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000)
notify_revisions = models.BooleanField('اطلاع‌رسانی فیش اصلاح‌شده مشتری', default=True)
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
class Meta:
verbose_name = 'تنظیمات اطلاع‌رسانی فیش مشتری'
verbose_name_plural = 'تنظیمات اطلاع‌رسانی فیش مشتری'
permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')]
constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')]
def __str__(self):
return 'اطلاع‌رسانی فیش مشتری'
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
def clean(self):
from django.core.exceptions import ValidationError
self.pk = 1
self.bale_token = self.bale_token.strip()
self.bale_chat_id = self.bale_chat_id.strip()
if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token):
raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'})
if self.bale_enabled and (not self.bale_token or not self.bale_chat_id):
raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.')
class PaymentReceiptAlertRecipient(models.Model):
configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients')
name = models.CharField('نام مدیر', max_length=150)
mobile = models.CharField('شماره موبایل مدیر', max_length=20)
is_active = models.BooleanField('دریافت پیامک فعال', default=True)
class Meta:
verbose_name = 'مدیر دریافت‌کننده پیامک فیش'
verbose_name_plural = 'مدیران دریافت‌کننده پیامک فیش'
constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')]
def __str__(self):
return self.name
def clean(self):
from apps.accounts.models import normalize_mobile
from django.core.exceptions import ValidationError
from .validators import digits
try:
self.mobile = normalize_mobile(digits(self.mobile))
except ValueError:
raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'})
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
class PaymentReceiptAlert(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert')
revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts')
recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT)
recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True)
recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True)
bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True)
context = models.JSONField()
message_template = models.TextField('قالب هنگام ثبت')
rendered_body = models.TextField('متن ارسال', blank=True)
class Meta:
verbose_name = 'سابقه اعلان فیش مشتری'
verbose_name_plural = 'سوابق اعلان فیش مشتری'
+25 -5
View File
@@ -5,15 +5,19 @@ from django.utils import timezone
from apps.notifications.services import enqueue from apps.notifications.services import enqueue
from .models import PaymentNotification, PaymentNotificationAttempt from .models import PaymentNotification, PaymentNotificationAttempt
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'} EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link'] TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link', 'rejection_reason']
def notice_sms_pending(notice):
return PaymentNotification.objects.filter(notice=notice, job__status__in=['PENDING', 'PROCESSING', 'RETRYING']).exists()
def context(customer, notice=None, receipt=None, amount=0): def context(customer, notice=None, receipt=None, amount=0):
profile = getattr(customer, 'customer_profile', None) profile = getattr(customer, 'customer_profile', None)
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME, return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '', 'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '',
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '', 'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '', 'rejection_reason': '',
'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')} 'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')}
@@ -27,9 +31,25 @@ def queue_approval(receipt, cycle):
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)}) PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)})
def queue_rejection(receipt, cycle, reason):
job = enqueue('SMS', 'PAYMENT_RECEIPT_REJECTED', None, f'payment-rejected:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
details = context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit,
'context': {**details, 'rejection_reason': reason}})
def payment_sms_body(job, notification):
"""Editable templates must always include the customer's rejection reason."""
from apps.notifications.management.commands.process_notifications import _render_sms
body = _render_sms(job.event_type, notification.context, notification.unit)
reason = notification.context.get('rejection_reason', '')
if reason and reason not in body:
body += '\nدلیل کارشناس: ' + reason
return body
def send_payment_sms(job, provider=None, provider_factory=None): def send_payment_sms(job, provider=None, provider_factory=None):
from apps.notifications.models import SMSTemplate from apps.notifications.models import SMSTemplate
from apps.notifications.management.commands.process_notifications import _render_sms
notification = PaymentNotification.objects.get(job=job) notification = PaymentNotification.objects.get(job=job)
# Persist the attempt before rendering/provider construction: failed preparation is history too. # Persist the attempt before rendering/provider construction: failed preparation is history too.
attempt = PaymentNotificationAttempt.objects.create(notification=notification) attempt = PaymentNotificationAttempt.objects.create(notification=notification)
@@ -48,7 +68,7 @@ def send_payment_sms(job, provider=None, provider_factory=None):
notification.context = {**notification.context, 'payment_link': url} notification.context = {**notification.context, 'payment_link': url}
notification.save(update_fields=['context', 'access_link', 'rendered_body']) notification.save(update_fields=['context', 'access_link', 'rendered_body'])
if not notification.rendered_body: if not notification.rendered_body:
body = _render_sms(job.event_type, notification.context, notification.unit) body = payment_sms_body(job, notification)
template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None
template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True) template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
notification.rendered_body = body notification.rendered_body = body
+25 -3
View File
@@ -1,5 +1,5 @@
from django.db.models import Q from django.db.models import Q
from .models import PaymentAccessConfiguration from .models import PaymentAccessConfiguration, PaymentNotice
def is_staff(user): def is_staff(user):
@@ -37,7 +37,29 @@ def can_manage_banks(user):
return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts')) return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts'))
def may_delete_notice(user, notice):
role = getattr(getattr(user, 'staff_profile', None), 'role', '')
return is_staff(user) and (notice.created_by_id == user.pk or user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'})
def can_delete_notice(user, notice):
return may_delete_notice(user, notice) and not notice.deleted_at and not notice.receipts.exists()
def notice_actions(queryset, user):
"""Allow deletion actors to reach this notice without broadening other lists."""
queryset = queryset.filter(deleted_at__isnull=True)
if not is_staff(user):
return queryset.none()
role = getattr(getattr(user, 'staff_profile', None), 'role', '')
if user.is_superuser or role in {'CEO', 'GENERAL_ADMIN'}:
return queryset
return queryset.filter(Q(created_by=user) | Q(pk__in=visible(queryset, user).values('pk')))
def visible(queryset, user): def visible(queryset, user):
if queryset.model == PaymentNotice:
queryset = queryset.filter(deleted_at__isnull=True)
if not user.is_authenticated or not user.is_active: if not user.is_authenticated or not user.is_active:
return queryset.none() return queryset.none()
if user.account_type == 'CUSTOMER': if user.account_type == 'CUSTOMER':
@@ -57,8 +79,8 @@ def visible(queryset, user):
def can_decide(user, receipt, cycle, senior=False): def can_decide(user, receipt, cycle, senior=False):
code = 'senior_approve_paymentreceipt' if senior else 'approve_paymentreceipt' # Selection in the approval policy grants the role for this cycle. Requiring
# an additional Django permission made selected non-admin staff unable to vote.
return (is_staff(user) and receipt.submitted_by_id != user.pk and return (is_staff(user) and receipt.submitted_by_id != user.pk and
has_permission(user, code, receipt.owning_unit) and
visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and
cycle.assignments.filter(user=user, is_senior=senior).exists()) cycle.assignments.filter(user=user, is_senior=senior).exists())
+106
View File
@@ -0,0 +1,106 @@
"""Receipt-only outbox handlers. No network operation runs during receipt registration."""
import logging
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from apps.core.templatetags.jalali import jalali_date, jalali_datetime
from apps.integrations.sms.base import SMSResult
from apps.notifications.services import enqueue
from .alert_templates import validate_alert_template
from .models import PaymentReceiptAlert, PaymentReceiptAlertConfiguration
logger = logging.getLogger(__name__)
RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
def receipt_alert_context(revision):
from .identity import receipt_identity
customer = revision.receipt.customer
return {'customer_name': receipt_identity(customer, revision.depositor_name),
'depositor_name': revision.depositor_name,
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
'receipt_link': settings.PUBLIC_BASE_URL.rstrip('/') + reverse('staff_payments:receipt_detail', args=[revision.receipt.public_id])}
def queue_receipt_alerts(revision, actor):
# Submission/revision services authorize staff before reaching this queue.
# Keep customer submissions restricted to their own receipt.
if actor.account_type != 'STAFF' and (actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id):
return
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
return
context = receipt_alert_context(revision)
destinations = []
if configuration.bale_enabled:
destinations.append(('BALE', None, configuration.bale_caption))
if configuration.sms_enabled:
destinations.extend(('SMS', recipient, configuration.sms_template) for recipient in configuration.recipients.filter(is_active=True))
for channel, recipient, template in destinations:
try:
with transaction.atomic():
job = enqueue(channel, RECEIPT_ALERT_EVENT, None,
f'receipt-alert:{revision.pk}:{channel}:{recipient.pk if recipient else "group"}', customer=revision.receipt.customer)
PaymentReceiptAlert.objects.get_or_create(job=job, defaults={'revision': revision, 'recipient': recipient,
'recipient_name': recipient.name if recipient else '', 'recipient_mobile': recipient.mobile if recipient else '',
'bale_chat_id': configuration.bale_chat_id if channel == 'BALE' else '', 'message_template': template,
'context': {**context, 'manager_name': recipient.name if recipient else ''}})
except Exception as error:
logger.error('Receipt alert destination queue failed revision=%s channel=%s type=%s', revision.pk, channel, type(error).__name__)
def safe_queue_receipt_alerts(revision, actor):
# A failed configuration lookup/queue write rolls back only this savepoint.
try:
with transaction.atomic():
queue_receipt_alerts(revision, actor)
except Exception as error:
logger.error('Receipt alert queue failed revision=%s type=%s', revision.pk, type(error).__name__)
def prepare_receipt_alert(job):
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
alert = PaymentReceiptAlert.objects.select_related('recipient').get(job=job)
if job.channel == 'BALE':
enabled = configuration and configuration.bale_enabled and configuration.bale_chat_id == alert.bale_chat_id
else:
enabled = configuration and configuration.sms_enabled and alert.recipient and alert.recipient.is_active and alert.recipient.mobile == alert.recipient_mobile
if enabled:
return True
job.status = 'CANCELLED'
job.last_error = 'اطلاع‌رسانی این کانال یا گیرنده غیرفعال شده یا مقصد تغییر کرده است.'
job.save(update_fields=['status', 'last_error'])
return False
def send_receipt_alert(job, sms_provider_factory=None):
"""A destination failure is returned to the worker's existing bounded retry policy."""
try:
alert = PaymentReceiptAlert.objects.select_related('revision').get(job=job)
if not alert.rendered_body:
validate_alert_template(alert.message_template)
alert.rendered_body = alert.message_template.format(**alert.context)
if alert.context.get('depositor_name') and '{customer_name}' not in alert.message_template:
alert.rendered_body += '\nمشتری / واریزکننده: ' + alert.context['customer_name']
alert.save(update_fields=['rendered_body'])
if job.channel == 'SMS':
from apps.integrations.sms.providers import get_sms_provider
return (sms_provider_factory or get_sms_provider)().send_message(alert.recipient_mobile, alert.rendered_body)
from apps.integrations.bale.providers import BaleBotProvider
configuration = PaymentReceiptAlertConfiguration.objects.get(pk=1)
if not configuration.bale_token:
return SMSResult(False, error='توکن ربات بله پرداخت تنظیم نشده است.')
if len(alert.rendered_body) > 4096:
return SMSResult(False, error='متن همراه تصویر بله بیش از ۴۰۹۶ کاراکتر است.')
with alert.revision.image.open('rb') as image:
return BaleBotProvider().send_photo(alert.bale_chat_id, image, alert.rendered_body,
token=configuration.bale_token, filename=f'receipt-{alert.revision.pk}{Path(alert.revision.image.name).suffix}',
mime_type=alert.revision.mime_type)
except Exception as error:
# HTTP exception strings can contain credentials in the request URL. Never persist them.
return SMSResult(False, error=f'خطا در ارسال اعلان فیش ({type(error).__name__})')
+69 -2
View File
@@ -1,8 +1,74 @@
"""Bounded-query projections use only the current evidence revision for financial totals.""" """Bounded-query projections use only the current evidence revision for financial totals."""
from datetime import datetime, time, timedelta
from django.urls import reverse from django.urls import reverse
from django.db.models import BigIntegerField, F, OuterRef, Subquery, Sum, Value from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from .models import PaymentReceiptRevision from django.utils import timezone
from .models import PaymentReceipt, PaymentReceiptRevision
def daily_payment_summary(receipts, notices):
"""Summarize permission-scoped, unfiltered querysets in two aggregate queries.
Submission means initial receipt creation. Final outcomes use the current
revision's finalization time, including receipts submitted on earlier days.
Superseded revisions and partial votes never count as final outcomes.
"""
local_timezone = timezone.get_default_timezone()
today = timezone.localdate(timezone=local_timezone)
start = timezone.make_aware(datetime.combine(today, time.min), local_timezone)
end = timezone.make_aware(datetime.combine(today + timedelta(days=1), time.min), local_timezone)
current = PaymentReceiptRevision.objects.filter(
receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
receipts = receipts.annotate(
summary_amount=Subquery(current.values('amount')[:1]),
summary_finalized_at=Subquery(current.values('cycle__finalized_at')[:1]))
submitted = Q(created_at__gte=start, created_at__lt=end)
finalized = Q(summary_finalized_at__gte=start, summary_finalized_at__lt=end)
approved = finalized & Q(status=PaymentReceipt.Status.APPROVED)
rejected = finalized & Q(status=PaymentReceipt.Status.REJECTED)
pending = Q(status__in=[PaymentReceipt.Status.PENDING, PaymentReceipt.Status.PARTIAL])
summary = receipts.aggregate(
submitted_count=Count('pk', filter=submitted),
submitted_amount=Sum('summary_amount', filter=submitted, default=0),
approved_count=Count('pk', filter=approved),
approved_amount=Sum('summary_amount', filter=approved, default=0),
rejected_count=Count('pk', filter=rejected),
rejected_amount=Sum('summary_amount', filter=rejected, default=0),
pending_count=Count('pk', filter=pending),
pending_amount=Sum('summary_amount', filter=pending, default=0))
summary['notice_count'] = notices.filter(created_at__gte=start, created_at__lt=end).count()
summary['date'] = today
return summary
def notice_sms_history(notice):
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
from django.db.models import Prefetch
from .models import PaymentNotificationAttempt
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
).order_by('-job__created_at', '-pk')
for notification in notifications:
job = notification.job
notification.manual_resend = ':resend:' in job.dedupe_key
notification.delivery_class = 'info'
if job.status != 'SENT':
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
elif not job.provider_message_id:
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
elif not job.delivery_status:
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
elif job.delivery_is_final:
notification.delivery_label = job.get_delivery_status_display()
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
elif job.delivery_status == 'PENDING':
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
else:
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
return notifications
def notices_with_totals(queryset): def notices_with_totals(queryset):
@@ -16,6 +82,7 @@ def notices_with_totals(queryset):
def receipts_with_current_values(queryset): def receipts_with_current_values(queryset):
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version')) current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]), return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]),
current_depositor_name=Subquery(current.values('depositor_name')[:1]),
current_paid_date=Subquery(current.values('paid_date')[:1])) current_paid_date=Subquery(current.values('paid_date')[:1]))
+76 -7
View File
@@ -17,6 +17,36 @@ def actor_name(actor):
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
@transaction.atomic
def resend_notice_sms(actor, notice, request_key):
from uuid import UUID
from .notifications import context, notice_sms_pending
from .models import PaymentNotification
from apps.notifications.services import enqueue
if not policies.can_create(actor, notice.owning_unit) or not policies.notice_actions(PaymentNotice.objects.filter(pk=notice.pk), actor).exists():
raise PermissionDenied
try:
key = str(UUID(str(request_key)))
except (ValueError, TypeError, AttributeError):
raise ValidationError('درخواست ارسال معتبر نیست؛ صفحه را دوباره باز کنید.')
# Same customer -> notice lock order as issuance, receipt submission and deletion.
customer = User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if notice.deleted_at or notice.cancelled_at or not customer.is_active or not customer.mobile:
raise ValidationError('این اعلامیه یا مشتری امکان ارسال پیامک ندارد.')
dedupe_key = f'payment-notice:{notice.public_id}:resend:{key}'
previous = PaymentNotification.objects.filter(notice=notice, job__dedupe_key=dedupe_key).select_related('job').first()
if previous:
return previous.job
if notice_sms_pending(notice):
raise ValidationError('پیامک این اعلامیه در صف یا در حال ارسال است؛ تا پایان تلاش جاری منتظر بمانید.')
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, dedupe_key, customer=customer, staff_user=actor)
PaymentNotification.objects.create(job=job, notice=notice, unit=notice.owning_unit,
context=context(customer, notice=notice, amount=notice.amount))
audit(actor, 'NOTICE_SMS_RESEND_REQUESTED', notice, {'job_id': job.pk})
return job
def audit(actor, action, obj, details=None): def audit(actor, action, obj, details=None):
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM', return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {}) action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
@@ -24,7 +54,33 @@ def audit(actor, action, obj, details=None):
def configuration_snapshot(obj): def configuration_snapshot(obj):
"""Serialize only the financial configuration passed here, including flags and M2M IDs.""" """Serialize only the financial configuration passed here, including flags and M2M IDs."""
return json.loads(serializers.serialize('json', [obj]))[0]['fields'] snapshot = json.loads(serializers.serialize('json', [obj]))[0]['fields']
if 'bale_token' in snapshot:
snapshot['bale_token'] = '[configured]' if snapshot['bale_token'] else ''
return snapshot
@transaction.atomic
def delete_notice(actor, notice):
# Receipt submission locks the same customer, then notice. Do not lock access
# links here: anonymous submission already holds its link before the customer.
actor = User.objects.select_related('staff_profile').get(pk=actor.pk)
User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if not policies.may_delete_notice(actor, notice):
raise PermissionDenied
if notice.receipts.exists():
raise ValidationError('برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.')
if notice.deleted_at:
return
notice.deleted_at = notice.cancelled_at = timezone.now()
notice.save(update_fields=['deleted_at', 'cancelled_at', 'updated_at'])
from apps.notifications.models import NotificationJob
NotificationJob.objects.filter(payment_notification__notice=notice,
status__in=['PENDING', 'RETRYING', 'FAILED']).update(status='CANCELLED',
last_error='اعلامیه واریز حذف شده است.')
audit(actor, 'NOTICE_DELETED', notice, {'number': notice.number, 'customer_id': notice.customer_id,
'subject': notice.subject, 'amount': notice.amount})
@transaction.atomic @transaction.atomic
@@ -37,7 +93,8 @@ def issue_notice(actor, data):
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk) account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
if not account.is_active or not account.available_to_staff: if not account.is_active or not account.available_to_staff:
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.') raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
deposit_id = validators.digits(data.get('deposit_id', '')) from .deposit_identifier import normalize_deposit_identifier
deposit_id = normalize_deposit_identifier(data.get('deposit_id', ''))
if account.requires_deposit_id and not deposit_id: if account.requires_deposit_id and not deposit_id:
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.') raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
key = data.get('submission_key') key = data.get('submission_key')
@@ -85,9 +142,11 @@ def assign_cycle(revision, actor):
def receipt_metadata(data): def receipt_metadata(data):
return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'], from .deposit_identifier import normalize_deposit_identifier
from .identity import depositor_name
return {'depositor_name': depositor_name(data.get('depositor_name')), 'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''), 'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
'deposit_id': validators.digits(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''), 'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
'description': data.get('description', '')} 'description': data.get('description', '')}
@@ -99,6 +158,7 @@ def validate_destination(account, notice):
def _submit_receipt(actor, customer, data, file_info, created_files): def _submit_receipt(actor, customer, data, file_info, created_files):
fields = receipt_metadata(data)
# Same-customer lock serializes retries of a submission nonce before inserting it. # Same-customer lock serializes retries of a submission nonce before inserting it.
User.objects.select_for_update().get(pk=customer.pk) User.objects.select_for_update().get(pk=customer.pk)
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first() existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
@@ -108,7 +168,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
raise PermissionDenied raise PermissionDenied
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
existing.notice_id != getattr(data.get('notice_id'), 'pk', None)): existing.notice_id != getattr(data.get('notice_id'), 'pk', None) or
revision.depositor_name != fields['depositor_name']):
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.') raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing return existing
notice = data.get('notice_id') notice = data.get('notice_id')
@@ -120,7 +181,6 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
validate_destination(account, notice) validate_destination(account, notice)
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice, receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key']) owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
fields = receipt_metadata(data)
fields['destination'] = account fields['destination'] = account
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '') fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
revision = PaymentReceiptRevision(receipt=receipt, version=1, revision = PaymentReceiptRevision(receipt=receipt, version=1,
@@ -133,6 +193,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
created_files.append((revision.image.storage, revision.image.name)) created_files.append((revision.image.storage, revision.image.name))
assign_cycle(revision, actor) assign_cycle(revision, actor)
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256}) audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt return receipt
@@ -166,7 +228,7 @@ def submit_receipt(actor, customer, data, *, access_link=None):
@transaction.atomic @transaction.atomic
def decide_receipt(actor, receipt_id, version, action, reason=''): def decide_receipt(actor, receipt_id, version, action, reason='', *, rejection_confirmed=True):
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN. # Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id) receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
# Never authorize a financial vote using a stale User/profile/permission cache. # Never authorize a financial vote using a stale User/profile/permission cache.
@@ -184,6 +246,8 @@ def decide_receipt(actor, receipt_id, version, action, reason=''):
allowed = policies.can_decide(actor, receipt, cycle, senior=True) allowed = policies.can_decide(actor, receipt, cycle, senior=True)
if not allowed: if not allowed:
raise PermissionDenied raise PermissionDenied
if action == 'REJECT' and not rejection_confirmed:
raise ValidationError('برای رد فیش، ارسال دلیل رد به مشتری را تأیید کنید.')
reason = reason.strip() reason = reason.strip()
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason: if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
raise ValidationError('دلیل این تصمیم را بنویسید.') raise ValidationError('دلیل این تصمیم را بنویسید.')
@@ -217,6 +281,9 @@ def decide_receipt(actor, receipt_id, version, action, reason=''):
if status == 'APPROVED': if status == 'APPROVED':
from .notifications import queue_approval from .notifications import queue_approval
queue_approval(receipt, cycle) queue_approval(receipt, cycle)
elif status == 'REJECTED':
from .notifications import queue_rejection
queue_rejection(receipt, cycle, reason)
return receipt return receipt
@@ -279,6 +346,8 @@ def revise_receipt(actor, receipt_id, version, data):
receipt.status='PENDING' receipt.status='PENDING'
receipt.save(update_fields=['current_version','status','updated_at']) receipt.save(update_fields=['current_version','status','updated_at'])
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256}) audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
from .receipt_alerts import safe_queue_receipt_alerts
safe_queue_receipt_alerts(revision, actor)
return receipt return receipt
except Exception: except Exception:
for storage,name in created_files: for storage,name in created_files:
+2
View File
@@ -8,6 +8,8 @@ urlpatterns = [
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'), path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
path('notices/new/', views.notice_new, name='notice_new'), path('notices/new/', views.notice_new, name='notice_new'),
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'), path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/delete/', views.notice_delete, name='notice_delete'),
path('notices/<uuid:public_id>/resend-sms/', views.notice_resend_sms, name='notice_resend_sms'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'), path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'), path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'), path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
+1
View File
@@ -6,6 +6,7 @@ urlpatterns = [
path('', views.home, name='home'), path('', views.home, name='home'),
path('bank-accounts/', views.notices, name='notices'), path('bank-accounts/', views.notices, name='notices'),
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'), path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
path('bank-accounts/<uuid:public_id>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
path('payment-receipt/', views.receipt_new, name='receipt_new'), path('payment-receipt/', views.receipt_new, name='receipt_new'),
path('receipts/', views.receipt_list, name='receipt_list'), path('receipts/', views.receipt_list, name='receipt_list'),
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'), path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
+80 -8
View File
@@ -42,6 +42,8 @@ def home(request):
def complete_notice(notice): def complete_notice(notice):
from .deposit_identifier import normalize_deposit_identifier
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
snapshot = notice.account_snapshot snapshot = notice.account_snapshot
approved = getattr(notice, 'approved_total', None) approved = getattr(notice, 'approved_total', None)
if approved is None: if approved is None:
@@ -57,6 +59,8 @@ def complete_notice(notice):
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')), ('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id), ('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value) ('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
if notice.deposit_id:
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید.'
return notice return notice
@@ -84,6 +88,17 @@ def customer_notice_detail(request, public_id):
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True)) notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
@require_POST
@financial_required(allow_payment_link=True)
def customer_notice_share_link(request, public_id):
notice = get_object_or_404(access.visible_payments(request, PaymentNotice.objects.all()),
public_id=public_id, customer__is_active=True)
url = access.notice_share_url(request, notice)
response = JsonResponse({'url': url})
response['Cache-Control'] = 'private, no-store'
return response
def receipt_form_page(request, customer, staff=False): def receipt_form_page(request, customer, staff=False):
link = getattr(request, 'payment_access_link', None) link = getattr(request, 'payment_access_link', None)
initial = access.receipt_initial(request, customer) initial = access.receipt_initial(request, customer)
@@ -93,9 +108,13 @@ def receipt_form_page(request, customer, staff=False):
try: try:
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link) receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.') messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])})
return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id) return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id)
except ValidationError as error: except ValidationError as error:
form.add_error(None, error) form.add_error(None, error)
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
selected = form['notice_id'].value() selected = form['notice_id'].value()
available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form) available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form)
notice_data = [selectors.notice_form_data(n, staff=staff) for n in available] notice_data = [selectors.notice_form_data(n, staff=staff) for n in available]
@@ -103,7 +122,7 @@ def receipt_form_page(request, customer, staff=False):
image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first() image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True, ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True,
'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer, 'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer,
'account_requirements': {str(a.pk): a.requires_deposit_id for a in form.fields['destination_account'].queryset}} 'account_requirements': form.account_warnings, 'customer_deposit_id': form.customer_deposit_id}
return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx)) return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx))
@@ -202,7 +221,8 @@ def decision(request, public_id):
get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id) get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id)
try: try:
version = int(request.POST.get('version', '0')) version = int(request.POST.get('version', '0'))
receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', '')) receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', ''),
rejection_confirmed=request.POST.get('confirm_rejection') == '1')
messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display()) messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display())
except (ValidationError, ValueError) as error: except (ValidationError, ValueError) as error:
messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.') messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.')
@@ -254,8 +274,12 @@ def filtered(queryset, request):
@financial_required(staff=True) @financial_required(staff=True)
def desk(request): def desk(request):
tab = request.GET.get('tab', 'receipts') tab = request.GET.get('tab', 'receipts')
if tab not in {'receipts', 'notices'}:
tab = 'receipts'
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user)) receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user))
notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user)) notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user))
daily_summary = selectors.daily_payment_summary(receipts, notice_items)
if tab == 'notices': if tab == 'notices':
items = filtered(notice_items, request) items = filtered(notice_items, request)
state = request.GET.get('notice_state', '') state = request.GET.get('notice_state', '')
@@ -274,7 +298,7 @@ def desk(request):
items = items.filter(notice__isnull=True) items = items.filter(notice__isnull=True)
elif request.GET.get('kind') == 'linked': elif request.GET.get('kind') == 'linked':
items = items.filter(notice__isnull=False) items = items.filter(notice__isnull=False)
if request.GET.get('mine'): if mine:
my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'), my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'),
cycle__revision__version=OuterRef('current_version'), user=request.user) cycle__revision__version=OuterRef('current_version'), user=request.user)
items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'), items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'),
@@ -289,8 +313,14 @@ def desk(request):
page.object_list = selectors.receipt_review_summaries(page.object_list) page.object_list = selectors.receipt_review_summaries(page.object_list)
query = request.GET.copy() query = request.GET.copy()
query.pop('page', None) query.pop('page', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, statuses=PaymentReceipt.Status.choices, query['tab'] = tab
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode())) if mine:
query['mine'] = '1'
else:
query.pop('mine', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode(),
daily_summary=daily_summary))
@financial_required(staff=True) @financial_required(staff=True)
@@ -330,17 +360,53 @@ def notice_new(request):
@financial_required(staff=True) @financial_required(staff=True)
def notice_detail(request, public_id): def notice_detail(request, public_id):
notice = complete_notice(get_object_or_404(policies.visible(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id)) notice = complete_notice(get_object_or_404(policies.notice_actions(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id))
services.audit(request.user, 'NOTICE_VIEWED', notice) services.audit(request.user, 'NOTICE_VIEWED', notice)
share = request.session.get('payment_link_share', {}) share = request.session.get('payment_link_share', {})
if share.get('notice') == str(public_id): if share.get('notice') == str(public_id):
request.session.pop('payment_link_share', None) request.session.pop('payment_link_share', None)
else: else:
share = {} share = {}
from uuid import uuid4
from .notifications import notice_sms_pending
return render(request, 'payments/notice_detail.html', staff_context(notice=notice, return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
can_delete_notice=policies.can_delete_notice(request.user, notice),
may_delete_notice=policies.may_delete_notice(request.user, notice),
can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active, can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active,
payment_share_url=share.get('url', ''), payment_share_url=share.get('url', ''),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=notice.paymentnotification_set.select_related('job'))) can_resend_notice_sms=policies.can_create(request.user, notice.owning_unit) and notice.customer.is_active and not notice.cancelled_at,
notice_sms_pending=notice_sms_pending(notice), notice_sms_request_key=str(uuid4()),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=selectors.notice_sms_history(notice)))
@financial_required(staff=True)
@require_POST
def notice_resend_sms(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
try:
services.resend_notice_sms(request.user, notice, request.POST.get('request_key', ''))
messages.success(request, 'درخواست ارسال مجدد پیامک در صف ثبت شد؛ نتیجه ارسال و دریافت در سوابق نمایش داده می‌شود.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
@require_POST
def notice_delete(request, public_id):
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
if not policies.may_delete_notice(request.user, notice):
raise PermissionDenied
if request.POST.get('confirm') != 'delete':
messages.error(request, 'برای حذف اعلامیه، ابتدا هشدار را بخوانید و حذف را تأیید کنید.')
return redirect('staff_payments:notice_detail', public_id=public_id)
try:
services.delete_notice(request.user, notice)
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:notice_detail', public_id=public_id)
messages.success(request, 'اعلامیه واریز حذف شد و لینک‌های آن دیگر قابل استفاده نیستند.')
return redirect(reverse('staff_payments:desk') + '?tab=notices')
@financial_required(staff=True) @financial_required(staff=True)
@@ -442,8 +508,10 @@ def receipt_revise(request, public_id):
receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED') receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED')
import jdatetime import jdatetime
current = receipt.revisions.get(version=receipt.current_version) current = receipt.revisions.get(version=receipt.current_version)
from .identity import customer_name
paid = jdatetime.date.fromgregorian(date=current.paid_date) paid = jdatetime.date.fromgregorian(date=current.paid_date)
initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id, initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id,
'depositor_name':current.depositor_name or customer_name(request.payment_customer),
'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id, 'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id,
'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year), 'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year),
'origin_bank':current.origin_bank_id,'origin_other':current.origin_other, 'origin_bank':current.origin_bank_id,'origin_other':current.origin_other,
@@ -454,13 +522,17 @@ def receipt_revise(request, public_id):
try: try:
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data) services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.') messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.')
if request.headers.get('Accept') == 'application/json':
return JsonResponse({'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])})
return redirect('payments:receipt_detail',public_id=receipt.public_id) return redirect('payments:receipt_detail',public_id=receipt.public_id)
except (ValidationError,ValueError) as error: except (ValidationError,ValueError) as error:
form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.') form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.')
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
from apps.attachments.models import AttachmentPolicy from apps.attachments.models import AttachmentPolicy
policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first() policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first()
notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)] notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)]
return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0, return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0,
'payment_link_access': link, 'payment_link_access': link,
'account_requirements':{str(a.pk):a.requires_deposit_id for a in form.fields['destination_account'].queryset}, 'account_requirements':form.account_warnings, 'customer_deposit_id':form.customer_deposit_id,
'financial_page':True,'revision_version':receipt.current_version}) 'financial_page':True,'revision_version':receipt.current_version})
+2 -2
View File
@@ -14,14 +14,14 @@ class UnitMembershipInline(admin.TabularInline):
@admin.register(OrganizationalUnit) @admin.register(OrganizationalUnit)
class OrganizationalUnitAdmin(admin.ModelAdmin): class OrganizationalUnitAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at") list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active") list_filter = ("supervisor_sms_ticket_link_enabled", "show_in_customer_form", "accepts_external_tickets", "is_active")
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5") search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active") list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
filter_horizontal = ("permissions",) filter_horizontal = ("permissions",)
inlines = (UnitMembershipInline,) inlines = (UnitMembershipInline,)
fieldsets = ( fieldsets = (
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}), ("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")}), ("اطلاع‌رسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5", "supervisor_sms_ticket_link_enabled")}),
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}), ("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
) )
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-13 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0012_organizationalunit_supervisor_mobile_5'),
]
operations = [
migrations.AddField(
model_name='organizationalunit',
name='supervisor_sms_ticket_link_enabled',
field=models.BooleanField(default=False, help_text='لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.', verbose_name='افزودن لینک تیکت به پیامک سرپرستان'),
),
]
+5
View File
@@ -38,6 +38,11 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم") supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم") supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم") supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم")
supervisor_sms_ticket_link_enabled = models.BooleanField(
default=False,
verbose_name="افزودن لینک تیکت به پیامک سرپرستان",
help_text="لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.",
)
class Meta: class Meta:
ordering = ["sort_order", "name", "pk"] ordering = ["sort_order", "name", "pk"]
+40 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag from .models import DispatchResponseControl,GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag
@admin.register(Ticket) @admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin): class TicketAdmin(admin.ModelAdmin):
list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at") list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at")
@@ -7,6 +7,15 @@ class TicketAdmin(admin.ModelAdmin):
search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name") search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name")
filter_horizontal=("tags",) filter_horizontal=("tags",)
def get_readonly_fields(self, request, obj=None):
if obj and obj.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=obj.group_dispatch_id).exists():
return ("group_dispatch", "delivery_type", "customer", "created_by")
return super().get_readonly_fields(request, obj)
def get_queryset(self, request):
from .dispatch_responses import managed_ticket_q
return super().get_queryset(request).filter(managed_ticket_q(request.user)).distinct()
class DispatchTicketInline(admin.TabularInline): class DispatchTicketInline(admin.TabularInline):
model = Ticket model = Ticket
@@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin):
readonly_fields = ("recipient_count", "dispatched_at") readonly_fields = ("recipient_count", "dispatched_at")
inlines = (DispatchTicketInline,) inlines = (DispatchTicketInline,)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if obj and DispatchResponseControl.objects.filter(dispatch_id=obj.pk).exists():
return fields + ("unit", "operation_type", "created_by", "contact_group")
return fields
def get_queryset(self, request):
from .policies import DispatchPolicy
from django.db.models import Q
return super().get_queryset(request).filter(Q(response_control__isnull=True) | Q(pk__in=DispatchPolicy.visible_dispatches(request.user)))
@admin.register(DispatchResponseControl)
class DispatchResponseControlAdmin(admin.ModelAdmin):
list_display = ("dispatch", "mode", "notify_sms", "opened_at", "opened_by")
readonly_fields = tuple(field.name for field in DispatchResponseControl._meta.fields)
def get_queryset(self, request):
from .policies import DispatchPolicy
return super().get_queryset(request).filter(dispatch__in=DispatchPolicy.visible_dispatches(request.user))
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(TicketCategory) @admin.register(TicketCategory)
class TicketCategoryAdmin(admin.ModelAdmin): class TicketCategoryAdmin(admin.ModelAdmin):
+35
View File
@@ -0,0 +1,35 @@
"""Read-only option metadata for the staff dispatch composer."""
from django.db.models import Count, Q
from .models import GroupDispatch, TicketCategory
from .policies import DispatchPolicy
def dispatch_form_options(user, units, groups):
"""Expose only permitted units; recipient counts follow the sending service."""
units = list(units)
eligible = (
Q(contacts__user__is_active=True, contacts__user__mobile__isnull=False)
& ~Q(contacts__user__mobile="")
)
return {
"units": [
{
"id": unit.pk,
"name": unit.name,
"operations": [
kind for kind in GroupDispatch.OperationType.values
if DispatchPolicy.can_create(user, unit, kind)
],
}
for unit in units
],
"groups": list(groups.annotate(recipient_count=Count("contacts", filter=eligible)).values(
"id", "name", "unit_id", "recipient_count",
)),
"categories": list(TicketCategory.objects.filter(
Q(unit_id__in=[unit.pk for unit in units]) | Q(unit__isnull=True),
is_active=True,
).order_by("title").values("id", "title", "unit_id")),
}
+45
View File
@@ -0,0 +1,45 @@
from django.core import signing
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from .dispatch_responses import can_open_responses, open_dispatch_responses, render_opening_sms, response_control
from .models import GroupDispatch
from apps.notifications.models import SMSTemplate
from .views import staff_required
@staff_required
@require_http_methods(["GET", "POST"])
@never_cache
def open_responses(request, public_id):
dispatch = get_object_or_404(GroupDispatch.objects.select_related("unit"), public_id=public_id)
control = response_control(dispatch)
if not control or control.mode != "SEALED" or not can_open_responses(request.user, dispatch):
raise PermissionDenied
if control.opened_at:
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
if request.method == "POST":
try:
proof = signing.loads(request.POST.get("confirmation", ""), salt="dispatch-opening", max_age=600)
if proof["user"] != request.user.pk or proof["dispatch"] != str(public_id):
raise signing.BadSignature
open_dispatch_responses(dispatch, request.user, proof["sms"])
except (signing.BadSignature, KeyError):
messages.error(request, "تأیید معتبر نیست یا مهلت آن پایان یافته؛ دوباره بررسی کنید.")
except (ValidationError, ValueError, SMSTemplate.DoesNotExist) as error:
messages.error(request, str(error))
else:
messages.success(request, "پاسخ‌ها هم‌زمان بازگشایی شدند؛ وضعیت پیامک را در گزارش بررسی کنید.")
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
try:
sms = render_opening_sms(dispatch) if control.notify_sms else ""
except (SMSTemplate.DoesNotExist, ValueError):
messages.error(request, "قالب پیامک بازگشایی معتبر یا فعال نیست؛ تنظیمات قالب را بررسی کنید.")
return redirect("staff_tickets:dispatch_detail", public_id=public_id)
from .dispatch_responses import _respondents
proof = signing.dumps({"user": request.user.pk, "dispatch": str(public_id), "sms": sms}, salt="dispatch-opening")
return render(request, "tickets/dispatch_open_confirm.html", {"dispatch": dispatch, "control": control,
"opening_sms": sms, "respondent_count": len(_respondents(dispatch)), "confirmation": proof})
+139
View File
@@ -0,0 +1,139 @@
"""Response confidentiality is independent of ordinary ticket permissions."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Count, F, Max, Min, Q
from django.utils import timezone
from .models import DispatchResponseControl, GroupDispatch
from .policies import DispatchPolicy, TicketPolicy
OPENING_EVENT = "DISPATCH_RESPONSES_OPENED"
def response_control(dispatch):
if dispatch is None:
return None
return DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).first()
def responses_locked(ticket):
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
return bool(control and control.mode == control.Mode.SEALED and control.opened_at is None)
def sealed_message_q(prefix=""):
return Q(**{
prefix + "ticket__group_dispatch__response_control__mode": "SEALED",
prefix + "ticket__group_dispatch__response_control__opened_at__isnull": True,
prefix + "author_id": F(prefix + "ticket__customer_id"),
})
def can_read_response(user, message):
if message.author_id != message.ticket.customer_id:
return True
if getattr(user, "account_type", None) == "CUSTOMER" and user.pk == message.author_id:
return True
return not responses_locked(message.ticket)
def filter_response_messages(query, user):
if getattr(user, "account_type", None) == "CUSTOMER":
return query.exclude(sealed_message_q() & ~Q(author_id=user.pk))
return query.exclude(sealed_message_q())
def can_record_ticket_read(ticket, user):
if not TicketPolicy.can_view(user, ticket):
return False
if not responses_locked(ticket):
return True
return not ticket.ticket_messages.filter(author_id=ticket.customer_id, deleted_at__isnull=True).exists()
def can_open_responses(user, dispatch):
if not user.is_authenticated or not user.is_active or not DispatchPolicy.can_view(user, dispatch):
return False
profile = getattr(user, "staff_profile", None)
if getattr(user, "account_type", None) != "STAFF" or (profile and not profile.is_active):
return False
return bool(user.is_superuser or dispatch.created_by_id == user.pk or user.has_perm("tickets.open_all_dispatch_responses"))
def render_opening_sms(dispatch):
from apps.notifications.management.commands.process_notifications import _render_sms
return _render_sms(OPENING_EVENT, {}, dispatch.unit)
def _respondents(dispatch):
# A withdrawn/deleted message is not a current response; attachment-only replies count.
return list(dispatch.tickets.filter(ticket_messages__author_id=F("customer_id"),
ticket_messages__deleted_at__isnull=True).distinct().order_by("pk"))
def _queue_opening_sms(control, tickets):
from apps.notifications.services import enqueue
from apps.notifications.models import NotificationJob
for ticket in tickets:
enqueue(NotificationJob.Channel.SMS, OPENING_EVENT, ticket,
f"dispatch:{control.dispatch_id}:opened:{ticket.customer_id}",
customer=ticket.customer,
payload={"dispatch_id": control.dispatch_id, "body": control.sms_body_snapshot})
@transaction.atomic
def open_dispatch_responses(dispatch, user, expected_sms=""):
# Replies take the same control lock before locking their ticket.
control = DispatchResponseControl.objects.select_for_update(of=("self",)).select_related("dispatch__unit").get(dispatch=dispatch)
dispatch = control.dispatch
if not can_open_responses(user, dispatch):
raise PermissionDenied
if control.mode != control.Mode.SEALED:
raise ValidationError("این ارسال در حالت مشاهده جداگانه است.")
if control.opened_at:
return control, False
text = render_opening_sms(dispatch) if control.notify_sms else ""
if text != expected_sms:
raise ValidationError("قالب پیامک تغییر کرده است؛ متن جدید را بررسی و دوباره تأیید کنید.")
tickets = _respondents(dispatch)
control.opened_at = timezone.now()
control.opened_by = user
control.sms_body_snapshot = text
control.respondents_snapshot = [str(ticket.customer.public_id) for ticket in tickets]
control.save(update_fields=["opened_at", "opened_by", "sms_body_snapshot", "respondents_snapshot"])
if control.notify_sms:
_queue_opening_sms(control, tickets)
return control, True
def lock_dispatch_for_ticket(ticket_id):
"""Must be called inside the caller's transaction, before its ticket lock."""
return DispatchResponseControl.objects.select_for_update(of=("self",)).filter(dispatch__tickets__pk=ticket_id).first()
def managed_ticket_q(user):
"""Explicitly related tickets or dispatch visibility; unit-wide scope alone is insufficient."""
dispatches = DispatchPolicy.visible_dispatches(user)
direct = Q(created_by=user) | Q(owner=user) | Q(selected_expert=user)
direct |= Q(access_grants__user=user, access_grants__revoked_at__isnull=True)
return Q(group_dispatch__response_control__isnull=True) | direct | Q(group_dispatch__in=dispatches)
def populate_report_responses(dispatch, tickets, locked):
"""Load report responses in batches; before opening, select metadata only."""
from apps.messaging.models import Message
responses = Message.objects.filter(ticket__group_dispatch=dispatch, author_id=F("ticket__customer_id"), deleted_at__isnull=True)
metadata = {row["ticket_id"]: row for row in responses.values("ticket_id").annotate(
count=Count("pk"), first=Min("created_at"), last=Max("created_at"))}
by_ticket = {}
if not locked:
for message in responses.order_by("created_at", "pk").prefetch_related("attachments"):
by_ticket.setdefault(message.ticket_id, []).append(message)
for ticket in tickets:
row = metadata.get(ticket.pk, {})
ticket.response_count = row.get("count", 0)
ticket.first_response_at = row.get("first")
ticket.last_response_at = row.get("last")
ticket.report_customer_responses = by_ticket.get(ticket.pk, [])
ticket.latest_customer_response = ticket.report_customer_responses[-1] if ticket.report_customer_responses else None
+7
View File
@@ -140,6 +140,8 @@ class StaffCreateTicketForm(forms.Form):
class GroupDispatchForm(forms.Form): class GroupDispatchForm(forms.Form):
response_mode = forms.ChoiceField(label="نحوه مشاهده پاسخ‌ها", choices=[("SEALED", "بازگشایی یکجا"), ("INDIVIDUAL", "مشاهده جداگانه")], initial="SEALED", required=False, widget=forms.RadioSelect)
notify_opening_sms = forms.BooleanField(label="اطلاع‌رسانی بازگشایی با پیامک", initial=True, required=False)
operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False) operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False)
unit = forms.ModelChoiceField(label="واحد ارسال‌کننده", queryset=OrganizationalUnit.objects.none()) unit = forms.ModelChoiceField(label="واحد ارسال‌کننده", queryset=OrganizationalUnit.objects.none())
contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none()) contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none())
@@ -177,6 +179,11 @@ class GroupDispatchForm(forms.Form):
data["operation_type"] = GroupDispatch.OperationType.BULK data["operation_type"] = GroupDispatch.OperationType.BULK
unit = data.get("unit") unit = data.get("unit")
group = data.get("contact_group") group = data.get("contact_group")
data["response_mode"] = data.get("response_mode") or "SEALED"
if data["response_mode"] == "INDIVIDUAL":
data["notify_opening_sms"] = False
if group and group.contacts.filter(user__is_active=True, user__mobile__isnull=False).exclude(user__mobile="").count() > 15:
data["notify_opening_sms"] = False
if unit and group and group.unit_id != unit.pk: if unit and group and group.unit_id != unit.pk:
self.add_error("contact_group", "این گروه متعلق به واحد انتخاب‌شده نیست.") self.add_error("contact_group", "این گروه متعلق به واحد انتخاب‌شده نیست.")
category=data.get("category") category=data.get("category")
@@ -0,0 +1,43 @@
# Generated by Django 5.1.1 on 2026-10-13 09:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def preserve_used_response_controls(apps, schema_editor):
from django.db.migrations.exceptions import IrreversibleError
if apps.get_model('tickets', 'DispatchResponseControl').objects.using(schema_editor.connection.alias).exists():
raise IrreversibleError('Response controls are in use; refusing to delete confidentiality and opening history.')
class Migration(migrations.Migration):
dependencies = [
('tickets', '0017_ticketaccessgrant_first_viewed_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DispatchResponseControl',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mode', models.CharField(choices=[('SEALED', 'بازگشایی یکجا'), ('INDIVIDUAL', 'مشاهده جداگانه')], default='SEALED', max_length=12)),
('notify_sms', models.BooleanField(default=True)),
('recipient_count_snapshot', models.PositiveIntegerField()),
('opened_at', models.DateTimeField(blank=True, null=True)),
('sms_body_snapshot', models.TextField(blank=True)),
('respondents_snapshot', models.JSONField(blank=True, default=list)),
('dispatch', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='response_control', to='tickets.groupdispatch')),
('opened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'تنظیمات و سابقه بازگشایی ارسال',
'verbose_name_plural': 'تنظیمات و سوابق بازگشایی ارسال‌ها',
'permissions': [('open_all_dispatch_responses', 'Can open responses of all authorized dispatches')],
'constraints': [models.CheckConstraint(condition=models.Q(('notify_sms', False), models.Q(('mode', 'SEALED'), ('recipient_count_snapshot__lte', 15)), _connector='OR'), name='dispatch_opening_sms_limit'), models.CheckConstraint(condition=models.Q(('mode__in', ['SEALED', 'INDIVIDUAL'])), name='dispatch_response_mode_valid'), models.CheckConstraint(condition=models.Q(models.Q(('opened_at__isnull', True), ('opened_by__isnull', True)), models.Q(('mode', 'SEALED'), ('opened_at__isnull', False), ('opened_by__isnull', False)), _connector='OR'), name='dispatch_opening_audit_complete')],
},
),
migrations.RunPython(migrations.RunPython.noop, preserve_used_response_controls),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.1.1 on 2026-10-13 12:22
import apps.tickets.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0018_dispatch_response_opening'),
]
operations = [
migrations.CreateModel(
name='StaffTicketShortLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('token', models.CharField(default=apps.tickets.models.staff_ticket_link_token, editable=False, max_length=10, unique=True)),
('expires_at', models.DateTimeField(default=apps.tickets.models.staff_ticket_link_expiry)),
('ticket', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_short_link', to='tickets.ticket')),
],
options={
'verbose_name': 'لینک کوتاه تیکت کارشناسان',
'verbose_name_plural': 'لینک\u200cهای کوتاه تیکت کارشناسان',
},
),
]
+52
View File
@@ -1,10 +1,35 @@
import uuid import uuid
import secrets
import string
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils import timezone
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel
def staff_ticket_link_token():
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
def staff_ticket_link_expiry():
return timezone.now() + timedelta(days=7)
class StaffTicketShortLink(TimeStampedModel):
"""A short navigation URL; it never authenticates or grants ticket access."""
ticket = models.OneToOneField("Ticket", on_delete=models.CASCADE, related_name="staff_short_link")
token = models.CharField(max_length=10, unique=True, default=staff_ticket_link_token, editable=False)
expires_at = models.DateTimeField(default=staff_ticket_link_expiry)
class Meta:
verbose_name = "لینک کوتاه تیکت کارشناسان"
verbose_name_plural = "لینک‌های کوتاه تیکت کارشناسان"
class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel): class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel):
persian_text_fields = {"title": False, "description": True, "search_keywords": True} persian_text_fields = {"title": False, "description": True, "search_keywords": True}
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.") unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دسته‌های قدیمی عمومی می‌تواند خالی باشد؛ دسته‌های جدید باید واحد داشته باشند.")
@@ -176,3 +201,30 @@ class MessageShare(PublicIdModel, models.Model):
class TicketReadState(models.Model): class TicketReadState(models.Model):
ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True) ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True)
class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")] class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")]
class DispatchResponseControl(models.Model):
"""Absent for legacy dispatches: their existing visibility is preserved."""
class Mode(models.TextChoices):
SEALED = "SEALED", "بازگشایی یکجا"
INDIVIDUAL = "INDIVIDUAL", "مشاهده جداگانه"
dispatch = models.OneToOneField(GroupDispatch, on_delete=models.PROTECT, related_name="response_control")
mode = models.CharField(max_length=12, choices=Mode.choices, default=Mode.SEALED)
notify_sms = models.BooleanField(default=True)
recipient_count_snapshot = models.PositiveIntegerField()
opened_at = models.DateTimeField(null=True, blank=True)
opened_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True)
sms_body_snapshot = models.TextField(blank=True)
respondents_snapshot = models.JSONField(default=list, blank=True)
class Meta:
verbose_name = "تنظیمات و سابقه بازگشایی ارسال"
verbose_name_plural = "تنظیمات و سوابق بازگشایی ارسال‌ها"
permissions = [("open_all_dispatch_responses", "Can open responses of all authorized dispatches")]
constraints = [
models.CheckConstraint(condition=Q(notify_sms=False) | Q(mode="SEALED", recipient_count_snapshot__lte=15), name="dispatch_opening_sms_limit"),
models.CheckConstraint(condition=Q(mode__in=["SEALED", "INDIVIDUAL"]), name="dispatch_response_mode_valid"),
models.CheckConstraint(condition=Q(opened_at__isnull=True, opened_by__isnull=True) | Q(mode="SEALED", opened_at__isnull=False, opened_by__isnull=False), name="dispatch_opening_audit_complete"),
]
+46
View File
@@ -70,6 +70,20 @@ class TicketPolicy:
} }
@classmethod @classmethod
def can_view(cls,user,ticket): def can_view(cls,user,ticket):
from .models import DispatchResponseControl
if ticket.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=ticket.group_dispatch_id).exists():
if not user.is_authenticated:
return False
if cls.is_customer(user):
return ticket.customer_id == user.pk
if DispatchPolicy.can_view(user, ticket.group_dispatch):
return True
return bool(ticket.created_by_id == user.pk or ticket.owner_id == user.pk or ticket.selected_expert_id == user.pk
or ticket.access_grants.filter(user=user, revoked_at__isnull=True).exists())
return cls._legacy_can_view(user, ticket)
@classmethod
def _legacy_can_view(cls,user,ticket):
if not user.is_authenticated: return False if not user.is_authenticated: return False
if cls.is_customer(user): return ticket.customer_id==user.id if cls.is_customer(user): return ticket.customer_id==user.id
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True
@@ -83,6 +97,11 @@ class TicketPolicy:
return False return False
@classmethod @classmethod
def viewable_q(cls, user): def viewable_q(cls, user):
from .dispatch_responses import managed_ticket_q
return (cls._legacy_viewable_q(user) | Q(group_dispatch__in=DispatchPolicy.visible_dispatches(user))) & managed_ticket_q(user)
@classmethod
def _legacy_viewable_q(cls, user):
"""Database equivalent of can_view; use it for every staff ticket collection.""" """Database equivalent of can_view; use it for every staff ticket collection."""
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
return Q() return Q()
@@ -169,5 +188,32 @@ class DispatchPolicy:
@classmethod @classmethod
def can_view(cls, user, dispatch): def can_view(cls, user, dispatch):
if dispatch.created_by_id == user.id: if dispatch.created_by_id == user.id:
from .models import DispatchResponseControl
if DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).exists():
return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF")
return cls.can_create(user, dispatch.unit, dispatch.operation_type) return cls.can_create(user, dispatch.unit, dispatch.operation_type)
return cls.can_view_all(user, dispatch.operation_type, dispatch.unit) return cls.can_view_all(user, dispatch.operation_type, dispatch.unit)
@classmethod
def visible_dispatches(cls, user):
from .models import GroupDispatch
if user.is_superuser:
return GroupDispatch.objects.all()
visibility = Q(created_by=user, response_control__isnull=False)
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if cls.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_units = [item.unit_id for item in memberships if cls.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if cls.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
@classmethod
def can_list(cls, user):
if any(cls.can_view_all(user, kind) for kind in cls.VIEW_ALL_PERMISSIONS):
return True
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")
return any(cls.can_view_all(user, kind, item.unit) for item in units for kind in cls.VIEW_ALL_PERMISSIONS)
+22
View File
@@ -0,0 +1,22 @@
"""Even superusers use the response-content gate in the application admin."""
from django.contrib import admin
from django.db.models import Q
from .dispatch_responses import sealed_message_q
from .models import Ticket
from .policies import TicketPolicy
class ResponseContentAdmin(admin.ModelAdmin):
message_prefix = ""
def get_queryset(self, request):
query = super().get_queryset(request).exclude(sealed_message_q(self.message_prefix))
ticket_path = self.message_prefix + "ticket"
allowed = Ticket.objects.filter(TicketPolicy.viewable_q(request.user)).values("pk")
return query.filter(Q(**{ticket_path + "__group_dispatch__response_control__isnull": True})
| Q(**{ticket_path + "__in": allowed})).distinct()
class RelatedResponseContentAdmin(ResponseContentAdmin):
message_prefix = "message__"
+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(
+22 -1
View File
@@ -63,6 +63,7 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
) )
if not contacts: if not contacts:
raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.") raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.")
from .models import DispatchResponseControl
dispatch = GroupDispatch.objects.create( dispatch = GroupDispatch.objects.create(
unit=unit, unit=unit,
operation_type=operation_type, operation_type=operation_type,
@@ -74,6 +75,9 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
status=GroupDispatch.Status.QUEUED, status=GroupDispatch.Status.QUEUED,
recipient_count=len(contacts), recipient_count=len(contacts),
) )
DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED",
notify_sms=len(contacts) <= 15,
recipient_count_snapshot=len(contacts))
tickets=[] tickets=[]
for contact in contacts: for contact in contacts:
for uploaded in files: for uploaded in files:
@@ -97,6 +101,20 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
return dispatch, tickets return dispatch, tickets
@transaction.atomic
def create_group_dispatch(*, response_mode="SEALED", notify_opening_sms=True, **dispatch_fields):
"""Configure responses without changing the existing creation API."""
from .models import DispatchResponseControl
if response_mode not in DispatchResponseControl.Mode.values:
raise ValidationError("روش مشاهده پاسخ‌ها معتبر نیست.")
dispatch, tickets = dispatch_group_tickets(**dispatch_fields)
control = DispatchResponseControl.objects.get(dispatch=dispatch)
control.mode = response_mode
control.notify_sms = bool(notify_opening_sms and response_mode == "SEALED" and control.recipient_count_snapshot <= 15)
control.save(update_fields=["mode", "notify_sms"])
return dispatch, tickets
def refresh_group_dispatch_status(dispatch_id): def refresh_group_dispatch_status(dispatch_id):
"""Persist delivery state independently of whether a report page is opened.""" """Persist delivery state independently of whether a report page is opened."""
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first() dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
@@ -194,6 +212,8 @@ def revoke_access(ticket,user,target):
@transaction.atomic @transaction.atomic
def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()): def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()):
from .dispatch_responses import lock_dispatch_for_ticket
control = lock_dispatch_for_ticket(ticket_id)
ticket=Ticket.objects.select_for_update().get(pk=ticket_id) ticket=Ticket.objects.select_for_update().get(pk=ticket_id)
if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied
if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied
@@ -204,7 +224,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
if not TicketPolicy.can_view(target,ticket): if not TicketPolicy.can_view(target,ticket):
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target) if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.") else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.")
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]}) excerpt = "پاسخ محرمانه ثبت شد." if control and control.mode == "SEALED" and not control.opened_at and user.pk == ticket.customer_id else body[:150]
MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":excerpt})
now=timezone.now(); ticket.last_activity_at=now now=timezone.now(); ticket.last_activity_at=now
if not internal: if not internal:
if TicketPolicy.is_customer(user): if TicketPolicy.is_customer(user):
+5 -1
View File
@@ -66,6 +66,9 @@ def share_staff_ticket(ticket, actor, target, level, note=""):
@transaction.atomic @transaction.atomic
def share_staff_message(message, actor, target, note=""): def share_staff_message(message, actor, target, note=""):
from .dispatch_responses import can_read_response
if not can_read_response(actor, message):
raise PermissionDenied("پاسخ تا بازگشایی قابل اشتراک نیست.")
ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id) ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id)
require_share_authority(actor, ticket) require_share_authority(actor, ticket)
validate_recipient(actor, target) validate_recipient(actor, target)
@@ -147,11 +150,12 @@ def staff_message_context(ticket, actor, conversation=()):
def sharing_context(ticket, actor): def sharing_context(ticket, actor):
from .dispatch_responses import sealed_message_q
if not TicketPolicy.can_share(actor, ticket): if not TicketPolicy.can_share(actor, ticket):
return {} return {}
return { return {
"grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"), "grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"),
"message_shares": MessageShare.objects.filter(message__ticket=ticket).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"), "message_shares": MessageShare.objects.filter(message__ticket=ticket).exclude(sealed_message_q("message__")).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"),
"sharing_events": sharing_events(ticket), "sharing_events": sharing_events(ticket),
} }
+27 -18
View File
@@ -1,25 +1,30 @@
"""Routes available only to staff, including isolated message attachment delivery.""" """Routes available only to staff, including isolated message attachment delivery."""
import mimetypes
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 JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from apps.attachments.delivery import signed_media_redirect from apps.attachments.delivery import attachment_response
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):
from .dispatch_responses import can_read_response
grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id) grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id)
profile = getattr(request.user, "staff_profile", None) profile = getattr(request.user, "staff_profile", None)
if not can_read_response(request.user, grant.message):
raise PermissionDenied
if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at
or not request.user.is_active or not profile or not profile.is_active): or not request.user.is_active or not profile or not profile.is_active):
raise PermissionDenied raise PermissionDenied
@@ -66,18 +71,27 @@ 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) from .dispatch_responses import can_record_ticket_read
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 can_record_ticket_read(ticket, request.user):
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
@@ -93,9 +107,4 @@ def unread_count(request):
def shared_attachment(request, share_id, attachment_id, download=False): def shared_attachment(request, share_id, attachment_id, download=False):
grant = received_message_share(request, share_id) grant = received_message_share(request, share_id)
attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id) attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id)
if settings.USE_NGINX_MEDIA: return attachment_response(attachment, download=download)
return signed_media_redirect(attachment.relative_path, download=download)
content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response = FileResponse(attachment.file.open("rb"), content_type=content_type, as_attachment=download, filename=attachment.original_name)
response["X-Content-Type-Options"] = "nosniff"
return response
+38
View File
@@ -0,0 +1,38 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_GET
from apps.accounts.models import User
from .models import StaffTicketShortLink
from .policies import TicketPolicy
def append_supervisor_ticket_link(ticket, message):
unit = ticket.owning_unit
if not unit or not unit.supervisor_sms_ticket_link_enabled:
return message
link, _ = StaffTicketShortLink.objects.get_or_create(ticket=ticket)
if link.expires_at <= timezone.now():
return message
url = settings.PUBLIC_BASE_URL.rstrip("/") + reverse("staff_ticket_short_link", args=[link.token])
return f"{message}\nمشاهده و پاسخ: {url}"
@never_cache
@require_GET
@login_required(login_url="/staff/login/")
def open_staff_ticket_link(request, token):
if not request.user.is_active or request.user.account_type != User.AccountType.STAFF:
raise PermissionDenied
link = get_object_or_404(
StaffTicketShortLink.objects.select_related("ticket"),
token=token, expires_at__gt=timezone.now(),
)
if not TicketPolicy.can_view(request.user, link.ticket):
raise PermissionDenied
return redirect("staff_tickets:detail", public_id=link.ticket.public_id)
+4 -1
View File
@@ -12,7 +12,9 @@ from .sharing import staff_message_context
def received_messages(user, ticket=None): def received_messages(user, ticket=None):
from .dispatch_responses import sealed_message_q
grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True) grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True)
grants = grants.exclude(sealed_message_q("message__"))
return grants.filter(message__ticket=ticket) if ticket is not None else grants return grants.filter(message__ticket=ticket) if ticket is not None else grants
@@ -28,7 +30,8 @@ def visible_messages(ticket, user):
profile = getattr(user, "staff_profile", None) profile = getattr(user, "staff_profile", None)
if not user.is_active or (profile is not None and not profile.is_active): if not user.is_active or (profile is not None and not profile.is_active):
raise PermissionDenied raise PermissionDenied
query = ticket.ticket_messages.all() from .dispatch_responses import filter_response_messages
query = filter_response_messages(ticket.ticket_messages.all(), user)
if TicketPolicy.can_view(user, ticket): if TicketPolicy.can_view(user, ticket):
return query return query
grants = received_messages(user, ticket) grants = received_messages(user, ticket)
+2 -1
View File
@@ -1,9 +1,10 @@
from django.urls import path from django.urls import path
from . import views, sharing_views from . import views, sharing_views, dispatch_response_views
app_name="staff_tickets" app_name="staff_tickets"
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")] urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contacts/<uuid:public_id>/call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
urlpatterns += [ urlpatterns += [
path("dispatches/<uuid:public_id>/open-responses/", dispatch_response_views.open_responses, name="dispatch_open_responses"),
path("shares/count/", sharing_views.unread_count, name="shares_count"), path("shares/count/", sharing_views.unread_count, name="shares_count"),
path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"), path("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"),
path("shares/", sharing_views.inbox, name="shares"), path("shares/", sharing_views.inbox, name="shares"),
+58 -53
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,13 +32,13 @@ 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
from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket from .services import change_priority,change_status,claim_ticket,create_ticket,create_group_dispatch,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket
MESSAGE_PAGE_SIZE=50 MESSAGE_PAGE_SIZE=50
SMS_RETRYABLE_DELIVERY_STATUSES=( SMS_RETRYABLE_DELIVERY_STATUSES=(
NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.UNDELIVERED,
@@ -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,21 @@ 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'))
from .dispatch_responses import response_control
control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None
revision=hashlib.sha256(repr((edits, control.opened_at if control else None)).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() from .dispatch_responses import filter_response_messages
qs=filter_response_messages(ticket.ticket_messages.all(), actor)
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 +143,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 +155,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 +334,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,13 +459,9 @@ 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): history=Ticket.objects.filter(TicketPolicy.viewable_q(request.user), customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at").distinct()[:5]
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")) 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"))
can_retry_sms=TicketPolicy.can_reply(request.user,ticket) can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
@@ -467,12 +472,13 @@ def staff_detail(request,public_id):
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job) sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
latest=conversation[-1] if conversation else None latest=conversation[-1] if conversation else None
group_dispatch = ticket.group_dispatch group_dispatch = ticket.group_dispatch
message_sharing["can_view_dispatch"] = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch))
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
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 +520,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):
@@ -612,10 +617,19 @@ def staff_delete(request, public_id):
@staff_required @staff_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def staff_dispatch_create(request): def staff_dispatch_create(request):
from .dispatch_presentation import dispatch_form_options
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
base_dispatches = _visible_dispatches(request.user)
if not create_unit_ids and (request.method == "POST" or not (DispatchPolicy.can_list(request.user) or base_dispatches.exists())):
raise PermissionDenied
form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user) form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
try: try:
dispatch, _ = dispatch_group_tickets( dispatch, _ = create_group_dispatch(
user=request.user, user=request.user,
unit=form.cleaned_data["unit"], unit=form.cleaned_data["unit"],
contact_group=form.cleaned_data["contact_group"], contact_group=form.cleaned_data["contact_group"],
@@ -623,6 +637,8 @@ def staff_dispatch_create(request):
subject=form.cleaned_data["subject"], subject=form.cleaned_data["subject"],
body=form.cleaned_data["body"], body=form.cleaned_data["body"],
operation_type=form.cleaned_data["operation_type"], operation_type=form.cleaned_data["operation_type"],
response_mode=form.cleaned_data["response_mode"],
notify_opening_sms=form.cleaned_data["notify_opening_sms"],
tags=form.cleaned_data["tags"], tags=form.cleaned_data["tags"],
files=request.FILES.getlist("files"), files=request.FILES.getlist("files"),
) )
@@ -630,13 +646,6 @@ def staff_dispatch_create(request):
return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id) return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id)
except (PermissionDenied, ValidationError) as exc: except (PermissionDenied, ValidationError) as exc:
form.add_error(None, str(exc)) form.add_error(None, str(exc))
create_unit_ids = {
unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True)
if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values)
}
if not create_unit_ids:
raise PermissionDenied
base_dispatches = _visible_dispatches(request.user)
metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count")) metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count"))
metrics = { metrics = {
"total": metrics_row["total"] or 0, "total": metrics_row["total"] or 0,
@@ -671,19 +680,27 @@ def staff_dispatch_create(request):
return render(request, "tickets/staff_dispatch_create.html", { return render(request, "tickets/staff_dispatch_create.html", {
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics, "form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
"units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices, "units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices,
"dispatch_options": dispatch_form_options(request.user, units, groups),
"can_create_dispatch": bool(create_unit_ids),
"history_units": OrganizationalUnit.objects.filter(pk__in=base_dispatches.values("unit_id")),
"history_groups": ContactGroup.objects.filter(pk__in=base_dispatches.values("contact_group_id")),
"active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی", "active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی",
}) })
@staff_required @staff_required
def staff_dispatch_detail(request, public_id): def staff_dispatch_detail(request, public_id):
from .dispatch_responses import response_control, can_open_responses, populate_report_responses
dispatch = get_object_or_404( dispatch = get_object_or_404(
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"), GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
public_id=public_id, public_id=public_id,
) )
if not DispatchPolicy.can_view(request.user, dispatch): if not DispatchPolicy.can_view(request.user, dispatch):
raise PermissionDenied raise PermissionDenied
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id")) control = response_control(dispatch)
locked = bool(control and control.mode == "SEALED" and not control.opened_at)
tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "notificationjob_set").order_by("customer_id"))
populate_report_responses(dispatch, tickets, locked)
delivery_failures = { delivery_failures = {
NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.UNDELIVERED,
NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.EXPIRED,
@@ -695,7 +712,6 @@ def staff_dispatch_detail(request, public_id):
for ticket in tickets: for ticket in tickets:
ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None) ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None)
ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at))) ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at)))
ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None)
links = list(ticket.magic_links.all()) links = list(ticket.magic_links.all())
ticket.magic_total_views = sum(link.view_count for link in links) ticket.magic_total_views = sum(link.view_count for link in links)
ticket.magic_link_count = len(links) ticket.magic_link_count = len(links)
@@ -703,17 +719,17 @@ def staff_dispatch_detail(request, public_id):
ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None) ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None)
job = ticket.sms_job job = ticket.sms_job
if not job: if not job:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب سابقه ارسال پیامک ثبت نشده است.", "neutral"
elif job.status == NotificationJob.Status.FAILED: elif job.status == NotificationJob.Status.FAILED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", "ارسال خودکار متوقف شده است؛ علت خطا و امکان تلاش مجدد را در ادامه بررسی کنید.", "danger"
elif job.status == NotificationJob.Status.RETRYING: elif job.status == NotificationJob.Status.RETRYING:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "سامانه پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning"
elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING): elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING):
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED: elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success"
elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at: elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست؛ پیگیری خودکار تحویل ادامه دارد.", "warning"
elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED: elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED:
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger" ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED: elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED:
@@ -731,14 +747,15 @@ def staff_dispatch_detail(request, public_id):
tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count() tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count()
opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count() opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count()
total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0 total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0
replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count() replied = sum(bool(ticket.response_count) for ticket in tickets)
refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"]) refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"])
pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING)) pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING))
delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0 delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0
metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent} metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent}
open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets) open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets)
can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket") can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket")
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"}) opening_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, event_type="DISPATCH_RESPONSES_OPENED").values("status").annotate(count=Count("pk"))
return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches", "response_control": control, "responses_locked": locked, "can_open_responses": can_open_responses(request.user, dispatch), "opening_jobs": list(opening_jobs)})
@staff_required @staff_required
@@ -781,19 +798,7 @@ def _unit_ids_with_permission(user, permission):
def _visible_dispatches(user): def _visible_dispatches(user):
if user.is_superuser: return DispatchPolicy.visible_dispatches(user)
return GroupDispatch.objects.all()
visibility = Q(pk__in=[])
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
for operation_type in GroupDispatch.OperationType.values:
if DispatchPolicy.can_view_all(user, operation_type):
visibility |= Q(operation_type=operation_type)
continue
view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)]
create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)]
visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units)
visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user)
return GroupDispatch.objects.filter(visibility)
CONTACT_AUDIT_FIELDS = { CONTACT_AUDIT_FIELDS = {
+2 -1
View File
@@ -29,7 +29,8 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "apps.accounts.customer_access.CustomerLoginAccessMiddleware",
"apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
+5 -1
View File
@@ -9,6 +9,8 @@ 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
from apps.tickets.short_links import open_staff_ticket_link
def healthz(request): def healthz(request):
@@ -48,6 +50,8 @@ def web_manifest(request):
return response return response
urlpatterns = [ urlpatterns = [
path("vt/<str:token>", open_staff_ticket_link, name="staff_ticket_short_link"),
path("p/<str:token>/", payment_link_views.open_link, name="payment_short_link"),
path("healthz/", healthz, name="healthz"), path("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"),
@@ -65,7 +69,7 @@ urlpatterns = [
def debug_media(request, path): def debug_media(request, path):
import posixpath import posixpath
if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith("payments/"): if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith(("payments/", "dispatch-responses/")):
from django.http import Http404 from django.http import Http404
raise Http404 raise Http404
return serve(request, path, document_root=settings.MEDIA_ROOT) return serve(request, path, document_root=settings.MEDIA_ROOT)
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

@@ -0,0 +1,71 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>پیش‌نمایش اقدام ارسال فیش · چهارسو</title>
<style>
@font-face{font-family:Vazirmatn;src:url("../../static/fonts/vazirmatn-arabic-wght-normal.woff2?v=receipt-preview-1") format("woff2");font-weight:100 900;font-display:swap}
*{box-sizing:border-box}body{margin:0;background:#eee;color:#08376b;font-family:Vazirmatn,sans-serif;font-size:14px;line-height:1.8}
button{font:inherit;cursor:pointer}a{color:inherit}svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.preview-note{padding:9px 16px;background:#fff3da;color:#805300;text-align:center;font-size:12px}
.header{background:white;border-bottom:1px solid #dce5ed}.header-inner{max-width:1000px;margin:auto;padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
.brand{display:flex;align-items:center;gap:16px}.brand img{width:85px;height:auto}.brand strong{display:block;font-size:20px}.brand small{color:#526c86}
.main{max-width:900px;padding:28px 24px 48px;margin:auto}.breadcrumb{font-size:12px;color:#526c86;margin-bottom:20px}
.title{display:flex;align-items:center;gap:16px;margin-bottom:20px}.page-icon{display:grid;place-items:center;width:56px;height:56px;border-radius:18px;background:#e5f4ff;border:1px solid #cde6f7}.page-icon svg{width:28px;height:28px}h1{font-size:28px;line-height:1.5;margin:0;font-weight:900}.title p{margin:5px 0 0;color:#526c86;font-size:14px}
.notice{background:white;border:1px solid #dce5ed;border-radius:23px;overflow:hidden}
.notice-head{padding:20px 22px;border-bottom:1px solid #e7edf2;display:flex;justify-content:space-between;gap:12px}.notice-head strong{font-size:16px}.notice-head small{display:block;color:#526c86;font-size:12px;margin-top:4px}.amount{font-size:20px;font-weight:900;text-align:left}.amount span{font-size:12px;font-weight:400}.state{font-size:12px;color:#526c86;text-align:left}
.details{padding:20px 22px}.bank{display:flex;justify-content:space-between;gap:8px;margin-bottom:16px}.bank span{color:#526c86}
.values{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0}.value{border:1px solid #dce5ed;background:#fafcfe;border-radius:12px;padding:12px 14px;display:grid;grid-template-columns:1fr auto;align-items:center;gap:4px 12px}.value dt{font-size:12px;color:#526c86}.value dd{margin:0;grid-column:1/-1;direction:ltr;text-align:right;font-size:16px;overflow-wrap:anywhere}.copy{min-height:44px;border:1px solid #dce5ed;background:white;border-radius:10px;color:#08376b;display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px}.copy svg{width:16px;height:16px}
.warning{padding:12px 14px;border-right:3px solid #fa9f00;background:#fff8eb;border-radius:10px;margin:16px 0 12px;line-height:1.9}
.copy-all{background:transparent;border:0;color:#08376b;display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0;text-decoration:underline;text-underline-offset:4px}
.next-step{display:flex;align-items:center;justify-content:space-between;gap:24px;background:#f0f8fd;border:1px solid #cde6f7;border-radius:16px;padding:20px;margin-top:20px}
.next-step h2{font-size:18px;font-weight:900;margin:0 0 4px}.next-step p{font-size:14px;color:#526c86;margin:0;max-width:390px}
.send{display:flex;align-items:center;justify-content:center;gap:10px;min-height:52px;padding:12px 20px;border:1px solid #08376b;border-radius:12px;background:#08376b;color:white;font-size:16px;font-weight:800;white-space:nowrap;transition:background .15s,box-shadow .15s}.send:hover{background:#0b4c8c;box-shadow:0 3px 10px #08376b1a}.send:active{background:#08376b}
button:focus-visible{outline:3px solid #1977ce;outline-offset:3px}
.extra{margin-top:24px;padding-top:20px;border-top:1px solid #e7edf2}.extra h3{font-size:14px;font-weight:800;margin:0 0 8px}.extra p{margin:4px 0;font-size:14px}.extra .muted{color:#526c86}
.bottom{margin-top:20px;padding-top:16px;border-top:1px solid #e7edf2;display:flex;justify-content:space-between;align-items:center;gap:12px;color:#526c86}.bottom button{color:#08376b;background:transparent;border:0;min-height:44px;padding:0;display:flex;align-items:center;gap:8px;text-decoration:underline;text-underline-offset:4px}
.feedback{margin-top:20px;background:white;border:1px solid #dce5ed;border-radius:12px;padding:12px 16px;color:#526c86}.feedback:empty{display:none}
.footer{text-align:center;font-size:12px;color:#526c86;margin-top:24px}
@media(max-width:639px){.header-inner{padding:12px 16px}.brand{gap:10px}.brand img{width:65px}.header-label{display:none}.main{padding:20px 12px 32px}.title{gap:12px;align-items:flex-start}h1{font-size:24px}.page-icon{width:48px;height:48px;flex-shrink:0}.notice-head{padding:16px;flex-direction:column;gap:8px}.amount,.state{text-align:right}.details{padding:16px}.bank{flex-direction:column;gap:0}.values{grid-template-columns:1fr}.next-step{flex-direction:column;align-items:stretch;gap:16px;padding:18px 16px}.send{width:100%}.bottom{flex-direction:column;align-items:flex-start;gap:0}.extra{margin-top:20px}}
@media(prefers-reduced-motion:reduce){.send{transition:none}}
</style>
</head>
<body>
<div class="preview-note">پیش‌نمایش پیشنهادی · تمام داده‌ها ساختگی‌اند · پرداخت یا ارسال واقعی انجام نمی‌شود</div>
<svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
<symbol id="receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
</svg>
<header class="header"><div class="header-inner"><div class="brand"><img src="../../static/images/company-logo.png?v=receipt-preview-1" alt="لوله گستر گلپایگان"><div><strong>چهارسو</strong><small>مرکز خدمات مشتریان</small></div></div><span class="header-label">خدمات مشتریان</span></div></header>
<main class="main">
<nav class="breadcrumb" aria-label="مسیر صفحه">شماره حساب‌ها / جزئیات واریز</nav>
<div class="title"><span class="page-icon"><svg aria-hidden="true"><use href="#bank"/></svg></span><div><h1>جزئیات واریز</h1><p>اطلاعات حساب را بردارید؛ پس از واریز، فیش را از همین صفحه ارسال کنید.</p></div></div>
<article class="notice">
<header class="notice-head"><div><strong>واریز بابت سفارش لوله و اتصالات</strong><small>اعلامیهٔ نمونه ۱۰۴۲ · ۲۰ مهر ۱۴۰۵</small></div><div><div class="amount">۲۵۰٬۰۰۰٬۰۰۰ <span>ریال</span></div><div class="state">هنوز فیشی ارسال نشده است</div></div></header>
<div class="details">
<div class="bank"><strong>حساب نمونهٔ شرکت · بانک نمونه</strong><span>صاحب حساب: شرکت نمونه</span></div>
<dl class="values">
<div class="value"><dt>مبلغ (ریال)</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>۲۵۰٬۰۰۰٬۰۰۰</dd></div>
<div class="value"><dt>شماره حساب · ساختگی</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>0000 0000 0000</dd></div>
<div class="value"><dt>شبا · ساختگی</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>IR00 0000 0000 0000 0000 0000 00</dd></div>
<div class="value"><dt>شناسه واریز · ضروری</dt><button class="copy" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی</button><dd>000000000</dd></div>
</dl>
<p class="warning">هنگام واریز، حتماً شناسهٔ واریز را در فرم بانکی وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p>
<button class="copy-all" type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی کامل اطلاعات واریز</button>
<section class="next-step" aria-labelledby="next-step-title"><div><h2 id="next-step-title">پس از واریز</h2><p>تصویر فیش را برای بررسی این واریز ارسال کنید.</p></div><button class="send" type="button" id="send-preview"><svg aria-hidden="true"><use href="#receipt"/></svg>ارسال فیش این واریز</button></section>
<section class="extra"><h3>اطلاعات تکمیلی و توضیحات</h3><p>فاکتور: نمونه ۱۲۳۴</p><p class="muted">این توضیح نمونه برای نمایش جایگاه اطلاعات تکمیلی پس از بخش ارسال فیش است.</p></section>
<div class="bottom"><span>تأییدشده: ۰ ریال · باقی‌مانده: ۲۵۰٬۰۰۰٬۰۰۰ ریال</span><button type="button"><svg aria-hidden="true"><use href="#copy"/></svg>کپی لینک این صفحه</button></div>
</div>
</article>
<p class="feedback" id="feedback" role="status" aria-live="polite"></p>
<p class="footer">پیش‌نمایش مستقل برای بررسی چیدمان · کد اصلی برنامه تغییر نکرده است</p>
</main>
<script>
document.querySelectorAll('button').forEach(button=>button.addEventListener('click',()=>{
document.getElementById('feedback').textContent=button.id==='send-preview'?'در نسخهٔ اصلی، این دکمه فرم ارسال فیش مرتبط با همین اعلامیه را باز می‌کند. این پیش‌نمایش ارسال واقعی ندارد.':'این کنترل در پیش‌نمایش فقط نمایشی است و اطلاعاتی کپی نمی‌کند.';
}));
</script>
</body>
</html>
+5
View File
@@ -27,6 +27,11 @@ server {
return 404; return 404;
} }
# These files require an authenticated application check on every request.
location ^~ /protected-media/dispatch-responses/ {
return 404;
}
location /protected-media/ { location /protected-media/ {
alias /app/media/; alias /app/media/;
secure_link $arg_md5,$arg_expires; secure_link $arg_md5,$arg_expires;
+11 -6
View File
@@ -18,11 +18,12 @@
- اعلامیه، فیش و تصمیم تأیید مستقل‌اند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمی‌کند. - اعلامیه، فیش و تصمیم تأیید مستقل‌اند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمی‌کند.
- مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام می‌شود و مقدار ریال به سرور می‌رود. - مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام می‌شود و مقدار ریال به سرور می‌رود.
- نمایش تاریخ شمسی و انتخاب سه‌قسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره می‌کند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد می‌شود. - نمایش تاریخ شمسی و انتخاب سه‌قسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره می‌کند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد می‌شود.
- شناسه‌ها، شماره‌ها و شبا رشته‌اند؛ صفر ابتدایی حفظ می‌شود. شبا و کارت موجود اعتبارسنجی می‌شوند. - شماره حساب، کارت و شبا رشته‌اند و صفر ابتدایی آن‌ها حفظ می‌شود. شناسه واریز رشته است اما صفرهای ابتدایی آن پس از محاسبه رقم کنترل حذف می‌شوند؛ تولید، ذخیره و نمایش همین مقدار را استفاده می‌کنند. migration `0006_normalize_deposit_identifiers` شناسه اعلامیه‌ها و نسخه‌های فیش قبلی را هماهنگ می‌کند و snapshot بانکی یا ممیزی تاریخی را تغییر نمی‌دهد. شبا و کارت موجود اعتبارسنجی می‌شوند.
- حساب شناسه‌دار در صدور اعلامیه الزاماً شناسه می‌خواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد. - حساب شناسه‌دار در صدور اعلامیه الزاماً شناسه می‌خواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد.
- حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف می‌کند و سوابق را پنهان نمی‌کند. - حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف می‌کند و سوابق را پنهان نمی‌کند.
- snapshot حساب و شناسه اعلامیه حفظ می‌شود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمی‌دهد. - snapshot حساب و شناسه اعلامیه حفظ می‌شود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمی‌دهد.
- فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمی‌کند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد می‌شود. - فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمی‌کند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد می‌شود.
- فرم مشترک فیش مشتری، کارشناس، دسترسی لینک پرداخت و اصلاح فیش، تصویر را با XMLHttpRequest ارسال می‌کند: درصد و حجم واقعی درخواست، زمان سپری‌شده و تخمین زمان باقی‌مانده نمایش داده می‌شوند. پس از پایان انتقال، مرحله بررسی و ثبت سرور جداگانه نمایش داده می‌شود. دکمه ارسال و فیلدها تا پاسخ سرور قفل‌اند؛ در خطا، تصویر و nonce حفظ می‌شوند و امکان تلاش مجدد وجود دارد. درخواست دارای `Accept: application/json` مقصد ادامه را در `redirect_url` یا خطاهای فرم را با وضعیت ۴۲۲ دریافت می‌کند؛ ارسال معمولی بدون JavaScript همچنان از redirect استفاده می‌کند.
- وضعیت اعلامیه از مبلغ نسخه جاری فیش‌های تأییدشده بدون مغایرت مقصد محاسبه می‌شود؛ پرداخت جزئی و باقی‌مانده نمایش داده می‌شوند. - وضعیت اعلامیه از مبلغ نسخه جاری فیش‌های تأییدشده بدون مغایرت مقصد محاسبه می‌شود؛ پرداخت جزئی و باقی‌مانده نمایش داده می‌شوند.
## تنظیمات ضروری پیش از استفاده مالی واقعی ## تنظیمات ضروری پیش از استفاده مالی واقعی
@@ -37,19 +38,17 @@
۳. در مدیریت Django `/x5admin/`، بخش `PaymentApprovalPolicy`، یک سیاست عمومی یا سیاست واحد بسازید. همه افراد فهرست `reviewers` باید رأی بدهند؛ یک نفر یعنی تأیید تک‌نفره، چند نفر یعنی شرط تأیید همه اشخاص تعیین‌شده. افراد `seniors` می‌توانند با دلیل، تأیید نهایی ارشد انجام دهند. ۳. در مدیریت Django `/x5admin/`، بخش `PaymentApprovalPolicy`، یک سیاست عمومی یا سیاست واحد بسازید. همه افراد فهرست `reviewers` باید رأی بدهند؛ یک نفر یعنی تأیید تک‌نفره، چند نفر یعنی شرط تأیید همه اشخاص تعیین‌شده. افراد `seniors` می‌توانند با دلیل، تأیید نهایی ارشد انجام دهند.
۴. **انتخاب فرد در سیاست به‌تنهایی کافی نیست.** مجوزهای زیر را به افراد مجاز بدهید؛ مدیر مالی یا نقش FINANCE خودکار تأییدکننده نمی‌شود: ۴. **انتخاب کارشناس فعال در سیاست، اختیار تأیید یا رد را برای چرخه‌های آن سیاست می‌دهد.** افراد `reviewers` رأی الزامی و افراد `seniors` اختیار تأیید نهایی مستقل با دلیل دارند؛ مجوز جداگانه Django برای رأی لازم نیست. مدیر مالی یا نقش FINANCE بدون انتخاب در سیاست، خودکار تأییدکننده نمی‌شود. مجوزهای مدیریتی و مشاهده همچنان مستقل‌اند:
| مجوز | کاربرد | | مجوز | کاربرد |
| --- | --- | | --- | --- |
| `payments.approve_paymentreceipt` | رأی عادی فردِ تعیین‌شده در سیاست |
| `payments.senior_approve_paymentreceipt` | تأیید ارشد فردِ تعیین‌شده در seniors |
| `payments.manage_payment_approval_policy` | مدیریت سیاست‌ها و تخصیص سیاست فعال به چرخه خالی | | `payments.manage_payment_approval_policy` | مدیریت سیاست‌ها و تخصیص سیاست فعال به چرخه خالی |
| `payments.manage_company_bankaccounts` | مدیریت حساب‌ها | | `payments.manage_company_bankaccounts` | مدیریت حساب‌ها |
| `payments.manage_payment_access` | تنظیم باز یا محدود بودن مشاهده و ایجاد | | `payments.manage_payment_access` | تنظیم باز یا محدود بودن مشاهده و ایجاد |
| `payments.view_all_payments` | مشاهده مالی در همه واحدها | | `payments.view_all_payments` | مشاهده مالی در همه واحدها |
| `payments.view_payment_audit` | دسترسی ممیزی مالی در مدیریت | | `payments.view_payment_audit` | دسترسی ممیزی مالی در مدیریت |
مجوزهای تأیید می‌توانند سراسری، در عضویت فعال واحد یا در مجوزهای واحد فعال باشند. مشاهده همان فیش و تخصیص همان چرخه نیز لازم است. ثبت‌کننده کارشناس، فیشی را که خودش ثبت کرده تأیید نمی‌کند. مدیریت Django علاوه بر مجوز مالی، شرایط ورود استاندارد پنل admin را لازم دارد. مشاهده همان فیش و تخصیص نقش عادی یا ارشد در همان چرخه لازم است. مجوزهای قدیمی `approve_paymentreceipt` و `senior_approve_paymentreceipt` به‌تنهایی اختیار رأی نمی‌دهند. ثبت‌کننده کارشناس، فیشی را که خودش ثبت کرده تأیید نمی‌کند. غیرفعال‌کردن حساب یا پروفایل کارشناس مانع رأی جدید او می‌شود. افراد هر چرخه هنگام ارسال فیش ذخیره می‌شوند؛ تغییر سیاست، افراد چرخه‌های قبلی را بازنویسی نمی‌کند. مدیریت Django شرایط ورود استاندارد پنل admin را لازم دارد.
۵. نبود سیاست فعال باعث **ماندن فیش در انتظار بررسی** می‌شود. تأیید خودکار یا تعیین فرضی مدیرعامل انجام نشده است. پس از تعریف سیاست، مدیر مجاز از جزئیات فیش، «اعمال سیاست فعال تأیید» را برای چرخه خالی بزند. سیاست چرخه دارای تخصیص/رأی بازنویسی نمی‌شود. ۵. نبود سیاست فعال باعث **ماندن فیش در انتظار بررسی** می‌شود. تأیید خودکار یا تعیین فرضی مدیرعامل انجام نشده است. پس از تعریف سیاست، مدیر مجاز از جزئیات فیش، «اعمال سیاست فعال تأیید» را برای چرخه خالی بزند. سیاست چرخه دارای تخصیص/رأی بازنویسی نمی‌شود.
@@ -77,7 +76,13 @@
`recipient_name`, `company_name`, `notice_number`, `receipt_number`, `amount_rial`, `invoice_number`, `payment_link` `recipient_name`, `company_name`, `notice_number`, `receipt_number`, `amount_rial`, `invoice_number`, `payment_link`
صف فعلی `NotificationJob` و worker `process_notifications` به کار می‌روند. اعلامیه به معنی صف‌شدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آماده‌سازی snapshot می‌شود؛ retry همان متن را حفظ می‌کند. تلاش ناموفقِ آماده‌سازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک ورود کامل مشتری را لازم دارد. صف فعلی `NotificationJob` و worker `process_notifications` به کار می‌روند. اعلامیه به معنی صف‌شدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آماده‌سازی snapshot می‌شود؛ retry همان متن را حفظ می‌کند. تلاش ناموفقِ آماده‌سازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک لینک موقت مستقل از ورود کامل مشتری است.
در جزئیات اعلامیه کارشناسان، «ارسال مجدد پیامک اعلامیه واریز» از مسیر POST `notices/<uuid>/resend-sms/` یک job و `PaymentNotification` مستقل می‌سازد؛ ارسال‌های قبلی و نتیجه دریافت آن‌ها بازنویسی نمی‌شوند. کلید UUID فرم درخواست تکراری را یکسان می‌کند و قفل مشتری سپس اعلامیه، درخواست‌های همزمان را هماهنگ می‌کند. تا پایان job در صف/پردازش/تلاش خودکار، درخواست جدید ثبت نمی‌شود. مجوز ایجاد در دامنه واحد و دسترسی به اعلامیه، فعال بودن مشتری و لغو/حذف نشدن اعلامیه بررسی می‌شوند؛ رویداد `NOTICE_SMS_RESEND_REQUESTED` ممیزی می‌شود. worker همان handler و قالب‌های فعلی را استفاده می‌کند و برای هر ارسال مجدد لینک پرداخت تازه می‌سازد.
سوابق، زمان درخواست و ارسال، درخواست‌کننده، متن آماده‌شده، تمام تلاش‌های ارسال با خطا و شناسه و کد provider، وضعیت دریافت، شرح/کد و زمان آخرین استعلام و استعلام بعدی را نمایش می‌دهند. `SENT` فقط پذیرش سرویس است؛ رسیدن به گوشی نیاز به نتیجه قطعی دلیوری دارد. پاسخ‌های غیرقطعی یا پایان پایش بدون تأیید دریافت، صریحاً غیرقطعی نمایش داده می‌شوند. سوابق با تازه‌سازی صفحه به‌روز می‌شوند.
میز پرداخت کارشناسان به‌طور پیش‌فرض فیش‌های «نیازمند اقدام من» را نمایش می‌دهد؛ `?tab=receipts` همه فیش‌های مجاز و `?tab=notices` اعلامیه‌ها را انتخاب می‌کند. صفحه‌بندی ۲۵تایی با شمارنده صفحه و محدوده نتایج، تب و فیلترها را نگه می‌دارد.
در آزمون‌ها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است. در آزمون‌ها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است.
+57
View File
@@ -0,0 +1,57 @@
# مسدودی ورود مشتریان و مخاطبان
در Django Admin، پروفایل مشتری / مخاطب را با شماره موبایل جست‌وجو کنید.
گزینهٔ «مسدود کردن ورود مشتری / مخاطب» را فعال کنید، «دلیل مسدودی ورود» را
بنویسید و ذخیره کنید. ثبت مسدودی بدون دلیل در ادمین پذیرفته نمی‌شود.
برای رفع مسدودی، همان گزینه را غیرفعال کنید و ذخیره کنید.
این تنظیم ورود با موبایل و OTP در `/login/` و ورود با رمز عبور در
`/login/password/` را کنترل می‌کند. دلیل با متن قرمز، فونت برنامه و کادر
دارای `role="alert"` نمایش داده می‌شود. متن دلیل HTML اجرا نمی‌کند.
تنظیمات مسدودی در فرم مشتری و فرم مدیریت مخاطبان کارشناسان قابل تغییر نیست.
هر بار مسدود کردن، نسخهٔ دسترسی پروفایل را افزایش می‌دهد. نشست‌های ورود
قدیمی در نخستین درخواست بعدی خارج می‌شوند و حتی پس از رفع مسدودی قابل
استفاده نیستند؛ ورود مجدد لازم است. نشست‌های قبل از استقرار این قابلیت
نسخهٔ صفر دارند و تا زمانی که حساب مسدود نشود معتبر می‌مانند.
صفحات مشتری هر ۱۰ ثانیه وضعیت نشست را از `/login/access/` بررسی می‌کنند.
در مرورگر باز و متصل، مسدودی بدون کلیک کاربر نیز باعث انتقال به صفحهٔ
ورود و نمایش دلیل می‌شود. هنگام بازگشت به تب، باز شدن مجدد صفحه یا اتصال
دوبارهٔ شبکه هم بررسی انجام می‌شود. کنترل سمت سرور به جاوااسکریپت وابسته نیست.
حساب‌های `STAFF`، کاربران دارای `is_staff` و ابرکاربران از این محدودیت
مستثنا هستند. لینک‌های امن با دسترسی محدود به تیکت یا پرداخت، نشست ورود
کامل مشتری ایجاد نمی‌کنند و قواعد مستقل قبلی خود را حفظ می‌کنند.
این قابلیت مقدار `is_active` حساب را تغییر نمی‌دهد.
Migration مورد نیاز: `customers.0007_customerprofile_login_access_version_and_more`.
برای استقرار با پایتون محیط پروژه، `manage.py migrate` را اجرا کنید و سرویس
Django را دوباره راه‌اندازی کنید.
تست‌ها در `tests/test_customer_login_access.py` و
`tests/e2e/test_customer_login_access_e2e.py` قرار دارند. تست مرورگر از
`live_server` و Chromium مدیریت‌شدهٔ پروژه استفاده می‌کند.
## نتیجهٔ بررسی
- مجموعهٔ گستردهٔ ورود، نقش‌ها، مخاطبان، پرداخت‌ها، لینک‌های امن و انتقال
داده: ۲۶۰ تست موفق.
- آخرین اجرای پس از اصلاح ترتیب middlewareها: ۸۵ تست موفق، شامل
`test_customer_login_access.py`، `test_views.py`، `test_role_workflow_regression.py`،
تست‌های مرورگر مسدودی و `test_pwa_versioning.py`.
- کنترل‌های Django، نبود migration جاافتاده، بررسی نحو Python و JavaScript
و `git diff --check` برای فایل‌های این قابلیت موفق بودند.
- صفحهٔ ورود، CSS قرمز نسخه‌دار و endpoint بدون کش نشست از سرور توسعه
روی `0.0.0.0:8000` با پاسخ HTTP 200 بررسی شدند.
Graphify برای ارتباط مفهومی پروفایل و دسترسی‌ها و Ripwire برای بررسی
نمادها، اثر تغییر، محل استفاده و گیت‌های پس از تغییر اجرا شدند.
`test-gate` فهرست تست‌های لازم را ارائه می‌کند و خود تست‌ها را اجرا نمی‌کند؛
تست‌های معرفی‌شده جداگانه اجرا و موفق شدند. `quality-delta` سبز نشد:
هشدارهایی دربارهٔ callbackهای Django، تست‌های کشف‌شده توسط pytest و شباهت
ساختاری توابع بازیابی رکورد باقی مانده است. برای نمونه، بازیابی
`CustomerProfile` با بازیابی `DispatchResponseControl` شبیه تشخیص داده شد؛
این دو به مدل‌ها و قواعد مستقل مربوط‌اند و ادغام آن‌ها مناسب نیست.
این هشدارهای تحلیل ایستا به معنی شکست تست‌های اجرایی نیستند و به‌عنوان
نتیجهٔ سبز گیت کیفیت گزارش نشده‌اند.
+69
View File
@@ -0,0 +1,69 @@
# بازگشایی پاسخ‌های ارسال گروهی
ارسال‌های جدید پیش‌فرض بازگشایی یکجا دارند. ارسال‌های قدیمی بدون رکورد
`DispatchResponseControl` رفتار قبلی را حفظ می‌کنند و پیامک بازگشایی ندارند.
در حالت یکجا، کارکنان و superuser تا بازگشایی متن، پیوست و تاریخچهٔ پاسخ
مخاطب را نمی‌بینند. مخاطب به پاسخ خودش دسترسی دارد.
## مجوزها در Django Admin
- مشاهدهٔ همه ارسال‌های گروهی: `tickets.view_all_bulk_dispatches`
- مشاهدهٔ همه استعلام‌ها: `tickets.view_all_inquiries`
- بازگشایی همه ارسال‌های قابل مشاهده: `tickets.open_all_dispatch_responses`
مجوز بازگشایی به‌تنهایی مجوز مشاهده ایجاد نمی‌کند. ایجادکنندهٔ ارسال خودش
می‌تواند بازگشایی کند. حساب باید فعال باشد؛ کاربران پنل staff با پروفایل
غیرفعال مجاز نیستند. عنوان شغلی یا نقش جدیدی به‌صورت خودکار مجوز نمی‌گیرد.
## پیامک
رویداد `DISPATCH_RESPONSES_OPENED` در SMSTemplate قابل ویرایش است؛ قالب
اختصاصی واحد بر قالب عمومی اولویت دارد. این رویداد تگ جایگزین ندارد.
متن دقیق هنگام تأیید ثبت می‌شود و تغییر بعدی قالب بر پیامک‌های صف‌شده اثر
نمی‌گذارد. تغییر قالب میان پیش‌نمایش و تأیید، نیاز به بررسی دوباره دارد.
فقط پاسخ‌دهندگان با پاسخ حذف‌نشده در لحظهٔ بازگشایی در صف پیامک قرار
می‌گیرند. پاسخ صرفاً دارای پیوست نیز معتبر است. سقف ۱۵ بر اساس گیرندگان
واجد شرایط هنگام ایجاد است و با تغییر بعدی گروه عوض نمی‌شود. حالت
جداگانه و ارسال بیش از ۱۵ گیرنده پیامک بازگشایی ندارند.
بازگشایی یک‌بار است. پاسخ و ویرایش بعدی فوراً برای کاربران مجاز قابل مشاهده
است و پیامک بازگشایی دیگری ایجاد نمی‌کند. بازگشایی با رسیدِ خواندن پیام
تفاوت دارد. صف و تحویل پیامک نیز جدا از بازگشایی گزارش می‌شوند.
اعلان پاسخ ارسال‌های جدید در بله، با اطلاعات وضعیت و لینک پنل، به مقصد
خصوصی بلهٔ ایجادکننده ارسال می‌شود؛ تنظیم مقصد و فعال بودن اعلان در پروفایل
کارشناس لازم است. متن پیشنهاد در اعلان قرار نمی‌گیرد. اعلان‌های تیکت‌های
عادی و ارسال‌های قدیمی مسیر موجود خود را حفظ می‌کنند.
## انتشار و migration
1. از دیتابیس production پشتیبان قابل بازیابی بگیرید و migrationها را ابتدا
روی کپی آزمایشی بررسی کنید. این تغییر production را خودکار migrate نمی‌کند.
2. migrationهای جدید: `tickets.0018_dispatch_response_opening` و
`notifications.0021_dispatch_response_opening`. اولی جدول جدید می‌سازد؛
دومی choices را فقط در وضعیت Django تغییر می‌دهد و قالب مفقود را با
`get_or_create` اضافه می‌کند. رکورد یا قالب قبلی بازنویسی نمی‌شود.
3. کد برنامه و worker را هماهنگ منتشر و worker پیامک را با نسخهٔ جدید
راه‌اندازی مجدد کنید؛ worker قدیمی رویداد تازه را نمی‌شناسد.
4. تنظیم جدید Nginx برای `/protected-media/dispatch-responses/` را نیز منتشر
کنید. فایل‌های این مسیر فقط از endpoint مجاز برنامه تحویل داده می‌شوند.
فایل‌های قبلی جابه‌جا نمی‌شوند.
5. مجوز مشاهده و بازگشایی را فقط به کاربران منتخب بدهید. هیچ مجوزی قفل
پاسخ را دور نمی‌زند. تنظیمات و سابقه بازگشایی در Admin فقط خواندنی‌اند.
پس از استفادهٔ قابلیت، rollback جدول کنترل پاسخ ممنوع است تا محرمانگی و
سابقه بازگشایی حذف نشود. بازگشت به کد قدیمی نیز قفل جدید را نمی‌شناسد؛ برای
بازگشت از نسخه، از برنامهٔ بازیابی هماهنگ کد و دیتابیس استفاده کنید.
این محدودیت در برنامه و Django Admin اعمال می‌شود؛ دسترسی مستقیم مدیر
زیرساخت به دیتابیس یا فایل سرور، رمزنگاری نشده است.
## تست‌ها
`tests/test_dispatch_response_opening.py` دسترسی، پیوست، Admin، قالب، سقف
مخاطب، تراکنش صف و worker را بررسی می‌کند.
`tests/test_dispatch_opening_postgres.py` هم‌زمانی واقعی و حفظ داده در
migration را بررسی می‌کند. تست‌های هم‌زمانی باید روی PostgreSQL اجرا شوند.
سناریوهای رابط کاربری در `tests/e2e/test_dispatch_response_opening_e2e.py`
با fixture مرورگر پروژه، در عرض‌های ۱۴۴۰، ۳۹۰ و ۳۲۰ اجرا می‌شوند.
+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 نیز در مرورگر بررسی می‌شوند.
+8 -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` استفاده می‌کند تا انتخاب عکس و اسکرین‌شات از گالری ممکن باشد. نمایش دقیق انتخاب‌گر به مرورگر و سیستم‌عامل گوشی بستگی دارد؛ فرمت و اندازه فایل همچنان در مرورگر و سرور اعتبارسنجی می‌شوند.
- تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد. - تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد.
- اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود. - اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود.
@@ -22,6 +23,12 @@
شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده می‌شوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده می‌شود. شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده می‌شوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده می‌شود.
## کپی اطلاعات و اشتراک صفحه
شناسه واریز در تولید، ذخیره، نمایش و متن کپی بدون صفرهای ابتدایی است؛ رقم کنترل و صفرهای داخلی حفظ می‌شوند. در صورت وجود شناسه، متن کپی کامل صریحاً می‌گوید وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است.
زیر دکمه کپی کامل در جزئیات واریز، «کپی لینک کوتاه این صفحه برای ارسال به دیگران» قرار دارد. درخواست POST با CSRF و کنترل مالکیت/محدوده اجرا می‌شود. مشتری واردشده می‌تواند لینک موقت همین اعلامیه را بسازد؛ پیامکی ارسال نمی‌شود. بازدیدکننده ناشناس فقط لینک معتبر دریافت‌شده را کپی می‌کند؛ انقضا یا محدوده دسترسی تمدید نمی‌شود. برای کپی پس از redirect، توکن در نشست سمت سرور نگهداری می‌شود؛ مدل لینک همچنان فقط هش توکن را ذخیره می‌کند. نشست‌های قدیمی فاقد توکن باید لینک دریافتی را دوباره باز کنند. Clipboard ناموجود، کپی دستی قابل انتخاب را نمایش می‌دهد.
## تست دائمی ## تست دائمی
از WSL و در ریشه پروژه اجرا کنید: از WSL و در ریشه پروژه اجرا کنید:
+19
View File
@@ -0,0 +1,19 @@
# حذف اعلامیه بدون فیش
در جزئیات اعلامیه پنل staff، ایجادکننده، مدیرعامل (`CEO`)، مدیرکل (`GENERAL_ADMIN`) و superuser فعال می‌توانند اعلامیه فاقد فیش را حذف کنند. حساب و پروفایل کارشناس باید فعال باشند. مجوزهای ایجاد یا تأیید پرداخت به‌تنهایی مجوز حذف نمی‌دهند.
بخش «حذف اعلامیه واریز» ابتدا هشدار را باز می‌کند. ارسال فرم به تأیید صریح checkbox، POST و CSRF نیاز دارد. وجود هر فیش مرتبط، در هر وضعیت از جمله ردشده، حذف را ممنوع می‌کند. فیش مستقلِ بدون ارتباط به اعلامیه مانع حذف نیست.
حذف منطقی با `deleted_at` انجام می‌شود و `cancelled_at` نیز تنظیم می‌شود. سوابق مالی، پیامک و لینک‌های صادرشده برای ممیزی نگهداری می‌شوند؛ اعلامیه از فهرست‌ها و صفحات مشتری و staff خارج می‌شود و لینک‌ها و نشست‌های مربوط دیگر معتبر نیستند. رویداد `NOTICE_DELETED` مشخصات اعلامیه و عامل حذف را نگه می‌دارد. بازیابی از پنل ارائه نشده است.
سرویس حذف نقش فعلی عامل را مجدداً از دیتابیس می‌خواند و مانند ثبت فیش، ابتدا مشتری و سپس اعلامیه را قفل می‌کند. در نتیجه ثبت هم‌زمان فیش مرتبط و حذف نمی‌توانند هر دو موفق شوند. قفل لینک در سرویس حذف گرفته نمی‌شود تا با ترتیب قفل‌های ارسال فیش ناشناس تداخل نداشته باشد. فرم‌های ثبت فیشِ بازمانده از قبل از حذف نیز در سرور رد می‌شوند.
پیامک‌های PENDING، RETRYING و FAILED لغو می‌شوند. worker پیش از پردازش پیامک صدور اعلامیه، حذف را دوباره بررسی می‌کند؛ پیامکی که قبلاً به ارائه‌دهنده تحویل داده شده قابل بازگرداندن نیست، اما لینک آن نامعتبر می‌شود.
تست‌های دائمی:
```bash
.venv/Scripts/python.exe -m pytest tests/test_payment_notice_deletion.py tests/test_payment_notice_concurrency.py tests/e2e/test_notice_deletion_e2e.py -q
```
تست هم‌زمانی فقط با دیتابیس تست PostgreSQL اجرا می‌شود؛ SQLite صحت قفل ردیف را اثبات نمی‌کند. تست‌های دیگر و مرورگر از دیتابیس جداگانه و provider ساختگی استفاده می‌کنند و پیامک واقعی نمی‌فرستند.
+51
View File
@@ -0,0 +1,51 @@
# اطلاع‌رسانی فیش مشتری
پس از ثبت موفق فیش مشتری، اپ `payments` اعلان‌ها را در صف `NotificationJob` قرار می‌دهد. ثبت از حساب مشتری، لینک کوتاه پرداخت و «ثبت فیش برای مشتری» در پنل کارشناس همگی پشتیبانی می‌شوند. ثبت دستی کارشناس نیز با همان تنظیمات به مدیران فعال پیامک می‌فرستد و تصویر فیش را به همان گروه بله ارسال می‌کند؛ اطلاعات پیام مربوط به مشتری و واریزکننده است. تصویر هر نسخه مستقل است؛ گزینه «اطلاع‌رسانی فیش اصلاح‌شده مشتری» تعیین می‌کند نسخه‌های اصلاحی نیز، چه توسط مشتری و چه کارشناس مجاز ثبت شوند، اعلان داشته باشند.
این اصلاح migration یا تنظیمات جداگانه نمی‌خواهد. برای اعمال آن، سرور برنامه و Worker موجود `process_notifications` را با نسخه جدید کد بازراه‌اندازی کنید. اعلان‌های مربوط به ثبت‌های قبلی که صفی برای آن‌ها ساخته نشده، به‌صورت خودکار ارسال نمی‌شوند.
## راه‌اندازی
۱. migration `payments.0007_receipt_alerts` را اجرا کنید. هر دو کانال در شروع خاموش‌اند و بدون تنظیمات هیچ اعلانی ساخته نمی‌شود.
۲. در Django Admin، بخش پرداخت‌ها، «تنظیمات اطلاع‌رسانی فیش مشتری» را باز کنید:
`/x5admin/payments/paymentreceiptalertconfiguration/`
- بله: توکن ربات، شناسه گروه و متن همراه تصویر را وارد و کانال را فعال کنید. ربات باید مجوز ارسال به گروه داشته باشد. این توکن از `BALE_BOT_TOKEN` مربوط به تیکت‌ها مستقل است.
- پیامک: کانال را فعال کنید، متن پیامک را تنظیم کنید و در جدول همان صفحه مدیران دریافت‌کننده را با نام و شماره موبایل اضافه کنید. هر مدیر گزینه فعال مستقل دارد. شماره‌ها با قالب موبایل فعلی پروژه یکسان‌سازی می‌شوند و شماره تکراری پذیرفته نمی‌شود. درگاه پیامک فعلی پروژه استفاده می‌شود.
- برای حفظ توکن ذخیره‌شده، فیلد توکن را خالی بگذارید. برای تعویض، توکن تازه وارد کنید. برای حذف، ابتدا بله را خاموش و گزینه پاک‌کردن توکن را انتخاب کنید. مقدار توکن در HTML فرم و ممیزی پرداخت درج نمی‌شود؛ توکن در دیتابیس تنظیمات نگهداری می‌شود و دسترسی دیتابیس/نسخه پشتیبان باید محدود باشد.
۳. Worker موجود `process_notifications` باید با نسخه جدید کد اجرا شود و به storage تصاویر فیش دسترسی داشته باشد. در محیط تولید از محیط مجازی همان سرور استفاده کنید. اجرای Worker قبلی را پس از انتشار بازراه‌اندازی کنید؛ Worker جدید جداگانه لازم نیست.
مجوز مدیریت تنظیمات و مدیران: `payments.manage_receipt_alerts`. مدیرعامل یا superuser فعال با حساب STAFF نیز مطابق سیاست فعلی پرداخت دسترسی دارد.
## متن و سابقه
تگ‌های متن: `{amount_rial}` (ریال)، `{customer_name}`، `{customer_mobile}`، `{received_at}` (تاریخ و ساعت شمسی)، `{paid_date}` (تاریخ واریز شمسی)، `{receipt_number}`، `{revision_version}`، `{receipt_link}` و `{manager_name}`. تگ `{customer_name}` شامل نام مشتری و نام واریزکننده ثبت‌شده روی نسخه فیش است؛ اگر این دو نام یکسان باشند، فقط یک بار نوشته می‌شود. اگر نام مشتری خالی باشد، نام واریزکننده نمایش داده می‌شود. در قالب سفارشی بدون این تگ نیز هویت مشتری/واریزکننده به متن اضافه می‌شود. نام‌ها با قواعد یکسان‌سازی فارسی مقایسه می‌شوند. قالب‌بندی اضافی یا دسترسی به ویژگی‌های شیء در تگ‌ها پذیرفته نمی‌شود.
## نام واریزکننده و رد فیش
فیلد «نام واریزکننده» در تمام مسیرهای ثبت و اصلاح فیش اجباری است. نام پروفایل مشتری (و در نبود آن، نام حساب کاربری) پیش‌فرض فرم است و قابل تغییر می‌ماند؛ ثبت این نام، پروفایل مشتری را تغییر نمی‌دهد. هر نسخه نام مستقل دارد. migration `payments.0008_paymentreceiptrevision_depositor_name` برای نسخه‌های قدیمی مقدار خالی می‌گذارد، چون نام واقعی واریزکننده قابل بازسازی نیست؛ پنل این وضعیت را مشخص می‌کند و هنگام اصلاح، ثبت نام الزامی است.
پس از رد فیش توسط تأییدکننده مجاز، یک پیامک مشتری با رویداد `PAYMENT_RECEIPT_REJECTED` در صف موجود ثبت می‌شود. پیش از ثبت، صفحه بررسی هشدار می‌دهد که دلیل رد برای مشتری ارسال می‌شود و تأیید کارشناس را با نمایش متن دلیل می‌گیرد. درخواست HTTP بدون تأیید پذیرفته نمی‌شود. پیامک شامل دلیل کامل و لینک کوتاه مشاهده و اصلاح فیش است؛ کلید یکتای چرخه بررسی جلوی ساخت پیامک تکراری را می‌گیرد. ثبت تصمیم و صف در یک تراکنش انجام می‌شود و ارسال شبکه‌ای در Worker است.
migrationهای `notifications.0019` و `notifications.0020` رویداد و قالب عمومی قابل ویرایش آن را اضافه می‌کنند. تگ `{rejection_reason}` متن دلیل است؛ اگر قالب سفارشی آن را حذف کند، دلیل همچنان به انتهای پیام اضافه می‌شود. پس از انتشار، migrationها را اجرا و Worker موجود را بازراه‌اندازی کنید.
بله فایل خصوصی را مستقیماً همراه متن دریافت می‌کند؛ آدرس عمومی تصویر ایجاد نمی‌شود. لینک پیش‌فرض متن به جزئیات محافظت‌شده کارشناسان است. PNG/JPEG به‌صورت تصویر و دیگر فرمت‌های مجاز فیش به‌صورت فایل تصویری ارسال می‌شوند.
صفحه سوابق:
`/x5admin/payments/paymentreceiptalert/`
برای هر نسخه و مقصد، سابقه مستقل شامل گیرنده، متن، کانال، تعداد تلاش، خطای آخر و نتیجه دریافت پیامک وجود دارد. پذیرش سرویس پیامک با رسیدن به گوشی متفاوت است؛ نتیجه دریافت از استعلام Worker و قابلیت درگاه می‌آید. سوابق فقط خواندنی‌اند.
## رفتار هنگام خطا و تغییر تنظیمات
- ثبت فیش هیچ درخواست شبکه‌ای برای اعلان انجام نمی‌دهد. ساخت صف در همان تراکنش است؛ rollback ثبت فیش، صف آن را نیز حذف می‌کند. nonce و کلید یکتای نسخه/کانال/گیرنده جلوی ساخت اعلان تکراری را می‌گیرند.
- خرابی صف یک مقصد با savepoint جدا مدیریت می‌شود؛ مقصدهای دیگر و ثبت فیش ادامه پیدا می‌کنند. اگر خواندن تنظیمات یا ساخت صف ممکن نباشد، خطای بدون اطلاعات محرمانه در لاگ ثبت می‌شود؛ چنین خطایی صف قابل تلاش مجدد ایجاد نمی‌کند.
- خطای شبکه/درگاه/فایل در Worker به خطای اعلان تبدیل می‌شود. تلاش مجدد از سیاست محدود فعلی پیامک و بله پیروی می‌کند و ثبت یا بررسی فیش را تغییر نمی‌دهد. خطای یک اعلان پردازش مقصدهای دیگر را متوقف نمی‌کند.
- خاموش‌کردن کانال یا مدیر، یا تغییر شماره/شناسه گروه، اعلان‌های قبلی ارسال‌نشده آن مقصد را لغو می‌کند. توکن جدید برای تلاش‌های بعدی خوانده می‌شود. متن و اطلاعات مشتری/گیرنده مربوط به زمان ثبت نسخه حفظ می‌شوند؛ تغییر متن، پیام‌های جدید را تغییر می‌دهد.
- همانند صف فعلی پروژه، اگر سرویس پیام را بپذیرد ولی پاسخ شبکه گم شود، تلاش بعدی ممکن است ارسال بیرونی را تکرار کند؛ سرویس بیرونی تضمین exactly-once ندارد.
تست‌ها در `tests/test_payment_receipt_alerts.py` تمام ارسال‌ها را mock می‌کنند و به گروه یا موبایل واقعی پیام نمی‌فرستند.
@@ -0,0 +1,27 @@
# ارزیابی نام واریزکننده و پیامک رد فیش
تاریخ ارزیابی: ۱۹ مهر ۱۴۰۵ (۲۰۲۶-۱۰-۱۱)
## رفتار پیاده‌شده
- تمام فرم‌های ثبت و اصلاح فیش، نام واریزکننده اجباری و قابل ویرایش دارند. نام از پروفایل مشتری یا نام حساب پر می‌شود؛ POST بدون نام حتی با وجود نام در پروفایل رد می‌شود.
- نام روی نسخه فیش ذخیره می‌شود و اصلاح آن، نام نسخه قبلی و پروفایل مشتری را تغییر نمی‌دهد. نام مشتری و نام واریزکننده در میزکار و جزئیات کارکنان نمایش داده می‌شوند.
- اعلان ثبت فیش در پیامک مدیران و بله هر دو نام متفاوت را نمایش می‌دهد؛ نام یکسان پس از یکسان‌سازی حروف و فاصله‌ها فقط یک بار می‌آید. اطلاعات اعلان snapshot زمان ثبت نسخه است.
- رد فیش با دلیل و تأیید صریح کارشناس انجام می‌شود. انصراف از دیالوگ، فرم و دکمه‌ها را قابل استفاده نگه می‌دارد. کنترل مجوز پیش از کنترل تأیید انجام می‌شود.
- پیامک رد در تراکنش تصمیم و با کلید یکتای چرخه بررسی ساخته می‌شود؛ ارسال از Worker موجود انجام می‌شود. دلیل کامل حتی در قالب سفارشی بدون تگ دلیل به مشتری می‌رسد.
- هشدار قرمز بانک شناسه‌دار، نام بانک و شناسه اختصاصی تولیدشده با الگوریتم موجود را نمایش می‌دهد و با تغییر حساب مقصد به‌روز می‌شود. الزام واردکردن شناسه در خود عملیات بانکی توضیح داده می‌شود؛ فیلد اختیاری شناسه درج‌شده روی فیش مطابق قرارداد فعلی حفظ شده است.
## نتایج بررسی
- کل تست‌های سمت سرور: ۵۱۴ موفق، ۷ ردشده از اجرا به علت نیاز به PostgreSQL؛ محیط فعلی SQLite است.
- تست‌های مرورگری پرداخت: ۴۱ سناریوی متمایز موفق، شامل تست نسخه‌گذاری PWA. یک خطای واقعی قفل شدن دکمه پس از انصراف کشف، اصلاح و دوباره تست شد.
- ۱۶ تست جدید سمت سرور و ۶ سناریوی جدید مرورگری برای نام، هشدار بانک و تأیید رد اضافه شدند.
- تست‌های ارسال، درگاه پیامک و بله را mock می‌کنند و پیامی به مشتری یا گروه واقعی نمی‌فرستند.
- `manage.py check` بدون اشکال؛ `makemigrations --check --dry-run` بدون تغییر جامانده؛ بررسی whitespace فایل‌های این تغییر بدون خطا.
- `PROJECT_TREE.txt`، تست‌های موجود و مستندات اعلان بررسی شدند. Graphify برای ارتباط مستندات و بخش‌ها و Ripwire برای impact، uses، قرارداد و دروازه تست به کار رفتند.
- Ripwire پس از بازخوانی بدون کش یک هشدار عمده باقی‌مانده دارد: افزایش تعداد پارامترهای `decide_receipt` به دلیل پارامتر اختیاری تأیید رد. این تغییر عمدی است؛ بررسی قرارداد هیچ فراخوان ناسازگاری گزارش نکرد و تست‌های مجوز و تصمیم موفق بودند. یافته‌های عدم پوشش AST، جایگزین نتایج اجرای تست نیستند.
- محتوای واقعی `/chaharsu/payment-receipt/` و `/staff/payments/` و فایل‌های JavaScript سرو‌شده روی سرور محلی بررسی شدند. دقیقاً یک شنونده روی `0.0.0.0:8000` وجود دارد. UUIDهای نمونه درخواست در دیتابیس محلی نبودند؛ صفحات جزئیات با داده مستقل در live_server تست شدند.
## انتشار
روی دیتابیس توسعه، پس از backup، migrationهای `payments.0008`، `notifications.0019` و `notifications.0020` اجرا شدند. برای محیط اصلی نیز migrationها باید اجرا و Worker `process_notifications` با کد جدید بازراه‌اندازی شود. migrationهای قبلی و تغییرات موجود کاربر حفظ شدند. برای فیش‌های قدیمی نام واقعی واریزکننده جعل یا از پروفایل بازسازی نمی‌شود؛ وضعیت ثبت نشدن نام در پنل مشخص است.
+31
View File
@@ -0,0 +1,31 @@
# لینک کوتاه تیکت در پیامک سرپرستان
در ادمین Django، بخش واحدهای سازمانی، گزینه «افزودن لینک تیکت به پیامک
سرپرستان» را در قسمت «اطلاع‌رسانی بله و سرپرستان» فعال کنید. پیش‌فرض خاموش
است و تنظیم هر واحد مستقل است. پیامک به شماره‌های سرپرست اول تا پنجم آن
واحد ارسال می‌شود؛ در صورت فعال بودن گزینه، لینک به انتهای الگوی فعلی
پیامک اضافه می‌شود.
لینک به شکل `https://your-domain/vt/Ab12Cd34Ef` است. دامنه از تنظیم موجود
`PUBLIC_BASE_URL` خوانده می‌شود و باید دامنه عمومی و قابل دسترس سامانه باشد.
لینک هنگام اولین ارسال پیامک ساخته می‌شود و دقیقاً ۷ روز اعتبار دارد. تمام
سرپرستان یک تیکت و تلاش‌های مجدد ارسال، همان لینک و همان تاریخ انقضا را
استفاده می‌کنند؛ ارسال مجدد اعتبار را تمدید نمی‌کند. پس از انقضا، پیامک
فقط متن اعلان را دارد.
این لینک صرفاً میانبر صفحه واقعی `/staff/tickets/<uuid>/` است. هیچ ورود
خودکار، مجوز جدید، نشست magic link یا دسترسی اشتراکی ایجاد نمی‌کند. کاربران
بدون ورود به `/staff/login/?next=/vt/...` هدایت می‌شوند و پس از ورود معتبر
staff به تیکت بازمی‌گردند. مجوز مشاهده و پاسخ همان مجوزهای عادی تیکت است؛
حساب مشتری و کارشناس بدون دسترسی با خطای ۴۰۳ مواجه می‌شوند. لینک نامعتبر،
منقضی یا متعلق به تیکت حذف‌شده پس از ورود خطای ۴۰۴ دارد. پاسخ‌های میانبر
قابل کش کردن نیستند.
خاموش کردن گزینه، افزودن لینک به پیامک‌های بعدی را متوقف می‌کند؛ لینک‌های
قبلاً ارسال‌شده تا پایان اعتبار خود معتبرند و همچنان ورود و مجوز لازم دارند.
برای اعمال ساختار پایگاه داده:
```powershell
.\.venv\Scripts\python.exe manage.py migrate
```
+11
View File
@@ -35,6 +35,13 @@
.chaharsu-dock a.active{background:#0b4c8c}.chaharsu-dock a{min-height:52px;color:#cadbea} .chaharsu-dock a.active{background:#0b4c8c}.chaharsu-dock a{min-height:52px;color:#cadbea}
/* Staff payment workspace: one reading order across desktop and mobile. */ /* Staff payment workspace: one reading order across desktop and mobile. */
.pay-day-summary{--day-ink:var(--color-ink,#08376b);--day-line:#dce5ed;--day-muted:#526c86;margin-block:0 16px;padding:12px;border:1px solid var(--day-line);border-radius:20px;background:var(--color-paper,#fff);color:var(--day-ink)}
.pay-day-head{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:4px 12px;margin-bottom:8px}.pay-day-head h2{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px;margin:0;font-size:1rem;font-weight:800}.pay-day-head time,.pay-day-head>span{font-size:.75rem;font-weight:400;color:var(--day-muted)}
.pay-day-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0}.pay-day-metric{min-width:0;padding:8px 12px;border-radius:12px;background:#f6f8fa}.pay-day-metric dt{font-size:.875rem;font-weight:700;line-height:1.7}.pay-day-metric dd{margin:0;overflow-wrap:anywhere}.pay-day-count{font-size:1.375rem;font-weight:900;line-height:1.6;font-variant-numeric:tabular-nums}.pay-day-count span{font-size:.75rem;font-weight:400;color:var(--day-muted)}.pay-day-amount{font-size:.875rem;font-weight:700;line-height:1.8;font-variant-numeric:tabular-nums}.pay-day-amount bdi{overflow-wrap:anywhere}.pay-day-amount span{font-weight:400}.pay-day-note{font-size:.75rem;line-height:1.8;color:var(--day-muted)}
.pay-day-approved dt{color:#216345}.pay-day-rejected dt{color:#9f1f1f}.pay-day-pending{background:#fff8eb}.pay-day-pending dt{color:#805300}.pay-day-notices{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;column-gap:12px}.pay-day-notices dt{grid-column:1;grid-row:1}.pay-day-notices .pay-day-count{grid-column:2;grid-row:1/3}.pay-day-notices .pay-day-note{grid-column:1}
.pay-day-definitions{margin-top:4px}.pay-day-definitions summary{width:fit-content;min-height:44px;display:flex;align-items:center;gap:8px;cursor:pointer;font-size:.875rem;font-weight:700;list-style:none}.pay-day-definitions summary::-webkit-details-marker{display:none}.pay-day-definitions summary::after{content:'+'}.pay-day-definitions[open] summary::after{content:'−'}.pay-day-definitions summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px;border-radius:4px}.pay-day-definitions p{margin:0 0 8px;font-size:.875rem;line-height:1.8;color:var(--day-muted)}
@media(min-width:640px){.pay-day-summary{padding:16px}.pay-day-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-day-notices{grid-column:auto;display:block}}
@media(min-width:1000px){.pay-day-grid{grid-template-columns:repeat(5,minmax(0,1fr))}}
.pay-workspace{max-width:1200px}.pay-page-head{display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:24px} .pay-workspace{max-width:1200px}.pay-page-head{display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:24px}
.pay-page-title{display:flex;align-items:center;gap:14px;min-width:0;flex:1}.pay-page-title>div{min-width:0}.pay-page-title h1{font-size:24px;line-height:1.6;margin:0}.pay-page-title p{margin:4px 0 0;color:#526c86;font-size:14px} .pay-page-title{display:flex;align-items:center;gap:14px;min-width:0;flex:1}.pay-page-title>div{min-width:0}.pay-page-title h1{font-size:24px;line-height:1.6;margin:0}.pay-page-title p{margin:4px 0 0;color:#526c86;font-size:14px}
.pay-page-icon{display:grid;place-items:center;width:52px;height:52px;flex-shrink:0;border:1px solid #cde6f7;border-radius:16px;background:#e5f4ff;color:#08376b}.pay-page-icon .cs-icon{width:26px;height:26px} .pay-page-icon{display:grid;place-items:center;width:52px;height:52px;flex-shrink:0;border:1px solid #cde6f7;border-radius:16px;background:#e5f4ff;color:#08376b}.pay-page-icon .cs-icon{width:26px;height:26px}
@@ -119,3 +126,7 @@
.customer-accounts .pay-notice-entry{background:#fff;border:1px solid #dce5ed;border-radius:23px;margin:16px 0;overflow:hidden}.customer-accounts .pay-notice-entry .payment-notice{border:0;border-radius:0;margin:0}.customer-accounts .pay-notice-entry .pay-notice-permalink{display:flex;justify-content:space-between;border-top:1px solid #e7edf2;margin:0;padding:12px 20px;min-height:48px;text-decoration:none;background:#fafcfe} .customer-accounts .pay-notice-entry{background:#fff;border:1px solid #dce5ed;border-radius:23px;margin:16px 0;overflow:hidden}.customer-accounts .pay-notice-entry .payment-notice{border:0;border-radius:0;margin:0}.customer-accounts .pay-notice-entry .pay-notice-permalink{display:flex;justify-content:space-between;border-top:1px solid #e7edf2;margin:0;padding:12px 20px;min-height:48px;text-decoration:none;background:#fafcfe}
.pay-identifier-control{display:flex;align-items:stretch;gap:12px}.pay-identifier-control>input{flex:1;min-width:0}.pay-identifier-control>.pay-button{gap:8px;flex-shrink:0;white-space:nowrap;font-size:14px}.pay-identifier-control>.pay-button:disabled{opacity:.6;cursor:not-allowed}.pay-staff-form #id_deposit_id{letter-spacing:.5px}.pay-staff-form #identifier-replacement p{font-size:14px}.pay-staff-form #identifier-replacement .pay-text-link{font:inherit;background:transparent;border:0;color:#526c86;min-height:44px;padding:8px 12px;cursor:pointer}.pay-staff-form #identifier-replacement[hidden]{display:none} .pay-identifier-control{display:flex;align-items:stretch;gap:12px}.pay-identifier-control>input{flex:1;min-width:0}.pay-identifier-control>.pay-button{gap:8px;flex-shrink:0;white-space:nowrap;font-size:14px}.pay-identifier-control>.pay-button:disabled{opacity:.6;cursor:not-allowed}.pay-staff-form #id_deposit_id{letter-spacing:.5px}.pay-staff-form #identifier-replacement p{font-size:14px}.pay-staff-form #identifier-replacement .pay-text-link{font:inherit;background:transparent;border:0;color:#526c86;min-height:44px;padding:8px 12px;cursor:pointer}.pay-staff-form #identifier-replacement[hidden]{display:none}
@media(max-width:639px){.pay-identifier-control{flex-direction:column;gap:8px}.pay-identifier-control>.pay-button{align-self:stretch}} @media(max-width:639px){.pay-identifier-control{flex-direction:column;gap:8px}.pay-identifier-control>.pay-button{align-self:stretch}}
.pay-pagination{flex-wrap:wrap}.pay-pagination small{flex-basis:100%;text-align:center;color:#526c86}.pay-sms-panel{overflow-wrap:anywhere}.pay-sms-panel>form>.pay-button{width:100%;white-space:normal;line-height:1.8}.pay-sms-panel button:disabled{opacity:.6;cursor:not-allowed}.pay-sms-record{padding:16px 0;border-top:1px solid #e7edf2}.pay-sms-record header{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap}.pay-sms-record p{margin:8px 0;font-size:13px;line-height:1.9}.pay-sms-record time{font-size:12px;color:#526c86}.pay-sms-panel .pay-status{white-space:normal;max-width:100%;line-height:1.8}.pay-sms-record summary{cursor:pointer;font-size:13px;padding:10px 0;color:#17435d}.pay-sms-body{font-size:13px;line-height:1.9;background:#f0f8fc;padding:12px;border-radius:10px}.pay-sms-attempt{padding:10px 0;border-top:1px dashed #dce5ed}.pay-sms-error{color:#9f1f1f}
.receipt-upload{padding:18px;margin:16px 0;border:1px solid #c7dfeb;border-radius:16px;background:#f0f8fc;color:#17435d;overflow-wrap:anywhere}.receipt-upload[hidden]{display:none}.receipt-upload p{margin:8px 0;font-size:14px}.receipt-upload small{display:block;color:#526c86}.receipt-upload-meter{display:flex;align-items:center;gap:12px;margin-top:12px}.receipt-upload-meter progress{width:100%;flex:1;min-width:0;height:12px;accent-color:#087d8b;direction:ltr}.receipt-upload-meter span{min-width:44px;text-align:center;font-weight:700;font-variant-numeric:tabular-nums}.customer-receipt-actions button:disabled{opacity:.65;cursor:wait}.customer-receipt-form.is-uploading input,.customer-receipt-form.is-uploading select,.customer-receipt-form.is-uploading textarea{opacity:.75}
.chaharsu .pay-deposit-warning{color:#b42318;background:#fff4f2;border:1px solid #f5b5ad;border-radius:12px;padding:14px 16px;font-size:14px;line-height:2;overflow-wrap:anywhere}
+24
View File
@@ -0,0 +1,24 @@
/* Shared Chaharsu navigation. Composer changes are confined to the dock. */
.customer-mobile-dock.chaharsu-dock {
grid-template-columns: repeat(3, minmax(0, 1fr));
bottom: calc(12px + env(safe-area-inset-bottom));
}
.customer-mobile-dock.chaharsu-dock a {
min-height: 52px;
gap: 4px;
font-size: 12px;
color: #cadbea;
}
.customer-mobile-dock.chaharsu-dock svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.customer-mobile-dock.chaharsu-dock a.active {
background: #0b4c8c;
color: #fff;
}
.customer-mobile-dock.chaharsu-dock a:focus-visible {
outline: 3px solid #fff;
outline-offset: -3px;
}
+17
View File
@@ -0,0 +1,17 @@
.customer-login-block {
margin-block: 1.25rem;
padding: 1rem 1.25rem;
border: 1px solid #fca5a5;
border-inline-start: 4px solid #b91c1c;
border-radius: 12px;
background: #fff1f2;
color: #991b1b;
font-family: inherit;
font-size: 1rem;
line-height: 1.9;
text-align: start;
overflow-wrap: anywhere;
}
.customer-login-block strong { display: block; font-size: 1.125rem; font-weight: 800; }
.customer-login-block .customer-login-block-label { margin: .5rem 0 0; color: #991b1b; }
.customer-login-block .customer-login-block-reason { margin: .25rem 0 0; color: #991b1b; font-weight: 700; white-space: pre-wrap; }
+36
View File
@@ -0,0 +1,36 @@
/* Chaharsu profile: scoped rules preserve the ticket composer and other forms. */
.profile-breadcrumb { display: inline-flex; align-items: center; min-height: 44px; }
.profile-heading { display: flex; align-items: flex-start; gap: 16px; margin-top: 8px; }
.profile-heading .service-icon { flex-shrink: 0; }
.profile-heading h1 { margin: 0; }
.customer-profile .profile-heading p { margin: 6px 0 0; }
.customer-profile-form.pay-form { max-width: none; margin: 0; padding: 0; border: 0; background: transparent; box-shadow: none; }
.customer-profile-form .pay-card { margin: 0 0 20px; padding: 22px; border-radius: 22px; }
.customer-profile-form h2 { margin: 0 0 12px; }
.profile-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.profile-card-heading h2 { margin: 0; }
.profile-mobile { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; color: #526c86; font-size: .875rem; }
.profile-mobile bdi { color: #08376b; font-weight: 700; }
.profile-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 24px; }
.customer-profile-form .pay-field { margin: 0; min-width: 0; }
.customer-profile-form .pay-field label { font-size: .875rem; }
.customer-profile-form input:not([type=hidden]),.customer-profile-form select { min-height: 48px; padding: 10px 12px; font-size: 1rem; box-shadow: none; }
.customer-profile-form input[type=password] { direction: ltr; text-align: right; }
.customer-profile .profile-guidance { margin: 8px 0 16px; color: #526c86; font-size: .875rem; line-height: 1.8; }
.customer-profile [data-location-hint] { margin: 12px 0 0; }
.customer-profile .is-error,.customer-profile .errorlist { color: #9f1f1f; font-size: .875rem; line-height: 1.8; }
.customer-profile .errorlist { list-style: none; padding: 0; margin: 6px 0 0; }
.customer-profile-form [aria-invalid=true] { border-color: #9f1f1f; }
.profile-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
.profile-actions .pay-button { min-width: 160px; }
@media (max-width: 639px) {
.profile-heading { display: grid; grid-template-columns: minmax(0, 1fr) 40px; gap: 8px 12px; align-items: center; }
.profile-heading > div { display: contents; }
.profile-heading h1 { grid-column: 1; grid-row: 1; }
.profile-heading .service-icon { grid-column: 2; grid-row: 1; width: 40px; height: 40px; border-radius: 12px; }
.customer-profile .profile-heading p { grid-column: 1 / -1; grid-row: 2; margin: 0; }
.customer-profile-form .pay-card { padding: 16px; border-radius: 20px; margin-bottom: 16px; }
.profile-field-grid { grid-template-columns: minmax(0, 1fr); gap: 18px; }
.profile-actions { flex-direction: column; gap: 8px; margin-top: 20px; }
.profile-actions .pay-button { width: 100%; }
}
+42
View File
@@ -0,0 +1,42 @@
/* Ticket history follows the Chaharsu shell; no composer selectors. */
.customer-tickets .chaharsu-heading h1 { margin: 4px 0 0; }
.customer-tickets .tickets-breadcrumb { display: inline-flex; align-items: center; min-height: 44px; }
.customer-tickets .tickets-intro { margin-bottom: 20px; }
.customer-tickets .tickets-intro-kicker { margin: 0 0 8px; font-size: 12px; color: #cadbea; }
.customer-tickets .tickets-intro .service-actions { padding-top: 16px; }
.customer-tickets .service-actions a { white-space: normal; text-align: center; }
.tickets-profile-note { display: flex; gap: 12px; padding: 16px; border: 1px solid #cde6f7; border-radius: 20px; background: #f6fbff; }
.tickets-profile-note > div { min-width: 0; }
.customer-tickets .tickets-profile-note h2 { font-size: 16px; margin: 0; }
.tickets-profile-note p { color: #526c86; font-size: 14px; line-height: 1.8; }
.tickets-profile-note .service-icon { width: 36px; height: 36px; flex-shrink: 0; }
.tickets-profile-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 12px; }
.customer-tickets .tickets-text-link { display: inline-flex; align-items: center; min-height: 44px; color: #08376b; text-decoration: underline; text-underline-offset: 4px; }
.customer-tickets .customer-list-heading { scroll-margin-top: 100px; flex-wrap: wrap; }
.tickets-list-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.tickets-list-card { display: flex; flex-direction: column; min-width: 0; padding: 16px; border: 1px solid #dce5ed; border-radius: 20px; background: #fff; color: #08376b; transition: border-color .18s, transform .18s, box-shadow .18s; }
.tickets-card-top { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; }
.tickets-status { padding: 4px 12px; border-radius: 20px; background: #e5f4ff; color: #08376b; font-size: 12px; font-weight: 700; }
.tickets-number { color: #526c86; font-size: 12px; }
.customer-tickets .tickets-list-card h3 { margin: 16px 0; font-size: 18px; font-weight: 800; line-height: 1.7; }
.tickets-card-meta { display: flex; flex-direction: column; gap: 4px; margin-top: auto; padding-top: 12px; border-top: 1px solid #e7edf2; color: #526c86; font-size: 14px; }
.tickets-card-meta time { font-size: 12px; margin-top: 4px; }
.tickets-card-link { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-top: 12px; font-size: 14px; font-weight: 700; }
.tickets-card-link svg { width: 18px; height: 18px; }
.tickets-list-card:hover { border-color: #91bfdd; transform: translateY(-3px); box-shadow: 0 15px 35px -25px #08376b70; }
.tickets-empty { grid-column: 1 / -1; margin: 0; }
.customer-tickets .tickets-empty h3 { font-size: 18px; margin: 12px 0 0; }
.tickets-empty .pay-button { margin-top: 12px; }
@media (min-width: 640px) {
.tickets-list-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.tickets-list-card { padding: 22px; border-radius: 22px; }
}
@media (max-width: 639px) {
.customer-tickets .tickets-intro .service-actions { padding-top: 10px; }
.tickets-profile-note { padding: 14px; }
.tickets-profile-note > .service-icon { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tickets-list-card { transition: none; }
.tickets-list-card:hover { transform: none; }
}
+263
View File
@@ -0,0 +1,263 @@
/* Scoped to group dispatch pages; the staff shell and ticket conversation keep their own styles. */
.dispatch-response-banner { padding: 24px; margin: 16px 0; background: #fff; border: 1px solid #dce5ed; border-radius: 16px; color: #08376b; }
.dispatch-response-banner p, .dispatch-response-options p, .opening-sms-preview { font-size: 14px; line-height: 1.9; overflow-wrap: anywhere; }
.dispatch-response-options fieldset { margin: 16px; padding: 16px; border: 1px solid #dce5ed; border-radius: 12px; }
.dispatch-response-options fieldset label, .dispatch-sms-choice { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; }
.dispatch-response-options input[type="radio"], .dispatch-response-options input[type="checkbox"] { width: 20px; height: 20px; min-height: 20px; flex: 0 0 auto; }
.dispatch-response-options > p, .dispatch-response-options > small, .dispatch-sms-choice { margin: 12px 16px; }
.dispatch-open-confirm { max-width: 900px; margin: auto; }
.opening-sms-preview { padding: 20px; margin: 16px 0; background: #f6fbff; border-inline-start: 3px solid #1977ce; }
.dispatch-opening-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.dispatch-workspace {
--dispatch-ink: #08376b;
--dispatch-muted: #526c86;
--dispatch-line: #dce5ed;
--dispatch-soft: #f6f9fc;
color: var(--dispatch-ink);
font-size: 1rem;
gap: 1.25rem;
padding-bottom: 1rem;
}
.dispatch-workspace *, .dispatch-workspace *::before, .dispatch-workspace *::after { box-sizing: border-box; }
.dispatch-workspace [hidden] { display: none !important; }
.dispatch-workspace h1, .dispatch-workspace h2, .dispatch-workspace h3, .dispatch-workspace p { overflow-wrap: anywhere; }
.dispatch-workspace :is(span, small, dt, dd) { overflow-wrap: anywhere; }
.dispatch-workspace .dispatch-icon { width: 1.25rem; height: 1.25rem; flex: none; vertical-align: middle; }
.dispatch-workspace a, .dispatch-workspace button, .dispatch-workspace input, .dispatch-workspace select, .dispatch-workspace textarea, .dispatch-workspace summary { -webkit-tap-highlight-color: transparent; }
.dispatch-workspace :is(a, button, input, select, textarea, summary):focus-visible { outline: 3px solid #1977ce; outline-offset: 3px; }
.dispatch-workspace .dispatch-eyebrow { margin: 0 0 .35rem; color: var(--dispatch-muted); font-size: .75rem; font-weight: 700; }
.dispatch-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; }
.dispatch-page-heading h1 { margin: 0; font-size: 1.75rem; font-weight: 900; line-height: 1.5; }
.dispatch-page-heading p:not(.dispatch-eyebrow) { margin: .45rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.8; }
.dispatch-privacy { display: flex; align-items: center; gap: .6rem; max-width: 18rem; border: 1px solid #c9e2d5; border-radius: .75rem; padding: .75rem; color: #166347; background: #f2faf6; font-size: .75rem; line-height: 1.8; }
.dispatch-summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #fff; padding: 1rem 0; }
.dispatch-summary-strip > div { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem; padding: 0 1.25rem; }
.dispatch-summary-strip > div + div { border-inline-start: 1px solid var(--dispatch-line); }
.dispatch-summary-strip span { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.7; }
.dispatch-summary-strip strong { font-size: 1.5rem; font-weight: 900; }
.dispatch-view-switch { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; border-bottom: 1px solid #c6d4e0; padding-bottom: .75rem; }
.dispatch-view-switch a { display: inline-flex; align-items: center; gap: .5rem; min-height: 2.75rem; border: 1px solid transparent; border-radius: .75rem; padding: .6rem 1rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; text-decoration: none; }
.dispatch-view-switch a[aria-current="true"] { border-color: var(--dispatch-ink); background: var(--dispatch-ink); color: #fff; }
.dispatch-view-switch a:not([aria-current="true"]):hover { background: #fff; border-color: var(--dispatch-line); }
.dispatch-view-switch a > span { border-radius: 1rem; padding: .1rem .5rem; background: #e5f4ff; color: var(--dispatch-ink); font-size: .75rem; }
.dispatch-compose-layout { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; align-items: start; gap: 1.25rem; }
.dispatch-workspace .dispatch-composer { gap: 1rem; min-width: 0; }
.dispatch-workspace .dispatch-composer > section { border-color: var(--dispatch-line); border-radius: 1.25rem; box-shadow: none; }
.dispatch-workspace .dispatch-composer > section > header { padding: 1.25rem; background: #fff; }
.dispatch-workspace .dispatch-composer > section > header > span { width: 2.25rem; height: 2.25rem; background: #e5f4ff; color: var(--dispatch-ink); font-size: .875rem; }
.dispatch-workspace .dispatch-composer h2 { font-size: 1.125rem; font-weight: 900; }
.dispatch-workspace .dispatch-composer header p { margin: .3rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.7; }
.dispatch-workspace .editor-grid { gap: 1rem; padding: 1.25rem; }
.dispatch-workspace .editor-grid label, .dispatch-workspace .message-editor { gap: .5rem; font-size: .875rem; font-weight: 700; color: var(--dispatch-ink); }
.dispatch-workspace .editor-grid em, .dispatch-workspace .message-editor em { margin-inline-start: .4rem; color: var(--dispatch-muted); font-size: .75rem; font-weight: 400; }
.dispatch-workspace :is(.editor-grid input, .editor-grid select, .message-editor textarea, .dispatch-tags select, .dispatch-filter input, .dispatch-filter select, .dispatch-recipient-tools input, .dispatch-recipient-tools select) { width: 100%; min-height: 2.875rem; border: 1px solid #aabed0; border-radius: .75rem; background: #fff; padding: .65rem .75rem; color: var(--dispatch-ink); font: inherit; font-size: 1rem; box-shadow: none; }
.dispatch-workspace select { cursor: pointer; }
.dispatch-workspace select:disabled { background: #f2f5f8; color: var(--dispatch-muted); cursor: default; }
.dispatch-workspace .editor-grid small, .dispatch-workspace .message-editor small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; font-weight: 400; }
.dispatch-workspace .message-editor { margin: 0 1.25rem 1.25rem; }
.dispatch-workspace .message-editor textarea { min-height: 9rem; height: 10rem; resize: vertical; line-height: 1.9; }
.dispatch-workspace .dispatch-tags { margin: 0 1.25rem 1.25rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; }
.dispatch-workspace summary { min-height: 2.75rem; padding: .65rem .8rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 700; cursor: pointer; }
.dispatch-tags summary span { color: var(--dispatch-muted); font-size: .75rem; font-weight: 400; margin-inline-start: .5rem; }
.dispatch-tags label { display: grid; gap: .5rem; padding: 0 .8rem .8rem; font-size: .875rem; }
.dispatch-tags small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-workspace .dispatch-tags select { min-height: 5rem; }
.dispatch-workspace .dispatch-attachment-picker { margin: 0 1.25rem 1.25rem; padding: 1rem; border-radius: 1rem; background: #f6fbff; }
.dispatch-workspace .dispatch-attachment-picker b { color: var(--dispatch-ink); font-size: .875rem; }
.dispatch-workspace .dispatch-attachment-picker b small { display: inline; margin-inline-start: .5rem; }
.dispatch-workspace .dispatch-attachment-picker small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-workspace .media-picker-button { display: flex; justify-content: center; align-items: center; min-height: 2.75rem; border: 1px solid #9ebbd1; border-radius: .75rem; padding: .6rem .8rem; background: #fff; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; cursor: pointer; }
.dispatch-workspace .media-picker-button:has(+ input:focus-visible) { outline: 3px solid #1977ce; outline-offset: 3px; }
.dispatch-workspace .media-picker-button:focus-visible { outline: 3px solid #1977ce; outline-offset: 3px; }
.dispatch-workspace .file-chip { max-width: 100%; box-shadow: none; }
.dispatch-workspace .file-chip small { overflow-wrap: anywhere; }
.dispatch-workspace .file-chip button { min-width: 2.75rem; min-height: 2.75rem; font-size: 1.25rem; }
.dispatch-workspace .dispatch-composer > footer { padding: 1rem 1.25rem; border-radius: 1.25rem; box-shadow: none; }
.dispatch-workspace .dispatch-composer footer b { font-size: .875rem; }
.dispatch-workspace .dispatch-composer footer small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-workspace .dispatch-primary, .dispatch-workspace .dispatch-secondary, .dispatch-workspace .dispatch-composer footer button, .dispatch-workspace .dispatch-filter button, .dispatch-workspace .dispatch-reply-link { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 2.75rem; border: 1px solid var(--dispatch-ink); border-radius: .75rem; padding: .65rem 1rem; background: var(--dispatch-ink); color: #fff; font: inherit; font-size: .875rem; font-weight: 800; text-decoration: none; cursor: pointer; box-shadow: none; line-height: 1.7; }
.dispatch-workspace .dispatch-primary:hover, .dispatch-workspace .dispatch-composer footer button:hover { background: #0b4c8c; transform: none; }
.dispatch-workspace .dispatch-secondary { border-color: #aabed0; background: #fff; color: var(--dispatch-ink); }
.dispatch-workspace .dispatch-secondary:hover { border-color: var(--dispatch-ink); background: var(--dispatch-soft); transform: none; }
.dispatch-workspace button:disabled { cursor: wait; opacity: .65; }
.dispatch-compose-guide { border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #f8fbfe; padding: 1.25rem; }
.dispatch-guide-icon { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; border-radius: .875rem; background: #e5f4ff; }
.dispatch-compose-guide h2 { margin: .85rem 0 .4rem; font-size: 1.125rem; font-weight: 900; }
.dispatch-compose-guide p { font-size: .875rem; line-height: 1.9; color: var(--dispatch-muted); }
.dispatch-compose-guide dl { display: grid; gap: 1rem; margin: 1.25rem 0; }
.dispatch-compose-guide dt, .dispatch-review-meta dt, .dispatch-operation-details dt { color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-compose-guide dd, .dispatch-review-meta dd, .dispatch-operation-details dd { margin: .25rem 0 0; font-size: .875rem; font-weight: 800; overflow-wrap: anywhere; }
.dispatch-compose-guide .dispatch-guide-note { display: flex; align-items: flex-start; gap: .5rem; margin: 0; border-top: 1px solid var(--dispatch-line); padding-top: 1rem; font-size: .75rem; }
.dispatch-workspace .dispatch-history { position: static; border-color: var(--dispatch-line); border-radius: 1.25rem; }
.dispatch-workspace .dispatch-history > header { gap: 1rem; padding: 1.25rem; }
.dispatch-workspace .dispatch-history h2, .dispatch-workspace .stream-head h2 { margin: 0; font-size: 1.125rem; font-weight: 900; }
.dispatch-workspace .dispatch-history header p, .dispatch-workspace .stream-head p { color: var(--dispatch-muted); margin: .35rem 0 0; font-size: .875rem; line-height: 1.8; }
.dispatch-workspace .dispatch-history header > span { display: block; width: auto; height: auto; flex: none; padding: .3rem .75rem; background: #e5f4ff; font-size: .75rem; border-radius: 1rem; }
.dispatch-workspace .dispatch-filter { grid-template-columns: minmax(12rem, 2fr) minmax(10rem, 1fr); align-items: end; gap: 1rem; padding: 1.25rem; background: var(--dispatch-soft); }
.dispatch-workspace .dispatch-filter label, .dispatch-workspace .dispatch-recipient-tools label { display: grid; gap: .4rem; font-size: .875rem; font-weight: 700; color: var(--dispatch-ink); }
.dispatch-workspace .dispatch-filter > footer { justify-content: flex-start; grid-column: 1 / -1; }
.dispatch-workspace .dispatch-filter button { flex: none; }
.dispatch-workspace .dispatch-filter footer a { display: inline-flex; align-items: center; min-height: 2.75rem; padding: .5rem; color: var(--dispatch-ink); font-size: .875rem; }
.dispatch-more-filters { grid-column: 1 / -1; }
.dispatch-more-filters > div { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; margin-top: .5rem; }
.dispatch-workspace .dispatch-results { max-height: none; overflow: visible; }
.dispatch-workspace .dispatch-history-card { grid-template-columns: minmax(0, 2fr) auto auto minmax(10rem, 1fr) auto; gap: 1rem; padding: 1.25rem; text-decoration: none; }
.dispatch-workspace .dispatch-history-card b { white-space: normal; overflow: visible; font-size: 1rem; color: var(--dispatch-ink); }
.dispatch-workspace .dispatch-history-card small, .dispatch-workspace .dispatch-history-card time, .dispatch-history-count { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-history-open { display: flex; align-items: center; gap: .4rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; }
.dispatch-workspace .operations-pagination { padding: 1rem 1.25rem; font-size: .875rem; }
.dispatch-workspace .operations-pagination a { display: inline-flex; min-height: 2.75rem; align-items: center; padding: .5rem .85rem; color: var(--dispatch-ink); }
.dispatch-empty { padding: 2rem 1rem; text-align: center; }
.dispatch-empty > svg { width: 2rem; height: 2rem; color: var(--dispatch-muted); }
.dispatch-empty h3 { margin: .65rem 0; font-size: 1.125rem; }
.dispatch-empty p { color: var(--dispatch-muted); font-size: .875rem; line-height: 1.8; }
.dispatch-error-summary { border: 1px solid #dfa5a5; border-radius: .875rem; padding: 1rem; background: #fff6f6; color: #9f1f1f; font-size: .875rem; line-height: 1.9; }
.dispatch-error-summary a { color: #9f1f1f; text-decoration: underline; }
.dispatch-workspace .errorlist { color: #9f1f1f; font-size: .875rem; line-height: 1.8; }
.dispatch-workspace [aria-invalid="true"] { border-color: #9f1f1f; }
.dispatch-review-dialog { width: min(42rem, calc(100% - 1.5rem)); max-height: calc(100dvh - 2rem); margin: auto; border: 1px solid var(--dispatch-line); border-radius: 1.25rem; padding: 1.5rem; color: var(--dispatch-ink); background: #fff; overflow-y: auto; box-shadow: 0 1.5rem 5rem #08376b33; }
.dispatch-review-dialog::backdrop { background: #08284880; }
.dispatch-review-dialog header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.dispatch-review-dialog h2 { margin: 0; font-size: 1.25rem; font-weight: 900; }
.dispatch-workspace .dispatch-dialog-close { display: grid; place-items: center; flex: none; min-width: 2.75rem; height: 2.75rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; background: #fff; color: var(--dispatch-ink); font-size: 1.5rem; padding: 0; box-shadow: none; }
.dispatch-review-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.5rem 0; padding: 1rem; background: var(--dispatch-soft); border-radius: .875rem; }
.dispatch-review-dialog h3 { margin: 1rem 0 .5rem; font-size: 1.125rem; }
.dispatch-review-body { white-space: pre-wrap; font-size: 1rem; line-height: 1.9; }
.dispatch-review-warning { padding: 1rem; border: 1px solid #efd8ad; border-radius: .75rem; background: #fff8eb; color: #805300; font-size: .875rem; line-height: 1.9; }
.dispatch-inquiry-recommendation { padding: 1rem; border: 1px solid #f2b5bb; border-inline-start: 4px solid #b42332; border-radius: .75rem; background: #fff2f3; color: #a11727; font-size: .875rem; line-height: 1.9; }
.dispatch-inquiry-recommendation p { margin: .5rem 0 0; }
.dispatch-review-dialog footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .75rem; margin-top: 1.25rem; }
.dispatch-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; font-size: .75rem; color: var(--dispatch-muted); }
.dispatch-breadcrumb a { display: inline-flex; align-items: center; gap: .4rem; min-height: 2.75rem; color: var(--dispatch-ink); font-size: .875rem; }
.dispatch-workspace .dispatch-report-hero { align-items: center; border: 0; border-radius: 0; padding: 0; background: transparent; box-shadow: none; }
.dispatch-workspace .dispatch-report-title { min-width: 0; }
.dispatch-workspace .dispatch-report-title h1 { color: var(--dispatch-ink); font-size: 1.75rem; line-height: 1.6; font-weight: 900; }
.dispatch-workspace .dispatch-title-line { flex-wrap: wrap; }
.dispatch-workspace .report-status { padding: .35rem .7rem; font-size: .75rem; font-weight: 800; background: #eaf0f5; color: #405d75; }
.dispatch-workspace .report-status.status-sent { background: #e2f5ea; color: #166347; }
.dispatch-workspace .report-status.status-partial { background: #fff0e1; color: #805300; }
.dispatch-workspace .dispatch-meta-list span { border: 0; background: transparent; padding: 0; color: var(--dispatch-muted); font-size: .75rem; flex-wrap: wrap; }
.dispatch-workspace .dispatch-meta-list span + span { border-inline-start: 1px solid #c6d4e0; padding-inline-start: .75rem; }
.dispatch-workspace .dispatch-kpi-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; overflow: visible; }
.dispatch-workspace .dispatch-kpi { min-width: 0; align-items: flex-start; gap: .5rem; border-radius: 1rem; padding: 1rem; box-shadow: none; }
.dispatch-workspace .dispatch-kpi > svg { color: var(--dispatch-ink); margin-top: .25rem; }
.dispatch-workspace .dispatch-kpi small { color: var(--dispatch-ink); font-size: .875rem; }
.dispatch-workspace .dispatch-kpi strong { color: var(--dispatch-ink); font-size: 1.5rem; font-weight: 900; }
.dispatch-workspace .dispatch-kpi p { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-workspace .delivery-overview { border-color: var(--dispatch-line); border-radius: 1rem; padding: 1rem 1.25rem; }
.dispatch-workspace .delivery-progress-copy { flex-wrap: wrap; }
.dispatch-workspace .delivery-progress-copy strong { color: var(--dispatch-ink); font-size: 1rem; }
.dispatch-workspace .delivery-progress-copy p, .dispatch-workspace .delivery-overview-note span { color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-workspace .delivery-progress-copy span, .dispatch-workspace .delivery-overview-note b { font-size: .875rem; color: var(--dispatch-ink); }
.dispatch-workspace .delivery-progress-track { height: .375rem; background: #e7edf2; box-shadow: none; }
.dispatch-workspace .delivery-progress-track span { background: #16815c; }
.dispatch-workspace .dispatch-original-message { gap: .85rem; padding: 1.25rem; border-color: var(--dispatch-line); border-radius: 1.25rem; background: #fff; box-shadow: none; }
.dispatch-workspace .dispatch-original-message h2 { color: var(--dispatch-ink); font-size: 1.125rem; }
.dispatch-workspace .dispatch-original-message header > span { direction: rtl; color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-workspace .dispatch-original-message blockquote { border-inline-start: 3px solid #1977ce; border-right: 0; background: var(--dispatch-soft); color: var(--dispatch-ink); font-size: 1rem; line-height: 1.9; padding: .75rem 1rem; overflow-wrap: anywhere; }
.dispatch-operation-details { border-top: 1px solid var(--dispatch-line); }
.dispatch-operation-details dl { display: flex; flex-wrap: wrap; gap: 1rem 2rem; padding: 0 .8rem; margin: .4rem 0; }
.dispatch-workspace .dispatch-recipient-report { border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #fff; overflow: hidden; }
.dispatch-workspace .stream-head { align-items: flex-start; gap: 1rem; padding: 1.25rem; background: #fff; }
.dispatch-workspace .stream-head > span { flex: none; color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-recipient-tools { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) auto; gap: .75rem; align-items: end; border-block: 1px solid var(--dispatch-line); padding: 1rem 1.25rem; background: var(--dispatch-soft); }
.dispatch-recipient-tools > p { grid-column: 1 / -1; margin: 0; color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-workspace .table-scroll { overflow: visible; }
.dispatch-workspace .dispatch-detail-table { width: 100%; min-width: 0; table-layout: fixed; font-size: .875rem; }
.dispatch-workspace .dispatch-detail-table th { padding: .8rem 1rem; color: var(--dispatch-muted); font-size: .75rem; background: var(--dispatch-soft); text-align: right; }
.dispatch-detail-table th:nth-child(1) { width: 20%; }
.dispatch-detail-table th:nth-child(2) { width: 26%; }
.dispatch-detail-table th:nth-child(3) { width: 14%; }
.dispatch-detail-table th:nth-child(4) { width: 40%; }
.dispatch-workspace .dispatch-detail-table td { padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
.dispatch-workspace .recipient-cell { min-width: 0; }
.dispatch-workspace .recipient-cell strong, .dispatch-workspace .ticket-reference strong, .dispatch-workspace .sms-state-card strong, .dispatch-workspace .engagement-state strong { color: var(--dispatch-ink); font-size: .875rem; }
.dispatch-workspace .recipient-cell small, .dispatch-workspace .sms-state-card p, .dispatch-workspace .sms-state-card small, .dispatch-workspace .engagement-state small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-workspace .ticket-reference { min-height: 2.75rem; align-content: center; margin-top: .25rem; }
.dispatch-workspace .ticket-reference strong { white-space: normal; overflow-wrap: anywhere; font-weight: 800; }
.dispatch-workspace .ticket-state-pill { color: var(--dispatch-ink); font-size: .75rem; white-space: normal; }
.dispatch-workspace .dispatch-sms-state { min-width: 0; }
.dispatch-workspace .sms-state-card { padding: .75rem; }
.dispatch-workspace .sms-state-card > div { min-width: 0; }
.dispatch-workspace .sms-state-icon { width: 1.5rem; height: 1.5rem; background: transparent; }
.dispatch-workspace .sms-technical-details, .dispatch-workspace .sms-error-panel { font-size: .875rem; }
.dispatch-workspace .sms-technical-details dt { color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-workspace .sms-technical-details dd { font-size: .875rem; }
.dispatch-workspace .inline-retry-form button { min-height: 2.75rem; border-radius: .75rem; padding: .5rem; color: #9f1f1f; font-size: .875rem; }
.dispatch-workspace .engagement-state { min-width: 0; gap: .5rem; }
.dispatch-workspace .engagement-state > svg { color: #526c86; }
.dispatch-workspace .engagement-state.has-engagement > svg { color: #5c369d; }
.dispatch-workspace .recipient-reply { min-width: 0; margin-top: .75rem; padding: .875rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; background: #f6fbff; }
.dispatch-workspace .recipient-reply p { color: var(--dispatch-ink); font-size: .875rem; line-height: 1.9; overflow-wrap: anywhere; }
.dispatch-workspace .recipient-reply time { color: var(--dispatch-muted); font-size: .75rem; }
.dispatch-response-files { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
.dispatch-response-files a { font-size: .8rem; overflow-wrap: anywhere; text-decoration: underline; }
.dispatch-workspace .recipient-reply small, .dispatch-workspace .no-reply-state { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-workspace .no-reply-state { margin: 0; }
.dispatch-workspace .dispatch-reply-link { margin-top: .75rem; width: 2.25rem; min-height: 2.25rem; padding: .4rem; border-color: var(--dispatch-line); background: #f6fbff; color: var(--dispatch-ink); }
.dispatch-management { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 1.25rem; border: 1px solid var(--dispatch-line); border-radius: 1.25rem; padding: 1.25rem; background: #fff; }
.dispatch-management h2 { margin: 0; font-size: 1.125rem; font-weight: 900; }
.dispatch-management p { margin: .4rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.9; }
.dispatch-management-actions { display: grid; gap: .75rem; }
.dispatch-management form { margin: 0; }
.dispatch-management button { width: 100%; }
.dispatch-management small { display: block; margin-top: .4rem; color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; }
.dispatch-management .dispatch-complete { display: flex; align-items: flex-start; gap: .5rem; margin: 0; color: #166347; }
.dispatch-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 1200px) {
.dispatch-workspace .dispatch-history-card { grid-template-columns: minmax(0, 1fr) auto; gap: .6rem 1rem; }
.dispatch-history-main { grid-column: 1; }
.dispatch-history-card time { grid-column: 1; }
.dispatch-workspace .dispatch-kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dispatch-workspace .dispatch-detail-table thead { display: none; }
.dispatch-workspace .dispatch-detail-table, .dispatch-workspace .dispatch-detail-table tbody { display: block; }
.dispatch-workspace .dispatch-detail-table tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: .5rem solid var(--dispatch-soft); }
.dispatch-workspace .dispatch-detail-table td { display: block; width: auto; border-bottom: 1px solid var(--dispatch-line); }
.dispatch-workspace .dispatch-detail-table td::before { display: block; margin-bottom: .6rem; content: attr(data-label); color: var(--dispatch-muted); font-size: .75rem; font-weight: 700; }
}
@media (max-width: 1000px) {
.dispatch-compose-layout { grid-template-columns: minmax(0, 1fr); }
.dispatch-compose-guide { padding: 1rem; }
.dispatch-compose-guide dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dispatch-more-filters > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
.dispatch-workspace { gap: 1rem; }
.dispatch-page-heading { align-items: stretch; flex-direction: column; gap: .75rem; }
.dispatch-page-heading h1, .dispatch-workspace .dispatch-report-title h1 { font-size: 1.5rem; }
.dispatch-privacy { max-width: none; }
.dispatch-summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 0; }
.dispatch-summary-strip > div { min-height: 4rem; padding: .75rem; }
.dispatch-summary-strip > div:nth-child(3) { border-inline-start: 0; }
.dispatch-summary-strip > div:nth-child(n+3) { border-top: 1px solid var(--dispatch-line); }
.dispatch-view-switch a { flex: 1; justify-content: center; padding-inline: .5rem; }
.dispatch-workspace .dispatch-composer > section > header, .dispatch-workspace .editor-grid { padding: 1rem; }
.dispatch-workspace .editor-grid { grid-template-columns: minmax(0, 1fr); }
.dispatch-workspace .message-editor, .dispatch-workspace .dispatch-tags, .dispatch-workspace .dispatch-attachment-picker { margin-inline: 1rem; }
.dispatch-workspace .dispatch-composer > footer { align-items: stretch; flex-direction: column; gap: .75rem; padding: 1rem; }
.dispatch-workspace .dispatch-composer footer button { width: 100%; }
.dispatch-compose-guide dl { grid-template-columns: 1fr; }
.dispatch-workspace .dispatch-filter { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
.dispatch-workspace .dispatch-history > header { flex-wrap: wrap; padding: 1rem; }
.dispatch-workspace .dispatch-history-card { padding: 1rem; }
.dispatch-more-filters > div { grid-template-columns: minmax(0, 1fr); }
.dispatch-review-dialog { padding: 1rem; }
.dispatch-review-dialog footer { flex-direction: column-reverse; }
.dispatch-review-meta { grid-template-columns: minmax(0, 1fr); }
.dispatch-workspace .dispatch-report-hero { display: grid; }
.dispatch-workspace .dispatch-meta-list span + span { border: 0; padding: 0; }
.dispatch-workspace .dispatch-meta-list { display: grid; gap: .4rem; }
.dispatch-workspace .dispatch-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dispatch-workspace .dispatch-kpi { padding: .85rem; }
.dispatch-workspace .dispatch-kpi > svg { display: none; }
.dispatch-workspace .dispatch-original-message { padding: 1rem; }
.dispatch-workspace .dispatch-original-message header { display: grid; gap: .5rem; }
.dispatch-workspace .stream-head { flex-wrap: wrap; padding: 1rem; }
.dispatch-recipient-tools { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
.dispatch-workspace .dispatch-detail-table tr { grid-template-columns: minmax(0, 1fr); }
.dispatch-workspace .dispatch-detail-table td { padding: 1rem; }
.dispatch-management { grid-template-columns: minmax(0, 1fr); padding: 1rem; }
}
@media (prefers-reduced-motion: reduce) {
.dispatch-workspace *, .dispatch-workspace *::before, .dispatch-workspace *::after { transition: none; animation: none; scroll-behavior: auto; }
}
+11
View File
@@ -1,4 +1,5 @@
/* Temporary payment access follows the Chaharsu style guide. */ /* Temporary payment access follows the Chaharsu style guide. */
.pay-page-share{margin:12px 0 18px;padding-top:16px;border-top:1px solid #e7edf2}.pay-page-share>.pay-button{display:flex;gap:10px;max-width:100%;min-height:48px;white-space:normal;text-align:start;line-height:1.8}.pay-page-share>.pay-button:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.pay-page-share>.pay-button:disabled{opacity:.65;cursor:wait}.pay-page-share p{margin:8px 0 0;color:#526c86;font-size:13px;line-height:1.9}.pay-page-share .pay-share-feedback:empty{display:none}@media(max-width:639px){.pay-page-share>.pay-button{width:100%;justify-content:center}}
.pay-share-url{overflow-wrap:anywhere;font-size:14px;line-height:1.8;padding:12px;border:1px solid #dce5ed;border-radius:12px;background:#f6fbff}.pay-detail-aside .pay-card:has(.pay-share-url) form{margin-top:12px} .pay-share-url{overflow-wrap:anywhere;font-size:14px;line-height:1.8;padding:12px;border:1px solid #dce5ed;border-radius:12px;background:#f6fbff}.pay-detail-aside .pay-card:has(.pay-share-url) form{margin-top:12px}
.pay-link-status{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 16px;margin-bottom:24px;padding:14px 18px;border:1px solid #cde6f7;border-radius:14px;background:#f6fbff;color:#08376b;line-height:1.8} .pay-link-status{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 16px;margin-bottom:24px;padding:14px 18px;border:1px solid #cde6f7;border-radius:14px;background:#f6fbff;color:#08376b;line-height:1.8}
.pay-link-status>span{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700}.pay-link-status .cs-icon{width:20px;height:20px;flex-shrink:0}.pay-link-status small{color:#526c86;font-size:14px}.pay-link-session-label{font-size:14px;color:#526c86} .pay-link-status>span{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700}.pay-link-status .cs-icon{width:20px;height:20px;flex-shrink:0}.pay-link-status small{color:#526c86;font-size:14px}.pay-link-session-label{font-size:14px;color:#526c86}
@@ -8,3 +9,13 @@
.customer-receipt-form #selected-payment .pay-deposit-warning{margin-top:12px;color:#9f1f1f;font-size:14px}.customer-receipt-form #id_deposit_id.pay-deposit-emphasis{color:#9f1f1f;border-color:#d39090;font-weight:700} .customer-receipt-form #selected-payment .pay-deposit-warning{margin-top:12px;color:#9f1f1f;font-size:14px}.customer-receipt-form #id_deposit_id.pay-deposit-emphasis{color:#9f1f1f;border-color:#d39090;font-weight:700}
.pay-link-message{max-width:620px;margin:48px auto}.pay-link-message .pay-card{padding:32px;border-radius:23px}.pay-link-message h1{margin:20px 0 12px;font-size:24px}.pay-link-message p{color:#526c86;font-size:16px;line-height:1.9;margin-bottom:24px}.pay-link-message .pay-actions{gap:12px;flex-wrap:wrap} .pay-link-message{max-width:620px;margin:48px auto}.pay-link-message .pay-card{padding:32px;border-radius:23px}.pay-link-message h1{margin:20px 0 12px;font-size:24px}.pay-link-message p{color:#526c86;font-size:16px;line-height:1.9;margin-bottom:24px}.pay-link-message .pay-actions{gap:12px;flex-wrap:wrap}
@media(max-width:639px){.pay-link-message{margin:24px auto}.pay-link-message .pay-card{padding:24px 18px}.pay-link-status{padding:12px 14px;margin-bottom:18px}.pay-link-session-label{display:none}.pay-link-message .pay-button{width:100%}} @media(max-width:639px){.pay-link-message{margin:24px auto}.pay-link-message .pay-card{padding:24px 18px}.pay-link-status{padding:12px 14px;margin-bottom:18px}.pay-link-session-label{display:none}.pay-link-message .pay-button{width:100%}}
/* Receipt action is emphasized only on the customer's individual notice page. */
.customer-notice-detail .pay-receipt-guidance{margin:0 0 20px;font-size:14px;line-height:1.8;color:inherit}
.customer-notice-detail .pay-receipt-next-step{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-block:20px;padding:18px 20px;border:1px solid #cde6f7;border-radius:16px;background:#f6fbff}
.customer-notice-detail .pay-receipt-next-step>div{min-width:0}
.customer-notice-detail .pay-receipt-next-step h2{margin:0 0 4px;font-size:18px;font-weight:800}
.customer-notice-detail .pay-receipt-next-step p{margin:0;color:#526c86;font-size:14px;line-height:1.8}
.customer-notice-detail .pay-receipt-action{gap:10px;min-height:52px;padding:12px 20px;font-size:16px;flex-shrink:0}
.customer-notice-detail .pay-receipt-action .cs-icon{width:22px;height:22px}
@media(max-width:639px){.customer-notice-detail .pay-receipt-next-step{flex-direction:column;align-items:stretch;gap:16px;padding:16px}.customer-notice-detail .pay-receipt-action{width:100%}}
+1 -1
View File
@@ -48,7 +48,7 @@
const data = await response.json(); const data = await response.json();
if (request !== controller || customer.value !== selectedCustomer) return; if (request !== controller || customer.value !== selectedCustomer) return;
if (!response.ok) {failureMessage = data.error || 'شناسه تولید نشد؛ دوباره تلاش کنید.'; throw new Error('generation');} if (!response.ok) {failureMessage = data.error || 'شناسه تولید نشد؛ دوباره تلاش کنید.'; throw new Error('generation');}
if (data.customer !== selectedCustomer || !/^[0-9]{13}$/.test(data.identifier)) {failureMessage = 'پاسخ سیستم معتبر نیست؛ دوباره تلاش کنید.'; throw new Error('generation');} if (data.customer !== selectedCustomer || !/^9[0-9]{11}$/.test(data.identifier)) {failureMessage = 'پاسخ سیستم معتبر نیست؛ دوباره تلاش کنید.'; throw new Error('generation');}
if (input.value.trim() && input.value !== data.identifier) { if (input.value.trim() && input.value !== data.identifier) {
pending = data.identifier; candidate.textContent = pending; replacement.hidden = false; pending = data.identifier; candidate.textContent = pending; replacement.hidden = false;
status.textContent = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.'; status.textContent = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.';
+26 -1
View File
@@ -4,15 +4,40 @@
let toast; let toast;
function status(text){if(!toast){toast=document.createElement('div');toast.className='pay-copy-status';toast.setAttribute('role','status');document.body.append(toast);}toast.textContent=text;toast.hidden=false;setTimeout(()=>{toast.hidden=true;},3000);} function status(text){if(!toast){toast=document.createElement('div');toast.className='pay-copy-status';toast.setAttribute('role','status');document.body.append(toast);}toast.textContent=text;toast.hidden=false;setTimeout(()=>{toast.hidden=true;},3000);}
async function copy(text){ async function copy(text){
try{await navigator.clipboard.writeText(text);status('کپی شد');} try{await navigator.clipboard.writeText(text);status('کپی شد');return true;}
catch{ catch{
const dialog=document.createElement('dialog');dialog.className='pay-manual-copy';dialog.setAttribute('aria-label','کپی اطلاعات'); const dialog=document.createElement('dialog');dialog.className='pay-manual-copy';dialog.setAttribute('aria-label','کپی اطلاعات');
const title=document.createElement('h2');title.textContent='متن را انتخاب و کپی کنید';const area=document.createElement('textarea');area.readOnly=true;area.value=text; const title=document.createElement('h2');title.textContent='متن را انتخاب و کپی کنید';const area=document.createElement('textarea');area.readOnly=true;area.value=text;
const select=document.createElement('button');select.className='pay-button';select.textContent='انتخاب متن';select.onclick=()=>{area.focus();area.select();}; const select=document.createElement('button');select.className='pay-button';select.textContent='انتخاب متن';select.onclick=()=>{area.focus();area.select();};
const close=document.createElement('button');close.className='pay-button secondary';close.textContent='بستن';close.onclick=()=>dialog.close();dialog.append(title,area,select,close);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();area.focus();area.select(); const close=document.createElement('button');close.className='pay-button secondary';close.textContent='بستن';close.onclick=()=>dialog.close();dialog.append(title,area,select,close);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();area.focus();area.select();
return false;
} }
} }
document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy))); document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy)));
document.querySelectorAll('[data-notice-sms]').forEach(form=>form.addEventListener('submit',event=>{
if(form.dataset.sending==='true'){event.preventDefault();return;}
form.dataset.sending='true';const button=form.querySelector('button');button.disabled=true;button.textContent='در حال ثبت درخواست ارسال…';
}));
document.querySelectorAll('[data-share-link]').forEach(form=>{
const button=form.querySelector('button'),label=button.querySelector('span'),originalLabel=label.textContent;
const feedback=form.querySelector('[role="status"]');
form.addEventListener('submit',async event=>{
event.preventDefault();
// This AJAX form owns its button state; keep the generic navigation submit handler out.
event.stopPropagation();
if(button.disabled)return;
button.disabled=true;button.setAttribute('aria-busy','true');label.textContent='در حال آماده‌سازی لینک…';feedback.textContent='در حال آماده‌سازی لینک…';
try{
const response=await fetch(form.action,{method:'POST',body:new FormData(form),credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}});
if(!response.ok || !response.headers.get('Content-Type')?.includes('application/json'))throw new Error('share unavailable');
const data=await response.json();
const copied=await copy(data.url);
label.textContent=copied?'لینک کپی شد؛ کپی دوباره':originalLabel;
feedback.textContent=copied?'لینک کوتاه کپی شد؛ می‌توانید آن را برای دیگران ارسال کنید.':'لینک آماده است؛ کپی خودکار ممکن نشد. متن را از پنجرهٔ بازشده انتخاب و کپی کنید.';
}catch{label.textContent=originalLabel;feedback.textContent='دریافت لینک ممکن نشد. دوباره تلاش کنید یا لینک پرداخت دریافتی را باز کنید.';}
finally{button.disabled=false;button.removeAttribute('aria-busy');}
});
});
const identifiers=document.querySelector('#identifier-accounts'),account=document.querySelector('#id_account'),deposit=document.querySelector('#id_deposit_id'); const identifiers=document.querySelector('#identifier-accounts'),account=document.querySelector('#id_account'),deposit=document.querySelector('#id_deposit_id');
if(identifiers && account && deposit){const ids=JSON.parse(identifiers.textContent).map(String);function update(){deposit.required=ids.includes(account.value);document.querySelector('#identifier-help').textContent=deposit.required?'این حساب شناسه‌دار است؛ شناسه واریز مشتری الزامی است.':'شناسه واریز در صورت اعلام بانک وارد شود.';}account.addEventListener('change',update);update();} if(identifiers && account && deposit){const ids=JSON.parse(identifiers.textContent).map(String);function update(){deposit.required=ids.includes(account.value);document.querySelector('#identifier-help').textContent=deposit.required?'این حساب شناسه‌دار است؛ شناسه واریز مشتری الزامی است.':'شناسه واریز در صورت اعلام بانک وارد شود.';}account.addEventListener('change',update);update();}
const badge=document.querySelector('[data-payment-count]'); const badge=document.querySelector('[data-payment-count]');
+1
View File
@@ -6,6 +6,7 @@
const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return; const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return;
input.inputMode='numeric'; input.inputMode='numeric';
const words=document.querySelector('#amount-words'); const words=document.querySelector('#amount-words');
if(words)input.setAttribute('aria-describedby',[input.getAttribute('aria-describedby'),words.id].filter(Boolean).join(' '));
const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده']; const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده'];
const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود']; const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود'];
const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد']; const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد'];
+26
View File
@@ -0,0 +1,26 @@
/* Preview the exact rejection reason before submitting the staff decision. */
(() => {
const form = document.querySelector('.pay-decision-form');
if (!form) return;
form.addEventListener('submit', event => {
const confirmation = form.elements.namedItem('confirm_rejection');
confirmation.value = '';
if (event.submitter?.value !== 'REJECT') return;
const reason = form.elements.namedItem('reason');
if (!reason.value.trim()) {
event.preventDefault();
event.stopPropagation();
reason.setCustomValidity('دلیل رد فیش را برای مشتری بنویسید.');
reason.reportValidity();
reason.setCustomValidity('');
reason.focus();
return;
}
if (!window.confirm('این فیش رد می‌شود و دلیل زیر با پیامک برای مشتری ارسال خواهد شد:\n\n' + reason.value.trim() + '\n\nرد فیش و ارسال این دلیل را تأیید می‌کنید؟')) {
event.preventDefault();
event.stopPropagation();
return;
}
confirmation.value = '1';
});
})();
+14 -5
View File
@@ -5,6 +5,7 @@
const form=document.querySelector('#receipt-form');if(!form)return; const form=document.querySelector('#receipt-form');if(!form)return;
const q=name=>form.elements.namedItem(name),button=document.querySelector('#submit-receipt'); const q=name=>form.elements.namedItem(name),button=document.querySelector('#submit-receipt');
const notices=JSON.parse(document.querySelector('#notice-data').textContent); const notices=JSON.parse(document.querySelector('#notice-data').textContent);
const accounts=JSON.parse(document.querySelector('#account-requirements').textContent);
const day=q('paid_day'),month=q('paid_month'),year=q('paid_year'); const day=q('paid_day'),month=q('paid_month'),year=q('paid_year');
const digits=value=>String(value).replace(/[۰-۹]/g,x=>String(x.charCodeAt(0)-1776)).replace(/[٠-٩]/g,x=>String(x.charCodeAt(0)-1632)); const digits=value=>String(value).replace(/[۰-۹]/g,x=>String(x.charCodeAt(0)-1776)).replace(/[٠-٩]/g,x=>String(x.charCodeAt(0)-1632));
const formatter=new Intl.DateTimeFormat('en-US',{calendar:'persian',numberingSystem:'latn',timeZone:'Asia/Tehran',year:'numeric',month:'numeric',day:'numeric'}); const formatter=new Intl.DateTimeFormat('en-US',{calendar:'persian',numberingSystem:'latn',timeZone:'Asia/Tehran',year:'numeric',month:'numeric',day:'numeric'});
@@ -24,6 +25,10 @@ function monthUpdate(){
[month,year].forEach(control=>control.addEventListener('change',monthUpdate));day.addEventListener('change',dateUpdate); [month,year].forEach(control=>control.addEventListener('change',monthUpdate));day.addEventListener('change',dateUpdate);
document.querySelector('#date-today').addEventListener('click',()=>{const p=parts(new Date());year.value=p.year;month.value=p.month;monthUpdate();day.value=p.day;dateUpdate();day.focus();});monthUpdate(); document.querySelector('#date-today').addEventListener('click',()=>{const p=parts(new Date());year.value=p.year;month.value=p.month;monthUpdate();day.value=p.day;dateUpdate();day.focus();});monthUpdate();
function sync(){ function sync(){
const destination=accounts[q('destination_account').value];
const warning=document.querySelector('#destination-deposit-warning');
warning.hidden=!destination?.required;
warning.querySelector('[data-destination-bank]').textContent=destination?.bank || '';
const linked=q('receipt_mode').value==='linked';const select=q('notice_id');form.querySelector('[data-field=notice_id]').hidden=!linked;select.disabled=!linked;select.required=linked; const linked=q('receipt_mode').value==='linked';const select=q('notice_id');form.querySelector('[data-field=notice_id]').hidden=!linked;select.disabled=!linked;select.required=linked;
if(!linked)select.value=''; if(!linked)select.value='';
const notice=linked?notices.find(n=>n.id===select.value):null;const summary=document.querySelector('#selected-payment');summary.hidden=!notice; const notice=linked?notices.find(n=>n.id===select.value):null;const summary=document.querySelector('#selected-payment');summary.hidden=!notice;
@@ -42,8 +47,8 @@ fields.forEach(field=>{const controls=field.querySelectorAll('input,select,texta
let busy=false; let busy=false;
function showErrors(errors){ function showErrors(errors){
fields.forEach(field=>{const name=field.dataset.field,message=errors.get(name)||'',error=field.querySelector('.field-error');error.textContent=message;error.hidden=!message;field.querySelectorAll('input,select,textarea').forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));}); fields.forEach(field=>{const name=field.dataset.field,message=errors.get(name)||'',error=field.querySelector('.field-error');error.textContent=message;error.hidden=!message;field.querySelectorAll('input,select,textarea').forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));});
const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),link=document.createElement('a');link.href='#id_'+name;link.textContent=message;link.onclick=event=>{event.preventDefault();q(name).focus();q(name).scrollIntoView({block:'center'});};item.append(link);list.append(item);});summary.hidden=!errors.size; const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),control=q(name);if(control && typeof control.focus==='function'){const link=document.createElement('a');link.href='#'+control.id;link.textContent=message;link.onclick=event=>{event.preventDefault();control.focus();control.scrollIntoView({block:'center'});};item.append(link);}else item.textContent=message;list.append(item);});summary.hidden=!errors.size;
if(errors.size){const first=q(errors.keys().next().value);first.focus();first.scrollIntoView({block:'center'});} if(errors.size){const first=q(errors.keys().next().value);if(first && typeof first.focus==='function'){first.focus();first.scrollIntoView({block:'center'});}else{summary.tabIndex=-1;summary.focus();summary.scrollIntoView({block:'center'});}}
} }
async function validateImage(file){ async function validateImage(file){
if(!file)return 'تصویر فیش واریز را انتخاب کنید.'; if(!file)return 'تصویر فیش واریز را انتخاب کنید.';
@@ -53,8 +58,9 @@ async function validateImage(file){
} }
let revision=0;form.addEventListener('input',()=>revision++);form.addEventListener('change',()=>revision++); let revision=0;form.addEventListener('input',()=>revision++);form.addEventListener('change',()=>revision++);
form.addEventListener('submit',async event=>{ form.addEventListener('submit',async event=>{
event.preventDefault();if(busy)return;busy=true;button.disabled=true;const version=revision,errors=new Map(); event.preventDefault();if(busy)return;busy=true;button.disabled=true;button.textContent='در حال بررسی تصویر…';document.querySelector('#form-status').textContent='';const version=revision,errors=new Map();let navigating=false;
try{ try{
if(!q('depositor_name').value.replace(/[\s\u200c\u200b]/g,''))errors.set('depositor_name','نام واریزکننده را وارد کنید.');
const value=digits(q('paid_amount').value).trim();if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ ]\d{3})+$/.test(value))errors.set('paid_amount','مبلغ را به ریال و با عدد صحیح وارد کنید.');else{const amount=BigInt(value.replace(/[,٬ ]/g,''));if(amount<=0n || amount>9007199254740991n)errors.set('paid_amount','مبلغ باید مثبت و در محدوده مجاز باشد.');} const value=digits(q('paid_amount').value).trim();if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ ]\d{3})+$/.test(value))errors.set('paid_amount','مبلغ را به ریال و با عدد صحیح وارد کنید.');else{const amount=BigInt(value.replace(/[,٬ ]/g,''));if(amount<=0n || amount>9007199254740991n)errors.set('paid_amount','مبلغ باید مثبت و در محدوده مجاز باشد.');}
if(q('receipt_mode').value==='linked' && !q('notice_id').value)errors.set('notice_id','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.'); if(q('receipt_mode').value==='linked' && !q('notice_id').value)errors.set('notice_id','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.');
if(!q('destination_account').value)errors.set('destination_account','حساب مقصد را انتخاب کنید.'); if(!q('destination_account').value)errors.set('destination_account','حساب مقصد را انتخاب کنید.');
@@ -63,8 +69,11 @@ form.addEventListener('submit',async event=>{
const imageError=await validateImage(q('receipt_image').files[0]);if(imageError)errors.set('receipt_image',imageError); const imageError=await validateImage(q('receipt_image').files[0]);if(imageError)errors.set('receipt_image',imageError);
if(version!==revision){document.querySelector('#form-status').textContent='اطلاعات تغییر کرده است؛ دوباره ارسال را بزنید.';return;} if(version!==revision){document.querySelector('#form-status').textContent='اطلاعات تغییر کرده است؛ دوباره ارسال را بزنید.';return;}
showErrors(errors);if(errors.size)return; showErrors(errors);if(errors.size)return;
button.textContent='در حال ثبت…';HTMLFormElement.prototype.submit.call(form);return; const result=await window.ReceiptUpload.send(form);
}finally{busy=false;if(button.textContent!=='در حال ثبت…')button.disabled=false;} if(result.redirect_url){const destination=new URL(result.redirect_url,location.href);if(destination.origin!==location.origin)throw new Error('پاسخ ثبت فیش معتبر نیست.');navigating=true;location.assign(destination.href);return;}
showErrors(new Map(Object.entries(result.errors).map(([name,items])=>[name,items.map(item=>item.message).join(' ')])));
}catch(error){showErrors(new Map([['__all__',error.message || 'ارسال فیش انجام نشد؛ دوباره تلاش کنید.']]));}
finally{if(!navigating){busy=false;button.disabled=false;button.textContent='ثبت و ارسال فیش';}}
}); });
if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}} if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}}
})(); })();
+65
View File
@@ -0,0 +1,65 @@
/* Real multipart upload progress; registration remains authoritative on the server. */
(() => {
'use strict';
const number=new Intl.NumberFormat('fa-IR',{maximumFractionDigits:1});
function bytes(value){return value>=1048576?number.format(value/1048576)+' مگابایت':number.format(value/1024)+' کیلوبایت';}
function duration(seconds){return seconds>=60?number.format(Math.ceil(seconds/60))+' دقیقه':number.format(Math.max(1,Math.ceil(seconds)))+' ثانیه';}
function send(form){
const payload=new FormData(form),panel=document.querySelector('#receipt-upload');
const title=document.querySelector('#receipt-upload-title'),meter=document.querySelector('#receipt-upload-progress');
const percent=document.querySelector('#receipt-upload-percent'),amount=document.querySelector('#receipt-upload-bytes'),time=document.querySelector('#receipt-upload-time');
const button=document.querySelector('#submit-receipt');
const controls=[...form.querySelectorAll('input,select,textarea,button')].map(control=>[control,control.disabled]);
controls.forEach(([control])=>{control.disabled=true;});form.classList.add('is-uploading');button.setAttribute('aria-busy','true');
panel.hidden=false;title.textContent='در حال ارسال فیش…';button.textContent='در حال ارسال…';
meter.value=0;percent.textContent='۰٪';amount.textContent='در انتظار شروع ارسال';
const started=performance.now();let loaded=0,total=0,lastProgress=started,processing=false;
function updateTime(){
if(processing)return;
const elapsed=(performance.now()-started)/1000;
const estimate=loaded>0 && elapsed>=1 && total>loaded;
time.textContent='زمان سپری‌شده: '+number.format(Math.floor(elapsed))+' ثانیه'+(performance.now()-lastProgress>5000?' · منتظر ادامه ارسال؛ سرعت اتصال تغییر کرده است.':estimate?' · حدود '+duration((total-loaded)*elapsed/loaded)+' دیگر برای ارسال':' · در حال محاسبه زمان باقی‌مانده…');
}
function process(){
processing=true;meter.value=100;percent.textContent='۱۰۰٪';
title.textContent='ارسال کامل شد؛ در حال بررسی و ثبت فیش…';button.textContent='در حال ثبت…';
time.textContent='منتظر تأیید ثبت از سرور؛ زمان این مرحله به سرعت پردازش بستگی دارد.';
}
updateTime();panel.scrollIntoView({block:'nearest'});
return new Promise((resolve,reject)=>{
const xhr=new XMLHttpRequest(),timer=setInterval(updateTime,1000);let settled=false;
function finish(error,result){
if(settled)return;settled=true;clearInterval(timer);
if(!result?.redirect_url){
controls.forEach(([control,disabled])=>{control.disabled=disabled;});form.classList.remove('is-uploading');button.removeAttribute('aria-busy');
title.textContent='ثبت فیش انجام نشد';time.textContent='اطلاعات و تصویر حفظ شده‌اند؛ پس از رفع مشکل دوباره ارسال کنید.';
}
if(error)reject(error);else resolve(result);
}
xhr.upload.addEventListener('progress',event=>{
loaded=event.loaded;total=event.lengthComputable?event.total:0;lastProgress=performance.now();
amount.textContent='ارسال‌شده: '+bytes(loaded)+(total?' از '+bytes(total):'');
if(total){const value=Math.min(100,Math.floor(loaded/total*100));meter.value=value;percent.textContent=number.format(value)+'٪';}
else{meter.removeAttribute('value');percent.textContent='در حال ارسال';}
updateTime();
});
xhr.upload.addEventListener('load',process);
xhr.addEventListener('load',()=>{
const result=xhr.response;
if(xhr.status>=200 && xhr.status<300 && result?.redirect_url){finish(null,result);return;}
if(xhr.status===422 && result?.errors){finish(null,result);return;}
const message=[401,403,410].includes(xhr.status)?'دسترسی یا نشست شما معتبر نیست؛ صفحه را دوباره باز کنید.':xhr.status===413?'حجم تصویر بیش از حد مجاز سرور است؛ تصویر کوچک‌تری انتخاب کنید.':'ثبت فیش تأیید نشد؛ دوباره تلاش کنید. در صورت تکرار، وضعیت فیش‌های خود را بررسی کنید.';
finish(new Error(message));
});
xhr.addEventListener('error',()=>finish(new Error('ارتباط با سرور قطع شد؛ اتصال اینترنت را بررسی و دوباره ارسال کنید.')));
xhr.addEventListener('abort',()=>finish(new Error('ارسال متوقف شد؛ دوباره تلاش کنید.')));
try{
xhr.open('POST',form.action || location.href);xhr.responseType='json';
xhr.setRequestHeader('Accept','application/json');
xhr.setRequestHeader('X-CSRFToken',payload.get('csrfmiddlewaretoken'));
xhr.send(payload);
}catch(error){finish(error);}
});
}
window.ReceiptUpload=Object.freeze({send});
})();
+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();
});
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more