diff --git a/.env.example b/.env.example index 92b9090..034748f 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,8 @@ SMS_MAX_RETRIES=3 SMS_RETRY_DELAY_SECONDS=180 SMS_DELIVERY_CHECK_DELAY_SECONDS=180 SMS_DELIVERY_MAX_CHECKS=15 +SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS=2700 +SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS=30 SECURE_SSL_REDIRECT=true SECURE_HSTS_SECONDS=31536000 SECURE_HSTS_INCLUDE_SUBDOMAINS=true diff --git a/apps/notifications/admin.py b/apps/notifications/admin.py index eb7bbc5..71d9583 100644 --- a/apps/notifications/admin.py +++ b/apps/notifications/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from .models import NotificationJob,NotificationPolicy,SMSTemplate @admin.register(NotificationJob) -class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","provider_message_id","created_at"); list_filter=("channel","status","delivery_status"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","dedupe_key","created_at","sent_at") +class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_status_code","delivery_status","delivery_retry_source","provider_message_id","created_at"); list_filter=("channel","status","delivery_status","delivery_is_final"); readonly_fields=("provider_message_id","provider_status_code","delivery_status_code","delivery_description","delivery_checked_at","delivery_is_final","delivery_retry_source","dedupe_key","created_at","sent_at") @admin.register(SMSTemplate) class SMSTemplateAdmin(admin.ModelAdmin): list_display=("event_type","unit","title","is_active","updated_at") diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 8d791d3..93aba2e 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -19,6 +19,12 @@ from apps.tickets.models import Ticket logger = logging.getLogger(__name__) +DEFINITIVE_DELIVERY_RETRY_EVENT = "STAFF_TICKET_CREATED" +DEFINITIVE_DELIVERY_RETRY_STATUSES = { + NotificationJob.DeliveryStatus.UNDELIVERED, + NotificationJob.DeliveryStatus.EXPIRED, +} + 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}/" @@ -158,11 +164,50 @@ def _record_success(job, result): job.last_error = "" job.provider_message_id = getattr(result, "provider_id", "") if result is not None else "" job.provider_status_code = getattr(result, "provider_code", "") if result is not None else "" + job.delivery_is_final = False if job.channel == NotificationJob.Channel.SMS and job.provider_message_id: job.delivery_status = NotificationJob.DeliveryStatus.PENDING job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS) +def _delivery_retry_dedupe_key(job): + suffix = f":definitive-delivery-retry:{job.pk}" + return f"{job.dedupe_key[:200-len(suffix)]}{suffix}" + + +def _schedule_definitive_delivery_retry(job): + """Schedule exactly one delayed resend for a definitive individual-ticket failure.""" + if ( + job.channel != NotificationJob.Channel.SMS + or job.event_type != DEFINITIVE_DELIVERY_RETRY_EVENT + or job.delivery_retry_source_id + or not job.ticket_id + or job.ticket.group_dispatch_id + or not job.delivery_is_final + or job.delivery_status not in DEFINITIVE_DELIVERY_RETRY_STATUSES + ): + return None + sent_at = job.sent_at or job.created_at + due = max( + timezone.now(), + sent_at + timedelta(seconds=settings.SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS), + ) + retry, _ = NotificationJob.objects.get_or_create( + delivery_retry_source=job, + defaults={ + "channel": job.channel, + "event_type": job.event_type, + "customer_id": job.customer_id, + "staff_user_id": job.staff_user_id, + "ticket_id": job.ticket_id, + "payload": job.payload, + "next_attempt_at": due, + "dedupe_key": _delivery_retry_dedupe_key(job), + }, + ) + return retry + + def _process_delivery_checks(options): if options.get("channel") not in (None, NotificationJob.Channel.SMS): return @@ -207,11 +252,13 @@ def _process_delivery_checks(options): job.delivery_status = "" job.delivery_next_check_at = None job.delivery_description = "این ارائه‌دهنده از استعلام دلیوری پشتیبانی نمی‌کند." + job.delivery_is_final = False else: job.delivery_check_attempts += 1 job.delivery_checked_at = timezone.now() job.delivery_status_code = result.provider_code[:50] job.delivery_description = result.description[:500] + job.delivery_is_final = result.is_final if result.is_final: job.delivery_status = result.status job.delivery_next_check_at = None @@ -225,7 +272,9 @@ def _process_delivery_checks(options): job.save(update_fields=[ "delivery_status", "delivery_status_code", "delivery_description", "delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at", + "delivery_is_final", ]) + _schedule_definitive_delivery_retry(job) 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) diff --git a/apps/notifications/migrations/0013_notificationjob_definitive_delivery_retry.py b/apps/notifications/migrations/0013_notificationjob_definitive_delivery_retry.py new file mode 100644 index 0000000..d18dad3 --- /dev/null +++ b/apps/notifications/migrations/0013_notificationjob_definitive_delivery_retry.py @@ -0,0 +1,72 @@ +from datetime import timedelta + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from django.utils import timezone + + +def _retry_dedupe_key(job): + suffix = f":definitive-delivery-retry:{job.pk}" + return f"{job.dedupe_key[:200-len(suffix)]}{suffix}" + + +def queue_historical_definitive_delivery_retries(apps, schema_editor): + """Queue each old, definitively expired individual staff-created SMS once.""" + NotificationJob = apps.get_model("notifications", "NotificationJob") + delay = getattr(settings, "SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS", 2700) + stagger = getattr(settings, "SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS", 30) + now = timezone.now() + sources = NotificationJob.objects.filter( + channel="SMS", + event_type="STAFF_TICKET_CREATED", + status="SENT", + ticket__group_dispatch__isnull=True, + delivery_status="EXPIRED", + delivery_next_check_at__isnull=True, + delivery_retry_source__isnull=True, + ).order_by("id") + queued = 0 + for source in sources.iterator(): + if NotificationJob.objects.filter(delivery_retry_source_id=source.pk).exists(): + continue + sent_at = source.sent_at or source.created_at + due = max(sent_at + timedelta(seconds=delay), now + timedelta(seconds=(queued + 1) * stagger)) + NotificationJob.objects.create( + channel=source.channel, + event_type=source.event_type, + customer_id=source.customer_id, + staff_user_id=source.staff_user_id, + ticket_id=source.ticket_id, + payload=source.payload, + status="PENDING", + attempts=0, + next_attempt_at=due, + dedupe_key=_retry_dedupe_key(source), + delivery_retry_source_id=source.pk, + ) + queued += 1 + + +class Migration(migrations.Migration): + dependencies = [("notifications", "0012_supervisor_sms_template")] + + operations = [ + migrations.AddField( + model_name="notificationjob", + name="delivery_is_final", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="notificationjob", + name="delivery_retry_source", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="delivery_retry_job", + to="notifications.notificationjob", + ), + ), + migrations.RunPython(queue_historical_definitive_delivery_retries, migrations.RunPython.noop), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index ecb6fc7..b6e6089 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -63,5 +63,10 @@ class NotificationJob(models.Model): delivery_checked_at=models.DateTimeField(null=True,blank=True) delivery_check_attempts=models.PositiveSmallIntegerField(default=0) delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True) + delivery_is_final=models.BooleanField(default=False) + delivery_retry_source=models.OneToOneField( + "self",on_delete=models.CASCADE,null=True,blank=True, + related_name="delivery_retry_job", + ) class Meta: indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")] diff --git a/config/settings/base.py b/config/settings/base.py index 72afe50..0aa6c35 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -100,6 +100,8 @@ SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3")) SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180")) SMS_DELIVERY_CHECK_DELAY_SECONDS = int(os.getenv("SMS_DELIVERY_CHECK_DELAY_SECONDS", "180")) SMS_DELIVERY_MAX_CHECKS = int(os.getenv("SMS_DELIVERY_MAX_CHECKS", "15")) +SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS = int(os.getenv("SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS", "2700")) +SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS = int(os.getenv("SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS", "30")) PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/") COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان") WEBOTP_HOST = os.getenv("WEBOTP_HOST", "") diff --git a/docker-compose.yml b/docker-compose.yml index 259dce9..4d7510c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,8 @@ x-django-service: &django-service SMS_RETRY_DELAY_SECONDS: ${SMS_RETRY_DELAY_SECONDS:-180} SMS_DELIVERY_CHECK_DELAY_SECONDS: ${SMS_DELIVERY_CHECK_DELAY_SECONDS:-180} SMS_DELIVERY_MAX_CHECKS: ${SMS_DELIVERY_MAX_CHECKS:-15} + SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS: ${SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS:-2700} + SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS: ${SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS:-30} SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true} SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000} SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true} diff --git a/static/js/customer-ticket-wizard.js b/static/js/customer-ticket-wizard.js index 6ec97f6..db92e1f 100644 --- a/static/js/customer-ticket-wizard.js +++ b/static/js/customer-ticket-wizard.js @@ -18,6 +18,22 @@ const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]); + function focusWizardCard({ respectExistingFocus = false } = {}) { + requestAnimationFrame(() => { + if ( + respectExistingFocus + && document.activeElement !== document.body + && document.activeElement !== document.documentElement + ) return; + form.focus({ preventScroll: true }); + form.scrollIntoView({ + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', + block: 'start', + inline: 'nearest', + }); + }); + } + function updateProgress(number) { progress.forEach(item => { const itemNumber = Number(item.dataset.wizardProgressItem); @@ -35,7 +51,7 @@ step.classList.toggle('is-active', active); }); updateProgress(number); - if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' }); + if (scroll) focusWizardCard(); } function clearValidation(number) { @@ -349,19 +365,6 @@ setupVoiceRecorder(voiceRoot); form.dataset.wizardEnhanced = ''; - function focusWizardCard() { - requestAnimationFrame(() => { - // Do not steal focus if the visitor has already started interacting. - if (document.activeElement !== document.body && document.activeElement !== document.documentElement) return; - form.focus({ preventScroll: true }); - form.scrollIntoView({ - behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', - block: 'start', - inline: 'nearest', - }); - }); - } - const serverErrorStep = steps.find(step => step.querySelector('.errorlist')); const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1; showStep(initialStep, { scroll: false }); @@ -372,5 +375,5 @@ .filter(Boolean); const number = Number(serverErrorStep.dataset.wizardStep); requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields })); - } else focusWizardCard(); + } else focusWizardCard({ respectExistingFocus: true }); })(); diff --git a/tests/e2e/test_public_ticket_workflows_e2e.py b/tests/e2e/test_public_ticket_workflows_e2e.py index 16cd330..e4bef42 100644 --- a/tests/e2e/test_public_ticket_workflows_e2e.py +++ b/tests/e2e/test_public_ticket_workflows_e2e.py @@ -195,12 +195,16 @@ def test_e2e_customer_wizard_validation_preview_edit_and_floating_submit( page.locator('[data-ticket-subject]').fill(subject) expect(page.locator('[data-ticket-subject]')).to_have_value(subject) page.locator('[data-wizard-step="1"] [data-wizard-next]').click() + expect(wizard).to_be_focused() + expect(wizard).to_be_in_viewport() page.locator('[data-wizard-step="2"] [data-wizard-next]').click() page.locator('[data-wizard-step="3"] [data-wizard-next]').click() page.locator('textarea[name="body"]').fill("شرح خوانا برای پیش‌نمایش مرحله پنجم") page.locator('[data-wizard-step="4"] [data-wizard-next]').click() expect(page.locator('[data-wizard-step="5"]')).to_be_visible() + expect(wizard).to_be_focused() + expect(wizard).to_be_in_viewport() expect(page.locator('[data-review-subject]')).to_have_text(subject) expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا") final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی") diff --git a/tests/test_domain.py b/tests/test_domain.py index 67164e5..ff2f3a5 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -644,6 +644,133 @@ def test_worker_rechecks_provisional_delivery_until_definitive_result(category,c assert job.delivery_status==NotificationJob.DeliveryStatus.DELIVERED assert job.delivery_check_attempts==3 and job.delivery_next_check_at is None + +@pytest.mark.django_db +def test_definitive_staff_created_delivery_failure_schedules_exactly_one_delayed_resend(category,customer,monkeypatch,settings): + from django.core.management import call_command + from apps.integrations.sms.base import SMSDeliveryResult,SMSResult + from apps.notifications.management.commands import process_notifications + + settings.SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS=2700 + staff=make_staff("delivery-retry-staff") + ticket=create_ticket(customer=customer,created_by=staff,unit=category.unit,category=category,subject="ارسال قطعی ناموفق",body="متن") + source=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_TICKET_CREATED") + sent_at=timezone.now() + NotificationJob.objects.filter(pk=source.pk).update( + status=NotificationJob.Status.SENT,attempts=1,sent_at=sent_at, + provider_message_id="123456789",delivery_status=NotificationJob.DeliveryStatus.PENDING, + delivery_next_check_at=timezone.now()-timedelta(seconds=1), + ) + + class Provider: + def get_delivery_status(self,provider_id): + return SMSDeliveryResult("EXPIRED","7","مهلت دریافت پیامک منقضی شده است.",is_final=True) + def send_message(self,mobile,message): + return SMSResult(True,provider_id="223456789",provider_code="2") + monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider()) + + call_command("process_notifications",once=True,job_id=source.pk) + source.refresh_from_db() + retry=NotificationJob.objects.get(delivery_retry_source=source) + assert source.delivery_is_final is True + assert source.delivery_status==NotificationJob.DeliveryStatus.EXPIRED + assert retry.event_type=="STAFF_TICKET_CREATED" and retry.status==NotificationJob.Status.PENDING + assert retry.attempts==0 and retry.ticket_id==ticket.pk and retry.customer_id==customer.pk + assert sent_at+timedelta(seconds=2699) <= retry.next_attempt_at <= sent_at+timedelta(seconds=2701) + + process_notifications._schedule_definitive_delivery_retry(source) + assert NotificationJob.objects.filter(delivery_retry_source=source).count()==1 + + NotificationJob.objects.filter(pk=retry.pk).update(next_attempt_at=timezone.now()-timedelta(seconds=1)) + call_command("process_notifications",once=True,job_id=retry.pk) + retry.refresh_from_db() + assert retry.status==NotificationJob.Status.SENT and retry.attempts==1 + assert retry.provider_message_id=="223456789" + + retry.sent_at=timezone.now()-timedelta(hours=1) + retry.delivery_status=NotificationJob.DeliveryStatus.EXPIRED + retry.delivery_is_final=True + retry.save() + assert process_notifications._schedule_definitive_delivery_retry(retry) is None + assert NotificationJob.objects.filter(ticket=ticket,event_type="STAFF_TICKET_CREATED").count()==2 + + +@pytest.mark.django_db +def test_provisional_or_unrelated_delivery_states_never_schedule_automatic_resend(category,customer,monkeypatch,settings): + from django.core.management import call_command + from apps.integrations.sms.base import SMSDeliveryResult + from apps.notifications.management.commands import process_notifications + + settings.SMS_DELIVERY_MAX_CHECKS=1 + staff=make_staff("provisional-retry-staff") + staff_ticket=create_ticket(customer=customer,created_by=staff,unit=category.unit,category=category,subject="دلیوری موقت",body="متن") + staff_job=NotificationJob.objects.get(ticket=staff_ticket,event_type="STAFF_TICKET_CREATED") + NotificationJob.objects.filter(pk=staff_job.pk).update( + status=NotificationJob.Status.SENT,sent_at=timezone.now()-timedelta(hours=1), + provider_message_id="123456789",delivery_status=NotificationJob.DeliveryStatus.PENDING, + delivery_next_check_at=timezone.now()-timedelta(seconds=1), + ) + class Provider: + def get_delivery_status(self,provider_id): + return SMSDeliveryResult("UNDELIVERED","6","تحویل هنوز تأیید نشده است.",is_final=False) + monkeypatch.setattr(process_notifications,"get_sms_provider",lambda:Provider()) + call_command("process_notifications",once=True,job_id=staff_job.pk) + staff_job.refresh_from_db() + assert staff_job.delivery_is_final is False + assert not NotificationJob.objects.filter(delivery_retry_source=staff_job).exists() + + customer_ticket=create_ticket(customer=customer,category=category,subject="رویداد خارج از دامنه",body="متن") + unrelated=NotificationJob.objects.get(ticket=customer_ticket,event_type="CUSTOMER_TICKET_CREATED") + unrelated.status=NotificationJob.Status.SENT + unrelated.sent_at=timezone.now()-timedelta(hours=1) + unrelated.delivery_status=NotificationJob.DeliveryStatus.EXPIRED + unrelated.delivery_is_final=True + unrelated.save() + assert process_notifications._schedule_definitive_delivery_retry(unrelated) is None + + +@pytest.mark.django_db +def test_blocked_and_delivery_error_are_not_definitive_resend_candidates(category,customer): + from apps.notifications.management.commands import process_notifications + + staff=make_staff("nonretryable-delivery-staff") + ticket=create_ticket(customer=customer,created_by=staff,unit=category.unit,category=category,subject="عدم ارسال مجدد خطا",body="متن") + source=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_TICKET_CREATED") + source.status=NotificationJob.Status.SENT + source.sent_at=timezone.now()-timedelta(hours=1) + source.delivery_is_final=True + for status in (NotificationJob.DeliveryStatus.BLOCKED,NotificationJob.DeliveryStatus.ERROR): + source.delivery_status=status + source.save(update_fields=["status","sent_at","delivery_is_final","delivery_status"]) + assert process_notifications._schedule_definitive_delivery_retry(source) is None + assert not NotificationJob.objects.filter(delivery_retry_source=source).exists() + + +@pytest.mark.django_db +def test_historical_expired_staff_ticket_delivery_is_queued_once_by_data_migration(category,customer,settings): + import importlib + from django.apps import apps as django_apps + + settings.SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS=2700 + settings.SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS=30 + staff=make_staff("historical-delivery-staff") + ticket=create_ticket(customer=customer,created_by=staff,unit=category.unit,category=category,subject="خطای قدیمی دلیوری",body="متن") + source=NotificationJob.objects.get(ticket=ticket,event_type="STAFF_TICKET_CREATED") + NotificationJob.objects.filter(pk=source.pk).update( + status=NotificationJob.Status.SENT,sent_at=timezone.now()-timedelta(days=1), + provider_message_id="987654321",delivery_status=NotificationJob.DeliveryStatus.EXPIRED, + delivery_next_check_at=None,delivery_is_final=False, + ) + migration=importlib.import_module("apps.notifications.migrations.0013_notificationjob_definitive_delivery_retry") + + before=timezone.now() + migration.queue_historical_definitive_delivery_retries(django_apps,None) + retry=NotificationJob.objects.get(delivery_retry_source=source) + assert retry.status==NotificationJob.Status.PENDING + assert retry.next_attempt_at>=before+timedelta(seconds=29) + migration.queue_historical_definitive_delivery_retries(django_apps,None) + assert NotificationJob.objects.filter(delivery_retry_source=source).count()==1 + @pytest.mark.django_db def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch): from datetime import timedelta