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

+81 -42
View File
@@ -1,42 +1,81 @@
# Project operating rules # Project Operating & Agent Guidelines
## Development server ## 1. Codebase Navigation & Analysis (Ripwire vs. Graphify)
- Always bind the Django development server to `0.0.0.0:8000` so the project is reachable from phones and other devices through the computer's local-network IP address. Do not start it on `127.0.0.1` unless the user explicitly requests localhost-only access. > **Rule of Thumb:** Default to **Ripwire** for all hands-on coding, AST, edits, call graphs, and tests. Reserve **Graphify** strictly for high-level architectural understanding, cross-artifact relations, and non-code assets.
- Before starting or restarting Django on port `8000`, inspect the port and identify every existing listener.
- Stop the previous project server and its parent/child process tree before launching the replacement. CLI-only (never use MCP). Route strictly by question and artifact type. Never query both tools for the same sub-task; never execute in parallel. Always prefer `--expand` over reading entire files.
- After launch, verify that exactly one process is listening on port `8000`.
- Verify the listener is bound to `0.0.0.0:8000`, then fetch the site locally through `127.0.0.1:8000` as a health check. | Metric / Dimension | **Ripwire** (Deterministic AST) | **Graphify** (Semantic KG) |
- Fetch a changed page/static asset from the live server and confirm the expected new content is actually being served. |---|---|---|
- When frontend assets change, remind the user to perform a hard refresh, but do not treat browser cache as the cause until the live listener and served content have been verified. | **Primary Scope** | Local source code AST, symbol graphs, in-repo text | Cross-artifact: Code + Docs + PDFs + Media + SQL Schema |
- Keep the active server session identifier and shut it down cleanly before starting another instance. | **Best at** | Callers, callees, impact, tests, quality delta, `--arch`, `--doc-drift` | Conceptual "How A relates to B", ADR/RFC context, schemas |
| **Primary CLI** | `ripwire . --for="…"`, `--callers=SYM`, `--impact=SYM`, `--test-gate` | `graphify query "…"`, `graphify path "A" "B"`, `graphify explain "node"` |
## Graphify queries | **Boundary** | Prose meaning, non-code files, cross-asset concepts | Exact symbol references, impact analysis, code test gates |
- Treat questions about this codebase as Graphify queries first whenever `graphify-out/graph.json` exists. ### Routing Gate & Pipeline
- Before querying, expand the user's Persian question against the actual vocabulary in `graphify-out/graph.json`. 1. **Trivial edits (≤1 file, no exported symbols):** Modify directly without querying either tool.
- Use the best combination of Persian and English terms that are present in the graph vocabulary; never rely on automatic translation, stemming, or synonyms inside Graphify. 2. **Execution routing:**
- For code queries, prioritize the graph's English identifiers (class, function, model, template, route, and file names), because the structural code graph is predominantly English; add Persian terms when they actually exist in the graph, especially for requirements and documentation. - Hands-on coding, symbols, AST, usages, impact, and tests → **Ripwire**.
- Choose the traversal deliberately: BFS for broad relationships, DFS for dependency/data-flow traces, `path` for a shortest connection, and `explain` for one node and its neighbors. - Architecture discovery, cross-artifact concepts (PDF, media, docs, ADR/RFC), and SQL schemas → **Graphify**. (Note: `.sql` symbols use Ripwire; schema semantics use Graphify).
- Validate important Graphify findings against the referenced source code or project documentation before acting on them. - Ambiguous/Mixed tasks → Run `ripwire . --help-task="task"` to classify, then route by artifact.
3. **Graphify query rules (when selected):**
## Python package mirror - Requires `graphify-out/graph.json`; if absent, route code questions to Ripwire.
- **Bilingual alignment:** Match terms directly against `graphify-out/graph.json` vocabulary. Prioritize English code identifiers; include Persian terms only when verified in graph documentation/requirements. Never rely on internal translation or stemming.
- Use Liara's PyPI mirror for Python dependency resolution and installation: `https://package-mirror.liara.ir/repository/pypi/simple`. - **Traversal selection:** Use BFS for broad relationships, DFS for dependency traces, `path` for shortest linkage, and `explain` for node neighbors.
- Prefer the persistent user-level pip configuration over repeating `--index-url` in individual commands. - Always cross-verify critical graph findings against source code before editing.
- If the mirror is temporarily unavailable or missing a required package/version, report it before using the public PyPI index as a fallback. 4. **Pre-edit impact gate:** Before any non-trivial code change, inspect the target symbol with both `ripwire . --impact=SYM` and `ripwire . --uses=SYM`; if either query refuses or is ambiguous, resolve the symbol using the CLI guidance before editing.
5. **Post-edit pipeline:** Run `ripwire . --test-gate` followed by `ripwire . --quality-delta`.
## Frontend and PWA cache safety
---
- Every locally served frontend asset in templates must include `static_asset_version` in its URL.
- Keep the service-worker registration URL versioned with the same value and keep `updateViaCache: 'none'`; changing only the service-worker cache name is not sufficient. ## 2. Dynamic Help, Self-Correction & Staleness
- Any change to frontend caching or PWA lifecycle code must preserve the regression assertions in `tests/test_views.py` and pass `tests/e2e/test_pwa_versioning.py` with service workers enabled.
- Before blaming a user's browser cache, verify the live listener and served asset content according to the development-server rules above. Never stall on tool errors or hallucinate flags. Dynamic CLI help always takes precedence over memorized parameters.
## End-to-end browser tests - **Help Precedence:** Before using an unfamiliar or ambiguous capability, inspect `ripwire --help` or `ripwire --help=--FLAG`; if any instruction or remembered syntax conflicts with that output, the CLI help is authoritative.
- **Dynamic Inspection (on error or flag confusion):**
- Put browser scenarios under `tests/e2e/`, mark them `e2e`, and use Django's transactional `live_server`. - Ripwire: `ripwire --help`, `ripwire --help=--FLAG`, `ripwire . --doctor`, `ripwire . --help-task="task"`
- Use the managed Chromium and isolated BrowserContext provided by `tests/e2e/conftest.py`; do not launch browsers directly inside tests. Keep Playwright function-scoped because a session-scoped sync Playwright event loop conflicts with Django's synchronous test-database setup. - Graphify: `graphify --help`, `graphify <cmd> --help`
- Keep selectors and reusable browser actions in `tests/e2e/pages/` Page Objects, while tests remain focused on user-visible outcomes. - **Fallback & Retries (Cap at 1):**
- Never use fixed sleeps for synchronization. Use Playwright locators, `expect`, navigation, response, or application-state waits. - If a command fails or rejects flags: inspect its `--help` and retry once.
- Preserve automatic screenshot and trace capture on failure. PWA scenarios must explicitly enable service workers and carry the `pwa` marker. - If a tool returns empty results or an explicit refusal: try the alternative tool once; if still unresolved, report to user.
- If data is suspected to be stale: run `graphify update .` or `ripwire . --no-cache` and retry once.
- If a tool binary is missing (`command -v` check fails): state missing dependency clearly; do not guess syntax.
---
## 3. Git Operations & Safety
- **Read-only (Agent allowed):** `git status`, `git diff`, `git log`.
- **Human-gated (User execution only):** `git add`, `commit`, `push`, `checkout`, `merge`, `rebase`, and branch management. If needed, request user action and pause.
- **Exception:** `graphify hook install` is permitted (read-side only).
---
## 4. Development Server Lifecycle
- **Binding:** Always bind Django to `0.0.0.0:8000` (LAN accessible). Never use `127.0.0.1` unless explicitly requested.
- **Process Management:** Before launching, inspect port `8000`. Terminate previous project server instances and their child process trees cleanly.
- **Health Verification:** Confirm exactly one process listens on `0.0.0.0:8000`, then fetch `127.0.0.1:8000` locally to verify health.
- **Asset Confirmation:** When checking frontend changes, verify the served content on the live server before suggesting browser cache issues to the user.
---
## 5. Environment, Frontend & PWA Safety
- **Python virtual environment:** Use the repository `.venv` for all Python and Django commands during development, tests, dependency management, and server startup. From WSL, run `.venv/Scripts/python.exe`; from Windows, run `.venv\Scripts\python.exe`. Run pip with `<venv-python> -m pip` and Django with `<venv-python> manage.py ...`. Do not use system Python or pip or create a second environment unless the user requests it.
- **PyPI Mirror:** Use Liara's mirror: `https://package-mirror.liara.ir/repository/pypi/simple`. Use user-level pip configuration. Report downtime before falling back to public PyPI.
- **Asset Versioning:** All locally served frontend assets must include `static_asset_version` in their URLs.
- **PWA Lifecycle:** Keep the service worker registration URL versioned with `updateViaCache: 'none'`.
- **Cache Regression Gate:** Changes to frontend caching or PWA code must pass assertions in `tests/test_views.py` and `tests/e2e/test_pwa_versioning.py`.
---
## 6. End-to-End Browser Tests (Playwright)
- **Organization:** Place tests in `tests/e2e/`, mark with `e2e`, and run against Django's transactional `live_server`.
- **Scope & Lifecycle:** Use managed Chromium and BrowserContext from `tests/e2e/conftest.py`. Keep Playwright function-scoped to prevent event loop deadlocks with Django's synchronous database setup.
- **Page Objects:** Encapsulate interactions inside `tests/e2e/pages/`. Tests must assert user-visible outcomes only.
- **Synchronization:** Never use hardcoded sleeps. Rely on Playwright locators, `expect`, navigation, and state-change assertions.
- **Diagnostics:** Maintain automatic screenshot/trace capture on failure. PWA scenarios must enable service workers and use the `pwa` marker.
+78 -78
View File
@@ -1,78 +1,78 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='OTPChallenge', name='OTPChallenge',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mobile', models.CharField(db_index=True, max_length=16)), ('mobile', models.CharField(db_index=True, max_length=16)),
('purpose', models.CharField(choices=[('LOGIN', 'ورود')], default='LOGIN', max_length=20)), ('purpose', models.CharField(choices=[('LOGIN', 'ورود')], default='LOGIN', max_length=20)),
('code_hash', models.CharField(max_length=64)), ('code_hash', models.CharField(max_length=64)),
('expires_at', models.DateTimeField()), ('expires_at', models.DateTimeField()),
('attempt_count', models.PositiveSmallIntegerField(default=0)), ('attempt_count', models.PositiveSmallIntegerField(default=0)),
('max_attempts', models.PositiveSmallIntegerField(default=5)), ('max_attempts', models.PositiveSmallIntegerField(default=5)),
('verified_at', models.DateTimeField(blank=True, null=True)), ('verified_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('request_ip', models.GenericIPAddressField(blank=True, null=True)), ('request_ip', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=300)), ('user_agent', models.CharField(blank=True, max_length=300)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='User', name='User',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('mobile', models.CharField(blank=True, max_length=16, null=True, unique=True)), ('mobile', models.CharField(blank=True, max_length=16, null=True, unique=True)),
('account_type', models.CharField(choices=[('CUSTOMER', 'مشتری'), ('STAFF', 'کارشناس')], default='CUSTOMER', max_length=10)), ('account_type', models.CharField(choices=[('CUSTOMER', 'مشتری'), ('STAFF', 'کارشناس')], default='CUSTOMER', max_length=10)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],
options={ options={
'abstract': False, 'abstract': False,
}, },
managers=[ managers=[
('objects', django.contrib.auth.models.UserManager()), ('objects', django.contrib.auth.models.UserManager()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='TicketMagicLink', name='TicketMagicLink',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(max_length=64, unique=True)), ('token_hash', models.CharField(max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()), ('expires_at', models.DateTimeField()),
('used_at', models.DateTimeField(blank=True, null=True)), ('used_at', models.DateTimeField(blank=True, null=True)),
('revoked_at', models.DateTimeField(blank=True, null=True)), ('revoked_at', models.DateTimeField(blank=True, null=True)),
('created_reason', models.CharField(blank=True, max_length=100)), ('created_reason', models.CharField(blank=True, max_length=100)),
('last_used_ip', models.GenericIPAddressField(blank=True, null=True)), ('last_used_ip', models.GenericIPAddressField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
), ),
] ]
+22 -22
View File
@@ -1,22 +1,22 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('accounts', '0001_initial'), ('accounts', '0001_initial'),
('tickets', '0001_initial'), ('tickets', '0001_initial'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='ticket', name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to='tickets.ticket'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to='tickets.ticket'),
), ),
] ]
@@ -1,38 +1,38 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57 # Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounts', '0002_initial'), ('accounts', '0002_initial'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='MagicLinkAccessLog', name='MagicLinkAccessLog',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('opened_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('opened_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)), ('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=500)), ('user_agent', models.CharField(blank=True, max_length=500)),
], ],
options={ options={
'verbose_name': 'بازدید لینک جادویی', 'verbose_name': 'بازدید لینک جادویی',
'verbose_name_plural': 'بازدیدهای لینک جادویی', 'verbose_name_plural': 'بازدیدهای لینک جادویی',
'ordering': ['-opened_at'], 'ordering': ['-opened_at'],
}, },
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='ticketmagiclink', name='ticketmagiclink',
options={'permissions': [('view_all_magic_links', 'Can audit magic links across all units')]}, options={'permissions': [('view_all_magic_links', 'Can audit magic links across all units')]},
), ),
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='created_by', name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_magic_links', to=settings.AUTH_USER_MODEL), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_magic_links', to=settings.AUTH_USER_MODEL),
), ),
] ]
@@ -1,45 +1,45 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57 # Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounts', '0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more'), ('accounts', '0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='created_unit', name='created_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='magic_links', to='staff.organizationalunit'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='magic_links', to='staff.organizationalunit'),
), ),
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='last_user_agent', name='last_user_agent',
field=models.CharField(blank=True, max_length=500), field=models.CharField(blank=True, max_length=500),
), ),
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='recipient_mobile', name='recipient_mobile',
field=models.CharField(blank=True, max_length=16), field=models.CharField(blank=True, max_length=16),
), ),
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='recipient_name', name='recipient_name',
field=models.CharField(blank=True, max_length=200), field=models.CharField(blank=True, max_length=200),
), ),
migrations.AddField( migrations.AddField(
model_name='ticketmagiclink', model_name='ticketmagiclink',
name='view_count', name='view_count',
field=models.PositiveIntegerField(default=0), field=models.PositiveIntegerField(default=0),
), ),
migrations.AddField( migrations.AddField(
model_name='magiclinkaccesslog', model_name='magiclinkaccesslog',
name='magic_link', name='magic_link',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='accounts.ticketmagiclink'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='accounts.ticketmagiclink'),
), ),
] ]
@@ -1,27 +1,27 @@
# Generated by Django 5.1.1 on 2026-09-08 15:32 # Generated by Django 5.1.1 on 2026-09-08 15:32
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounts', '0004_ticketmagiclink_created_unit_and_more'), ('accounts', '0004_ticketmagiclink_created_unit_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
('tickets', '0006_assign_legacy_contacts_to_sales'), ('tickets', '0006_assign_legacy_contacts_to_sales'),
] ]
operations = [ operations = [
migrations.AddIndex( migrations.AddIndex(
model_name='magiclinkaccesslog', model_name='magiclinkaccesslog',
index=models.Index(fields=['magic_link', 'opened_at'], name='magic_opened_idx'), index=models.Index(fields=['magic_link', 'opened_at'], name='magic_opened_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticketmagiclink', model_name='ticketmagiclink',
index=models.Index(fields=['created_unit', 'created_at'], name='magic_unit_created_idx'), index=models.Index(fields=['created_unit', 'created_at'], name='magic_unit_created_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticketmagiclink', model_name='ticketmagiclink',
index=models.Index(fields=['expires_at', 'revoked_at'], name='magic_validity_idx'), index=models.Index(fields=['expires_at', 'revoked_at'], name='magic_validity_idx'),
), ),
] ]
+49 -49
View File
@@ -1,49 +1,49 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import apps.attachments.models import apps.attachments.models
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Attachment', name='Attachment',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('file', models.FileField(upload_to=apps.attachments.models.attachment_path)), ('file', models.FileField(upload_to=apps.attachments.models.attachment_path)),
('original_name', models.CharField(max_length=255)), ('original_name', models.CharField(max_length=255)),
('stored_name', models.CharField(max_length=255)), ('stored_name', models.CharField(max_length=255)),
('relative_path', models.CharField(max_length=500)), ('relative_path', models.CharField(max_length=500)),
('attachment_type', models.CharField(choices=[('IMAGE', 'تصویر'), ('VIDEO', 'ویدئو'), ('VOICE', 'صدا'), ('PDF', 'PDF'), ('WORD', 'Word'), ('EXCEL', 'Excel'), ('ZIP', 'ZIP'), ('RAR', 'RAR'), ('OTHER', 'سایر')], max_length=20)), ('attachment_type', models.CharField(choices=[('IMAGE', 'تصویر'), ('VIDEO', 'ویدئو'), ('VOICE', 'صدا'), ('PDF', 'PDF'), ('WORD', 'Word'), ('EXCEL', 'Excel'), ('ZIP', 'ZIP'), ('RAR', 'RAR'), ('OTHER', 'سایر')], max_length=20)),
('mime_type', models.CharField(max_length=150)), ('mime_type', models.CharField(max_length=150)),
('extension', models.CharField(max_length=20)), ('extension', models.CharField(max_length=20)),
('file_size', models.PositiveBigIntegerField()), ('file_size', models.PositiveBigIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
], ],
options={ options={
'permissions': [('download_attachment', 'Can download attachment'), ('manage_attachment_policy', 'Can manage attachment policy')], 'permissions': [('download_attachment', 'Can download attachment'), ('manage_attachment_policy', 'Can manage attachment policy')],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='AttachmentPolicy', name='AttachmentPolicy',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment_type', models.CharField(max_length=20, unique=True)), ('attachment_type', models.CharField(max_length=20, unique=True)),
('is_enabled', models.BooleanField(default=True)), ('is_enabled', models.BooleanField(default=True)),
('max_file_size', models.PositiveBigIntegerField()), ('max_file_size', models.PositiveBigIntegerField()),
('max_files_per_message', models.PositiveSmallIntegerField(default=10)), ('max_files_per_message', models.PositiveSmallIntegerField(default=10)),
('allowed_extensions', models.JSONField(default=list)), ('allowed_extensions', models.JSONField(default=list)),
('allowed_mime_types', models.JSONField(default=list)), ('allowed_mime_types', models.JSONField(default=list)),
('sort_order', models.PositiveIntegerField(default=0)), ('sort_order', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
], ],
), ),
] ]
+29 -29
View File
@@ -1,29 +1,29 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('attachments', '0001_initial'), ('attachments', '0001_initial'),
('messaging', '0001_initial'), ('messaging', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='attachment', model_name='attachment',
name='message', name='message',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attachments', to='messaging.message'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attachments', to='messaging.message'),
), ),
migrations.AddField( migrations.AddField(
model_name='attachment', model_name='attachment',
name='uploaded_by', name='uploaded_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
), ),
] ]
@@ -1,19 +1,19 @@
# Generated by Django 5.1.1 on 2026-09-05 08:31 # Generated by Django 5.1.1 on 2026-09-05 08:31
import apps.attachments.models import apps.attachments.models
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('attachments', '0002_initial'), ('attachments', '0002_initial'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='attachment', model_name='attachment',
name='file', name='file',
field=models.FileField(max_length=500, upload_to=apps.attachments.models.attachment_path), field=models.FileField(max_length=500, upload_to=apps.attachments.models.attachment_path),
), ),
] ]
+47 -47
View File
@@ -1,47 +1,47 @@
# Generated by Django 5.1.1 on 2026-09-09 03:38 # Generated by Django 5.1.1 on 2026-09-09 03:38
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='PageTitle', name='PageTitle',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('route_name', models.CharField(help_text='مانند accounts:login', max_length=160, unique=True)), ('route_name', models.CharField(help_text='مانند accounts:login', max_length=160, unique=True)),
('title', models.CharField(max_length=240)), ('title', models.CharField(max_length=240)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
], ],
options={ options={
'verbose_name': 'عنوان صفحه', 'verbose_name': 'عنوان صفحه',
'verbose_name_plural': 'عنوان صفحات', 'verbose_name_plural': 'عنوان صفحات',
'ordering': ('route_name',), 'ordering': ('route_name',),
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SiteBranding', name='SiteBranding',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('site_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)), ('site_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
('header_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)), ('header_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
('header_subtitle', models.CharField(default='درخواست روشن، پیگیری مطمئن', max_length=200)), ('header_subtitle', models.CharField(default='درخواست روشن، پیگیری مطمئن', max_length=200)),
('staff_brand_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)), ('staff_brand_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)),
('staff_brand_subtitle', models.CharField(default='مرکز ارتباط با مشتری', max_length=200)), ('staff_brand_subtitle', models.CharField(default='مرکز ارتباط با مشتری', max_length=200)),
('staff_panel_label', models.CharField(default='پنل کارشناسان', max_length=120)), ('staff_panel_label', models.CharField(default='پنل کارشناسان', max_length=120)),
('pwa_title', models.CharField(default='واحد فروش', max_length=80)), ('pwa_title', models.CharField(default='واحد فروش', max_length=80)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
], ],
options={ options={
'verbose_name': 'تنظیمات عنوان و برند', 'verbose_name': 'تنظیمات عنوان و برند',
'verbose_name_plural': 'تنظیمات عنوان و برند', 'verbose_name_plural': 'تنظیمات عنوان و برند',
}, },
), ),
] ]
@@ -1,53 +1,53 @@
# Generated by Django 5.1.1 on 2026-09-09 04:55 # Generated by Django 5.1.1 on 2026-09-09 04:55
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0002_seed_branding_and_page_titles'), ('core', '0002_seed_branding_and_page_titles'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='developer_credit_html', name='developer_credit_html',
field=models.TextField(blank=True, help_text='فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.', verbose_name='HTML سفارشی امضای توسعه\u200cدهنده'), field=models.TextField(blank=True, help_text='فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.', verbose_name='HTML سفارشی امضای توسعه\u200cدهنده'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='developer_credit_text', name='developer_credit_text',
field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129', max_length=500, verbose_name='متن امضای توسعه\u200cدهنده'), field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129', max_length=500, verbose_name='متن امضای توسعه\u200cدهنده'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='login_intro_body', name='login_intro_body',
field=models.TextField(default='درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.', verbose_name='متن معرفی ورود'), field=models.TextField(default='درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.', verbose_name='متن معرفی ورود'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='login_intro_enabled', name='login_intro_enabled',
field=models.BooleanField(default=True, verbose_name='نمایش بخش معرفی ورود'), field=models.BooleanField(default=True, verbose_name='نمایش بخش معرفی ورود'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='login_intro_eyebrow', name='login_intro_eyebrow',
field=models.CharField(default='ارتباط مستقیم با تیم فروش', max_length=200, verbose_name='بالانویس معرفی ورود'), field=models.CharField(default='ارتباط مستقیم با تیم فروش', max_length=200, verbose_name='بالانویس معرفی ورود'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='login_intro_heading', name='login_intro_heading',
field=models.TextField(default='پاسخ روشن،\nپیگیری ساده.', verbose_name='عنوان معرفی ورود'), field=models.TextField(default='پاسخ روشن،\nپیگیری ساده.', verbose_name='عنوان معرفی ورود'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='login_intro_icon', name='login_intro_icon',
field=models.CharField(blank=True, default='✦', max_length=32, verbose_name='آیکون معرفی ورود'), field=models.CharField(blank=True, default='✦', max_length=32, verbose_name='آیکون معرفی ورود'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='use_developer_credit_html', name='use_developer_credit_html',
field=models.BooleanField(default=False, help_text='با فعال\u200cسازی، HTML بدون escape نمایش داده می\u200cشود؛ فقط مدیر ارشد باید آن را ویرایش کند.', verbose_name='استفاده از HTML سفارشی'), field=models.BooleanField(default=False, help_text='با فعال\u200cسازی، HTML بدون escape نمایش داده می\u200cشود؛ فقط مدیر ارشد باید آن را ویرایش کند.', verbose_name='استفاده از HTML سفارشی'),
), ),
] ]
@@ -1,28 +1,28 @@
# Generated by Django 5.1.1 on 2026-09-09 05:04 # Generated by Django 5.1.1 on 2026-09-09 05:04
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0003_sitebranding_developer_credit_html_and_more'), ('core', '0003_sitebranding_developer_credit_html_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_sidebar_credit_html', name='staff_sidebar_credit_html',
field=models.TextField(blank=True, help_text='مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.', verbose_name='HTML سفارشی امضای sidebar کارکنان'), field=models.TextField(blank=True, help_text='مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.', verbose_name='HTML سفارشی امضای sidebar کارکنان'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_sidebar_credit_text', name='staff_sidebar_credit_text',
field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت', max_length=500, verbose_name='متن امضای پایین sidebar کارکنان'), field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت', max_length=500, verbose_name='متن امضای پایین sidebar کارکنان'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='use_staff_sidebar_credit_html', name='use_staff_sidebar_credit_html',
field=models.BooleanField(default=False, verbose_name='استفاده از HTML سفارشی در sidebar'), field=models.BooleanField(default=False, verbose_name='استفاده از HTML سفارشی در sidebar'),
), ),
] ]
@@ -1,93 +1,93 @@
# Generated by Django 5.1.1 on 2026-09-09 05:18 # Generated by Django 5.1.1 on 2026-09-09 05:18
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0004_sitebranding_staff_sidebar_credit_html_and_more'), ('core', '0004_sitebranding_staff_sidebar_credit_html_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='company_logo', name='company_logo',
field=models.ImageField(blank=True, upload_to='branding/logo/', verbose_name='لوگوی اصلی'), field=models.ImageField(blank=True, upload_to='branding/logo/', verbose_name='لوگوی اصلی'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='customer_tickets_hero_body', name='customer_tickets_hero_body',
field=models.TextField(default='موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق\u200cتر و سریع\u200cتر پیگیری کند.', verbose_name='متن صفحه تیکت\u200cهای مشتری'), field=models.TextField(default='موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق\u200cتر و سریع\u200cتر پیگیری کند.', verbose_name='متن صفحه تیکت\u200cهای مشتری'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='customer_tickets_hero_enabled', name='customer_tickets_hero_enabled',
field=models.BooleanField(default=True, verbose_name='نمایش معرفی صفحه تیکت\u200cهای مشتری'), field=models.BooleanField(default=True, verbose_name='نمایش معرفی صفحه تیکت\u200cهای مشتری'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='customer_tickets_hero_eyebrow', name='customer_tickets_hero_eyebrow',
field=models.CharField(default='مرکز ارتباط با فروش', max_length=200, verbose_name='بالانویس صفحه تیکت\u200cهای مشتری'), field=models.CharField(default='مرکز ارتباط با فروش', max_length=200, verbose_name='بالانویس صفحه تیکت\u200cهای مشتری'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='customer_tickets_hero_heading', name='customer_tickets_hero_heading',
field=models.TextField(default='هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.', verbose_name='عنوان صفحه تیکت\u200cهای مشتری'), field=models.TextField(default='هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.', verbose_name='عنوان صفحه تیکت\u200cهای مشتری'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='favicon', name='favicon',
field=models.ImageField(blank=True, upload_to='branding/favicon/', verbose_name='Favicon'), field=models.ImageField(blank=True, upload_to='branding/favicon/', verbose_name='Favicon'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='pwa_description', name='pwa_description',
field=models.CharField(default='ثبت و پیگیری درخواست\u200cهای فروش لوله گستر گلپایگان', max_length=300, verbose_name='توضیح PWA'), field=models.CharField(default='ثبت و پیگیری درخواست\u200cهای فروش لوله گستر گلپایگان', max_length=300, verbose_name='توضیح PWA'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='pwa_icon', name='pwa_icon',
field=models.ImageField(blank=True, upload_to='branding/pwa/', verbose_name='آیکون PWA'), field=models.ImageField(blank=True, upload_to='branding/pwa/', verbose_name='آیکون PWA'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='pwa_install_help', name='pwa_install_help',
field=models.CharField(default='برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.', max_length=300, verbose_name='راهنمای نصب PWA'), field=models.CharField(default='برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.', max_length=300, verbose_name='راهنمای نصب PWA'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='pwa_install_label', name='pwa_install_label',
field=models.CharField(default='نصب گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='عنوان پیشنهاد نصب PWA'), field=models.CharField(default='نصب گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='عنوان پیشنهاد نصب PWA'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='pwa_name', name='pwa_name',
field=models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='نام کامل PWA'), field=models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='نام کامل PWA'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_login_intro_body', name='staff_login_intro_body',
field=models.TextField(default='صف درخواست\u200cها، SLA، اولویت\u200cها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.', verbose_name='متن ورود کارشناسان'), field=models.TextField(default='صف درخواست\u200cها، SLA، اولویت\u200cها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.', verbose_name='متن ورود کارشناسان'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_login_intro_enabled', name='staff_login_intro_enabled',
field=models.BooleanField(default=True, verbose_name='نمایش معرفی ورود کارشناسان'), field=models.BooleanField(default=True, verbose_name='نمایش معرفی ورود کارشناسان'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_login_intro_eyebrow', name='staff_login_intro_eyebrow',
field=models.CharField(default='پنل کارشناسان فروش', max_length=200, verbose_name='بالانویس ورود کارشناسان'), field=models.CharField(default='پنل کارشناسان فروش', max_length=200, verbose_name='بالانویس ورود کارشناسان'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_login_intro_heading', name='staff_login_intro_heading',
field=models.TextField(default='پاسخ\u200cهای بهتر،\nفروش هوشمندتر.', verbose_name='عنوان ورود کارشناسان'), field=models.TextField(default='پاسخ\u200cهای بهتر،\nفروش هوشمندتر.', verbose_name='عنوان ورود کارشناسان'),
), ),
migrations.AddField( migrations.AddField(
model_name='sitebranding', model_name='sitebranding',
name='staff_login_intro_icon', name='staff_login_intro_icon',
field=models.CharField(blank=True, default='◈', max_length=32, verbose_name='آیکون معرفی ورود کارشناسان'), field=models.CharField(blank=True, default='◈', max_length=32, verbose_name='آیکون معرفی ورود کارشناسان'),
), ),
] ]
+32 -32
View File
@@ -1,32 +1,32 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='CustomerProfile', name='CustomerProfile',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('first_name', models.CharField(blank=True, max_length=100)), ('first_name', models.CharField(blank=True, max_length=100)),
('last_name', models.CharField(blank=True, max_length=100)), ('last_name', models.CharField(blank=True, max_length=100)),
('customer_type', models.CharField(blank=True, choices=[('PERSON', 'شخص'), ('CONSUMER', 'مصرف\u200cکننده نهایی'), ('SHOP', 'فروشگاه'), ('REPRESENTATIVE', 'نمایندگی'), ('COMPANY', 'شرکت'), ('OTHER', 'سایر')], max_length=20)), ('customer_type', models.CharField(blank=True, choices=[('PERSON', 'شخص'), ('CONSUMER', 'مصرف\u200cکننده نهایی'), ('SHOP', 'فروشگاه'), ('REPRESENTATIVE', 'نمایندگی'), ('COMPANY', 'شرکت'), ('OTHER', 'سایر')], max_length=20)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer_profile', to=settings.AUTH_USER_MODEL)), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer_profile', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'abstract': False, 'abstract': False,
}, },
), ),
] ]
@@ -1,41 +1,41 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57 # Generated by Django 5.1.1 on 2026-09-08 09:57
import apps.core.models import apps.core.models
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('customers', '0002_customerprofile_city_customerprofile_province'), ('customers', '0002_customerprofile_city_customerprofile_province'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='ContactGroup', name='ContactGroup',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=150)), ('name', models.CharField(max_length=150)),
('description', models.TextField(blank=True)), ('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
], ],
options={ options={
'verbose_name': 'گروه مخاطبان', 'verbose_name': 'گروه مخاطبان',
'verbose_name_plural': 'گروه\u200cهای مخاطبان', 'verbose_name_plural': 'گروه\u200cهای مخاطبان',
'ordering': ['unit__name', 'name'], 'ordering': ['unit__name', 'name'],
}, },
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
), ),
migrations.AddField( migrations.AddField(
model_name='customerprofile', model_name='customerprofile',
name='organization_name', name='organization_name',
field=models.CharField(blank=True, max_length=180), field=models.CharField(blank=True, max_length=180),
), ),
migrations.AddField( migrations.AddField(
model_name='customerprofile', model_name='customerprofile',
name='stakeholder_type', name='stakeholder_type',
field=models.CharField(choices=[('CUSTOMER', 'مشتری'), ('SUPPLIER', 'تامین\u200cکننده'), ('CARRIER', 'باربری'), ('OTHER', 'سایر')], db_index=True, default='CUSTOMER', max_length=20), field=models.CharField(choices=[('CUSTOMER', 'مشتری'), ('SUPPLIER', 'تامین\u200cکننده'), ('CARRIER', 'باربری'), ('OTHER', 'سایر')], db_index=True, default='CUSTOMER', max_length=20),
), ),
] ]
@@ -1,29 +1,29 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57 # Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('customers', '0003_contactgroup_customerprofile_organization_name_and_more'), ('customers', '0003_contactgroup_customerprofile_organization_name_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='contactgroup', model_name='contactgroup',
name='unit', name='unit',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to='staff.organizationalunit'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to='staff.organizationalunit'),
), ),
migrations.AddField( migrations.AddField(
model_name='customerprofile', model_name='customerprofile',
name='groups', name='groups',
field=models.ManyToManyField(blank=True, related_name='contacts', to='customers.contactgroup'), field=models.ManyToManyField(blank=True, related_name='contacts', to='customers.contactgroup'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='contactgroup', model_name='contactgroup',
constraint=models.UniqueConstraint(fields=('unit', 'name'), name='unique_contact_group_name_per_unit'), constraint=models.UniqueConstraint(fields=('unit', 'name'), name='unique_contact_group_name_per_unit'),
), ),
] ]
+53 -53
View File
@@ -1,53 +1,53 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='MessageMention', name='MessageMention',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='MessageRevision', name='MessageRevision',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField()), ('body', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Message', name='Message',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('body', models.TextField(blank=True)), ('body', models.TextField(blank=True)),
('message_type', models.CharField(choices=[('PUBLIC', 'عمومی'), ('INTERNAL_NOTE', 'یادداشت داخلی')], default='PUBLIC', max_length=20)), ('message_type', models.CharField(choices=[('PUBLIC', 'عمومی'), ('INTERNAL_NOTE', 'یادداشت داخلی')], default='PUBLIC', max_length=20)),
('is_internal', models.BooleanField(default=False)), ('is_internal', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('edited_at', models.DateTimeField(blank=True, null=True)), ('edited_at', models.DateTimeField(blank=True, null=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)), ('deleted_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='authored_messages', to=settings.AUTH_USER_MODEL)), ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='authored_messages', to=settings.AUTH_USER_MODEL)),
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='deleted_messages', to=settings.AUTH_USER_MODEL)), ('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='deleted_messages', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'ordering': ['created_at', 'id'], 'ordering': ['created_at', 'id'],
'permissions': [('add_internal_note', 'Can add internal note'), ('edit_own_message', 'Can edit own message'), ('delete_own_message', 'Can delete own message'), ('view_deleted_message_content', 'Can view deleted content'), ('view_message_revisions', 'Can view revisions')], 'permissions': [('add_internal_note', 'Can add internal note'), ('edit_own_message', 'Can edit own message'), ('delete_own_message', 'Can delete own message'), ('view_deleted_message_content', 'Can view deleted content'), ('view_message_revisions', 'Can view revisions')],
}, },
), ),
] ]
+48 -48
View File
@@ -1,48 +1,48 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('messaging', '0001_initial'), ('messaging', '0001_initial'),
('tickets', '0001_initial'), ('tickets', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='message', model_name='message',
name='ticket', name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_messages', to='tickets.ticket'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_messages', to='tickets.ticket'),
), ),
migrations.AddField( migrations.AddField(
model_name='messagemention', model_name='messagemention',
name='mentioned_user', name='mentioned_user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
), ),
migrations.AddField( migrations.AddField(
model_name='messagemention', model_name='messagemention',
name='message', name='message',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mentions', to='messaging.message'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mentions', to='messaging.message'),
), ),
migrations.AddField( migrations.AddField(
model_name='messagerevision', model_name='messagerevision',
name='edited_by', name='edited_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
), ),
migrations.AddField( migrations.AddField(
model_name='messagerevision', model_name='messagerevision',
name='message', name='message',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='messaging.message'), field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='messaging.message'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='messagemention', model_name='messagemention',
constraint=models.UniqueConstraint(fields=('message', 'mentioned_user'), name='unique_message_mention'), constraint=models.UniqueConstraint(fields=('message', 'mentioned_user'), name='unique_message_mention'),
), ),
] ]
@@ -1,29 +1,29 @@
# Generated by Django 5.1.1 on 2026-09-06 15:45 # Generated by Django 5.1.1 on 2026-09-06 15:45
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('messaging', '0002_initial'), ('messaging', '0002_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='MessageReadReceipt', name='MessageReadReceipt',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read_at', models.DateTimeField(auto_now_add=True, db_index=True)), ('read_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_receipts', to='messaging.message')), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_receipts', to='messaging.message')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='message_read_receipts', to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='message_read_receipts', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'ordering': ['read_at', 'id'], 'ordering': ['read_at', 'id'],
'constraints': [models.UniqueConstraint(fields=('message', 'user'), name='unique_message_read_receipt')], 'constraints': [models.UniqueConstraint(fields=('message', 'user'), name='unique_message_read_receipt')],
}, },
), ),
] ]
+37 -37
View File
@@ -1,37 +1,37 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('tickets', '0001_initial'), ('tickets', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='NotificationJob', name='NotificationJob',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel', models.CharField(choices=[('SMS', 'پیامک'), ('BALE', 'بله')], max_length=10)), ('channel', models.CharField(choices=[('SMS', 'پیامک'), ('BALE', 'بله')], max_length=10)),
('event_type', models.CharField(max_length=50)), ('event_type', models.CharField(max_length=50)),
('payload', models.JSONField(default=dict)), ('payload', models.JSONField(default=dict)),
('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال ارسال'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق')], db_index=True, default='PENDING', max_length=15)), ('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال ارسال'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق')], db_index=True, default='PENDING', max_length=15)),
('attempts', models.PositiveSmallIntegerField(default=0)), ('attempts', models.PositiveSmallIntegerField(default=0)),
('next_attempt_at', models.DateTimeField(db_index=True)), ('next_attempt_at', models.DateTimeField(db_index=True)),
('last_error', models.TextField(blank=True)), ('last_error', models.TextField(blank=True)),
('dedupe_key', models.CharField(max_length=200, unique=True)), ('dedupe_key', models.CharField(max_length=200, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('sent_at', models.DateTimeField(blank=True, null=True)), ('sent_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_notifications', to=settings.AUTH_USER_MODEL)), ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer_notifications', to=settings.AUTH_USER_MODEL)),
('staff_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='staff_notifications', to=settings.AUTH_USER_MODEL)), ('staff_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='staff_notifications', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')), ('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')),
], ],
), ),
] ]
@@ -1,6 +1,6 @@
# Generated by Django 5.1.1 on 2026-09-07 04:56 # Generated by Django 5.1.1 on 2026-09-07 04:56
import apps.core.models import apps.core.models
from django.db import migrations, models from django.db import migrations, models
TAGS=["magic_link","first_name","last_name","ticket_number","subject","province","city","expert_name"] TAGS=["magic_link","first_name","last_name","ticket_number","subject","province","city","expert_name"]
@@ -14,26 +14,26 @@ def seed_sms_templates(apps,schema_editor):
for event,(title,body) in DEFAULTS.items(): model.objects.get_or_create(event_type=event,defaults={"title":title,"body":body,"allowed_tags":TAGS}) for event,(title,body) in DEFAULTS.items(): model.objects.get_or_create(event_type=event,defaults={"title":title,"body":body,"allowed_tags":TAGS})
def remove_sms_templates(apps,schema_editor): def remove_sms_templates(apps,schema_editor):
apps.get_model("notifications","SMSTemplate").objects.filter(event_type__in=DEFAULTS).delete() apps.get_model("notifications","SMSTemplate").objects.filter(event_type__in=DEFAULTS).delete()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('notifications', '0001_initial'), ('notifications', '0001_initial'),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='SMSTemplate', name='SMSTemplate',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('event_type', models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50, unique=True)), ('event_type', models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50, unique=True)),
('title', models.CharField(max_length=120)), ('title', models.CharField(max_length=120)),
('body', models.TextField(help_text='تگ\u200cها را مانند {magic_link} در متن قرار دهید.')), ('body', models.TextField(help_text='تگ\u200cها را مانند {magic_link} در متن قرار دهید.')),
('allowed_tags', models.JSONField(blank=True, default=list)), ('allowed_tags', models.JSONField(blank=True, default=list)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
], ],
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
), ),
migrations.RunPython(seed_sms_templates,remove_sms_templates), migrations.RunPython(seed_sms_templates,remove_sms_templates),
@@ -1,33 +1,33 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57 # Generated by Django 5.1.1 on 2026-09-08 09:57
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('notifications', '0004_customer_reply_bale_policy'), ('notifications', '0004_customer_reply_bale_policy'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='smstemplate', model_name='smstemplate',
name='unit', name='unit',
field=models.ForeignKey(blank=True, help_text='خالی یعنی الگوی عمومی و fallback.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sms_templates', to='staff.organizationalunit'), field=models.ForeignKey(blank=True, help_text='خالی یعنی الگوی عمومی و fallback.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sms_templates', to='staff.organizationalunit'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='smstemplate', model_name='smstemplate',
name='event_type', name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50), field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='smstemplate', model_name='smstemplate',
constraint=models.UniqueConstraint(fields=('unit', 'event_type'), name='unique_sms_template_per_unit'), constraint=models.UniqueConstraint(fields=('unit', 'event_type'), name='unique_sms_template_per_unit'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='smstemplate', model_name='smstemplate',
constraint=models.UniqueConstraint(condition=models.Q(('unit__isnull', True)), fields=('event_type',), name='unique_global_sms_template_event'), constraint=models.UniqueConstraint(condition=models.Q(('unit__isnull', True)), fields=('event_type',), name='unique_global_sms_template_event'),
), ),
] ]
@@ -1,6 +1,6 @@
# Generated by Django 5.1.1 on 2026-09-08 10:05 # Generated by Django 5.1.1 on 2026-09-08 10:05
import apps.notifications.models import apps.notifications.models
from django.db import migrations, models from django.db import migrations, models
TAGS = ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"] TAGS = ["magic_link", "first_name", "last_name", "recipient_name", "unit_name", "company_name", "ticket_number", "subject", "province", "city", "expert_name"]
@@ -14,15 +14,15 @@ def add_supported_tags(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'), ('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='smstemplate', model_name='smstemplate',
name='allowed_tags', name='allowed_tags',
field=models.JSONField(blank=True, default=apps.notifications.models.sms_template_tags), field=models.JSONField(blank=True, default=apps.notifications.models.sms_template_tags),
), ),
migrations.RunPython(add_supported_tags, migrations.RunPython.noop), migrations.RunPython(add_supported_tags, migrations.RunPython.noop),
@@ -1,24 +1,24 @@
# Generated by Django 5.1.1 on 2026-09-08 15:32 # Generated by Django 5.1.1 on 2026-09-08 15:32
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('notifications', '0006_alter_smstemplate_allowed_tags'), ('notifications', '0006_alter_smstemplate_allowed_tags'),
('tickets', '0006_assign_legacy_contacts_to_sales'), ('tickets', '0006_assign_legacy_contacts_to_sales'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.AddIndex( migrations.AddIndex(
model_name='notificationjob', model_name='notificationjob',
index=models.Index(fields=['status', 'next_attempt_at'], name='notif_due_idx'), index=models.Index(fields=['status', 'next_attempt_at'], name='notif_due_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='notificationjob', model_name='notificationjob',
index=models.Index(fields=['ticket', 'channel', 'status'], name='notif_ticket_chan_idx'), index=models.Index(fields=['ticket', 'channel', 'status'], name='notif_ticket_chan_idx'),
), ),
] ]
@@ -1,18 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-08 15:40 # Generated by Django 5.1.1 on 2026-09-08 15:40
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('notifications', '0007_notificationjob_notif_due_idx_and_more'), ('notifications', '0007_notificationjob_notif_due_idx_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='notificationjob', model_name='notificationjob',
name='provider_message_id', name='provider_message_id',
field=models.CharField(blank=True, max_length=500), field=models.CharField(blank=True, max_length=500),
), ),
] ]
+37 -37
View File
@@ -1,37 +1,37 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='StaffProfile', name='StaffProfile',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('display_name', models.CharField(max_length=150)), ('display_name', models.CharField(max_length=150)),
('avatar', models.ImageField(blank=True, upload_to='staff/avatars/')), ('avatar', models.ImageField(blank=True, upload_to='staff/avatars/')),
('mobile', models.CharField(blank=True, max_length=16)), ('mobile', models.CharField(blank=True, max_length=16)),
('bale_chat_id', models.CharField(blank=True, max_length=100)), ('bale_chat_id', models.CharField(blank=True, max_length=100)),
('bale_notifications_enabled', models.BooleanField(default=True)), ('bale_notifications_enabled', models.BooleanField(default=True)),
('is_customer_selectable', models.BooleanField(default=True)), ('is_customer_selectable', models.BooleanField(default=True)),
('sort_order', models.PositiveIntegerField(default=0)), ('sort_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_profile', to=settings.AUTH_USER_MODEL)), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'ordering': ['sort_order', 'display_name'], 'ordering': ['sort_order', 'display_name'],
}, },
), ),
] ]
@@ -1,28 +1,28 @@
# Generated by Django 5.1.1 on 2026-09-06 10:20 # Generated by Django 5.1.1 on 2026-09-06 10:20
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0001_initial'), ('staff', '0001_initial'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='staffprofile', model_name='staffprofile',
name='first_name', name='first_name',
field=models.CharField(blank=True, max_length=100), field=models.CharField(blank=True, max_length=100),
), ),
migrations.AddField( migrations.AddField(
model_name='staffprofile', model_name='staffprofile',
name='job_title', name='job_title',
field=models.CharField(blank=True, default='کارشناس فروش', max_length=150), field=models.CharField(blank=True, default='کارشناس فروش', max_length=150),
), ),
migrations.AddField( migrations.AddField(
model_name='staffprofile', model_name='staffprofile',
name='last_name', name='last_name',
field=models.CharField(blank=True, max_length=100), field=models.CharField(blank=True, max_length=100),
), ),
] ]
+18 -18
View File
@@ -1,18 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-07 11:02 # Generated by Django 5.1.1 on 2026-09-07 11:02
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0002_staffprofile_first_name_staffprofile_job_title_and_more'), ('staff', '0002_staffprofile_first_name_staffprofile_job_title_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='staffprofile', model_name='staffprofile',
name='role', name='role',
field=models.CharField(choices=[('SALES_EXPERT', 'کارشناس فروش'), ('SALES_SUPERVISOR', 'سرپرست فروش'), ('SALES_MANAGER', 'مدیر فروش'), ('CEO', 'مدیرعامل'), ('GENERAL_ADMIN', 'مدیرکل Admin')], db_index=True, default='SALES_EXPERT', max_length=24), field=models.CharField(choices=[('SALES_EXPERT', 'کارشناس فروش'), ('SALES_SUPERVISOR', 'سرپرست فروش'), ('SALES_MANAGER', 'مدیر فروش'), ('CEO', 'مدیرعامل'), ('GENERAL_ADMIN', 'مدیرکل Admin')], db_index=True, default='SALES_EXPERT', max_length=24),
), ),
] ]
@@ -1,61 +1,61 @@
# Generated by Django 5.1.1 on 2026-09-08 09:57 # Generated by Django 5.1.1 on 2026-09-08 09:57
import apps.core.models import apps.core.models
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ('auth', '0012_alter_user_first_name_max_length'),
('staff', '0003_staffprofile_role'), ('staff', '0003_staffprofile_role'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='OrganizationalUnit', name='OrganizationalUnit',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=120, unique=True)), ('name', models.CharField(max_length=120, unique=True)),
('slug', models.SlugField(max_length=80, unique=True)), ('slug', models.SlugField(max_length=80, unique=True)),
('description', models.TextField(blank=True)), ('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
('permissions', models.ManyToManyField(blank=True, help_text='مجوزهایی که به تمام اعضای فعال این واحد داده می\u200cشود.', related_name='organizational_units', to='auth.permission')), ('permissions', models.ManyToManyField(blank=True, help_text='مجوزهایی که به تمام اعضای فعال این واحد داده می\u200cشود.', related_name='organizational_units', to='auth.permission')),
], ],
options={ options={
'verbose_name': 'واحد سازمانی', 'verbose_name': 'واحد سازمانی',
'verbose_name_plural': 'واحدهای سازمانی', 'verbose_name_plural': 'واحدهای سازمانی',
'ordering': ['name'], 'ordering': ['name'],
}, },
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
), ),
migrations.AddField( migrations.AddField(
model_name='staffprofile', model_name='staffprofile',
name='primary_unit', name='primary_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='primary_staff', to='staff.organizationalunit'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='primary_staff', to='staff.organizationalunit'),
), ),
migrations.CreateModel( migrations.CreateModel(
name='UnitMembership', name='UnitMembership',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('is_primary', models.BooleanField(default=False)), ('is_primary', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
('permissions', models.ManyToManyField(blank=True, help_text='مجوزهای اضافه مخصوص این عضو در همین واحد.', related_name='unit_memberships', to='auth.permission')), ('permissions', models.ManyToManyField(blank=True, help_text='مجوزهای اضافه مخصوص این عضو در همین واحد.', related_name='unit_memberships', to='auth.permission')),
('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='staff.organizationalunit')), ('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='staff.organizationalunit')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_memberships', to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_memberships', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'verbose_name': 'عضویت واحد', 'verbose_name': 'عضویت واحد',
'verbose_name_plural': 'عضویت\u200cهای واحد', 'verbose_name_plural': 'عضویت\u200cهای واحد',
'ordering': ['unit__name', 'user__username'], 'ordering': ['unit__name', 'user__username'],
'constraints': [models.UniqueConstraint(fields=('user', 'unit'), name='unique_unit_membership')], 'constraints': [models.UniqueConstraint(fields=('user', 'unit'), name='unique_unit_membership')],
}, },
), ),
] ]
@@ -1,18 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-09 05:43 # Generated by Django 5.1.1 on 2026-09-09 05:43
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='organizationalunit', model_name='organizationalunit',
name='accepts_external_tickets', name='accepts_external_tickets',
field=models.BooleanField(default=True, help_text='اگر غیرفعال باشد، مشتریان نمی\u200cتوانند مستقیماً برای این واحد تیکت ثبت کنند.', verbose_name='قابل انتخاب برای تیکت مشتری'), field=models.BooleanField(default=True, help_text='اگر غیرفعال باشد، مشتریان نمی\u200cتوانند مستقیماً برای این واحد تیکت ثبت کنند.', verbose_name='قابل انتخاب برای تیکت مشتری'),
), ),
] ]
@@ -1,27 +1,27 @@
# Generated by Django 5.1.1 on 2026-09-09 09:31 # Generated by Django 5.1.1 on 2026-09-09 09:31
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0005_organizationalunit_accepts_external_tickets'), ('staff', '0005_organizationalunit_accepts_external_tickets'),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='organizationalunit', name='organizationalunit',
options={'ordering': ['sort_order', 'name', 'pk'], 'verbose_name': 'واحد سازمانی', 'verbose_name_plural': 'واحدهای سازمانی'}, options={'ordering': ['sort_order', 'name', 'pk'], 'verbose_name': 'واحد سازمانی', 'verbose_name_plural': 'واحدهای سازمانی'},
), ),
migrations.AddField( migrations.AddField(
model_name='organizationalunit', model_name='organizationalunit',
name='search_keywords', name='search_keywords',
field=models.TextField(blank=True, help_text='واژه\u200cها و عبارت\u200cهای مترادف را با فاصله یا ویرگول وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'), field=models.TextField(blank=True, help_text='واژه\u200cها و عبارت\u200cهای مترادف را با فاصله یا ویرگول وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'),
), ),
migrations.AddField( migrations.AddField(
model_name='organizationalunit', model_name='organizationalunit',
name='sort_order', name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'), 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 # Generated by Django 5.1.1 on 2026-09-09 09:40
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0006_alter_organizationalunit_options_and_more'), ('staff', '0006_alter_organizationalunit_options_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='organizationalunit', model_name='organizationalunit',
name='show_in_customer_form', name='show_in_customer_form',
field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'), field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'),
), ),
] ]
@@ -1,18 +1,18 @@
# Generated by Django 5.1.1 on 2026-09-10 05:57 # Generated by Django 5.1.1 on 2026-09-10 05:57
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0008_unitmembership_ticket_scope_and_roles'), ('staff', '0008_unitmembership_ticket_scope_and_roles'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='staffprofile', model_name='staffprofile',
name='sort_order', name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, help_text='عدد کمتر، کارشناس را زودتر در فهرست همان واحد نمایش می\u200cدهد.', verbose_name='ترتیب نمایش در انتخاب کارشناس'), field=models.PositiveIntegerField(db_index=True, default=0, help_text='عدد کمتر، کارشناس را زودتر در فهرست همان واحد نمایش می\u200cدهد.', verbose_name='ترتیب نمایش در انتخاب کارشناس'),
), ),
] ]
+130 -130
View File
@@ -1,130 +1,130 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03 # Generated by Django 5.1.1 on 2026-09-05 07:03
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('messaging', '0001_initial'), ('messaging', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='SLAConfig', name='SLAConfig',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.CharField(max_length=15, unique=True)), ('priority', models.CharField(max_length=15, unique=True)),
('first_response_minutes', models.PositiveIntegerField()), ('first_response_minutes', models.PositiveIntegerField()),
('resolution_minutes', models.PositiveIntegerField()), ('resolution_minutes', models.PositiveIntegerField()),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='TicketCategory', name='TicketCategory',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=100)), ('title', models.CharField(max_length=100)),
('slug', models.SlugField(unique=True)), ('slug', models.SlugField(unique=True)),
('description', models.TextField(blank=True)), ('description', models.TextField(blank=True)),
('icon', models.CharField(blank=True, max_length=50)), ('icon', models.CharField(blank=True, max_length=50)),
('sort_order', models.PositiveIntegerField(default=0)), ('sort_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
], ],
options={ options={
'ordering': ['sort_order', 'title'], 'ordering': ['sort_order', 'title'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Ticket', name='Ticket',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('ticket_number', models.CharField(editable=False, max_length=30, 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)])), ('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)), ('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)), ('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)), ('origin_visibility', models.CharField(choices=[('PUBLIC', 'عمومی'), ('SELECTED', 'کارشناس انتخابی')], default='PUBLIC', max_length=15)),
('last_activity_at', models.DateTimeField(db_index=True)), ('last_activity_at', models.DateTimeField(db_index=True)),
('first_response_at', models.DateTimeField(blank=True, null=True)), ('first_response_at', models.DateTimeField(blank=True, null=True)),
('last_customer_message_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)), ('last_staff_message_at', models.DateTimeField(blank=True, null=True)),
('first_response_due_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)), ('resolution_due_at', models.DateTimeField(blank=True, null=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)), ('resolved_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)), ('closed_at', models.DateTimeField(blank=True, null=True)),
('reopened_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)), ('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)), ('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)), ('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')), ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.ticketcategory')),
], ],
options={ options={
'ordering': ['-last_activity_at'], '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')], '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( migrations.CreateModel(
name='TicketEvent', name='TicketEvent',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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)), ('metadata', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)), ('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)), ('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')), ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tickets.ticket')),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='TicketReadState', name='TicketReadState',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_read_at', models.DateTimeField()), ('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')), ('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')), ('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)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='TicketAccessGrant', name='TicketAccessGrant',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('permission_level', models.CharField(choices=[('VIEW_ONLY', 'فقط مشاهده'), ('REPLY', 'پاسخ'), ('FULL', 'کامل')], default='FULL', max_length=15)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(blank=True, null=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)), ('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')), ('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)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_grants', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')], 'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')],
}, },
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticket', model_name='ticket',
index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'), index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticket', model_name='ticket',
index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'), index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticket', model_name='ticket',
index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'), index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='ticketreadstate', model_name='ticketreadstate',
constraint=models.UniqueConstraint(fields=('ticket', 'user'), name='unique_ticket_read_state'), 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 # Generated by Django 5.1.1 on 2026-09-08 09:57
import apps.core.models import apps.core.models
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('customers', '0003_contactgroup_customerprofile_organization_name_and_more'), ('customers', '0003_contactgroup_customerprofile_organization_name_and_more'),
('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'),
('tickets', '0003_ticket_city_ticket_province'), ('tickets', '0003_ticket_city_ticket_province'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='TicketTag', name='TicketTag',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=80, unique=True)), ('name', models.CharField(max_length=80, unique=True)),
('slug', models.SlugField(max_length=80, unique=True)), ('slug', models.SlugField(max_length=80, unique=True)),
('color', models.CharField(default='#52677d', max_length=7)), ('color', models.CharField(default='#52677d', max_length=7)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
], ],
options={ options={
'verbose_name': 'برچسب تیکت', 'verbose_name': 'برچسب تیکت',
'verbose_name_plural': 'برچسب\u200cهای تیکت', 'verbose_name_plural': 'برچسب\u200cهای تیکت',
'ordering': ['name'], 'ordering': ['name'],
}, },
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
), ),
migrations.AddField( migrations.AddField(
model_name='ticket', model_name='ticket',
name='assigned_unit', name='assigned_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'),
), ),
migrations.AddField( migrations.AddField(
model_name='ticket', model_name='ticket',
name='created_by', 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), 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( migrations.AddField(
model_name='ticket', model_name='ticket',
name='delivery_type', name='delivery_type',
field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12), field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12),
), ),
migrations.AddField( migrations.AddField(
model_name='ticket', model_name='ticket',
name='owning_unit', name='owning_unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'),
), ),
migrations.CreateModel( migrations.CreateModel(
name='GroupDispatch', name='GroupDispatch',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])), ('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])),
('body', models.TextField()), ('body', models.TextField()),
('status', models.CharField(choices=[('DRAFT', 'پیش\u200cنویس'), ('QUEUED', 'در صف ارسال'), ('SENT', 'ارسال\u200cشده'), ('PARTIAL', 'ارسال ناقص'), ('CANCELLED', 'لغوشده')], db_index=True, default='DRAFT', max_length=12)), ('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)), ('recipient_count', models.PositiveIntegerField(default=0)),
('dispatched_at', models.DateTimeField(blank=True, null=True)), ('dispatched_at', models.DateTimeField(blank=True, null=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tickets.ticketcategory')), ('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')), ('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)), ('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')), ('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_dispatches', to='staff.organizationalunit')),
], ],
options={ options={
'verbose_name': 'ارسال گروهی', 'verbose_name': 'ارسال گروهی',
'verbose_name_plural': 'ارسال\u200cهای گروهی', 'verbose_name_plural': 'ارسال\u200cهای گروهی',
'ordering': ['-created_at'], 'ordering': ['-created_at'],
'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')], 'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')],
}, },
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
), ),
migrations.AddField( migrations.AddField(
model_name='ticket', model_name='ticket',
name='group_dispatch', name='group_dispatch',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'),
), ),
migrations.AddField( migrations.AddField(
model_name='ticket', model_name='ticket',
name='tags', name='tags',
field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'), field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticket', model_name='ticket',
index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'), index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name='ticket', model_name='ticket',
index=models.Index(fields=['delivery_type', 'last_activity_at'], name='tickets_tic_deliver_c53084_idx'), 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 # Generated by Django 5.1.1 on 2026-09-09 05:43
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('staff', '0005_organizationalunit_accepts_external_tickets'), ('staff', '0005_organizationalunit_accepts_external_tickets'),
('tickets', '0006_assign_legacy_contacts_to_sales'), ('tickets', '0006_assign_legacy_contacts_to_sales'),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='ticketcategory', name='ticketcategory',
options={'ordering': ['unit__name', 'sort_order', 'title']}, options={'ordering': ['unit__name', 'sort_order', 'title']},
), ),
migrations.AddField( migrations.AddField(
model_name='ticketcategory', model_name='ticketcategory',
name='unit', 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='واحد سازمانی'), 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 # Generated by Django 5.1.1 on 2026-09-09 09:31
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tickets', '0009_seed_customer_facing_units_and_categories'), ('tickets', '0009_seed_customer_facing_units_and_categories'),
('staff', '0006_alter_organizationalunit_options_and_more'), ('staff', '0006_alter_organizationalunit_options_and_more'),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='ticketcategory', name='ticketcategory',
options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']}, options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']},
), ),
migrations.AddField( migrations.AddField(
model_name='ticketcategory', model_name='ticketcategory',
name='search_keywords', name='search_keywords',
field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'), field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='ticketcategory', model_name='ticketcategory',
name='sort_order', name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'), 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 # Generated by Django 5.1.1 on 2026-09-09 09:40
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tickets', '0010_alter_ticketcategory_options_and_more'), ('tickets', '0010_alter_ticketcategory_options_and_more'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='ticketcategory', model_name='ticketcategory',
name='show_in_customer_form', name='show_in_customer_form',
field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'), field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'),
), ),
] ]
+1 -1
View File
@@ -1,4 +1,4 @@
from django.urls import path from django.urls import path
from . import views from . import views
app_name="staff_tickets" 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 .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 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 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): def conversation_page(queryset,before=None):
queryset=queryset.order_by("-created_at","-id") queryset=queryset.order_by("-created_at","-id")
if before: if before:
@@ -395,13 +442,25 @@ def staff_detail(request,public_id):
mark_messages_read(conversation,request.user) 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}) 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] 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 latest=conversation[-1] if conversation else None
group_dispatch = ticket.group_dispatch 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"))) 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 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 ""}) 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 @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): def staff_messages(request,public_id):
ticket=get_object_or_404(Ticket,public_id=public_id) ticket=get_object_or_404(Ticket,public_id=public_id)
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied 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): def staff_dispatch_retry(request, public_id):
dispatch=get_object_or_404(GroupDispatch,public_id=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 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)
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))
job_id=request.POST.get("job_id","").strip() job_id=request.POST.get("job_id","").strip()
if job_id: if job_id:
if not job_id.isdigit(): if not job_id.isdigit():
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.") messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id) return redirect("staff_tickets:dispatch_detail",public_id=public_id)
jobs=jobs.filter(pk=int(job_id)) jobs=jobs.filter(pk=int(job_id))
count=jobs.update( count=_requeue_retryable_sms_jobs(jobs)
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,
)
refresh_group_dispatch_status(dispatch.pk) refresh_group_dispatch_status(dispatch.pk)
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.") messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
return redirect("staff_tickets:dispatch_detail",public_id=public_id) return redirect("staff_tickets:dispatch_detail",public_id=public_id)
+1 -1
View File
@@ -17,7 +17,7 @@
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel> <aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
<button type="button" class="panel-close" data-close-panel>×</button> <button type="button" class="panel-close" data-close-panel>×</button>
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section> <section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
{% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامک‌های مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمان‌بندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}</details>{% endfor %}</div></section>{% endif %} {% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامک‌های مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمان‌بندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}{% if sms_job.sms_retryable %}<form method="post" action="{% url 'staff_tickets:sms_retry' ticket.public_id sms_job.pk %}" class="inline-retry-form">{% csrf_token %}<button type="submit" aria-label="تلاش مجدد برای ارسال این پیامک" title="تلاش مجدد"><span aria-hidden="true">↻</span> تلاش مجدد</button></form>{% endif %}</details>{% endfor %}</div></section>{% endif %}
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section> <section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %} {% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیام‌ها، فایل‌ها و سوابق تیکت برای همیشه حذف می‌شوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابل‌بازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
</aside> </aside>
+54 -2
View File
@@ -9,6 +9,7 @@ from apps.staff.models import OrganizationalUnit, StaffProfile
from apps.tickets.models import Ticket, TicketCategory from apps.tickets.models import Ticket, TicketCategory
from apps.tickets.services import create_ticket, claim_ticket, reply_ticket from apps.tickets.services import create_ticket, claim_ticket, reply_ticket
from apps.messaging.models import Message,MessageReadReceipt from apps.messaging.models import Message,MessageReadReceipt
from apps.notifications.models import NotificationJob
from apps.customers.models import CustomerProfile from apps.customers.models import CustomerProfile
from apps.core.persian_text import normalize_persian_text from apps.core.persian_text import normalize_persian_text
@@ -256,8 +257,6 @@ def test_staff_reply_feedback_and_new_reply_action_remain_available(client):
@pytest.mark.django_db @pytest.mark.django_db
def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client): def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client):
from apps.core.templatetags.jalali import jalali_datetime from apps.core.templatetags.jalali import jalali_datetime
from apps.notifications.models import NotificationJob
category=TicketCategory.objects.create(title="رهگیری پیامک",slug="sms-timeline") category=TicketCategory.objects.create(title="رهگیری پیامک",slug="sms-timeline")
customer=User.objects.create_user(username="sms-timeline-customer",mobile="09120000031",account_type="CUSTOMER") customer=User.objects.create_user(username="sms-timeline-customer",mobile="09120000031",account_type="CUSTOMER")
expert=User.objects.create_user(username="sms-timeline-expert",mobile="09120000032",account_type="STAFF") expert=User.objects.create_user(username="sms-timeline-expert",mobile="09120000032",account_type="STAFF")
@@ -288,6 +287,59 @@ def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client):
assert jalali_datetime(delivered_at) in content assert jalali_datetime(delivered_at) in content
assert "123456789" in content assert "123456789" in content
@pytest.mark.django_db
def test_staff_can_manually_retry_only_failed_sms_for_the_current_ticket(client):
category=TicketCategory.objects.create(title="تلاش مجدد پیامک",slug="manual-sms-retry")
customer=User.objects.create_user(username="sms-retry-customer",mobile="09120000041",account_type="CUSTOMER")
expert=User.objects.create_user(username="sms-retry-expert",mobile="09120000042",account_type="STAFF")
outsider=User.objects.create_user(username="sms-retry-outsider",mobile="09120000043",account_type="STAFF")
StaffProfile.objects.create(user=expert,display_name="کارشناس تلاش مجدد")
StaffProfile.objects.create(user=outsider,display_name="کارشناس نامرتبط")
ticket=create_ticket(customer=customer,category=category,subject="بررسی retry پیامک",selected_expert=expert,body="شرح")
failed_job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
NotificationJob.objects.filter(pk=failed_job.pk).update(
status=NotificationJob.Status.FAILED,attempts=4,last_error="خطای درگاه",
provider_message_id="failed-provider-id",provider_status_code="-19",
delivery_status=NotificationJob.DeliveryStatus.ERROR,delivery_status_code="-19",
delivery_description="ارسال قطعی ناموفق بود.",delivery_checked_at=timezone.now(),
delivery_check_attempts=3,delivery_is_final=True,
)
failed_job.refresh_from_db()
provisional_job=NotificationJob.objects.create(
channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=customer,
status=NotificationJob.Status.SENT,next_attempt_at=timezone.now(),dedupe_key="test:provisional-manual-retry",
delivery_status=NotificationJob.DeliveryStatus.UNKNOWN,delivery_next_check_at=timezone.now()+timedelta(minutes=3),
)
retry_url=reverse("staff_tickets:sms_retry",args=[ticket.public_id,failed_job.pk])
provisional_retry_url=reverse("staff_tickets:sms_retry",args=[ticket.public_id,provisional_job.pk])
client.force_login(expert)
content=client.get(reverse("staff_tickets:detail",args=[ticket.public_id])).content.decode()
assert retry_url in content
assert 'aria-label="تلاش مجدد برای ارسال این پیامک"' in content
assert provisional_retry_url not in content
assert client.get(retry_url).status_code==405
client.force_login(outsider)
assert client.post(retry_url).status_code==403
failed_job.refresh_from_db()
assert failed_job.status==NotificationJob.Status.FAILED
client.force_login(expert)
response=client.post(retry_url,follow=True)
assert response.status_code==200
assert "پیامک ناموفق دوباره در صف ارسال قرار گرفت." in response.content.decode()
failed_job.refresh_from_db(); provisional_job.refresh_from_db()
assert failed_job.status==NotificationJob.Status.PENDING
assert failed_job.attempts==0 and failed_job.next_attempt_at<=timezone.now()
assert failed_job.last_error=="" and failed_job.provider_message_id=="" and failed_job.provider_status_code==""
assert failed_job.sent_at is None and failed_job.delivery_status=="" and failed_job.delivery_status_code==""
assert failed_job.delivery_description=="" and failed_job.delivery_checked_at is None
assert failed_job.delivery_check_attempts==0 and failed_job.delivery_next_check_at is None
assert failed_job.delivery_is_final is False
assert provisional_job.status==NotificationJob.Status.SENT
@pytest.mark.django_db @pytest.mark.django_db
def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client): def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client):
expert=User.objects.create_superuser(username="sidebar-admin",mobile="09384999655",password="test-pass",account_type="STAFF") expert=User.objects.create_superuser(username="sidebar-admin",mobile="09384999655",password="test-pass",account_type="STAFF")