From cb92abbb41527fff143e697be190ecca49aa393b Mon Sep 17 00:00:00 2001 From: javad Date: Wed, 23 Sep 2026 18:23:18 +0330 Subject: [PATCH] add retry sms manually --- AGENTS.md | 123 ++++++--- apps/accounts/migrations/0001_initial.py | 156 +++++------ apps/accounts/migrations/0002_initial.py | 44 +-- ..._alter_ticketmagiclink_options_and_more.py | 76 ++--- ...4_ticketmagiclink_created_unit_and_more.py | 90 +++--- ...linkaccesslog_magic_opened_idx_and_more.py | 54 ++-- apps/attachments/migrations/0001_initial.py | 98 +++---- apps/attachments/migrations/0002_initial.py | 58 ++-- .../migrations/0003_alter_attachment_file.py | 38 +-- apps/core/migrations/0001_initial.py | 94 +++---- ...branding_developer_credit_html_and_more.py | 106 +++---- ...ding_staff_sidebar_credit_html_and_more.py | 56 ++-- ...0005_sitebranding_company_logo_and_more.py | 186 ++++++------- apps/customers/migrations/0001_initial.py | 64 ++--- ...tomerprofile_organization_name_and_more.py | 82 +++--- ...up_unit_customerprofile_groups_and_more.py | 58 ++-- apps/messaging/migrations/0001_initial.py | 106 +++---- apps/messaging/migrations/0002_initial.py | 96 +++---- .../migrations/0003_messagereadreceipt.py | 58 ++-- apps/notifications/migrations/0001_initial.py | 74 ++--- .../migrations/0002_smstemplate.py | 44 +-- ...t_alter_smstemplate_event_type_and_more.py | 66 ++--- .../0006_alter_smstemplate_allowed_tags.py | 22 +- ..._notificationjob_notif_due_idx_and_more.py | 48 ++-- ...008_notificationjob_provider_message_id.py | 36 +-- apps/staff/migrations/0001_initial.py | 74 ++--- ...st_name_staffprofile_job_title_and_more.py | 56 ++-- .../migrations/0003_staffprofile_role.py | 36 +-- ...unit_staffprofile_primary_unit_and_more.py | 122 ++++---- ...nizationalunit_accepts_external_tickets.py | 36 +-- ...ter_organizationalunit_options_and_more.py | 54 ++-- ...rganizationalunit_show_in_customer_form.py | 36 +-- .../0009_alter_staffprofile_sort_order.py | 36 +-- apps/tickets/migrations/0001_initial.py | 260 +++++++++--------- ...ssigned_unit_ticket_created_by_and_more.py | 204 +++++++------- ...ketcategory_options_ticketcategory_unit.py | 48 ++-- ...0_alter_ticketcategory_options_and_more.py | 50 ++-- ...11_ticketcategory_show_in_customer_form.py | 36 +-- apps/tickets/staff_urls.py | 2 +- apps/tickets/views.py | 71 ++++- templates/tickets/staff_detail.html | 2 +- tests/test_views.py | 56 +++- 42 files changed, 1628 insertions(+), 1484 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8146cd5..11c3a02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,42 +1,81 @@ -# Project operating rules - -## Development server - -- 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. -- 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. -- 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. -- 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. -- Keep the active server session identifier and shut it down cleanly before starting another instance. - -## Graphify queries - -- Treat questions about this codebase as Graphify queries first whenever `graphify-out/graph.json` exists. -- Before querying, expand the user's Persian question against the actual vocabulary in `graphify-out/graph.json`. -- 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. -- 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. -- 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. -- Validate important Graphify findings against the referenced source code or project documentation before acting on them. - -## Python package mirror - -- Use Liara's PyPI mirror for Python dependency resolution and installation: `https://package-mirror.liara.ir/repository/pypi/simple`. -- Prefer the persistent user-level pip configuration over repeating `--index-url` in individual commands. -- If the mirror is temporarily unavailable or missing a required package/version, report it before using the public PyPI index as a fallback. - -## 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. -- 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. - -## End-to-end browser tests - -- Put browser scenarios under `tests/e2e/`, mark them `e2e`, and use Django's transactional `live_server`. -- 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. -- Keep selectors and reusable browser actions in `tests/e2e/pages/` Page Objects, while tests remain focused on user-visible outcomes. -- Never use fixed sleeps for synchronization. Use Playwright locators, `expect`, navigation, response, or application-state waits. -- Preserve automatic screenshot and trace capture on failure. PWA scenarios must explicitly enable service workers and carry the `pwa` marker. +# Project Operating & Agent Guidelines + +## 1. Codebase Navigation & Analysis (Ripwire vs. Graphify) + +> **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. + +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. + +| Metric / Dimension | **Ripwire** (Deterministic AST) | **Graphify** (Semantic KG) | +|---|---|---| +| **Primary Scope** | Local source code AST, symbol graphs, in-repo text | Cross-artifact: Code + Docs + PDFs + Media + SQL Schema | +| **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"` | +| **Boundary** | Prose meaning, non-code files, cross-asset concepts | Exact symbol references, impact analysis, code test gates | + +### Routing Gate & Pipeline +1. **Trivial edits (≤1 file, no exported symbols):** Modify directly without querying either tool. +2. **Execution routing:** + - Hands-on coding, symbols, AST, usages, impact, and tests → **Ripwire**. + - Architecture discovery, cross-artifact concepts (PDF, media, docs, ADR/RFC), and SQL schemas → **Graphify**. (Note: `.sql` symbols use Ripwire; schema semantics use Graphify). + - Ambiguous/Mixed tasks → Run `ripwire . --help-task="task"` to classify, then route by artifact. +3. **Graphify query rules (when selected):** + - 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. + - **Traversal selection:** Use BFS for broad relationships, DFS for dependency traces, `path` for shortest linkage, and `explain` for node neighbors. + - Always cross-verify critical graph findings against source code before editing. +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`. + +--- + +## 2. Dynamic Help, Self-Correction & Staleness + +Never stall on tool errors or hallucinate flags. Dynamic CLI help always takes precedence over memorized parameters. + +- **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):** + - Ripwire: `ripwire --help`, `ripwire --help=--FLAG`, `ripwire . --doctor`, `ripwire . --help-task="task"` + - Graphify: `graphify --help`, `graphify --help` +- **Fallback & Retries (Cap at 1):** + - If a command fails or rejects flags: inspect its `--help` and retry once. + - 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 ` -m pip` and Django with ` 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. diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py index 017dfd2..177576f 100644 --- a/apps/accounts/migrations/0001_initial.py +++ b/apps/accounts/migrations/0001_initial.py @@ -1,78 +1,78 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.db.models.deletion -import django.utils.timezone -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='OTPChallenge', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('mobile', models.CharField(db_index=True, max_length=16)), - ('purpose', models.CharField(choices=[('LOGIN', 'ورود')], default='LOGIN', max_length=20)), - ('code_hash', models.CharField(max_length=64)), - ('expires_at', models.DateTimeField()), - ('attempt_count', models.PositiveSmallIntegerField(default=0)), - ('max_attempts', models.PositiveSmallIntegerField(default=5)), - ('verified_at', models.DateTimeField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), - ('request_ip', models.GenericIPAddressField(blank=True, null=True)), - ('user_agent', models.CharField(blank=True, max_length=300)), - ], - ), - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('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')), - ('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')), - ('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')), - ('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')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, 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)), - ('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')), - ], - options={ - 'abstract': False, - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name='TicketMagicLink', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token_hash', models.CharField(max_length=64, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('expires_at', models.DateTimeField()), - ('used_at', models.DateTimeField(blank=True, null=True)), - ('revoked_at', models.DateTimeField(blank=True, null=True)), - ('created_reason', models.CharField(blank=True, max_length=100)), - ('last_used_ip', models.GenericIPAddressField(blank=True, null=True)), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='OTPChallenge', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mobile', models.CharField(db_index=True, max_length=16)), + ('purpose', models.CharField(choices=[('LOGIN', 'ورود')], default='LOGIN', max_length=20)), + ('code_hash', models.CharField(max_length=64)), + ('expires_at', models.DateTimeField()), + ('attempt_count', models.PositiveSmallIntegerField(default=0)), + ('max_attempts', models.PositiveSmallIntegerField(default=5)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('request_ip', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, max_length=300)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('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')), + ('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')), + ('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')), + ('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')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, 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)), + ('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')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='TicketMagicLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('created_reason', models.CharField(blank=True, max_length=100)), + ('last_used_ip', models.GenericIPAddressField(blank=True, null=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apps/accounts/migrations/0002_initial.py b/apps/accounts/migrations/0002_initial.py index fb47120..5df42ba 100644 --- a/apps/accounts/migrations/0002_initial.py +++ b/apps/accounts/migrations/0002_initial.py @@ -1,22 +1,22 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('accounts', '0001_initial'), - ('tickets', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='ticketmagiclink', - name='ticket', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to='tickets.ticket'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='ticketmagiclink', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='magic_links', to='tickets.ticket'), + ), + ] diff --git a/apps/accounts/migrations/0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more.py b/apps/accounts/migrations/0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more.py index bd9c5fb..a863b30 100644 --- a/apps/accounts/migrations/0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more.py +++ b/apps/accounts/migrations/0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more.py @@ -1,38 +1,38 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0002_initial'), - ] - - operations = [ - migrations.CreateModel( - name='MagicLinkAccessLog', - fields=[ - ('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)), - ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('user_agent', models.CharField(blank=True, max_length=500)), - ], - options={ - 'verbose_name': 'بازدید لینک جادویی', - 'verbose_name_plural': 'بازدیدهای لینک جادویی', - 'ordering': ['-opened_at'], - }, - ), - migrations.AlterModelOptions( - name='ticketmagiclink', - options={'permissions': [('view_all_magic_links', 'Can audit magic links across all units')]}, - ), - migrations.AddField( - model_name='ticketmagiclink', - 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), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_initial'), + ] + + operations = [ + migrations.CreateModel( + name='MagicLinkAccessLog', + fields=[ + ('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)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, max_length=500)), + ], + options={ + 'verbose_name': 'بازدید لینک جادویی', + 'verbose_name_plural': 'بازدیدهای لینک جادویی', + 'ordering': ['-opened_at'], + }, + ), + migrations.AlterModelOptions( + name='ticketmagiclink', + options={'permissions': [('view_all_magic_links', 'Can audit magic links across all units')]}, + ), + migrations.AddField( + model_name='ticketmagiclink', + 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), + ), + ] diff --git a/apps/accounts/migrations/0004_ticketmagiclink_created_unit_and_more.py b/apps/accounts/migrations/0004_ticketmagiclink_created_unit_and_more.py index 0474a91..40deb3e 100644 --- a/apps/accounts/migrations/0004_ticketmagiclink_created_unit_and_more.py +++ b/apps/accounts/migrations/0004_ticketmagiclink_created_unit_and_more.py @@ -1,45 +1,45 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more'), - ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='ticketmagiclink', - name='created_unit', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='magic_links', to='staff.organizationalunit'), - ), - migrations.AddField( - model_name='ticketmagiclink', - name='last_user_agent', - field=models.CharField(blank=True, max_length=500), - ), - migrations.AddField( - model_name='ticketmagiclink', - name='recipient_mobile', - field=models.CharField(blank=True, max_length=16), - ), - migrations.AddField( - model_name='ticketmagiclink', - name='recipient_name', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='ticketmagiclink', - name='view_count', - field=models.PositiveIntegerField(default=0), - ), - migrations.AddField( - model_name='magiclinkaccesslog', - name='magic_link', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='accounts.ticketmagiclink'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more'), + ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='ticketmagiclink', + name='created_unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='magic_links', to='staff.organizationalunit'), + ), + migrations.AddField( + model_name='ticketmagiclink', + name='last_user_agent', + field=models.CharField(blank=True, max_length=500), + ), + migrations.AddField( + model_name='ticketmagiclink', + name='recipient_mobile', + field=models.CharField(blank=True, max_length=16), + ), + migrations.AddField( + model_name='ticketmagiclink', + name='recipient_name', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='ticketmagiclink', + name='view_count', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='magiclinkaccesslog', + name='magic_link', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='accounts.ticketmagiclink'), + ), + ] diff --git a/apps/accounts/migrations/0005_magiclinkaccesslog_magic_opened_idx_and_more.py b/apps/accounts/migrations/0005_magiclinkaccesslog_magic_opened_idx_and_more.py index e7b5a41..014c43d 100644 --- a/apps/accounts/migrations/0005_magiclinkaccesslog_magic_opened_idx_and_more.py +++ b/apps/accounts/migrations/0005_magiclinkaccesslog_magic_opened_idx_and_more.py @@ -1,27 +1,27 @@ -# Generated by Django 5.1.1 on 2026-09-08 15:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0004_ticketmagiclink_created_unit_and_more'), - ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), - ('tickets', '0006_assign_legacy_contacts_to_sales'), - ] - - operations = [ - migrations.AddIndex( - model_name='magiclinkaccesslog', - index=models.Index(fields=['magic_link', 'opened_at'], name='magic_opened_idx'), - ), - migrations.AddIndex( - model_name='ticketmagiclink', - index=models.Index(fields=['created_unit', 'created_at'], name='magic_unit_created_idx'), - ), - migrations.AddIndex( - model_name='ticketmagiclink', - index=models.Index(fields=['expires_at', 'revoked_at'], name='magic_validity_idx'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 15:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_ticketmagiclink_created_unit_and_more'), + ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), + ('tickets', '0006_assign_legacy_contacts_to_sales'), + ] + + operations = [ + migrations.AddIndex( + model_name='magiclinkaccesslog', + index=models.Index(fields=['magic_link', 'opened_at'], name='magic_opened_idx'), + ), + migrations.AddIndex( + model_name='ticketmagiclink', + index=models.Index(fields=['created_unit', 'created_at'], name='magic_unit_created_idx'), + ), + migrations.AddIndex( + model_name='ticketmagiclink', + index=models.Index(fields=['expires_at', 'revoked_at'], name='magic_validity_idx'), + ), + ] diff --git a/apps/attachments/migrations/0001_initial.py b/apps/attachments/migrations/0001_initial.py index d829b4e..f024a46 100644 --- a/apps/attachments/migrations/0001_initial.py +++ b/apps/attachments/migrations/0001_initial.py @@ -1,49 +1,49 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import apps.attachments.models -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Attachment', - fields=[ - ('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)), - ('file', models.FileField(upload_to=apps.attachments.models.attachment_path)), - ('original_name', models.CharField(max_length=255)), - ('stored_name', models.CharField(max_length=255)), - ('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)), - ('mime_type', models.CharField(max_length=150)), - ('extension', models.CharField(max_length=20)), - ('file_size', models.PositiveBigIntegerField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - options={ - 'permissions': [('download_attachment', 'Can download attachment'), ('manage_attachment_policy', 'Can manage attachment policy')], - }, - ), - migrations.CreateModel( - name='AttachmentPolicy', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('attachment_type', models.CharField(max_length=20, unique=True)), - ('is_enabled', models.BooleanField(default=True)), - ('max_file_size', models.PositiveBigIntegerField()), - ('max_files_per_message', models.PositiveSmallIntegerField(default=10)), - ('allowed_extensions', models.JSONField(default=list)), - ('allowed_mime_types', models.JSONField(default=list)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import apps.attachments.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('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)), + ('file', models.FileField(upload_to=apps.attachments.models.attachment_path)), + ('original_name', models.CharField(max_length=255)), + ('stored_name', models.CharField(max_length=255)), + ('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)), + ('mime_type', models.CharField(max_length=150)), + ('extension', models.CharField(max_length=20)), + ('file_size', models.PositiveBigIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'permissions': [('download_attachment', 'Can download attachment'), ('manage_attachment_policy', 'Can manage attachment policy')], + }, + ), + migrations.CreateModel( + name='AttachmentPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attachment_type', models.CharField(max_length=20, unique=True)), + ('is_enabled', models.BooleanField(default=True)), + ('max_file_size', models.PositiveBigIntegerField()), + ('max_files_per_message', models.PositiveSmallIntegerField(default=10)), + ('allowed_extensions', models.JSONField(default=list)), + ('allowed_mime_types', models.JSONField(default=list)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/apps/attachments/migrations/0002_initial.py b/apps/attachments/migrations/0002_initial.py index 2a22742..15864f6 100644 --- a/apps/attachments/migrations/0002_initial.py +++ b/apps/attachments/migrations/0002_initial.py @@ -1,29 +1,29 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('attachments', '0001_initial'), - ('messaging', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='attachment', - name='message', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attachments', to='messaging.message'), - ), - migrations.AddField( - model_name='attachment', - name='uploaded_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('attachments', '0001_initial'), + ('messaging', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='message', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attachments', to='messaging.message'), + ), + migrations.AddField( + model_name='attachment', + name='uploaded_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/attachments/migrations/0003_alter_attachment_file.py b/apps/attachments/migrations/0003_alter_attachment_file.py index a57068b..0748f80 100644 --- a/apps/attachments/migrations/0003_alter_attachment_file.py +++ b/apps/attachments/migrations/0003_alter_attachment_file.py @@ -1,19 +1,19 @@ -# Generated by Django 5.1.1 on 2026-09-05 08:31 - -import apps.attachments.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('attachments', '0002_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='attachment', - name='file', - field=models.FileField(max_length=500, upload_to=apps.attachments.models.attachment_path), - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 08:31 + +import apps.attachments.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(max_length=500, upload_to=apps.attachments.models.attachment_path), + ), + ] diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py index 3e8b938..fa22be5 100644 --- a/apps/core/migrations/0001_initial.py +++ b/apps/core/migrations/0001_initial.py @@ -1,47 +1,47 @@ -# Generated by Django 5.1.1 on 2026-09-09 03:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='PageTitle', - fields=[ - ('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)), - ('title', models.CharField(max_length=240)), - ('is_active', models.BooleanField(default=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - options={ - 'verbose_name': 'عنوان صفحه', - 'verbose_name_plural': 'عنوان صفحات', - 'ordering': ('route_name',), - }, - ), - migrations.CreateModel( - name='SiteBranding', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('site_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)), - ('staff_brand_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)), - ('staff_brand_subtitle', models.CharField(default='مرکز ارتباط با مشتری', max_length=200)), - ('staff_panel_label', models.CharField(default='پنل کارشناسان', max_length=120)), - ('pwa_title', models.CharField(default='واحد فروش', max_length=80)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - options={ - 'verbose_name': 'تنظیمات عنوان و برند', - 'verbose_name_plural': 'تنظیمات عنوان و برند', - }, - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 03:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PageTitle', + fields=[ + ('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)), + ('title', models.CharField(max_length=240)), + ('is_active', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'عنوان صفحه', + 'verbose_name_plural': 'عنوان صفحات', + 'ordering': ('route_name',), + }, + ), + migrations.CreateModel( + name='SiteBranding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('site_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)), + ('staff_brand_title', models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160)), + ('staff_brand_subtitle', models.CharField(default='مرکز ارتباط با مشتری', max_length=200)), + ('staff_panel_label', models.CharField(default='پنل کارشناسان', max_length=120)), + ('pwa_title', models.CharField(default='واحد فروش', max_length=80)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'تنظیمات عنوان و برند', + 'verbose_name_plural': 'تنظیمات عنوان و برند', + }, + ), + ] diff --git a/apps/core/migrations/0003_sitebranding_developer_credit_html_and_more.py b/apps/core/migrations/0003_sitebranding_developer_credit_html_and_more.py index a88d4e8..9b5fc8b 100644 --- a/apps/core/migrations/0003_sitebranding_developer_credit_html_and_more.py +++ b/apps/core/migrations/0003_sitebranding_developer_credit_html_and_more.py @@ -1,53 +1,53 @@ -# Generated by Django 5.1.1 on 2026-09-09 04:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0002_seed_branding_and_page_titles'), - ] - - operations = [ - migrations.AddField( - model_name='sitebranding', - name='developer_credit_html', - field=models.TextField(blank=True, help_text='فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.', verbose_name='HTML سفارشی امضای توسعه\u200cدهنده'), - ), - migrations.AddField( - model_name='sitebranding', - name='developer_credit_text', - field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129', max_length=500, verbose_name='متن امضای توسعه\u200cدهنده'), - ), - migrations.AddField( - model_name='sitebranding', - name='login_intro_body', - field=models.TextField(default='درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.', verbose_name='متن معرفی ورود'), - ), - migrations.AddField( - model_name='sitebranding', - name='login_intro_enabled', - field=models.BooleanField(default=True, verbose_name='نمایش بخش معرفی ورود'), - ), - migrations.AddField( - model_name='sitebranding', - name='login_intro_eyebrow', - field=models.CharField(default='ارتباط مستقیم با تیم فروش', max_length=200, verbose_name='بالانویس معرفی ورود'), - ), - migrations.AddField( - model_name='sitebranding', - name='login_intro_heading', - field=models.TextField(default='پاسخ روشن،\nپیگیری ساده.', verbose_name='عنوان معرفی ورود'), - ), - migrations.AddField( - model_name='sitebranding', - name='login_intro_icon', - field=models.CharField(blank=True, default='✦', max_length=32, verbose_name='آیکون معرفی ورود'), - ), - migrations.AddField( - model_name='sitebranding', - name='use_developer_credit_html', - field=models.BooleanField(default=False, help_text='با فعال\u200cسازی، HTML بدون escape نمایش داده می\u200cشود؛ فقط مدیر ارشد باید آن را ویرایش کند.', verbose_name='استفاده از HTML سفارشی'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 04:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_seed_branding_and_page_titles'), + ] + + operations = [ + migrations.AddField( + model_name='sitebranding', + name='developer_credit_html', + field=models.TextField(blank=True, help_text='فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.', verbose_name='HTML سفارشی امضای توسعه\u200cدهنده'), + ), + migrations.AddField( + model_name='sitebranding', + name='developer_credit_text', + field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129', max_length=500, verbose_name='متن امضای توسعه\u200cدهنده'), + ), + migrations.AddField( + model_name='sitebranding', + name='login_intro_body', + field=models.TextField(default='درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.', verbose_name='متن معرفی ورود'), + ), + migrations.AddField( + model_name='sitebranding', + name='login_intro_enabled', + field=models.BooleanField(default=True, verbose_name='نمایش بخش معرفی ورود'), + ), + migrations.AddField( + model_name='sitebranding', + name='login_intro_eyebrow', + field=models.CharField(default='ارتباط مستقیم با تیم فروش', max_length=200, verbose_name='بالانویس معرفی ورود'), + ), + migrations.AddField( + model_name='sitebranding', + name='login_intro_heading', + field=models.TextField(default='پاسخ روشن،\nپیگیری ساده.', verbose_name='عنوان معرفی ورود'), + ), + migrations.AddField( + model_name='sitebranding', + name='login_intro_icon', + field=models.CharField(blank=True, default='✦', max_length=32, verbose_name='آیکون معرفی ورود'), + ), + migrations.AddField( + model_name='sitebranding', + name='use_developer_credit_html', + field=models.BooleanField(default=False, help_text='با فعال\u200cسازی، HTML بدون escape نمایش داده می\u200cشود؛ فقط مدیر ارشد باید آن را ویرایش کند.', verbose_name='استفاده از HTML سفارشی'), + ), + ] diff --git a/apps/core/migrations/0004_sitebranding_staff_sidebar_credit_html_and_more.py b/apps/core/migrations/0004_sitebranding_staff_sidebar_credit_html_and_more.py index 6b054ee..c407444 100644 --- a/apps/core/migrations/0004_sitebranding_staff_sidebar_credit_html_and_more.py +++ b/apps/core/migrations/0004_sitebranding_staff_sidebar_credit_html_and_more.py @@ -1,28 +1,28 @@ -# Generated by Django 5.1.1 on 2026-09-09 05:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0003_sitebranding_developer_credit_html_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='sitebranding', - name='staff_sidebar_credit_html', - field=models.TextField(blank=True, help_text='مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.', verbose_name='HTML سفارشی امضای sidebar کارکنان'), - ), - migrations.AddField( - model_name='sitebranding', - name='staff_sidebar_credit_text', - field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت', max_length=500, verbose_name='متن امضای پایین sidebar کارکنان'), - ), - migrations.AddField( - model_name='sitebranding', - name='use_staff_sidebar_credit_html', - field=models.BooleanField(default=False, verbose_name='استفاده از HTML سفارشی در sidebar'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 05:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_sitebranding_developer_credit_html_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='sitebranding', + name='staff_sidebar_credit_html', + field=models.TextField(blank=True, help_text='مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.', verbose_name='HTML سفارشی امضای sidebar کارکنان'), + ), + migrations.AddField( + model_name='sitebranding', + name='staff_sidebar_credit_text', + field=models.CharField(default='برنامه نویسی و توسعه: محمد جواد فصاحت', max_length=500, verbose_name='متن امضای پایین sidebar کارکنان'), + ), + migrations.AddField( + model_name='sitebranding', + name='use_staff_sidebar_credit_html', + field=models.BooleanField(default=False, verbose_name='استفاده از HTML سفارشی در sidebar'), + ), + ] diff --git a/apps/core/migrations/0005_sitebranding_company_logo_and_more.py b/apps/core/migrations/0005_sitebranding_company_logo_and_more.py index 48e3d17..eeba731 100644 --- a/apps/core/migrations/0005_sitebranding_company_logo_and_more.py +++ b/apps/core/migrations/0005_sitebranding_company_logo_and_more.py @@ -1,93 +1,93 @@ -# Generated by Django 5.1.1 on 2026-09-09 05:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0004_sitebranding_staff_sidebar_credit_html_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='sitebranding', - name='company_logo', - field=models.ImageField(blank=True, upload_to='branding/logo/', verbose_name='لوگوی اصلی'), - ), - migrations.AddField( - model_name='sitebranding', - name='customer_tickets_hero_body', - field=models.TextField(default='موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق\u200cتر و سریع\u200cتر پیگیری کند.', verbose_name='متن صفحه تیکت\u200cهای مشتری'), - ), - migrations.AddField( - model_name='sitebranding', - name='customer_tickets_hero_enabled', - field=models.BooleanField(default=True, verbose_name='نمایش معرفی صفحه تیکت\u200cهای مشتری'), - ), - migrations.AddField( - model_name='sitebranding', - name='customer_tickets_hero_eyebrow', - field=models.CharField(default='مرکز ارتباط با فروش', max_length=200, verbose_name='بالانویس صفحه تیکت\u200cهای مشتری'), - ), - migrations.AddField( - model_name='sitebranding', - name='customer_tickets_hero_heading', - field=models.TextField(default='هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.', verbose_name='عنوان صفحه تیکت\u200cهای مشتری'), - ), - migrations.AddField( - model_name='sitebranding', - name='favicon', - field=models.ImageField(blank=True, upload_to='branding/favicon/', verbose_name='Favicon'), - ), - migrations.AddField( - model_name='sitebranding', - name='pwa_description', - field=models.CharField(default='ثبت و پیگیری درخواست\u200cهای فروش لوله گستر گلپایگان', max_length=300, verbose_name='توضیح PWA'), - ), - migrations.AddField( - model_name='sitebranding', - name='pwa_icon', - field=models.ImageField(blank=True, upload_to='branding/pwa/', verbose_name='آیکون PWA'), - ), - migrations.AddField( - model_name='sitebranding', - name='pwa_install_help', - field=models.CharField(default='برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.', max_length=300, verbose_name='راهنمای نصب PWA'), - ), - migrations.AddField( - model_name='sitebranding', - name='pwa_install_label', - field=models.CharField(default='نصب گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='عنوان پیشنهاد نصب PWA'), - ), - migrations.AddField( - model_name='sitebranding', - name='pwa_name', - field=models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='نام کامل PWA'), - ), - migrations.AddField( - model_name='sitebranding', - name='staff_login_intro_body', - field=models.TextField(default='صف درخواست\u200cها، SLA، اولویت\u200cها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.', verbose_name='متن ورود کارشناسان'), - ), - migrations.AddField( - model_name='sitebranding', - name='staff_login_intro_enabled', - field=models.BooleanField(default=True, verbose_name='نمایش معرفی ورود کارشناسان'), - ), - migrations.AddField( - model_name='sitebranding', - name='staff_login_intro_eyebrow', - field=models.CharField(default='پنل کارشناسان فروش', max_length=200, verbose_name='بالانویس ورود کارشناسان'), - ), - migrations.AddField( - model_name='sitebranding', - name='staff_login_intro_heading', - field=models.TextField(default='پاسخ\u200cهای بهتر،\nفروش هوشمندتر.', verbose_name='عنوان ورود کارشناسان'), - ), - migrations.AddField( - model_name='sitebranding', - name='staff_login_intro_icon', - field=models.CharField(blank=True, default='◈', max_length=32, verbose_name='آیکون معرفی ورود کارشناسان'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 05:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_sitebranding_staff_sidebar_credit_html_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='sitebranding', + name='company_logo', + field=models.ImageField(blank=True, upload_to='branding/logo/', verbose_name='لوگوی اصلی'), + ), + migrations.AddField( + model_name='sitebranding', + name='customer_tickets_hero_body', + field=models.TextField(default='موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق\u200cتر و سریع\u200cتر پیگیری کند.', verbose_name='متن صفحه تیکت\u200cهای مشتری'), + ), + migrations.AddField( + model_name='sitebranding', + name='customer_tickets_hero_enabled', + field=models.BooleanField(default=True, verbose_name='نمایش معرفی صفحه تیکت\u200cهای مشتری'), + ), + migrations.AddField( + model_name='sitebranding', + name='customer_tickets_hero_eyebrow', + field=models.CharField(default='مرکز ارتباط با فروش', max_length=200, verbose_name='بالانویس صفحه تیکت\u200cهای مشتری'), + ), + migrations.AddField( + model_name='sitebranding', + name='customer_tickets_hero_heading', + field=models.TextField(default='هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.', verbose_name='عنوان صفحه تیکت\u200cهای مشتری'), + ), + migrations.AddField( + model_name='sitebranding', + name='favicon', + field=models.ImageField(blank=True, upload_to='branding/favicon/', verbose_name='Favicon'), + ), + migrations.AddField( + model_name='sitebranding', + name='pwa_description', + field=models.CharField(default='ثبت و پیگیری درخواست\u200cهای فروش لوله گستر گلپایگان', max_length=300, verbose_name='توضیح PWA'), + ), + migrations.AddField( + model_name='sitebranding', + name='pwa_icon', + field=models.ImageField(blank=True, upload_to='branding/pwa/', verbose_name='آیکون PWA'), + ), + migrations.AddField( + model_name='sitebranding', + name='pwa_install_help', + field=models.CharField(default='برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.', max_length=300, verbose_name='راهنمای نصب PWA'), + ), + migrations.AddField( + model_name='sitebranding', + name='pwa_install_label', + field=models.CharField(default='نصب گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='عنوان پیشنهاد نصب PWA'), + ), + migrations.AddField( + model_name='sitebranding', + name='pwa_name', + field=models.CharField(default='گفت\u200cوگو با واحد فروش', max_length=160, verbose_name='نام کامل PWA'), + ), + migrations.AddField( + model_name='sitebranding', + name='staff_login_intro_body', + field=models.TextField(default='صف درخواست\u200cها، SLA، اولویت\u200cها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.', verbose_name='متن ورود کارشناسان'), + ), + migrations.AddField( + model_name='sitebranding', + name='staff_login_intro_enabled', + field=models.BooleanField(default=True, verbose_name='نمایش معرفی ورود کارشناسان'), + ), + migrations.AddField( + model_name='sitebranding', + name='staff_login_intro_eyebrow', + field=models.CharField(default='پنل کارشناسان فروش', max_length=200, verbose_name='بالانویس ورود کارشناسان'), + ), + migrations.AddField( + model_name='sitebranding', + name='staff_login_intro_heading', + field=models.TextField(default='پاسخ\u200cهای بهتر،\nفروش هوشمندتر.', verbose_name='عنوان ورود کارشناسان'), + ), + migrations.AddField( + model_name='sitebranding', + name='staff_login_intro_icon', + field=models.CharField(blank=True, default='◈', max_length=32, verbose_name='آیکون معرفی ورود کارشناسان'), + ), + ] diff --git a/apps/customers/migrations/0001_initial.py b/apps/customers/migrations/0001_initial.py index 43606b1..1680f63 100644 --- a/apps/customers/migrations/0001_initial.py +++ b/apps/customers/migrations/0001_initial.py @@ -1,32 +1,32 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='CustomerProfile', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('first_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)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer_profile', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CustomerProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('first_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)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='customer_profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/customers/migrations/0003_contactgroup_customerprofile_organization_name_and_more.py b/apps/customers/migrations/0003_contactgroup_customerprofile_organization_name_and_more.py index 67bb2bd..02d3bf8 100644 --- a/apps/customers/migrations/0003_contactgroup_customerprofile_organization_name_and_more.py +++ b/apps/customers/migrations/0003_contactgroup_customerprofile_organization_name_and_more.py @@ -1,41 +1,41 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import apps.core.models -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0002_customerprofile_city_customerprofile_province'), - ] - - operations = [ - migrations.CreateModel( - name='ContactGroup', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=150)), - ('description', models.TextField(blank=True)), - ('is_active', models.BooleanField(default=True)), - ], - options={ - 'verbose_name': 'گروه مخاطبان', - 'verbose_name_plural': 'گروه\u200cهای مخاطبان', - 'ordering': ['unit__name', 'name'], - }, - bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), - ), - migrations.AddField( - model_name='customerprofile', - name='organization_name', - field=models.CharField(blank=True, max_length=180), - ), - migrations.AddField( - model_name='customerprofile', - name='stakeholder_type', - field=models.CharField(choices=[('CUSTOMER', 'مشتری'), ('SUPPLIER', 'تامین\u200cکننده'), ('CARRIER', 'باربری'), ('OTHER', 'سایر')], db_index=True, default='CUSTOMER', max_length=20), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import apps.core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0002_customerprofile_city_customerprofile_province'), + ] + + operations = [ + migrations.CreateModel( + name='ContactGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=150)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'گروه مخاطبان', + 'verbose_name_plural': 'گروه\u200cهای مخاطبان', + 'ordering': ['unit__name', 'name'], + }, + bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), + ), + migrations.AddField( + model_name='customerprofile', + name='organization_name', + field=models.CharField(blank=True, max_length=180), + ), + migrations.AddField( + model_name='customerprofile', + name='stakeholder_type', + field=models.CharField(choices=[('CUSTOMER', 'مشتری'), ('SUPPLIER', 'تامین\u200cکننده'), ('CARRIER', 'باربری'), ('OTHER', 'سایر')], db_index=True, default='CUSTOMER', max_length=20), + ), + ] diff --git a/apps/customers/migrations/0004_contactgroup_unit_customerprofile_groups_and_more.py b/apps/customers/migrations/0004_contactgroup_unit_customerprofile_groups_and_more.py index 980fa07..12ce296 100644 --- a/apps/customers/migrations/0004_contactgroup_unit_customerprofile_groups_and_more.py +++ b/apps/customers/migrations/0004_contactgroup_unit_customerprofile_groups_and_more.py @@ -1,29 +1,29 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0003_contactgroup_customerprofile_organization_name_and_more'), - ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='contactgroup', - name='unit', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to='staff.organizationalunit'), - ), - migrations.AddField( - model_name='customerprofile', - name='groups', - field=models.ManyToManyField(blank=True, related_name='contacts', to='customers.contactgroup'), - ), - migrations.AddConstraint( - model_name='contactgroup', - constraint=models.UniqueConstraint(fields=('unit', 'name'), name='unique_contact_group_name_per_unit'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0003_contactgroup_customerprofile_organization_name_and_more'), + ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='unit', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_groups', to='staff.organizationalunit'), + ), + migrations.AddField( + model_name='customerprofile', + name='groups', + field=models.ManyToManyField(blank=True, related_name='contacts', to='customers.contactgroup'), + ), + migrations.AddConstraint( + model_name='contactgroup', + constraint=models.UniqueConstraint(fields=('unit', 'name'), name='unique_contact_group_name_per_unit'), + ), + ] diff --git a/apps/messaging/migrations/0001_initial.py b/apps/messaging/migrations/0001_initial.py index 5ad03bc..ca8f0f1 100644 --- a/apps/messaging/migrations/0001_initial.py +++ b/apps/messaging/migrations/0001_initial.py @@ -1,53 +1,53 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='MessageMention', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='MessageRevision', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('body', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Message', - fields=[ - ('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)), - ('body', models.TextField(blank=True)), - ('message_type', models.CharField(choices=[('PUBLIC', 'عمومی'), ('INTERNAL_NOTE', 'یادداشت داخلی')], default='PUBLIC', max_length=20)), - ('is_internal', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('edited_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)), - ('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={ - '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')], - }, - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MessageMention', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='MessageRevision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('body', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Message', + fields=[ + ('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)), + ('body', models.TextField(blank=True)), + ('message_type', models.CharField(choices=[('PUBLIC', 'عمومی'), ('INTERNAL_NOTE', 'یادداشت داخلی')], default='PUBLIC', max_length=20)), + ('is_internal', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('edited_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)), + ('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={ + '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')], + }, + ), + ] diff --git a/apps/messaging/migrations/0002_initial.py b/apps/messaging/migrations/0002_initial.py index d2acb65..d6a9dc8 100644 --- a/apps/messaging/migrations/0002_initial.py +++ b/apps/messaging/migrations/0002_initial.py @@ -1,48 +1,48 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('messaging', '0001_initial'), - ('tickets', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='message', - name='ticket', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_messages', to='tickets.ticket'), - ), - migrations.AddField( - model_name='messagemention', - name='mentioned_user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='messagemention', - name='message', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mentions', to='messaging.message'), - ), - migrations.AddField( - model_name='messagerevision', - name='edited_by', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='messagerevision', - name='message', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='messaging.message'), - ), - migrations.AddConstraint( - model_name='messagemention', - constraint=models.UniqueConstraint(fields=('message', 'mentioned_user'), name='unique_message_mention'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('messaging', '0001_initial'), + ('tickets', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ticket_messages', to='tickets.ticket'), + ), + migrations.AddField( + model_name='messagemention', + name='mentioned_user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='messagemention', + name='message', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mentions', to='messaging.message'), + ), + migrations.AddField( + model_name='messagerevision', + name='edited_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='messagerevision', + name='message', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='messaging.message'), + ), + migrations.AddConstraint( + model_name='messagemention', + constraint=models.UniqueConstraint(fields=('message', 'mentioned_user'), name='unique_message_mention'), + ), + ] diff --git a/apps/messaging/migrations/0003_messagereadreceipt.py b/apps/messaging/migrations/0003_messagereadreceipt.py index c5f92e5..f441360 100644 --- a/apps/messaging/migrations/0003_messagereadreceipt.py +++ b/apps/messaging/migrations/0003_messagereadreceipt.py @@ -1,29 +1,29 @@ -# Generated by Django 5.1.1 on 2026-09-06 15:45 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('messaging', '0002_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='MessageReadReceipt', - fields=[ - ('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)), - ('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)), - ], - options={ - 'ordering': ['read_at', 'id'], - 'constraints': [models.UniqueConstraint(fields=('message', 'user'), name='unique_message_read_receipt')], - }, - ), - ] +# Generated by Django 5.1.1 on 2026-09-06 15:45 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('messaging', '0002_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MessageReadReceipt', + fields=[ + ('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)), + ('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)), + ], + options={ + 'ordering': ['read_at', 'id'], + 'constraints': [models.UniqueConstraint(fields=('message', 'user'), name='unique_message_read_receipt')], + }, + ), + ] diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py index e0d107b..f8117b0 100644 --- a/apps/notifications/migrations/0001_initial.py +++ b/apps/notifications/migrations/0001_initial.py @@ -1,37 +1,37 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('tickets', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='NotificationJob', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('channel', models.CharField(choices=[('SMS', 'پیامک'), ('BALE', 'بله')], max_length=10)), - ('event_type', models.CharField(max_length=50)), - ('payload', models.JSONField(default=dict)), - ('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال ارسال'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق')], db_index=True, default='PENDING', max_length=15)), - ('attempts', models.PositiveSmallIntegerField(default=0)), - ('next_attempt_at', models.DateTimeField(db_index=True)), - ('last_error', models.TextField(blank=True)), - ('dedupe_key', models.CharField(max_length=200, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=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)), - ('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')), - ], - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tickets', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('channel', models.CharField(choices=[('SMS', 'پیامک'), ('BALE', 'بله')], max_length=10)), + ('event_type', models.CharField(max_length=50)), + ('payload', models.JSONField(default=dict)), + ('status', models.CharField(choices=[('PENDING', 'در صف'), ('PROCESSING', 'در حال ارسال'), ('SENT', 'ارسال\u200cشده'), ('FAILED', 'ناموفق')], db_index=True, default='PENDING', max_length=15)), + ('attempts', models.PositiveSmallIntegerField(default=0)), + ('next_attempt_at', models.DateTimeField(db_index=True)), + ('last_error', models.TextField(blank=True)), + ('dedupe_key', models.CharField(max_length=200, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=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)), + ('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')), + ], + ), + ] diff --git a/apps/notifications/migrations/0002_smstemplate.py b/apps/notifications/migrations/0002_smstemplate.py index 513c8f1..a7a761e 100644 --- a/apps/notifications/migrations/0002_smstemplate.py +++ b/apps/notifications/migrations/0002_smstemplate.py @@ -1,6 +1,6 @@ -# Generated by Django 5.1.1 on 2026-09-07 04:56 - -import apps.core.models +# Generated by Django 5.1.1 on 2026-09-07 04:56 + +import apps.core.models from django.db import migrations, models 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}) def remove_sms_templates(apps,schema_editor): apps.get_model("notifications","SMSTemplate").objects.filter(event_type__in=DEFAULTS).delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ('notifications', '0001_initial'), - ] - - operations = [ + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + ] + + operations = [ migrations.CreateModel( - name='SMSTemplate', - fields=[ - ('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)), - ('title', models.CharField(max_length=120)), - ('body', models.TextField(help_text='تگ\u200cها را مانند {magic_link} در متن قرار دهید.')), - ('allowed_tags', models.JSONField(blank=True, default=list)), - ('is_active', models.BooleanField(default=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], + name='SMSTemplate', + fields=[ + ('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)), + ('title', models.CharField(max_length=120)), + ('body', models.TextField(help_text='تگ\u200cها را مانند {magic_link} در متن قرار دهید.')), + ('allowed_tags', models.JSONField(blank=True, default=list)), + ('is_active', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), ), migrations.RunPython(seed_sms_templates,remove_sms_templates), diff --git a/apps/notifications/migrations/0005_smstemplate_unit_alter_smstemplate_event_type_and_more.py b/apps/notifications/migrations/0005_smstemplate_unit_alter_smstemplate_event_type_and_more.py index 953b50e..03eaf7c 100644 --- a/apps/notifications/migrations/0005_smstemplate_unit_alter_smstemplate_event_type_and_more.py +++ b/apps/notifications/migrations/0005_smstemplate_unit_alter_smstemplate_event_type_and_more.py @@ -1,33 +1,33 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('notifications', '0004_customer_reply_bale_policy'), - ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='smstemplate', - 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'), - ), - migrations.AlterField( - model_name='smstemplate', - name='event_type', - field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50), - ), - migrations.AddConstraint( - model_name='smstemplate', - constraint=models.UniqueConstraint(fields=('unit', 'event_type'), name='unique_sms_template_per_unit'), - ), - migrations.AddConstraint( - model_name='smstemplate', - constraint=models.UniqueConstraint(condition=models.Q(('unit__isnull', True)), fields=('event_type',), name='unique_global_sms_template_event'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0004_customer_reply_bale_policy'), + ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='smstemplate', + 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'), + ), + migrations.AlterField( + model_name='smstemplate', + name='event_type', + field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس')], max_length=50), + ), + migrations.AddConstraint( + model_name='smstemplate', + constraint=models.UniqueConstraint(fields=('unit', 'event_type'), name='unique_sms_template_per_unit'), + ), + migrations.AddConstraint( + model_name='smstemplate', + constraint=models.UniqueConstraint(condition=models.Q(('unit__isnull', True)), fields=('event_type',), name='unique_global_sms_template_event'), + ), + ] diff --git a/apps/notifications/migrations/0006_alter_smstemplate_allowed_tags.py b/apps/notifications/migrations/0006_alter_smstemplate_allowed_tags.py index c666895..4e7deca 100644 --- a/apps/notifications/migrations/0006_alter_smstemplate_allowed_tags.py +++ b/apps/notifications/migrations/0006_alter_smstemplate_allowed_tags.py @@ -1,6 +1,6 @@ -# Generated by Django 5.1.1 on 2026-09-08 10:05 - -import apps.notifications.models +# Generated by Django 5.1.1 on 2026-09-08 10:05 + +import apps.notifications.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"] @@ -14,15 +14,15 @@ def add_supported_tags(apps, schema_editor): class Migration(migrations.Migration): - - dependencies = [ - ('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'), - ] - - operations = [ + + dependencies = [ + ('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'), + ] + + operations = [ migrations.AlterField( - model_name='smstemplate', - name='allowed_tags', + model_name='smstemplate', + name='allowed_tags', field=models.JSONField(blank=True, default=apps.notifications.models.sms_template_tags), ), migrations.RunPython(add_supported_tags, migrations.RunPython.noop), diff --git a/apps/notifications/migrations/0007_notificationjob_notif_due_idx_and_more.py b/apps/notifications/migrations/0007_notificationjob_notif_due_idx_and_more.py index ba71c97..dc871d6 100644 --- a/apps/notifications/migrations/0007_notificationjob_notif_due_idx_and_more.py +++ b/apps/notifications/migrations/0007_notificationjob_notif_due_idx_and_more.py @@ -1,24 +1,24 @@ -# Generated by Django 5.1.1 on 2026-09-08 15:32 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('notifications', '0006_alter_smstemplate_allowed_tags'), - ('tickets', '0006_assign_legacy_contacts_to_sales'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddIndex( - model_name='notificationjob', - index=models.Index(fields=['status', 'next_attempt_at'], name='notif_due_idx'), - ), - migrations.AddIndex( - model_name='notificationjob', - index=models.Index(fields=['ticket', 'channel', 'status'], name='notif_ticket_chan_idx'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 15:32 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0006_alter_smstemplate_allowed_tags'), + ('tickets', '0006_assign_legacy_contacts_to_sales'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name='notificationjob', + index=models.Index(fields=['status', 'next_attempt_at'], name='notif_due_idx'), + ), + migrations.AddIndex( + model_name='notificationjob', + index=models.Index(fields=['ticket', 'channel', 'status'], name='notif_ticket_chan_idx'), + ), + ] diff --git a/apps/notifications/migrations/0008_notificationjob_provider_message_id.py b/apps/notifications/migrations/0008_notificationjob_provider_message_id.py index ecf9b1e..4b70c10 100644 --- a/apps/notifications/migrations/0008_notificationjob_provider_message_id.py +++ b/apps/notifications/migrations/0008_notificationjob_provider_message_id.py @@ -1,18 +1,18 @@ -# Generated by Django 5.1.1 on 2026-09-08 15:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('notifications', '0007_notificationjob_notif_due_idx_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='notificationjob', - name='provider_message_id', - field=models.CharField(blank=True, max_length=500), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0007_notificationjob_notif_due_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='notificationjob', + name='provider_message_id', + field=models.CharField(blank=True, max_length=500), + ), + ] diff --git a/apps/staff/migrations/0001_initial.py b/apps/staff/migrations/0001_initial.py index d37adb4..5343b77 100644 --- a/apps/staff/migrations/0001_initial.py +++ b/apps/staff/migrations/0001_initial.py @@ -1,37 +1,37 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='StaffProfile', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('display_name', models.CharField(max_length=150)), - ('avatar', models.ImageField(blank=True, upload_to='staff/avatars/')), - ('mobile', models.CharField(blank=True, max_length=16)), - ('bale_chat_id', models.CharField(blank=True, max_length=100)), - ('bale_notifications_enabled', models.BooleanField(default=True)), - ('is_customer_selectable', models.BooleanField(default=True)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('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)), - ], - options={ - 'ordering': ['sort_order', 'display_name'], - }, - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StaffProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('display_name', models.CharField(max_length=150)), + ('avatar', models.ImageField(blank=True, upload_to='staff/avatars/')), + ('mobile', models.CharField(blank=True, max_length=16)), + ('bale_chat_id', models.CharField(blank=True, max_length=100)), + ('bale_notifications_enabled', models.BooleanField(default=True)), + ('is_customer_selectable', models.BooleanField(default=True)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('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)), + ], + options={ + 'ordering': ['sort_order', 'display_name'], + }, + ), + ] diff --git a/apps/staff/migrations/0002_staffprofile_first_name_staffprofile_job_title_and_more.py b/apps/staff/migrations/0002_staffprofile_first_name_staffprofile_job_title_and_more.py index c716c0f..8d184a2 100644 --- a/apps/staff/migrations/0002_staffprofile_first_name_staffprofile_job_title_and_more.py +++ b/apps/staff/migrations/0002_staffprofile_first_name_staffprofile_job_title_and_more.py @@ -1,28 +1,28 @@ -# Generated by Django 5.1.1 on 2026-09-06 10:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='staffprofile', - name='first_name', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AddField( - model_name='staffprofile', - name='job_title', - field=models.CharField(blank=True, default='کارشناس فروش', max_length=150), - ), - migrations.AddField( - model_name='staffprofile', - name='last_name', - field=models.CharField(blank=True, max_length=100), - ), - ] +# Generated by Django 5.1.1 on 2026-09-06 10:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='staffprofile', + name='first_name', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='staffprofile', + name='job_title', + field=models.CharField(blank=True, default='کارشناس فروش', max_length=150), + ), + migrations.AddField( + model_name='staffprofile', + name='last_name', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/apps/staff/migrations/0003_staffprofile_role.py b/apps/staff/migrations/0003_staffprofile_role.py index c963da1..68df65f 100644 --- a/apps/staff/migrations/0003_staffprofile_role.py +++ b/apps/staff/migrations/0003_staffprofile_role.py @@ -1,18 +1,18 @@ -# Generated by Django 5.1.1 on 2026-09-07 11:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0002_staffprofile_first_name_staffprofile_job_title_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='staffprofile', - 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), - ), - ] +# Generated by Django 5.1.1 on 2026-09-07 11:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0002_staffprofile_first_name_staffprofile_job_title_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='staffprofile', + 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), + ), + ] diff --git a/apps/staff/migrations/0004_organizationalunit_staffprofile_primary_unit_and_more.py b/apps/staff/migrations/0004_organizationalunit_staffprofile_primary_unit_and_more.py index 9fa6644..d18840d 100644 --- a/apps/staff/migrations/0004_organizationalunit_staffprofile_primary_unit_and_more.py +++ b/apps/staff/migrations/0004_organizationalunit_staffprofile_primary_unit_and_more.py @@ -1,61 +1,61 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import apps.core.models -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ('staff', '0003_staffprofile_role'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='OrganizationalUnit', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=120, unique=True)), - ('slug', models.SlugField(max_length=80, unique=True)), - ('description', models.TextField(blank=True)), - ('is_active', models.BooleanField(default=True)), - ('permissions', models.ManyToManyField(blank=True, help_text='مجوزهایی که به تمام اعضای فعال این واحد داده می\u200cشود.', related_name='organizational_units', to='auth.permission')), - ], - options={ - 'verbose_name': 'واحد سازمانی', - 'verbose_name_plural': 'واحدهای سازمانی', - 'ordering': ['name'], - }, - bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), - ), - migrations.AddField( - model_name='staffprofile', - name='primary_unit', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='primary_staff', to='staff.organizationalunit'), - ), - migrations.CreateModel( - name='UnitMembership', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('is_primary', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=True)), - ('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')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_memberships', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'عضویت واحد', - 'verbose_name_plural': 'عضویت\u200cهای واحد', - 'ordering': ['unit__name', 'user__username'], - 'constraints': [models.UniqueConstraint(fields=('user', 'unit'), name='unique_unit_membership')], - }, - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import apps.core.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('staff', '0003_staffprofile_role'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OrganizationalUnit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=120, unique=True)), + ('slug', models.SlugField(max_length=80, unique=True)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('permissions', models.ManyToManyField(blank=True, help_text='مجوزهایی که به تمام اعضای فعال این واحد داده می\u200cشود.', related_name='organizational_units', to='auth.permission')), + ], + options={ + 'verbose_name': 'واحد سازمانی', + 'verbose_name_plural': 'واحدهای سازمانی', + 'ordering': ['name'], + }, + bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), + ), + migrations.AddField( + model_name='staffprofile', + name='primary_unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='primary_staff', to='staff.organizationalunit'), + ), + migrations.CreateModel( + name='UnitMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_primary', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('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')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'عضویت واحد', + 'verbose_name_plural': 'عضویت\u200cهای واحد', + 'ordering': ['unit__name', 'user__username'], + 'constraints': [models.UniqueConstraint(fields=('user', 'unit'), name='unique_unit_membership')], + }, + ), + ] diff --git a/apps/staff/migrations/0005_organizationalunit_accepts_external_tickets.py b/apps/staff/migrations/0005_organizationalunit_accepts_external_tickets.py index fe1c2c0..62e424e 100644 --- a/apps/staff/migrations/0005_organizationalunit_accepts_external_tickets.py +++ b/apps/staff/migrations/0005_organizationalunit_accepts_external_tickets.py @@ -1,18 +1,18 @@ -# Generated by Django 5.1.1 on 2026-09-09 05:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='organizationalunit', - name='accepts_external_tickets', - field=models.BooleanField(default=True, help_text='اگر غیرفعال باشد، مشتریان نمی\u200cتوانند مستقیماً برای این واحد تیکت ثبت کنند.', verbose_name='قابل انتخاب برای تیکت مشتری'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 05:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='organizationalunit', + name='accepts_external_tickets', + field=models.BooleanField(default=True, help_text='اگر غیرفعال باشد، مشتریان نمی\u200cتوانند مستقیماً برای این واحد تیکت ثبت کنند.', verbose_name='قابل انتخاب برای تیکت مشتری'), + ), + ] diff --git a/apps/staff/migrations/0006_alter_organizationalunit_options_and_more.py b/apps/staff/migrations/0006_alter_organizationalunit_options_and_more.py index 340296c..e0a03aa 100644 --- a/apps/staff/migrations/0006_alter_organizationalunit_options_and_more.py +++ b/apps/staff/migrations/0006_alter_organizationalunit_options_and_more.py @@ -1,27 +1,27 @@ -# Generated by Django 5.1.1 on 2026-09-09 09:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0005_organizationalunit_accepts_external_tickets'), - ] - - operations = [ - migrations.AlterModelOptions( - name='organizationalunit', - options={'ordering': ['sort_order', 'name', 'pk'], 'verbose_name': 'واحد سازمانی', 'verbose_name_plural': 'واحدهای سازمانی'}, - ), - migrations.AddField( - model_name='organizationalunit', - name='search_keywords', - field=models.TextField(blank=True, help_text='واژه\u200cها و عبارت\u200cهای مترادف را با فاصله یا ویرگول وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'), - ), - migrations.AddField( - model_name='organizationalunit', - name='sort_order', - field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 09:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0005_organizationalunit_accepts_external_tickets'), + ] + + operations = [ + migrations.AlterModelOptions( + name='organizationalunit', + options={'ordering': ['sort_order', 'name', 'pk'], 'verbose_name': 'واحد سازمانی', 'verbose_name_plural': 'واحدهای سازمانی'}, + ), + migrations.AddField( + model_name='organizationalunit', + name='search_keywords', + field=models.TextField(blank=True, help_text='واژه\u200cها و عبارت\u200cهای مترادف را با فاصله یا ویرگول وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'), + ), + migrations.AddField( + model_name='organizationalunit', + name='sort_order', + field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'), + ), + ] diff --git a/apps/staff/migrations/0007_organizationalunit_show_in_customer_form.py b/apps/staff/migrations/0007_organizationalunit_show_in_customer_form.py index a8abcbf..9959baf 100644 --- a/apps/staff/migrations/0007_organizationalunit_show_in_customer_form.py +++ b/apps/staff/migrations/0007_organizationalunit_show_in_customer_form.py @@ -1,18 +1,18 @@ -# Generated by Django 5.1.1 on 2026-09-09 09:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0006_alter_organizationalunit_options_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='organizationalunit', - name='show_in_customer_form', - field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0006_alter_organizationalunit_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='organizationalunit', + name='show_in_customer_form', + field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، واحد در فرم ثبت تیکت و جستجوی مشتری نمایش داده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'), + ), + ] diff --git a/apps/staff/migrations/0009_alter_staffprofile_sort_order.py b/apps/staff/migrations/0009_alter_staffprofile_sort_order.py index 34fe30a..cb30d4f 100644 --- a/apps/staff/migrations/0009_alter_staffprofile_sort_order.py +++ b/apps/staff/migrations/0009_alter_staffprofile_sort_order.py @@ -1,18 +1,18 @@ -# Generated by Django 5.1.1 on 2026-09-10 05:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0008_unitmembership_ticket_scope_and_roles'), - ] - - operations = [ - migrations.AlterField( - model_name='staffprofile', - name='sort_order', - field=models.PositiveIntegerField(db_index=True, default=0, help_text='عدد کمتر، کارشناس را زودتر در فهرست همان واحد نمایش می\u200cدهد.', verbose_name='ترتیب نمایش در انتخاب کارشناس'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-10 05:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0008_unitmembership_ticket_scope_and_roles'), + ] + + operations = [ + migrations.AlterField( + model_name='staffprofile', + name='sort_order', + field=models.PositiveIntegerField(db_index=True, default=0, help_text='عدد کمتر، کارشناس را زودتر در فهرست همان واحد نمایش می\u200cدهد.', verbose_name='ترتیب نمایش در انتخاب کارشناس'), + ), + ] diff --git a/apps/tickets/migrations/0001_initial.py b/apps/tickets/migrations/0001_initial.py index 13baaf5..c1e9e91 100644 --- a/apps/tickets/migrations/0001_initial.py +++ b/apps/tickets/migrations/0001_initial.py @@ -1,130 +1,130 @@ -# Generated by Django 5.1.1 on 2026-09-05 07:03 - -import django.core.validators -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('messaging', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='SLAConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('priority', models.CharField(max_length=15, unique=True)), - ('first_response_minutes', models.PositiveIntegerField()), - ('resolution_minutes', models.PositiveIntegerField()), - ('is_active', models.BooleanField(default=True)), - ], - ), - migrations.CreateModel( - name='TicketCategory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('title', models.CharField(max_length=100)), - ('slug', models.SlugField(unique=True)), - ('description', models.TextField(blank=True)), - ('icon', models.CharField(blank=True, max_length=50)), - ('sort_order', models.PositiveIntegerField(default=0)), - ('is_active', models.BooleanField(default=True)), - ], - options={ - 'ordering': ['sort_order', 'title'], - }, - ), - migrations.CreateModel( - name='Ticket', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('ticket_number', models.CharField(editable=False, max_length=30, unique=True)), - ('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])), - ('status', models.CharField(choices=[('NEW', 'جدید'), ('OPEN', 'باز'), ('WAITING_CUSTOMER', 'در انتظار مشتری'), ('WAITING_STAFF', 'در انتظار کارشناس'), ('RESOLVED', 'حل\u200cشده'), ('CLOSED', 'بسته'), ('REOPENED', 'بازگشایی\u200cشده')], db_index=True, default='NEW', max_length=25)), - ('priority', models.CharField(choices=[('NORMAL', 'عادی'), ('IMPORTANT', 'مهم'), ('URGENT', 'فوری')], db_index=True, default='NORMAL', max_length=15)), - ('origin_visibility', models.CharField(choices=[('PUBLIC', 'عمومی'), ('SELECTED', 'کارشناس انتخابی')], default='PUBLIC', max_length=15)), - ('last_activity_at', models.DateTimeField(db_index=True)), - ('first_response_at', models.DateTimeField(blank=True, null=True)), - ('last_customer_message_at', models.DateTimeField(blank=True, null=True)), - ('last_staff_message_at', models.DateTimeField(blank=True, null=True)), - ('first_response_due_at', models.DateTimeField(blank=True, null=True)), - ('resolution_due_at', models.DateTimeField(blank=True, null=True)), - ('resolved_at', models.DateTimeField(blank=True, null=True)), - ('closed_at', models.DateTimeField(blank=True, null=True)), - ('reopened_at', models.DateTimeField(blank=True, null=True)), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='customer_tickets', to=settings.AUTH_USER_MODEL)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to=settings.AUTH_USER_MODEL)), - ('selected_expert', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='selected_tickets', to=settings.AUTH_USER_MODEL)), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.ticketcategory')), - ], - options={ - 'ordering': ['-last_activity_at'], - 'permissions': [('view_all_tickets', 'Can view all tickets'), ('claim_ticket', 'Can claim ticket'), ('transfer_ticket', 'Can transfer ticket'), ('share_ticket', 'Can share ticket'), ('change_ticket_priority', 'Can change ticket priority'), ('view_customer_history', 'Can view customer history')], - }, - ), - migrations.CreateModel( - name='TicketEvent', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('event_type', models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک'), ('ACCESS_REVOKED', 'لغو دسترسی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30)), - ('metadata', models.JSONField(blank=True, default=dict)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tickets.ticket')), - ], - ), - migrations.CreateModel( - name='TicketReadState', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('last_read_at', models.DateTimeField()), - ('last_read_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='messaging.message')), - ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='TicketAccessGrant', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('permission_level', models.CharField(choices=[('VIEW_ONLY', 'فقط مشاهده'), ('REPLY', 'پاسخ'), ('FULL', 'کامل')], default='FULL', max_length=15)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('revoked_at', models.DateTimeField(blank=True, null=True)), - ('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_ticket_access', to=settings.AUTH_USER_MODEL)), - ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='tickets.ticket')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_grants', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')], - }, - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'), - ), - migrations.AddConstraint( - model_name='ticketreadstate', - constraint=models.UniqueConstraint(fields=('ticket', 'user'), name='unique_ticket_read_state'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-05 07:03 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('messaging', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SLAConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.CharField(max_length=15, unique=True)), + ('first_response_minutes', models.PositiveIntegerField()), + ('resolution_minutes', models.PositiveIntegerField()), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='TicketCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=100)), + ('slug', models.SlugField(unique=True)), + ('description', models.TextField(blank=True)), + ('icon', models.CharField(blank=True, max_length=50)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['sort_order', 'title'], + }, + ), + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('ticket_number', models.CharField(editable=False, max_length=30, unique=True)), + ('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])), + ('status', models.CharField(choices=[('NEW', 'جدید'), ('OPEN', 'باز'), ('WAITING_CUSTOMER', 'در انتظار مشتری'), ('WAITING_STAFF', 'در انتظار کارشناس'), ('RESOLVED', 'حل\u200cشده'), ('CLOSED', 'بسته'), ('REOPENED', 'بازگشایی\u200cشده')], db_index=True, default='NEW', max_length=25)), + ('priority', models.CharField(choices=[('NORMAL', 'عادی'), ('IMPORTANT', 'مهم'), ('URGENT', 'فوری')], db_index=True, default='NORMAL', max_length=15)), + ('origin_visibility', models.CharField(choices=[('PUBLIC', 'عمومی'), ('SELECTED', 'کارشناس انتخابی')], default='PUBLIC', max_length=15)), + ('last_activity_at', models.DateTimeField(db_index=True)), + ('first_response_at', models.DateTimeField(blank=True, null=True)), + ('last_customer_message_at', models.DateTimeField(blank=True, null=True)), + ('last_staff_message_at', models.DateTimeField(blank=True, null=True)), + ('first_response_due_at', models.DateTimeField(blank=True, null=True)), + ('resolution_due_at', models.DateTimeField(blank=True, null=True)), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('reopened_at', models.DateTimeField(blank=True, null=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='customer_tickets', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to=settings.AUTH_USER_MODEL)), + ('selected_expert', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='selected_tickets', to=settings.AUTH_USER_MODEL)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.ticketcategory')), + ], + options={ + 'ordering': ['-last_activity_at'], + 'permissions': [('view_all_tickets', 'Can view all tickets'), ('claim_ticket', 'Can claim ticket'), ('transfer_ticket', 'Can transfer ticket'), ('share_ticket', 'Can share ticket'), ('change_ticket_priority', 'Can change ticket priority'), ('view_customer_history', 'Can view customer history')], + }, + ), + migrations.CreateModel( + name='TicketEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('CREATED', 'ایجاد'), ('CLAIMED', 'برداشت'), ('TRANSFERRED', 'انتقال'), ('SHARED', 'اشتراک'), ('ACCESS_REVOKED', 'لغو دسترسی'), ('STATUS_CHANGED', 'تغییر وضعیت'), ('PRIORITY_CHANGED', 'تغییر اولویت'), ('REOPENED', 'بازگشایی'), ('RESOLVED', 'حل'), ('CLOSED', 'بستن')], max_length=30)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='tickets.ticket')), + ], + ), + migrations.CreateModel( + name='TicketReadState', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('last_read_at', models.DateTimeField()), + ('last_read_message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='messaging.message')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticket')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='TicketAccessGrant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('permission_level', models.CharField(choices=[('VIEW_ONLY', 'فقط مشاهده'), ('REPLY', 'پاسخ'), ('FULL', 'کامل')], default='FULL', max_length=15)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('granted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_ticket_access', to=settings.AUTH_USER_MODEL)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='tickets.ticket')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_grants', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'constraints': [models.UniqueConstraint(condition=models.Q(('revoked_at__isnull', True)), fields=('ticket', 'user'), name='unique_active_ticket_grant')], + }, + ), + migrations.AddIndex( + model_name='ticket', + index=models.Index(fields=['status', 'last_activity_at'], name='tickets_tic_status_dbef56_idx'), + ), + migrations.AddIndex( + model_name='ticket', + index=models.Index(fields=['owner', 'status'], name='tickets_tic_owner_i_70fd59_idx'), + ), + migrations.AddIndex( + model_name='ticket', + index=models.Index(fields=['selected_expert', 'status'], name='tickets_tic_selecte_71a761_idx'), + ), + migrations.AddConstraint( + model_name='ticketreadstate', + constraint=models.UniqueConstraint(fields=('ticket', 'user'), name='unique_ticket_read_state'), + ), + ] diff --git a/apps/tickets/migrations/0004_tickettag_ticket_assigned_unit_ticket_created_by_and_more.py b/apps/tickets/migrations/0004_tickettag_ticket_assigned_unit_ticket_created_by_and_more.py index 729511f..5efff6a 100644 --- a/apps/tickets/migrations/0004_tickettag_ticket_assigned_unit_ticket_created_by_and_more.py +++ b/apps/tickets/migrations/0004_tickettag_ticket_assigned_unit_ticket_created_by_and_more.py @@ -1,102 +1,102 @@ -# Generated by Django 5.1.1 on 2026-09-08 09:57 - -import apps.core.models -import django.core.validators -import django.db.models.deletion -import uuid -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('customers', '0003_contactgroup_customerprofile_organization_name_and_more'), - ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), - ('tickets', '0003_ticket_city_ticket_province'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='TicketTag', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=80, unique=True)), - ('slug', models.SlugField(max_length=80, unique=True)), - ('color', models.CharField(default='#52677d', max_length=7)), - ('is_active', models.BooleanField(default=True)), - ], - options={ - 'verbose_name': 'برچسب تیکت', - 'verbose_name_plural': 'برچسب\u200cهای تیکت', - 'ordering': ['name'], - }, - bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), - ), - migrations.AddField( - model_name='ticket', - name='assigned_unit', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'), - ), - migrations.AddField( - model_name='ticket', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_tickets', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='ticket', - name='delivery_type', - field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12), - ), - migrations.AddField( - model_name='ticket', - name='owning_unit', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'), - ), - migrations.CreateModel( - name='GroupDispatch', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])), - ('body', models.TextField()), - ('status', models.CharField(choices=[('DRAFT', 'پیش\u200cنویس'), ('QUEUED', 'در صف ارسال'), ('SENT', 'ارسال\u200cشده'), ('PARTIAL', 'ارسال ناقص'), ('CANCELLED', 'لغوشده')], db_index=True, default='DRAFT', max_length=12)), - ('recipient_count', models.PositiveIntegerField(default=0)), - ('dispatched_at', models.DateTimeField(blank=True, null=True)), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tickets.ticketcategory')), - ('contact_group', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dispatches', to='customers.contactgroup')), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_group_dispatches', to=settings.AUTH_USER_MODEL)), - ('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_dispatches', to='staff.organizationalunit')), - ], - options={ - 'verbose_name': 'ارسال گروهی', - 'verbose_name_plural': 'ارسال\u200cهای گروهی', - 'ordering': ['-created_at'], - 'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')], - }, - bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), - ), - migrations.AddField( - model_name='ticket', - name='group_dispatch', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'), - ), - migrations.AddField( - model_name='ticket', - name='tags', - field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['delivery_type', 'last_activity_at'], name='tickets_tic_deliver_c53084_idx'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-08 09:57 + +import apps.core.models +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0003_contactgroup_customerprofile_organization_name_and_more'), + ('staff', '0004_organizationalunit_staffprofile_primary_unit_and_more'), + ('tickets', '0003_ticket_city_ticket_province'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TicketTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=80, unique=True)), + ('slug', models.SlugField(max_length=80, unique=True)), + ('color', models.CharField(default='#52677d', max_length=7)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'برچسب تیکت', + 'verbose_name_plural': 'برچسب\u200cهای تیکت', + 'ordering': ['name'], + }, + bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), + ), + migrations.AddField( + model_name='ticket', + name='assigned_unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_tickets', to='staff.organizationalunit'), + ), + migrations.AddField( + model_name='ticket', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='created_tickets', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='ticket', + name='delivery_type', + field=models.CharField(choices=[('INDIVIDUAL', 'فردی'), ('GROUP', 'ارسال گروهی')], db_index=True, default='INDIVIDUAL', max_length=12), + ), + migrations.AddField( + model_name='ticket', + name='owning_unit', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_tickets', to='staff.organizationalunit'), + ), + migrations.CreateModel( + name='GroupDispatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('subject', models.CharField(max_length=200, validators=[django.core.validators.MinLengthValidator(5)])), + ('body', models.TextField()), + ('status', models.CharField(choices=[('DRAFT', 'پیش\u200cنویس'), ('QUEUED', 'در صف ارسال'), ('SENT', 'ارسال\u200cشده'), ('PARTIAL', 'ارسال ناقص'), ('CANCELLED', 'لغوشده')], db_index=True, default='DRAFT', max_length=12)), + ('recipient_count', models.PositiveIntegerField(default=0)), + ('dispatched_at', models.DateTimeField(blank=True, null=True)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tickets.ticketcategory')), + ('contact_group', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='dispatches', to='customers.contactgroup')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_group_dispatches', to=settings.AUTH_USER_MODEL)), + ('unit', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='group_dispatches', to='staff.organizationalunit')), + ], + options={ + 'verbose_name': 'ارسال گروهی', + 'verbose_name_plural': 'ارسال\u200cهای گروهی', + 'ordering': ['-created_at'], + 'permissions': [('dispatch_group_tickets', 'Can dispatch isolated tickets to a contact group')], + }, + bases=(apps.core.models.PersianTextNormalizationMixin, models.Model), + ), + migrations.AddField( + model_name='ticket', + name='group_dispatch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tickets', to='tickets.groupdispatch'), + ), + migrations.AddField( + model_name='ticket', + name='tags', + field=models.ManyToManyField(blank=True, related_name='tickets', to='tickets.tickettag'), + ), + migrations.AddIndex( + model_name='ticket', + index=models.Index(fields=['owning_unit', 'status'], name='tickets_tic_owning__e629e3_idx'), + ), + migrations.AddIndex( + model_name='ticket', + index=models.Index(fields=['delivery_type', 'last_activity_at'], name='tickets_tic_deliver_c53084_idx'), + ), + ] diff --git a/apps/tickets/migrations/0007_alter_ticketcategory_options_ticketcategory_unit.py b/apps/tickets/migrations/0007_alter_ticketcategory_options_ticketcategory_unit.py index 77d9ad1..bf00888 100644 --- a/apps/tickets/migrations/0007_alter_ticketcategory_options_ticketcategory_unit.py +++ b/apps/tickets/migrations/0007_alter_ticketcategory_options_ticketcategory_unit.py @@ -1,24 +1,24 @@ -# Generated by Django 5.1.1 on 2026-09-09 05:43 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('staff', '0005_organizationalunit_accepts_external_tickets'), - ('tickets', '0006_assign_legacy_contacts_to_sales'), - ] - - operations = [ - migrations.AlterModelOptions( - name='ticketcategory', - options={'ordering': ['unit__name', 'sort_order', 'title']}, - ), - migrations.AddField( - model_name='ticketcategory', - name='unit', - field=models.ForeignKey(blank=True, help_text='برای دسته\u200cهای قدیمی عمومی می\u200cتواند خالی باشد؛ دسته\u200cهای جدید باید واحد داشته باشند.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ticket_categories', to='staff.organizationalunit', verbose_name='واحد سازمانی'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 05:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0005_organizationalunit_accepts_external_tickets'), + ('tickets', '0006_assign_legacy_contacts_to_sales'), + ] + + operations = [ + migrations.AlterModelOptions( + name='ticketcategory', + options={'ordering': ['unit__name', 'sort_order', 'title']}, + ), + migrations.AddField( + model_name='ticketcategory', + name='unit', + field=models.ForeignKey(blank=True, help_text='برای دسته\u200cهای قدیمی عمومی می\u200cتواند خالی باشد؛ دسته\u200cهای جدید باید واحد داشته باشند.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ticket_categories', to='staff.organizationalunit', verbose_name='واحد سازمانی'), + ), + ] diff --git a/apps/tickets/migrations/0010_alter_ticketcategory_options_and_more.py b/apps/tickets/migrations/0010_alter_ticketcategory_options_and_more.py index 209876a..ddf105b 100644 --- a/apps/tickets/migrations/0010_alter_ticketcategory_options_and_more.py +++ b/apps/tickets/migrations/0010_alter_ticketcategory_options_and_more.py @@ -1,28 +1,28 @@ -# Generated by Django 5.1.1 on 2026-09-09 09:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - +# Generated by Django 5.1.1 on 2026-09-09 09:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ ('tickets', '0009_seed_customer_facing_units_and_categories'), ('staff', '0006_alter_organizationalunit_options_and_more'), - ] - - operations = [ - migrations.AlterModelOptions( - name='ticketcategory', - options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']}, - ), - migrations.AddField( - model_name='ticketcategory', - name='search_keywords', - field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'), - ), - migrations.AlterField( - model_name='ticketcategory', - name='sort_order', - field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'), - ), - ] + ] + + operations = [ + migrations.AlterModelOptions( + name='ticketcategory', + options={'ordering': ['unit__sort_order', 'unit__name', 'sort_order', 'title', 'pk']}, + ), + migrations.AddField( + model_name='ticketcategory', + name='search_keywords', + field=models.TextField(blank=True, help_text='مترادف\u200cها و عبارت\u200cهای رایج مشتری را وارد کنید.', verbose_name='کلیدواژه\u200cهای جستجو'), + ), + migrations.AlterField( + model_name='ticketcategory', + name='sort_order', + field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='ترتیب نمایش'), + ), + ] diff --git a/apps/tickets/migrations/0011_ticketcategory_show_in_customer_form.py b/apps/tickets/migrations/0011_ticketcategory_show_in_customer_form.py index e9628f8..8d7b841 100644 --- a/apps/tickets/migrations/0011_ticketcategory_show_in_customer_form.py +++ b/apps/tickets/migrations/0011_ticketcategory_show_in_customer_form.py @@ -1,18 +1,18 @@ -# Generated by Django 5.1.1 on 2026-09-09 09:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('tickets', '0010_alter_ticketcategory_options_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='ticketcategory', - name='show_in_customer_form', - field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'), - ), - ] +# Generated by Django 5.1.1 on 2026-09-09 09:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0010_alter_ticketcategory_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='ticketcategory', + name='show_in_customer_form', + field=models.BooleanField(default=True, help_text='در صورت خاموش بودن، این دسته در انتخاب و جستجوی مشتری دیده نمی\u200cشود.', verbose_name='نمایش در فرم مشتری'), + ), + ] diff --git a/apps/tickets/staff_urls.py b/apps/tickets/staff_urls.py index fcc5f8c..20cf5ed 100644 --- a/apps/tickets/staff_urls.py +++ b/apps/tickets/staff_urls.py @@ -1,4 +1,4 @@ from django.urls import path from . import views app_name="staff_tickets" -urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts//edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//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//edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] diff --git a/apps/tickets/views.py b/apps/tickets/views.py index d133a86..f5e49a2 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -33,6 +33,53 @@ from .policies import DispatchPolicy, TicketPolicy from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket MESSAGE_PAGE_SIZE=50 +SMS_RETRYABLE_DELIVERY_STATUSES=( + NotificationJob.DeliveryStatus.UNDELIVERED, + NotificationJob.DeliveryStatus.EXPIRED, + NotificationJob.DeliveryStatus.BLOCKED, + NotificationJob.DeliveryStatus.UNKNOWN, + NotificationJob.DeliveryStatus.ERROR, +) + + +def _retryable_sms_jobs(jobs): + return jobs.filter( + Q(status=NotificationJob.Status.FAILED) + | Q( + status=NotificationJob.Status.SENT, + delivery_status__in=SMS_RETRYABLE_DELIVERY_STATUSES, + delivery_next_check_at__isnull=True, + ) + ) + + +def _sms_job_is_retryable(job): + return job.status == NotificationJob.Status.FAILED or ( + job.status == NotificationJob.Status.SENT + and job.delivery_status in SMS_RETRYABLE_DELIVERY_STATUSES + and job.delivery_next_check_at is None + ) + + +def _requeue_retryable_sms_jobs(jobs): + return _retryable_sms_jobs(jobs).update( + status=NotificationJob.Status.PENDING, + attempts=0, + next_attempt_at=timezone.now(), + last_error="", + provider_message_id="", + provider_status_code="", + sent_at=None, + delivery_status="", + delivery_status_code="", + delivery_description="", + delivery_checked_at=None, + delivery_check_attempts=0, + delivery_next_check_at=None, + delivery_is_final=False, + ) + + def conversation_page(queryset,before=None): queryset=queryset.order_by("-created_at","-id") if before: @@ -395,13 +442,25 @@ def staff_detail(request,public_id): mark_messages_read(conversation,request.user) last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last}) history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5] - sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at") + sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) + can_retry_sms=TicketPolicy.can_reply(request.user,ticket) + for sms_job in sms_jobs: + sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job) latest=conversation[-1] if conversation else None group_dispatch = ticket.group_dispatch can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""}) @staff_required +@require_POST +def staff_sms_retry(request,public_id,job_id): + ticket=get_object_or_404(Ticket,public_id=public_id) + if not TicketPolicy.can_reply(request.user,ticket): raise PermissionDenied + jobs=ticket.notificationjob_set.filter(pk=job_id,channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET") + count=_requeue_retryable_sms_jobs(jobs) + messages.success(request,"پیامک ناموفق دوباره در صف ارسال قرار گرفت." if count else "این پیامک در وضعیت قابل تلاش مجدد نیست.") + return redirect("staff_tickets:detail",public_id=public_id) +@staff_required def staff_messages(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied @@ -640,20 +699,14 @@ def staff_dispatch_resolve(request, public_id): def staff_dispatch_retry(request, public_id): dispatch=get_object_or_404(GroupDispatch,public_id=public_id) if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied - retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR] - jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True)) + jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED) job_id=request.POST.get("job_id","").strip() if job_id: if not job_id.isdigit(): messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.") return redirect("staff_tickets:dispatch_detail",public_id=public_id) jobs=jobs.filter(pk=int(job_id)) - count=jobs.update( - status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(), - last_error="", provider_message_id="", provider_status_code="", sent_at=None, - delivery_status="", delivery_status_code="", delivery_description="", - delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None, - ) + count=_requeue_retryable_sms_jobs(jobs) refresh_group_dispatch_status(dispatch.pk) messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.") return redirect("staff_tickets:dispatch_detail",public_id=public_id) diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index 6e3662f..78a2036 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -17,7 +17,7 @@ diff --git a/tests/test_views.py b/tests/test_views.py index 9fad577..75bc2aa 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -9,6 +9,7 @@ from apps.staff.models import OrganizationalUnit, StaffProfile from apps.tickets.models import Ticket, TicketCategory from apps.tickets.services import create_ticket, claim_ticket, reply_ticket from apps.messaging.models import Message,MessageReadReceipt +from apps.notifications.models import NotificationJob from apps.customers.models import CustomerProfile 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 def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client): from apps.core.templatetags.jalali import jalali_datetime - from apps.notifications.models import NotificationJob - category=TicketCategory.objects.create(title="رهگیری پیامک",slug="sms-timeline") 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") @@ -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 "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 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")