165 lines
11 KiB
Python
165 lines
11 KiB
Python
import logging
|
|
import time
|
|
from datetime import timedelta
|
|
|
|
import jdatetime
|
|
from django.conf import settings
|
|
from django.core.management.base import BaseCommand
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.accounts.models import TicketMagicLink
|
|
from apps.integrations.bale.providers import get_bale_provider
|
|
from apps.integrations.sms.providers import get_sms_provider
|
|
from apps.messaging.models import MessageReadReceipt
|
|
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
|
|
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
|
|
from apps.tickets.models import Ticket
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def _magic_link(job, reason):
|
|
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
|
|
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
|
|
|
|
def _sms_context(job,magic_link):
|
|
profile=getattr(job.customer,"customer_profile",None)
|
|
unit=job.ticket.owning_unit
|
|
return {"magic_link":magic_link,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","recipient_name":" ".join(filter(None,[getattr(profile,"first_name","") or "",getattr(profile,"last_name","") or ""])),"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(profile,"province","") or "","city":job.ticket.city or getattr(profile,"city","") or "","expert_name":job.ticket.assigned_expert_name}
|
|
|
|
def _render_sms(event_type,context,unit=None):
|
|
from string import Formatter
|
|
template=(SMSTemplate.objects.filter(event_type=event_type,is_active=True,unit=unit).first() if unit else None) or SMSTemplate.objects.get(event_type=event_type,is_active=True,unit__isnull=True)
|
|
used={field for _,field,_,_ in Formatter().parse(template.body) if field}
|
|
invalid=used-set(template.allowed_tags)
|
|
if invalid: raise ValueError(f"SMS template contains unsupported tags: {', '.join(sorted(invalid))}")
|
|
missing=used-set(context)
|
|
if missing: raise ValueError(f"SMS template context is missing: {', '.join(sorted(missing))}")
|
|
return template.body.format_map(context)
|
|
|
|
def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, reopened=False):
|
|
profile = getattr(ticket.customer, "customer_profile", None)
|
|
name = " ".join(x for x in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if x).strip() or "ثبت نشده"
|
|
location = "، ".join(x for x in [getattr(profile, "city", "") or ticket.city, getattr(profile, "province", "") or ticket.province] if x)
|
|
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 "بدون متن"
|
|
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=[
|
|
"♻️ بازگشایی تیکت توسط مشتری" if reopened else (f"💬 {message_count} پیام جدید مشتری" if is_reply and message_count>1 else ("💬 پاسخ جدید مشتری" if is_reply else "🆕 تیکت جدید مشتری")),
|
|
"────────────",
|
|
f"🎫 {ticket.ticket_number}",
|
|
f"📌 {ticket.subject}",
|
|
f"👤 {name}",
|
|
f"📞 {ticket.customer.local_mobile or 'ثبت نشده'}",
|
|
]
|
|
if location: lines.append(f"📍 {location}")
|
|
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
|
|
return "\n".join(lines)
|
|
|
|
def _send_sms(job):
|
|
issued = None
|
|
if job.event_type == "CUSTOMER_TICKET_CREATED":
|
|
issued, url = _magic_link(job, "customer_created_ticket")
|
|
message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
|
elif job.event_type == "STAFF_TICKET_CREATED":
|
|
issued, url = _magic_link(job, "staff_created_ticket"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
|
elif job.event_type == "STAFF_REPLY":
|
|
issued, url = _magic_link(job, "staff_reply"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
|
|
else:
|
|
raise ValueError(f"Unsupported SMS event: {job.event_type}")
|
|
return get_sms_provider().send_message(job.customer.mobile, message), issued
|
|
|
|
def _send_bale(job):
|
|
if job.event_type == "NEW_CUSTOMER_TICKET":
|
|
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket))
|
|
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(settings.BALE_GROUP_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 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
|
|
return get_bale_provider().send_message(profile.bale_chat_id, f"رویداد جدید برای تیکت {job.ticket.ticket_number}\n{settings.PUBLIC_BASE_URL}/staff/tickets/{job.ticket.public_id}/")
|
|
|
|
def _prepare_staff_reply_sms(job):
|
|
"""Final transactional guard immediately before the irreversible provider call."""
|
|
with transaction.atomic():
|
|
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
|
|
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
|
|
policy=staff_reply_policy()
|
|
message_id=locked.payload.get("latest_message_id")
|
|
message=ticket.ticket_messages.filter(pk=message_id,is_internal=False,author__account_type="STAFF").first()
|
|
reason=""
|
|
if not policy.is_active: reason="policy_disabled"
|
|
elif not message: reason="staff_message_missing"
|
|
elif MessageReadReceipt.objects.filter(message=message,user_id=locked.customer_id).exists(): reason="customer_read"
|
|
elif ticket.ticket_messages.filter(is_internal=False,author_id=locked.customer_id,created_at__gt=message.created_at).exists(): reason="customer_replied"
|
|
if reason:
|
|
locked.status=NotificationJob.Status.CANCELLED; locked.last_error=reason
|
|
locked.save(update_fields=["status","last_error"])
|
|
return False
|
|
due=ticket.last_activity_at+timedelta(seconds=policy.quiet_period_seconds)
|
|
if due>timezone.now():
|
|
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="conversation_active"
|
|
locked.save(update_fields=["status","next_attempt_at","last_error"])
|
|
return False
|
|
return True
|
|
|
|
def _prepare_customer_reply_bale(job):
|
|
with transaction.atomic():
|
|
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
|
|
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
|
|
policy=customer_reply_bale_policy()
|
|
if not policy.is_active:
|
|
locked.status=NotificationJob.Status.CANCELLED; locked.last_error="policy_disabled"; locked.save(update_fields=["status","last_error"]); return False
|
|
due=ticket.last_customer_message_at+timedelta(seconds=policy.quiet_period_seconds)
|
|
if due>timezone.now():
|
|
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="customer_chat_active"
|
|
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
|
|
return True
|
|
|
|
class Command(BaseCommand):
|
|
help = "Process notification outbox"
|
|
def add_arguments(self, parser):
|
|
parser.add_argument("--once", action="store_true")
|
|
parser.add_argument("--job-id", type=int, help="Process only one notification job")
|
|
parser.add_argument("--min-id", type=int, help="Process this job id and newer jobs")
|
|
parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel")
|
|
def handle(self, *args, **options):
|
|
while True:
|
|
stale = timezone.now() - timedelta(minutes=10)
|
|
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
|
|
with transaction.atomic():
|
|
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
|
|
if options.get("channel"):
|
|
queryset = queryset.filter(channel=options["channel"])
|
|
if options.get("job_id"):
|
|
queryset = queryset.filter(pk=options["job_id"])
|
|
elif options.get("min_id"):
|
|
queryset = queryset.filter(pk__gte=options["min_id"])
|
|
jobs = list(queryset.order_by("created_at")[:20])
|
|
NotificationJob.objects.filter(pk__in=[x.pk for x in jobs]).update(status="PROCESSING", next_attempt_at=timezone.now())
|
|
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):
|
|
continue
|
|
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
|
|
continue
|
|
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
|
|
else: result = _send_bale(job)
|
|
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
|
|
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
|
|
except Exception as exc:
|
|
if issued_link:
|
|
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
|
|
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
|
|
job.save()
|
|
if job.ticket_id and job.ticket.group_dispatch_id:
|
|
from apps.tickets.services import refresh_group_dispatch_status
|
|
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
|
if options["once"] or options.get("job_id"): break
|
|
time.sleep(5)
|