add retry sms manually
This commit is contained in:
1 parent
33930e972c
commit
cb92abbb41
5 files changed
+189
-45
No files matched your search
@@ -1,42 +1,81 @@
|
||||
# Project operating rules
|
||||
# Project Operating & Agent Guidelines
|
||||
|
||||
## Development server
|
||||
## 1. Codebase Navigation & Analysis (Ripwire vs. Graphify)
|
||||
|
||||
- Always bind the Django development server to `0.0.0.0:8000` so the project is reachable from phones and other devices through the computer's local-network IP address. Do not start it on `127.0.0.1` unless the user explicitly requests localhost-only access.
|
||||
- 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.
|
||||
> **Rule of Thumb:** Default to **Ripwire** for all hands-on coding, AST, edits, call graphs, and tests. Reserve **Graphify** strictly for high-level architectural understanding, cross-artifact relations, and non-code assets.
|
||||
|
||||
## Graphify queries
|
||||
CLI-only (never use MCP). Route strictly by question and artifact type. Never query both tools for the same sub-task; never execute in parallel. Always prefer `--expand` over reading entire files.
|
||||
|
||||
- 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.
|
||||
| Metric / Dimension | **Ripwire** (Deterministic AST) | **Graphify** (Semantic KG) |
|
||||
|---|---|---|
|
||||
| **Primary Scope** | Local source code AST, symbol graphs, in-repo text | Cross-artifact: Code + Docs + PDFs + Media + SQL Schema |
|
||||
| **Best at** | Callers, callees, impact, tests, quality delta, `--arch`, `--doc-drift` | Conceptual "How A relates to B", ADR/RFC context, schemas |
|
||||
| **Primary CLI** | `ripwire . --for="…"`, `--callers=SYM`, `--impact=SYM`, `--test-gate` | `graphify query "…"`, `graphify path "A" "B"`, `graphify explain "node"` |
|
||||
| **Boundary** | Prose meaning, non-code files, cross-asset concepts | Exact symbol references, impact analysis, code test gates |
|
||||
|
||||
## Python package mirror
|
||||
### Routing Gate & Pipeline
|
||||
1. **Trivial edits (≤1 file, no exported symbols):** Modify directly without querying either tool.
|
||||
2. **Execution routing:**
|
||||
- Hands-on coding, symbols, AST, usages, impact, and tests → **Ripwire**.
|
||||
- Architecture discovery, cross-artifact concepts (PDF, media, docs, ADR/RFC), and SQL schemas → **Graphify**. (Note: `.sql` symbols use Ripwire; schema semantics use Graphify).
|
||||
- Ambiguous/Mixed tasks → Run `ripwire . --help-task="task"` to classify, then route by artifact.
|
||||
3. **Graphify query rules (when selected):**
|
||||
- Requires `graphify-out/graph.json`; if absent, route code questions to Ripwire.
|
||||
- **Bilingual alignment:** Match terms directly against `graphify-out/graph.json` vocabulary. Prioritize English code identifiers; include Persian terms only when verified in graph documentation/requirements. Never rely on internal translation or stemming.
|
||||
- **Traversal selection:** Use BFS for broad relationships, DFS for dependency traces, `path` for shortest linkage, and `explain` for node neighbors.
|
||||
- Always cross-verify critical graph findings against source code before editing.
|
||||
4. **Pre-edit impact gate:** Before any non-trivial code change, inspect the target symbol with both `ripwire . --impact=SYM` and `ripwire . --uses=SYM`; if either query refuses or is ambiguous, resolve the symbol using the CLI guidance before editing.
|
||||
5. **Post-edit pipeline:** Run `ripwire . --test-gate` followed by `ripwire . --quality-delta`.
|
||||
|
||||
- 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
|
||||
## 2. Dynamic Help, Self-Correction & Staleness
|
||||
|
||||
- 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.
|
||||
Never stall on tool errors or hallucinate flags. Dynamic CLI help always takes precedence over memorized parameters.
|
||||
|
||||
## End-to-end browser tests
|
||||
- **Help Precedence:** Before using an unfamiliar or ambiguous capability, inspect `ripwire --help` or `ripwire --help=--FLAG`; if any instruction or remembered syntax conflicts with that output, the CLI help is authoritative.
|
||||
- **Dynamic Inspection (on error or flag confusion):**
|
||||
- Ripwire: `ripwire --help`, `ripwire --help=--FLAG`, `ripwire . --doctor`, `ripwire . --help-task="task"`
|
||||
- Graphify: `graphify --help`, `graphify <cmd> --help`
|
||||
- **Fallback & Retries (Cap at 1):**
|
||||
- If a command fails or rejects flags: inspect its `--help` and retry once.
|
||||
- If a tool returns empty results or an explicit refusal: try the alternative tool once; if still unresolved, report to user.
|
||||
- If data is suspected to be stale: run `graphify update .` or `ripwire . --no-cache` and retry once.
|
||||
- If a tool binary is missing (`command -v` check fails): state missing dependency clearly; do not guess syntax.
|
||||
|
||||
- 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.
|
||||
---
|
||||
|
||||
## 3. Git Operations & Safety
|
||||
|
||||
- **Read-only (Agent allowed):** `git status`, `git diff`, `git log`.
|
||||
- **Human-gated (User execution only):** `git add`, `commit`, `push`, `checkout`, `merge`, `rebase`, and branch management. If needed, request user action and pause.
|
||||
- **Exception:** `graphify hook install` is permitted (read-side only).
|
||||
|
||||
---
|
||||
|
||||
## 4. Development Server Lifecycle
|
||||
|
||||
- **Binding:** Always bind Django to `0.0.0.0:8000` (LAN accessible). Never use `127.0.0.1` unless explicitly requested.
|
||||
- **Process Management:** Before launching, inspect port `8000`. Terminate previous project server instances and their child process trees cleanly.
|
||||
- **Health Verification:** Confirm exactly one process listens on `0.0.0.0:8000`, then fetch `127.0.0.1:8000` locally to verify health.
|
||||
- **Asset Confirmation:** When checking frontend changes, verify the served content on the live server before suggesting browser cache issues to the user.
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment, Frontend & PWA Safety
|
||||
|
||||
- **Python virtual environment:** Use the repository `.venv` for all Python and Django commands during development, tests, dependency management, and server startup. From WSL, run `.venv/Scripts/python.exe`; from Windows, run `.venv\Scripts\python.exe`. Run pip with `<venv-python> -m pip` and Django with `<venv-python> manage.py ...`. Do not use system Python or pip or create a second environment unless the user requests it.
|
||||
- **PyPI Mirror:** Use Liara's mirror: `https://package-mirror.liara.ir/repository/pypi/simple`. Use user-level pip configuration. Report downtime before falling back to public PyPI.
|
||||
- **Asset Versioning:** All locally served frontend assets must include `static_asset_version` in their URLs.
|
||||
- **PWA Lifecycle:** Keep the service worker registration URL versioned with `updateViaCache: 'none'`.
|
||||
- **Cache Regression Gate:** Changes to frontend caching or PWA code must pass assertions in `tests/test_views.py` and `tests/e2e/test_pwa_versioning.py`.
|
||||
|
||||
---
|
||||
|
||||
## 6. End-to-End Browser Tests (Playwright)
|
||||
|
||||
- **Organization:** Place tests in `tests/e2e/`, mark with `e2e`, and run against Django's transactional `live_server`.
|
||||
- **Scope & Lifecycle:** Use managed Chromium and BrowserContext from `tests/e2e/conftest.py`. Keep Playwright function-scoped to prevent event loop deadlocks with Django's synchronous database setup.
|
||||
- **Page Objects:** Encapsulate interactions inside `tests/e2e/pages/`. Tests must assert user-visible outcomes only.
|
||||
- **Synchronization:** Never use hardcoded sleeps. Rely on Playwright locators, `expect`, navigation, and state-change assertions.
|
||||
- **Diagnostics:** Maintain automatic screenshot/trace capture on failure. PWA scenarios must enable service workers and use the `pwa` marker.
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
app_name="staff_tickets"
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts/<uuid:public_id>/edit/",views.staff_contact_edit,name="contact_edit"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups/<int:group_id>/",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links/<int:link_id>/revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates/<int:template_id>/",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches/<uuid:public_id>/",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches/<uuid:public_id>/retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches/<uuid:public_id>/resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts/<uuid:public_id>/avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets/<uuid:public_id>/messages/older/",views.staff_messages,name="messages"),path("tickets/<uuid:public_id>/messages/poll/",views.staff_poll,name="poll"),path("tickets/<uuid:public_id>/sms/<int:job_id>/retry/",views.staff_sms_retry,name="sms_retry"),path("tickets/<uuid:public_id>/",views.staff_detail,name="detail"),path("tickets/<uuid:public_id>/claim/",views.staff_claim,name="claim"),path("tickets/<uuid:public_id>/reply/",views.staff_reply,name="reply"),path("tickets/<uuid:public_id>/note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets/<uuid:public_id>/transfer/",views.staff_transfer,name="transfer"),path("tickets/<uuid:public_id>/share/",views.staff_share,name="share"),path("tickets/<uuid:public_id>/revoke/<uuid:user_id>/",views.staff_revoke,name="revoke"),path("tickets/<uuid:public_id>/status/",views.staff_status,name="status"),path("tickets/<uuid:public_id>/priority/",views.staff_priority,name="priority"),path("tickets/<uuid:public_id>/delete/",views.staff_delete,name="delete")]
|
||||
+62
-9
@@ -33,6 +33,53 @@ from .policies import DispatchPolicy, TicketPolicy
|
||||
from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows
|
||||
from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,revoke_access,share_ticket,transfer_ticket
|
||||
MESSAGE_PAGE_SIZE=50
|
||||
SMS_RETRYABLE_DELIVERY_STATUSES=(
|
||||
NotificationJob.DeliveryStatus.UNDELIVERED,
|
||||
NotificationJob.DeliveryStatus.EXPIRED,
|
||||
NotificationJob.DeliveryStatus.BLOCKED,
|
||||
NotificationJob.DeliveryStatus.UNKNOWN,
|
||||
NotificationJob.DeliveryStatus.ERROR,
|
||||
)
|
||||
|
||||
|
||||
def _retryable_sms_jobs(jobs):
|
||||
return jobs.filter(
|
||||
Q(status=NotificationJob.Status.FAILED)
|
||||
| Q(
|
||||
status=NotificationJob.Status.SENT,
|
||||
delivery_status__in=SMS_RETRYABLE_DELIVERY_STATUSES,
|
||||
delivery_next_check_at__isnull=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _sms_job_is_retryable(job):
|
||||
return job.status == NotificationJob.Status.FAILED or (
|
||||
job.status == NotificationJob.Status.SENT
|
||||
and job.delivery_status in SMS_RETRYABLE_DELIVERY_STATUSES
|
||||
and job.delivery_next_check_at is None
|
||||
)
|
||||
|
||||
|
||||
def _requeue_retryable_sms_jobs(jobs):
|
||||
return _retryable_sms_jobs(jobs).update(
|
||||
status=NotificationJob.Status.PENDING,
|
||||
attempts=0,
|
||||
next_attempt_at=timezone.now(),
|
||||
last_error="",
|
||||
provider_message_id="",
|
||||
provider_status_code="",
|
||||
sent_at=None,
|
||||
delivery_status="",
|
||||
delivery_status_code="",
|
||||
delivery_description="",
|
||||
delivery_checked_at=None,
|
||||
delivery_check_attempts=0,
|
||||
delivery_next_check_at=None,
|
||||
delivery_is_final=False,
|
||||
)
|
||||
|
||||
|
||||
def conversation_page(queryset,before=None):
|
||||
queryset=queryset.order_by("-created_at","-id")
|
||||
if before:
|
||||
@@ -395,13 +442,25 @@ def staff_detail(request,public_id):
|
||||
mark_messages_read(conversation,request.user)
|
||||
last=conversation[-1] if conversation else None; TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last})
|
||||
history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5]
|
||||
sms_jobs=ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")
|
||||
sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at"))
|
||||
can_retry_sms=TicketPolicy.can_reply(request.user,ticket)
|
||||
for sms_job in sms_jobs:
|
||||
sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job)
|
||||
latest=conversation[-1] if conversation else None
|
||||
group_dispatch = ticket.group_dispatch
|
||||
can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket")))
|
||||
group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
|
||||
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":ShareForm(),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"viewer_user":request.user,"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else ""})
|
||||
@staff_required
|
||||
@require_POST
|
||||
def staff_sms_retry(request,public_id,job_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
if not TicketPolicy.can_reply(request.user,ticket): raise PermissionDenied
|
||||
jobs=ticket.notificationjob_set.filter(pk=job_id,channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET")
|
||||
count=_requeue_retryable_sms_jobs(jobs)
|
||||
messages.success(request,"پیامک ناموفق دوباره در صف ارسال قرار گرفت." if count else "این پیامک در وضعیت قابل تلاش مجدد نیست.")
|
||||
return redirect("staff_tickets:detail",public_id=public_id)
|
||||
@staff_required
|
||||
def staff_messages(request,public_id):
|
||||
ticket=get_object_or_404(Ticket,public_id=public_id)
|
||||
if not TicketPolicy.can_view(request.user,ticket): raise PermissionDenied
|
||||
@@ -640,20 +699,14 @@ def staff_dispatch_resolve(request, public_id):
|
||||
def staff_dispatch_retry(request, public_id):
|
||||
dispatch=get_object_or_404(GroupDispatch,public_id=public_id)
|
||||
if not DispatchPolicy.can_view(request.user,dispatch) or not DispatchPolicy.can_create(request.user,dispatch.unit,dispatch.operation_type): raise PermissionDenied
|
||||
retryable_delivery = [NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, NotificationJob.DeliveryStatus.BLOCKED, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.ERROR]
|
||||
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED).filter(Q(status=NotificationJob.Status.FAILED) | Q(status=NotificationJob.Status.SENT,delivery_status__in=retryable_delivery,delivery_next_check_at__isnull=True))
|
||||
jobs=NotificationJob.objects.filter(ticket__group_dispatch=dispatch,channel=NotificationJob.Channel.SMS,event_type=SMSTemplate.Event.STAFF_TICKET_CREATED)
|
||||
job_id=request.POST.get("job_id","").strip()
|
||||
if job_id:
|
||||
if not job_id.isdigit():
|
||||
messages.error(request,"شناسه پیامک برای تلاش مجدد معتبر نیست.")
|
||||
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
|
||||
jobs=jobs.filter(pk=int(job_id))
|
||||
count=jobs.update(
|
||||
status=NotificationJob.Status.PENDING, attempts=0, next_attempt_at=timezone.now(),
|
||||
last_error="", provider_message_id="", provider_status_code="", sent_at=None,
|
||||
delivery_status="", delivery_status_code="", delivery_description="",
|
||||
delivery_checked_at=None, delivery_check_attempts=0, delivery_next_check_at=None,
|
||||
)
|
||||
count=_requeue_retryable_sms_jobs(jobs)
|
||||
refresh_group_dispatch_status(dispatch.pk)
|
||||
messages.success(request,f"{count} ارسال ناموفق دوباره در صف قرار گرفت." if count else "ارسال ناموفقی برای تلاش مجدد وجود ندارد.")
|
||||
return redirect("staff_tickets:dispatch_detail",public_id=public_id)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<aside class="workspace-side actions-side" id="ticket-actions" data-workspace-panel>
|
||||
<button type="button" class="panel-close" data-close-panel>×</button>
|
||||
<section class="info-card"><p class="panel-index">وضعیت تیکت</p><div class="info-list"><div class="info-row"><span>وضعیت</span><b>{{ ticket.get_status_display }}</b></div><div class="info-row"><span>اولویت</span><b>{{ ticket.get_priority_display }}</b></div><div class="info-row"><span>دسته</span><b>{{ ticket.category }}</b></div><div class="info-row"><span>مالک</span><b>{{ ticket.assigned_expert_name }}</b></div>{% if ticket.first_response_due_at %}<div class="info-row"><span>مهلت پاسخ</span><b>{{ ticket.first_response_due_at|jalali_datetime }}</b></div>{% endif %}</div></section>
|
||||
{% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامکهای مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمانبندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}</details>{% endfor %}</div></section>{% endif %}
|
||||
{% if sms_jobs %}<section class="info-card sms-delivery-card"><div class="sms-delivery-heading"><div><h3>وضعیت پیامکهای مشتری</h3><small>خلاصه ارسال و تحویل</small></div><span aria-hidden="true">ⓘ</span></div><div class="sms-delivery-list">{% for sms_job in sms_jobs %}<details class="sms-delivery-item"><summary><span class="sms-delivery-event">{% if sms_job.event_type == 'STAFF_TICKET_CREATED' %}ثبت تیکت توسط کارشناس{% elif sms_job.event_type == 'CUSTOMER_TICKET_CREATED' %}ثبت تیکت مشتری{% elif sms_job.event_type == 'STAFF_REPLY' %}اعلان پاسخ کارشناس{% else %}پیامک سیستمی{% endif %}</span><b>{% if sms_job.delivery_status %}{{ sms_job.get_delivery_status_display }}{% else %}{{ sms_job.get_status_display }}{% endif %}</b><span class="sms-delivery-info" aria-label="نمایش جزئیات زمانبندی پیامک" title="نمایش جزئیات">i</span></summary><ol class="sms-delivery-timeline"><li class="is-complete"><span></span><div><b>ثبت درخواست ارسال</b><time datetime="{{ sms_job.created_at|date:'c' }}">{{ sms_job.created_at|jalali_datetime }}</time></div></li><li class="{% if sms_job.sent_at %}is-complete{% endif %}"><span></span><div><b>پذیرش توسط درگاه پیامک</b>{% if sms_job.sent_at %}<time datetime="{{ sms_job.sent_at|date:'c' }}">{{ sms_job.sent_at|jalali_datetime }}</time>{% else %}<small>{% if sms_job.status == 'FAILED' %}ناموفق{% elif sms_job.status == 'CANCELLED' %}لغوشده پیش از ارسال{% else %}در انتظار ارسال{% endif %}</small>{% endif %}</div></li><li class="{% if sms_job.delivery_status == 'DELIVERED' %}is-complete{% endif %}"><span></span><div><b>تأیید رسیدن به گوشی</b>{% if sms_job.delivery_status == 'DELIVERED' and sms_job.delivery_checked_at %}<time datetime="{{ sms_job.delivery_checked_at|date:'c' }}">{{ sms_job.delivery_checked_at|jalali_datetime }}</time>{% elif sms_job.delivery_checked_at %}<small>آخرین استعلام: {{ sms_job.delivery_checked_at|jalali_datetime }}</small>{% else %}<small>{% if sms_job.sent_at %}در انتظار نتیجه اپراتور{% else %}هنوز ارسال نشده{% endif %}</small>{% endif %}</div></li></ol>{% if sms_job.delivery_description %}<p class="sms-delivery-description">{{ sms_job.delivery_description }}</p>{% endif %}{% if sms_job.last_error %}<p class="sms-delivery-error">علت دقیق: {{ sms_job.last_error }}</p>{% endif %}{% if sms_job.provider_message_id %}<small class="sms-delivery-tracking" dir="ltr">شناسه رهگیری: {{ sms_job.provider_message_id }}</small>{% endif %}{% if sms_job.sms_retryable %}<form method="post" action="{% url 'staff_tickets:sms_retry' ticket.public_id sms_job.pk %}" class="inline-retry-form">{% csrf_token %}<button type="submit" aria-label="تلاش مجدد برای ارسال این پیامک" title="تلاش مجدد"><span aria-hidden="true">↻</span> تلاش مجدد</button></form>{% endif %}</details>{% endfor %}</div></section>{% endif %}
|
||||
<section class="info-card ticket-operations"><h3>عملیات تیکت</h3>{% if perms.tickets.change_ticket %}<form method="post" action="{% url 'staff_tickets:status' ticket.public_id %}">{% csrf_token %}<label>وضعیت<select name="status">{% for value,label in statuses %}<option value="{{ value }}" {% if ticket.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button>ثبت وضعیت</button></form>{% endif %}{% if perms.tickets.change_ticket_priority %}<form method="post" action="{% url 'staff_tickets:priority' ticket.public_id %}">{% csrf_token %}<label>اولویت<select name="priority">{% for value,label in priorities %}<option value="{{ value }}" {% if ticket.priority == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="secondary">ثبت اولویت</button></form>{% endif %}{% if can_transfer %}<details><summary>انتقال تیکت</summary><form method="post" action="{% url 'staff_tickets:transfer' ticket.public_id %}">{% csrf_token %}{{ transfer_form.as_p }}<button>انتقال</button></form></details>{% endif %}{% if can_share %}<details><summary>اشتراک با کارشناس</summary><form method="post" action="{% url 'staff_tickets:share' ticket.public_id %}">{% csrf_token %}{{ share_form.as_p }}<button>ایجاد دسترسی</button></form>{% for grant in grants %}<div class="grant"><span>{{ grant.user.staff_profile.display_name }}</span><form method="post" action="{% url 'staff_tickets:revoke' ticket.public_id grant.user.public_id %}">{% csrf_token %}<button class="danger-button">لغو</button></form></div>{% endfor %}</details>{% endif %}</section>
|
||||
{% if can_delete %}<section class="info-card danger-zone"><h3>حذف کامل تیکت</h3><p class="muted">تمام پیامها، فایلها و سوابق تیکت برای همیشه حذف میشوند.</p><form method="post" action="{% url 'staff_tickets:delete' ticket.public_id %}" onsubmit="return confirm('آیا از حذف کامل و غیرقابلبازگشت این تیکت مطمئن هستید؟')">{% csrf_token %}<button type="submit" class="danger-button">حذف کامل تیکت</button></form></section>{% endif %}
|
||||
</aside>
|
||||
|
||||
+54
-2
@@ -9,6 +9,7 @@ from apps.staff.models import OrganizationalUnit, StaffProfile
|
||||
from apps.tickets.models import Ticket, TicketCategory
|
||||
from apps.tickets.services import create_ticket, claim_ticket, reply_ticket
|
||||
from apps.messaging.models import Message,MessageReadReceipt
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.customers.models import CustomerProfile
|
||||
from apps.core.persian_text import normalize_persian_text
|
||||
|
||||
@@ -256,8 +257,6 @@ def test_staff_reply_feedback_and_new_reply_action_remain_available(client):
|
||||
@pytest.mark.django_db
|
||||
def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client):
|
||||
from apps.core.templatetags.jalali import jalali_datetime
|
||||
from apps.notifications.models import NotificationJob
|
||||
|
||||
category=TicketCategory.objects.create(title="رهگیری پیامک",slug="sms-timeline")
|
||||
customer=User.objects.create_user(username="sms-timeline-customer",mobile="09120000031",account_type="CUSTOMER")
|
||||
expert=User.objects.create_user(username="sms-timeline-expert",mobile="09120000032",account_type="STAFF")
|
||||
@@ -288,6 +287,59 @@ def test_staff_ticket_sms_status_is_compact_expandable_and_time_precise(client):
|
||||
assert jalali_datetime(delivered_at) in content
|
||||
assert "123456789" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_can_manually_retry_only_failed_sms_for_the_current_ticket(client):
|
||||
category=TicketCategory.objects.create(title="تلاش مجدد پیامک",slug="manual-sms-retry")
|
||||
customer=User.objects.create_user(username="sms-retry-customer",mobile="09120000041",account_type="CUSTOMER")
|
||||
expert=User.objects.create_user(username="sms-retry-expert",mobile="09120000042",account_type="STAFF")
|
||||
outsider=User.objects.create_user(username="sms-retry-outsider",mobile="09120000043",account_type="STAFF")
|
||||
StaffProfile.objects.create(user=expert,display_name="کارشناس تلاش مجدد")
|
||||
StaffProfile.objects.create(user=outsider,display_name="کارشناس نامرتبط")
|
||||
ticket=create_ticket(customer=customer,category=category,subject="بررسی retry پیامک",selected_expert=expert,body="شرح")
|
||||
failed_job=NotificationJob.objects.get(ticket=ticket,event_type="CUSTOMER_TICKET_CREATED")
|
||||
NotificationJob.objects.filter(pk=failed_job.pk).update(
|
||||
status=NotificationJob.Status.FAILED,attempts=4,last_error="خطای درگاه",
|
||||
provider_message_id="failed-provider-id",provider_status_code="-19",
|
||||
delivery_status=NotificationJob.DeliveryStatus.ERROR,delivery_status_code="-19",
|
||||
delivery_description="ارسال قطعی ناموفق بود.",delivery_checked_at=timezone.now(),
|
||||
delivery_check_attempts=3,delivery_is_final=True,
|
||||
)
|
||||
failed_job.refresh_from_db()
|
||||
provisional_job=NotificationJob.objects.create(
|
||||
channel=NotificationJob.Channel.SMS,event_type="STAFF_REPLY",ticket=ticket,customer=customer,
|
||||
status=NotificationJob.Status.SENT,next_attempt_at=timezone.now(),dedupe_key="test:provisional-manual-retry",
|
||||
delivery_status=NotificationJob.DeliveryStatus.UNKNOWN,delivery_next_check_at=timezone.now()+timedelta(minutes=3),
|
||||
)
|
||||
retry_url=reverse("staff_tickets:sms_retry",args=[ticket.public_id,failed_job.pk])
|
||||
provisional_retry_url=reverse("staff_tickets:sms_retry",args=[ticket.public_id,provisional_job.pk])
|
||||
|
||||
client.force_login(expert)
|
||||
content=client.get(reverse("staff_tickets:detail",args=[ticket.public_id])).content.decode()
|
||||
assert retry_url in content
|
||||
assert 'aria-label="تلاش مجدد برای ارسال این پیامک"' in content
|
||||
assert provisional_retry_url not in content
|
||||
assert client.get(retry_url).status_code==405
|
||||
|
||||
client.force_login(outsider)
|
||||
assert client.post(retry_url).status_code==403
|
||||
failed_job.refresh_from_db()
|
||||
assert failed_job.status==NotificationJob.Status.FAILED
|
||||
|
||||
client.force_login(expert)
|
||||
response=client.post(retry_url,follow=True)
|
||||
assert response.status_code==200
|
||||
assert "پیامک ناموفق دوباره در صف ارسال قرار گرفت." in response.content.decode()
|
||||
failed_job.refresh_from_db(); provisional_job.refresh_from_db()
|
||||
assert failed_job.status==NotificationJob.Status.PENDING
|
||||
assert failed_job.attempts==0 and failed_job.next_attempt_at<=timezone.now()
|
||||
assert failed_job.last_error=="" and failed_job.provider_message_id=="" and failed_job.provider_status_code==""
|
||||
assert failed_job.sent_at is None and failed_job.delivery_status=="" and failed_job.delivery_status_code==""
|
||||
assert failed_job.delivery_description=="" and failed_job.delivery_checked_at is None
|
||||
assert failed_job.delivery_check_attempts==0 and failed_job.delivery_next_check_at is None
|
||||
assert failed_job.delivery_is_final is False
|
||||
assert provisional_job.status==NotificationJob.Status.SENT
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_sidebar_hides_admin_link_and_shows_developer_credit(client):
|
||||
expert=User.objects.create_superuser(username="sidebar-admin",mobile="09384999655",password="test-pass",account_type="STAFF")
|
||||
|
||||
Reference in new issue
Block a user