improve bazdid 4
This commit is contained in:
1 parent
8fd881e0ea
commit
825f2a5973
13 files changed
+585
-15
No files matched your search
@@ -97,7 +97,8 @@ class Command(BaseCommand):
|
|||||||
# آمار نظرسنجیها
|
# آمار نظرسنجیها
|
||||||
nazar_stats = Action.objects.filter(
|
nazar_stats = Action.objects.filter(
|
||||||
nazarDT__range=(today_start, today_end),
|
nazarDT__range=(today_start, today_end),
|
||||||
grade__gt=0
|
grade__gt=0,
|
||||||
|
deleted_at__isnull=True,
|
||||||
).aggregate(
|
).aggregate(
|
||||||
avg_grade=Avg('grade'),
|
avg_grade=Avg('grade'),
|
||||||
total_count=Count('id')
|
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کننده'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -67,6 +67,25 @@ class Action(models.Model):
|
|||||||
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
|
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
|
||||||
grade = models.IntegerField(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:
|
class Meta:
|
||||||
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
|
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
|
||||||
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
|
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block BodyCustomer1 %}
|
{% 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 ">
|
<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 %}'
|
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 }}">
|
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
|
||||||
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
|
<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' %}
|
{% ifequal action.status 'finish' %}
|
||||||
<img class="" style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/check.png' %}">
|
<img class="" style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/check.png' %}">
|
||||||
{% endifequal %}
|
{% 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="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 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>
|
</div>
|
||||||
<div class="column is-12-mobile is-12-desktop actionBoxOpenB">
|
<div class="column is-12-mobile is-12-desktop actionBoxOpenB">
|
||||||
@@ -166,6 +188,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 =====
|
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
|
||||||
همهٔ کلاسها با پیشوند ax- هستند تا با سایر بخشها تداخل نکنند. */
|
همهٔ کلاسها با پیشوند ax- هستند تا با سایر بخشها تداخل نکنند. */
|
||||||
#ActionBox .ax-card {
|
#ActionBox .ax-card {
|
||||||
@@ -473,6 +531,16 @@
|
|||||||
var holdAutoChecked = false;
|
var holdAutoChecked = false;
|
||||||
var holdConfirmed = 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 برمیگرداند؛ اگر سلکتور هیچ المانی پیدا نکند
|
// نکته: pDatepicker یک Model برمیگرداند؛ اگر سلکتور هیچ المانی پیدا نکند
|
||||||
// مقدار undefined میشود. بدون این محافظ، یک ناهمخوانی نام کلاس باعث
|
// مقدار undefined میشود. بدون این محافظ، یک ناهمخوانی نام کلاس باعث
|
||||||
@@ -596,7 +664,12 @@
|
|||||||
showHoldConfirm();
|
showHoldConfirm();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".actionBoxOpenB").click(function(){
|
$(".actionBoxOpenB").click(function(event){
|
||||||
|
// کلیک روی کنترلهای «حذف» نباید مودال ویرایش را باز کند.
|
||||||
|
// (دکمه داخل کارت است و رویداد به والد بالا میرفت.)
|
||||||
|
if ($(event.target).closest('.ax-del-form, .ax-del').length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
isEditMode = true;
|
isEditMode = true;
|
||||||
$("#sabtBtn05").val("ویرایش");
|
$("#sabtBtn05").val("ویرایش");
|
||||||
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
|
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ urlpatterns = [
|
|||||||
path('customer/<int:c_id>/nazar/', views.customerNazar, name='customerNazar'),
|
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/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>/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>/del', views.customerDel, name='customerDel'),
|
||||||
path('customer/<int:c_id>/factors/', views.customerFactors, name='customerFactors'),
|
path('customer/<int:c_id>/factors/', views.customerFactors, name='customerFactors'),
|
||||||
path('customer/<int:c_id>/factor/<int:f_id>', views.customerFactor, name='customerFactor'),
|
path('customer/<int:c_id>/factor/<int:f_id>', views.customerFactor, name='customerFactor'),
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ from django.utils import timezone
|
|||||||
from ..models import Action, Customer
|
from ..models import Action, Customer
|
||||||
from ..sms.triggers import notify_visit_scheduled
|
from ..sms.triggers import notify_visit_scheduled
|
||||||
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||||
as_aware, clear_visit_hold, default_hold_until,
|
ActionDeleteError, as_aware, clear_visit_hold,
|
||||||
normalize_finish_datetime, normalize_hold_until,
|
default_hold_until, normalize_finish_datetime,
|
||||||
refresh_last_bazdid, set_visit_hold,
|
normalize_hold_until, refresh_last_bazdid,
|
||||||
|
set_visit_hold, soft_delete_action,
|
||||||
validate_action_payload)
|
validate_action_payload)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,8 +28,12 @@ def customerAction(request, c_id):
|
|||||||
filter_type = request.GET.get('actionType', 'all')
|
filter_type = request.GET.get('actionType', 'all')
|
||||||
filter_status = request.GET.get('status', 'all')
|
filter_status = request.GET.get('status', 'all')
|
||||||
|
|
||||||
# کوئری پایه برای اقدامات این مشتری
|
# کوئری پایه برای اقدامات این مشتری.
|
||||||
|
# اقدامهای حذفشده (soft-delete) بهطور پیشفرض نمایش داده نمیشوند؛ با
|
||||||
|
# فیلتر صریح `status=cancell` میتوان آنها را دید (برای حسابرسی).
|
||||||
actions = Action.objects.filter(customer=customer)
|
actions = Action.objects.filter(customer=customer)
|
||||||
|
if filter_status != 'cancell':
|
||||||
|
actions = actions.filter(deleted_at__isnull=True)
|
||||||
|
|
||||||
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
|
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
|
||||||
if filter_type != 'all':
|
if filter_type != 'all':
|
||||||
@@ -57,7 +62,9 @@ def customerAction(request, c_id):
|
|||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
def customerNazar(request ,c_id):
|
def customerNazar(request ,c_id):
|
||||||
customer = Customer.objects.get(id=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')
|
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
|
||||||
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
|
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
|
||||||
return render(request, 'admin_panel/customer/nazar.html', data)
|
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'))
|
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/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
def ActionListAll(request):
|
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')
|
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
|
||||||
data = {'actions': actions, 'allUsers': allUsers}
|
data = {'actions': actions, 'allUsers': allUsers}
|
||||||
return render(request, 'admin_panel/ActionList.html', data)
|
return render(request, 'admin_panel/ActionList.html', data)
|
||||||
|
|||||||
@@ -156,9 +156,10 @@ def report_debtors_no_vosol(request):
|
|||||||
).values('customer').annotate(c=Count('id')).values('c')
|
).values('customer').annotate(c=Count('id')).values('c')
|
||||||
|
|
||||||
# استخراج آخرین سابقه اکشن وصول
|
# استخراج آخرین سابقه اکشن وصول
|
||||||
|
# نکته: اقدام حذفشده/لغوشده نباید «آخرین اقدام وصول» بهحساب بیاید.
|
||||||
latest_vosol_qs = Action.objects.filter(
|
latest_vosol_qs = Action.objects.filter(
|
||||||
customer=OuterRef('pk'), actionType='vosol'
|
customer=OuterRef('pk'), actionType='vosol',
|
||||||
).order_by('-createdDT')
|
).exclude(status='cancell').order_by('-createdDT')
|
||||||
|
|
||||||
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
|
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
|
||||||
base_qs = Customer.objects.filter(is_active=True).annotate(
|
base_qs = Customer.objects.filter(is_active=True).annotate(
|
||||||
@@ -939,7 +940,8 @@ def rezayat_dashboard(request):
|
|||||||
# دریافت تمامی نظرسنجیهای ثبت شده معتبر (که تاریخ نظرسنجی دارند)
|
# دریافت تمامی نظرسنجیهای ثبت شده معتبر (که تاریخ نظرسنجی دارند)
|
||||||
valid_surveys = Action.objects.filter(
|
valid_surveys = Action.objects.filter(
|
||||||
nazarDT__isnull=False,
|
nazarDT__isnull=False,
|
||||||
grade__in=[5, 10, 15, 20]
|
grade__in=[5, 10, 15, 20],
|
||||||
|
deleted_at__isnull=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ۱. محاسبات شاخصهای کلیدی (KPIs)
|
# ۱. محاسبات شاخصهای کلیدی (KPIs)
|
||||||
@@ -965,7 +967,8 @@ def rezayat_dashboard(request):
|
|||||||
# ۴. واکشی لیست مشتریان نیازمند پیگیری (شامل متوسط ۱۵، ضعیف ۱۰ و بد ۵)
|
# ۴. واکشی لیست مشتریان نیازمند پیگیری (شامل متوسط ۱۵، ضعیف ۱۰ و بد ۵)
|
||||||
low_score_actions = Action.objects.filter(
|
low_score_actions = Action.objects.filter(
|
||||||
nazarDT__isnull=False,
|
nazarDT__isnull=False,
|
||||||
grade__in=[5, 10, 15] # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
|
grade__in=[5, 10, 15], # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
|
||||||
|
deleted_at__isnull=True,
|
||||||
).order_by('-nazarDT')
|
).order_by('-nazarDT')
|
||||||
|
|
||||||
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
|
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ SENT_STATUSES = [
|
|||||||
# نیازمند اقدام را نشان میدهد؛ پیامکِ ارسالشده از صف بیرون میرود و سابقهاش در
|
# نیازمند اقدام را نشان میدهد؛ پیامکِ ارسالشده از صف بیرون میرود و سابقهاش در
|
||||||
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده میشود. این کار جلوی حذفِ ناخواستهٔ
|
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده میشود. این کار جلوی حذفِ ناخواستهٔ
|
||||||
# تاریخچه از روی صف را میگیرد و صف را به فهرستِ کاریِ تمیز تبدیل میکند.
|
# تاریخچه از روی صف را میگیرد و صف را به فهرستِ کاریِ تمیز تبدیل میکند.
|
||||||
DONE_STATUSES = [SMSQueue.Status.SENT]
|
# پیامک «لغوشده» هم دیگر کاری برای انجام ندارد (مثلاً چون اقدامِ مرتبطش حذف شده)،
|
||||||
|
# پس از صف بیرون میرود.
|
||||||
|
DONE_STATUSES = [SMSQueue.Status.SENT, SMSQueue.Status.CANCELLED]
|
||||||
|
|
||||||
|
|
||||||
def _notice(url, code, **extra):
|
def _notice(url, code, **extra):
|
||||||
|
|||||||
@@ -936,3 +936,89 @@ def normalize_finish_datetime(value, *, now=None):
|
|||||||
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار میگیرد (نه ``None``)."""
|
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار میگیرد (نه ``None``)."""
|
||||||
value = as_aware(value)
|
value = as_aware(value)
|
||||||
return value if value is not None else _now(now)
|
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
|
||||||
@@ -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 در فرم اجباری است.
|
||||||
|
- تأیید دومرحلهای پیش از حذف (نوع اقدام در متن تأیید نمایش داده میشود).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ۷. سناریوهای تستشده
|
## ۷. سناریوهای تستشده
|
||||||
|
|
||||||
| # | سناریو | نتیجهٔ انتظار | وضعیت |
|
| # | سناریو | نتیجهٔ انتظار | وضعیت |
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ EXPECTED_URL_NAMES = {
|
|||||||
'customerNazar',
|
'customerNazar',
|
||||||
'customerNewAction',
|
'customerNewAction',
|
||||||
'customerEditAction',
|
'customerEditAction',
|
||||||
|
'customerActionDelete',
|
||||||
'customerDel',
|
'customerDel',
|
||||||
'customerFactors',
|
'customerFactors',
|
||||||
'customerFactor',
|
'customerFactor',
|
||||||
@@ -99,6 +100,7 @@ PARAMETERIZED = {
|
|||||||
'customerNazar': {'c_id': 1},
|
'customerNazar': {'c_id': 1},
|
||||||
'customerNewAction': {'c_id': 1},
|
'customerNewAction': {'c_id': 1},
|
||||||
'customerEditAction': {'c_id': 1, 'action_id': 1},
|
'customerEditAction': {'c_id': 1, 'action_id': 1},
|
||||||
|
'customerActionDelete': {'c_id': 1, 'action_id': 1},
|
||||||
'customerDel': {'c_id': 1},
|
'customerDel': {'c_id': 1},
|
||||||
'customerFactors': {'c_id': 1},
|
'customerFactors': {'c_id': 1},
|
||||||
'customerFactor': {'c_id': 1, 'f_id': 1},
|
'customerFactor': {'c_id': 1, 'f_id': 1},
|
||||||
|
|||||||
@@ -2106,3 +2106,263 @@ class TodayFollowupTests(TestCase):
|
|||||||
self._coordination(customer, -4)
|
self._coordination(customer, -4)
|
||||||
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
|
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
|
||||||
self.assertNotIn('class="ez-overdue-alert"', html)
|
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)
|
||||||
Reference in new issue
Block a user