Compare commits

...
2 Commits
Author SHA1 Message Date
javad 99c25a8216 improve bazdid 5 2026-10-21 16:34:38 +03:30
javad 825f2a5973 improve bazdid 4 2026-10-21 16:34:13 +03:30
14 changed files with 586 additions and 16 deletions

No files matched your search

@@ -97,7 +97,8 @@ class Command(BaseCommand):
# آمار نظرسنجی‌ها
nazar_stats = Action.objects.filter(
nazarDT__range=(today_start, today_end),
grade__gt=0
grade__gt=0,
deleted_at__isnull=True,
).aggregate(
avg_grade=Avg('grade'),
total_count=Count('id')
@@ -0,0 +1,26 @@
# Generated by Django 3.2.25 on 2026-10-21 12:31
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0079_customer_visit_hold'),
]
operations = [
migrations.AddField(
model_name='action',
name='deleted_at',
field=models.DateTimeField(blank=True, default=None, help_text='اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).', null=True, verbose_name='زمان حذف'),
),
migrations.AddField(
model_name='action',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_actions', to=settings.AUTH_USER_MODEL, verbose_name='حذف\u200cکننده'),
),
]
+19
View File
@@ -67,6 +67,25 @@ class Action(models.Model):
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
# --- حسابرسی «حذف» (soft-delete از طریق status='cancell') ---
# چرا ستون جدا؟ چون ``cancell`` از قبل معنای «لغو برنامه» را دارد و در گزارش‌ها
# هم استفاده می‌شود. با این دو ستون می‌توانیم «حذف توسط کاربر» را از «لغو
# عادی» تفکیک کنیم، بدون آنکه منطق محاسبات (که فقط waite/finish را می‌بیند)
# دست بخورد.
deleted_at = models.DateTimeField(
null=True, blank=True, default=None, verbose_name="زمان حذف",
help_text="اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).",
)
deleted_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name="deleted_actions", verbose_name="حذف‌کننده",
)
@property
def is_deleted(self):
"""آیا این اقدام با «حذف» کنار گذاشته شده است؟ (نه لغو عادی)"""
return self.deleted_at is not None
class Meta:
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
@@ -4,7 +4,7 @@
{% block BodyCustomer1 %}
{% include 'admin_panel/customerActionInc.html' %}
{% include 'admin_panel/customerActionInc.html' with show_delete=True %}
<!--
<div id="action" class="column is-12-mobile is-12-desktop ">
@@ -66,7 +66,8 @@
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4">
<div class="pr-4 pl-4 pb-4 pt-4 ax-cardrow">
<span class="ax-cardrow-main">
{% ifequal action.status 'finish' %}
<img class="" style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/check.png' %}">
{% endifequal %}
@@ -81,6 +82,27 @@
<span class="is-bazdid">{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}</span>
<span class="pr-3">{{ action.desc }}</span>
</span>
{% if show_delete %}
{% if action.nazarDT %}
<span class="ax-del is-locked" title="این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 0 1 6 0v3H9z"/></svg>
<span class="ax-del-text">نظرسنجی</span>
</span>
{% else %}
<form method="post" class="ax-del-form"
action="{% url 'admin_panel:customerActionDelete' action.customer.id action.id %}">
{% csrf_token %}
<button type="submit" class="ax-del is-danger"
title="حذف این اقدام"
data-type="{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}"
onclick="return confirm('این «' + (this.dataset.type || 'اقدام') + '» حذف شود؟\n\nاقدام از فهرست انتظار/وظایف خارج می‌شود و پیامک ارسال‌نشدهٔ مرتبط لغو می‌گردد. سابقه برای حسابرسی باقی می‌ماند.');">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2zm-3 6h12l-1 12a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9zm4 3v8h2v-8h-2zm4 0v8h2v-8h-2z"/></svg>
<span class="ax-del-text">حذف</span>
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
<div class="column is-12-mobile is-12-desktop actionBoxOpenB">
@@ -166,6 +188,42 @@
</div>
<style>
/* ===== دکمهٔ «حذف» روی کارت اقدام (mobile first) ===== */
.ax-cardrow {
display: flex; align-items: flex-start; justify-content: space-between;
gap: .5rem; flex-wrap: nowrap;
}
.ax-cardrow-main { flex: 1 1 auto; min-width: 0; }
.ax-del-form { margin: 0; flex: 0 0 auto; }
.ax-del {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .45rem; border-radius: .375rem; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: .68rem; font-weight: 700;
cursor: pointer; line-height: 1.5; white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease,
color .15s ease, box-shadow .15s ease;
}
.ax-del svg { width: 13px; height: 13px; fill: currentColor; flex: 0 0 auto; }
.ax-del:hover { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.ax-del:focus-visible {
outline: 2px solid #fca5a5; outline-offset: 1px;
}
.ax-del.is-danger { border-color: #fee2e2; color: #dc2626; background: #fff; }
.ax-del.is-danger:hover {
background: #dc2626; border-color: #dc2626; color: #fff;
box-shadow: 0 1px 6px rgba(220, 38, 38, .25);
}
/* «نظرسنجی دارد» ⇒ دکمه غیرفعال (قاعدهٔ محصولی) */
.ax-del.is-locked {
cursor: not-allowed; color: #9ca3af; background: #f9fafb;
border-color: #e5e7eb;
}
.ax-del.is-locked:hover { background: #f9fafb; border-color: #e5e7eb; color: #9ca3af; }
@media (max-width: 480px) {
.ax-del-text { display: none; } /* در موبایل فقط آیکون، برای صرفه‌جویی فضا */
.ax-del { padding: .25rem; }
}
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
همهٔ کلاس‌ها با پیشوند ax- هستند تا با سایر بخش‌ها تداخل نکنند. */
#ActionBox .ax-card {
@@ -473,6 +531,16 @@
var holdAutoChecked = false;
var holdConfirmed = false;
// لایهٔ دوم محافظت: فرم «حذف» داخل کارتِ قابل‌کلیک است؛ رویداد را همان‌جا
// متوقف می‌کنیم تا به هندلرِ بازکردن مودالِ ویرایش نرسد.
// بایند «مستقیم» (نه delegated) چون این بلوک با هر ردیف تکرار می‌شود؛
// نام‌فضا (axdel) + off مانع از انباشته‌شدن هندلرها روی ردیف‌های تکراری می‌شود.
$('.ax-del-form, .ax-del')
.off('.axdel')
.on('click.axdel mousedown.axdel', function (e) {
e.stopPropagation();
});
// پیکر «تا تاریخ پیگیری نشود» را ریست می‌کند.
// نکته: pDatepicker یک Model برمی‌گرداند؛ اگر سلکتور هیچ المانی پیدا نکند
// مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث
@@ -596,7 +664,12 @@
showHoldConfirm();
});
$(".actionBoxOpenB").click(function(){
$(".actionBoxOpenB").click(function(event){
// کلیک روی کنترل‌های «حذف» نباید مودال ویرایش را باز کند.
// (دکمه داخل کارت است و رویداد به والد بالا می‌رفت.)
if ($(event.target).closest('.ax-del-form, .ax-del').length) {
return;
}
isEditMode = true;
$("#sabtBtn05").val("ویرایش");
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
+1
View File
@@ -37,6 +37,7 @@ urlpatterns = [
path('customer/<int:c_id>/nazar/', views.customerNazar, name='customerNazar'),
path('customer/<int:c_id>/action/new', views.customerNewAction, name='customerNewAction'),
path('customer/<int:c_id>/action/<int:action_id>/edit', views.customerEditAction, name='customerEditAction'),
path('customer/<int:c_id>/action/<int:action_id>/delete', views.customerActionDelete, name='customerActionDelete'),
path('customer/<int:c_id>/del', views.customerDel, name='customerDel'),
path('customer/<int:c_id>/factors/', views.customerFactors, name='customerFactors'),
path('customer/<int:c_id>/factor/<int:f_id>', views.customerFactor, name='customerFactor'),
+47 -6
View File
@@ -12,9 +12,10 @@ from django.utils import timezone
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
as_aware, clear_visit_hold, default_hold_until,
normalize_finish_datetime, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
ActionDeleteError, as_aware, clear_visit_hold,
default_hold_until, normalize_finish_datetime,
normalize_hold_until, refresh_last_bazdid,
set_visit_hold, soft_delete_action,
validate_action_payload)
@@ -27,8 +28,12 @@ def customerAction(request, c_id):
filter_type = request.GET.get('actionType', 'all')
filter_status = request.GET.get('status', 'all')
# کوئری پایه برای اقدامات این مشتری
# کوئری پایه برای اقدامات این مشتری.
# اقدام‌های حذف‌شده (soft-delete) به‌طور پیش‌فرض نمایش داده نمی‌شوند؛ با
# فیلتر صریح `status=cancell` می‌توان آن‌ها را دید (برای حسابرسی).
actions = Action.objects.filter(customer=customer)
if filter_status != 'cancell':
actions = actions.filter(deleted_at__isnull=True)
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
if filter_type != 'all':
@@ -57,7 +62,9 @@ def customerAction(request, c_id):
@login_required(login_url='/panel/p_login/')
def customerNazar(request ,c_id):
customer = Customer.objects.get(id=c_id)
actions = Action.objects.filter(customer=customer, nazarDT__isnull=False).order_by("-createdDT")
actions = Action.objects.filter(
customer=customer, nazarDT__isnull=False, deleted_at__isnull=True,
).order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/customer/nazar.html', data)
@@ -224,9 +231,43 @@ def customerEditAction(request ,c_id, action_id):
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@login_required(login_url='/panel/p_login/')
def customerActionDelete(request, c_id, action_id):
"""«حذف» یک اقدام (soft-delete).
فقط POST مجاز است (تغییر وضعیت داده). اقدام فیزیکی حذف **نمی‌شود**؛
``status`` به ``cancell`` می‌رود و ``deleted_at``/``deleted_by`` پر می‌شوند.
چرا soft-delete؟ چون سابقهٔ CRM باید قابل حسابرسی بماند و منطق محاسبات
(entezar / ActionList / داشبورد) از قبل فقط ``waite``/``finish`` را می‌بیند؛
پس همین مقدار، اقدام را از همهٔ خروجی‌ها کنار می‌گذارد.
"""
customer_obj = get_object_or_404(Customer, id=c_id)
action = get_object_or_404(Action, id=action_id, customer=customer_obj)
back_url = '/panel/customer/%d/action' % customer_obj.pk
if request.method != 'POST':
# دسترسی مستقیم با GET نباید چیزی را عوض کند.
return redirect(back_url)
try:
cancelled = soft_delete_action(action, user=request.user)
except ActionDeleteError as error:
messages.error(request, str(error))
return redirect(back_url)
label = action.get_actionType_display() or 'اقدام'
message = '«%s» حذف شد و از فهرست‌های انتظار/وظایف خارج شد.' % label
if cancelled:
message += ' %d پیامک ارسال‌نشدهٔ مرتبط هم لغو شد.' % cancelled
messages.success(request, message)
return redirect(back_url)
@login_required(login_url='/panel/p_login/')
def ActionListAll(request):
actions = Action.objects.filter().order_by("-id")
# اقدام‌های حذف‌شده (soft-delete) در فهرست «همه» نمایش داده نمی‌شوند.
actions = Action.objects.filter(deleted_at__isnull=True).order_by("-id")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
+7 -4
View File
@@ -156,9 +156,10 @@ def report_debtors_no_vosol(request):
).values('customer').annotate(c=Count('id')).values('c')
# استخراج آخرین سابقه اکشن وصول
# نکته: اقدام حذف‌شده/لغوشده نباید «آخرین اقدام وصول» به‌حساب بیاید.
latest_vosol_qs = Action.objects.filter(
customer=OuterRef('pk'), actionType='vosol'
).order_by('-createdDT')
customer=OuterRef('pk'), actionType='vosol',
).exclude(status='cancell').order_by('-createdDT')
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
base_qs = Customer.objects.filter(is_active=True).annotate(
@@ -939,7 +940,8 @@ def rezayat_dashboard(request):
# دریافت تمامی نظرسنجی‌های ثبت شده معتبر (که تاریخ نظرسنجی دارند)
valid_surveys = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15, 20]
grade__in=[5, 10, 15, 20],
deleted_at__isnull=True,
)
# ۱. محاسبات شاخص‌های کلیدی (KPIs)
@@ -965,7 +967,8 @@ def rezayat_dashboard(request):
# ۴. واکشی لیست مشتریان نیازمند پیگیری (شامل متوسط ۱۵، ضعیف ۱۰ و بد ۵)
low_score_actions = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15] # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
grade__in=[5, 10, 15], # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
deleted_at__isnull=True,
).order_by('-nazarDT')
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
+3 -1
View File
@@ -40,7 +40,9 @@ SENT_STATUSES = [
# نیازمند اقدام را نشان می‌دهد؛ پیامکِ ارسال‌شده از صف بیرون می‌رود و سابقه‌اش در
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده می‌شود. این کار جلوی حذفِ ناخواستهٔ
# تاریخچه از روی صف را می‌گیرد و صف را به فهرستِ کاریِ تمیز تبدیل می‌کند.
DONE_STATUSES = [SMSQueue.Status.SENT]
# پیامک «لغوشده» هم دیگر کاری برای انجام ندارد (مثلاً چون اقدامِ مرتبطش حذف شده)،
# پس از صف بیرون می‌رود.
DONE_STATUSES = [SMSQueue.Status.SENT, SMSQueue.Status.CANCELLED]
def _notice(url, code, **extra):
+86
View File
@@ -936,3 +936,89 @@ def normalize_finish_datetime(value, *, now=None):
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار می‌گیرد (نه ``None``)."""
value = as_aware(value)
return value if value is not None else _now(now)
# ---------------------------------------------------------------------------
# حذف (soft-delete) یک اقدام
# ---------------------------------------------------------------------------
class ActionDeleteError(Exception):
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
def cancel_pending_sms_for_action(customer, *, now=None):
"""لغو پیامک‌های **ارسال‌نشدهٔ** «بازدید ثبت شد» همان مشتری.
چرا با ``action`` پیدا نمی‌کنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
دیگر وجود ندارد، پیامک‌های معلق همان مشتری با نوع ``visit_created`` لغو
می‌شوند. پیامک‌های **ارسال‌شده** دست نمی‌خورند (سابقه است).
خروجی: تعداد رکوردهای لغوشده.
"""
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
from .models import SMSQueue, SMSType
if customer is None or customer.pk is None:
return 0
now = _now(now)
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
return (
SMSQueue.objects.filter(
customer_id=customer.pk,
sms_type=SMSType.VISIT_CREATED,
status__in=pending,
)
.exclude(status=SMSQueue.Status.SENT)
.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
)
def soft_delete_action(action, *, user=None, now=None):
"""«حذف» یک اقدام: soft-delete با ``status='cancell'`` + حسابرسی.
چرا ``cancell`` و نه ستون ``is_deleted``؟ چون **تمام** محاسبات
(:func:`last_visit_map`، :func:`cycle_anchor_map`،
:func:`appointment_sources_map`، :func:`followups_on_date`،
:func:`overdue_followups`) از قبل فقط ``waite``/``finish`` را می‌بینند؛ پس
همین یک مقدار، اقدام را از همهٔ خروجی‌ها حذف می‌کند — بدون Manager سفارشی،
بدون تغییر ایندکس پوشا و بدون دست‌زدن به ۳۱ کوئری.
پیامدهای جانبی که همین‌جا انجام می‌شوند:
۱. ``last_bazdid_dt`` بازمحاسبه می‌شود (کش کهنه نماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بی‌اثر می‌شود
(قاعدهٔ «ستون، مرجع نیست»).
۳. پیامک‌های **ارسال‌نشده**ی همان مشتری لغو می‌شوند.
ممنوعیت: اقدامی که نظرسنجی ثبت‌شده دارد (``nazarDT`` پر است) حذف نمی‌شود؛
زیرا هیچ آرشیو/کپی‌ای وجود ندارد و گزارش رضایت‌سنجی برای همیشه تغییر می‌کرد.
"""
if action is None or action.pk is None:
raise ActionDeleteError("اقدام معتبر نیست.")
if action.status == Action.Status.CANCELL and action.deleted_at is not None:
raise ActionDeleteError("این اقدام قبلاً حذف شده است.")
if action.nazarDT is not None:
raise ActionDeleteError(
"این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست. "
"برای کنار گذاشتن آن، از ویرایش و تغییر وضعیت استفاده کنید."
)
now = _now(now)
customer = action.customer
with transaction.atomic():
Action.objects.filter(pk=action.pk).update(
status=Action.Status.CANCELL,
deleted_at=now,
deleted_by=user if getattr(user, "pk", None) else None,
)
action.status = Action.Status.CANCELL
action.deleted_at = now
if customer is not None:
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
cancelled = cancel_pending_sms_for_action(customer, now=now)
else:
cancelled = 0
return cancelled
+56
View File
@@ -319,6 +319,62 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید)
---
## ۶.۱ حذف یک اقدام (soft-delete)
دکمهٔ «حذف» فقط در صفحهٔ اقدام‌های مشتری (`/panel/customer/<id>/action`) نمایش
داده می‌شود و **هیچ رکوردی فیزیکی حذف نمی‌شود**؛ به‌جای آن:
| ستون | مقدار |
|---|---|
| `status` | `cancell` |
| `deleted_at` | زمان حذف |
| `deleted_by` | کاربر حذف‌کننده |
### چرا `cancell` و نه ستون `is_deleted`؟
همهٔ محاسبات (`last_visit_map`، `cycle_anchor_map`، `appointment_sources_map`،
`followups_on_date`، `overdue_followups`) از قبل فقط `waite`/`finish` را
می‌بینند؛ و `ActionList*` و `ActionCount` هم با `status='waite'` فیلتر می‌شوند.
پس همین یک مقدار، اقدام را از **همهٔ** خروجی‌ها خارج می‌کند — بدون Manager
سفارشی، بدون تغییر ایندکس پوشا و بدون دست‌زدن به ۳۱ کوئری.
### قاعدهٔ محصولی: اقدام دارای نظرسنجی حذف نمی‌شود
اگر `nazarDT` پر باشد (نظرسنجی ثبت‌شده)، حذف انجام **نمی‌شود**؛ چون هیچ
آرشیو/کپی‌ای وجود ندارد و گزارش رضایت‌سنجی برای همیشه تغییر می‌کرد. در UI
دکمه به حالت قفل‌شده با برچسب «نظرسنجی» درمی‌آید.
### پیامدهای جانبی (در همان تراکنش)
۱. `last_bazdid_dt` بازمحاسبه می‌شود (کش کهنه نمی‌ماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بی‌اثر می‌شود.
۳. پیامک‌های **ارسال‌نشدهٔ** «بازدید ثبت شد» همان مشتری به `cancelled` می‌روند
(پیامک‌های ارسال‌شده دست نمی‌خورند).
### اثر روی مصرف‌کننده‌ها
| بخش | اثر |
|---|---|
| `/panel/entezar/` (همهٔ نماها) | اقدام کاملاً نادیده گرفته می‌شود |
| `/panel/ActionList/<u>/today` | از صف وظایف خارج می‌شود |
| `ActionCount` داشبورد | بلافاصله کم می‌شود |
| فهرست اقدام‌های مشتری | به‌طور پیش‌فرض پنهان (با `?status=cancell` قابل مشاهده برای حسابرسی) |
| `/panel/customer/<id>/nazar/` | پنهان |
| `/panel/ActionList/all` | پنهان |
| گزارش وصول (`finance.py`) | آخرین اقدام وصول دیگر آن را نمی‌بیند |
| داشبورد رضایت‌سنجی و گزارش روزانه | `deleted_at__isnull=True` |
| صف پیامک | `CANCELLED` در `DONE_STATUSES` ⇒ از صف بیرون می‌رود |
### امنیت
- فقط **POST** مجاز است؛ درخواست GET ریدایرکت می‌شود و چیزی را عوض نمی‌کند.
- `get_object_or_404(Action, id=..., customer=customer)` ⇒ حذف اقدام مشتری دیگر
ممکن نیست (۴۰۴).
- توکن CSRF در فرم اجباری است.
- تأیید دو‌مرحله‌ای پیش از حذف (نوع اقدام در متن تأیید نمایش داده می‌شود).
---
## ۷. سناریوهای تست‌شده
| # | سناریو | نتیجهٔ انتظار | وضعیت |
+1 -1
View File
@@ -82,7 +82,7 @@ DATABASES = {
}
}
#
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
+2
View File
@@ -36,6 +36,7 @@ EXPECTED_URL_NAMES = {
'customerNazar',
'customerNewAction',
'customerEditAction',
'customerActionDelete',
'customerDel',
'customerFactors',
'customerFactor',
@@ -99,6 +100,7 @@ PARAMETERIZED = {
'customerNazar': {'c_id': 1},
'customerNewAction': {'c_id': 1},
'customerEditAction': {'c_id': 1, 'action_id': 1},
'customerActionDelete': {'c_id': 1, 'action_id': 1},
'customerDel': {'c_id': 1},
'customerFactors': {'c_id': 1},
'customerFactor': {'c_id': 1, 'f_id': 1},
+260
View File
@@ -2106,3 +2106,263 @@ class TodayFollowupTests(TestCase):
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-overdue-alert"', html)
class ActionSoftDeleteTests(TestCase):
"""حذف (soft-delete) یک اقدام: status='cancell' + حسابرسی.
قواعد قفل‌شده:
* حذف ⇒ اقدام از `entezar`، `ActionList` و داشبورد خارج می‌شود.
* رکورد فیزیکی حذف **نمی‌شود** (سابقه برای حسابرسی می‌ماند).
* اقدام دارای نظرسنجی قابل حذف نیست.
* پیامک ارسال‌نشدهٔ مرتبط لغو می‌شود.
* `last_bazdid_dt` بازمحاسبه می‌شود.
* GET بی‌اثر است؛ URL فقط POST می‌پذیرد.
"""
def setUp(self):
self.user = make_user('deleter', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('مشتری حذف')
Customer.objects.filter(pk=self.customer.pk).update(
installDate=timezone.localdate() - timedelta(days=500))
self.customer.refresh_from_db()
def _action(self, **kwargs):
defaults = dict(customer=self.customer, actionType='hamahangi',
status='finish', actionDateTime=timezone.now(),
finishDateTime=timezone.now())
defaults.update(kwargs)
return Action.objects.create(**defaults)
def _delete(self, action):
return self.client.post(
reverse('admin_panel:customerActionDelete',
args=[self.customer.pk, action.pk]))
def _followup_ids(self):
response = self.client.get('/panel/entezar/?view=today')
return {o.id for o in response.context['customers']}
# ---------- رفتار اصلی ----------
def test_delete_marks_cancelled_with_audit(self):
action = self._action()
self._delete(action)
action.refresh_from_db()
self.assertEqual(action.status, 'cancell')
self.assertIsNotNone(action.deleted_at)
self.assertEqual(action.deleted_by_id, self.user.pk)
self.assertTrue(action.is_deleted)
def test_record_is_not_physically_deleted(self):
action = self._action()
pk = action.pk
self._delete(action)
self.assertTrue(Action.objects.filter(pk=pk).exists(),
'رکورد باید برای حسابرسی باقی بماند')
def test_deleted_action_leaves_followup_list(self):
action = self._action()
self.assertIn(self.customer.id, self._followup_ids())
self._delete(action)
self.assertNotIn(self.customer.id, self._followup_ids())
def test_get_request_does_not_delete(self):
action = self._action()
response = self.client.get(reverse(
'admin_panel:customerActionDelete',
args=[self.customer.pk, action.pk]))
self.assertEqual(response.status_code, 302)
action.refresh_from_db()
self.assertIsNone(action.deleted_at)
def test_cannot_delete_twice(self):
action = self._action()
self._delete(action)
# بار دوم باید خطا بدهد ولی کرش نکند
response = self._delete(action)
self.assertEqual(response.status_code, 302)
action.refresh_from_db()
self.assertEqual(action.status, 'cancell')
# ---------- ممنوعیت نظرسنجی ----------
def test_action_with_survey_cannot_be_deleted(self):
action = self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
self._delete(action)
action.refresh_from_db()
self.assertEqual(action.status, 'finish', 'نباید وضعیت عوض شود')
self.assertIsNone(action.deleted_at)
def test_delete_blocked_message_shown(self):
action = self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
response = self._delete(action)
messages_list = list(response.wsgi_request._messages)
self.assertTrue(any('نظرسنجی' in str(m) for m in messages_list))
# ---------- پیامک ----------
def test_pending_visit_sms_is_cancelled(self):
from admin_panel.models import SMSQueue, SMSTemplate
SMSTemplate.objects.update_or_create(
sms_type='visit_created',
defaults={'text': 'بازدید {name}', 'is_enabled': True})
PhoneNumber.objects.create(customer=self.customer, title='موبایل',
phone_number='09120000000')
Customer.objects.filter(pk=self.customer.pk).update(phone1='09120000000')
self.customer.refresh_from_db()
from admin_panel.sms.triggers import notify_visit_scheduled
action = self._action(actionType='bazdid', status='waite')
notify_visit_scheduled(self.customer, action, created_by=self.user)
self.assertEqual(
SMSQueue.objects.filter(customer=self.customer,
sms_type='visit_created').count(), 1)
self._delete(action)
self.assertEqual(
SMSQueue.objects.filter(customer=self.customer,
sms_type='visit_created',
status=SMSQueue.Status.CANCELLED).count(), 1)
# ---------- بازمحاسبهٔ ستون ----------
def test_last_bazdid_recalculated_after_delete(self):
old_visit = self._action(actionType='bazdid',
actionDateTime=at(-400), finishDateTime=at(-400))
recent = self._action(actionType='bazdid',
actionDateTime=at(-2), finishDateTime=at(-2))
refresh_last_bazdid(self.customer, reason='create', user=self.user)
self.customer.refresh_from_db()
self.assertEqual(self.customer.last_bazdid_dt.date(), at(-2).date())
self._delete(recent)
self.customer.refresh_from_db()
self.assertEqual(self.customer.last_bazdid_dt.date(), at(-400).date())
self.assertTrue(Action.objects.filter(pk=old_visit.pk).exists())
# ---------- سایر نماها ----------
def test_deleted_action_hidden_from_customer_action_list(self):
action = self._action()
self._delete(action)
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertNotIn('action_id=\'%d\'' % action.pk, html)
def test_deleted_action_visible_with_explicit_filter(self):
"""با فیلتر صریح status=cancell باید دیده شود (حسابرسی)."""
action = self._action()
self._delete(action)
html = self.client.get(
'/panel/customer/%d/action?status=cancell' % self.customer.pk
).content.decode()
self.assertIn('action_id=\'%d\'' % action.pk, html)
def test_deleted_action_hidden_from_actionlist_all(self):
action = self._action(status='waite', forUser=self.user)
self._delete(action)
response = self.client.get(reverse('admin_panel:ActionListAll'))
ids = {a.pk for a in response.context['actions']}
self.assertNotIn(action.pk, ids)
def test_deleted_action_hidden_from_nazar_page(self):
action = self._action(nazarDT=None)
self._delete(action)
response = self.client.get(
'/panel/customer/%d/nazar/' % self.customer.pk)
ids = {a.pk for a in response.context['actions']}
self.assertNotIn(action.pk, ids)
class ActionDeleteUITests(TestCase):
"""دکمهٔ حذف روی صفحهٔ اقدام‌ها: حضور، قفل نظرسنجی و اسکوپ."""
def setUp(self):
self.user = make_user('deleteUi', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('UI حذف')
Customer.objects.filter(pk=self.customer.pk).update(
installDate=timezone.localdate() - timedelta(days=500))
self.customer.refresh_from_db()
def _action(self, **kwargs):
defaults = dict(customer=self.customer, actionType='hamahangi',
status='waite', actionDateTime=timezone.now())
defaults.update(kwargs)
return Action.objects.create(**defaults)
def test_delete_button_rendered_on_customer_action_page(self):
action = self._action()
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn('/action/%d/delete' % action.pk, html)
self.assertIn('ax-del is-danger', html)
self.assertIn('method="post"', html)
self.assertIn('csrfmiddlewaretoken', html)
# تأیید پیش از حذف
self.assertIn('confirm(', html)
def test_delete_button_absent_on_actionlist(self):
action = self._action(forUser=self.user)
html = self.client.get(
'/panel/ActionList/%d/today' % self.user.pk).content.decode()
self.assertNotIn('ax-del is-danger', html,
'دکمهٔ حذف نباید در فهرست وظایف ظاهر شود')
self.assertNotIn('/action/%d/delete' % action.pk, html)
def test_survey_action_shows_locked_state(self):
self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn('ax-del is-locked', html)
self.assertNotIn('ax-del is-danger', html)
def test_deleted_action_hidden_by_default(self):
kept = self._action()
gone = self._action()
self.client.post(reverse('admin_panel:customerActionDelete',
args=[self.customer.pk, gone.pk]))
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn("/action/%d/delete" % kept.pk, html)
self.assertNotIn("/action/%d/delete" % gone.pk, html)
class DeleteButtonDoesNotOpenModalTests(TestCase):
"""کلیک روی «حذف» نباید مودال ویرایش را باز کند.
دکمهٔ حذف داخل کارتی است که کلاس ``actionBoxOpenB`` دارد و هندلرِ بازکردنِ
مودال روی همان کارت بسته شده است؛ بدون محافظ، رویداد به والد می‌رسد و
مودالِ ویرایش هم باز می‌شد. دو لایه محافظ گذاشته شده است:
۱) گاردِ ``closest('.ax-del-form, .ax-del')`` در هندلرِ ``actionBoxOpenB``
۲) ``stopPropagation`` روی خودِ فرم/دکمهٔ حذف
"""
def setUp(self):
self.user = make_user('noModalOnDelete', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('حذف بدون مودال')
self.action = make_visit(self.customer, action_dt=at(-1),
finish_dt=at(-1), action_type='hamahangi')
self.html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
def test_modal_handler_guards_delete_controls(self):
self.assertIn("closest('.ax-del-form, .ax-del')", self.html)
def test_delete_controls_stop_propagation(self):
self.assertIn('stopPropagation', self.html)
self.assertRegex(
self.html,
r"\$\('\.ax-del-form, \.ax-del'\)"
r"\s*\n\s*\.off\('\.axdel'\)"
r"\s*\n\s*\.on\('click\.axdel mousedown\.axdel'",
)
def test_guard_precedes_modal_open_logic(self):
"""گارد باید پیش از ``isEditMode = true`` اجرا شود."""
guard = self.html.index("closest('.ax-del-form, .ax-del')")
opener = self.html.index('isEditMode = true;', guard - 900)
self.assertLess(guard, opener)
def test_delete_form_has_csrf_and_post(self):
self.assertIn('ax-del-form', self.html)
self.assertIn('csrfmiddlewaretoken', self.html)