first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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", "-"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
Whitespace-only changes.
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
name = "apps.accounts"
|
||||||
|
|
||||||
@@ -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"])
|
||||||
@@ -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
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+38
@@ -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.
@@ -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}"
|
||||||
@@ -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")]
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Attachment,AttachmentPolicy
|
||||||
|
admin.site.register([Attachment,AttachmentPolicy])
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class AttachmentsConfig(AppConfig): name="apps.attachments"
|
||||||
@@ -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.
@@ -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")]
|
||||||
@@ -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
|
||||||
@@ -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")]
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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",)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
name = "apps.core"
|
||||||
|
|
||||||
@@ -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"),
|
||||||
|
}}
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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."))
|
||||||
@@ -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
|
||||||
@@ -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='آیکون معرفی ورود کارشناسان'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Whitespace-only changes.
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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 ""
|
||||||
Whitespace-only changes.
@@ -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",)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class CustomersConfig(AppConfig): name = "apps.customers"
|
||||||
@@ -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)),
|
||||||
|
]
|
||||||
+41
@@ -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.
@@ -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)
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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.")
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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"]
|
||||||
@@ -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])
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Message,MessageMention,MessageRevision
|
||||||
|
admin.site.register([Message,MessageMention,MessageRevision])
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class MessagingConfig(AppConfig): name="apps.messaging"
|
||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")]
|
||||||
|
|
||||||
@@ -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")})
|
||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
+33
@@ -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
Reference in new issue
Block a user