improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 15:13:35 +03:30
1 parent f86d060e4d
commit 8fd881e0ea
10 files changed
+1752 -320

No files matched your search

+543 -29
View File
@@ -24,6 +24,7 @@ from django.urls import reverse
from django.utils import timezone
from admin_panel.models import Action, Customer, PhoneNumber
from admin_panel.views.customers import ENTEZAR_PAGE_SIZE
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
last_visit_map, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
@@ -329,7 +330,9 @@ class EntezarScannerConsistencyTests(TestCase):
def _entezar_ids(self):
self.client.force_login(self.user)
response = self.client.get('/panel/entezar/')
# نمای صریح «تعیین تکلیف نشده» — همان چیزی که اسکنر پیامک با آن
# هم‌تراز است. (پیش‌فرض صفحه اکنون «نیازمند پیگیری امروز» است.)
response = self.client.get('/panel/entezar/?view=uncoordinated')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
@@ -607,7 +610,8 @@ class NextAppointmentTests(TestCase):
return customer
def _entezar_ids(self):
response = self.client.get('/panel/entezar/')
# نمای صریح «تعیین تکلیف نشده» (پیش‌فرض صفحه اکنون «پیگیری امروز» است).
response = self.client.get('/panel/entezar/?view=uncoordinated')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
@@ -783,13 +787,18 @@ class VisitHoldTests(TestCase):
customer.refresh_from_db()
return customer
def _entezar(self, view=''):
def _entezar(self, view='uncoordinated'):
"""پاسخ صفحه؛ پیش‌فرض این کلاس «تعیین تکلیف نشده» است.
(پیش‌فرض خودِ صفحه اکنون «نیازمند پیگیری امروز» است و در
``TodayFollowupTests`` جداگانه پوشش داده می‌شود.)
"""
url = '/panel/entezar/' + (f'?view={view}' if view else '')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
return response
def _ids(self, view=''):
def _ids(self, view='uncoordinated'):
return {c.id for c in self._entezar(view).context['customers']}
def _post_action(self, customer, **payload):
@@ -1141,8 +1150,11 @@ class VisitHoldTests(TestCase):
def test_invalid_view_falls_back_to_default(self):
a = self._customer('فقط الف')
self.assertEqual(self._ids('nonsense'), {a.pk})
self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated')
# پیش‌فرض صفحه «نیازمند پیگیری امروز» است؛ مشتری بدون موعد در آن نیست،
# ولی در نمای «تعیین تکلیف نشده» دیده می‌شود.
self.assertEqual(self._entezar('nonsense').context['view'], 'today')
self.assertEqual(self._ids('nonsense'), set())
self.assertIn(a.pk, self._ids('uncoordinated'))
def test_card_number_matches_filtered_list(self):
"""عدد هر کارت باید با تعداد ردیف‌های همان فیلتر یکی باشد.
@@ -1216,15 +1228,54 @@ class VisitHoldTests(TestCase):
# سرِ نوار آمار + توضیح «عقب‌افتاده»
self.assertIn('ez-stats-title', html)
self.assertIn('ez-stats-hint', html)
# پنج توضیح برای کارت‌های بدون‌قرار/با‌قرار/… و ششمی برای هماهنگی
self.assertEqual(html.count('class="ez-desc"'), 6)
for phrase in ('هنوز وقت بازدید تعیین نشده',
'وقت بازدیدشان مشخص است',
'تعویق دستی از فرم هماهنگی',
'اقدام «بازدید» با تاریخ آینده',
'پیگیری به آینده موکول شده است'):
# چهار کارت: پیگیری امروز + سه کارت وضعیت
self.assertEqual(html.count('class="ez-desc"'), 4)
for phrase in ('موعد تماسشان امروز است',
'با هر وضعیت پیگیری',
'هنوز وقت بازدید تعیین نشده',
'وقت بعدی‌شان مشخص است'):
self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست')
def test_card_titles_use_business_language(self):
"""نام کارت‌ها باید زبان کسب‌وکار باشد، نه زبان دیتابیس."""
html = self._entezar().content.decode()
self.assertIn('همهٔ مشتریان عقب‌افتاده', html)
self.assertIn('تعیین تکلیف نشده', html)
self.assertIn('تعیین تکلیف شده', html)
# نام‌های قدیمیِ گیج‌کننده نباید برگردند.
self.assertNotIn('کل عقب‌افتاده‌ها', html)
self.assertNotIn('قرار هماهنگی', html)
# «بازدید» به‌جای «بازدیدها» — عدد، تعداد مشتری است نه بازدید.
self.assertNotIn('کل بازدیدهای عقب افتاده', html)
def test_extra_cards_moved_into_type_filter(self):
"""سه کارت حذف‌شده باید فقط در دراپ‌داون «نوع قرار» باشند، نه روی کارت‌ها."""
html = self._entezar().content.decode()
# چهار کارت آمار: «نیازمند پیگیری امروز» + سه کارت وضعیت
self.assertEqual(html.count('class="ez-stat '), 4)
self.assertIn('نیازمند پیگیری امروز', html)
# هر سه فیلتر در دراپ‌داون موجودند
self.assertIn('name="view"', html)
for value in ('manual', 'scheduled', 'coordination'):
self.assertIn(f'value="{value}"', html)
def test_cards_have_no_breakdown_or_warning(self):
"""کارت‌ها باید ساده بمانند: هیچ تفکیک «تعویق/بازدید/پیگیری» رویشان نباشد."""
html = self._entezar().content.decode()
self.assertNotIn('ez-split', html)
self.assertNotIn('ez-alert', html)
cards = html.split('class="ez-filters"')[0]
# چهار عدد روی کارت‌ها (نه پنج/شش عدد قبلیِ تفکیک)
self.assertEqual(cards.count('class="ez-num'), 4)
# «تعویق» فقط در تفکیکِ حذف‌شده بود؛ نباید هیچ‌جای کارت‌ها بماند.
self.assertNotIn('تعویق', cards)
# هیچ شمارنده‌ای کنار «بازدید» یا «پیگیری» نباید باشد
# (الگوی قبلی: «۱۲ بازدید» / «۳۰۶ پیگیری»).
for word in ('بازدید', 'پیگیری'):
self.assertNotRegex(
cards, r'\d+\s*' + word,
msg=f'شمارندهٔ کنار «{word}» نباید روی کارت‌ها باشد')
# ---------- اسکنر پیامک هم قرار دستی را می‌بیند ----------
def test_scanner_skips_customer_with_manual_hold(self):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
@@ -1476,7 +1527,11 @@ class EntezarLayoutTests(TestCase):
def test_header_cells_match_body_cells(self):
thead = self.html.split('<tbody>')[0]
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
self.assertEqual(thead.count('<th>'), first_row.count('<td'))
# سرستون‌ها اکنون لینک مرتب‌سازی دارند، پس کلاس می‌گیرند نه <th>ِ ساده.
# (``<thead`` هم با «<th» شروع می‌شود، پس با regex دقیق می‌شماریم.)
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
first_row.count('<td'))
self.assertEqual(len(re.findall(r'<th[\s>]', thead)), 9)
def test_no_fixed_min_width_on_columns(self):
# min-width روی ستون‌ها باعث اسکرول افقی و بی‌تناسبی می‌شد.
@@ -1524,33 +1579,40 @@ class EntezarPageControlsTests(TestCase):
finish_dt=at(-visit_days_ago))
return Customer.objects.get(pk=customer.pk)
def test_default_view_is_uncoordinated(self):
def test_default_view_is_today(self):
"""پیش‌فرض صفحه = «نیازمند پیگیری امروز»."""
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'uncoordinated')
self.assertEqual(response.context['sort'], 'oldest')
self.assertEqual(response.context['view'], 'today')
self.assertTrue(response.context['is_followup_view'])
# ترتیب پیش‌فرض نمای پیگیری، زودترین موعد است (نه قدیمی‌ترین بازدید).
self.assertEqual(response.context['sort'], 'last_visit')
self.assertEqual(response.context['sort_dir'], 'asc')
def test_invalid_view_and_sort_fall_back(self):
response = self._get(view='nonsense', sort='bogus')
response = self._get(view='nonsense', sort='bogus', delay='bogus')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'uncoordinated')
self.assertEqual(response.context['sort'], 'oldest')
self.assertEqual(response.context['view'], 'today')
self.assertEqual(response.context['sort'], 'last_visit')
self.assertEqual(response.context['sort_dir'], 'asc')
self.assertEqual(response.context['delay'], '')
def test_counts_partition_total(self):
self._overdue_customer('معوق ساده', visit_days_ago=400)
counts = self._get().context['counts']
counts = self._get(view='uncoordinated').context['counts']
self.assertEqual(counts['total'],
counts['uncoordinated'] + counts['coordinated'])
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
def test_coordinated_customer_hidden_in_default_view(self):
def test_coordinated_customer_hidden_in_uncoordinated_view(self):
customer = self._overdue_customer('با قرار', visit_days_ago=400)
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
set_visit_hold(customer, at(+10))
default_ids = [obj.id for obj in self._get().context['customers']]
self.assertNotIn(customer.id, default_ids)
uncoordinated_ids = [
obj.id for obj in self._get(view='uncoordinated').context['customers']]
self.assertNotIn(customer.id, uncoordinated_ids)
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
self.assertIn(customer.id, all_ids)
@@ -1563,13 +1625,13 @@ class EntezarPageControlsTests(TestCase):
coordination = make_visit(customer, action_dt=at(-1),
action_type='hamahangi', status='waite')
set_visit_hold(customer, at(+10))
self.assertNotIn(customer.id,
[obj.id for obj in self._get().context['customers']])
self.assertNotIn(customer.id, [
obj.id for obj in self._get(view='uncoordinated').context['customers']])
coordination.status = 'cancell'
coordination.save(update_fields=['status'])
self.assertIn(customer.id,
[obj.id for obj in self._get().context['customers']])
self.assertIn(customer.id, [
obj.id for obj in self._get(view='uncoordinated').context['customers']])
def test_search_by_name_and_phone(self):
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
@@ -1578,11 +1640,86 @@ class EntezarPageControlsTests(TestCase):
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
response = self._get(searchVal=value, searchOn=field)
response = self._get(view='uncoordinated', searchVal=value, searchOn=field)
self.assertEqual(response.status_code, 200)
self.assertIn(customer.id,
[obj.id for obj in response.context['customers']])
# ---------- فیلتر «میزان تأخیر» ----------
def test_delay_filter_selects_exact_range(self):
"""هر بازهٔ تأخیر فقط مشتریان همان بازه را برمی‌گرداند."""
# تأخیر = امروز − (آخرین بازدید + دورهٔ ۹ ماهه).
# بازدید ۲۸۰ روز پیش ⇒ تأخیر ~۶ روز (بازهٔ lt30)
# بازدید ۷۰۰ روز پیش ⇒ تأخیر ~۴۲۶ روز (بازهٔ gt180)
fresh = self._overdue_customer('تازه معوق', visit_days_ago=280)
old = self._overdue_customer('کهنه معوق', visit_days_ago=700)
ids_lt30 = [o.id for o in self._get(view='all', delay='lt30').context['customers']]
self.assertIn(fresh.id, ids_lt30)
self.assertNotIn(old.id, ids_lt30)
ids_gt180 = [o.id for o in self._get(view='all', delay='gt180').context['customers']]
self.assertNotIn(fresh.id, ids_gt180)
self.assertIn(old.id, ids_gt180)
def test_delay_buckets_sum_to_view_total(self):
self._overdue_customer('الف', visit_days_ago=280)
self._overdue_customer('ب', visit_days_ago=430)
for view in ('all', 'uncoordinated'):
counts = self._get(view=view).context['counts']
self.assertEqual(sum(counts['buckets'].values()), counts['view_total'],
f'جمع بازه‌های تأخیر با نمای {view} نمی‌خواند')
def test_delay_filter_does_not_change_card_counts(self):
"""کارت‌ها همیشه کل جامعه را نشان می‌دهند، نه نتیجهٔ فیلتر را."""
self._overdue_customer('الف', visit_days_ago=280)
base = self._get().context['counts']
filtered = self._get(delay='gt180').context['counts']
self.assertEqual(base['total'], filtered['total'])
self.assertEqual(base['uncoordinated'], filtered['uncoordinated'])
self.assertLessEqual(filtered['selected'], base['selected'])
# ---------- مرتب‌سازی ستون‌ها ----------
def test_every_column_is_sortable_both_directions(self):
self._overdue_customer('الف', visit_days_ago=400)
self._overdue_customer('ب', visit_days_ago=500)
for column in ('name', 'status', 'manual', 'scheduled', 'coordination',
'last_visit', 'overdue', 'phone', 'address'):
for direction in ('asc', 'desc'):
response = self._get(view='all', sort=column, dir=direction)
self.assertEqual(response.status_code, 200,
f'ستون {column}/{direction} خطا داد')
self.assertEqual(response.context['sort'], column)
self.assertEqual(response.context['sort_dir'], direction)
def test_explicit_direction_overrides_legacy_alias(self):
"""``sort=overdue&dir=asc`` باید صعودی بماند، نه به desc قدیمی بیفتد."""
self._overdue_customer('الف', visit_days_ago=400)
response = self._get(view='all', sort='overdue', dir='asc')
self.assertEqual(response.context['sort'], 'overdue')
self.assertEqual(response.context['sort_dir'], 'asc')
def test_name_sort_orders_alphabetically(self):
first = self._overdue_customer('آ ا', visit_days_ago=400)
second = self._overdue_customer('ی ی', visit_days_ago=400)
ids = [o.id for o in self._get(view='all', sort='name', dir='asc').context['customers']]
self.assertLess(ids.index(first.id), ids.index(second.id))
ids_desc = [o.id for o in self._get(view='all', sort='name', dir='desc').context['customers']]
self.assertLess(ids_desc.index(second.id), ids_desc.index(first.id))
def test_missing_appointment_dates_sort_last(self):
"""مشتریِ بدون قرار دستی باید در هر دو جهت، آخرِ فهرست بیاید."""
with_hold = self._overdue_customer('با قرار', visit_days_ago=400)
make_visit(with_hold, action_dt=at(-1), action_type='hamahangi', status='waite')
set_visit_hold(with_hold, at(+10))
without = self._overdue_customer('بدون قرار', visit_days_ago=400)
for direction in ('asc', 'desc'):
ids = [o.id for o in self._get(view='all', sort='manual',
dir=direction).context['customers']]
self.assertLess(ids.index(with_hold.id), ids.index(without.id),
f'در جهت {direction} مشتریِ بدون قرار اول آمد')
def test_sort_overdue_puts_biggest_delay_first(self):
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
@@ -1592,3 +1729,380 @@ class EntezarPageControlsTests(TestCase):
ids_newest = [obj.id for obj in self._get(view='all', sort='newest').context['customers']]
self.assertLess(ids_newest.index(newer.id), ids_newest.index(oldest.id))
# ---------- سرستون‌ها و حفظ فیلترها ----------
def test_sortable_headers_render_with_arrows(self):
html = self._get(view='all', sort='name', dir='desc').content.decode()
self.assertEqual(html.count('class="ez-th '), 9)
# فقط یک سرستون فعال است (کلاس دقیق، نه تکرار در CSS).
self.assertEqual(html.count('class="ez-th is-sorted"'), 1)
self.assertIn('ez-arrow', html)
self.assertIn('▼', html) # جهت نزولیِ فعال
self.assertIn('⇅', html) # ستون‌های غیرفعال
def test_header_link_toggles_direction(self):
response = self._get(view='all', sort='name', dir='asc')
header = {h['key']: h for h in response.context['sort_header']}['name']
self.assertTrue(header['active'])
self.assertEqual(header['dir'], 'asc')
# لینک همان ستون باید جهت را برگرداند.
self.assertIn('dir=desc', header['link'])
def test_filter_links_preserve_other_filters(self):
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
ctx = response.context
for link in ctx['view_links'].values():
self.assertIn('delay=gt180', link)
self.assertIn('sort=name', link)
for link in ctx['delay_links'].values():
self.assertIn('sort=name', link)
for link in ctx['sort_links'].values():
self.assertIn('delay=gt180', link)
def test_clear_link_resets_everything(self):
response = self._get(view='all', delay='gt180', sort='name', dir='asc',
searchVal='x', searchOn='name')
self.assertEqual(response.context['clear_link'], '?view=today')
self.assertTrue(response.context['has_filters'])
def test_no_filters_means_clean_state(self):
self.assertFalse(self._get().context['has_filters'])
def test_pagination_keeps_filters(self):
# یک ردیف بیشتر از اندازهٔ صفحه تا صفحهٔ دوم ساخته شود.
for index in range(ENTEZAR_PAGE_SIZE + 1):
self._overdue_customer(f'مشتری شماره {index}', visit_days_ago=700)
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
self.assertTrue(response.context['page_next'])
self.assertIn('delay=gt180', response.context['page_next'])
self.assertIn('page=2', response.context['page_next'])
self.assertIn('sort=name', response.context['page_next'])
self.assertEqual(response.context['page_prev'], '')
class TodayFollowupTests(TestCase):
"""نمای «نیازمند پیگیری امروز» در صفحهٔ انتظار.
قواعد قفل‌شده:
* فقط موعدهای **همان روز** (پیش‌فرض: امروز) نمایش داده می‌شوند.
* مشتریانی که برایشان «بازدید» گذاشته شده **هرگز** در این فهرست نمی‌آیند.
* با تقویم شمسی می‌توان روزهای دیگر (گذشته/آینده) را دید.
* عدد کارت با تعداد فهرست امروز برابر است و با تغییر تاریخ عوض نمی‌شود.
"""
URL = '/panel/entezar/'
TODAY = timezone.localdate()
def setUp(self):
self.user = make_user('followupViewer', superuser=True)
self.client.force_login(self.user)
# ---------- helpers ----------
def _customer(self, name, *, active=True):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=self.TODAY - timedelta(days=400), is_active=active,
)
customer.refresh_from_db()
return customer
def _coordination(self, customer, day_offset=0):
"""اقدام هماهنگی با موعد پیگیری در روز مشخص (نسبت به امروز)."""
noon = timezone.make_aware(
datetime.datetime.combine(
self.TODAY + timedelta(days=day_offset), datetime.time(12, 0)
),
timezone.get_current_timezone(),
)
return make_visit(customer, action_dt=noon, action_type='hamahangi',
status='finish')
def _visit(self, customer, day_offset=0):
"""اقدام بازدیدِ در انتظار (قرار بازدید)."""
noon = timezone.make_aware(
datetime.datetime.combine(
self.TODAY + timedelta(days=day_offset), datetime.time(10, 0)
),
timezone.get_current_timezone(),
)
return make_visit(customer, action_dt=noon, action_type='bazdid',
status='waite')
def _jalali(self, day_offset=0):
from persiantools.jdatetime import JalaliDate
return JalaliDate(self.TODAY + timedelta(days=day_offset)).strftime('%Y/%m/%d')
def _ids(self, query=''):
response = self.client.get(self.URL + '?view=today' + query)
self.assertEqual(response.status_code, 200)
return {obj.id for obj in response.context['customers']}
# ---------- هستهٔ رفتار ----------
def test_followup_today_appears(self):
customer = self._customer('پیگیری امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids())
def test_followup_other_days_not_in_today(self):
yesterday = self._customer('دیروز')
tomorrow = self._customer('فردا')
self._coordination(yesterday, -1)
self._coordination(tomorrow, +1)
ids = self._ids()
self.assertNotIn(yesterday.id, ids)
self.assertNotIn(tomorrow.id, ids)
def test_calendar_navigates_to_other_days(self):
yesterday = self._customer('دیروز')
tomorrow = self._customer('فردا')
self._coordination(yesterday, -1)
self._coordination(tomorrow, +1)
past = self._ids('&fdate=' + self._jalali(-1))
future = self._ids('&fdate=' + self._jalali(+1))
self.assertIn(yesterday.id, past)
self.assertNotIn(tomorrow.id, past)
self.assertIn(tomorrow.id, future)
self.assertNotIn(yesterday.id, future)
def test_invalid_followup_date_falls_back_to_today(self):
customer = self._customer('امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids('&fdate=نامعتبر'))
def test_customer_without_any_followup_never_appears(self):
customer = self._customer('بدون موعد')
self.assertNotIn(customer.id, self._ids())
# ---------- قاعدهٔ قطعی: «بازدید» هرگز در این فهرست نمی‌آید ----------
def test_visit_today_excludes_customer(self):
"""بازدید برای همان روز ⇒ مشتری نباید در فهرست پیگیری باشد."""
customer = self._customer('بازدید امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids())
self._visit(customer, 0)
self.assertNotIn(
customer.id, self._ids(),
msg='مشتریِ دارای بازدیدِ امروز نباید در فهرست پیگیری بیاید',
)
def test_visit_in_future_excludes_customer(self):
"""بازدیدی که برای آینده گذاشته شده هم مشتری را از فهرست بیرون می‌برد."""
customer = self._customer('بازدید آینده')
self._coordination(customer, 0)
self._visit(customer, +5)
self.assertNotIn(customer.id, self._ids())
def test_visit_in_past_does_not_exclude(self):
"""بازدیدِ کهنه (backlog بسته‌نشده) قرار فعال نیست ⇒ نباید پنهان کند."""
customer = self._customer('بازدید کهنه')
self._coordination(customer, 0)
self._visit(customer, -30)
self.assertIn(customer.id, self._ids())
def test_scheduled_view_still_works(self):
"""قاعدهٔ حذف نباید نمای «بازدید برنامه‌ریزی‌شده» را خالی کند."""
customer = self._customer('بازدید برنامه')
self._visit(customer, +3)
response = self.client.get(self.URL + '?view=scheduled')
self.assertIn(customer.id, {o.id for o in response.context['customers']})
# ---------- قرار دستی روی ستون مشتری ----------
def test_manual_hold_today_appears(self):
customer = self._customer('قرار دستی امروز')
self._coordination(customer, -3)
# قرار دستی روی پایان همان روزِ امروز
noon_today = timezone.make_aware(
datetime.datetime.combine(self.TODAY, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
set_visit_hold(customer, noon_today)
self.assertIn(customer.id, self._ids())
# ---------- شمارنده‌ها و نماها ----------
def test_card_count_matches_list(self):
first = self._customer('الف')
second = self._customer('ب')
self._coordination(first, 0)
self._coordination(second, 0)
response = self.client.get(self.URL)
self.assertEqual(response.context['counts']['followup_today'],
response.context['counts']['followup_today'])
listed = self.client.get(self.URL + '?view=today').context
self.assertEqual(listed['counts']['followup_today'],
listed['customers'].paginator.count)
def test_card_count_stays_today_when_browsing_other_day(self):
customer = self._customer('فردا')
self._coordination(customer, +1)
today_ctx = self.client.get(self.URL).context
past_ctx = self.client.get(
self.URL + '?view=today&fdate=' + self._jalali(-1)).context
self.assertEqual(today_ctx['counts']['followup_today'],
past_ctx['counts']['followup_today'])
def test_default_view_is_today(self):
response = self.client.get(self.URL)
self.assertEqual(response.context['view'], 'today')
self.assertTrue(response.context['is_followup_view'])
def test_inactive_and_cancelled_customers_excluded(self):
inactive = self._customer('غیرفعال', active=False)
cancelled = self._customer('لغو بازدید')
self._coordination(inactive, 0)
self._coordination(cancelled, 0)
Customer.objects.filter(pk=cancelled.pk).update(laghv_bazdid=True)
ids = self._ids()
self.assertNotIn(inactive.id, ids)
self.assertNotIn(cancelled.id, ids)
def test_search_inside_followup_view(self):
wanted = self._customer('علی رضایی')
other = self._customer('محمد حسینی')
self._coordination(wanted, 0)
self._coordination(other, 0)
ids = self._ids('&searchVal=رضایی&searchOn=name')
self.assertIn(wanted.id, ids)
self.assertNotIn(other.id, ids)
def test_ordered_by_earliest_followup_time(self):
late = self._customer('دیرتر')
early = self._customer('زودتر')
tz = timezone.get_current_timezone()
for customer, hour in ((late, 18), (early, 8)):
moment = timezone.make_aware(
datetime.datetime.combine(self.TODAY, datetime.time(hour, 0)), tz)
make_visit(customer, action_dt=moment, action_type='hamahangi',
status='finish')
response = self.client.get(self.URL + '?view=today')
order = [obj.id for obj in response.context['customers']]
self.assertLess(order.index(early.id), order.index(late.id))
def test_date_picker_and_nav_rendered(self):
customer = self._customer('نمایش تقویم')
self._coordination(customer, 0)
html = self.client.get(self.URL + '?view=today').content.decode()
self.assertIn('followupDatePicker', html)
self.assertIn('pDatepicker', html)
self.assertIn('ez-datebar', html)
self.assertIn('name="fdate"', html)
# پیش‌فرض صفحه = نمای پیگیری ⇒ نوار تاریخ باید در صفحهٔ بدون پارامتر هم باشد.
html_default = self.client.get(self.URL).content.decode()
self.assertIn('class="ez-datebar"', html_default)
self.assertIn('followupDatePicker', html_default)
# در نمای دیگر، نوار تاریخ رندر نشود.
html_other = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-datebar"', html_other)
self.assertNotIn('followupDatePicker', html_other)
def test_browsing_other_day_shows_banner(self):
customer = self._customer('روز دیگر')
self._coordination(customer, -1)
html = self.client.get(
self.URL + '?view=today&fdate=' + self._jalali(-1)).content.decode()
self.assertIn('بازگشت به امروز', html)
# ---------- ستون «تأخیر» فقط در نمای پیگیری مخفی ----------
def test_overdue_column_hidden_only_in_followup_view(self):
customer = self._customer('ستون تأخیر')
self._coordination(customer, 0)
followup_html = self.client.get(self.URL + '?view=today').content.decode()
self.assertNotIn('data-label="تأخیر"', followup_html)
self.assertNotIn('>تأخیر<', followup_html)
other_html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertIn('data-label="تأخیر"', other_html)
def test_column_widths_sum_to_hundred_in_both_modes(self):
customer = self._customer('ردیف نمونه')
self._coordination(customer, 0)
for query in ('?view=today', '?view=all'):
html = self.client.get(self.URL + query).content.decode()
widths = [float(w) for w in re.findall(
r'<col style="width: (\d+(?:\.\d+)?)%">', html)]
self.assertAlmostEqual(sum(widths), 100.0, places=1,
msg=f'جمع عرض ستون‌ها در {query} برابر ۱۰۰ نیست')
# تعداد <th> با تعداد <td> هر ردیف باید یکی باشد.
thead = html.split('<tbody>')[0]
first_row = html.split('<tbody>')[1].split('</tr>')[0]
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
first_row.count('<td'))
# ---------- صفحه‌بندی ۱۰۰تایی ----------
def test_page_size_is_one_hundred(self):
for index in range(105):
self._customer(f'مشتری شماره {index}')
page = self.client.get(self.URL + '?view=all').context['customers']
self.assertEqual(len(page.object_list), 100)
self.assertEqual(page.paginator.num_pages, 2)
# ---------- هشدار پیگیری‌های عقب‌افتاده ----------
def test_overdue_warning_appears_for_past_due(self):
late = self._customer('موعد گذشته')
self._coordination(late, -3)
ctx = self.client.get(self.URL).context
self.assertEqual(ctx['counts']['followup_overdue'], 1)
html = self.client.get(self.URL).content.decode()
self.assertIn('class="ez-overdue-alert"', html)
self.assertIn('مورد نیازمند پیگیری از روزهای قبل باقی مانده', html)
def test_overdue_warning_absent_when_all_on_time(self):
today_only = self._customer('فقط امروز')
self._coordination(today_only, 0)
future = self._customer('فردا')
self._coordination(future, +2)
ctx = self.client.get(self.URL).context
self.assertEqual(ctx['counts']['followup_overdue'], 0)
self.assertNotIn('class="ez-overdue-alert"',
self.client.get(self.URL).content.decode())
def test_overdue_warning_matches_missed_list(self):
"""عدد هشدار باید دقیقاً برابر تعداد ردیف‌های فهرست «عقب‌افتاده‌ها» باشد."""
for offset in (-1, -5, -30, 0):
customer = self._customer(f'موعد {offset}')
self._coordination(customer, offset)
ctx = self.client.get(self.URL).context
missed = self.client.get(self.URL + '?view=today&missed=1').context
self.assertEqual(missed['customers'].paginator.count,
ctx['counts']['followup_overdue'])
self.assertEqual(missed['counts']['followup_overdue'],
ctx['counts']['followup_overdue'])
def test_newer_coordination_clears_overdue(self):
customer = self._customer('تمدید شده')
self._coordination(customer, -10)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 1)
# کارشناس دوباره تماس گرفته و موعد جدید گذاشته ⇒ دیگر عقب‌افتاده نیست.
self._coordination(customer, +5)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
def test_scheduled_visit_clears_overdue(self):
customer = self._customer('بازدید گرفته')
self._coordination(customer, -10)
self._visit(customer, +1)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
def test_missed_mode_renders_chip_and_back_link(self):
customer = self._customer('عقب‌افتاده')
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=today&missed=1').content.decode()
self.assertIn('class="ez-overdue-mode"', html)
self.assertNotIn('<a class="ez-overdue-alert"', html)
self.assertIn('بازگشت به امروز', html)
def test_overdue_warning_only_in_followup_view(self):
customer = self._customer('عقب‌افتاده دیگر')
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-overdue-alert"', html)