This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
.git
.venv
venv
.env*
*.sqlite3
*.sqlite3-*
media
staticfiles
node_modules
graphify-out
test-results
playwright-report
.pytest_cache
__pycache__
*.py[cod]
tests
docs
.idea
.vscode
npm-debug.log*
PROJECT_TREE.txt
project_tree.py
package-lock.json
package.json
requirements-dev.txt
.pytest_cache
+46
View File
@@ -0,0 +1,46 @@
DJANGO_SETTINGS_MODULE=config.settings.prod
DJANGO_SECRET_KEY=replace-with-a-unique-random-secret-of-at-least-50-characters
DJANGO_ALLOWED_HOSTS=support.example.com
CSRF_TRUSTED_ORIGINS=https://support.example.com
PUBLIC_BASE_URL=https://support.example.com
WEBOTP_HOST=support.example.com
TIME_ZONE=Asia/Tehran
# Prefer DATABASE_URL in Coolify. Percent-encode URL-special password characters.
DATABASE_URL=postgresql://postgres:replace-me@managed-postgres-host:5432/customerTicket
# These fields are used only when DATABASE_URL is empty.
POSTGRES_DB=customerTicket
POSTGRES_USER=postgres
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=5432
DB_CONN_MAX_AGE=60
DB_CONNECT_TIMEOUT=10
DB_SSLMODE=prefer
DATABASE_WAIT_TIMEOUT=60
STATIC_VOLUME_NAME=customer_ticket_static
MEDIA_VOLUME_NAME=customer_ticket_media
MEDIA_SIGNING_SECRET=replace-with-a-separate-random-secret-of-at-least-32-characters
SMS_PROVIDER_CLASS=apps.integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider
SMS0098_USERNAME=
SMS0098_PASSWORD=
SMS0098_FROM=
SMS0098_API_URL=http://www.0098sms.com/sendsmslink.aspx
BEHIN_SMS_USERNAME=
BEHIN_SMS_PASSWORD=
BEHIN_SMS_SPECIAL_NUMBER=
BEHIN_SMS_WSDL_URL=http://panel.behinsms.com/SMSWS/SOAP.asmx?wsdl
SMS_API_URL=https://sms-provider.example/api/messages
SMS_API_TOKEN=replace-me
SMS_SENDER=
BALE_API_BASE=https://tapi.bale.ai
BALE_BOT_TOKEN=
BALE_GROUP_CHAT_ID=5924558401
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
MAX_NOTIFICATION_ATTEMPTS=5
SECURE_SSL_REDIRECT=true
SECURE_HSTS_SECONDS=31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS=true
SECURE_HSTS_PRELOAD=false
+12
View File
@@ -0,0 +1,12 @@
# Copy to .env.local for direct local development. Never commit real tokens.
DJANGO_SETTINGS_MODULE=config.settings.dev
USE_SQLITE=true
DJANGO_DEBUG=true
PUBLIC_BASE_URL=http://127.0.0.1:8000
DEV_OTP_MOBILE=+989202202129
DEV_OTP_CODE=12345
SMS_PROVIDER_CLASS=apps.integrations.sms.console.ConsoleSMSProvider
BALE_API_BASE=https://tapi.bale.ai
BALE_BOT_TOKEN=
BALE_GROUP_CHAT_ID=5924558401
INTEGRATION_HTTP_TIMEOUT_SECONDS=10
+70
View File
@@ -0,0 +1,70 @@
# Python bytecode and interpreter caches
__pycache__/
*.py[cod]
*$py.class
.python-version
# Virtual environments
.venv/
venv/
env/
ENV/
# Python packaging and build artifacts
build/
dist/
*.egg-info/
.eggs/
pip-wheel-metadata/
# Test, coverage, lint, and type-check caches
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.mypy_cache/
.ruff_cache/
.hypothesis/
test-results/
playwright-report/
blob-report/
# Django runtime data
*.sqlite3
*.sqlite3-*
media/
staticfiles/
*.log
# Local environment and secrets; keep documented templates
.env
.env.*
!.env.example
!.env.*.example
# Node/npm generated and machine-local files
node_modules/
.npm/
.npmrc
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.parcel-cache/
# Graphify generated graph, cache, reports, and memories
graphify-out/
# IDE/editor settings and temporary files
.idea/
.vscode/
*.swp
*.swo
*~
# Operating-system metadata
.DS_Store
Thumbs.db
Desktop.ini
+42
View File
@@ -0,0 +1,42 @@
# 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.
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_INDEX_URL=https://package-mirror.liara.ir/repository/pypi/simple
WORKDIR /app
# Liara mirrors Debian and PyPI traffic inside Iran. The Debian mirror is a
# build argument so it can be replaced without editing the image recipe.
ARG DEBIAN_MIRROR_HOST=linux-mirror.liara.ir/repository
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR_HOST}|g" /etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev && \
rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --default-timeout=1000 -r requirements.txt
FROM python:3.11-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 1000 django
ARG DEBIAN_MIRROR_HOST=linux-mirror.liara.ir/repository
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR_HOST}|g" /etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends libmagic1 libpq5 && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/staticfiles /app/media && chown -R django:django /app
COPY --from=builder --chown=django:django /opt/venv /opt/venv
COPY --chown=django:django . .
RUN chmod +x /app/docker/entrypoint.sh
USER django
EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "30", "--access-logfile", "-", "--error-logfile", "-"]
+738
View File
@@ -0,0 +1,738 @@
Project Root: customerTicket
Generated At: 2026-09-10T06:55:10+00:00
Python Files: 174
Django Files: 37
JavaScript/TypeScript Files: 17
HTML Files: 29
Total Files: 648
Total Directories: 80
customerTicket
├── apps
│ ├── accounts
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_initial.py
│ │ │ ├── 0003_magiclinkaccesslog_alter_ticketmagiclink_options_and_more.py
│ │ │ ├── 0004_ticketmagiclink_created_unit_and_more.py
│ │ │ ├── 0005_magiclinkaccesslog_magic_opened_idx_and_more.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── forms.py
│ │ ├── magic_access.py
│ │ ├── models.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── attachments
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_initial.py
│ │ │ ├── 0003_alter_attachment_file.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models.py
│ │ ├── services.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── core
│ │ ├── management
│ │ │ ├── commands
│ │ │ │ ├── __init__.py
│ │ │ │ ├── bootstrap.py
│ │ │ │ └── normalize_persian_data.py
│ │ │ └── __init__.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_seed_branding_and_page_titles.py
│ │ │ ├── 0003_sitebranding_developer_credit_html_and_more.py
│ │ │ ├── 0004_sitebranding_staff_sidebar_credit_html_and_more.py
│ │ │ ├── 0005_sitebranding_company_logo_and_more.py
│ │ │ └── __init__.py
│ │ ├── templatetags
│ │ │ ├── __init__.py
│ │ │ └── jalali.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── context_processors.py
│ │ ├── iran_locations.py
│ │ ├── middleware.py
│ │ ├── models.py
│ │ └── persian_text.py
│ ├── customers
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_customerprofile_city_customerprofile_province.py
│ │ │ ├── 0003_contactgroup_customerprofile_organization_name_and_more.py
│ │ │ ├── 0004_contactgroup_unit_customerprofile_groups_and_more.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ └── models.py
│ ├── integrations
│ │ ├── bale
│ │ │ ├── __init__.py
│ │ │ └── providers.py
│ │ ├── sms
│ │ │ ├── __init__.py
│ │ │ ├── base.py
│ │ │ ├── behin.py
│ │ │ ├── console.py
│ │ │ ├── factory.py
│ │ │ ├── http.py
│ │ │ ├── providers.py
│ │ │ └── sms0098.py
│ │ └── __init__.py
│ ├── messaging
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_initial.py
│ │ │ ├── 0003_messagereadreceipt.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models.py
│ │ ├── services.py
│ │ ├── urls.py
│ │ └── views.py
│ ├── notifications
│ │ ├── management
│ │ │ ├── commands
│ │ │ │ ├── __init__.py
│ │ │ │ └── process_notifications.py
│ │ │ └── __init__.py
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_smstemplate.py
│ │ │ ├── 0003_notificationpolicy_notificationjob_status.py
│ │ │ ├── 0004_customer_reply_bale_policy.py
│ │ │ ├── 0005_smstemplate_unit_alter_smstemplate_event_type_and_more.py
│ │ │ ├── 0006_alter_smstemplate_allowed_tags.py
│ │ │ ├── 0007_notificationjob_notif_due_idx_and_more.py
│ │ │ ├── 0008_notificationjob_provider_message_id.py
│ │ │ ├── 0009_ensure_global_sms_templates.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models.py
│ │ └── services.py
│ ├── staff
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_staffprofile_first_name_staffprofile_job_title_and_more.py
│ │ │ ├── 0003_staffprofile_role.py
│ │ │ ├── 0004_organizationalunit_staffprofile_primary_unit_and_more.py
│ │ │ ├── 0005_organizationalunit_accepts_external_tickets.py
│ │ │ ├── 0006_alter_organizationalunit_options_and_more.py
│ │ │ ├── 0007_organizationalunit_show_in_customer_form.py
│ │ │ ├── 0008_unitmembership_ticket_scope_and_roles.py
│ │ │ ├── 0009_alter_staffprofile_sort_order.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── forms.py
│ │ └── models.py
│ ├── tickets
│ │ ├── migrations
│ │ │ ├── 0001_initial.py
│ │ │ ├── 0002_alter_ticketreadstate_ticket.py
│ │ │ ├── 0003_ticket_city_ticket_province.py
│ │ │ ├── 0004_tickettag_ticket_assigned_unit_ticket_created_by_and_more.py
│ │ │ ├── 0005_bootstrap_legacy_staff_unit.py
│ │ │ ├── 0006_assign_legacy_contacts_to_sales.py
│ │ │ ├── 0007_alter_ticketcategory_options_ticketcategory_unit.py
│ │ │ ├── 0008_assign_categories_to_default_unit.py
│ │ │ ├── 0009_seed_customer_facing_units_and_categories.py
│ │ │ ├── 0010_alter_ticketcategory_options_and_more.py
│ │ │ ├── 0011_ticketcategory_show_in_customer_form.py
│ │ │ ├── 0012_groupdispatch_operation_permissions_and_seed_org.py
│ │ │ ├── 0013_merge_legacy_accounting_unit.py
│ │ │ ├── 0014_merge_legacy_warehouse_units.py
│ │ │ ├── 0015_seed_customer_category_catalog.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── category_catalog.py
│ │ ├── customer_urls.py
│ │ ├── forms.py
│ │ ├── models.py
│ │ ├── policies.py
│ │ ├── selectors.py
│ │ ├── services.py
│ │ ├── staff_urls.py
│ │ └── views.py
│ └── __init__.py
├── config
│ ├── settings
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── prod.py
│ ├── __init__.py
│ ├── asgi.py
│ ├── env.py
│ ├── urls.py
│ └── wsgi.py
├── docker
│ ├── entrypoint.sh
│ └── nginx.conf
├── docs
│ ├── ORGANIZATIONAL_TICKETING.md
│ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md
│ ├── QA_PERMISSION_MATRIX.md
│ └── THEMING.md
├── graphify-out
│ ├── 2026-09-05
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-09-06
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-09-07
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-09-08
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── cost.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-09-09
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── cost.json
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.56-s2
│ │ │ ├── 01d84090a3b22716edb63a5cf9c0a56cc94c0f5207cdbd5005843f858bbb292f.json
│ │ │ ├── 027726034309b90d12137f6110b86751f2558dec73d338c93e190b2f9706caa4.json
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
│ │ │ ├── 074e22579bb4a8d9c4f0c12cdf3cee2aa1dbc9e91778fdbbbbe22ce014511f47.json
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
│ │ │ ├── 091194a653ec9ff59e50d6d6121d12c0b0238b3f5cbb0467133a224b7eda59c8.json
│ │ │ ├── 0aafac1c4b289aeef9a6251e3a12fbf1710ae576dbd647d155aa3981008d50b7.json
│ │ │ ├── 0bc6d4b595803438408992e7a267aabdae34a6e30bcb947a90307114918928fb.json
│ │ │ ├── 0d837f2122843751eeb3e945c9c56992b5c410b54ca0dbceccf5413022601d27.json
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
│ │ │ ├── 0fd49b978854a13276bcd9479e5d287e54f19e235d799825f049400153a78c6f.json
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
│ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json
│ │ │ ├── 157a547bf11b52f36b8ae31334708f612d0d615ad75a9d7e42076806661217d7.json
│ │ │ ├── 161e1e32c695094d9d3ceb2a867fa497be0aaef0db27bdfdf94489c6f306e82b.json
│ │ │ ├── 16f67769b1d3e30239a9537cd98689a1f4448c60b2cc8bfdf8468ac9dc712ba6.json
│ │ │ ├── 17ab5521ea572ea85443db030bd5085f1443c20b20da9cbf808f21ad695eb319.json
│ │ │ ├── 17ff6234af168a40b807e5226bfdb41cf2011a7134c763aba3c69428b6879d66.json
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
│ │ │ ├── 1b9e0fc2a347605b7d5ae84c15a0dc71fca5af90fa461763085e622f4c5877ec.json
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
│ │ │ ├── 20a6a77982f0ffa677ecf8b96662481ca9acccb7ef05274a31fd7ddbd217842a.json
│ │ │ ├── 211549439bc4b41e71f437e9909b8fc7bc306273615743fc61f53c8b8dc57e56.json
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
│ │ │ ├── 21f56aad8baf0dfcbc69c801ff4f738d0bcb84270150cde91f6290ebdef0e2d4.json
│ │ │ ├── 22534d744aadf9c9b61b908eccea0e9b3eaa30bc381bace54b3221ee0861a0ec.json
│ │ │ ├── 2447edb7f88778fbf1ff0ac017796eb52bb2ced4c91786eef12a341b37adfaf7.json
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
│ │ │ ├── 2667c1ca679408fbaca6212c012d64f504eb8ddd574841569403de39c25dff3b.json
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
│ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json
│ │ │ ├── 26f1fc81bffa8c22334482cd1cc638ac1f38647ae723ac8c360dd855281598e9.json
│ │ │ ├── 287fdc570ea3598f1b62d5764970740f7397df117fd25e9340f03b0f8d0e5171.json
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
│ │ │ ├── 2ac9a38700a628e196b14c23c8615d221fc2efdf0a9564722d4ddf4e293e70c2.json
│ │ │ ├── 2b104842fa1eaead6f52dc015e74fa38c6327cfa1a95f0858e649d093b1e98bd.json
│ │ │ ├── 2e17dd08443df447b74080559d8cfc6b782adfc04963abfb7473e8a59c21d701.json
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
│ │ │ ├── 32acb58a89265f31e57697995a8939b1636afd415969541ff44be40b8dd198f0.json
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
│ │ │ ├── 334f218e8e74fa3ac3e285059183c77efdbfe99ccfb426df17b4eceef14627e2.json
│ │ │ ├── 3358bbaf4a10bb5102cd25fe7cdb5b8401880e8ec0d711b9b9e9b4c92404865e.json
│ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
│ │ │ ├── 367d8bf55d8d85048571009c40b04ef43211cc9c630af6222fc5db65fb382cc8.json
│ │ │ ├── 37159060b34fd31f9b937b215a65baef4b2091c310ea6430960e1d4991a37c5c.json
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
│ │ │ ├── 3b878a979cc2ea2ac926f8a72f24dbef940bbaafdfbdc76679f4054a3ebf791a.json
│ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
│ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json
│ │ │ ├── 44684e85104b3a520e0369a953041469689cb680c2144a6c3fd7107cadc9eed0.json
│ │ │ ├── 47730ea39698b58e7a30a7e5fce471272e9844e0a0c09a0d603699446667a9e2.json
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
│ │ │ ├── 49be8523d744110ec6038ec61ccc9e8f47894a392b60e970206c512c27e68178.json
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
│ │ │ ├── 4bcdbebd8ab168095ad8130c68a697dd5ed64c3f52350fbd395c37408b9a7afa.json
│ │ │ ├── 4ca6d659e3dbcd21e9bfcdc394e19ad8b4dfb6a26665f3395e5a0d5f41e611ce.json
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
│ │ │ ├── 4dae69d87cc12f2afcaf76a8124cbb0ed4ed5b1b196563ead3179384bf5cb5a8.json
│ │ │ ├── 4ece1fa07b9b0b7d50f5efe48a083265e3d8efac191ed4a14875c8399522db42.json
│ │ │ ├── 4ed0e6e63784e85d5e256cb6eda996383dad486161209fda91200caa511e8907.json
│ │ │ ├── 4f4799a971a5d6701087428e8c709396c0d947f7ce5bcf7a048cd542d5551c57.json
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
│ │ │ ├── 53705e64605d8b06954f91829d03ceef80bfacf943427a2d51079c554e41a42b.json
│ │ │ ├── 552bcabe2c76a5db8c1f071dbd2b19c31aeee0b07cf605cd5f2b031ea3b6440f.json
│ │ │ ├── 57507a7cdc8d6a5af4629b2714e7f0226805f833900d496b938e8f03dfc617ab.json
│ │ │ ├── 576178824e99e86dee1ca5fcdf46f61962380f6b8a708b046fbdfc766eb650ce.json
│ │ │ ├── 5794ed0ca9011299d935a3be468c921c65ab616e78f0c6509ec22e3a77cdb794.json
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
│ │ │ ├── 5bb0895d417e210a10ebf2c63ecf3185dca651057d952203e2e39abc249180a8.json
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
│ │ │ ├── 5dd23426751ac130946faa8dff76b2200a8a6c5515863890c15ac5c6c3a542d3.json
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
│ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json
│ │ │ ├── 6469cbb1dbfa0b98762fde08c6fd05cc831e1cf21bd61aebb56b34a7e916bb1a.json
│ │ │ ├── 6506009802a6a268be82b680ace7580613f18dc702ed27aaff929398a85760f3.json
│ │ │ ├── 688d055d3333b15b1beed13c74d14b507a1db1400918f22f01c3bc37bdb4c84e.json
│ │ │ ├── 68f74aa5f41fe3d0e77d36da903e26c49346ee9226c8218a71f7b44c2c41bd93.json
│ │ │ ├── 696bb846cc6a3735e085b502a1008e270903c81c7a7adcc7676abcf1930e926a.json
│ │ │ ├── 6a7b027b7f77736aea87ce048be3247c43cf4878e8a1105811247839427ebcf8.json
│ │ │ ├── 6b60f8a4e1c97f193edc097890ea2759ec246b8ea09035b2afe8b0b32181f345.json
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
│ │ │ ├── 6e56bc8cfd4ce0c21b56fe2b18b7923c24b34cc09ff39276d1438666c54b074f.json
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
│ │ │ ├── 71e1e6a563535dd1761f4048b6638e891580152d186b926416df47f8f2948782.json
│ │ │ ├── 72d77ebab0d6c84d4c8e54af7eaf7fae0344a476dbc91cceedae586a5e54f3c6.json
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
│ │ │ ├── 749d284b3c2e0b313c52afd8d5707b846e0e87e537e3c5fc2455c825f43b3947.json
│ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json
│ │ │ ├── 7592b56384d6034a7fc29e92b9e6d8b494cff9ff5536a9880a95856d28942151.json
│ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json
│ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json
│ │ │ ├── 77f3cfc32bc9234033fcc789ff327a4a98809057bd34d1539b7f2883ccf74ab3.json
│ │ │ ├── 7805df77950548e276a1dac439cf36a3620025bdb8a5739e7a84237f38d01a8c.json
│ │ │ ├── 780c2256439479b3eadb498309a4309b20313a36633aa8bec2a0dfcfce3a3d32.json
│ │ │ ├── 78381b49b02bd2a6248eb0363cbceaafe887dbf8e6b19dc513739b35b87cca95.json
│ │ │ ├── 79cb5047a177592ad737ee438a5e0de370e5dcd28b164b5a48234a6e4f554b1e.json
│ │ │ ├── 7aff3a4d59fae95d952044a679293bff08c7305212ff12bd7be1d314e1f25a3a.json
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
│ │ │ ├── 7c9789f4b51a967448da1de52e4ac3bfb4b342284bab1257343262aaa32b0d6d.json
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
│ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json
│ │ │ ├── 80ee0e1cf4fb023c632197378ec4601f72c438df11001fe2648f59ae2aa89199.json
│ │ │ ├── 8206f93340496777fb5c0c2b431493c2d243c12fb74dc8cac46123d15f9d3f44.json
│ │ │ ├── 835c014cd4e3398cae6e9466790acda90cd37183b9a777ddece3881351026c3c.json
│ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
│ │ │ ├── 876b8154980e87a39dbec815f7b9810aa93ab2f5af2c8c483a2dfddca662dc37.json
│ │ │ ├── 8790d1fdf629fe1dddd54df9f0f37c212fb3308b574efeada0108e83ec8b33ec.json
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
│ │ │ ├── 897df8bbfa3d5a2c272916d47f3326de2b1cad7c3046015c949a46f69e9b8247.json
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
│ │ │ ├── 8aeea7acc875fd642cb75f8766dc1fa84e8f91f027c3ced3bf44b8faa1a2798f.json
│ │ │ ├── 8b45a75ab824f6e8b21a94f6828c0da172fd242fadeb1f61b32de34b80f62cee.json
│ │ │ ├── 8c258f28e7a23d569a250b17e2f591ea2b1a5b9072ab1e0ddff1b535468badb7.json
│ │ │ ├── 8fd86558bd11e7a00f6b28cfad545707ef94afd87a9095ab532588b36c49f842.json
│ │ │ ├── 8fe2c3236c2b05a4814f32898daea17b341332cd0cc887df7a154ad999480d75.json
│ │ │ ├── 9141ebe765f15cd7e3b24431f315af03e41a165ff0a54c33bec2c4884515eff2.json
│ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json
│ │ │ ├── 939d30ca6a3e0c8379078425d21de30c3e8c2743ddf3e13efd984becbf68d109.json
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
│ │ │ ├── 972650f72e30ee3a72cd37dedff2f0ba823b355300dd68c5e196f87717bd5bbe.json
│ │ │ ├── 9729a8690e1957a356322dc991c0618b49605bd58709dcf8ce3ab1af7d254a1c.json
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
│ │ │ ├── 9931a5b7d7ab58794d003cce1d0719c201c623a844c7f834f66c88e9390c824e.json
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
│ │ │ ├── 9a6a5b3e4a7ac5efbe662c2a44a7d9400c2c46f18f15c5dd3627deab4bd7c8d7.json
│ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json
│ │ │ ├── 9bc00a73e1e7393f535b9f58edc50d42169c407c58af664ffdf838c533bd29cf.json
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
│ │ │ ├── 9e5144f3933fd4424f1e271f60b78ae110855d238a7921b499b1586d21ad16a9.json
│ │ │ ├── 9eb524ba1a6374b2d29429652e7e8bf087d839fecd65494380982eab349382a8.json
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
│ │ │ ├── 9ef0e904ba6d1b295c75a1d5741851f571339a8e22c7de96bf5e2508f764ee43.json
│ │ │ ├── a05b3a5bb6e91642075fb765d43a0a6e4dc595bb9131be86643e6d06e6fc3cd9.json
│ │ │ ├── a063cce1553aca090b61f3b2b621a089d1c128ff347e11f836857b77ef5f3d56.json
│ │ │ ├── a1d213c52d246524be10c084f9134e77ff2e2a9aa4361b71dc4bedc89f461cdf.json
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
│ │ │ ├── a7729f0b6bf20ded62ff7b6ee678fdaae9516dcd80c2a8f9e9f3d9299f6e22a0.json
│ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json
│ │ │ ├── aa56d5609649d9327e5f4cf6d02b5e8afb7749388f6d4f8776dcf910e5453935.json
│ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json
│ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json
│ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json
│ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json
│ │ │ ├── aff120c654cfc097d07e9078775b97e4f066e7c539b21b49397a372086d45543.json
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
│ │ │ ├── b39d104e50bfbd13e83fdf946668708536d98cd0c6228eded5fabd5bcf6ddd18.json
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
│ │ │ ├── b4db8b4f6749eb80079ccf2d1a2e81dae05804168c415767ed58a23f16ae2ef8.json
│ │ │ ├── b51c7386a49f53f1e51e3c0534090d4b3e068752cc09b3bf1c07acae4897b44e.json
│ │ │ ├── b5349ed182daa82709b46b433d85ac4c9a610994647244f39a895138f087c4cc.json
│ │ │ ├── b58aa068f1fd9b9bbdc1f9acfd10920458e7ce8cc56676580d1970e1375e7c61.json
│ │ │ ├── b6a1d3264457641619e6f60d3f961556b765901b600688661d4795c974cc1eaa.json
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
│ │ │ ├── b7034e69b8b9437fdf34faa4adc116aaca2fcee50e966a400972c365076e88fc.json
│ │ │ ├── b736fc83f0aef50bd9c65d26cabc1cadf09c0469465e44fb8fca8b9b30bee7a4.json
│ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json
│ │ │ ├── bafb94885159908a9055100ceaf4034f3822bbb4f78672fa552b0a276a0c895f.json
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
│ │ │ ├── bd5b6283310ced47ad086af119cad6259d88c1d2857a6aed4f4f9d8a86233de8.json
│ │ │ ├── be6cc64f9c2a8edbdbbd10a1a31e3b3a746eb4bddf806c71d525eff5719b9217.json
│ │ │ ├── beb1b14e942397ae4b9f2e8a743282f127cc9f8c6e7222db7a28185de4493e57.json
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
│ │ │ ├── c1943dfe9eee1a9f88ea571dc5aa06a3f521241fffa51d55b1414c675e63c236.json
│ │ │ ├── c251d835538bf3c08a96268c8b1feaae8084a9c298d634ce2f2ee3b4b15188ce.json
│ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json
│ │ │ ├── c8c8647cd740fbc96327657baebd4b4a16bfa1d8e7d4cfe3f845a0a5ab7c98f7.json
│ │ │ ├── ca42945436e914d9530e20dc780794451ec6ab51e17414e8c9107ccad3125fd5.json
│ │ │ ├── ca8e9d2e76f32d42a0da88c9477956b47dca6cdb27504d8d126e0075306eae98.json
│ │ │ ├── cb744c0a1cb5fa65e239993bee622ad4a60990c628cd1d4503f9e8806d1d2c92.json
│ │ │ ├── cb87cd70f59a8e0950529ae42a2229a259ff8b305e4319422fb1a9dbc41e63e2.json
│ │ │ ├── cc5e32b33a50eb2225359c15bceebc406d04a07218cda65de3e6f79603d67416.json
│ │ │ ├── ccd15ad60b63cd29e2bd2762133a74e2047a2894fd67206dff7f7c7fd082b399.json
│ │ │ ├── cdeaabefa47597c8be9310653d52fb6870d859162b49c8dae6db8f4f699dc4cc.json
│ │ │ ├── cf1725a1db0d7ecffa63d8b1b693854170e55385eaa4b0cc604f9d6ab204eb4e.json
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
│ │ │ ├── d210ed7569b9e06acf7037d9a7d15ccac0f6f95ef7d3a0707741db8629443177.json
│ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json
│ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json
│ │ │ ├── d48290356397f2b79e05fc0b6bc6f9c327da366096ff14c15e90516fad7218a1.json
│ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json
│ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
│ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json
│ │ │ ├── db04dbc0e2dca073d2b145db8ba6c2c5299d3fd93742e1022c611c938fcb4d80.json
│ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
│ │ │ ├── e0373f56a7e20838a75d2c1920393433e8d9a6ffa919334851b70f210ed4dbf3.json
│ │ │ ├── e15f9a7e0698366b7d8b4bee6102d351241860a078550f47a1eb27503e521bbc.json
│ │ │ ├── e2c372f92146217844d34664334cb0d7f01fe262ff98edf009a66c4bf4743b5c.json
│ │ │ ├── e42f6a96ad8938a6ab248b342feca7ec6ecf2a734d25141f62dbc9a6c52ecf6b.json
│ │ │ ├── e53a0dfeecd1b547e38896f8e7920e6801d84dbf61bb4513541892d44d42ae99.json
│ │ │ ├── e7c0ad70ad7e7f3a1f6f35e55f4cf87f6c0850abc63de71a418e81b88ec826b7.json
│ │ │ ├── e831be2a985dcf2094a014d182ea02e4ee10b94fbd35b44e3c69a98a7acb3451.json
│ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json
│ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json
│ │ │ ├── ea42f92a2b1964458b3c1060b13a607581b30e9b43d5e031f8a263cae90ba581.json
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
│ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json
│ │ │ ├── edfca154606b071ac823ab03572797546c6044eda88073e9e3f55b4960cb3400.json
│ │ │ ├── ee19f3245c11f658fb60d32050a8bc4864ea289071ff5f829717ecacbac1d3e4.json
│ │ │ ├── f12416bacd2eea21842fe89fcfc2c02ffe84619d7ce380d13679b64c6ec7496d.json
│ │ │ ├── f150f764dc46f341076a8b373709a9cfefe48476672882b2a979df4624aa2626.json
│ │ │ ├── f2069429b170b19df531b051a2f5fa81854a7bedf1a036aab1a46c89a9ba4c92.json
│ │ │ ├── f2287c55ccdc0e5e9ab12f79895b1a1f46b65b2d14f70915ceb09adfc624e3b8.json
│ │ │ ├── f411c6e74a1dc71e5f7a16f159d065410a8562fd545dd209179b54ae2e848e10.json
│ │ │ ├── f4436ad45c3f2a27b4461333ae2ed21af157a3945e152fca7f43d640dc39f63b.json
│ │ │ ├── f58927b2be96cb8769bb26a2ede3d8c05796e7a79fefd7fdcdc89d658609fc66.json
│ │ │ ├── f71ec68c7b2a4467358d618354305f3f4e99cc0dbe60fa00d39478b686fe8bb2.json
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
│ │ │ ├── f92455d5b493a0d7e9565d2c7463a1aa3fe62fa20ea19e635e66c35582ab685e.json
│ │ │ ├── fa19a1bc8027144f14106a6f90d4ddab0277580b8bcdb6e9e3ba67496c043d62.json
│ │ │ ├── fc313334fba5b89ae7b30c44e1b0bd43e185bfd06c1e9804ad1b9b6de65efad8.json
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
│ │ │ ├── fe77fbd662d91d04f2efc69788d30e9859584c8d87d1fabd60ca66661568091e.json
│ │ │ ├── fe93d0fb278c013183276f7bf5c5e2d0e62fe9c75f6695390f5994ec0b9c8dec.json
│ │ │ └── ff1bf9257c3d267b5a04560ed07ac6d4442a145b3e6db5a0f8e63e8e7f386533.json
│ │ ├── semantic
│ │ │ └── pa567fc138e3a
│ │ │ ├── 00825b46909baea25186d0f8351fc2c3fae63520319b5e7bede4ed3e9f53e54d.json
│ │ │ ├── 09f927cff0791d0e91365f50b4dba1a6c8cc0496ef70847d2ba9f1726092d00c.json
│ │ │ ├── 1753a19b402251a9e18e9c4113a5f1750a4168e9b0bedb06b4d6f35f2aa5980c.json
│ │ │ ├── 195617b9b33d83896a4ffa16edb1d849e0da5cb7a1aa2b64d82d1bafed070f80.json
│ │ │ ├── 1e1ba446fa646a9dbd2244d5138ad84e217e8f238f2508603cfe4c290423dde8.json
│ │ │ ├── 1f916be2dc79752fb2aa8e0c386470f43cc983c9021de885b62ca962ffea960f.json
│ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json
│ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json
│ │ │ ├── 23f032018f0612c58670d22539577856b5479f6107878d558e5e062bac9da30b.json
│ │ │ ├── 28f2ce9d1005a70b7f624b04888ce9c511251d8da243d6c1c57a630eb14c1220.json
│ │ │ ├── 29a4f053d88deb1406e9e7fd20dac4fc1a3c518382af3adcb25fc0d6415e6c31.json
│ │ │ ├── 2b0ca95b4d2205396255ef9ad6880126d69de168f4349406796caf7cee711791.json
│ │ │ ├── 3451c7089bb943a2571e2fd451467aa5e18fc642a64edaee34380e7b59f54baa.json
│ │ │ ├── 379031c1044f245cdf1ded7504608f5861a11d5110845436678be4e121251062.json
│ │ │ ├── 481e0ef213e9eb7ac3679e2fdf54cfe7bafde152b3de57b0c9306416529ab904.json
│ │ │ ├── 485a9d1cf66322f5cb2886aac1f388a2846aba6fd444b87b251edb03c51ff00a.json
│ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json
│ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json
│ │ │ ├── 697c16d4c7fadca75d73c67dcfea3aefc8c3b91394718b1d09188e7e41d8811a.json
│ │ │ ├── 6d5ac5656791048028b6bec9a94588cede8731bd7ca2151f13f7819e2819f2e7.json
│ │ │ ├── 7322f205d129029de2b4bb0be04fc6de2798fa17122738ae5d5f300e66ee5409.json
│ │ │ ├── 77b69c31898165440ab0d856d50bbe29495f04c804d5ca4104be340e0ed39cc7.json
│ │ │ ├── 78aa9c4767c326ca3364fd87dfabda624b9bdeb9a14dfc7b678d0bcd9c00e6f7.json
│ │ │ ├── 8042771a592072bef73f5634ecc39cd2b83ba3d808e806a69bacde795ff1d2dc.json
│ │ │ ├── 80cbf2282ae0d907d07bf110760fa70e985fa748f41bcc90533f6ff46eaf46c9.json
│ │ │ ├── 853a41742ac5792c2f6d6762d095b7170499a35c16efaa4fa567ee936b4ccb3c.json
│ │ │ ├── 87083d9b896625e7efbbfda3a8b4c0983a3914779adc1a9f1fd4775200a98843.json
│ │ │ ├── 8a7c3cffb298a6af2f6f497e70513330244117b87649ee61c964461dbcaa05e8.json
│ │ │ ├── 8e62122fceb99e49a7dd46a86cc46cbe3218cd86773d310fe15135a6ad4d6998.json
│ │ │ ├── 9281775c35930ece9d1382446b2e7d54cb3ec777fbc045ce46cdce5287f589d9.json
│ │ │ ├── 95dc06be19ee8e1bbc725384f6e55bbafaab3b6c9f8deea714356fbd0669545a.json
│ │ │ ├── a7b56fb6165dabba4d71f0757463855dfca3d85741975d62820b05abee14890f.json
│ │ │ ├── b631ef1705c4bbcc263ee039213e6929538d85401b648d0a675849fbeb60db11.json
│ │ │ ├── bc16443a4c12a2ca6a8a74c6378c2f66b27425fd52be8ca5c938c2141c886a53.json
│ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json
│ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json
│ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json
│ │ │ ├── e3fb6d5f912be572c627f840a143729a58015403907a2616b32d3ebbe9933763.json
│ │ │ ├── e5f6e9aa1524a7e8c45c4ba6043bbf5798a8f8bec760ed991d69359cd416a08c.json
│ │ │ └── ec9174249dd1b030999fd9c188e1713659650afd92dbe8fcd7a1f29666023a81.json
│ │ ├── last_query_stamp
│ │ └── stat-index.json
│ ├── memory
│ │ ├── query_20260905_170246_63845aa1_افزودن_استان_و_شهر_قابل_جستجو_به_پروفایل_مشتری_و_ف.md
│ │ ├── query_20260906_045047_18cd50cb_بهبود_ui_ux_پنل_کارشناسان_و_رفع_active_نشدن_لینک_ه.md
│ │ ├── query_20260906_051057_e5dbb774_professional_high_contrast_typography_filters_meta.md
│ │ ├── query_20260906_104407_a2b8e7e3_staff_customer_directory_with_name_mobile_search.md
│ │ ├── query_20260906_105257_8eb55f7c_professional_responsive_ui_ux_redesign_for_the_sta.md
│ │ ├── query_20260906_105823_f78ba220_why_did_frontend_css_require_ctrl_f5_after_every_c.md
│ │ ├── query_20260906_111126_ebdc56bf_fix_staff_avatar_upload_in_django_admin_and_displa.md
│ │ ├── query_20260906_112825_ea391369_replace_the_project_wide_sms_implementation_with_t.md
│ │ ├── query_20260907_122020_4b046e4e_why_does_staff_ticket_polling_return_stale_changed.md
│ │ ├── query_20260907_123737_6134b5c8_آیا_با_هر_پاسخ_کارشناس_پیامک_برای_مشتری_ارسال_می_ش.md
│ │ ├── query_20260908_073243_f5d33807_graphify_build_full_py_برای_چه_بود__آیا_دیگر_بهش_ن.md
│ │ ├── query_20260908_080123_0dda7692_در_میزکار_staff_تیکت_های_قابل_اقدام_بر_اساس_جدیدتر.md
│ │ ├── query_20260908_144036_ded077cf_صفحات_staff_contacts_و_sms_templates_خطای_403_دارن.md
│ │ ├── query_20260908_150032_76389667_بازطراحی_ui_صفحات_staff_و_ادغام_customers_و_contac.md
│ │ ├── query_20260908_150749_7f7a3ef0_آیا_workerهای_sms_و_bale_در_حال_اجرا_هستند.md
│ │ ├── query_20260908_155640_736abe4d_بررسی_و_تکمیل_مرحله_به_مرحله_شکاف_های_سامانه_جامع.md
│ │ ├── query_20260909_035506_5355f42d_لطفا_تغییرات_عنوان_و_header_دیتابیس_محور__حذف_متن.md
│ │ ├── query_20260909_050053_deff48c3_در_صفحه_login_متن_بخش_auth_intro__زیرعنوان_درخواست.md
│ │ ├── query_20260909_050734_e5645542_متن_امضای_توسعه_دهنده_در_پایین_sidebar_پنل_staff_ج.md
│ │ ├── query_20260909_052451_568b2e38_متون_staff_login_و_صفحه_tickets_از_دیتابیس_مدیریت.md
│ │ ├── query_20260909_161708_1f0b7523_اصلاح_معماری_دسترسی_واحد_سازمانی_نقش_permission_و.md
│ │ ├── query_20260909_164343_d087dac1_چرا_در_صفحه_tickets_new_با_تغییر_واحد_کارشناسان_آن.md
│ │ ├── query_20260909_170048_f114f91f_چرا_تغییر_واحد_در_ui_فورا__خطای_دریافت_کارشناسان_م.md
│ │ ├── query_20260910_053611_613a8cc4_qa_role_organization_unit_permission_scope_workflo.md
│ │ ├── query_20260910_060824_3faac489_in__tickets_new__place_unit_and_category_side_by_s.md
│ │ ├── query_20260910_064145_f562e795_برای_واحدهای_فروش_و_مالی_حسابداری_دسته_بندی_های_مش.md
│ │ └── query_20260910_065345_ff4c3175_جستجوی_دسته_بندی_های__tickets_new_search_را_حرفه_ا.md
│ ├── reflections
│ │ └── LESSONS.md
│ ├── .graphify_analysis.json
│ ├── .graphify_ast.json
│ ├── .graphify_detect.json
│ ├── .graphify_extract.json
│ ├── .graphify_labels.json
│ ├── .graphify_labels.json.sig
│ ├── .graphify_learning.json
│ ├── .graphify_python
│ ├── .graphify_root
│ ├── .graphify_semantic.json
│ ├── .vocab.txt
│ ├── cost.json
│ ├── graph.html
│ ├── graph.json
│ ├── GRAPH_REPORT.md
│ └── manifest.json
├── static
│ ├── css
│ │ ├── app.css
│ │ ├── dispatches.css
│ │ ├── enhancements.css
│ │ ├── operations.css
│ │ ├── tailwind.css
│ │ ├── ticket-composer-v2.css
│ │ └── ticket-composer.css
│ ├── data
│ │ ├── iran-locations.json
│ │ └── iran-locations.LICENSE.md
│ ├── fonts
│ │ ├── vazirmatn-arabic-wght-normal.woff2
│ │ └── vazirmatn-latin-wght-normal.woff2
│ ├── images
│ │ ├── company-logo.png
│ │ ├── favicon.ico
│ │ └── pwa-icon.png
│ ├── js
│ │ ├── attachment-uploader.js
│ │ ├── chat-coach.js
│ │ ├── conversation.js
│ │ ├── image-viewer.js
│ │ ├── location-picker.js
│ │ ├── pwa.js
│ │ ├── staff-customer-picker.js
│ │ ├── ticket-intent-search.js
│ │ ├── ticket-unit-options.js
│ │ ├── ui.js
│ │ ├── voice-recorder.js
│ │ └── webotp.js
│ ├── src
│ │ └── tailwind.css
│ ├── vendor
│ │ ├── alpine-3.14.8.min.js
│ │ ├── compressor.min.js
│ │ └── htmx-2.0.3.min.js
│ └── manifest.webmanifest
├── templates
│ ├── accounts
│ │ ├── customer_login.html
│ │ ├── customer_password_login.html
│ │ ├── otp.html
│ │ ├── profile.html
│ │ └── staff_login.html
│ ├── includes
│ │ ├── auth_brand.html
│ │ └── auth_footer.html
│ ├── messaging
│ │ └── revisions.html
│ ├── pwa
│ │ └── service-worker.js
│ ├── tickets
│ │ ├── _image_viewer.html
│ │ ├── _messages.html
│ │ ├── _older_messages.html
│ │ ├── _staff_customer_results.html
│ │ ├── _staff_ticket_results.html
│ │ ├── customer_detail.html
│ │ ├── customer_list.html
│ │ ├── customer_new.html
│ │ ├── staff_contacts.html
│ │ ├── staff_create.html
│ │ ├── staff_customers.html
│ │ ├── staff_detail.html
│ │ ├── staff_dispatch_create.html
│ │ ├── staff_dispatch_detail.html
│ │ ├── staff_entity_form.html
│ │ ├── staff_groups.html
│ │ ├── staff_list.html
│ │ ├── staff_magic_audit.html
│ │ └── staff_sms_templates.html
│ └── base.html
├── test-results
│ ├── e2e
│ │ ├── tests_e2e_test_chat_coach.py__test_customer_sees_chat_guidance_after_two_rapid_messages
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_chat_coach.py__test_staff_sees_chat_guidance_after_two_rapid_replies
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_natural_language_search_selects_helpful_catalog_route[invoice]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_natural_language_search_selects_helpful_catalog_route[shipping]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_natural_language_search_selects_helpful_catalog_route[supplier]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_switches_unit_and_sees_its_categories
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_05_06_limited_operation_owner_and_ceo_view_all[BULK-03]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_05_06_limited_operation_owner_and_ceo_view_all[INQUIRY-04]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_07_sales_general_queue_creates_verifiable_ticket
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_08_repeated_staff_changes_store_only_last_choice
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_09_10_11_attachment_variants_are_saved_and_rendered[jpg-images0-documents0-expected0]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_09_10_11_attachment_variants_are_saved_and_rendered[mixed-images2-documents2-expected2]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_09_10_11_attachment_variants_are_saved_and_rendered[multi-file-images3-documents3-expected3]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_09_10_11_attachment_variants_are_saved_and_rendered[pdf-images1-documents1-expected1]
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_11_removed_attachment_is_not_submitted
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_12_sequential_unit_staff_category_and_attachment_state
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ ├── tests_e2e_test_pwa_versioning.py__test_pwa_registers_versioned_worker_and_matching_cache
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ └── tests_e2e_test_staff_operations_ui.py__test_staff_operations_pages_are_authorized_and_visually_styled
│ │ ├── failure.png
│ │ └── trace.zip
│ ├── customer-chat-guidance.png
│ └── staff-chat-guidance.png
├── tests
│ ├── e2e
│ │ ├── pages
│ │ │ ├── __init__.py
│ │ │ ├── auth.py
│ │ │ ├── base.py
│ │ │ ├── customer_new_ticket.py
│ │ │ └── tickets.py
│ │ ├── config.py
│ │ ├── conftest.py
│ │ ├── README.md
│ │ ├── test_chat_coach.py
│ │ ├── test_customer_unit_categories_e2e.py
│ │ ├── test_external_environment_smoke.py
│ │ ├── test_organization_permissions_e2e.py
│ │ ├── test_public_ticket_workflows_e2e.py
│ │ ├── test_pwa_versioning.py
│ │ └── test_staff_operations_ui.py
│ ├── js
│ │ └── chat-coach.test.mjs
│ ├── test_branding_operations.py
│ ├── test_customer_category_catalog.py
│ ├── test_customer_unit_categories.py
│ ├── test_domain.py
│ ├── test_env.py
│ ├── test_jalali_templates.py
│ ├── test_organizational_ticketing.py
│ ├── test_permission_architecture.py
│ ├── test_persian_text.py
│ ├── test_role_workflow_regression.py
│ └── test_views.py
├── .dockerignore
├── .env
├── .env.example
├── .env.local
├── .env.local.example
├── .gitignore
├── .npmrc
├── AGENTS.md
├── db.sqlite3
├── docker-compose.yml
├── Dockerfile
├── manage.py
├── package-lock.json
├── package.json
├── project_tree.py
├── pytest.ini
├── README.md
├── requirements-dev.txt
├── requirements.txt
└── Software Requirements & Architecture Specification — سیستم تیکتینگ فروش.md
+45
View File
@@ -0,0 +1,45 @@
# سامانه تیکتینگ فروش
پیاده‌سازی Django Modular Monolith بر اساس سند نیازمندی همین مخزن. رابط فارسی و RTL است و جریان‌های مشتری، کارشناس، دسترسی خصوصی، پیام، فایل، OTP، Magic Link و Notification Outbox را پوشش می‌دهد.
## اجرای محلی
```powershell
python -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements.txt
.\.venv\Scripts\python manage.py migrate
.\.venv\Scripts\python manage.py bootstrap
.\.venv\Scripts\python manage.py createsuperuser
.\.venv\Scripts\python manage.py runserver
```
در محیط توسعه SQLite فعال است. OTP در ترمینال با پیشوند `[DEV OTP]` چاپ می‌شود. برای تست سریع، شماره `09202202129` با کد ثابت `12345` فقط در تنظیمات Development قابل ورود است؛ تنظیمات Production این میان‌بر را غیرفعال نگه می‌دارند. برای ایجاد کارشناس، User با `account_type=STAFF` و StaffProfile بسازید و او را عضو گروه `Sales Expert` یا `Sales Manager` کنید.
## اجرای Production با Docker
```powershell
Copy-Item .env.example .env
docker volume create customer_ticket_static
docker volume create customer_ticket_media
docker compose up --build
docker compose exec web python manage.py createsuperuser
```
قبل از استقرار مقادیر secret و host در `.env` را تغییر دهید. TLS و proxy اپلیکیشن توسط Traefik خود Coolify مدیریت می‌شود. migration، bootstrap و collectstatic هنگام شروع web به شکل خودکار اجرا می‌شوند. Nginx فقط static را سرو می‌کند و فایل‌های آپلودی خصوصی پس از کنترل مجوز مستقیماً توسط Django تحویل داده می‌شوند.
راهنمای کامل تنظیم Coolify، volumeها، PostgreSQL خارجی و worker در [docs/DEPLOYMENT_COOLIFY.md](docs/DEPLOYMENT_COOLIFY.md) است.
## تست
```powershell
.\.venv\Scripts\python -m pytest -q
```
قواعد کلیدی در `apps/tickets/policies.py` متمرکز شده‌اند و عملیات حساس در `apps/tickets/services.py` تراکنشی هستند.
## Environment files
- Direct development with `config.settings.dev` automatically loads `.env.local`.
- Production with `config.settings.prod` can load `.env`; Docker Compose also injects that file into its services.
- Set `DJANGO_ENV_FILE` in the process environment to select another file, such as `/etc/customer-ticket/server.env`.
- Existing process variables always take precedence over values in dotenv files.
- Copy `.env.local.example` to `.env.local` for a safe local starting point; real dotenv files are excluded from Git and Docker images.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+72
View File
@@ -0,0 +1,72 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from apps.staff.models import StaffProfile
from apps.staff.forms import StaffProfileAdminForm
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
class MagicLinkAccessLogInline(admin.TabularInline):
model = MagicLinkAccessLog
extra = 0
can_delete = False
fields = ("opened_at", "ip_address", "user_agent")
readonly_fields = fields
def has_add_permission(self, request, obj=None):
return False
@admin.register(TicketMagicLink)
class TicketMagicLinkAdmin(admin.ModelAdmin):
list_display = ("ticket", "recipient_name", "recipient_mobile", "created_unit", "created_by", "created_at", "expires_at", "view_count", "revoked_at")
list_filter = ("created_unit", "created_reason", "revoked_at", "expires_at")
search_fields = ("ticket__ticket_number", "recipient_name", "recipient_mobile", "created_by__username")
readonly_fields = ("ticket", "customer", "token_hash", "created_by", "created_unit", "recipient_name", "recipient_mobile", "created_at", "expires_at", "used_at", "revoked_at", "created_reason", "last_used_ip", "last_user_agent", "view_count")
inlines = (MagicLinkAccessLogInline,)
def get_queryset(self, request):
queryset = super().get_queryset(request).select_related("ticket", "created_unit", "created_by")
if request.user.is_superuser or request.user.has_perm("accounts.view_all_magic_links"):
return queryset
unit_ids = request.user.unit_memberships.filter(is_active=True).values_list("unit_id", flat=True)
return queryset.filter(created_unit_id__in=unit_ids)
def has_add_permission(self, request):
return False
@admin.register(MagicLinkAccessLog)
class MagicLinkAccessLogAdmin(admin.ModelAdmin):
list_display = ("magic_link", "opened_at", "ip_address", "user_agent")
list_filter = ("opened_at",)
search_fields = ("magic_link__ticket__ticket_number", "ip_address", "user_agent")
readonly_fields = ("magic_link", "opened_at", "ip_address", "user_agent")
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
class StaffProfileInline(admin.StackedInline):
model = StaffProfile
form = StaffProfileAdminForm
extra = 0
max_num = 1
fields = ("primary_unit", "role", "first_name", "last_name", "display_name", "job_title", "avatar", "mobile", "bale_chat_id", "bale_notifications_enabled", "is_customer_selectable", "sort_order", "is_active")
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = UserAdmin.fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
add_fieldsets = UserAdmin.add_fieldsets + (("سامانه تیکت", {"fields": ("mobile", "account_type")}),)
list_display = ("username", "mobile", "account_type", "is_active", "is_staff")
list_filter = UserAdmin.list_filter + ("account_type",)
inlines = (StaffProfileInline,)
def save_model(self, request, obj, form, change):
if obj.account_type == User.AccountType.STAFF:
obj.is_staff = True
super().save_model(request, obj, form, change)
admin.site.register(OTPChallenge)
+4
View File
@@ -0,0 +1,4 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "apps.accounts"
+35
View File
@@ -0,0 +1,35 @@
from django import forms
from django.contrib.auth import authenticate
from .models import normalize_mobile
from apps.customers.models import CustomerProfile
from apps.core.iran_locations import validate_location
class MobileForm(forms.Form):
mobile = forms.CharField(label="شماره موبایل")
def clean_mobile(self):
try: return normalize_mobile(self.cleaned_data["mobile"])
except ValueError as exc: raise forms.ValidationError(str(exc))
class OTPForm(forms.Form): code = forms.CharField(label="کد تایید", min_length=4, max_length=8, widget=forms.TextInput(attrs={"autocomplete":"one-time-code","inputmode":"numeric","pattern":"[0-9۰-۹]*","data-webotp":""}))
class CustomerPasswordLoginForm(forms.Form):
mobile=forms.CharField(label="شماره موبایل")
password=forms.CharField(label="رمز عبور",widget=forms.PasswordInput)
def clean_mobile(self):
try:return normalize_mobile(self.cleaned_data["mobile"])
except ValueError as exc:raise forms.ValidationError(str(exc))
def authenticate(self,request):
from .models import User
user=User.objects.filter(mobile=self.cleaned_data["mobile"],account_type=User.AccountType.CUSTOMER).first()
return authenticate(request,username=user.username,password=self.cleaned_data["password"]) if user else None
class CustomerProfileForm(forms.ModelForm):
password=forms.CharField(label="رمز عبور جدید",required=False,min_length=8,widget=forms.PasswordInput,help_text="برای حفظ رمز فعلی خالی بگذارید.")
class Meta:
model=CustomerProfile; fields=["first_name","last_name","customer_type","province","city"]; labels={"first_name":"نام","last_name":"نام خانوادگی","customer_type":"نوع مشتری","province":"استان","city":"شهر"}
widgets={"province":forms.TextInput(attrs={"list":"profile-province-options","autocomplete":"off","data-province-input":""}),"city":forms.TextInput(attrs={"list":"profile-city-options","autocomplete":"off","data-city-input":""})}
def clean(self):
data=super().clean(); province,city,error=validate_location(data.get("province"),data.get("city")); data["province"],data["city"]=province,city
if error: self.add_error("city" if city else "province",error)
return data
class StaffLoginForm(forms.Form):
username = forms.CharField(label="نام کاربری")
password = forms.CharField(label="رمز عبور", widget=forms.PasswordInput)
def authenticate(self, request): return authenticate(request, username=self.cleaned_data["username"], password=self.cleaned_data["password"])
+32
View File
@@ -0,0 +1,32 @@
from django.utils import timezone
from .models import TicketMagicLink
MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
def grant_ticket_magic_access(request, link):
"""Grant an anonymous session access to exactly one ticket until link expiry."""
request.session.cycle_key()
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
request.session.set_expiry(remaining)
def get_ticket_magic_access(request, ticket=None):
link_id = request.session.get(MAGIC_LINK_SESSION_KEY)
if not link_id:
return None
filters = {
"pk": link_id,
"revoked_at__isnull": True,
"expires_at__gt": timezone.now(),
"customer__is_active": True,
}
if ticket is not None:
filters["ticket_id"] = ticket.pk
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(**filters).first()
if link is None and ticket is None:
request.session.pop(MAGIC_LINK_SESSION_KEY, None)
return link
+78
View File
@@ -0,0 +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)),
],
),
]
+22
View File
@@ -0,0 +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'),
),
]
@@ -0,0 +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),
),
]
@@ -0,0 +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'),
),
]
@@ -0,0 +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'),
),
]
Whitespace-only changes.
+157
View File
@@ -0,0 +1,157 @@
import hashlib, secrets, string
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import IntegrityError, models, transaction
from django.utils import timezone
from apps.core.models import PublicIdModel, TimeStampedModel
def normalize_mobile(value: str) -> str:
value = "".join(c for c in (value or "").strip() if c.isdigit() or c == "+")
if value.startswith("0098"): value = "+98" + value[4:]
elif value.startswith("98") and not value.startswith("+98"): value = "+" + value
elif value.startswith("0"): value = "+98" + value[1:]
if not (value.startswith("+989") and len(value) == 13 and value[1:].isdigit()):
raise ValueError("شماره موبایل معتبر نیست.")
return value
class User(PublicIdModel, AbstractUser):
class AccountType(models.TextChoices):
CUSTOMER = "CUSTOMER", "مشتری"
STAFF = "STAFF", "کارشناس"
mobile = models.CharField(max_length=16, unique=True, null=True, blank=True)
account_type = models.CharField(max_length=10, choices=AccountType.choices, default=AccountType.CUSTOMER)
def save(self, *args, **kwargs):
if self.mobile: self.mobile = normalize_mobile(self.mobile)
super().save(*args, **kwargs)
@property
def local_mobile(self):
return f"0{self.mobile[3:]}" if self.mobile and self.mobile.startswith("+98") else (self.mobile or "")
class OTPChallenge(models.Model):
class Purpose(models.TextChoices): LOGIN = "LOGIN", "ورود"
mobile = models.CharField(max_length=16, db_index=True)
purpose = models.CharField(max_length=20, choices=Purpose.choices, default=Purpose.LOGIN)
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(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
request_ip = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=300, blank=True)
@staticmethod
def hash_code(code): return hashlib.sha256(f"{settings.SECRET_KEY}:{code}".encode()).hexdigest()
@classmethod
def issue(cls, mobile, request=None):
mobile = normalize_mobile(mobile)
request_ip = request.META.get("REMOTE_ADDR") if request else None
window = timezone.now() - timedelta(seconds=settings.OTP_RATE_LIMIT_WINDOW_SECONDS)
if cls.objects.filter(mobile=mobile, created_at__gte=window).count() >= settings.OTP_RATE_LIMIT_PER_MOBILE:
raise ValueError("تعداد درخواست کد برای این شماره بیش از حد مجاز است.")
if request_ip and cls.objects.filter(request_ip=request_ip, created_at__gte=window).count() >= settings.OTP_RATE_LIMIT_PER_IP:
raise ValueError("تعداد درخواست کد از این اتصال بیش از حد مجاز است.")
cutoff = timezone.now() - timedelta(seconds=settings.OTP_RESEND_SECONDS)
if cls.objects.filter(mobile=mobile, created_at__gt=cutoff).exists(): raise ValueError("برای ارسال مجدد کمی صبر کنید.")
is_dev_login = settings.DEBUG and mobile == settings.DEV_OTP_MOBILE and bool(settings.DEV_OTP_CODE)
code = settings.DEV_OTP_CODE if is_dev_login else "".join(secrets.choice("0123456789") for _ in range(settings.OTP_LENGTH))
obj = cls.objects.create(mobile=mobile, code_hash=cls.hash_code(code), expires_at=timezone.now()+timedelta(seconds=settings.OTP_EXPIRE_SECONDS), max_attempts=settings.OTP_MAX_ATTEMPTS, request_ip=request_ip, user_agent=(request.META.get("HTTP_USER_AGENT", "")[:300] if request else ""))
return obj, code
def verify(self, code):
if self.verified_at or timezone.now() >= self.expires_at or self.attempt_count >= self.max_attempts: return False
self.attempt_count += 1
ok = secrets.compare_digest(self.code_hash, self.hash_code(code))
if ok: self.verified_at = timezone.now()
self.save(update_fields=["attempt_count", "verified_at"])
return ok
class TicketMagicLink(models.Model):
ticket = models.ForeignKey("tickets.Ticket", on_delete=models.CASCADE, related_name="magic_links")
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
token_hash = models.CharField(max_length=64, unique=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="created_magic_links",
)
created_unit = models.ForeignKey(
"staff.OrganizationalUnit",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="magic_links",
)
recipient_name = models.CharField(max_length=200, blank=True)
recipient_mobile = models.CharField(max_length=16, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)
created_reason = models.CharField(max_length=100, blank=True)
last_used_ip = models.GenericIPAddressField(null=True, blank=True)
last_user_agent = models.CharField(max_length=500, blank=True)
view_count = models.PositiveIntegerField(default=0)
class Meta:
permissions = [("view_all_magic_links", "Can audit magic links across all units")]
indexes = [models.Index(fields=["created_unit", "created_at"], name="magic_unit_created_idx"), models.Index(fields=["expires_at", "revoked_at"], name="magic_validity_idx")]
@staticmethod
def digest(token): return hashlib.sha256(token.encode()).hexdigest()
@staticmethod
def generate_secure_token(length=10):
alphabet=string.ascii_lowercase+string.digits
return "".join(secrets.SystemRandom().choices(alphabet,k=length))
@classmethod
def issue_with_record(cls,ticket,reason="staff_reply",max_retries=5,created_by=None):
# Never add a preflight SELECT to token issuance; callers may select_related
# the profile when they need a populated recipient-name snapshot.
profile = ticket.customer._state.fields_cache.get("customer_profile")
recipient_name = " ".join(
value for value in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if value
).strip()
for attempt in range(max_retries):
token=cls.generate_secure_token(10)
try:
with transaction.atomic():
record=cls.objects.create(
ticket=ticket,
customer=ticket.customer,
token_hash=cls.digest(token),
expires_at=timezone.now()+timedelta(days=settings.MAGIC_LINK_TTL_DAYS),
created_reason=reason,
created_by=created_by or ticket.created_by,
created_unit=ticket.owning_unit,
recipient_name=recipient_name,
recipient_mobile=ticket.customer.mobile or "",
)
return token,record
except IntegrityError:
if attempt==max_retries-1: raise RuntimeError("خطای نادر: عدم موفقیت در تولید توکن یکتا.")
raise RuntimeError("خطای نادر: عدم موفقیت در تولید توکن یکتا.")
@classmethod
def issue(cls,ticket,reason="staff_reply"):
token,_=cls.issue_with_record(ticket,reason)
return token
class MagicLinkAccessLog(models.Model):
"""Immutable audit row for every valid magic-link opening."""
magic_link = models.ForeignKey(
TicketMagicLink,
on_delete=models.CASCADE,
related_name="access_logs",
)
opened_at = models.DateTimeField(auto_now_add=True, db_index=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.CharField(max_length=500, blank=True)
class Meta:
ordering = ["-opened_at"]
indexes = [models.Index(fields=["magic_link", "opened_at"], name="magic_opened_idx")]
verbose_name = "بازدید لینک جادویی"
verbose_name_plural = "بازدیدهای لینک جادویی"
def __str__(self):
return f"{self.magic_link_id} @ {self.opened_at}"
+4
View File
@@ -0,0 +1,4 @@
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [path("login/", views.customer_login, name="login"),path("login/password/",views.customer_password_login,name="password_login"), path("login/otp/", views.verify_otp, name="otp"),path("profile/",views.customer_profile,name="profile"), path("logout/", views.logout_view, name="logout"), path("staff/login/", views.staff_login, name="staff_login"), path("staff/logout/", views.logout_view, name="staff_logout"), path("t/<str:token>/", views.consume_magic_link, name="magic")]
+131
View File
@@ -0,0 +1,131 @@
from django.contrib import messages
from django.conf import settings
from django.contrib.auth import login, logout
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from django.views.decorators.http import require_POST
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
from apps.customers.models import CustomerProfile
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import F
from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User
from .magic_access import grant_ticket_magic_access
from apps.integrations.sms.providers import SMSResult, get_sms_provider
import logging
logger = logging.getLogger(__name__)
def _authenticated_home(request):
if not request.user.is_authenticated: return None
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list")
@require_http_methods(["GET", "POST"])
def customer_login(request):
if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None)
if request.method == "POST" and form.is_valid():
challenge = None
try:
challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request)
is_fixed_dev_otp = settings.DEBUG and challenge.mobile == settings.DEV_OTP_MOBILE and code == settings.DEV_OTP_CODE
result = SMSResult(True, "fixed-dev-otp") if is_fixed_dev_otp else get_sms_provider().send_otp(challenge.mobile, code)
if not result.success:
raise RuntimeError(result.error or "سرویس پیامک درخواست را نپذیرفت.")
request.session["otp_challenge_id"] = challenge.pk
return redirect("accounts:otp")
except ValueError as exc: form.add_error(None, str(exc))
except Exception:
if challenge is not None:
challenge.delete()
logger.exception("OTP SMS delivery failed")
form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.")
return render(request, "accounts/customer_login.html", {"form": form})
@require_http_methods(["GET", "POST"])
def verify_otp(request):
if response:=_authenticated_home(request): return response
challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id"))
form = OTPForm(request.POST or None)
if request.method == "POST" and form.is_valid():
if challenge.verify(form.cleaned_data["code"]):
user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER})
if created: user.set_unusable_password(); user.save(update_fields=["password"])
CustomerProfile.objects.get_or_create(user=user)
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
else:
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(request.session.pop("next", "tickets:list"))
else: form.add_error("code", "کد نامعتبر یا منقضی است.")
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
@require_http_methods(["GET","POST"])
def customer_password_login(request):
if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None)
if request.method=="POST" and form.is_valid():
user=form.authenticate(request)
if user and user.is_active:
login(request,user); return redirect(request.session.pop("next","tickets:list"))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form})
@login_required
@require_http_methods(["GET","POST"])
def customer_profile(request):
if request.user.account_type!=User.AccountType.CUSTOMER: return redirect("staff_tickets:list")
profile,_=CustomerProfile.objects.get_or_create(user=request.user)
form=CustomerProfileForm(request.POST or None,instance=profile)
if request.method=="POST" and form.is_valid():
form.save(); password=form.cleaned_data.get("password")
if password: request.user.set_password(password); request.user.save(update_fields=["password"]); login(request,request.user,backend="django.contrib.auth.backends.ModelBackend")
messages.success(request,"پروفایل شما ذخیره شد."); return redirect("accounts:profile")
return render(request,"accounts/profile.html",{"form":form})
@require_http_methods(["GET", "POST"])
def staff_login(request):
if response:=_authenticated_home(request): return response
form = StaffLoginForm(request.POST or None)
if request.method == "POST" and form.is_valid():
user = form.authenticate(request)
if user and user.is_active and user.account_type == User.AccountType.STAFF:
login(request, user); return redirect("staff_tickets:list")
form.add_error(None, "اطلاعات ورود صحیح نیست.")
return render(request, "accounts/staff_login.html", {"form": form})
@require_POST
def logout_view(request):
logout(request)
request.session.flush()
response=redirect("accounts:login")
response["Cache-Control"]="no-store"
return response
def consume_magic_link(request, token):
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first()
if not link or link.revoked_at or link.expires_at <= timezone.now():
request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/"
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.")
return redirect("accounts:login")
if request.user.is_authenticated:
if request.user.pk != link.customer_id:
from django.core.exceptions import PermissionDenied
raise PermissionDenied
else:
grant_ticket_magic_access(request, link)
ip_address = request.META.get("REMOTE_ADDR")
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
with transaction.atomic():
MagicLinkAccessLog.objects.create(
magic_link=link,
ip_address=ip_address,
user_agent=user_agent,
)
TicketMagicLink.objects.filter(pk=link.pk).update(
used_at=timezone.now(),
last_used_ip=ip_address,
last_user_agent=user_agent,
view_count=F("view_count") + 1,
)
response = redirect("tickets:detail", public_id=link.ticket.public_id); response["Referrer-Policy"] = "no-referrer"; return response
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
from .models import Attachment,AttachmentPolicy
admin.site.register([Attachment,AttachmentPolicy])
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class AttachmentsConfig(AppConfig): name="apps.attachments"
+21
View File
@@ -0,0 +1,21 @@
import base64
import hashlib
import time
from urllib.parse import quote, urlencode
from django.conf import settings
from django.http import HttpResponseRedirect
def signed_media_redirect(relative_path, *, download=False, lifetime=300):
"""Create an Nginx secure_link URL after Django has authorized the request."""
safe_path = quote(str(relative_path).replace("\\", "/").lstrip("/"), safe="/")
uri = f"/protected-media/{safe_path}"
expires = int(time.time()) + lifetime
source = f"{expires}{uri} {settings.MEDIA_SIGNING_SECRET}".encode()
digest = hashlib.md5(source, usedforsecurity=False).digest()
signature = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
query = {"md5": signature, "expires": expires}
if download:
query["download"] = "1"
return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
@@ -0,0 +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)),
],
),
]
@@ -0,0 +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),
),
]
@@ -0,0 +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),
),
]
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
import uuid
from pathlib import Path
from django.conf import settings
from django.db import models
from apps.core.models import PublicIdModel
def attachment_path(instance,filename):
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
class AttachmentPolicy(models.Model):
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)
class Attachment(PublicIdModel,models.Model):
class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر"
message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); 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(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
class Meta: permissions=[("download_attachment","Can download attachment"),("manage_attachment_policy","Can manage attachment policy")]
+32
View File
@@ -0,0 +1,32 @@
import mimetypes
import magic
from pathlib import Path
from django.core.exceptions import ValidationError
from .models import Attachment,AttachmentPolicy
TYPE_EXT={"IMAGE":{".jpg",".jpeg",".png",".gif",".webp"},"VIDEO":{".mp4",".webm",".mov"},"VOICE":{".webm",".ogg",".mp3",".m4a",".wav"},"PDF":{".pdf"},"WORD":{".doc",".docx"},"EXCEL":{".xls",".xlsx"},"ZIP":{".zip"},"RAR":{".rar"}}
def classify(ext,mime):
if ext==".webm" and str(mime).startswith("audio/"): return "VOICE"
for kind,extensions in TYPE_EXT.items():
if ext in extensions:return kind
return "OTHER"
def save_attachments(message,files,user):
files=list(files); result=[]
counts={}
for upload in files:
ext=Path(Path(upload.name).name).suffix.lower(); kind=classify(ext,getattr(upload,"content_type", "")); counts[kind]=counts.get(kind,0)+1
for kind,count in counts.items():
policy=AttachmentPolicy.objects.filter(attachment_type=kind,is_enabled=True).first()
if not policy: raise ValidationError(f"نوع فایل {kind} مجاز نیست.")
if count>policy.max_files_per_message: raise ValidationError(f"تعداد فایل‌های {kind} بیش از حد مجاز است.")
for upload in files:
name=Path(upload.name).name[:255]; ext=Path(name).suffix.lower(); declared=getattr(upload,"content_type","") or mimetypes.guess_type(name)[0] or "application/octet-stream"
head=upload.read(8192); upload.seek(0); detected=magic.from_buffer(head,mime=True) or "application/octet-stream"; kind=classify(ext,declared)
policy=AttachmentPolicy.objects.filter(attachment_type=kind,is_enabled=True).first()
if not policy: raise ValidationError(f"نوع فایل {ext or name} مجاز نیست.")
if upload.size>policy.max_file_size: raise ValidationError(f"حجم فایل {name} بیش از حد مجاز است.")
if policy.allowed_extensions and ext not in policy.allowed_extensions: raise ValidationError("پسوند فایل مجاز نیست.")
if policy.allowed_mime_types and detected not in policy.allowed_mime_types: raise ValidationError("نوع محتوای واقعی فایل مجاز نیست.")
compatible={"IMAGE":detected.startswith("image/"),"VIDEO":detected.startswith("video/"),"VOICE":detected.startswith("audio/") or detected in {"video/webm","application/ogg"},"PDF":detected=="application/pdf","ZIP":detected in {"application/zip","application/x-zip-compressed"}}.get(kind,True)
if not compatible: raise ValidationError(f"محتوای واقعی فایل {name} با پسوند آن سازگار نیست.")
obj=Attachment(message=message,file=upload,original_name=name,stored_name="pending",relative_path="pending",attachment_type=kind,mime_type=detected,extension=ext,file_size=upload.size,uploaded_by=user); obj.save(); obj.stored_name=Path(obj.file.name).name; obj.relative_path=obj.file.name; obj.save(update_fields=["stored_name","relative_path"]); result.append(obj)
return result
+4
View File
@@ -0,0 +1,4 @@
from django.urls import path
from .views import download, view
app_name="attachments"
urlpatterns=[path("<uuid:public_id>/view/",view,name="view"),path("<uuid:public_id>/download/",download,name="download")]
+30
View File
@@ -0,0 +1,30 @@
import mimetypes
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from apps.tickets.policies import TicketPolicy
from apps.accounts.magic_access import get_ticket_magic_access
from .delivery import signed_media_redirect
from .models import Attachment
def _authorized_attachment(request,public_id):
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
return attachment
def view(request,public_id):
attachment=_authorized_attachment(request,public_id)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path)
content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response=FileResponse(attachment.file.open("rb"),content_type=content_type)
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
response["X-Content-Type-Options"]="nosniff"
return response
def download(request,public_id):
attachment=_authorized_attachment(request,public_id)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path, download=True)
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)
View File
Whitespace-only changes.
+37
View File
@@ -0,0 +1,37 @@
from django.contrib import admin
from .models import PageTitle, SiteBranding
@admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin):
fieldsets = (
("عمومی", {"fields": ("site_title", "header_title", "header_subtitle")}),
("لوگو و آیکون‌ها", {"fields": ("company_logo", "favicon", "pwa_icon"), "description": "در صورت خالی بودن هر فیلد، فایل پیش‌فرض فعلی استفاده می‌شود."}),
("PWA", {"fields": ("pwa_name", "pwa_title", "pwa_description", "pwa_install_label", "pwa_install_help")}),
("پنل کارشناسان", {"fields": ("staff_brand_title", "staff_brand_subtitle", "staff_panel_label")}),
("صفحه ورود مشتری", {"fields": ("login_intro_enabled", "login_intro_icon", "login_intro_eyebrow", "login_intro_heading", "login_intro_body")}),
("صفحه ورود کارشناسان", {"fields": ("staff_login_intro_enabled", "staff_login_intro_icon", "staff_login_intro_eyebrow", "staff_login_intro_heading", "staff_login_intro_body")}),
("صفحه تیکت‌های مشتری", {"fields": ("customer_tickets_hero_enabled", "customer_tickets_hero_eyebrow", "customer_tickets_hero_heading", "customer_tickets_hero_body")}),
("امضای توسعه‌دهنده", {"fields": ("developer_credit_text", "developer_credit_html", "use_developer_credit_html"), "description": "HTML خام تنها برای محتوای کاملاً مورد اعتماد فعال شود."}),
("امضای مستقل sidebar کارکنان", {"fields": ("staff_sidebar_credit_text", "staff_sidebar_credit_html", "use_staff_sidebar_credit_html"), "description": "این مقدار فقط در پایین sidebar پنل staff نمایش داده می‌شود."}),
)
def get_readonly_fields(self, request, obj=None):
if request.user.is_superuser:
return ()
return ("developer_credit_html", "use_developer_credit_html", "staff_sidebar_credit_html", "use_staff_sidebar_credit_html")
def has_add_permission(self, request):
return not SiteBranding.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
@admin.register(PageTitle)
class PageTitleAdmin(admin.ModelAdmin):
list_display = ("route_name", "title", "is_active", "updated_at")
list_editable = ("title", "is_active")
search_fields = ("route_name", "title")
list_filter = ("is_active",)
+4
View File
@@ -0,0 +1,4 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "apps.core"
+68
View File
@@ -0,0 +1,68 @@
from django.conf import settings
from django.db import OperationalError, ProgrammingError
from django.utils.safestring import mark_safe
from django.templatetags.static import static
from .models import PageTitle, SiteBranding
def site_branding(request):
"""Expose editable branding and the title configured for the current route."""
try:
branding = SiteBranding.load()
route_name = getattr(getattr(request, "resolver_match", None), "view_name", "") or ""
configured = PageTitle.objects.filter(route_name=route_name, is_active=True).values_list("title", flat=True).first()
except (OperationalError, ProgrammingError, RuntimeError):
branding = SiteBranding()
configured = None
developer_credit = (
mark_safe(branding.developer_credit_html)
if branding.use_developer_credit_html and branding.developer_credit_html.strip()
else branding.developer_credit_text
)
staff_sidebar_credit = (
mark_safe(branding.staff_sidebar_credit_html)
if branding.use_staff_sidebar_credit_html and branding.staff_sidebar_credit_html.strip()
else branding.staff_sidebar_credit_text
)
def asset_url(field, fallback):
try:
return field.url if field else static(fallback)
except (ValueError, OSError):
return static(fallback)
branding_version = str(int(branding.updated_at.timestamp())) if branding.pk and branding.updated_at else "default"
return {
"site_branding": branding,
"page_title": configured or branding.site_title,
"developer_credit": developer_credit,
"staff_sidebar_credit": staff_sidebar_credit,
"branding_version": branding_version,
"company_logo_url": asset_url(branding.company_logo, "images/company-logo.png"),
"favicon_url": asset_url(branding.favicon, "images/favicon.ico"),
"pwa_icon_url": asset_url(branding.pwa_icon, "images/pwa-icon.png"),
}
def static_asset_version(request):
"""Change frontend and worker versions whenever static files or templates change."""
roots = (settings.BASE_DIR / "static", settings.BASE_DIR / "templates")
mtimes = (path.stat().st_mtime_ns for root in roots if root.exists() for path in root.rglob("*") if path.is_file())
return {"static_asset_version": str(max(mtimes, default=0))}
def staff_operations_access(request):
"""Expose unit-scoped navigation capabilities without hardcoded roles."""
user = request.user
if not getattr(user, "is_authenticated", False) or getattr(user, "account_type", "") != "STAFF":
return {}
from apps.tickets.policies import TicketPolicy
memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit"))
def allowed(permission):
return user.is_superuser or user.has_perm(permission) or any(TicketPolicy.has_unit_permission(user, item.unit, permission) for item in memberships)
return {"staff_operations_access": {
"create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"),
"contacts": allowed("customers.view_customerprofile"),
"dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"),
"templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"),
"audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"),
}}
+36
View File
@@ -0,0 +1,36 @@
import json
from functools import lru_cache
from pathlib import Path
from django.conf import settings
@lru_cache(maxsize=1)
def location_data():
path = Path(settings.BASE_DIR) / "static" / "data" / "iran-locations.json"
return json.loads(path.read_text(encoding="utf-8"))
@lru_cache(maxsize=1)
def provinces():
return tuple(location_data())
@lru_cache(maxsize=1)
def cities_by_province():
return {item["province"]: tuple(item["cities"]) for item in location_data()}
def validate_location(province, city):
province = (province or "").strip()
city = (city or "").strip()
if not province and not city:
return province, city, None
if city and not province:
return province, city, "برای انتخاب شهر، ابتدا استان را انتخاب کنید."
cities = cities_by_province()
if province not in cities:
return province, city, "استان انتخاب‌شده معتبر نیست."
if city and city not in cities[province]:
return province, city, "شهر انتخاب‌شده متعلق به این استان نیست."
return province, city, None
View File
Whitespace-only changes.
Whitespace-only changes.
+147
View File
@@ -0,0 +1,147 @@
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand
from apps.attachments.models import AttachmentPolicy
from apps.tickets.models import SLAConfig, TicketCategory
from apps.accounts.models import User
from apps.customers.models import ContactGroup
from apps.staff.models import OrganizationalUnit, StaffProfile, UnitMembership
from apps.tickets.category_catalog import CATEGORY_CATALOG, UNIT_CONTENT
class Command(BaseCommand):
help = "Create initial categories, policies and permission groups"
def merge_legacy_unit(self, legacy_slug, canonical_slug, canonical_name, canonical_description):
legacy = OrganizationalUnit.objects.filter(slug=legacy_slug).first()
canonical = OrganizationalUnit.objects.filter(slug=canonical_slug).first()
if legacy is None:
return
if canonical is None:
legacy.slug = canonical_slug
legacy.name = canonical_name
legacy.description = canonical_description
legacy.save()
return
from apps.accounts.models import TicketMagicLink
from apps.notifications.models import SMSTemplate
from apps.tickets.models import GroupDispatch, Ticket
canonical.permissions.add(*legacy.permissions.all())
TicketCategory.objects.filter(unit=legacy).update(unit=canonical)
Ticket.objects.filter(owning_unit=legacy).update(owning_unit=canonical)
Ticket.objects.filter(assigned_unit=legacy).update(assigned_unit=canonical)
GroupDispatch.objects.filter(unit=legacy).update(unit=canonical)
TicketMagicLink.objects.filter(created_unit=legacy).update(created_unit=canonical)
StaffProfile.objects.filter(primary_unit=legacy).update(primary_unit=canonical)
for membership in UnitMembership.objects.filter(unit=legacy):
existing = UnitMembership.objects.filter(user=membership.user, unit=canonical).first()
if existing is None:
membership.unit = canonical
membership.save(update_fields=["unit", "updated_at"])
continue
existing.permissions.add(*membership.permissions.all())
existing.is_active = existing.is_active or membership.is_active
existing.is_primary = existing.is_primary or membership.is_primary
if membership.ticket_scope == UnitMembership.TicketScope.UNIT:
existing.ticket_scope = UnitMembership.TicketScope.UNIT
existing.save(update_fields=["is_active", "is_primary", "ticket_scope", "updated_at"])
membership.delete()
for group in ContactGroup.objects.filter(unit=legacy):
existing = ContactGroup.objects.filter(unit=canonical, name=group.name).first()
if existing is None:
group.unit = canonical
group.save(update_fields=["unit", "updated_at"])
continue
existing.contacts.add(*group.contacts.all())
GroupDispatch.objects.filter(contact_group=group).update(contact_group=existing)
group.delete()
for template in SMSTemplate.objects.filter(unit=legacy):
if SMSTemplate.objects.filter(unit=canonical, event_type=template.event_type).exists():
template.delete()
else:
template.unit = canonical
template.save(update_fields=["unit", "updated_at"])
legacy.delete()
def handle(self, *args, **opts):
self.merge_legacy_unit("accounting", "finance", "مالی / حسابداری", "واحد مالی و حسابداری (یک مفهوم سازمانی)")
self.merge_legacy_unit("logistics", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
self.merge_legacy_unit("warehouse", "warehouse-loading", "بارگیری انبار", "بارگیری، انبار و ارسال")
categories = [("استعلام قیمت","price"),("درصد تخفیف","discount"),("شرایط فروش","sales-terms"),("موجودی","inventory"),("شرایط ارسال","delivery"),("درخواست نمایندگی","agency"),("شکایت","complaint"),("مشاوره","consulting"),("سایر","other")]
for i, (title, slug) in enumerate(categories):
TicketCategory.objects.get_or_create(slug=slug, defaults={"title":title,"sort_order":i})
sizes = {"IMAGE":10,"VOICE":25,"VIDEO":100,"PDF":30,"WORD":30,"EXCEL":30,"ZIP":50,"RAR":50}
exts = {"IMAGE":[".jpg",".jpeg",".png",".gif",".webp"],"VOICE":[".webm",".ogg",".mp3",".m4a",".wav"],"VIDEO":[".mp4",".webm",".mov"],"PDF":[".pdf"],"WORD":[".doc",".docx"],"EXCEL":[".xls",".xlsx"],"ZIP":[".zip"],"RAR":[".rar"]}
for i, (kind, mb) in enumerate(sizes.items()):
AttachmentPolicy.objects.get_or_create(attachment_type=kind, defaults={"max_file_size":mb*1024*1024,"allowed_extensions":exts[kind],"sort_order":i})
for priority, first, resolution in [("NORMAL",240,2880),("IMPORTANT",120,1440),("URGENT",30,480)]:
SLAConfig.objects.get_or_create(priority=priority, defaults={"first_response_minutes":first,"resolution_minutes":resolution})
expert_codes = ["view_ticket","add_ticket","claim_ticket","transfer_ticket","share_ticket","change_ticket","add_internal_note","edit_own_message","delete_own_message","download_attachment"]
supervisor_codes = expert_codes + ["view_message_revisions","change_ticket_priority","view_customer_history"]
manager_codes = supervisor_codes + ["view_deleted_message_content","delete_ticket"]
role_permissions = {
"Sales Expert": expert_codes,
"Sales Supervisor": supervisor_codes,
"Sales Manager": manager_codes,
"Warehouse Expert": expert_codes,
"Finance Expert": expert_codes,
"CEO": manager_codes + ["view_all_tickets", "create_bulk_dispatch", "view_all_bulk_dispatches", "create_inquiry", "view_all_inquiries"],
"General Admin": manager_codes + ["view_all_tickets"],
}
for group_name, codes in role_permissions.items():
group, _ = Group.objects.get_or_create(name=group_name)
group.permissions.set(Permission.objects.filter(codename__in=codes))
units = {}
for order, (slug, name, description, external, customer_visible) in enumerate([
("sales", "فروش", UNIT_CONTENT["sales"]["description"], True, True),
("warehouse-loading", "بارگیری انبار", "عملیات داخلی بارگیری و انبار", True, False),
("finance", "مالی / حسابداری", UNIT_CONTENT["finance"]["description"], True, True),
("executive", "مدیریت ارشد", "مدیریت عامل", False, False),
], 10):
unit, _ = OrganizationalUnit.objects.update_or_create(slug=slug, defaults={"name": name, "description": description, "search_keywords": UNIT_CONTENT.get(slug, {}).get("search_keywords", ""), "is_active": True, "accepts_external_tickets": external, "show_in_customer_form": customer_visible, "sort_order": order})
units[slug] = unit
for unit_slug, rows in CATEGORY_CATALOG.items():
for sort_order, slug, title, description, search_keywords in rows:
TicketCategory.objects.update_or_create(slug=slug, defaults={"unit": units[unit_slug], "title": title, "description": description, "search_keywords": search_keywords, "sort_order": sort_order, "is_active": True, "show_in_customer_form": True})
ContactGroup.objects.get_or_create(unit=units[unit_slug], name=f"مخاطبان {units[unit_slug].name}")
TicketCategory.objects.update_or_create(
slug="warehouse-loading-general",
defaults={"unit": units["warehouse-loading"], "title": "عملیات بارگیری انبار", "description": "هماهنگی داخلی عملیات انبار و بارگیری.", "search_keywords": "انبار بارگیری عملیات داخلی", "sort_order": 10, "is_active": True, "show_in_customer_form": False},
)
TicketCategory.objects.filter(slug__in=("other", "e2e-sms")).update(show_in_customer_form=False)
unit_permission_pairs = [
("tickets", "view_ticket"), ("tickets", "add_ticket"), ("tickets", "claim_ticket"),
("messaging", "add_message"), ("customers", "view_customerprofile"),
("customers", "add_customerprofile"), ("customers", "change_customerprofile"),
("customers", "view_contactgroup"), ("customers", "add_contactgroup"), ("customers", "change_contactgroup"),
]
for unit in (units["sales"], units["warehouse-loading"], units["finance"]):
unit.permissions.add(*[Permission.objects.get(content_type__app_label=app, codename=code) for app, code in unit_permission_pairs])
people = [
("kimiya_ghaffari", "کیمیا", "غفاری", "کیمیا غفاری", StaffProfile.Role.SALES_EXPERT, "sales", "کارشناس فروش", UnitMembership.TicketScope.SELF),
("zahra_jamali", "زهرا", "جمالی", "زهرا جمالی", StaffProfile.Role.SALES_EXPERT, "sales", "کارشناس فروش", UnitMembership.TicketScope.SELF),
("maryam_fotovat", "مریم", "فتوت", "مریم فتوت", StaffProfile.Role.SALES_SUPERVISOR, "sales", "سرپرست فروش", UnitMembership.TicketScope.UNIT),
("morteza_sokhaeifard", "مرتضی", "سخائی فرد", "مرتضی سخائی فرد", StaffProfile.Role.SALES_MANAGER, "sales", "مدیر فروش", UnitMembership.TicketScope.UNIT),
("mostafa_sokhaeifard", "مصطفی", "سخائی فرد", "مصطفی سخائی فرد", StaffProfile.Role.CEO, "executive", "مدیرعامل", UnitMembership.TicketScope.UNIT),
("ms_sabzali", "", "سبزعلی", "خانم سبزعلی", StaffProfile.Role.WAREHOUSE_EXPERT, "warehouse-loading", "کارشناس بارگیری انبار", UnitMembership.TicketScope.UNIT),
("mehdi_azami", "مهدی", "عظمی", "آقای مهدی عظمی", StaffProfile.Role.WAREHOUSE_EXPERT, "warehouse-loading", "کارشناس بارگیری انبار", UnitMembership.TicketScope.UNIT),
("fereshteh_yavari", "فرشته", "یاوری", "خانم فرشته یاوری", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
("fatemeh_sokhaei", "فاطمه", "سخایی", "خانم فاطمه سخایی", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
("rana_nobakht", "رعنا", "نوبخت", "خانم رعنا نوبخت", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
("mehdi_liaqat", "مهدی", "لیاقت", "آقای مهدی لیاقت", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
("aliasghar_sokhaeifard", "علی اصغر", "سخائی فرد", "علی اصغر سخائی فرد", StaffProfile.Role.FINANCE_EXPERT, "finance", "کارشناس مالی / حسابداری", UnitMembership.TicketScope.UNIT),
]
for username, first_name, last_name, display_name, role, unit_slug, title, scope in people:
user, created = User.objects.get_or_create(username=username, defaults={"account_type": User.AccountType.STAFF})
user.account_type = User.AccountType.STAFF; user.first_name = first_name; user.last_name = last_name; user.is_active = True
if created: user.set_unusable_password()
user.save()
profile, _ = StaffProfile.objects.update_or_create(user=user, defaults={"primary_unit": units[unit_slug], "role": role, "display_name": display_name, "first_name": first_name, "last_name": last_name, "job_title": title, "is_active": True})
membership = UnitMembership.objects.get(user=user, unit=units[unit_slug])
membership.ticket_scope = scope; membership.is_primary = True; membership.is_active = True; membership.save()
user.unit_memberships.exclude(pk=membership.pk).delete()
sabzali = User.objects.get(username="ms_sabzali")
sabzali.unit_memberships.get(unit=units["warehouse-loading"]).permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["create_bulk_dispatch", "create_inquiry"]))
ceo = User.objects.get(username="mostafa_sokhaeifard")
ceo.user_permissions.add(*Permission.objects.filter(content_type__app_label="tickets", codename__in=["view_all_tickets", "add_ticket", "create_bulk_dispatch", "view_all_bulk_dispatches", "create_inquiry", "view_all_inquiries"]))
self.stdout.write(self.style.SUCCESS("Initial data created."))
@@ -0,0 +1,30 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.customers.models import CustomerProfile
from apps.messaging.models import Message, MessageRevision
from apps.staff.models import StaffProfile
from apps.tickets.models import Ticket, TicketCategory
class Command(BaseCommand):
help = "Normalize existing human-authored Persian fields using the model policy."
@transaction.atomic
def handle(self, *args, **options):
specs = (
(CustomerProfile, CustomerProfile.persian_text_fields),
(StaffProfile, StaffProfile.persian_text_fields),
(TicketCategory, TicketCategory.persian_text_fields),
(Ticket, Ticket.persian_text_fields),
(Message, Message.persian_text_fields),
(MessageRevision, MessageRevision.persian_text_fields),
)
changed = 0
for model, fields in specs:
for instance in model.objects.all().iterator():
before = tuple(getattr(instance, name) for name in fields)
instance.save(update_fields=[*fields])
after = tuple(getattr(instance, name) for name in fields)
changed += before != after
self.stdout.write(self.style.SUCCESS(f"Normalized {changed} existing records."))
+13
View File
@@ -0,0 +1,13 @@
class NoStoreHTMLMiddleware:
"""Prevent browsers and installed PWAs from retaining authenticated HTML views."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if response.get("Content-Type", "").lower().startswith("text/html"):
response["Cache-Control"] = "private, no-cache, no-store, must-revalidate"
response["Pragma"] = "no-cache"
response["Expires"] = "0"
return response
+47
View File
@@ -0,0 +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': 'تنظیمات عنوان و برند',
},
),
]
@@ -0,0 +1,40 @@
from django.db import migrations
PAGE_TITLES = {
"accounts:login": "ورود مشتری | گفت‌وگو با واحد فروش",
"accounts:password_login": "ورود با رمز عبور | گفت‌وگو با واحد فروش",
"accounts:otp": "تأیید شماره موبایل | گفت‌وگو با واحد فروش",
"accounts:profile": "پروفایل من | گفت‌وگو با واحد فروش",
"accounts:staff_login": "ورود کارشناسان | گفت‌وگو با واحد فروش",
"accounts:magic": "مشاهده امن تیکت | گفت‌وگو با واحد فروش",
"tickets:list": "درخواست‌های من | گفت‌وگو با واحد فروش",
"tickets:new": "درخواست جدید | گفت‌وگو با واحد فروش",
"tickets:detail": "جزئیات درخواست | گفت‌وگو با واحد فروش",
"staff_tickets:list": "میز کار | پنل کارشناسان",
"staff_tickets:create": "ثبت تیکت برای مشتری | پنل کارشناسان",
"staff_tickets:detail": "جزئیات تیکت | پنل کارشناسان",
"staff_tickets:contacts": "مخاطبان | پنل کارشناسان",
"staff_tickets:customers": "مخاطبان | پنل کارشناسان",
"staff_tickets:contact_create": "مخاطب جدید | پنل کارشناسان",
"staff_tickets:contact_edit": "ویرایش مخاطب | پنل کارشناسان",
"staff_tickets:groups": "گروه‌های مخاطبان | پنل کارشناسان",
"staff_tickets:sms_templates": "الگوهای پیامک | پنل کارشناسان",
"staff_tickets:sms_template_edit": "ویرایش الگوی پیامک | پنل کارشناسان",
"staff_tickets:dispatch_create": "ارسال گروهی و ردیابی | پنل کارشناسان",
"staff_tickets:dispatch_detail": "گزارش ارسال گروهی | پنل کارشناسان",
"staff_tickets:magic_audit": "ممیزی لینک‌های جادویی | پنل کارشناسان",
"messages:revisions": "تاریخچه ویرایش پیام | گفت‌وگو با واحد فروش",
}
def seed(apps, schema_editor):
apps.get_model("core", "SiteBranding").objects.get_or_create(pk=1)
model = apps.get_model("core", "PageTitle")
for route_name, title in PAGE_TITLES.items():
model.objects.get_or_create(route_name=route_name, defaults={"title": title})
class Migration(migrations.Migration):
dependencies = [("core", "0001_initial")]
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
@@ -0,0 +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 سفارشی'),
),
]
@@ -0,0 +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'),
),
]
@@ -0,0 +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='آیکون معرفی ورود کارشناسان'),
),
]
View File
Whitespace-only changes.
+127
View File
@@ -0,0 +1,127 @@
import uuid
from django.db import models
from .persian_text import normalize_persian_text
class PersianTextNormalizationMixin:
"""Normalize only explicitly listed human-authored model fields on save."""
persian_text_fields = {}
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
for field_name, multiline in self.persian_text_fields.items():
if update_fields is None or field_name in update_fields:
value = getattr(self, field_name, None)
if value is not None:
setattr(
self,
field_name,
normalize_persian_text(value, multiline=multiline),
)
return super().save(*args, **kwargs)
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class PublicIdModel(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
class Meta:
abstract = True
class SiteBranding(models.Model):
"""Database-backed copy shared by customer, authentication and staff pages."""
site_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش")
header_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش")
header_subtitle = models.CharField(max_length=200, default="درخواست روشن، پیگیری مطمئن")
staff_brand_title = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش")
staff_brand_subtitle = models.CharField(max_length=200, default="مرکز ارتباط با مشتری")
staff_panel_label = models.CharField(max_length=120, default="پنل کارشناسان")
pwa_title = models.CharField(max_length=80, default="واحد فروش")
company_logo = models.ImageField(upload_to="branding/logo/", blank=True, verbose_name="لوگوی اصلی")
favicon = models.ImageField(upload_to="branding/favicon/", blank=True, verbose_name="Favicon")
pwa_icon = models.ImageField(upload_to="branding/pwa/", blank=True, verbose_name="آیکون PWA")
pwa_name = models.CharField(max_length=160, default="گفت‌وگو با واحد فروش", verbose_name="نام کامل PWA")
pwa_description = models.CharField(max_length=300, default="ثبت و پیگیری درخواست‌های فروش لوله گستر گلپایگان", verbose_name="توضیح PWA")
pwa_install_label = models.CharField(max_length=160, default="نصب گفت‌وگو با واحد فروش", verbose_name="عنوان پیشنهاد نصب PWA")
pwa_install_help = models.CharField(max_length=300, default="برای دسترسی سریع، برنامه را به صفحه اصلی گوشی اضافه کنید.", verbose_name="راهنمای نصب PWA")
login_intro_enabled = models.BooleanField(default=True, verbose_name="نمایش بخش معرفی ورود")
login_intro_icon = models.CharField(max_length=32, default="✦", blank=True, verbose_name="آیکون معرفی ورود")
login_intro_eyebrow = models.CharField(max_length=200, default="ارتباط مستقیم با تیم فروش", verbose_name="بالانویس معرفی ورود")
login_intro_heading = models.TextField(default="پاسخ روشن،\nپیگیری ساده.", verbose_name="عنوان معرفی ورود")
login_intro_body = models.TextField(
default="درخواست خود را ثبت کنید، فایل و پیام صوتی بفرستید و پاسخ کارشناس را در یک فضای امن دنبال کنید.",
verbose_name="متن معرفی ورود",
)
staff_login_intro_enabled = models.BooleanField(default=True, verbose_name="نمایش معرفی ورود کارشناسان")
staff_login_intro_icon = models.CharField(max_length=32, default="◈", blank=True, verbose_name="آیکون معرفی ورود کارشناسان")
staff_login_intro_eyebrow = models.CharField(max_length=200, default="پنل کارشناسان فروش", verbose_name="بالانویس ورود کارشناسان")
staff_login_intro_heading = models.TextField(default="پاسخ‌های بهتر،\nفروش هوشمندتر.", verbose_name="عنوان ورود کارشناسان")
staff_login_intro_body = models.TextField(default="صف درخواست‌ها، SLA، اولویت‌ها و سوابق مشتری را از یک میز کار متمرکز مدیریت کنید.", verbose_name="متن ورود کارشناسان")
customer_tickets_hero_enabled = models.BooleanField(default=True, verbose_name="نمایش معرفی صفحه تیکت‌های مشتری")
customer_tickets_hero_eyebrow = models.CharField(max_length=200, default="مرکز ارتباط با فروش", verbose_name="بالانویس صفحه تیکت‌های مشتری")
customer_tickets_hero_heading = models.TextField(default="هر درخواست، یک مسیر روشن\nتا رسیدن به پاسخ.", verbose_name="عنوان صفحه تیکت‌های مشتری")
customer_tickets_hero_body = models.TextField(default="موضوع جدیدی دارید؟ آن را جدا ثبت کنید تا تیم فروش دقیق‌تر و سریع‌تر پیگیری کند.", verbose_name="متن صفحه تیکت‌های مشتری")
developer_credit_text = models.CharField(
max_length=500,
default="برنامه نویسی و توسعه: محمد جواد فصاحت | 09202202129",
verbose_name="متن امضای توسعه‌دهنده",
)
developer_credit_html = models.TextField(
blank=True,
verbose_name="HTML سفارشی امضای توسعه‌دهنده",
help_text="فقط برای HTML مورد اعتماد؛ لینک، آیکون و style خطی مجاز است.",
)
use_developer_credit_html = models.BooleanField(
default=False,
verbose_name="استفاده از HTML سفارشی",
help_text="با فعال‌سازی، HTML بدون escape نمایش داده می‌شود؛ فقط مدیر ارشد باید آن را ویرایش کند.",
)
staff_sidebar_credit_text = models.CharField(
max_length=500,
default="برنامه نویسی و توسعه: محمد جواد فصاحت",
verbose_name="متن امضای پایین sidebar کارکنان",
)
staff_sidebar_credit_html = models.TextField(
blank=True,
verbose_name="HTML سفارشی امضای sidebar کارکنان",
help_text="مستقل از امضای عمومی؛ فقط برای HTML مورد اعتماد.",
)
use_staff_sidebar_credit_html = models.BooleanField(
default=False,
verbose_name="استفاده از HTML سفارشی در sidebar",
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "تنظیمات عنوان و برند"
verbose_name_plural = "تنظیمات عنوان و برند"
def save(self, *args, **kwargs):
self.pk = 1
return super().save(*args, **kwargs)
@classmethod
def load(cls):
return cls.objects.filter(pk=1).first() or cls()
def __str__(self):
return self.site_title
class PageTitle(models.Model):
"""Editable browser title for a named Django route."""
route_name = models.CharField(max_length=160, unique=True, help_text="مانند accounts:login")
title = models.CharField(max_length=240)
is_active = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("route_name",)
verbose_name = "عنوان صفحه"
verbose_name_plural = "عنوان صفحات"
def __str__(self):
return f"{self.route_name}: {self.title}"
+66
View File
@@ -0,0 +1,66 @@
"""Safe, conservative Persian text normalization for human-authored fields."""
import re
from functools import lru_cache
from hazm import Normalizer
# Hazm's spacing rules are intended for prose and can add spaces inside URLs and
# technical identifiers. Temporarily replace those spans before normalization.
_PROTECTED_SPAN = re.compile(
r"https?://\S+|www\.\S+|[\w.+-]+@[\w.-]+\.\w+|"
r"(?<![\w\u0600-\u06ff])[A-Za-z0-9][A-Za-z0-9._:/?#%+\\=@-]*"
)
_TOKEN_PREFIX = "HAZMPROTECTEDTOKEN"
@lru_cache(maxsize=1)
def _normalizer():
return Normalizer(
remove_diacritics=False,
remove_specials_chars=False,
decrease_repeated_chars=False,
persian_numbers=False,
)
def _alpha_index(index):
"""Return an ASCII-letter-only stable suffix (A, B, ..., AA)."""
result = ""
while True:
index, remainder = divmod(index, 26)
result = chr(65 + remainder) + result
if index == 0:
return result
index -= 1
def normalize_persian_text(value, *, multiline=False):
"""Normalize Persian orthography without rewriting meaning or technical data.
Multiline values retain paragraph boundaries. Empty/None values become an
empty string, matching the project's non-null textual model fields.
"""
if value is None:
return ""
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
protected = {}
def protect(match):
token = f"{_TOKEN_PREFIX}{_alpha_index(len(protected))}"
protected[token] = match.group(0)
return token
text = _PROTECTED_SPAN.sub(protect, text)
if multiline:
normalized = "\n".join(
_normalizer().normalize(line).strip() if line.strip() else ""
for line in text.split("\n")
).strip()
else:
normalized = _normalizer().normalize(" ".join(text.splitlines())).strip()
for token, original in protected.items():
normalized = normalized.replace(token, original)
return normalized
+1
View File
@@ -0,0 +1 @@
+53
View File
@@ -0,0 +1,53 @@
"""Presentation-only Jalali filters; persisted datetimes stay Gregorian/UTC."""
from datetime import date, datetime
import jdatetime
from django import template
from django.utils import timezone
register = template.Library()
_PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
def _persian_digits(value):
return str(value).translate(_PERSIAN_DIGITS)
def _local_gregorian(value):
if isinstance(value, datetime) and timezone.is_aware(value):
return timezone.localtime(value)
return value
def _jalali(value):
value = _local_gregorian(value)
if isinstance(value, datetime):
return jdatetime.datetime.fromgregorian(datetime=value, locale=jdatetime.FA_LOCALE)
if isinstance(value, date):
return jdatetime.date.fromgregorian(date=value, locale=jdatetime.FA_LOCALE)
return None
@register.filter
def jalali_datetime(value):
"""Example: ۱۵ شهریور ۱۴۰۵، ساعت ۰۹:۳۰."""
converted = _jalali(value)
if converted is None:
return ""
pattern = "%d %B %Y، ساعت %H:%M" if isinstance(converted, jdatetime.datetime) else "%d %B %Y"
return _persian_digits(converted.strftime(pattern))
@register.filter
def jalali_date(value):
"""Example: ۱۵ شهریور ۱۴۰۵."""
converted = _jalali(value)
return _persian_digits(converted.strftime("%d %B %Y")) if converted else ""
@register.filter
def persian_time(value):
"""Display local Iran time using Persian digits."""
value = _local_gregorian(value)
return _persian_digits(value.strftime("%H:%M")) if isinstance(value, datetime) else ""
View File
Whitespace-only changes.
+17
View File
@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import ContactGroup, CustomerProfile
@admin.register(ContactGroup)
class ContactGroupAdmin(admin.ModelAdmin):
list_display = ("name", "unit", "is_active", "updated_at")
list_filter = ("unit", "is_active")
search_fields = ("name", "description")
@admin.register(CustomerProfile)
class CustomerProfileAdmin(admin.ModelAdmin):
list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name")
list_filter = ("stakeholder_type", "groups")
search_fields = ("user__mobile", "organization_name", "first_name", "last_name")
filter_horizontal = ("groups",)
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class CustomersConfig(AppConfig): name = "apps.customers"
+32
View File
@@ -0,0 +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,
},
),
]
@@ -0,0 +1,9 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("customers", "0001_initial")]
operations = [
migrations.AddField(model_name="customerprofile", name="city", field=models.CharField(blank=True, max_length=100)),
migrations.AddField(model_name="customerprofile", name="province", field=models.CharField(blank=True, max_length=100)),
]
@@ -0,0 +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),
),
]
@@ -0,0 +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'),
),
]
Whitespace-only changes.
+54
View File
@@ -0,0 +1,54 @@
from django.conf import settings
from django.db import models
from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel
class ContactGroup(PersianTextNormalizationMixin, TimeStampedModel):
"""A unit-owned address-book group; contacts may belong to many groups."""
persian_text_fields = {"name": False, "description": True}
name = models.CharField(max_length=150)
unit = models.ForeignKey(
"staff.OrganizationalUnit",
on_delete=models.CASCADE,
related_name="contact_groups",
)
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["unit", "name"], name="unique_contact_group_name_per_unit")
]
ordering = ["unit__name", "name"]
verbose_name = "گروه مخاطبان"
verbose_name_plural = "گروه‌های مخاطبان"
def __str__(self):
return f"{self.unit}: {self.name}"
class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel):
persian_text_fields = {"first_name": False, "last_name": False, "organization_name": False, "province": False, "city": False}
class Type(models.TextChoices):
PERSON="PERSON","شخص"; CONSUMER="CONSUMER","مصرف‌کننده نهایی"; SHOP="SHOP","فروشگاه"; REPRESENTATIVE="REPRESENTATIVE","نمایندگی"; COMPANY="COMPANY","شرکت"; OTHER="OTHER","سایر"
class StakeholderType(models.TextChoices):
CUSTOMER = "CUSTOMER", "مشتری"
SUPPLIER = "SUPPLIER", "تامین‌کننده"
CARRIER = "CARRIER", "باربری"
OTHER = "OTHER", "سایر"
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="customer_profile")
first_name = models.CharField(max_length=100, blank=True)
last_name = models.CharField(max_length=100, blank=True)
customer_type = models.CharField(max_length=20, choices=Type.choices, blank=True)
stakeholder_type = models.CharField(
max_length=20,
choices=StakeholderType.choices,
default=StakeholderType.CUSTOMER,
db_index=True,
)
organization_name = models.CharField(max_length=180, blank=True)
groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts")
province = models.CharField(max_length=100, blank=True)
city = models.CharField(max_length=100, blank=True)
def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username)
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
from dataclasses import dataclass
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@dataclass
class NotificationResult:
success: bool
provider_id: str = ""
error: str = ""
class ConsoleBaleProvider:
def send_message(self, chat_id, text):
if settings.DEBUG: print(f"[DEV BALE] {chat_id or 'dashboard'}: {text}")
return NotificationResult(True, "console")
class BaleBotProvider:
def send_message(self, chat_id, text):
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
response.raise_for_status(); data = response.json()
if not data.get("ok", True): return NotificationResult(False, error=str(data.get("description", "Bale rejected message")))
return NotificationResult(True, str((data.get("result") or {}).get("message_id", "bale")))
def get_bale_provider():
if settings.BALE_BOT_TOKEN:
return BaleBotProvider()
if settings.DEBUG:
return ConsoleBaleProvider()
raise ImproperlyConfigured("BALE_BOT_TOKEN is required to deliver Bale notifications in production.")
View File
Whitespace-only changes.
+18
View File
@@ -0,0 +1,18 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from urllib.parse import urlparse
from django.conf import settings
@dataclass(frozen=True, slots=True)
class SMSResult:
success: bool
provider_id: str = ""
error: str = ""
def webotp_message(code):
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
return f"کد شما: {code}\n@{host} #{code}"
class SMSProvider(ABC):
"""Stable application-facing contract for every SMS vendor."""
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
@abstractmethod
def send_message(self,mobile,message): raise NotImplementedError
+69
View File
@@ -0,0 +1,69 @@
import logging
import zeep
from django.conf import settings
from zeep.helpers import serialize_object
from .base import SMSProvider, SMSResult
logger = logging.getLogger(__name__)
class BehinSMSProvider(SMSProvider):
"""SOAP adapter for panel.behinsms.com, isolated from application code."""
def __init__(self):
self.username = settings.BEHIN_SMS_USERNAME
self.password = settings.BEHIN_SMS_PASSWORD
self.special_number = settings.BEHIN_SMS_SPECIAL_NUMBER
self.wsdl_url = settings.BEHIN_SMS_WSDL_URL
self._client = None
def _configuration_error(self):
missing = [name for name, value in (
("BEHIN_SMS_USERNAME", self.username),
("BEHIN_SMS_PASSWORD", self.password),
("BEHIN_SMS_SPECIAL_NUMBER", self.special_number)
) if not value]
return f"تنظیمات پیامک بهین ناقص است: {', '.join(missing)}" if missing else ""
@property
def client(self):
if self._client is None:
# استفاده از تنظیمات پیش‌فرض Zeep (مشابه نمونهٔ کاری)
self._client = zeep.Client(wsdl=self.wsdl_url)
return self._client
def get_credit(self):
error = self._configuration_error()
if error:
raise ValueError(error)
return self.client.service.GetCredit(UserName=self.username, Password=self.password)
def send_message(self, mobile, message):
return self.send_many([mobile], message)
def send_many(self, mobile_numbers, message, is_flash=False):
error = self._configuration_error()
if error:
return SMSResult(False, error=error)
recipients = list(dict.fromkeys(str(number).strip() for number in mobile_numbers if str(number).strip()))
if not recipients:
return SMSResult(False, error="شماره گیرنده پیامک خالی است.")
try:
response = self.client.service.SendArray(
UserName=self.username,
Password=self.password,
RecipientNumber={"string": recipients},
MessageBody=message,
SpecialNumber=self.special_number,
IsFlashMessage=is_flash,
CheckingMessageID={"long": [0] * len(recipients)}
)
serialized = serialize_object(response)
if serialized is False or serialized is None:
return SMSResult(False, error="وب‌سرویس بهین ارسال پیامک را تأیید نکرد.")
return SMSResult(True, provider_id=str(serialized)[:500])
except Exception as exc:
logger.exception("Behin SMS delivery failed")
return SMSResult(False, error=str(exc)[:1000])
+10
View File
@@ -0,0 +1,10 @@
import logging
from django.conf import settings
from .base import SMSProvider,SMSResult,webotp_message
logger=logging.getLogger(__name__)
class ConsoleSMSProvider(SMSProvider):
def send_otp(self,mobile,code):
if settings.DEBUG: print(f"[DEV OTP] {mobile}: {webotp_message(code)}")
return SMSResult(True,"console")
def send_message(self,mobile,message):
logger.info("[DEV SMS] destination=***%s message=%s",mobile[-4:],message); return SMSResult(True,"console")
+10
View File
@@ -0,0 +1,10 @@
from functools import lru_cache
from django.conf import settings
from django.utils.module_loading import import_string
from .base import SMSProvider
@lru_cache(maxsize=1)
def get_sms_provider():
provider_class=import_string(settings.SMS_PROVIDER_CLASS)
if not issubclass(provider_class,SMSProvider): raise TypeError("SMS_PROVIDER_CLASS باید از SMSProvider ارث‌بری کند.")
return provider_class()
def reset_sms_provider(): get_sms_provider.cache_clear()
+13
View File
@@ -0,0 +1,13 @@
import requests
from django.conf import settings
from .base import SMSProvider,SMSResult
class HTTPSMSProvider(SMSProvider):
def send_message(self,mobile,message):
if not settings.SMS_API_URL: return SMSResult(False,error="SMS_API_URL تنظیم نشده است.")
headers={"Accept":"application/json","Content-Type":"application/json"}
if settings.SMS_API_TOKEN: headers["Authorization"]=f"Bearer {settings.SMS_API_TOKEN}"
try:
response=requests.post(settings.SMS_API_URL,json={"to":mobile,"message":message,"sender":settings.SMS_SENDER},headers=headers,timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS); response.raise_for_status(); data=response.json() if response.content else {}
if data.get("success") is False: return SMSResult(False,error=str(data.get("error","Provider rejected SMS")))
return SMSResult(True,str(data.get("id") or data.get("message_id") or "http"))
except requests.RequestException as exc: return SMSResult(False,error=str(exc))
+7
View File
@@ -0,0 +1,7 @@
"""Backward-compatible public facade for the SMS integration."""
from .base import SMSProvider,SMSResult,webotp_message
from .behin import BehinSMSProvider
from .console import ConsoleSMSProvider
from .factory import get_sms_provider,reset_sms_provider
from .http import HTTPSMSProvider
__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
+36
View File
@@ -0,0 +1,36 @@
import logging
import requests
from django.conf import settings
from apps.accounts.models import normalize_mobile
from .base import SMSProvider, SMSResult
logger = logging.getLogger(__name__)
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
"""Adapter for the 0098SMS HTTP gateway."""
def __init__(self):
self.username = settings.SMS0098_USERNAME
self.password = settings.SMS0098_PASSWORD
self.sender = settings.SMS0098_FROM
self.api_url = settings.SMS0098_API_URL
def send_message(self, mobile, message):
missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value]
if missing:
return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}")
try:
recipient = int(normalize_mobile(str(mobile)).removeprefix("+98"))
response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
response.raise_for_status()
# This legacy endpoint may append an HTML page containing the
# original query string. Persist only its first scalar result so
# credentials can never leak into notification logs.
provider_id = response.text.strip().splitlines()[0][:100]
return SMSResult(True, provider_id=provider_id)
except (ValueError, requests.RequestException) as exc:
logger.exception("0098SMS delivery failed")
return SMSResult(False, error=str(exc)[:1000])
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
from .models import Message,MessageMention,MessageRevision
admin.site.register([Message,MessageMention,MessageRevision])
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class MessagingConfig(AppConfig): name="apps.messaging"
+53
View File
@@ -0,0 +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')],
},
),
]
+48
View File
@@ -0,0 +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'),
),
]
@@ -0,0 +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')],
},
),
]
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from django.conf import settings
from django.db import models
from apps.core.models import PersianTextNormalizationMixin, PublicIdModel
class Message(PersianTextNormalizationMixin, PublicIdModel, models.Model):
persian_text_fields = {"body": True}
class Type(models.TextChoices): PUBLIC="PUBLIC","عمومی"; INTERNAL_NOTE="INTERNAL_NOTE","یادداشت داخلی"
ticket=models.ForeignKey("tickets.Ticket",on_delete=models.PROTECT,related_name="ticket_messages"); author=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,related_name="authored_messages"); body=models.TextField(blank=True); message_type=models.CharField(max_length=20,choices=Type.choices,default=Type.PUBLIC); 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(null=True,blank=True); deleted_at=models.DateTimeField(null=True,blank=True); deleted_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT,null=True,blank=True,related_name="deleted_messages")
class Meta:
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")]
@property
def author_name(self):
profile=getattr(self.author,"staff_profile",None) or getattr(self.author,"customer_profile",None)
return str(profile) if profile else self.author.username
class MessageRevision(PersianTextNormalizationMixin, models.Model):
persian_text_fields = {"body": True}
message=models.ForeignKey(Message,on_delete=models.CASCADE,related_name="revisions"); body=models.TextField(); edited_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
class MessageMention(models.Model):
message=models.ForeignKey(Message,on_delete=models.CASCADE,related_name="mentions"); mentioned_user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); created_at=models.DateTimeField(auto_now_add=True)
class Meta: constraints=[models.UniqueConstraint(fields=["message","mentioned_user"],name="unique_message_mention")]
class MessageReadReceipt(models.Model):
message=models.ForeignKey(Message,on_delete=models.CASCADE,related_name="read_receipts")
user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE,related_name="message_read_receipts")
read_at=models.DateTimeField(auto_now_add=True,db_index=True)
class Meta:
ordering=["read_at","id"]
constraints=[models.UniqueConstraint(fields=["message","user"],name="unique_message_read_receipt")]
@property
def reader_name(self):
profile=getattr(self.user,"staff_profile",None) or getattr(self.user,"customer_profile",None)
return str(profile) if profile else self.user.username
+21
View File
@@ -0,0 +1,21 @@
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.utils import timezone
from apps.core.persian_text import normalize_persian_text
from apps.tickets.policies import TicketPolicy
from .models import Message,MessageRevision
@transaction.atomic
def edit_message(message_id,user,body):
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied
if msg.deleted_at: raise PermissionDenied
body=normalize_persian_text(body,multiline=True)
if not body: raise ValueError("متن پیام نمی‌تواند خالی باشد.")
MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg
@transaction.atomic
def soft_delete_message(message_id,user):
msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id)
may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message")
if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied
msg.deleted_at=timezone.now(); msg.deleted_by=user; msg.save(update_fields=["deleted_at","deleted_by","updated_at"]); return msg
+5
View File
@@ -0,0 +1,5 @@
from django.urls import path
from . import views
app_name="messages"
urlpatterns=[path("<uuid:public_id>/edit/",views.edit,name="edit"),path("<uuid:public_id>/delete/",views.delete,name="delete"),path("<uuid:public_id>/revisions/",views.revisions,name="revisions")]
+28
View File
@@ -0,0 +1,28 @@
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
from django.shortcuts import get_object_or_404,redirect,render
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from .models import Message
from .services import edit_message,soft_delete_message
from apps.tickets.policies import TicketPolicy
def _safe_next(request, fallback):
value=request.POST.get("next")
return value if value and url_has_allowed_host_and_scheme(value,allowed_hosts={request.get_host()},require_https=request.is_secure()) else fallback
@login_required
@require_POST
def edit(request,public_id):
msg=get_object_or_404(Message,public_id=public_id)
try: edit_message(msg.pk,request.user,request.POST.get("body",""))
except (PermissionDenied,ValueError) as exc: raise PermissionDenied(str(exc))
return redirect(_safe_next(request,"/tickets/"))
@login_required
@require_POST
def delete(request,public_id):
msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/"))
@login_required
def revisions(request,public_id):
msg=get_object_or_404(Message,public_id=public_id)
if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied
return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")})
View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
from django.contrib import admin
from .models import NotificationJob,NotificationPolicy,SMSTemplate
@admin.register(NotificationJob)
class NotificationJobAdmin(admin.ModelAdmin): list_display=("channel","event_type","status","attempts","provider_message_id","created_at"); list_filter=("channel","status"); readonly_fields=("provider_message_id","dedupe_key","created_at","sent_at")
@admin.register(SMSTemplate)
class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at")
list_filter=("unit","is_active","event_type")
readonly_fields=("allowed_tags",)
@admin.register(NotificationPolicy)
class NotificationPolicyAdmin(admin.ModelAdmin):
list_display=("key","quiet_period_seconds","is_active","updated_at")
def has_add_permission(self,request): return NotificationPolicy.objects.count()<len(NotificationPolicy.Key.values)
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig): name="apps.notifications"
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,164 @@
import logging
import time
from datetime import timedelta
import jdatetime
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from apps.accounts.models import TicketMagicLink
from apps.integrations.bale.providers import get_bale_provider
from apps.integrations.sms.providers import get_sms_provider
from apps.messaging.models import MessageReadReceipt
from apps.notifications.models import NotificationJob,NotificationPolicy,SMSTemplate
from apps.notifications.services import customer_reply_bale_policy,staff_reply_policy
from apps.tickets.models import Ticket
logger = logging.getLogger(__name__)
def _magic_link(job, reason):
token,link = TicketMagicLink.issue_with_record(job.ticket, reason=reason, created_by=job.staff_user or job.ticket.owner or job.ticket.created_by)
return link, f"{settings.PUBLIC_BASE_URL}/t/{token}/"
def _sms_context(job,magic_link):
profile=getattr(job.customer,"customer_profile",None)
unit=job.ticket.owning_unit
return {"magic_link":magic_link,"first_name":getattr(profile,"first_name","") or "","last_name":getattr(profile,"last_name","") or "","recipient_name":" ".join(filter(None,[getattr(profile,"first_name","") or "",getattr(profile,"last_name","") or ""])),"unit_name":unit.name if unit else "","company_name":getattr(settings,"COMPANY_NAME","لوله گستر گلپایگان"),"ticket_number":job.ticket.ticket_number,"subject":job.ticket.subject,"province":job.ticket.province or getattr(profile,"province","") or "","city":job.ticket.city or getattr(profile,"city","") or "","expert_name":job.ticket.assigned_expert_name}
def _render_sms(event_type,context,unit=None):
from string import Formatter
template=(SMSTemplate.objects.filter(event_type=event_type,is_active=True,unit=unit).first() if unit else None) or SMSTemplate.objects.get(event_type=event_type,is_active=True,unit__isnull=True)
used={field for _,field,_,_ in Formatter().parse(template.body) if field}
invalid=used-set(template.allowed_tags)
if invalid: raise ValueError(f"SMS template contains unsupported tags: {', '.join(sorted(invalid))}")
missing=used-set(context)
if missing: raise ValueError(f"SMS template context is missing: {', '.join(sorted(missing))}")
return template.body.format_map(context)
def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, reopened=False):
profile = getattr(ticket.customer, "customer_profile", None)
name = " ".join(x for x in [getattr(profile, "first_name", ""), getattr(profile, "last_name", "")] if x).strip() or "ثبت نشده"
location = "، ".join(x for x in [getattr(profile, "city", "") or ticket.city, getattr(profile, "province", "") or ticket.province] if x)
message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first()
raw_excerpt = " ".join((message.body if message else "").split())
excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن"
local_created = timezone.localtime(message.created_at if message else ticket.created_at)
jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M")
lines=[
"♻️ بازگشایی تیکت توسط مشتری" if reopened else (f"💬 {message_count} پیام جدید مشتری" if is_reply and message_count>1 else ("💬 پاسخ جدید مشتری" if is_reply else "🆕 تیکت جدید مشتری")),
"────────────",
f"🎫 {ticket.ticket_number}",
f"📌 {ticket.subject}",
f"👤 {name}",
f"📞 {ticket.customer.local_mobile or 'ثبت نشده'}",
]
if location: lines.append(f"📍 {location}")
lines.extend([f"🕒 {jalali}", "", f"📝 {excerpt}", "", "🔗 مشاهده و پاسخ", f"{settings.PUBLIC_BASE_URL}/staff/tickets/{ticket.public_id}/"])
return "\n".join(lines)
def _send_sms(job):
issued = None
if job.event_type == "CUSTOMER_TICKET_CREATED":
issued, url = _magic_link(job, "customer_created_ticket")
message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
elif job.event_type == "STAFF_TICKET_CREATED":
issued, url = _magic_link(job, "staff_created_ticket"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
elif job.event_type == "STAFF_REPLY":
issued, url = _magic_link(job, "staff_reply"); message = _render_sms(job.event_type,_sms_context(job,url),job.ticket.owning_unit)
else:
raise ValueError(f"Unsupported SMS event: {job.event_type}")
return get_sms_provider().send_message(job.customer.mobile, message), issued
def _send_bale(job):
if job.event_type == "NEW_CUSTOMER_TICKET":
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket))
if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]:
message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first()
if not message: raise ValueError("Customer reply message no longer exists")
return get_bale_provider().send_message(settings.BALE_GROUP_CHAT_ID, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED"))
if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured")
profile = job.staff_user.staff_profile
if not profile.bale_notifications_enabled: return None
return get_bale_provider().send_message(profile.bale_chat_id, f"رویداد جدید برای تیکت {job.ticket.ticket_number}\n{settings.PUBLIC_BASE_URL}/staff/tickets/{job.ticket.public_id}/")
def _prepare_staff_reply_sms(job):
"""Final transactional guard immediately before the irreversible provider call."""
with transaction.atomic():
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
policy=staff_reply_policy()
message_id=locked.payload.get("latest_message_id")
message=ticket.ticket_messages.filter(pk=message_id,is_internal=False,author__account_type="STAFF").first()
reason=""
if not policy.is_active: reason="policy_disabled"
elif not message: reason="staff_message_missing"
elif MessageReadReceipt.objects.filter(message=message,user_id=locked.customer_id).exists(): reason="customer_read"
elif ticket.ticket_messages.filter(is_internal=False,author_id=locked.customer_id,created_at__gt=message.created_at).exists(): reason="customer_replied"
if reason:
locked.status=NotificationJob.Status.CANCELLED; locked.last_error=reason
locked.save(update_fields=["status","last_error"])
return False
due=ticket.last_activity_at+timedelta(seconds=policy.quiet_period_seconds)
if due>timezone.now():
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="conversation_active"
locked.save(update_fields=["status","next_attempt_at","last_error"])
return False
return True
def _prepare_customer_reply_bale(job):
with transaction.atomic():
ticket=Ticket.objects.select_for_update().get(pk=job.ticket_id)
locked=NotificationJob.objects.select_for_update().get(pk=job.pk)
policy=customer_reply_bale_policy()
if not policy.is_active:
locked.status=NotificationJob.Status.CANCELLED; locked.last_error="policy_disabled"; locked.save(update_fields=["status","last_error"]); return False
due=ticket.last_customer_message_at+timedelta(seconds=policy.quiet_period_seconds)
if due>timezone.now():
locked.status=NotificationJob.Status.PENDING; locked.next_attempt_at=due; locked.last_error="customer_chat_active"
locked.save(update_fields=["status","next_attempt_at","last_error"]); return False
return True
class Command(BaseCommand):
help = "Process notification outbox"
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true")
parser.add_argument("--job-id", type=int, help="Process only one notification job")
parser.add_argument("--min-id", type=int, help="Process this job id and newer jobs")
parser.add_argument("--channel", choices=NotificationJob.Channel.values, help="Process only one notification channel")
def handle(self, *args, **options):
while True:
stale = timezone.now() - timedelta(minutes=10)
NotificationJob.objects.filter(status="PROCESSING", next_attempt_at__lt=stale).update(status="FAILED", next_attempt_at=timezone.now(), last_error="Recovered stale processing job")
with transaction.atomic():
queryset = NotificationJob.objects.select_for_update(skip_locked=True).filter(status__in=["PENDING", "FAILED"], attempts__lt=settings.MAX_NOTIFICATION_ATTEMPTS, next_attempt_at__lte=timezone.now())
if options.get("channel"):
queryset = queryset.filter(channel=options["channel"])
if options.get("job_id"):
queryset = queryset.filter(pk=options["job_id"])
elif options.get("min_id"):
queryset = queryset.filter(pk__gte=options["min_id"])
jobs = list(queryset.order_by("created_at")[:20])
NotificationJob.objects.filter(pk__in=[x.pk for x in jobs]).update(status="PROCESSING", next_attempt_at=timezone.now())
for job in jobs:
issued_link = None
try:
if job.channel == NotificationJob.Channel.SMS and job.event_type == "STAFF_REPLY" and not _prepare_staff_reply_sms(job):
continue
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
continue
if job.channel == NotificationJob.Channel.SMS: result, issued_link = _send_sms(job)
else: result = _send_bale(job)
if result is not None and not result.success: raise RuntimeError(result.error or "Provider rejected notification")
job.attempts += 1; job.status = "SENT"; job.sent_at = timezone.now(); job.last_error = ""; job.provider_message_id = getattr(result,"provider_id","") if result is not None else ""
except Exception as exc:
if issued_link:
issued_link.revoked_at = timezone.now(); issued_link.save(update_fields=["revoked_at"])
job.attempts += 1; job.last_error = str(exc)[:1000]; job.provider_message_id = ""; job.status = "FAILED"; job.next_attempt_at = timezone.now() + timedelta(seconds=min(3600, 2**job.attempts * 10)); logger.exception("notification failed id=%s", job.pk)
job.save()
if job.ticket_id and job.ticket.group_dispatch_id:
from apps.tickets.services import refresh_group_dispatch_status
refresh_group_dispatch_status(job.ticket.group_dispatch_id)
if options["once"] or options.get("job_id"): break
time.sleep(5)
@@ -0,0 +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')),
],
),
]
@@ -0,0 +1,40 @@
# 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"]
DEFAULTS={
"CUSTOMER_TICKET_CREATED":("ثبت تیکت توسط مشتری","{first_name} عزیز، از ثبت درخواست شما متشکریم. تیکت {ticket_number} با موفقیت ثبت شد.\nمشاهده و پیگیری: {magic_link}\nپس از بررسی و پاسخ کارشناس، از طریق پیامک به شما اطلاع خواهیم داد.\nلوله گستر گلپایگان"),
"STAFF_TICKET_CREATED":("ثبت تیکت توسط کارشناس","{first_name} عزیز، تیکت {ticket_number} با موضوع «{subject}» توسط واحد فروش برای شما ثبت شد.\nمشاهده و پاسخ: {magic_link}\nلوله گستر گلپایگان"),
"STAFF_REPLY":("پاسخ کارشناس","{first_name} عزیز، پاسخ جدیدی برای تیکت {ticket_number} ثبت شد.\nمشاهده پاسخ: {magic_link}\nلوله گستر گلپایگان"),
}
def seed_sms_templates(apps,schema_editor):
model=apps.get_model("notifications","SMSTemplate")
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 = [
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)),
],
bases=(apps.core.models.PersianTextNormalizationMixin, models.Model),
),
migrations.RunPython(seed_sms_templates,remove_sms_templates),
]
@@ -0,0 +1,31 @@
from django.db import migrations, models
def seed_policy(apps, schema_editor):
apps.get_model("notifications", "NotificationPolicy").objects.get_or_create(
key="STAFF_REPLY_SMS",
defaults={"quiet_period_seconds": 300, "is_active": True},
)
class Migration(migrations.Migration):
dependencies = [("notifications", "0002_smstemplate")]
operations = [
migrations.CreateModel(
name="NotificationPolicy",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("key", models.CharField(choices=[("STAFF_REPLY_SMS", "پیامک پاسخ کارشناس")], max_length=50, unique=True)),
("quiet_period_seconds", models.PositiveIntegerField(default=300, help_text="پس از آخرین فعالیت تیکت، چند ثانیه برای ادامه گفت‌وگو صبر شود.")),
("is_active", models.BooleanField(default=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"verbose_name": "سیاست اعلان", "verbose_name_plural": "سیاست‌های اعلان"},
),
migrations.AlterField(
model_name="notificationjob",
name="status",
field=models.CharField(choices=[("PENDING", "در صف"), ("PROCESSING", "در حال ارسال"), ("SENT", "ارسال‌شده"), ("FAILED", "ناموفق"), ("CANCELLED", "لغوشده")], db_index=True, default="PENDING", max_length=15),
),
migrations.RunPython(seed_policy, migrations.RunPython.noop),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
def seed_policy(apps, schema_editor):
apps.get_model("notifications", "NotificationPolicy").objects.get_or_create(
key="CUSTOMER_REPLY_BALE",
defaults={"quiet_period_seconds": 600, "is_active": True},
)
class Migration(migrations.Migration):
dependencies = [("notifications", "0003_notificationpolicy_notificationjob_status")]
operations = [
migrations.AlterField(
model_name="notificationpolicy",
name="key",
field=models.CharField(
choices=[
("STAFF_REPLY_SMS", "پیامک پاسخ کارشناس"),
("CUSTOMER_REPLY_BALE", "اعلان پاسخ مشتری در بله"),
],
max_length=50,
unique=True,
),
),
migrations.RunPython(seed_policy, migrations.RunPython.noop),
]
@@ -0,0 +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'),
),
]
@@ -0,0 +1,29 @@
# 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"]
def add_supported_tags(apps, schema_editor):
template_model = apps.get_model("notifications", "SMSTemplate")
for template in template_model.objects.all().iterator():
template.allowed_tags = list(dict.fromkeys([*template.allowed_tags, *TAGS]))
template.save(update_fields=["allowed_tags"])
class Migration(migrations.Migration):
dependencies = [
('notifications', '0005_smstemplate_unit_alter_smstemplate_event_type_and_more'),
]
operations = [
migrations.AlterField(
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),
]
@@ -0,0 +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'),
),
]
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more