Files
customerTicket/apps/notifications/management/commands/process_notifications.py
T

175 lines
12 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 == "UNIT_SUPERVISOR_NEW_TICKET":
recipient = job.payload.get("recipient_mobile", "")
if not recipient:
raise ValueError("Unit supervisor mobile is not configured")
subject = " ".join(job.ticket.subject.split())[:80] or "بدون موضوع"
category = " ".join(job.ticket.category.title.split())[:50]
customer_mobile = job.ticket.customer.local_mobile or "ثبت نشده"
message = f"تیکت جدید {job.ticket.ticket_number}\nتلفن: {customer_mobile}\nموضوع: {subject}\nدسته: {category}"
return get_sms_provider().send_message(recipient, message), issued
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):
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
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)