some fix
This commit is contained in:
1 parent
6b841dc5fa
commit
0ed677e5fc
10 files changed
+282
-16
No files matched your search
@@ -45,6 +45,8 @@ SMS_MAX_RETRIES=3
|
|||||||
SMS_RETRY_DELAY_SECONDS=180
|
SMS_RETRY_DELAY_SECONDS=180
|
||||||
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
SMS_DELIVERY_CHECK_DELAY_SECONDS=180
|
||||||
SMS_DELIVERY_MAX_CHECKS=15
|
SMS_DELIVERY_MAX_CHECKS=15
|
||||||
|
SMS_DEFINITIVE_DELIVERY_RETRY_AFTER_SECONDS=2700
|
||||||
|
SMS_HISTORICAL_DELIVERY_RETRY_STAGGER_SECONDS=30
|
||||||
SECURE_SSL_REDIRECT=true
|
SECURE_SSL_REDIRECT=true
|
||||||
SECURE_HSTS_SECONDS=31536000
|
SECURE_HSTS_SECONDS=31536000
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
|
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
from .models import NotificationJob,NotificationPolicy,SMSTemplate
|
||||||
@admin.register(NotificationJob)
|
@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)
|
@admin.register(SMSTemplate)
|
||||||
class SMSTemplateAdmin(admin.ModelAdmin):
|
class SMSTemplateAdmin(admin.ModelAdmin):
|
||||||
list_display=("event_type","unit","title","is_active","updated_at")
|
list_display=("event_type","unit","title","is_active","updated_at")
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ from apps.tickets.models import Ticket
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
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)
|
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}/"
|
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
|
||||||
@@ -158,11 +164,50 @@ def _record_success(job, result):
|
|||||||
job.last_error = ""
|
job.last_error = ""
|
||||||
job.provider_message_id = getattr(result, "provider_id", "") if result is not None else ""
|
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.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:
|
if job.channel == NotificationJob.Channel.SMS and job.provider_message_id:
|
||||||
job.delivery_status = NotificationJob.DeliveryStatus.PENDING
|
job.delivery_status = NotificationJob.DeliveryStatus.PENDING
|
||||||
job.delivery_next_check_at = timezone.now() + timedelta(seconds=settings.SMS_DELIVERY_CHECK_DELAY_SECONDS)
|
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):
|
def _process_delivery_checks(options):
|
||||||
if options.get("channel") not in (None, NotificationJob.Channel.SMS):
|
if options.get("channel") not in (None, NotificationJob.Channel.SMS):
|
||||||
return
|
return
|
||||||
@@ -207,11 +252,13 @@ def _process_delivery_checks(options):
|
|||||||
job.delivery_status = ""
|
job.delivery_status = ""
|
||||||
job.delivery_next_check_at = None
|
job.delivery_next_check_at = None
|
||||||
job.delivery_description = "این ارائهدهنده از استعلام دلیوری پشتیبانی نمیکند."
|
job.delivery_description = "این ارائهدهنده از استعلام دلیوری پشتیبانی نمیکند."
|
||||||
|
job.delivery_is_final = False
|
||||||
else:
|
else:
|
||||||
job.delivery_check_attempts += 1
|
job.delivery_check_attempts += 1
|
||||||
job.delivery_checked_at = timezone.now()
|
job.delivery_checked_at = timezone.now()
|
||||||
job.delivery_status_code = result.provider_code[:50]
|
job.delivery_status_code = result.provider_code[:50]
|
||||||
job.delivery_description = result.description[:500]
|
job.delivery_description = result.description[:500]
|
||||||
|
job.delivery_is_final = result.is_final
|
||||||
if result.is_final:
|
if result.is_final:
|
||||||
job.delivery_status = result.status
|
job.delivery_status = result.status
|
||||||
job.delivery_next_check_at = None
|
job.delivery_next_check_at = None
|
||||||
@@ -225,7 +272,9 @@ def _process_delivery_checks(options):
|
|||||||
job.save(update_fields=[
|
job.save(update_fields=[
|
||||||
"delivery_status", "delivery_status_code", "delivery_description",
|
"delivery_status", "delivery_status_code", "delivery_description",
|
||||||
"delivery_checked_at", "delivery_check_attempts", "delivery_next_check_at",
|
"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:
|
if job.ticket_id and job.ticket.group_dispatch_id:
|
||||||
from apps.tickets.services import refresh_group_dispatch_status
|
from apps.tickets.services import refresh_group_dispatch_status
|
||||||
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -63,5 +63,10 @@ class NotificationJob(models.Model):
|
|||||||
delivery_checked_at=models.DateTimeField(null=True,blank=True)
|
delivery_checked_at=models.DateTimeField(null=True,blank=True)
|
||||||
delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
|
delivery_check_attempts=models.PositiveSmallIntegerField(default=0)
|
||||||
delivery_next_check_at=models.DateTimeField(null=True,blank=True,db_index=True)
|
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:
|
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")]
|
indexes=[models.Index(fields=["status","next_attempt_at"],name="notif_due_idx"),models.Index(fields=["ticket","channel","status"],name="notif_ticket_chan_idx")]
|
||||||
@@ -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_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_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_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("/")
|
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان")
|
COMPANY_NAME = os.getenv("COMPANY_NAME", "لوله گستر گلپایگان")
|
||||||
WEBOTP_HOST = os.getenv("WEBOTP_HOST", "")
|
WEBOTP_HOST = os.getenv("WEBOTP_HOST", "")
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ x-django-service: &django-service
|
|||||||
SMS_RETRY_DELAY_SECONDS: ${SMS_RETRY_DELAY_SECONDS:-180}
|
SMS_RETRY_DELAY_SECONDS: ${SMS_RETRY_DELAY_SECONDS:-180}
|
||||||
SMS_DELIVERY_CHECK_DELAY_SECONDS: ${SMS_DELIVERY_CHECK_DELAY_SECONDS:-180}
|
SMS_DELIVERY_CHECK_DELAY_SECONDS: ${SMS_DELIVERY_CHECK_DELAY_SECONDS:-180}
|
||||||
SMS_DELIVERY_MAX_CHECKS: ${SMS_DELIVERY_MAX_CHECKS:-15}
|
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_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-true}
|
||||||
SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000}
|
SECURE_HSTS_SECONDS: ${SECURE_HSTS_SECONDS:-31536000}
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true}
|
SECURE_HSTS_INCLUDE_SUBDOMAINS: ${SECURE_HSTS_INCLUDE_SUBDOMAINS:-true}
|
||||||
|
|||||||
@@ -18,6 +18,22 @@
|
|||||||
|
|
||||||
const toPersianDigits = value => String(value).replace(/\d/g, digit => '۰۱۲۳۴۵۶۷۸۹'[digit]);
|
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) {
|
function updateProgress(number) {
|
||||||
progress.forEach(item => {
|
progress.forEach(item => {
|
||||||
const itemNumber = Number(item.dataset.wizardProgressItem);
|
const itemNumber = Number(item.dataset.wizardProgressItem);
|
||||||
@@ -35,7 +51,7 @@
|
|||||||
step.classList.toggle('is-active', active);
|
step.classList.toggle('is-active', active);
|
||||||
});
|
});
|
||||||
updateProgress(number);
|
updateProgress(number);
|
||||||
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
|
if (scroll) focusWizardCard();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearValidation(number) {
|
function clearValidation(number) {
|
||||||
@@ -349,19 +365,6 @@
|
|||||||
setupVoiceRecorder(voiceRoot);
|
setupVoiceRecorder(voiceRoot);
|
||||||
form.dataset.wizardEnhanced = '';
|
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 serverErrorStep = steps.find(step => step.querySelector('.errorlist'));
|
||||||
const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1;
|
const initialStep = serverErrorStep ? Number(serverErrorStep.dataset.wizardStep) : 1;
|
||||||
showStep(initialStep, { scroll: false });
|
showStep(initialStep, { scroll: false });
|
||||||
@@ -372,5 +375,5 @@
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const number = Number(serverErrorStep.dataset.wizardStep);
|
const number = Number(serverErrorStep.dataset.wizardStep);
|
||||||
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields }));
|
requestAnimationFrame(() => showValidation(number, { message: messages.join(' '), fields }));
|
||||||
} else focusWizardCard();
|
} else focusWizardCard({ respectExistingFocus: true });
|
||||||
})();
|
})();
|
||||||
@@ -195,12 +195,16 @@ def test_e2e_customer_wizard_validation_preview_edit_and_floating_submit(
|
|||||||
page.locator('[data-ticket-subject]').fill(subject)
|
page.locator('[data-ticket-subject]').fill(subject)
|
||||||
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
|
expect(page.locator('[data-ticket-subject]')).to_have_value(subject)
|
||||||
page.locator('[data-wizard-step="1"] [data-wizard-next]').click()
|
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="2"] [data-wizard-next]').click()
|
||||||
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
|
page.locator('[data-wizard-step="3"] [data-wizard-next]').click()
|
||||||
page.locator('textarea[name="body"]').fill("شرح خوانا برای پیشنمایش مرحله پنجم")
|
page.locator('textarea[name="body"]').fill("شرح خوانا برای پیشنمایش مرحله پنجم")
|
||||||
page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
|
page.locator('[data-wizard-step="4"] [data-wizard-next]').click()
|
||||||
|
|
||||||
expect(page.locator('[data-wizard-step="5"]')).to_be_visible()
|
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-subject]')).to_have_text(subject)
|
||||||
expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا")
|
expect(page.locator('[data-review-body]')).to_contain_text("شرح خوانا")
|
||||||
final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی")
|
final_submit = page.get_by_role("button", name="ثبت و ارسال نهایی")
|
||||||
|
|||||||
@@ -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_status==NotificationJob.DeliveryStatus.DELIVERED
|
||||||
assert job.delivery_check_attempts==3 and job.delivery_next_check_at is None
|
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
|
@pytest.mark.django_db
|
||||||
def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch):
|
def test_customer_ticket_created_sms_has_seven_day_magic_link(category,customer,settings,monkeypatch):
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|||||||
Reference in new issue
Block a user