add retry sms manually

This commit is contained in:
javad committed 2026-09-23 18:23:18 +03:30
1 parent 33930e972c
commit cb92abbb41
42 files changed
+1628 -1484

No files matched your search

+130 -130
View File
@@ -1,130 +1,130 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('messaging', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SLAConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.CharField(max_length=15, unique=True)),
('first_response_minutes', models.PositiveIntegerField()),
('resolution_minutes', models.PositiveIntegerField()),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='TicketCategory',
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)),
('title', models.CharField(max_length=100)),
('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True)),
('icon', models.CharField(blank=True, max_length=50)),
('sort_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['sort_order', 'title'],
},
),
migrations.CreateModel(
name='Ticket',
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)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('ticket_number', models.CharField(editable=False, max_length=30, unique=True)),
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
('status', models.CharField(choices=[('NEW', 'جدید'), ('OPEN', 'باز'), ('WAITING_CUSTOMER', 'در انتظار مشتری'), ('WAITING_STAFF', 'در انتظار کارشناس'), ('RESOLVED', 'حل\u200cشده'), ('CLOSED', 'بسته'), ('REOPENED', 'بازگشایی\u200cشده')], db_index=True, default='NEW', max_length=25)),
('priority', models.CharField(choices=[('NORMAL', 'عادی'), ('IMPORTANT', 'مهم'), ('URGENT', 'فوری')], db_index=True, default='NORMAL', max_length=15)),
('origin_visibility', models.CharField(choices=[('PUBLIC', 'عمومی'), ('SELECTED', 'کارشناس انتخابی')], default='PUBLIC', max_length=15)),
('last_activity_at', models.DateTimeField(db_index=True)),
('first_response_at', models.DateTimeField(blank=True, null=True)),
('last_customer_message_at', models.DateTimeField(blank=True, null=True)),
('last_staff_message_at', models.DateTimeField(blank=True, null=True)),
('first_response_due_at', models.DateTimeField(blank=True, null=True)),
('resolution_due_at', models.DateTimeField(blank=True, null=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('reopened_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='customer_tickets', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to=settings.AUTH_USER_MODEL)),
('selected_expert', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='selected_tickets', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.ticketcategory')),
],
options={
'ordering': ['-last_activity_at'],
'permissions': [('view_all_tickets', 'Can view all tickets'), ('claim_ticket', 'Can claim ticket'), ('transfer_ticket', 'Can transfer ticket'), ('share_ticket', 'Can share ticket'), ('change_ticket_priority', 'Can change ticket priority'), ('view_customer_history', 'Can view customer history')],
},
),
migrations.CreateModel(
name='TicketEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک'), ('ACCESS_REVOKED', 'لغو دسترسی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30)),
('metadata', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tickets.ticket')),
],
),
migrations.CreateModel(
name='TicketReadState',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_read_at', models.DateTimeField()),
('last_read_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='messaging.message')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TicketAccessGrant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('permission_level', models.CharField(choices=[('VIEW_ONLY', 'فقط مشاهده'), ('REPLY', 'پاسخ'), ('FULL', 'کامل')], default='FULL', max_length=15)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_ticket_access', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='tickets.ticket')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_grants', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')],
},
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'),
),
migrations.AddConstraint(
model_name='ticketreadstate',
constraint=models.UniqueConstraint(fields=('ticket', 'user'), name='unique_ticket_read_state'),
),
]
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('messaging', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SLAConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.CharField(max_length=15, unique=True)),
('first_response_minutes', models.PositiveIntegerField()),
('resolution_minutes', models.PositiveIntegerField()),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='TicketCategory',
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)),
('title', models.CharField(max_length=100)),
('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True)),
('icon', models.CharField(blank=True, max_length=50)),
('sort_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['sort_order', 'title'],
},
),
migrations.CreateModel(
name='Ticket',
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)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('ticket_number', models.CharField(editable=False, max_length=30, unique=True)),
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
('status', models.CharField(choices=[('NEW', 'جدید'), ('OPEN', 'باز'), ('WAITING_CUSTOMER', 'در انتظار مشتری'), ('WAITING_STAFF', 'در انتظار کارشناس'), ('RESOLVED', 'حل\u200cشده'), ('CLOSED', 'بسته'), ('REOPENED', 'بازگشایی\u200cشده')], db_index=True, default='NEW', max_length=25)),
('priority', models.CharField(choices=[('NORMAL', 'عادی'), ('IMPORTANT', 'مهم'), ('URGENT', 'فوری')], db_index=True, default='NORMAL', max_length=15)),
('origin_visibility', models.CharField(choices=[('PUBLIC', 'عمومی'), ('SELECTED', 'کارشناس انتخابی')], default='PUBLIC', max_length=15)),
('last_activity_at', models.DateTimeField(db_index=True)),
('first_response_at', models.DateTimeField(blank=True, null=True)),
('last_customer_message_at', models.DateTimeField(blank=True, null=True)),
('last_staff_message_at', models.DateTimeField(blank=True, null=True)),
('first_response_due_at', models.DateTimeField(blank=True, null=True)),
('resolution_due_at', models.DateTimeField(blank=True, null=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('reopened_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='customer_tickets', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to=settings.AUTH_USER_MODEL)),
('selected_expert', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='selected_tickets', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.ticketcategory')),
],
options={
'ordering': ['-last_activity_at'],
'permissions': [('view_all_tickets', 'Can view all tickets'), ('claim_ticket', 'Can claim ticket'), ('transfer_ticket', 'Can transfer ticket'), ('share_ticket', 'Can share ticket'), ('change_ticket_priority', 'Can change ticket priority'), ('view_customer_history', 'Can view customer history')],
},
),
migrations.CreateModel(
name='TicketEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک'), ('ACCESS_REVOKED', 'لغو دسترسی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30)),
('metadata', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tickets.ticket')),
],
),
migrations.CreateModel(
name='TicketReadState',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_read_at', models.DateTimeField()),
('last_read_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='messaging.message')),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TicketAccessGrant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('permission_level', models.CharField(choices=[('VIEW_ONLY', 'فقط مشاهده'), ('REPLY', 'پاسخ'), ('FULL', 'کامل')], default='FULL', max_length=15)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_ticket_access', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='tickets.ticket')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_grants', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')],
},
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'),
),
migrations.AddConstraint(
model_name='ticketreadstate',
constraint=models.UniqueConstraint(fields=('ticket', 'user'), name='unique_ticket_read_state'),
),
]
@@ -1,102 +1,102 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57
import apps.core.models
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0003_contactgroup_customerprofile_organization_name_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
('tickets', '0003_ticket_city_ticket_province'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TicketTag',
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)),
('name', models.CharField(max_length=80, unique=True)),
('slug', models.SlugField(max_length=80, unique=True)),
('color', models.CharField(default='#52677d', max_length=7)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'برچسب تیکت',
'verbose_name_plural': 'برچسب\u200cهای تیکت',
'ordering': ['name'],
},
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.AddField(
model_name='ticket',
name='assigned_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'),
),
migrations.AddField(
model_name='ticket',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_tickets', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticket',
name='delivery_type',
field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12),
),
migrations.AddField(
model_name='ticket',
name='owning_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'),
),
migrations.CreateModel(
name='GroupDispatch',
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)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
('body', models.TextField()),
('status', models.CharField(choices=[('DRAFT', 'پیش\u200cنویس'), ('QUEUED', 'در صف ارسال'), ('SENT', 'ارسال\u200cشده'), ('PARTIAL', 'ارسال ناقص'), ('CANCELLED', 'لغوشده')], db_index=True, default='DRAFT', max_length=12)),
('recipient_count', models.PositiveIntegerField(default=0)),
('dispatched_at', models.DateTimeField(blank=True, null=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tickets.ticketcategory')),
('contact_group', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dispatches', to='customers.contactgroup')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_group_dispatches', to=settings.AUTH_USER_MODEL)),
('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_dispatches', to='staff.organizationalunit')),
],
options={
'verbose_name': 'ارسال گروهی',
'verbose_name_plural': 'ارسال\u200cهای گروهی',
'ordering': ['-created_at'],
'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')],
},
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.AddField(
model_name='ticket',
name='group_dispatch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'),
),
migrations.AddField(
model_name='ticket',
name='tags',
field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['delivery_type', 'last_activity_at'], name='tickets_tic_deliver_c53084_idx'),
),
]
# Generated by Django 5.1.1 on 2026-09-08 09:57
import apps.core.models
import django.core.validators
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0003_contactgroup_customerprofile_organization_name_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
('tickets', '0003_ticket_city_ticket_province'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TicketTag',
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)),
('name', models.CharField(max_length=80, unique=True)),
('slug', models.SlugField(max_length=80, unique=True)),
('color', models.CharField(default='#52677d', max_length=7)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'برچسب تیکت',
'verbose_name_plural': 'برچسب\u200cهای تیکت',
'ordering': ['name'],
},
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.AddField(
model_name='ticket',
name='assigned_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'),
),
migrations.AddField(
model_name='ticket',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_tickets', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticket',
name='delivery_type',
field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12),
),
migrations.AddField(
model_name='ticket',
name='owning_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'),
),
migrations.CreateModel(
name='GroupDispatch',
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)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
('body', models.TextField()),
('status', models.CharField(choices=[('DRAFT', 'پیش\u200cنویس'), ('QUEUED', 'در صف ارسال'), ('SENT', 'ارسال\u200cشده'), ('PARTIAL', 'ارسال ناقص'), ('CANCELLED', 'لغوشده')], db_index=True, default='DRAFT', max_length=12)),
('recipient_count', models.PositiveIntegerField(default=0)),
('dispatched_at', models.DateTimeField(blank=True, null=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tickets.ticketcategory')),
('contact_group', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dispatches', to='customers.contactgroup')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_group_dispatches', to=settings.AUTH_USER_MODEL)),
('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_dispatches', to='staff.organizationalunit')),
],
options={
'verbose_name': 'ارسال گروهی',
'verbose_name_plural': 'ارسال\u200cهای گروهی',
'ordering': ['-created_at'],
'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')],
},
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.AddField(
model_name='ticket',
name='group_dispatch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'),
),
migrations.AddField(
model_name='ticket',
name='tags',
field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['delivery_type', 'last_activity_at'], name='tickets_tic_deliver_c53084_idx'),
),
]
@@ -1,24 +1,24 @@
# Generated by Django 5.1.1 on 2026-09-09 05:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0005_organizationalunit_accepts_external_tickets'),
('tickets', '0006_assign_legacy_contacts_to_sales'),
]
operations = [
migrations.AlterModelOptions(
name='ticketcategory',
options={'ordering': ['unit__name', 'sort_order', 'title']},
),
migrations.AddField(
model_name='ticketcategory',
name='unit',
field=models.ForeignKey(blank=True, help_text='برای دسته\u200cهای قدیمی عمومی می\u200cتواند خالی باشد؛ دسته\u200cهای جدید باید واحد داشته باشند.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ticket_categories', to='staff.organizationalunit', verbose_name='واحد سازمانی'),
),
]
# Generated by Django 5.1.1 on 2026-09-09 05:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('staff', '0005_organizationalunit_accepts_external_tickets'),
('tickets', '0006_assign_legacy_contacts_to_sales'),
]
operations = [
migrations.AlterModelOptions(
name='ticketcategory',
options={'ordering': ['unit__name', 'sort_order', 'title']},
),
migrations.AddField(
model_name='ticketcategory',
name='unit',
field=models.ForeignKey(blank=True, help_text='برای دسته\u200cهای قدیمی عمومی می\u200cتواند خالی باشد؛ دسته\u200cهای جدید باید واحد داشته باشند.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ticket_categories', to='staff.organizationalunit', verbose_name='واحد سازمانی'),
),
]
@@ -1,28 +1,28 @@
# Generated by Django 5.1.1 on 2026-09-09 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
# Generated by Django 5.1.1 on 2026-09-09 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0009_seed_customer_facing_units_and_categories'),
('staff', '0006_alter_organizationalunit_options_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='ticketcategory',
options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']},
),
migrations.AddField(
model_name='ticketcategory',
name='search_keywords',
field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'),
),
migrations.AlterField(
model_name='ticketcategory',
name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'),
),
]
]
operations = [
migrations.AlterModelOptions(
name='ticketcategory',
options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']},
),
migrations.AddField(
model_name='ticketcategory',
name='search_keywords',
field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'),
),
migrations.AlterField(
model_name='ticketcategory',
name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'),
),
]
@@ -1,18 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-09 09:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0010_alter_ticketcategory_options_and_more'),
]
operations = [
migrations.AddField(
model_name='ticketcategory',
name='show_in_customer_form',
field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'),
),
]
# Generated by Django 5.1.1 on 2026-09-09 09:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0010_alter_ticketcategory_options_and_more'),
]
operations = [
migrations.AddField(
model_name='ticketcategory',
name='show_in_customer_form',
field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'),
),
]
+1 -1
View File
@@ -1,4 +1,4 @@
from django.urls import path
from . import 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("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>/",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("",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("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>/",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")]
+62 -9
View File
@@ -33,6 +33,53 @@ from .policies import DispatchPolicy, TicketPolicy
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,revoke_access,share_ticket,transfer_ticket
MESSAGE_PAGE_SIZE=50
SMS_RETRYABLE_DELIVERY_STATUSES=(
NotificationJob.DeliveryStatus.UNDELIVERED,
NotificationJob.DeliveryStatus.EXPIRED,
NotificationJob.DeliveryStatus.BLOCKED,
NotificationJob.DeliveryStatus.UNKNOWN,
NotificationJob.DeliveryStatus.ERROR,
)
def _retryable_sms_jobs(jobs):
return jobs.filter(
Q(status=NotificationJob.Status.FAILED)
| Q(
status=NotificationJob.Status.SENT,
delivery_status__in=SMS_RETRYABLE_DELIVERY_STATUSES,
delivery_next_check_at__isnull=True,
)
)
def _sms_job_is_retryable(job):
return job.status == NotificationJob.Status.FAILED or (
job.status == NotificationJob.Status.SENT
and job.delivery_status in SMS_RETRYABLE_DELIVERY_STATUSES
and job.delivery_next_check_at is None
)
def _requeue_retryable_sms_jobs(jobs):
return _retryable_sms_jobs(jobs).update(
status=NotificationJob.Status.PENDING,
attempts=0,
next_attempt_at=timezone.now(),
last_error="",
provider_message_id="",
provider_status_code="",
sent_at=None,
delivery_status="",
delivery_status_code="",
delivery_description="",
delivery_checked_at=None,
delivery_check_attempts=0,
delivery_next_check_at=None,
delivery_is_final=False,
)
def conversation_page(queryset,before=None):
queryset=queryset.order_by("-created_at","-id")
if before:
@@ -395,13 +442,25 @@ def staff_detail(request,public_id):
mark_messages_read(conversation,request.user)
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]
sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
for sms_job in sms_jobs:
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
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
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":ShareForm(),"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,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
@staff_required
@require_POST
def staff_sms_retry(request,public_id,job_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
if not TicketPolicy.can_reply(request.user,ticket): raise PermissionDenied
jobs=ticket.notificationjob_set.filter(pk=job_id,channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET")
count=_requeue_retryable_sms_jobs(jobs)
messages.success(request,"پیامک ناموفق دوباره در صف ارسال قرار گرفت." if count else "این پیامک در وضعیت قابل تلاش مجدد نیست.")
return redirect("staff_tickets:detail",public_id=public_id)
@staff_required
def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id)
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
@@ -640,20 +699,14 @@ def staff_dispatch_resolve(request, public_id):
def staff_dispatch_retry(request, public_id):
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
job_id=request.POST.get("job_id","").strip()
if job_id:
if not job_id.isdigit():
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
jobs=jobs.filter(pk=int(job_id))
count=jobs.update(
status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(),
last_error="", provider_message_id="", provider_status_code="", sent_at=None,
delivery_status="", delivery_status_code="", delivery_description="",
delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None,
)
count=_requeue_retryable_sms_jobs(jobs)
refresh_group_dispatch_status(dispatch.pk)
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id)