Compare commits
10
Commits
de3135b588
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7a10cb61 | ||
|
|
089db0afa4 | ||
|
|
6b546aad76 | ||
|
|
35a1ec1794 | ||
|
|
90931a5a64 | ||
|
|
bb3ddd1610 | ||
|
|
4f7486152c | ||
|
|
b53a3e0c5c | ||
|
|
d99e47ecce | ||
|
|
1cbbbbd0dc |
No files matched your search
+696
-15
File diff suppressed because it is too large.
Load diff
@@ -2,3 +2,6 @@ from django.apps import AppConfig
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "apps.accounts"
|
||||
|
||||
def ready(self):
|
||||
from . import customer_access # Register the login-session receiver.
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from .customer_access import customer_login_access
|
||||
|
||||
app_name = "accounts"
|
||||
urlpatterns = [
|
||||
path("login/access/", customer_login_access, name="login_access"),
|
||||
path("", views.home, name="home"),
|
||||
path("login/", views.customer_login, name="login"),
|
||||
path("login/password/", views.customer_password_login, name="password_login"),
|
||||
|
||||
+31
-3
@@ -14,6 +14,7 @@ from django.db import transaction
|
||||
from django.db.models import F
|
||||
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
|
||||
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
|
||||
import logging
|
||||
|
||||
@@ -46,7 +47,11 @@ def customer_login(request):
|
||||
_remember_destination(request)
|
||||
if response:=_authenticated_home(request): return response
|
||||
form = MobileForm(request.POST or None)
|
||||
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
|
||||
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
|
||||
try:
|
||||
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
|
||||
@@ -62,13 +67,18 @@ def customer_login(request):
|
||||
challenge.delete()
|
||||
logger.exception("OTP SMS delivery failed")
|
||||
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"])
|
||||
def verify_otp(request):
|
||||
if response:=_authenticated_home(request): return response
|
||||
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
|
||||
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 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})
|
||||
@@ -86,12 +96,16 @@ def customer_password_login(request):
|
||||
_remember_destination(request)
|
||||
if response:=_authenticated_home(request): return response
|
||||
form=CustomerPasswordLoginForm(request.POST or None)
|
||||
block_reason = request.session.pop(BLOCK_REASON_KEY, "")
|
||||
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)
|
||||
if user and user.is_active:
|
||||
login(request,user); return redirect(_login_destination(request))
|
||||
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
|
||||
@require_http_methods(["GET","POST"])
|
||||
@@ -105,14 +119,28 @@ def customer_profile(request):
|
||||
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
|
||||
return render(request,"accounts/profile.html",{"form":form})
|
||||
|
||||
def _staff_login_destination(request):
|
||||
target = request.GET.get("next") or request.POST.get("next") or request.session.get("staff_login_next", "")
|
||||
if target.startswith(("/vt/", "/staff/tickets/")) and url_has_allowed_host_and_scheme(
|
||||
target, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
|
||||
):
|
||||
request.session["staff_login_next"] = target
|
||||
return target
|
||||
request.session.pop("staff_login_next", None)
|
||||
return reverse("staff_tickets:list")
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_login(request):
|
||||
destination = _staff_login_destination(request)
|
||||
if response:=_authenticated_home(request): return response
|
||||
form = StaffLoginForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
user = form.authenticate(request)
|
||||
if user and user.is_active and user.account_type == User.AccountType.STAFF:
|
||||
login(request, user); return redirect("staff_tickets:list")
|
||||
login(request, user)
|
||||
request.session.pop("staff_login_next", None)
|
||||
return redirect(destination)
|
||||
form.add_error(None, "اطلاعات ورود صحیح نیست.")
|
||||
return render(request, "accounts/staff_login.html", {"form": form})
|
||||
|
||||
|
||||
@@ -54,4 +54,5 @@ class AttachmentPolicyAdmin(admin.ModelAdmin):
|
||||
form = AttachmentPolicyAdminForm
|
||||
|
||||
|
||||
admin.site.register(Attachment)
|
||||
from apps.tickets.response_admin import RelatedResponseContentAdmin
|
||||
admin.site.register(Attachment, RelatedResponseContentAdmin)
|
||||
@@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300):
|
||||
if download:
|
||||
query["download"] = "1"
|
||||
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
|
||||
@@ -5,6 +5,10 @@ from django.db import models
|
||||
from apps.core.models import PublicIdModel
|
||||
def attachment_path(instance,filename):
|
||||
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}"
|
||||
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)
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import mimetypes
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
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 django.views.decorators.cache import never_cache
|
||||
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
|
||||
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
|
||||
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.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
|
||||
return attachment
|
||||
@never_cache
|
||||
def view(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
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
|
||||
return attachment_response(_authorized_attachment(request, public_id))
|
||||
@never_cache
|
||||
def download(request,public_id):
|
||||
attachment=_authorized_attachment(request,public_id)
|
||||
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)
|
||||
return attachment_response(_authorized_attachment(request, public_id), download=True)
|
||||
@@ -64,7 +64,7 @@ def staff_operations_access(request):
|
||||
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
|
||||
"contacts": True,
|
||||
"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"),
|
||||
"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
|
||||
|
||||
|
||||
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):
|
||||
help = "Import a verified production seed bundle into a pristine PostgreSQL database."
|
||||
|
||||
@@ -80,10 +88,7 @@ class Command(BaseCommand):
|
||||
copied_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Migrations contain defaults. Flush those rows before loading
|
||||
# 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)
|
||||
_load_seed_data(bundle_dir)
|
||||
# Old SQLite snapshots may predate unit-scoped queues. A
|
||||
# category already belongs to exactly one unit, so it is a
|
||||
# deterministic and safe source for repairing those rows.
|
||||
|
||||
@@ -16,6 +16,7 @@ SEED_MODEL_LABELS = (
|
||||
"tickets.SLAConfig",
|
||||
"tickets.TicketTag",
|
||||
"tickets.GroupDispatch",
|
||||
"tickets.DispatchResponseControl",
|
||||
"tickets.Ticket",
|
||||
"tickets.TicketAccessGrant",
|
||||
"tickets.TicketEvent",
|
||||
|
||||
@@ -46,6 +46,14 @@ def jalali_date(value):
|
||||
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
|
||||
def persian_time(value):
|
||||
"""Display local Iran time using Persian digits."""
|
||||
|
||||
@@ -11,8 +11,8 @@ class ContactGroupAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(CustomerProfile)
|
||||
class CustomerProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name")
|
||||
list_filter = ("stakeholder_type", "groups")
|
||||
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked")
|
||||
list_filter = ("stakeholder_type", "groups", "login_blocked")
|
||||
search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
|
||||
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='مسدود کردن ورود مشتری / مخاطب'),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError
|
||||
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
|
||||
|
||||
|
||||
@@ -51,6 +53,34 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
|
||||
province = 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)
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
|
||||
return NotificationResult(True, "console")
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.contrib import admin
|
||||
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)
|
||||
@@ -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
|
||||
@@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy
|
||||
from .models import Message,MessageRevision
|
||||
@transaction.atomic
|
||||
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)
|
||||
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
|
||||
@@ -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
|
||||
@transaction.atomic
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -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/"))
|
||||
@login_required
|
||||
def revisions(request,public_id):
|
||||
from apps.tickets.dispatch_responses import can_read_response
|
||||
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
|
||||
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
|
||||
@@ -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()
|
||||
raw_excerpt = " ".join((message.body if message else "").split())
|
||||
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)
|
||||
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
|
||||
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}/"])
|
||||
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):
|
||||
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
|
||||
return send_payment_sms(job, provider_factory=get_sms_provider)
|
||||
issued = None
|
||||
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
|
||||
from apps.tickets.short_links import append_supervisor_ticket_link
|
||||
recipient = job.payload.get("recipient_mobile", "")
|
||||
if not recipient:
|
||||
raise ValueError("Unit supervisor mobile is not configured")
|
||||
message = _render_sms(job.event_type, _sms_context(job), job.ticket.owning_unit)
|
||||
message = append_supervisor_ticket_link(job.ticket, message)
|
||||
return get_sms_provider().send_message(recipient, message), issued
|
||||
if job.event_type == "CUSTOMER_TICKET_CREATED":
|
||||
issued, url = _magic_link(job, "customer_created_ticket")
|
||||
@@ -94,14 +117,28 @@ def _send_sms(job):
|
||||
raise ValueError(f"Unsupported SMS event: {job.event_type}")
|
||||
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):
|
||||
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()
|
||||
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
|
||||
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()
|
||||
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
|
||||
return _send_customer_reply_bale(job, unit_chat_id)
|
||||
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
|
||||
if not profile.bale_notifications_enabled: return None
|
||||
@@ -337,7 +374,11 @@ class Command(BaseCommand):
|
||||
for job in jobs:
|
||||
issued_link = None
|
||||
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
|
||||
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
|
||||
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),
|
||||
]
|
||||
@@ -10,11 +10,13 @@ def sms_template_tags():
|
||||
class SMSTemplate(PersianTextNormalizationMixin, models.Model):
|
||||
persian_text_fields={"title":False,"body":True}
|
||||
class Event(models.TextChoices):
|
||||
DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخهای ارسال گروهی"
|
||||
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
|
||||
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
|
||||
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
|
||||
PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز"
|
||||
PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز"
|
||||
PAYMENT_RECEIPT_REJECTED="PAYMENT_RECEIPT_REJECTED","رد فیش واریز"
|
||||
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.")
|
||||
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()
|
||||
@classmethod
|
||||
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
|
||||
return list(TAGS)
|
||||
tags = sms_template_tags()
|
||||
|
||||
+33
-6
@@ -1,10 +1,10 @@
|
||||
"""A payment link never authenticates a user or grants access to their whole account."""
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -13,11 +13,13 @@ from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from apps.core.session_access import grant_scoped_session_access
|
||||
from apps.accounts.models import TicketMagicLink
|
||||
|
||||
from . import policies
|
||||
from .models import PaymentAccessLink, PaymentNotice
|
||||
|
||||
SESSION_KEY = 'payment_access_link_id'
|
||||
SHARE_SESSION_KEY = 'payment_page_share'
|
||||
|
||||
|
||||
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')
|
||||
if receipt is not None:
|
||||
notice = receipt.notice
|
||||
token = secrets.token_urlsafe(32)
|
||||
link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt,
|
||||
token_hash=token_digest(token),
|
||||
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS))
|
||||
return link, settings.PUBLIC_BASE_URL + reverse('payments:open_link', args=[token])
|
||||
for attempt in range(5):
|
||||
token = TicketMagicLink.generate_secure_token(10)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt,
|
||||
token_hash=token_digest(token),
|
||||
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS))
|
||||
return link, settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
|
||||
except IntegrityError:
|
||||
if attempt == 4:
|
||||
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
|
||||
|
||||
|
||||
def 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():
|
||||
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,
|
||||
customer__account_type='CUSTOMER')
|
||||
|
||||
|
||||
@@ -126,3 +126,91 @@ for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRe
|
||||
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
|
||||
models.PaymentNotification,models.PaymentNotificationAttempt]:
|
||||
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
|
||||
@@ -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('قالب معتبر نیست؛ فقط تگهای معرفیشده و بدون قالببندی اضافی مجازند.')
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Calculates the verification code based on the reverse-engineered logic:
|
||||
@@ -39,4 +45,4 @@ def generate_id_algorithm(id_code: str) -> str:
|
||||
# فرمتدهی دو رقمی (مثلاً 6 میشود 06)
|
||||
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
@@ -6,6 +6,31 @@ from django.utils import timezone
|
||||
from apps.accounts.models import User
|
||||
from . import validators
|
||||
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):
|
||||
@@ -71,6 +96,7 @@ class NoticeForm(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')
|
||||
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
|
||||
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_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
|
||||
paid_year = forms.ChoiceField(label='سال')
|
||||
receipt_image = forms.FileField(label='تصویر فیش')
|
||||
receipt_image = forms.FileField(label='تصویر فیش', widget=forms.ClearableFileInput(attrs={'accept': 'image/*'}))
|
||||
tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False)
|
||||
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
|
||||
submission_key = forms.UUIDField(widget=forms.HiddenInput)
|
||||
|
||||
def __init__(self, *args, customer, notice_ids=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial.setdefault('depositor_name', customer_name(customer))
|
||||
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
|
||||
if notice_ids is not None:
|
||||
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}'
|
||||
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.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())
|
||||
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())
|
||||
@@ -104,6 +134,9 @@ class ReceiptForm(forms.Form):
|
||||
def clean_paid_amount(self):
|
||||
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):
|
||||
upload = self.cleaned_data['receipt_image']
|
||||
self.image_metadata = validators.image(upload)
|
||||
|
||||
@@ -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})'
|
||||
@@ -15,7 +15,8 @@ from .models import PaymentAccessLink
|
||||
@never_cache
|
||||
@require_http_methods(['GET', 'POST'])
|
||||
def open_link(request, token):
|
||||
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'[A-Za-z0-9_-]{43}', token) else None
|
||||
# Keep previously issued long links valid until their original expiry/revocation.
|
||||
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'(?:[a-z0-9]{10}|[A-Za-z0-9_-]{43})', token) else None
|
||||
if link is None:
|
||||
return access.unavailable_link(request)
|
||||
if request.user.is_authenticated and request.user.pk != link.customer_id:
|
||||
@@ -28,6 +29,8 @@ def open_link(request, token):
|
||||
logout(request)
|
||||
if not request.user.is_authenticated:
|
||||
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():
|
||||
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, {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
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
|
||||
|
||||
@@ -85,6 +87,11 @@ class PaymentNotice(PublicIdModel, TimeStampedModel):
|
||||
invoice_number = models.CharField(max_length=100, blank=True)
|
||||
description = models.TextField(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:
|
||||
ordering = ['-created_at', '-pk']
|
||||
@@ -127,6 +134,7 @@ def receipt_path(instance, filename):
|
||||
class PaymentReceiptRevision(models.Model):
|
||||
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
|
||||
version = models.PositiveIntegerField()
|
||||
depositor_name = models.CharField('نام واریزکننده', max_length=150)
|
||||
amount = models.BigIntegerField()
|
||||
paid_date = models.DateField()
|
||||
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
|
||||
@@ -144,6 +152,10 @@ class PaymentReceiptRevision(models.Model):
|
||||
original_name = models.CharField(max_length=255)
|
||||
file_size = models.PositiveBigIntegerField()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
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')]
|
||||
|
||||
@@ -232,3 +244,84 @@ class PaymentNotificationAttempt(models.Model):
|
||||
provider_code = models.CharField(max_length=50, blank=True)
|
||||
provider_id = models.CharField(max_length=500, 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 = 'سوابق اعلان فیش مشتری'
|
||||
@@ -5,15 +5,19 @@ from django.utils import timezone
|
||||
from apps.notifications.services import enqueue
|
||||
from .models import PaymentNotification, PaymentNotificationAttempt
|
||||
|
||||
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
|
||||
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
|
||||
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', '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):
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
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 '',
|
||||
'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}')}
|
||||
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
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):
|
||||
from apps.notifications.models import SMSTemplate
|
||||
from apps.notifications.management.commands.process_notifications import _render_sms
|
||||
notification = PaymentNotification.objects.get(job=job)
|
||||
# Persist the attempt before rendering/provider construction: failed preparation is history too.
|
||||
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.save(update_fields=['context', 'access_link', '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 = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
|
||||
notification.rendered_body = body
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.db.models import Q
|
||||
from .models import PaymentAccessConfiguration
|
||||
from .models import PaymentAccessConfiguration, PaymentNotice
|
||||
|
||||
|
||||
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'))
|
||||
|
||||
|
||||
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):
|
||||
if queryset.model == PaymentNotice:
|
||||
queryset = queryset.filter(deleted_at__isnull=True)
|
||||
if not user.is_authenticated or not user.is_active:
|
||||
return queryset.none()
|
||||
if user.account_type == 'CUSTOMER':
|
||||
@@ -57,8 +79,8 @@ def visible(queryset, user):
|
||||
|
||||
|
||||
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
|
||||
has_permission(user, code, receipt.owning_unit) and
|
||||
visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and
|
||||
cycle.assignments.filter(user=user, is_senior=senior).exists())
|
||||
@@ -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__})')
|
||||
@@ -1,8 +1,74 @@
|
||||
"""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.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 .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):
|
||||
@@ -16,6 +82,7 @@ def notices_with_totals(queryset):
|
||||
def receipts_with_current_values(queryset):
|
||||
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
|
||||
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]))
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,36 @@ def actor_name(actor):
|
||||
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):
|
||||
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 {})
|
||||
@@ -24,7 +54,33 @@ def audit(actor, action, obj, details=None):
|
||||
|
||||
def configuration_snapshot(obj):
|
||||
"""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
|
||||
@@ -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)
|
||||
if not account.is_active or not account.available_to_staff:
|
||||
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:
|
||||
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
|
||||
key = data.get('submission_key')
|
||||
@@ -85,9 +142,11 @@ def assign_cycle(revision, actor):
|
||||
|
||||
|
||||
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', ''),
|
||||
'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', '')}
|
||||
|
||||
|
||||
@@ -99,6 +158,7 @@ def validate_destination(account, notice):
|
||||
|
||||
|
||||
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.
|
||||
User.objects.select_for_update().get(pk=customer.pk)
|
||||
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
|
||||
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
|
||||
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('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
|
||||
return existing
|
||||
notice = data.get('notice_id')
|
||||
@@ -120,7 +181,6 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
validate_destination(account, 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'])
|
||||
fields = receipt_metadata(data)
|
||||
fields['destination'] = account
|
||||
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,
|
||||
@@ -133,6 +193,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
created_files.append((revision.image.storage, revision.image.name))
|
||||
assign_cycle(revision, actor)
|
||||
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
|
||||
|
||||
|
||||
@@ -166,7 +228,7 @@ def submit_receipt(actor, customer, data, *, access_link=None):
|
||||
|
||||
|
||||
@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.
|
||||
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.
|
||||
@@ -184,6 +246,8 @@ def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
|
||||
if not allowed:
|
||||
raise PermissionDenied
|
||||
if action == 'REJECT' and not rejection_confirmed:
|
||||
raise ValidationError('برای رد فیش، ارسال دلیل رد به مشتری را تأیید کنید.')
|
||||
reason = reason.strip()
|
||||
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
|
||||
raise ValidationError('دلیل این تصمیم را بنویسید.')
|
||||
@@ -217,6 +281,9 @@ def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
if status == 'APPROVED':
|
||||
from .notifications import queue_approval
|
||||
queue_approval(receipt, cycle)
|
||||
elif status == 'REJECTED':
|
||||
from .notifications import queue_rejection
|
||||
queue_rejection(receipt, cycle, reason)
|
||||
return receipt
|
||||
|
||||
|
||||
@@ -279,6 +346,8 @@ def revise_receipt(actor, receipt_id, version, data):
|
||||
receipt.status='PENDING'
|
||||
receipt.save(update_fields=['current_version','status','updated_at'])
|
||||
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
|
||||
except Exception:
|
||||
for storage,name in created_files:
|
||||
|
||||
@@ -8,6 +8,8 @@ urlpatterns = [
|
||||
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
|
||||
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>/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('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
|
||||
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
|
||||
|
||||
@@ -6,6 +6,7 @@ urlpatterns = [
|
||||
path('', views.home, name='home'),
|
||||
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>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
|
||||
path('payment-receipt/', views.receipt_new, name='receipt_new'),
|
||||
path('receipts/', views.receipt_list, name='receipt_list'),
|
||||
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
|
||||
|
||||
+80
-8
@@ -42,6 +42,8 @@ def home(request):
|
||||
|
||||
|
||||
def complete_notice(notice):
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
|
||||
snapshot = notice.account_snapshot
|
||||
approved = getattr(notice, 'approved_total', None)
|
||||
if approved is None:
|
||||
@@ -57,6 +59,8 @@ def complete_notice(notice):
|
||||
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
|
||||
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
|
||||
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
|
||||
if notice.deposit_id:
|
||||
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایلبانک یا اینترنتبانک وارد کنید.'
|
||||
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))
|
||||
|
||||
|
||||
@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):
|
||||
link = getattr(request, 'payment_access_link', None)
|
||||
initial = access.receipt_initial(request, customer)
|
||||
@@ -93,9 +108,13 @@ def receipt_form_page(request, customer, staff=False):
|
||||
try:
|
||||
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
|
||||
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)
|
||||
except ValidationError as 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()
|
||||
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]
|
||||
@@ -103,7 +122,7 @@ def receipt_form_page(request, customer, staff=False):
|
||||
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,
|
||||
'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))
|
||||
|
||||
|
||||
@@ -202,7 +221,8 @@ def decision(request, public_id):
|
||||
get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id)
|
||||
try:
|
||||
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())
|
||||
except (ValidationError, ValueError) as error:
|
||||
messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.')
|
||||
@@ -254,8 +274,12 @@ def filtered(queryset, request):
|
||||
@financial_required(staff=True)
|
||||
def desk(request):
|
||||
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))
|
||||
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':
|
||||
items = filtered(notice_items, request)
|
||||
state = request.GET.get('notice_state', '')
|
||||
@@ -274,7 +298,7 @@ def desk(request):
|
||||
items = items.filter(notice__isnull=True)
|
||||
elif request.GET.get('kind') == 'linked':
|
||||
items = items.filter(notice__isnull=False)
|
||||
if request.GET.get('mine'):
|
||||
if mine:
|
||||
my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'),
|
||||
cycle__revision__version=OuterRef('current_version'), user=request.user)
|
||||
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)
|
||||
query = request.GET.copy()
|
||||
query.pop('page', None)
|
||||
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, statuses=PaymentReceipt.Status.choices,
|
||||
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode()))
|
||||
query['tab'] = tab
|
||||
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)
|
||||
@@ -330,17 +360,53 @@ def notice_new(request):
|
||||
|
||||
@financial_required(staff=True)
|
||||
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)
|
||||
share = request.session.get('payment_link_share', {})
|
||||
if share.get('notice') == str(public_id):
|
||||
request.session.pop('payment_link_share', None)
|
||||
else:
|
||||
share = {}
|
||||
from uuid import uuid4
|
||||
from .notifications import notice_sms_pending
|
||||
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,
|
||||
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)
|
||||
@@ -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')
|
||||
import jdatetime
|
||||
current = receipt.revisions.get(version=receipt.current_version)
|
||||
from .identity import customer_name
|
||||
paid = jdatetime.date.fromgregorian(date=current.paid_date)
|
||||
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_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,
|
||||
@@ -454,13 +522,17 @@ def receipt_revise(request, public_id):
|
||||
try:
|
||||
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
|
||||
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)
|
||||
except (ValidationError,ValueError) as error:
|
||||
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
|
||||
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)]
|
||||
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,
|
||||
'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})
|
||||
+2
-2
@@ -14,14 +14,14 @@ class UnitMembershipInline(admin.TabularInline):
|
||||
@admin.register(OrganizationalUnit)
|
||||
class OrganizationalUnitAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "slug", "bale_chat_id", "sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active", "updated_at")
|
||||
list_filter = ("show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||
list_filter = ("supervisor_sms_ticket_link_enabled", "show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||
search_fields = ("name", "slug", "bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")
|
||||
list_editable = ("sort_order", "show_in_customer_form", "accepts_external_tickets", "is_active")
|
||||
filter_horizontal = ("permissions",)
|
||||
inlines = (UnitMembershipInline,)
|
||||
fieldsets = (
|
||||
("مشخصات واحد", {"fields": ("name", "slug", "description", "search_keywords", "sort_order")}),
|
||||
("اطلاعرسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5")}),
|
||||
("اطلاعرسانی بله و سرپرستان", {"fields": ("bale_chat_id", "supervisor_mobile_1", "supervisor_mobile_2", "supervisor_mobile_3", "supervisor_mobile_4", "supervisor_mobile_5", "supervisor_sms_ticket_link_enabled")}),
|
||||
("نمایش و دسترسی", {"fields": ("permissions", "show_in_customer_form", "accepts_external_tickets", "is_active")}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-13 12:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('staff', '0012_organizationalunit_supervisor_mobile_5'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organizationalunit',
|
||||
name='supervisor_sms_ticket_link_enabled',
|
||||
field=models.BooleanField(default=False, help_text='لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.', verbose_name='افزودن لینک تیکت به پیامک سرپرستان'),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,11 @@ class OrganizationalUnit(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
supervisor_mobile_3 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست سوم")
|
||||
supervisor_mobile_4 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست چهارم")
|
||||
supervisor_mobile_5 = models.CharField(max_length=16, blank=True, verbose_name="شماره همراه سرپرست پنجم")
|
||||
supervisor_sms_ticket_link_enabled = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="افزودن لینک تیکت به پیامک سرپرستان",
|
||||
help_text="لینک کوتاه مشاهده و پاسخ با اعتبار ۷ روز؛ ورود کارشناسان و مجوز دسترسی به تیکت الزامی است.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_order", "name", "pk"]
|
||||
|
||||
+40
-1
@@ -1,5 +1,5 @@
|
||||
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)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
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")
|
||||
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):
|
||||
model = Ticket
|
||||
@@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ("recipient_count", "dispatched_at")
|
||||
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)
|
||||
class TicketCategoryAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -140,6 +140,8 @@ class StaffCreateTicketForm(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)
|
||||
unit = forms.ModelChoiceField(label="واحد ارسالکننده", queryset=OrganizationalUnit.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
|
||||
unit = data.get("unit")
|
||||
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:
|
||||
self.add_error("contact_group", "این گروه متعلق به واحد انتخابشده نیست.")
|
||||
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های کوتاه تیکت کارشناسان',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,10 +1,35 @@
|
||||
import uuid
|
||||
import secrets
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel, TimeStampedModel
|
||||
|
||||
|
||||
def staff_ticket_link_token():
|
||||
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(10))
|
||||
|
||||
|
||||
def staff_ticket_link_expiry():
|
||||
return timezone.now() + timedelta(days=7)
|
||||
|
||||
|
||||
class StaffTicketShortLink(TimeStampedModel):
|
||||
"""A short navigation URL; it never authenticates or grants ticket access."""
|
||||
|
||||
ticket = models.OneToOneField("Ticket", on_delete=models.CASCADE, related_name="staff_short_link")
|
||||
token = models.CharField(max_length=10, unique=True, default=staff_ticket_link_token, editable=False)
|
||||
expires_at = models.DateTimeField(default=staff_ticket_link_expiry)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "لینک کوتاه تیکت کارشناسان"
|
||||
verbose_name_plural = "لینکهای کوتاه تیکت کارشناسان"
|
||||
|
||||
|
||||
class TicketCategory(PersianTextNormalizationMixin, TimeStampedModel):
|
||||
persian_text_fields = {"title": False, "description": True, "search_keywords": True}
|
||||
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.PROTECT,null=True,blank=True,related_name="ticket_categories",verbose_name="واحد سازمانی",help_text="برای دستههای قدیمی عمومی میتواند خالی باشد؛ دستههای جدید باید واحد داشته باشند.")
|
||||
@@ -176,3 +201,30 @@ class MessageShare(PublicIdModel, 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)
|
||||
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"),
|
||||
]
|
||||
@@ -70,6 +70,20 @@ class TicketPolicy:
|
||||
}
|
||||
@classmethod
|
||||
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 cls.is_customer(user): return ticket.customer_id==user.id
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True
|
||||
@@ -83,6 +97,11 @@ class TicketPolicy:
|
||||
return False
|
||||
@classmethod
|
||||
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."""
|
||||
if user.is_superuser or user.has_perm("tickets.view_all_tickets"):
|
||||
return Q()
|
||||
@@ -169,5 +188,32 @@ class DispatchPolicy:
|
||||
@classmethod
|
||||
def can_view(cls, user, dispatch):
|
||||
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_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)
|
||||
@@ -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__"
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.db.models import Case, F, IntegerField, Q, Value, When
|
||||
from django.db.models import Case, Exists, IntegerField, OuterRef, Q, Value, When
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_date
|
||||
from .models import Ticket
|
||||
from .models import Ticket, TicketReadState
|
||||
from .policies import TicketPolicy
|
||||
def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile")
|
||||
def staff_ticket_rows(user):
|
||||
@@ -56,7 +56,8 @@ def filter_staff_tickets(qs, user, params):
|
||||
if tab == "shared":
|
||||
qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id")))
|
||||
else:
|
||||
qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True))
|
||||
read_by_user=TicketReadState.objects.filter(ticket_id=OuterRef('pk'),user=user,last_read_at__gte=OuterRef('last_activity_at'))
|
||||
qs=qs.alias(_read_by_current_user=Exists(read_by_user)).filter(_read_by_current_user=False)
|
||||
qs=qs.distinct()
|
||||
if tab in ("", "waiting_staff"):
|
||||
return qs.annotate(
|
||||
|
||||
@@ -63,6 +63,7 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
|
||||
)
|
||||
if not contacts:
|
||||
raise ValidationError("گروه انتخابشده مخاطب فعال دارای شماره همراه ندارد.")
|
||||
from .models import DispatchResponseControl
|
||||
dispatch = GroupDispatch.objects.create(
|
||||
unit=unit,
|
||||
operation_type=operation_type,
|
||||
@@ -74,6 +75,9 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
|
||||
status=GroupDispatch.Status.QUEUED,
|
||||
recipient_count=len(contacts),
|
||||
)
|
||||
DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED",
|
||||
notify_sms=len(contacts) <= 15,
|
||||
recipient_count_snapshot=len(contacts))
|
||||
tickets=[]
|
||||
for contact in contacts:
|
||||
for uploaded in files:
|
||||
@@ -97,6 +101,20 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body
|
||||
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):
|
||||
"""Persist delivery state independently of whether a report page is opened."""
|
||||
dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first()
|
||||
@@ -194,6 +212,8 @@ def revoke_access(ticket,user,target):
|
||||
|
||||
@transaction.atomic
|
||||
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)
|
||||
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
|
||||
@@ -204,7 +224,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user
|
||||
if not TicketPolicy.can_view(target,ticket):
|
||||
if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target)
|
||||
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
|
||||
if not internal:
|
||||
if TicketPolicy.is_customer(user):
|
||||
|
||||
@@ -66,6 +66,9 @@ def share_staff_ticket(ticket, actor, target, level, note=""):
|
||||
|
||||
@transaction.atomic
|
||||
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)
|
||||
require_share_authority(actor, ticket)
|
||||
validate_recipient(actor, target)
|
||||
@@ -147,11 +150,12 @@ def staff_message_context(ticket, actor, conversation=()):
|
||||
|
||||
|
||||
def sharing_context(ticket, actor):
|
||||
from .dispatch_responses import sealed_message_q
|
||||
if not TicketPolicy.can_share(actor, ticket):
|
||||
return {}
|
||||
return {
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
"""Routes available only to staff, including isolated message attachment delivery."""
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views.decorators.cache import never_cache
|
||||
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 .models import MessageShare, Ticket
|
||||
from .models import MessageShare, Ticket, TicketReadState
|
||||
from .policies import TicketPolicy
|
||||
from apps.messaging.read_receipts import message_read_ids
|
||||
from .staff_conversation import received_messages, unread_shares, visible_messages
|
||||
from .sharing import mark_share_opened, revoke_staff_share
|
||||
from .views import staff_required
|
||||
from .views import staff_required, mark_messages_read
|
||||
|
||||
|
||||
def received_message_share(request, share_id):
|
||||
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)
|
||||
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
|
||||
or not request.user.is_active or not profile or not profile.is_active):
|
||||
raise PermissionDenied
|
||||
@@ -66,18 +71,27 @@ def shared_message(request, share_id):
|
||||
@staff_required
|
||||
@require_POST
|
||||
@never_cache
|
||||
@transaction.atomic
|
||||
def read_shared_messages(request, public_id):
|
||||
ticket = get_object_or_404(Ticket, public_id=public_id)
|
||||
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)
|
||||
ids = request.POST.getlist("message_ids")[:100]
|
||||
# Filter strings without parsing attacker-controlled UUIDs in the ORM.
|
||||
visible = {str(value) for value in allowed.values_list("public_id", flat=True)}
|
||||
accepted = set(ids) & visible
|
||||
try:
|
||||
ids = message_read_ids(request)
|
||||
except ValidationError:
|
||||
return JsonResponse({"ok": False, "error": "invalid_message_ids"}, status=400)
|
||||
acknowledged = list(allowed.filter(public_id__in=ids, deleted_at__isnull=True))
|
||||
mark_messages_read(acknowledged, request.user)
|
||||
accepted = [item.public_id for item in acknowledged]
|
||||
grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted)
|
||||
for grant in grants:
|
||||
if grant.first_viewed_at is None:
|
||||
mark_share_opened(grant, request.user)
|
||||
return JsonResponse({"unread_shares": unread_shares(request.user)})
|
||||
latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first()
|
||||
if latest and latest.public_id in accepted and 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
|
||||
@@ -93,9 +107,4 @@ def unread_count(request):
|
||||
def shared_attachment(request, share_id, attachment_id, download=False):
|
||||
grant = received_message_share(request, share_id)
|
||||
attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id)
|
||||
if settings.USE_NGINX_MEDIA:
|
||||
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
|
||||
return attachment_response(attachment, download=download)
|
||||
@@ -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)
|
||||
@@ -12,7 +12,9 @@ from .sharing import staff_message_context
|
||||
|
||||
|
||||
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 = grants.exclude(sealed_message_q("message__"))
|
||||
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)
|
||||
if not user.is_active or (profile is not None and not profile.is_active):
|
||||
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):
|
||||
return query
|
||||
grants = received_messages(user, ticket)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views, sharing_views
|
||||
from . import views, sharing_views, dispatch_response_views
|
||||
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("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("tickets/<uuid:public_id>/shares/read/", sharing_views.read_shared_messages, name="read_shares"),
|
||||
path("shares/", sharing_views.inbox, name="shares"),
|
||||
|
||||
+58
-53
@@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied,ValidationError
|
||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Prefetch, Q, Sum
|
||||
from django.db.models import Count, Max, Prefetch, Q, Sum
|
||||
from django.shortcuts import get_object_or_404,redirect,render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -32,13 +32,13 @@ from apps.core.persian_text import normalize_persian_text
|
||||
from apps.integrations.voip import place_voip_call
|
||||
from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm
|
||||
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketReadState,TicketTag
|
||||
from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketTag
|
||||
from .forms import StaffMessageShareForm, StaffTicketShareForm
|
||||
from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context
|
||||
from .policies import DispatchPolicy, TicketPolicy
|
||||
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 .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
|
||||
SMS_RETRYABLE_DELIVERY_STATUSES=(
|
||||
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)
|
||||
def mark_messages_read(conversation,user):
|
||||
MessageReadReceipt.objects.bulk_create(
|
||||
[MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id],
|
||||
[MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id and not item.deleted_at],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
def customer_required(view):
|
||||
@@ -118,14 +118,21 @@ def customer_ticket_actor(request,ticket):
|
||||
return request.user,False
|
||||
link=get_ticket_magic_access(request,ticket)
|
||||
return (link.customer,True) if link else (None,False)
|
||||
def conversation_version(ticket,latest):
|
||||
def conversation_version(ticket,latest,include_internal=False):
|
||||
query=ticket.ticket_messages.all()
|
||||
if not include_internal: query=query.filter(is_internal=False)
|
||||
edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at'))
|
||||
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 ''}"
|
||||
return hashlib.sha256(source.encode()).hexdigest()[:20]
|
||||
return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision
|
||||
def conversation_poll_response(request,ticket,actor,include_internal=False):
|
||||
qs=ticket.ticket_messages.all()
|
||||
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)
|
||||
latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first()
|
||||
version=conversation_version(ticket,latest)
|
||||
version=conversation_version(ticket,latest,include_internal=include_internal)
|
||||
requested_cursor = request.GET.get("cursor", "")
|
||||
latest_cursor = str(latest.public_id) if latest else ""
|
||||
# A client can have the newest version token but still miss the DOM update
|
||||
@@ -136,7 +143,7 @@ def conversation_poll_response(request,ticket,actor,include_internal=False):
|
||||
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
cursor=requested_cursor
|
||||
replace=not cursor or not latest or str(latest.public_id)==cursor
|
||||
replace=not cursor or not latest or str(latest.public_id)==cursor or request.GET.get('version','').partition(':')[2]!=version.partition(':')[2]
|
||||
messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")
|
||||
if not replace:
|
||||
cursor_message=qs.filter(public_id=cursor).only("created_at","id").first()
|
||||
@@ -148,14 +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))
|
||||
else:
|
||||
conversation=list(messages_qs[:MESSAGE_PAGE_SIZE])
|
||||
if include_internal: mark_messages_read(conversation,actor)
|
||||
context={"conversation":conversation,"ticket":ticket,"viewer_user":actor}
|
||||
template="tickets/_messages.html"
|
||||
if include_internal:
|
||||
context.update(message_context(ticket,actor,conversation))
|
||||
template="tickets/_staff_messages.html"
|
||||
html=render_to_string(template,context,request=request)
|
||||
response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
|
||||
delivered = conversation[-1] if conversation else latest
|
||||
response=JsonResponse({"changed":True,"version":version,"cursor":str(delivered.public_id) if delivered else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()})
|
||||
response["Cache-Control"]="private, no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
@customer_required
|
||||
@@ -327,17 +334,19 @@ def customer_poll(request,public_id):
|
||||
if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login"))
|
||||
return conversation_poll_response(request,ticket,actor,include_internal=False)
|
||||
@require_POST
|
||||
@transaction.atomic
|
||||
def customer_read(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
from apps.messaging.read_receipts import message_read_ids
|
||||
ticket=get_object_or_404(Ticket.objects.select_for_update(),public_id=public_id)
|
||||
actor,_=customer_ticket_actor(request,ticket)
|
||||
if actor is None: return JsonResponse({"ok":False},status=401)
|
||||
try: message_ids=json.loads(request.body or b"{}").get("message_ids",[])
|
||||
except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400)
|
||||
messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor)
|
||||
MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True)
|
||||
try: message_ids=message_read_ids(request)
|
||||
except ValidationError: return JsonResponse({"ok":False,"error":"invalid_message_ids"},status=400)
|
||||
acknowledged=list(ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False,deleted_at__isnull=True))
|
||||
mark_messages_read(acknowledged,actor)
|
||||
from apps.notifications.services import cancel_staff_reply_sms_if_read
|
||||
cancel_staff_reply_sms_if_read(ticket,actor)
|
||||
return JsonResponse({"ok":True,"read":messages_qs.count()})
|
||||
return JsonResponse({"ok":True,"read":sum(item.author_id != actor.pk for item in acknowledged),"read_ids":[str(item.public_id) for item in acknowledged]})
|
||||
@require_POST
|
||||
def customer_reply(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES)
|
||||
@@ -450,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)
|
||||
visible=visible_messages(ticket,request.user)
|
||||
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
|
||||
mark_messages_read(conversation,request.user)
|
||||
mark_ticket_share_opened(ticket,request.user)
|
||||
message_sharing=message_context(ticket,request.user,conversation)
|
||||
if TicketPolicy.can_view(request.user,ticket):
|
||||
last=conversation[-1] if conversation else None
|
||||
TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
|
||||
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
|
||||
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]
|
||||
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"))
|
||||
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)
|
||||
latest=conversation[-1] if conversation else None
|
||||
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")))
|
||||
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(
|
||||
payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user)
|
||||
).order_by('-created_at', '-pk')[:3]
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest,include_internal=True),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
|
||||
|
||||
|
||||
@staff_required
|
||||
@@ -514,7 +520,6 @@ def staff_sms_retry(request,public_id,job_id):
|
||||
def staff_messages(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before"))
|
||||
mark_messages_read(conversation,request.user)
|
||||
return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)})
|
||||
@staff_required
|
||||
def staff_poll(request,public_id):
|
||||
@@ -612,10 +617,19 @@ def staff_delete(request, public_id):
|
||||
@staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
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)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
try:
|
||||
dispatch, _ = dispatch_group_tickets(
|
||||
dispatch, _ = create_group_dispatch(
|
||||
user=request.user,
|
||||
unit=form.cleaned_data["unit"],
|
||||
contact_group=form.cleaned_data["contact_group"],
|
||||
@@ -623,6 +637,8 @@ def staff_dispatch_create(request):
|
||||
subject=form.cleaned_data["subject"],
|
||||
body=form.cleaned_data["body"],
|
||||
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"],
|
||||
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)
|
||||
except (PermissionDenied, ValidationError) as 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 = {
|
||||
"total": metrics_row["total"] or 0,
|
||||
@@ -671,19 +680,27 @@ def staff_dispatch_create(request):
|
||||
return render(request, "tickets/staff_dispatch_create.html", {
|
||||
"form": form, "dispatches": page.object_list, "page": page, "metrics": metrics,
|
||||
"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": "ارسال گروهی و ردیابی",
|
||||
})
|
||||
|
||||
|
||||
@staff_required
|
||||
def staff_dispatch_detail(request, public_id):
|
||||
from .dispatch_responses import response_control, can_open_responses, populate_report_responses
|
||||
dispatch = get_object_or_404(
|
||||
GroupDispatch.objects.select_related("unit", "contact_group", "created_by"),
|
||||
public_id=public_id,
|
||||
)
|
||||
if not DispatchPolicy.can_view(request.user, dispatch):
|
||||
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 = {
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
NotificationJob.DeliveryStatus.EXPIRED,
|
||||
@@ -695,7 +712,6 @@ def staff_dispatch_detail(request, public_id):
|
||||
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_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())
|
||||
ticket.magic_total_views = sum(link.view_count for link in 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)
|
||||
job = ticket.sms_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:
|
||||
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:
|
||||
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):
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info"
|
||||
elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED:
|
||||
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:
|
||||
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:
|
||||
ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger"
|
||||
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()
|
||||
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
|
||||
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"])
|
||||
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
|
||||
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)
|
||||
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
|
||||
@@ -781,19 +798,7 @@ def _unit_ids_with_permission(user, permission):
|
||||
|
||||
|
||||
def _visible_dispatches(user):
|
||||
if user.is_superuser:
|
||||
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)
|
||||
return DispatchPolicy.visible_dispatches(user)
|
||||
|
||||
|
||||
CONTACT_AUDIT_FIELDS = {
|
||||
|
||||
@@ -29,7 +29,8 @@ MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware",
|
||||
"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",
|
||||
]
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
+5
-1
@@ -9,6 +9,8 @@ from django.urls import include, path
|
||||
from django.views.static import serve
|
||||
|
||||
from apps.core.models import SiteBranding
|
||||
from apps.payments import link_views as payment_link_views
|
||||
from apps.tickets.short_links import open_staff_ticket_link
|
||||
|
||||
|
||||
def healthz(request):
|
||||
@@ -48,6 +50,8 @@ def web_manifest(request):
|
||||
return response
|
||||
|
||||
urlpatterns = [
|
||||
path("vt/<str:token>", open_staff_ticket_link, name="staff_ticket_short_link"),
|
||||
path("p/<str:token>/", payment_link_views.open_link, name="payment_short_link"),
|
||||
path("healthz/", healthz, name="healthz"),
|
||||
path("media/branding/<path:path>", public_branding, name="public_branding"),
|
||||
path("service-worker.js", service_worker, name="service_worker"),
|
||||
@@ -65,7 +69,7 @@ urlpatterns = [
|
||||
|
||||
def debug_media(request, path):
|
||||
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
|
||||
raise Http404
|
||||
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>
|
||||
@@ -27,6 +27,11 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# These files require an authenticated application check on every request.
|
||||
location ^~ /protected-media/dispatch-responses/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /protected-media/ {
|
||||
alias /app/media/;
|
||||
secure_link $arg_md5,$arg_expires;
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
- اعلامیه، فیش و تصمیم تأیید مستقلاند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمیکند.
|
||||
- مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام میشود و مقدار ریال به سرور میرود.
|
||||
- نمایش تاریخ شمسی و انتخاب سهقسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره میکند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد میشود.
|
||||
- شناسهها، شمارهها و شبا رشتهاند؛ صفر ابتدایی حفظ میشود. شبا و کارت موجود اعتبارسنجی میشوند.
|
||||
- شماره حساب، کارت و شبا رشتهاند و صفر ابتدایی آنها حفظ میشود. شناسه واریز رشته است اما صفرهای ابتدایی آن پس از محاسبه رقم کنترل حذف میشوند؛ تولید، ذخیره و نمایش همین مقدار را استفاده میکنند. migration `0006_normalize_deposit_identifiers` شناسه اعلامیهها و نسخههای فیش قبلی را هماهنگ میکند و snapshot بانکی یا ممیزی تاریخی را تغییر نمیدهد. شبا و کارت موجود اعتبارسنجی میشوند.
|
||||
- حساب شناسهدار در صدور اعلامیه الزاماً شناسه میخواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد.
|
||||
- حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف میکند و سوابق را پنهان نمیکند.
|
||||
- snapshot حساب و شناسه اعلامیه حفظ میشود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمیدهد.
|
||||
- فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمیکند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد میشود.
|
||||
- فرم مشترک فیش مشتری، کارشناس، دسترسی لینک پرداخت و اصلاح فیش، تصویر را با XMLHttpRequest ارسال میکند: درصد و حجم واقعی درخواست، زمان سپریشده و تخمین زمان باقیمانده نمایش داده میشوند. پس از پایان انتقال، مرحله بررسی و ثبت سرور جداگانه نمایش داده میشود. دکمه ارسال و فیلدها تا پاسخ سرور قفلاند؛ در خطا، تصویر و nonce حفظ میشوند و امکان تلاش مجدد وجود دارد. درخواست دارای `Accept: application/json` مقصد ادامه را در `redirect_url` یا خطاهای فرم را با وضعیت ۴۲۲ دریافت میکند؛ ارسال معمولی بدون JavaScript همچنان از redirect استفاده میکند.
|
||||
- وضعیت اعلامیه از مبلغ نسخه جاری فیشهای تأییدشده بدون مغایرت مقصد محاسبه میشود؛ پرداخت جزئی و باقیمانده نمایش داده میشوند.
|
||||
|
||||
## تنظیمات ضروری پیش از استفاده مالی واقعی
|
||||
@@ -37,19 +38,17 @@
|
||||
|
||||
۳. در مدیریت 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_company_bankaccounts` | مدیریت حسابها |
|
||||
| `payments.manage_payment_access` | تنظیم باز یا محدود بودن مشاهده و ایجاد |
|
||||
| `payments.view_all_payments` | مشاهده مالی در همه واحدها |
|
||||
| `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`
|
||||
|
||||
صف فعلی `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 روی صف واقعی توسعه اجرا نشده است.
|
||||
|
||||
|
||||
@@ -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` شبیه تشخیص داده شد؛
|
||||
این دو به مدلها و قواعد مستقل مربوطاند و ادغام آنها مناسب نیست.
|
||||
این هشدارهای تحلیل ایستا به معنی شکست تستهای اجرایی نیستند و بهعنوان
|
||||
نتیجهٔ سبز گیت کیفیت گزارش نشدهاند.
|
||||
@@ -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 مرورگر پروژه، در عرضهای ۱۴۴۰، ۳۹۰ و ۳۲۰ اجرا میشوند.
|
||||
@@ -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 نیز در مرورگر بررسی میشوند.
|
||||
@@ -1,6 +1,6 @@
|
||||
# لینک موقت پرداخت
|
||||
|
||||
لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، اکنون یک مجوز مستقل است؛ کاربر با آن وارد حساب نمیشود. مسیر ورودی `/chaharsu/link/<token>/` پس از بررسی، به آدرس بدون توکن منتقل میشود.
|
||||
لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، یک مجوز مستقل است؛ کاربر با آن وارد حساب نمیشود. لینکهای جدید مسیر کوتاه `/p/<token>/` دارند؛ توکن ۱۰ کاراکتری با همان مولد تصادفی امن magic link ساخته و فقط هش آن ذخیره میشود. برخورد توکن حداکثر پنج بار در تراکنش مستقل تکرار میشود. مسیر قدیمی `/chaharsu/link/<token>/` نیز برای لینکهای قبلی تا انقضا یا ابطال معتبر است. هر دو مسیر پس از بررسی، به آدرس بدون توکن منتقل میشوند.
|
||||
|
||||
## محدوده و عمر دسترسی
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- مشاهده اطلاعات بانکی، کپی تکفیلد و کامل، ثبت فیش مرتبط و مستقل، مشاهده وضعیت، دریافت تصویر و اصلاح فیش ردشده بدون ورود ممکن است.
|
||||
- فیلد «شناسه واریز که با آن پرداخت انجام دادهاید» هنگام ارسال فیش اختیاری است؛ خالیبودن آن مانع ارسال نمیشود. هشدار ثبت شناسه در عملیات پرداخت بانکی همچنان نمایش داده میشود.
|
||||
- انقضا، ابطال و غیرفعالشدن مشتری در هر درخواست دوباره بررسی میشود. CSRF و اعتبارسنجی تصویر، مبلغ و تاریخ همچنان برقرارند.
|
||||
- دکمه انتخاب تصویر فیش از ورودی `accept="image/*"` بدون `capture` استفاده میکند تا انتخاب عکس و اسکرینشات از گالری ممکن باشد. نمایش دقیق انتخابگر به مرورگر و سیستمعامل گوشی بستگی دارد؛ فرمت و اندازه فایل همچنان در مرورگر و سرور اعتبارسنجی میشوند.
|
||||
- تأیید و رد فقط برای کاربران دارای مجوز و تعیینشده در سیاست تأیید باقی میماند. لینک پرداخت مجوز تأیید نمیدهد و لینک تیکت مجوز مالی نمیدهد.
|
||||
- اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام میشود.
|
||||
|
||||
@@ -22,6 +23,12 @@
|
||||
|
||||
شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده میشوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده میشود.
|
||||
|
||||
## کپی اطلاعات و اشتراک صفحه
|
||||
|
||||
شناسه واریز در تولید، ذخیره، نمایش و متن کپی بدون صفرهای ابتدایی است؛ رقم کنترل و صفرهای داخلی حفظ میشوند. در صورت وجود شناسه، متن کپی کامل صریحاً میگوید وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است.
|
||||
|
||||
زیر دکمه کپی کامل در جزئیات واریز، «کپی لینک کوتاه این صفحه برای ارسال به دیگران» قرار دارد. درخواست POST با CSRF و کنترل مالکیت/محدوده اجرا میشود. مشتری واردشده میتواند لینک موقت همین اعلامیه را بسازد؛ پیامکی ارسال نمیشود. بازدیدکننده ناشناس فقط لینک معتبر دریافتشده را کپی میکند؛ انقضا یا محدوده دسترسی تمدید نمیشود. برای کپی پس از redirect، توکن در نشست سمت سرور نگهداری میشود؛ مدل لینک همچنان فقط هش توکن را ذخیره میکند. نشستهای قدیمی فاقد توکن باید لینک دریافتی را دوباره باز کنند. Clipboard ناموجود، کپی دستی قابل انتخاب را نمایش میدهد.
|
||||
|
||||
## تست دائمی
|
||||
|
||||
از WSL و در ریشه پروژه اجرا کنید:
|
||||
|
||||
@@ -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 ساختگی استفاده میکنند و پیامک واقعی نمیفرستند.
|
||||
@@ -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های قبلی و تغییرات موجود کاربر حفظ شدند. برای فیشهای قدیمی نام واقعی واریزکننده جعل یا از پروفایل بازسازی نمیشود؛ وضعیت ثبت نشدن نام در پنل مشخص است.
|
||||
@@ -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
|
||||
```
|
||||
@@ -35,6 +35,13 @@
|
||||
.chaharsu-dock a.active{background:#0b4c8c}.chaharsu-dock a{min-height:52px;color:#cadbea}
|
||||
|
||||
/* 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-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}
|
||||
@@ -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}
|
||||
.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}}
|
||||
.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}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/* 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-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}
|
||||
@@ -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}
|
||||
.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%}}
|
||||
|
||||
/* 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%}}
|
||||
@@ -48,7 +48,7 @@
|
||||
const data = await response.json();
|
||||
if (request !== controller || customer.value !== selectedCustomer) return;
|
||||
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) {
|
||||
pending = data.identifier; candidate.textContent = pending; replacement.hidden = false;
|
||||
status.textContent = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.';
|
||||
|
||||
@@ -4,15 +4,40 @@
|
||||
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);}
|
||||
async function copy(text){
|
||||
try{await navigator.clipboard.writeText(text);status('کپی شد');}
|
||||
try{await navigator.clipboard.writeText(text);status('کپی شد');return true;}
|
||||
catch{
|
||||
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 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();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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');
|
||||
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]');
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return;
|
||||
input.inputMode='numeric';
|
||||
const words=document.querySelector('#amount-words');
|
||||
if(words)input.setAttribute('aria-describedby',[input.getAttribute('aria-describedby'),words.id].filter(Boolean).join(' '));
|
||||
const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده'];
|
||||
const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود'];
|
||||
const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد'];
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
})();
|
||||
@@ -5,6 +5,7 @@
|
||||
const form=document.querySelector('#receipt-form');if(!form)return;
|
||||
const q=name=>form.elements.namedItem(name),button=document.querySelector('#submit-receipt');
|
||||
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 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'});
|
||||
@@ -24,6 +25,10 @@ function monthUpdate(){
|
||||
[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();
|
||||
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;
|
||||
if(!linked)select.value='';
|
||||
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;
|
||||
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))));});
|
||||
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;
|
||||
if(errors.size){const first=q(errors.keys().next().value);first.focus();first.scrollIntoView({block:'center'});}
|
||||
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);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){
|
||||
if(!file)return 'تصویر فیش واریز را انتخاب کنید.';
|
||||
@@ -53,8 +58,9 @@ async function validateImage(file){
|
||||
}
|
||||
let revision=0;form.addEventListener('input',()=>revision++);form.addEventListener('change',()=>revision++);
|
||||
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{
|
||||
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','مبلغ باید مثبت و در محدوده مجاز باشد.');}
|
||||
if(q('receipt_mode').value==='linked' && !q('notice_id').value)errors.set('notice_id','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.');
|
||||
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);
|
||||
if(version!==revision){document.querySelector('#form-status').textContent='اطلاعات تغییر کرده است؛ دوباره ارسال را بزنید.';return;}
|
||||
showErrors(errors);if(errors.size)return;
|
||||
button.textContent='در حال ثبت…';HTMLFormElement.prototype.submit.call(form);return;
|
||||
}finally{busy=false;if(button.textContent!=='در حال ثبت…')button.disabled=false;}
|
||||
const result=await window.ReceiptUpload.send(form);
|
||||
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'});}}
|
||||
})();
|
||||
@@ -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});
|
||||
})();
|
||||
@@ -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
Reference in new issue
Block a user