2375 lines
119 KiB
Python
2375 lines
119 KiB
Python
"""تستهای «آخرین بازدید» (visit_policy) و دستور ترمیم.
|
||
|
||
این ماژول قواعد طلایی زیر را قفل میکند:
|
||
|
||
* فقط اقدام ``bazdid + finish`` در محاسبهٔ آخرین بازدید دیده میشود؛
|
||
«هماهنگی» هرگز.
|
||
* تاریخ مؤثر = ``actionDateTime`` اگر از حالا جلوتر نباشد، وگرنه ``finishDateTime``.
|
||
* بازمحاسبه و ترمیم، هیچ رکورد اقدامی را تغییر نمیدهند.
|
||
* بازدید انجامشده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد میشود.
|
||
"""
|
||
import datetime
|
||
import re
|
||
import io
|
||
import os
|
||
from datetime import timedelta
|
||
from pathlib import Path
|
||
|
||
from django.core.exceptions import ValidationError
|
||
from django.contrib.auth.models import Permission
|
||
from django.core.management import CommandError, call_command
|
||
from django.db import connection
|
||
from django.test import TestCase
|
||
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,
|
||
validate_action_payload,
|
||
visit_cycle_index, visit_due_date)
|
||
|
||
from .factories import make_customer, make_user
|
||
|
||
|
||
_BASE_NOW = timezone.now()
|
||
|
||
|
||
def at(days=0, hours=0):
|
||
"""لحظهٔ aware نسبت به «حالا» (ثابت در طول یک ماژول، بدون drift میکروثانیه)."""
|
||
return _BASE_NOW + timedelta(days=days, hours=hours)
|
||
|
||
|
||
def make_visit(customer, *, action_dt=None, finish_dt=None, status='finish',
|
||
action_type='bazdid'):
|
||
return Action.objects.create(
|
||
customer=customer, actionType=action_type, status=status,
|
||
actionDateTime=action_dt, finishDateTime=finish_dt,
|
||
desc='تست', result='نتیجه',
|
||
)
|
||
|
||
|
||
def action_snapshot(action):
|
||
"""همهٔ فیلدهای یک اقدام، برای اثبات دستنخورده ماندن آن."""
|
||
action.refresh_from_db()
|
||
return {field.name: getattr(action, field.name)
|
||
for field in action._meta.concrete_fields}
|
||
|
||
|
||
class EffectiveVisitDateTests(TestCase):
|
||
"""قاعدهٔ «تاریخ مؤثر» و انتخاب بزرگترین مقدار."""
|
||
|
||
def setUp(self):
|
||
self.customer = make_customer('مشتری تاریخ مؤثر')
|
||
|
||
def test_finish_before_action_uses_action_when_action_is_past(self):
|
||
# هر دو در گذشتهاند ⇒ قاعدهٔ جدید تاریخ واقعی بازدید (action) را میگیرد.
|
||
make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
|
||
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-30)))
|
||
|
||
def test_future_action_falls_back_to_finish(self):
|
||
make_visit(self.customer, action_dt=at(+10), finish_dt=at(-2))
|
||
self.assertLessEqual(last_visit_dt(self.customer), timezone.now())
|
||
|
||
def test_future_action_and_future_finish_yields_nothing(self):
|
||
make_visit(self.customer, action_dt=at(+10), finish_dt=at(+11))
|
||
self.assertIsNone(last_visit_dt(self.customer))
|
||
|
||
def test_only_action(self):
|
||
make_visit(self.customer, action_dt=at(-5))
|
||
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-5)))
|
||
|
||
def test_only_finish(self):
|
||
make_visit(self.customer, finish_dt=at(-4))
|
||
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-4)))
|
||
|
||
def test_no_dates(self):
|
||
make_visit(self.customer)
|
||
self.assertIsNone(last_visit_dt(self.customer))
|
||
|
||
def test_latest_finish_wins_over_future_planned_visit(self):
|
||
make_visit(self.customer, action_dt=at(-90), finish_dt=at(-90))
|
||
make_visit(self.customer, action_dt=at(+20), finish_dt=at(-3))
|
||
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-3)))
|
||
|
||
def test_pending_visit_is_ignored(self):
|
||
make_visit(self.customer, action_dt=at(-200), finish_dt=at(-200))
|
||
make_visit(self.customer, action_dt=at(-1), status='waite')
|
||
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-200)))
|
||
|
||
|
||
class HamahangiIsolationTests(TestCase):
|
||
"""هماهنگی هرگز نباید در «آخرین بازدید» دیده شود."""
|
||
|
||
def setUp(self):
|
||
self.customer = make_customer('مشتری هماهنگی')
|
||
|
||
def test_only_hamahangi_gives_no_visit(self):
|
||
make_visit(self.customer, action_dt=at(-1), finish_dt=at(-1),
|
||
action_type='hamahangi')
|
||
self.assertIsNone(last_visit_dt(self.customer))
|
||
|
||
def test_newer_hamahangi_does_not_override_visit(self):
|
||
make_visit(self.customer, action_dt=at(-60), finish_dt=at(-60))
|
||
make_visit(self.customer, action_dt=at(-2), finish_dt=at(-2),
|
||
action_type='hamahangi')
|
||
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-60)))
|
||
|
||
def test_refresh_does_not_clear_value_without_visit(self):
|
||
Customer.objects.filter(pk=self.customer.pk).update(last_bazdid_dt=at(-10))
|
||
self.customer.refresh_from_db()
|
||
make_visit(self.customer, action_dt=at(-1), action_type='hamahangi')
|
||
changed = refresh_last_bazdid(self.customer)
|
||
self.customer.refresh_from_db()
|
||
self.assertFalse(changed)
|
||
self.assertEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-10)))
|
||
|
||
def test_waiting_hamahangi_is_not_a_visit_but_is_a_deferral(self):
|
||
make_visit(self.customer, action_dt=at(+5), status='waite',
|
||
action_type='hamahangi')
|
||
self.assertIsNone(last_visit_dt(self.customer))
|
||
self.assertIsNotNone(active_deferral(self.customer))
|
||
|
||
def test_refresh_keeps_action_untouched(self):
|
||
action = make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
|
||
before = action_snapshot(action)
|
||
refresh_last_bazdid(self.customer)
|
||
self.assertEqual(action_snapshot(action), before)
|
||
|
||
|
||
class RefreshLastBazdidTests(TestCase):
|
||
"""نوشتن مقدار مشتری: فقط وقتی تغییر کند، و فقط همان ستون."""
|
||
|
||
def setUp(self):
|
||
self.customer = make_customer('مشتری بازمحاسبه')
|
||
|
||
def test_writes_action_date_not_finish_date(self):
|
||
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-1))
|
||
self.assertTrue(refresh_last_bazdid(self.customer))
|
||
self.customer.refresh_from_db()
|
||
self.assertLessEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-20)))
|
||
|
||
def test_is_idempotent(self):
|
||
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
|
||
self.assertTrue(refresh_last_bazdid(self.customer))
|
||
self.assertFalse(refresh_last_bazdid(self.customer))
|
||
|
||
def test_other_customer_fields_untouched(self):
|
||
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
|
||
before = Customer.objects.values().get(pk=self.customer.pk)
|
||
refresh_last_bazdid(self.customer)
|
||
after = Customer.objects.values().get(pk=self.customer.pk)
|
||
changed = {key for key in before if before[key] != after[key]}
|
||
self.assertEqual(changed, {'last_bazdid_dt'})
|
||
|
||
|
||
class ValidationTests(TestCase):
|
||
"""اعتبارسنجی لایهٔ HTTP (قاعدهٔ ۳۰ روز فقط برای بازدید انجامشده)."""
|
||
|
||
def payload(self, **overrides):
|
||
base = {'actionType': 'bazdid', 'status': 'finish', 'actionDateTime': at(-1)}
|
||
base.update(overrides)
|
||
return base
|
||
|
||
def test_finished_visit_far_in_future_is_rejected(self):
|
||
with self.assertRaises(ValidationError):
|
||
validate_action_payload(self.payload(actionDateTime=at(+60)))
|
||
|
||
def test_finished_visit_today_is_accepted(self):
|
||
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(0))))
|
||
|
||
def test_finished_visit_in_past_is_accepted(self):
|
||
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(-90))))
|
||
|
||
def test_hamahangi_in_future_is_accepted(self):
|
||
self.assertTrue(validate_action_payload(
|
||
self.payload(actionType='hamahangi', status='waite', actionDateTime=at(+40))
|
||
))
|
||
|
||
def test_invalid_action_type_is_rejected(self):
|
||
with self.assertRaises(ValidationError):
|
||
validate_action_payload(self.payload(actionType='انتخاب کنید'))
|
||
|
||
def test_invalid_status_is_rejected(self):
|
||
with self.assertRaises(ValidationError):
|
||
validate_action_payload(self.payload(status='done'))
|
||
|
||
def test_end_of_day_tehran_is_accepted(self):
|
||
# ۲۳:۵۹ امروز به وقت تهران (= ۲۰:۲۹ UTC) نباید رد شود.
|
||
tz = timezone.get_current_timezone()
|
||
today = timezone.localdate()
|
||
moment = datetime.datetime.combine(
|
||
today, datetime.time(23, 59), tzinfo=tz
|
||
)
|
||
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
|
||
|
||
def test_start_of_day_tehran_is_accepted(self):
|
||
# ۰۰:۳۰ امروز تهران در UTC روز قبل است؛ نباید بهاشتباه رد یا آینده حساب شود.
|
||
tz = timezone.get_current_timezone()
|
||
today = timezone.localdate()
|
||
moment = datetime.datetime.combine(today, datetime.time(0, 30), tzinfo=tz)
|
||
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
|
||
|
||
def test_naive_payload_is_made_aware(self):
|
||
# ورودی naive (بقایای کد قدیمی) باید با تایمزون پروژه aware شود.
|
||
naive = datetime.datetime.now() - timedelta(days=1)
|
||
self.assertTrue(validate_action_payload(self.payload(actionDateTime=naive)))
|
||
self.assertFalse(timezone.is_naive(as_aware(naive)))
|
||
|
||
|
||
def run_command(*args):
|
||
out = io.StringIO()
|
||
call_command('recalc_last_bazdid', *args, stdout=out)
|
||
return out.getvalue()
|
||
|
||
|
||
def plan_command(*args):
|
||
"""برنامهٔ تغییرات را از همان مسیر ``handle`` میگیرد، بدون نوشتن/بکاپ."""
|
||
from django.core.management.base import CommandParser
|
||
|
||
from admin_panel.management.commands.recalc_last_bazdid import Command
|
||
|
||
command = Command()
|
||
parser = CommandParser(add_help=False)
|
||
command.add_arguments(parser)
|
||
options = parser.parse_args(list(args))
|
||
defaults = {'no_color': True, 'force_color': False, 'skip_checks': False,
|
||
'traceback': False, 'verbosity': 0, 'pythonpath': None,
|
||
'settings': None, 'stdout': io.StringIO(), 'stderr': io.StringIO()}
|
||
defaults.update(vars(options))
|
||
return command._plan(
|
||
defaults['exact'], defaults['clear_orphans'], defaults['batch_size'],
|
||
)
|
||
|
||
|
||
class RecalcCommandTests(TestCase):
|
||
"""دستور ترمیم: فقط Customer را مینویسد، هماهنگی را دست نمیزند."""
|
||
|
||
def setUp(self):
|
||
self.forward = make_customer('مشتری رو به جلو')
|
||
self.backward = make_customer('مشتری رو به عقب')
|
||
self.ham = make_customer('مشتری هماهنگی')
|
||
# رو به جلو: مقدار فعلی قدیمیتر از واقعیت است.
|
||
make_visit(self.forward, action_dt=at(-30), finish_dt=at(-30))
|
||
Customer.objects.filter(pk=self.forward.pk).update(last_bazdid_dt=at(-400))
|
||
# رو به عقب: مقدار فعلی جلوتر از واقعیت است (drift ویرایش).
|
||
make_visit(self.backward, action_dt=at(-100), finish_dt=at(-100))
|
||
Customer.objects.filter(pk=self.backward.pk).update(last_bazdid_dt=at(-2))
|
||
# هماهنگی: مقدارش دقیقاً از تاریخ هماهنگی است.
|
||
self.ham_action = make_visit(
|
||
self.ham, action_dt=at(-5), finish_dt=at(-5), action_type='hamahangi',
|
||
)
|
||
Customer.objects.filter(pk=self.ham.pk).update(last_bazdid_dt=at(-5))
|
||
|
||
def _values(self):
|
||
return dict(Customer.objects.values_list('id', 'last_bazdid_dt'))
|
||
|
||
def test_dry_run_writes_nothing(self):
|
||
before = self._values()
|
||
run_command('--exact', '--dry-run')
|
||
self.assertEqual(self._values(), before)
|
||
|
||
def test_exact_requires_confirm(self):
|
||
with self.assertRaises(CommandError):
|
||
run_command('--exact')
|
||
|
||
def test_plan_default_mode_ignores_backward_drift(self):
|
||
stats, _, pending, _ = plan_command()
|
||
self.assertEqual(stats['forward'], 1)
|
||
self.assertEqual(stats['backward'], 0)
|
||
self.assertNotIn(self.backward.pk, [customer_id for customer_id, _ in pending])
|
||
|
||
def test_plan_exact_mode_fixes_both_directions(self):
|
||
stats, _, pending, _ = plan_command('--exact')
|
||
targets = dict(pending)
|
||
self.assertEqual(stats['forward'], 1)
|
||
self.assertEqual(stats['backward'], 1)
|
||
self.assertEqual(as_aware(targets[self.forward.pk]), as_aware(at(-30)))
|
||
self.assertEqual(as_aware(targets[self.backward.pk]), as_aware(at(-100)))
|
||
|
||
def test_apply_writes_only_customer_column(self):
|
||
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
|
||
|
||
_, _, pending, _ = plan_command('--exact')
|
||
before = action_snapshot(self.ham_action)
|
||
Cmd()._apply(pending)
|
||
values = self._values()
|
||
self.assertEqual(as_aware(values[self.forward.pk]), as_aware(at(-30)))
|
||
self.assertEqual(as_aware(values[self.backward.pk]), as_aware(at(-100)))
|
||
self.assertEqual(action_snapshot(self.ham_action), before)
|
||
|
||
def test_plan_is_idempotent_after_apply(self):
|
||
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
|
||
|
||
_, _, pending, _ = plan_command('--exact')
|
||
Cmd()._apply(pending)
|
||
stats, _, pending_again, _ = plan_command('--exact')
|
||
self.assertEqual(pending_again, [])
|
||
self.assertEqual(stats['no_change'], stats['scanned'])
|
||
|
||
def test_hamahangi_value_is_untouched_without_clear_orphans(self):
|
||
plan_command('--exact')
|
||
self.assertEqual(as_aware(self._values()[self.ham.pk]), as_aware(at(-5)))
|
||
|
||
def test_hamahangi_value_is_cleared_with_clear_orphans(self):
|
||
_, _, pending, _ = plan_command('--exact', '--clear-orphans')
|
||
self.assertIn((self.ham.pk, None), pending)
|
||
|
||
def test_hamahangi_suspect_heuristic(self):
|
||
_, _, _, suspects = plan_command('--exact')
|
||
self.assertEqual(suspects, 1) # فقط مشتری هماهنگی
|
||
|
||
|
||
class EntezarScannerConsistencyTests(TestCase):
|
||
"""صفحهٔ «انتظار» و اسکنر پیامک باید یک جواب بدهند."""
|
||
|
||
def setUp(self):
|
||
self.user = make_user('entezarAdmin', superuser=True)
|
||
|
||
def _entezar_ids(self):
|
||
self.client.force_login(self.user)
|
||
# نمای صریح «تعیین تکلیف نشده» — همان چیزی که اسکنر پیامک با آن
|
||
# همتراز است. (پیشفرض صفحه اکنون «نیازمند پیگیری امروز» است.)
|
||
response = self.client.get('/panel/entezar/?view=uncoordinated')
|
||
self.assertEqual(response.status_code, 200)
|
||
return {c.id for c in response.context['customers']}
|
||
|
||
def test_same_customer_state_on_both_sides(self):
|
||
from admin_panel.sms.scanner import scan_visit
|
||
from admin_panel.sms.timing import DEFAULT_VISIT_MONTHS
|
||
|
||
months = DEFAULT_VISIT_MONTHS
|
||
stale = make_customer('مشتری معوق مشترک')
|
||
fresh = make_customer('مشتری تازه بازدیدشده')
|
||
Customer.objects.filter(pk=stale.pk).update(
|
||
installDate=timezone.localdate() - timedelta(days=400)
|
||
)
|
||
Customer.objects.filter(pk=fresh.pk).update(
|
||
installDate=timezone.localdate() - timedelta(days=400)
|
||
)
|
||
make_visit(fresh, action_dt=timezone.now() - timedelta(days=5),
|
||
finish_dt=timezone.now() - timedelta(days=5))
|
||
|
||
ids = self._entezar_ids()
|
||
self.assertIn(stale.pk, ids)
|
||
self.assertNotIn(fresh.pk, ids)
|
||
|
||
# اسکنر هم باید فقط مشتری معوق را عقبافتاده بداند.
|
||
from admin_panel.visit_policy import active_deferrals, last_visit_map, visit_period
|
||
period = visit_period(months)
|
||
now = timezone.now()
|
||
visits = last_visit_map(now=now)
|
||
deferrals = active_deferrals(now=now)
|
||
|
||
def is_stale(customer):
|
||
anchor = visits.get(customer.pk) or as_aware(customer.installDate)
|
||
return (customer.pk not in deferrals) and (anchor + period <= now)
|
||
|
||
self.assertTrue(is_stale(Customer.objects.get(pk=stale.pk)))
|
||
self.assertFalse(is_stale(Customer.objects.get(pk=fresh.pk)))
|
||
|
||
def test_active_deferral_hides_customer(self):
|
||
deferred = make_customer('مشتری تعویقدار')
|
||
Customer.objects.filter(pk=deferred.pk).update(
|
||
installDate=timezone.localdate() - timedelta(days=400)
|
||
)
|
||
make_visit(deferred, action_dt=at(+3), status='waite',
|
||
action_type='hamahangi')
|
||
ids = self._entezar_ids()
|
||
self.assertNotIn(deferred.pk, ids)
|
||
|
||
|
||
class VisitCadenceTests(TestCase):
|
||
"""قاعدهٔ «هر ۹ ماه یکبار»: موعد و چرخه از یک تقویم میآیند."""
|
||
|
||
MONTHS = 9
|
||
|
||
def test_due_date_is_calendar_nine_months(self):
|
||
anchor = as_aware(datetime.date(2024, 3, 4))
|
||
self.assertEqual(visit_due_date(anchor, months=self.MONTHS).date(),
|
||
datetime.date(2024, 12, 4))
|
||
|
||
def test_cycle_increments_exactly_on_each_due_date(self):
|
||
anchor = as_aware(datetime.date(2024, 3, 4))
|
||
self.assertEqual(visit_cycle_index(anchor, anchor, months=self.MONTHS), 0)
|
||
for k in range(1, 6):
|
||
due = visit_due_date(anchor, months=self.MONTHS * k)
|
||
# در روز موعدِ k-ام، «دورهٔ جاری» همان k است.
|
||
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), k,
|
||
msg=f'چرخه در موعد {k} باید {k} باشد')
|
||
|
||
def test_cycle_does_not_advance_before_due_date(self):
|
||
anchor = as_aware(datetime.date(2024, 3, 4))
|
||
due = visit_due_date(anchor, months=self.MONTHS)
|
||
before = due - timedelta(days=1)
|
||
self.assertEqual(visit_cycle_index(anchor, before, months=self.MONTHS), 0)
|
||
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), 1)
|
||
|
||
def test_each_period_has_a_distinct_key(self):
|
||
"""بازگشت باگ اصلی: کلید هر دوره باید یکتا باشد (نه تکرار، نه پرش)."""
|
||
anchor = as_aware(datetime.date(2024, 1, 1))
|
||
keys = []
|
||
for k in range(1, 7):
|
||
due = visit_due_date(anchor, months=self.MONTHS * k)
|
||
keys.append((visit_cycle_index(anchor, due, months=self.MONTHS),
|
||
due.strftime('%Y-%m')))
|
||
self.assertEqual(len({key for _cycle, key in keys}), len(keys))
|
||
self.assertEqual([c for c, _k in keys], [1, 2, 3, 4, 5, 6])
|
||
|
||
def test_customer_without_any_visit_gets_every_period(self):
|
||
"""مشتری که هرگز بازدید نشده، برای هر ۹ ماه یک کلید تازه میگیرد."""
|
||
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||
from admin_panel.sms.scanner import scan_visit
|
||
|
||
customer = make_customer('مشتری بدون بازدید')
|
||
PhoneNumber.objects.create(customer=customer, phone_number='09120000123')
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
installDate=datetime.date(2023, 1, 1)
|
||
)
|
||
customer.refresh_from_db()
|
||
config = SMSProviderConfig.load()
|
||
# «اکنون» این تست در سال ۲۰۲۳–۲۰۲۵ است، پس پنجرهٔ یادآوری هم همانجا باز میشود.
|
||
config.allow_backlog = True
|
||
config.backlog_max = 100
|
||
config.scan_since = timezone.make_aware(
|
||
datetime.datetime(2023, 1, 2, 0, 0), timezone.get_current_timezone(),
|
||
)
|
||
config.save()
|
||
|
||
seen_keys = set()
|
||
cursor = datetime.date(2023, 1, 2)
|
||
while cursor <= datetime.date(2026, 1, 1):
|
||
moment = timezone.make_aware(
|
||
datetime.datetime.combine(cursor, datetime.time(12, 0)),
|
||
timezone.get_current_timezone(),
|
||
)
|
||
scan_visit(cursor, now=moment)
|
||
seen_keys.update(
|
||
SMSQueue.objects.filter(customer=customer)
|
||
.values_list('dedup_key', flat=True)
|
||
)
|
||
cursor += timedelta(days=14)
|
||
|
||
# ۹ ماه اول (۲۰۲۳-۱۰) ، ۱۸ ماه (۲۰۲۴-۰۷) و ۲۷ ماه (۲۰۲۵-۰۴) ⇒ سه دورهٔ یکتا.
|
||
# نکته: اسکنر همیشه «تازهترین موعد رسیده» را هدف میگیرد؛ پس اگر بین دو اجرا
|
||
# بیش از یک دوره گذشته باشد، فقط آخرین دوره صف میشود (نه ارسال انبوه).
|
||
due_months = {key.rsplit(':', 1)[1] for key in seen_keys}
|
||
self.assertEqual(
|
||
due_months, {'2023-10', '2024-07', '2025-04'},
|
||
msg=f'کلیدهای ساختهشده: {sorted(seen_keys)}',
|
||
)
|
||
|
||
|
||
class VisitDecisionTests(TestCase):
|
||
"""تصمیمهای محصولی: یک پیامک برای جدیدترین دوره، بازگشت پس از حذف، ریست با نصب."""
|
||
|
||
MONTHS = 9
|
||
|
||
def _scan_at(self, day):
|
||
from admin_panel.sms.scanner import scan_visit
|
||
moment = timezone.make_aware(
|
||
datetime.datetime.combine(day, datetime.time(12, 0)),
|
||
timezone.get_current_timezone(),
|
||
)
|
||
return scan_visit(day, now=moment)
|
||
|
||
def _prep(self, install_date):
|
||
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||
|
||
customer = make_customer('مشتری تصمیم')
|
||
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
|
||
# ثبت مستقیم: `installDate` با auto_now_add پر میشود، پس با update() بازنویسی میکنیم.
|
||
Customer.objects.filter(pk=customer.pk).update(installDate=install_date)
|
||
customer.refresh_from_db()
|
||
self.assertEqual(customer.installDate, install_date)
|
||
config = SMSProviderConfig.load()
|
||
config.allow_backlog = True
|
||
config.backlog_max = 100
|
||
config.scan_since = timezone.make_aware(
|
||
datetime.datetime(2020, 1, 1, 0, 0), timezone.get_current_timezone(),
|
||
)
|
||
config.save()
|
||
return customer, SMSQueue
|
||
|
||
# --- تصمیم ۱: فقط یک پیامک برای جدیدترین دوره ---
|
||
def test_absent_25_months_gets_exactly_one_reminder(self):
|
||
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||
self._scan_at(datetime.date(2025, 2, 10)) # ۲۵ ماه بعد
|
||
keys = list(SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True))
|
||
self.assertEqual(len(keys), 1, msg=f'کلیدها: {keys}')
|
||
# جدیدترین دورهٔ رسیده: ۱۸ ماهگی (۲۰۲۴-۰۷) — نه ۹ ماهگی.
|
||
self.assertEqual(keys[0].rsplit(':', 1)[1], '2024-07')
|
||
|
||
# --- تصمیم ۲: حذف یادآوری = کنسل همان دوره تا ۹ ماه ---
|
||
def test_deleted_reminder_stays_cancelled_until_next_period(self):
|
||
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||
self._scan_at(datetime.date(2024, 1, 10)) # دورهٔ ۹ ماهگی
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||
|
||
# مدیر از پنل صف، پیامک را حذف میکند (مسیر واقعی «کنسل دوره»).
|
||
from django.urls import reverse
|
||
|
||
from tests.factories import make_user
|
||
|
||
manager = make_user('smsQueueManager', superuser=True)
|
||
self.client.force_login(manager)
|
||
item = SMSQueue.objects.get(customer=customer)
|
||
response = self.client.post(
|
||
reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}),
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
||
|
||
# تا پایان دورهٔ اول (موعد بعدی = ۲۰۲۴-۰۷-۰۱) هیچ یادآوری تازهای ساخته نمیشود.
|
||
for day in (datetime.date(2024, 2, 10), datetime.date(2024, 6, 10)):
|
||
self._scan_at(day)
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0,
|
||
msg='حذف باید همان دوره را کنسل نگه دارد')
|
||
# دورهٔ دوم (۱۸ ماهگی) ⇒ یادآوری تازه با کلید دیگر.
|
||
self._scan_at(datetime.date(2024, 7, 1))
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||
self.assertEqual(
|
||
SMSQueue.objects.get(customer=customer).dedup_key.rsplit(':', 1)[1], '2024-07',
|
||
)
|
||
|
||
# --- تصمیم ۳: نصب چرخه را ریست میکند ---
|
||
def test_nasb_action_resets_the_cycle(self):
|
||
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||
from admin_panel.models import Action
|
||
|
||
Action.objects.create(
|
||
customer=customer, actionType='nasb', status='finish',
|
||
actionDateTime=timezone.make_aware(
|
||
datetime.datetime(2024, 1, 1, 10, 0), timezone.get_current_timezone(),
|
||
),
|
||
)
|
||
# ۸ ماه بعد از نصب ⇒ هنوز موعد نرسیده
|
||
self._scan_at(datetime.date(2024, 9, 1))
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
|
||
# ۹ ماه کامل بعد از نصب ⇒ یادآوری
|
||
self._scan_at(datetime.date(2024, 10, 5))
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||
|
||
def test_phone_call_does_not_reset_the_cycle(self):
|
||
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||
from admin_panel.models import Action
|
||
|
||
Action.objects.create(
|
||
customer=customer, actionType='outCall', status='finish',
|
||
actionDateTime=timezone.make_aware(
|
||
datetime.datetime(2024, 5, 1, 10, 0), timezone.get_current_timezone(),
|
||
),
|
||
)
|
||
# ۹ ماه از نصب گذشته ⇒ باید معوق بماند، چون تماس تلفنی ریست نمیکند
|
||
self._scan_at(datetime.date(2024, 10, 5))
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||
|
||
# --- تصمیم ۴: پایان تعویق ⇒ اگر بازدید قدیمی است، بلافاصله معوق ---
|
||
def test_after_deferral_ends_old_visit_makes_customer_due_immediately(self):
|
||
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||
from admin_panel.models import Action
|
||
|
||
# آخرین بازدید واقعی: ۱۰ ماه پیش از پایان تعویق (۲۰۲۵-۰۳-۱۵)
|
||
Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='finish',
|
||
actionDateTime=timezone.make_aware(
|
||
datetime.datetime(2025, 3, 15, 10, 0), timezone.get_current_timezone(),
|
||
),
|
||
)
|
||
# تعویق فعال تا ۲۰۲۶-۰۱-۱۵ ⇒ در این بازه یادآوری نمیگیرد
|
||
Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='waite',
|
||
actionDateTime=timezone.make_aware(
|
||
datetime.datetime(2026, 1, 15, 10, 0), timezone.get_current_timezone(),
|
||
),
|
||
)
|
||
self._scan_at(datetime.date(2026, 1, 10))
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
|
||
# تعویق تمام شد؛ ۱۰ ماه از بازدید گذشته ⇒ همان روز یادآوری میگیرد
|
||
self._scan_at(datetime.date(2026, 1, 20))
|
||
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||
|
||
|
||
class NextAppointmentTests(TestCase):
|
||
"""تصمیم: هماهنگیِ «انجامشده» و بازدیدِ برنامهریزیشده هم مشتری را از فهرست خارج میکنند."""
|
||
|
||
def setUp(self):
|
||
from tests.factories import make_user
|
||
|
||
self.user = make_user('apptExpert', superuser=True)
|
||
self.client.force_login(self.user)
|
||
|
||
def _customer(self, name, install_days=400):
|
||
customer = make_customer(name)
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
installDate=timezone.localdate() - timedelta(days=install_days)
|
||
)
|
||
customer.refresh_from_db()
|
||
return customer
|
||
|
||
def _entezar_ids(self):
|
||
# نمای صریح «تعیین تکلیف نشده» (پیشفرض صفحه اکنون «پیگیری امروز» است).
|
||
response = self.client.get('/panel/entezar/?view=uncoordinated')
|
||
self.assertEqual(response.status_code, 200)
|
||
return {c.id for c in response.context['customers']}
|
||
|
||
def _add(self, customer, action_type, status, when, **extra):
|
||
return Action.objects.create(
|
||
customer=customer, actionType=action_type, status=status,
|
||
actionDateTime=when, **extra,
|
||
)
|
||
|
||
# --- خواستهٔ ۱: هماهنگیِ finish ---
|
||
def test_finished_hamahangi_hides_customer_until_appointment(self):
|
||
customer = self._customer('هماهنگی انجامشده')
|
||
# همین حالا تماس گرفتیم و برای هفتهٔ بعد توافق شد؛ هماهنگی «انجام شد».
|
||
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
|
||
self.assertNotIn(customer.pk, self._entezar_ids())
|
||
|
||
def test_finished_hamahangi_with_past_date_does_not_hide(self):
|
||
customer = self._customer('هماهنگی گذشته')
|
||
self._add(customer, 'hamahangi', 'finish', at(-2), finishDateTime=at(-2))
|
||
self.assertIn(customer.pk, self._entezar_ids())
|
||
|
||
def test_finished_hamahangi_falls_back_to_finish_datetime(self):
|
||
customer = self._customer('هماهنگی بدون تاریخ اقدام')
|
||
self._add(customer, 'hamahangi', 'finish', None, finishDateTime=at(+5))
|
||
self.assertNotIn(customer.pk, self._entezar_ids())
|
||
|
||
# --- خواستهٔ ۲: بازدید waite با تاریخ آینده ---
|
||
def test_scheduled_visit_hides_customer(self):
|
||
customer = self._customer('بازدید برنامهریزیشده')
|
||
self._add(customer, 'bazdid', 'waite', at(+5))
|
||
self.assertNotIn(customer.pk, self._entezar_ids())
|
||
|
||
def test_scheduled_visit_in_the_past_does_not_hide(self):
|
||
customer = self._customer('بازدید عقبافتاده')
|
||
self._add(customer, 'bazdid', 'waite', at(-5))
|
||
self.assertIn(customer.pk, self._entezar_ids())
|
||
|
||
# --- پس از تاریخ قرار، مشتری برمیگردد ---
|
||
def test_customer_returns_after_appointment_date(self):
|
||
from admin_panel.visit_policy import next_appointment_map
|
||
|
||
customer = self._customer('قرار منقضی')
|
||
self._add(customer, 'bazdid', 'waite', at(+3))
|
||
self.assertIn(customer.pk, next_appointment_map(now=at(0)))
|
||
self.assertNotIn(customer.pk, next_appointment_map(now=at(+4)))
|
||
|
||
# --- هماهنگی بازدید نیست ---
|
||
def test_hamahangi_never_changes_last_visit(self):
|
||
customer = self._customer('هماهنگی بر بازدید بیاثر')
|
||
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
|
||
self.assertIsNone(last_visit_dt(customer))
|
||
|
||
# --- همترازی صفحه و اسکنر ---
|
||
def test_scanner_also_skips_customer_with_appointment(self):
|
||
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||
from admin_panel.sms.scanner import scan_visit
|
||
|
||
customer = self._customer('قرار + اسکنر')
|
||
PhoneNumber.objects.create(customer=customer, phone_number='09120000999')
|
||
config = SMSProviderConfig.load()
|
||
config.allow_backlog = True
|
||
config.backlog_max = 50
|
||
config.scan_since = at(-2000)
|
||
config.save()
|
||
self._add(customer, 'bazdid', 'waite', at(+5))
|
||
|
||
now = at(0)
|
||
scan_visit(timezone.localdate(), now=now)
|
||
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
||
|
||
|
||
class LastVisitMapEquivalenceTests(TestCase):
|
||
"""نسخهٔ SQL ``last_visit_map`` باید دقیقاً همان خروجی نسخهٔ پایتونی را بدهد."""
|
||
|
||
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
|
||
|
||
def setUp(self):
|
||
# رکوردهای مرزی که قاعده را به چالش میکشند.
|
||
cases = [
|
||
# (action, finish) هر دو در گذشته ⇒ action ملاک است.
|
||
(self.NOW - timedelta(days=30), self.NOW - timedelta(days=31)),
|
||
# action در آینده ⇒ finish ملاک است.
|
||
(self.NOW + timedelta(days=10), self.NOW - timedelta(days=2)),
|
||
# هر دو در آینده ⇒ هیچ تاریخ معتبری ندارد.
|
||
(self.NOW + timedelta(days=10), self.NOW + timedelta(days=11)),
|
||
# فقط action.
|
||
(self.NOW - timedelta(days=5), None),
|
||
# فقط finish.
|
||
(None, self.NOW - timedelta(days=4)),
|
||
# هیچکدام.
|
||
(None, None),
|
||
# دقیقاً برابر «حالا».
|
||
(self.NOW, self.NOW),
|
||
]
|
||
for index, (action_dt, finish_dt) in enumerate(cases):
|
||
customer = make_customer(f'همسانی {index}')
|
||
Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='finish',
|
||
actionDateTime=action_dt, finishDateTime=finish_dt,
|
||
)
|
||
|
||
def test_sql_matches_python_reference(self):
|
||
from admin_panel.visit_policy import _last_visit_map_python
|
||
|
||
py = _last_visit_map_python(now=self.NOW)
|
||
sql = last_visit_map(now=self.NOW)
|
||
self.assertEqual(py, sql)
|
||
|
||
def test_sql_matches_reference_for_other_clocks(self):
|
||
from admin_panel.visit_policy import _last_visit_map_python
|
||
|
||
for delta in (timedelta(days=-365), timedelta(days=1), timedelta(days=400)):
|
||
now = self.NOW + delta
|
||
self.assertEqual(
|
||
_last_visit_map_python(now=now), last_visit_map(now=now),
|
||
msg=f'clock shift {delta}',
|
||
)
|
||
|
||
def test_timezone_cutoff_is_utc_not_local(self):
|
||
"""مرز زمانی نباید با منطقهٔ محلی جابهجا شود (باگ ۳:۳۰ ساعتهٔ تهران)."""
|
||
customer = make_customer('مرز زمانی')
|
||
# رکوردی که ۵ دقیقه بعد از «حالا» است ⇒ نباید حساب شود.
|
||
Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='finish',
|
||
actionDateTime=self.NOW + timedelta(minutes=5),
|
||
finishDateTime=self.NOW + timedelta(minutes=5),
|
||
)
|
||
self.assertNotIn(customer.pk, last_visit_map(now=self.NOW))
|
||
|
||
|
||
class NextAppointmentEquivalenceTests(TestCase):
|
||
"""نسخهٔ SQL ``next_appointment_map`` باید همان نگاشت مرجع باشد."""
|
||
|
||
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
|
||
|
||
def test_matches_reference(self):
|
||
from admin_panel.visit_policy import next_appointment_map
|
||
|
||
customer_a = make_customer('قرار الف')
|
||
customer_b = make_customer('قرار ب')
|
||
customer_c = make_customer('قرار ج')
|
||
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
|
||
actionDateTime=self.NOW + timedelta(days=5))
|
||
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
|
||
actionDateTime=self.NOW + timedelta(days=9))
|
||
Action.objects.create(customer=customer_b, actionType='hamahangi', status='finish',
|
||
actionDateTime=self.NOW + timedelta(days=3),
|
||
finishDateTime=self.NOW)
|
||
Action.objects.create(customer=customer_c, actionType='bazdid', status='waite',
|
||
actionDateTime=self.NOW - timedelta(days=5))
|
||
|
||
result = next_appointment_map(now=self.NOW)
|
||
self.assertEqual(result[customer_a.pk], self.NOW + timedelta(days=5))
|
||
self.assertEqual(result[customer_b.pk], self.NOW + timedelta(days=3))
|
||
self.assertNotIn(customer_c.pk, result)
|
||
|
||
|
||
class VisitHoldTests(TestCase):
|
||
"""«قرار بعدی» (تعویق از فرم هماهنگی): هستهٔ رفع باگ + ریسکهای شناساییشده."""
|
||
|
||
def setUp(self):
|
||
from tests.factories import make_user
|
||
|
||
self.user = make_user('holdExpert', superuser=True)
|
||
self.client.force_login(self.user)
|
||
|
||
# ---------- helpers ----------
|
||
def _customer(self, name):
|
||
customer = make_customer(name)
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
installDate=timezone.localdate() - timedelta(days=400)
|
||
)
|
||
customer.refresh_from_db()
|
||
return customer
|
||
|
||
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='uncoordinated'):
|
||
return {c.id for c in self._entezar(view).context['customers']}
|
||
|
||
def _post_action(self, customer, **payload):
|
||
base = {'desc': 'تست', 'userID': self.user.id}
|
||
base.update(payload)
|
||
for key in [k for k, v in list(base.items()) if v is None]:
|
||
base.pop(key)
|
||
return self.client.post(
|
||
reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.pk}), base,
|
||
)
|
||
|
||
def _ms(self, moment):
|
||
return str(int(moment.timestamp() * 1000))
|
||
|
||
def _post_edit(self, customer, action, **payload):
|
||
base = {'desc': 'تست', 'userID': self.user.id}
|
||
base.update(payload)
|
||
for key in [k for k, v in list(base.items()) if v is None]:
|
||
base.pop(key)
|
||
return self.client.post(
|
||
reverse('admin_panel:customerEditAction',
|
||
kwargs={'c_id': customer.pk, 'action_id': action.pk}),
|
||
base,
|
||
HTTP_REFERER='/panel/customer/%d/action' % customer.pk,
|
||
)
|
||
|
||
# ---------- ویرایش: تبدیل بازدید به هماهنگی با تاریخ پیگیری ----------
|
||
def test_editing_action_into_coordination_saves_hold(self):
|
||
"""تبدیل یک اقدام «بازدید» موجود به «هماهنگی» باید تاریخ پیگیری را ذخیره کند."""
|
||
customer = self._customer('تبدیل به هماهنگی')
|
||
action = Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() + timedelta(days=5),
|
||
desc='بازدید اولیه', forUser=self.user,
|
||
)
|
||
target = timezone.now() + timedelta(days=20)
|
||
|
||
response = self._post_edit(
|
||
customer, action,
|
||
actionType='hamahangi',
|
||
dateTimeAnjam=self._ms(timezone.now()),
|
||
visitHoldUntil=self._ms(target),
|
||
is_done='on',
|
||
FinishDateTime=self._ms(timezone.now()),
|
||
result='هماهنگ شد',
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
customer.refresh_from_db()
|
||
action.refresh_from_db()
|
||
self.assertEqual(action.actionType, 'hamahangi')
|
||
self.assertIsNotNone(customer.visit_hold_until, 'تاریخ پیگیری ذخیره نشد')
|
||
self.assertEqual(customer.visit_hold_until.date(), target.date())
|
||
|
||
def test_edit_modal_prefills_saved_hold(self):
|
||
"""هنگام بازکردن مجدد اقدام، فیلد باید مقدار ذخیرهشده را نشان دهد.
|
||
|
||
رگرسیون: مودال مقدار را از دیتابیس نمیخواند، پس همیشه خالی دیده میشد
|
||
(کاربر فکر میکرد «ثبت نشده»).
|
||
"""
|
||
customer = self._customer('نمایش قرار در ویرایش')
|
||
Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5),
|
||
desc='هماهنگی', forUser=self.user,
|
||
)
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=18))
|
||
|
||
html = self.client.get(
|
||
reverse('admin_panel:customerAction', kwargs={'c_id': customer.pk})
|
||
).content.decode()
|
||
# ردیفها مقدار ذخیرهشده را بهصورت ISO در خود دارند...
|
||
self.assertIn('customer_hold=', html)
|
||
self.assertIn('function seedHoldPicker', html)
|
||
self.assertIn('seedHoldPicker(customerHold)', html)
|
||
|
||
def test_editing_old_visit_does_not_wipe_newer_hold(self):
|
||
"""ویرایش یک اقدام قدیمی نباید قرار هماهنگیِ جدیدتر را پاک کند."""
|
||
customer = self._customer('ویرایش قدیمی')
|
||
old_visit = Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(days=400),
|
||
finishDateTime=timezone.now() - timedelta(days=400),
|
||
desc='بازدید قدیمی', forUser=self.user,
|
||
)
|
||
Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5),
|
||
desc='هماهنگی جدید', forUser=self.user,
|
||
)
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=20))
|
||
|
||
# فقط توضیح اقدام قدیمی را عوض میکنیم
|
||
self._post_edit(
|
||
customer, old_visit,
|
||
actionType='bazdid',
|
||
dateTimeAnjam=self._ms(timezone.now() - timedelta(days=400)),
|
||
is_done='on',
|
||
FinishDateTime=self._ms(timezone.now() - timedelta(days=400)),
|
||
desc='توضیح اصلاح شد',
|
||
)
|
||
customer.refresh_from_db()
|
||
self.assertIsNotNone(customer.visit_hold_until,
|
||
'ویرایش اقدام قدیمی قرار معتبر را پاک کرد')
|
||
self.assertIn(customer.pk, self._ids('manual'))
|
||
|
||
# ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
|
||
def test_hold_until_today_hides_customer_until_end_of_day(self):
|
||
customer = self._customer('قرار امروز')
|
||
self._post_action(
|
||
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
||
is_done='on', visitHoldUntil=self._ms(timezone.now()),
|
||
)
|
||
customer.refresh_from_db()
|
||
self.assertIsNotNone(customer.visit_hold_until)
|
||
local = customer.visit_hold_until.astimezone(timezone.get_current_timezone())
|
||
self.assertEqual((local.hour, local.minute), (23, 59))
|
||
self.assertNotIn(customer.pk, self._ids())
|
||
|
||
# ---------- ریسک ۲: finish بدون تاریخ قرار ⇒ پیشفرض ۱۴ روز ----------
|
||
def test_finished_coordination_without_hold_defaults_to_two_weeks(self):
|
||
customer = self._customer('هماهنگی بدون تاریخ قرار')
|
||
self._post_action(
|
||
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
||
is_done='on',
|
||
)
|
||
customer.refresh_from_db()
|
||
self.assertIsNotNone(customer.visit_hold_until)
|
||
days = (customer.visit_hold_until.date() - timezone.localdate()).days
|
||
self.assertEqual(days, 14)
|
||
self.assertNotIn(customer.pk, self._ids())
|
||
|
||
# ---------- ریسک ۳: حذف/لغو هماهنگی ⇒ قرار بیاثر ----------
|
||
def test_hold_becomes_inert_when_coordination_is_deleted(self):
|
||
customer = self._customer('حذف هماهنگی')
|
||
self._post_action(
|
||
customer, actionType='hamahangi',
|
||
dateTimeAnjam=self._ms(timezone.now() + timedelta(days=10)),
|
||
is_done='on',
|
||
visitHoldUntil=self._ms(timezone.now() + timedelta(days=10)),
|
||
)
|
||
self.assertNotIn(customer.pk, self._ids())
|
||
Action.objects.filter(customer=customer).delete()
|
||
customer.refresh_from_db()
|
||
self.assertIsNotNone(customer.visit_hold_until) # ستون باقی است…
|
||
self.assertIn(customer.pk, self._ids()) # …ولی بیاثر شده است
|
||
|
||
def test_hold_becomes_inert_when_coordination_is_cancelled(self):
|
||
customer = self._customer('لغو هماهنگی')
|
||
action = Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() + timedelta(days=10),
|
||
finishDateTime=timezone.now(),
|
||
)
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=10))
|
||
self.assertNotIn(customer.pk, self._ids())
|
||
Action.objects.filter(pk=action.pk).update(status='cancell')
|
||
self.assertIn(customer.pk, self._ids())
|
||
|
||
# ---------- ریسک ۴: قاعدهٔ «فقط رو به جلو» ----------
|
||
def test_second_coordination_does_not_pull_hold_backwards(self):
|
||
customer = self._customer('قرار قبلی دورتر')
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=30))
|
||
written = set_visit_hold(customer, timezone.now() + timedelta(days=5))
|
||
self.assertFalse(written)
|
||
customer.refresh_from_db()
|
||
self.assertEqual(
|
||
customer.visit_hold_until.date(),
|
||
(timezone.now() + timedelta(days=30)).date(),
|
||
)
|
||
|
||
def test_second_coordination_can_extend_hold(self):
|
||
customer = self._customer('تمدید قرار')
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=5))
|
||
written = set_visit_hold(customer, timezone.now() + timedelta(days=20))
|
||
self.assertTrue(written)
|
||
customer.refresh_from_db()
|
||
self.assertEqual(
|
||
customer.visit_hold_until.date(),
|
||
(timezone.now() + timedelta(days=20)).date(),
|
||
)
|
||
|
||
|
||
# ---------- سقف ۶ ماه و تاریخ گذشته (اعتبارسنجی سرور) ----------
|
||
def test_hold_beyond_six_months_is_rejected(self):
|
||
customer = self._customer('تعویق بلند')
|
||
response = self._post_action(
|
||
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
||
is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=200)),
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertFalse(Action.objects.filter(customer=customer).exists())
|
||
customer.refresh_from_db()
|
||
self.assertIsNone(customer.visit_hold_until)
|
||
|
||
def test_hold_in_the_past_is_rejected(self):
|
||
with self.assertRaises(ValidationError):
|
||
normalize_hold_until(timezone.now() - timedelta(days=3))
|
||
|
||
def test_hold_none_returns_none(self):
|
||
self.assertIsNone(normalize_hold_until(None))
|
||
|
||
# ---------- بازدید واقعی ⇒ مشتری از فهرست خارج میشود ----------
|
||
def test_real_visit_clears_hold_and_leaves_list(self):
|
||
customer = self._customer('بازدید واقعی')
|
||
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() + timedelta(days=10),
|
||
finishDateTime=timezone.now())
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=10))
|
||
self.assertNotIn(customer.pk, self._ids())
|
||
|
||
self._post_action(
|
||
customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now()),
|
||
is_done='on', FinishDateTime=self._ms(timezone.now()), result='انجام شد',
|
||
)
|
||
customer.refresh_from_db()
|
||
self.assertIsNotNone(customer.last_bazdid_dt)
|
||
self.assertNotIn(customer.pk, self._ids('all'))
|
||
|
||
# ---------- هماهنگیِ finish با تاریخ آینده حتی بدون ستون، قرار میسازد ----------
|
||
def test_coordination_without_hold_still_hides_via_action(self):
|
||
customer = self._customer('هماهنگی بدون ستون')
|
||
Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() + timedelta(days=6),
|
||
finishDateTime=timezone.now(),
|
||
)
|
||
self.assertNotIn(customer.pk, self._ids())
|
||
|
||
# ---------- ثبت بازدید پس از هماهنگی ⇒ قرار هماهنگی منتفی میشود ----------
|
||
def test_visit_created_after_coordination_clears_manual_hold(self):
|
||
"""سناریوی گزارششده: هماهنگی + قرار، سپس ثبت «بازدید».
|
||
|
||
انتظار: مشتری از `view=manual` خارج شود و در `view=scheduled` دیده شود.
|
||
"""
|
||
customer = self._customer('بازدید بعد از هماهنگی')
|
||
Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='finish',
|
||
# «چند دقیقه پیش تماس گرفتم» — از مرز لحظهای فاصله دارد.
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5),
|
||
)
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=2))
|
||
self.assertIn(customer.pk, self._ids('manual'))
|
||
|
||
# بازدید برنامهریزیشده (بدون تیک «انجام شد»).
|
||
self._post_action(
|
||
customer, actionType='bazdid',
|
||
dateTimeAnjam=self._ms(timezone.now() + timedelta(hours=2)),
|
||
desc='بازدید برنامهریزی شد',
|
||
)
|
||
|
||
customer.refresh_from_db()
|
||
self.assertIsNone(customer.visit_hold_until, 'قرار دستی باید پاک شود')
|
||
self.assertNotIn(customer.pk, self._ids('manual'))
|
||
self.assertIn(customer.pk, self._ids('scheduled'))
|
||
|
||
def test_visit_created_after_coordination_without_hold_still_schedules(self):
|
||
customer = self._customer('بدون قرار قبلی')
|
||
self._post_action(
|
||
customer, actionType='bazdid',
|
||
dateTimeAnjam=self._ms(timezone.now() + timedelta(days=3)),
|
||
desc='بازدید',
|
||
)
|
||
self.assertIn(customer.pk, self._ids('scheduled'))
|
||
|
||
def test_old_visit_does_not_void_newer_coordination(self):
|
||
"""بازدید قدیمیتر نباید قرار هماهنگیِ جدیدتر را باطل کند."""
|
||
customer = self._customer('هماهنگی جدیدتر')
|
||
Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(days=400),
|
||
finishDateTime=timezone.now() - timedelta(days=400),
|
||
)
|
||
Action.objects.create(
|
||
customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5),
|
||
)
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=20))
|
||
self.assertIn(customer.pk, self._ids('manual'))
|
||
|
||
def test_scheduled_visit_earlier_today_still_counts(self):
|
||
"""بازدیدِ برنامهریزیشده برای «امروز» تا پایان امروز معتبر است.
|
||
|
||
رگرسیون: فیلتر قبلی لحظهای بود و بازدیدی که ساعتش چند دقیقه پیش گذشته
|
||
بود از فهرست ناپدید میشد.
|
||
"""
|
||
customer = self._customer('بازدید امروز')
|
||
Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() - timedelta(minutes=30),
|
||
)
|
||
self.assertIn(customer.pk, self._ids('scheduled'))
|
||
|
||
def test_scheduled_visit_yesterday_is_not_upcoming(self):
|
||
customer = self._customer('بازدید دیروز')
|
||
Action.objects.create(
|
||
customer=customer, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() - timedelta(days=1),
|
||
)
|
||
self.assertNotIn(customer.pk, self._ids('scheduled'))
|
||
|
||
|
||
# ---------- تفکیک چهارگروهی و فیلترها ----------
|
||
def test_groups_and_filters(self):
|
||
a = self._customer('الف بدون قرار')
|
||
b = self._customer('ب قرار دستی')
|
||
c = self._customer('ج بازدید برنامه')
|
||
d = self._customer('د هر دو')
|
||
|
||
# b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد).
|
||
Action.objects.create(customer=b, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5))
|
||
set_visit_hold(b, timezone.now() + timedelta(days=20))
|
||
|
||
Action.objects.create(customer=c, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() + timedelta(days=7))
|
||
|
||
# d: بازدید برنامهریزیشده (قدیمیتر) + قرار دستی (جدیدتر).
|
||
# ترتیب مهم است: اگر بازدید *پس از* هماهنگی ثبت شود، هماهنگی منتفی
|
||
# محسوب میشود (تست جداگانهٔ زیر). اینجا برعکس است تا گروه D پوشش داده شود.
|
||
Action.objects.create(customer=d, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() + timedelta(days=9))
|
||
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5))
|
||
set_visit_hold(d, timezone.now() + timedelta(days=25))
|
||
|
||
counts = self._entezar().context['counts']
|
||
self.assertEqual(counts['uncoordinated'], 1) # a
|
||
# هر کارت فقط منبع خودش را میشمارد؛ d هر دو منبع را دارد ⇒ در هر سه
|
||
# کارت شمرده میشود (قرار دستی، بازدید، با قرار).
|
||
self.assertEqual(counts['manual'], 2) # b + d
|
||
self.assertEqual(counts['scheduled'], 2) # c + d
|
||
self.assertEqual(counts['coordination'], 0) # هیچکدام هماهنگیِ فعال ندارد
|
||
self.assertEqual(counts['total'], 4)
|
||
self.assertEqual(counts['coordinated'], 3)
|
||
|
||
self.assertEqual(self._ids(), {a.pk})
|
||
self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk})
|
||
self.assertEqual(self._ids('manual'), {b.pk, d.pk})
|
||
self.assertEqual(self._ids('scheduled'), {c.pk, d.pk})
|
||
self.assertEqual(self._ids('coordination'), set())
|
||
self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk})
|
||
|
||
def test_invalid_view_falls_back_to_default(self):
|
||
a = self._customer('فقط الف')
|
||
# پیشفرض صفحه «نیازمند پیگیری امروز» است؛ مشتری بدون موعد در آن نیست،
|
||
# ولی در نمای «تعیین تکلیف نشده» دیده میشود.
|
||
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):
|
||
"""عدد هر کارت باید با تعداد ردیفهای همان فیلتر یکی باشد.
|
||
|
||
رگرسیون: کارت «قرار هماهنگی» فقط گروه B را میشمرد، ولی فیلتر
|
||
``view=manual`` گروه D را هم نشان میداد؛ کاربر عدد ۰ میدید و با کلیک
|
||
ردیف پیدا میکرد.
|
||
"""
|
||
b = self._customer('قرار دستی تنها')
|
||
Action.objects.create(customer=b, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5))
|
||
set_visit_hold(b, timezone.now() + timedelta(days=20))
|
||
|
||
d = self._customer('هم قرار و هم بازدید')
|
||
Action.objects.create(customer=d, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() + timedelta(days=9))
|
||
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() - timedelta(minutes=5),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5))
|
||
set_visit_hold(d, timezone.now() + timedelta(days=25))
|
||
|
||
counts = self._entezar().context['counts']
|
||
self.assertEqual(counts['manual'], len(self._ids('manual')))
|
||
self.assertEqual(counts['scheduled'], len(self._ids('scheduled')))
|
||
self.assertEqual(counts['coordination'], len(self._ids('coordination')))
|
||
self.assertEqual(counts['coordinated'], len(self._ids('coordinated')))
|
||
self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated')))
|
||
self.assertEqual(counts['total'], len(self._ids('all')))
|
||
|
||
def test_card_number_matches_list_for_every_new_view(self):
|
||
"""هر سه منبع، فیلتر مستقل خودش را دارد و عدد کارت با فهرست میخواند."""
|
||
# فقط هماهنگی (بدون بازدید) — موردِ باگ گزارششده
|
||
coord = self._customer('فقط هماهنگی')
|
||
Action.objects.create(customer=coord, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() + timedelta(days=8),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5))
|
||
# فقط بازدید
|
||
sched = self._customer('فقط بازدید')
|
||
Action.objects.create(customer=sched, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() + timedelta(days=5))
|
||
# هر دو
|
||
both = self._customer('بازدید و هماهنگی')
|
||
Action.objects.create(customer=both, actionType='bazdid', status='waite',
|
||
actionDateTime=timezone.now() + timedelta(days=6))
|
||
Action.objects.create(customer=both, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() + timedelta(days=9),
|
||
finishDateTime=timezone.now() - timedelta(minutes=5))
|
||
|
||
counts = self._entezar().context['counts']
|
||
# «هماهنگی» هرگز در نمای «بازدید برنامهریزیشده» ظاهر نمیشود.
|
||
self.assertNotIn(coord.pk, self._ids('scheduled'))
|
||
self.assertIn(coord.pk, self._ids('coordination'))
|
||
# و برعکس.
|
||
self.assertNotIn(sched.pk, self._ids('coordination'))
|
||
self.assertIn(sched.pk, self._ids('scheduled'))
|
||
# مشتریِ هر دو منبع، در هر دو نما هست.
|
||
self.assertIn(both.pk, self._ids('scheduled'))
|
||
self.assertIn(both.pk, self._ids('coordination'))
|
||
# اما در «با قرار» فقط یکبار شمرده میشود.
|
||
self.assertIn(both.pk, self._ids('coordinated'))
|
||
|
||
for key in ('manual', 'scheduled', 'coordination', 'coordinated',
|
||
'uncoordinated', 'total'):
|
||
self.assertEqual(counts[key], len(self._ids(
|
||
'all' if key == 'total' else key)), f'عدد کارت {key} با فهرست نمیخواند')
|
||
|
||
def test_stats_cards_have_minimal_descriptions(self):
|
||
"""هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنیاش روشن باشد."""
|
||
html = self._entezar().content.decode()
|
||
# سرِ نوار آمار + توضیح «عقبافتاده»
|
||
self.assertIn('ez-stats-title', html)
|
||
self.assertIn('ez-stats-hint', html)
|
||
# چهار کارت: پیگیری امروز + سه کارت وضعیت
|
||
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
|
||
from admin_panel.sms.scanner import scan_visit
|
||
|
||
customer = self._customer('قرار + اسکنر')
|
||
PhoneNumber.objects.create(customer=customer, phone_number='09120000555')
|
||
config = SMSProviderConfig.load()
|
||
config.allow_backlog = True
|
||
config.backlog_max = 50
|
||
config.scan_since = timezone.now() - timedelta(days=2000)
|
||
config.save()
|
||
|
||
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=timezone.now() + timedelta(days=15),
|
||
finishDateTime=timezone.now())
|
||
set_visit_hold(customer, timezone.now() + timedelta(days=15))
|
||
|
||
scan_visit(timezone.localdate())
|
||
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
||
|
||
# ---------- اجتماع منابع ----------
|
||
def test_next_appointment_is_union_of_sources(self):
|
||
from admin_panel.visit_policy import next_appointment_map
|
||
|
||
customer = self._customer('اجتماع منابع')
|
||
now = timezone.now()
|
||
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
||
actionDateTime=now + timedelta(days=30),
|
||
finishDateTime=now)
|
||
set_visit_hold(customer, now + timedelta(days=20))
|
||
merged = next_appointment_map(now=now)
|
||
self.assertIn(customer.pk, merged)
|
||
# قرار دستی «پایان روز» است، پس مقایسه در سطح تاریخ انجام میشود.
|
||
self.assertLessEqual(merged[customer.pk].date(), (now + timedelta(days=20)).date())
|
||
|
||
|
||
class VisitDedupCleanupTests(TestCase):
|
||
"""پاکسازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
|
||
|
||
class ActionModalTemplateTests(TestCase):
|
||
"""قرارداد فرم «ایجاد وظیفه» (`customerActionInc.html`).
|
||
|
||
این تستها تضمین میکنند بازطراحی UI، نام فیلدها/شناسههایی را که ویو
|
||
انتظار دارد تغییر ندهد (چون ویو با ``request.POST.get(name)`` میخواند) و
|
||
مودال با همان تقویم شمسی موجود کار کند.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self.user = make_user('modal_viewer')
|
||
self.user.user_permissions.add(*Permission.objects.filter(
|
||
content_type__app_label='admin_panel'))
|
||
self.client.force_login(self.user)
|
||
self.customer = make_customer('مشتری مودال')
|
||
self.html = self.client.get(
|
||
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
|
||
).content.decode()
|
||
|
||
def test_expected_form_fields_exist(self):
|
||
# اینها همان کلیدهایی هستند که ویو از request.POST میخواند.
|
||
for name in ('actionType', 'dateTimeAnjam', 'visitHoldUntil', 'desc',
|
||
'userID', 'is_done', 'FinishDateTime', 'result'):
|
||
self.assertIn(f'name="{name}"', self.html, f'فیلد {name} گم شده است')
|
||
|
||
def test_javascript_hooks_exist(self):
|
||
# JS داخل قالب و ویوهای دیگر به این شناسهها و کلاسها وابستهاند.
|
||
for element_id in ('actionType', 'userID', 'checkIsDone', 'sabtBtn05',
|
||
'ActionBox', 'holdHelpToggle', 'holdHelpPanel'):
|
||
self.assertIn(f'id="{element_id}"', self.html, f'شناسه {element_id} گم شده است')
|
||
for cls in ('closeActionBox', 'showWhenDone', 'showWhenCoordination'):
|
||
self.assertIn(cls, self.html, f'کلاس {cls} گم شده است')
|
||
|
||
def test_persian_datepickers_are_used(self):
|
||
for cls in ('dateTimePickerAnjam', 'dateTimePickerFinish', 'dateTimePickerHoldUntil'):
|
||
self.assertIn(cls, self.html, f'تقویم {cls} گم شده است')
|
||
|
||
def test_help_panel_is_collapsed_by_default(self):
|
||
# پنل راهنما باید بسته باشد و با aria-expanded مدیریت شود.
|
||
self.assertIn('aria-expanded="false"', self.html)
|
||
self.assertIn('ax-help-panel is-hidden', self.html)
|
||
self.assertIn('aria-controls="holdHelpPanel"', self.html)
|
||
|
||
def test_help_is_scoped_to_coordination(self):
|
||
# راهنما و فیلد تاریخ پیگیری فقط داخل بلوک «هماهنگی» هستند.
|
||
for marker in ('ax-help-wrap showWhenCoordination is-hidden',
|
||
'ax-field showWhenCoordination is-hidden'):
|
||
self.assertIn(marker, self.html, f'دامنهٔ نمایش {marker} اشتباه است')
|
||
|
||
def test_form_still_posts_to_new_action(self):
|
||
self.assertIn(
|
||
reverse('admin_panel:customerNewAction', kwargs={'c_id': self.customer.pk}),
|
||
self.html,
|
||
)
|
||
|
||
def test_ui_classes_are_namespaced(self):
|
||
# پیشوند ax- جلوی تداخل استایل با سایر صفحات را میگیرد.
|
||
self.assertIn('ax-card', self.html)
|
||
self.assertIn('ax-section', self.html)
|
||
|
||
|
||
class CoordinationDoneConfirmTests(TestCase):
|
||
"""پیشفرض «انجام شده» برای هماهنگی + هشدار و تأیید پیش از ثبت.
|
||
|
||
قواعد قفلشده:
|
||
* فقط «هماهنگی» تیکِ خودکار میگیرد؛ سایر انواع اقدام دستنخوردهاند.
|
||
* پیش از ثبتِ هماهنگیِ انجامشده، تأیید گرفته میشود.
|
||
* در حالت ویرایش و برای انواع دیگر، هیچ تأییدی در مسیر نیست.
|
||
"""
|
||
|
||
def setUp(self):
|
||
self.user = make_user('confirm_viewer')
|
||
self.user.user_permissions.add(*Permission.objects.filter(
|
||
content_type__app_label='admin_panel'))
|
||
self.client.force_login(self.user)
|
||
self.customer = make_customer('مشتری تأیید هماهنگی')
|
||
self.html = self.client.get(
|
||
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
|
||
).content.decode()
|
||
|
||
def test_warning_panel_starts_hidden(self):
|
||
self.assertIn('id="holdDoneWarning"', self.html)
|
||
block = self.html.split('id="holdDoneWarning"')[0].rsplit('<div', 1)[-1]
|
||
self.assertIn('is-hidden', block, 'پنل هشدار نباید از ابتدا باز باشد')
|
||
|
||
def test_warning_text_is_present(self):
|
||
self.assertIn('آیا مطمئن هستی که هماهنگی را انجام دادی؟', self.html)
|
||
self.assertIn('اگر هنوز هماهنگی نکردی، این تیک را بردار.', self.html)
|
||
|
||
def test_confirm_buttons_exist(self):
|
||
self.assertIn('id="holdConfirmYes"', self.html)
|
||
self.assertIn('id="holdConfirmNo"', self.html)
|
||
|
||
def test_autocheck_only_for_coordination(self):
|
||
# تیک خودکار باید داخل شاخهٔ hamahangi باشد و برای بقیه برداشته شود.
|
||
block = self.html.split("$(\"select#actionType\").on('change'", 1)[1]
|
||
block = block.split("});", 1)[0]
|
||
self.assertIn("type === 'hamahangi'", block)
|
||
self.assertIn('holdAutoChecked = true', block)
|
||
self.assertIn('holdAutoChecked = false', block)
|
||
|
||
def test_confirmation_is_gated_before_submit(self):
|
||
# هندلر «انجام شد» دومی است؛ هندلر اول مربوط به «عقببردن تاریخ قرار»
|
||
# است. پس بلوک را از هندلری میگیریم که به showHoldConfirm میرسد.
|
||
block = self.html.split('showHoldConfirm();', 1)[0].rsplit(
|
||
'$("#ActionBox").on(\'submit\'', 1)[1]
|
||
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
|
||
self.assertIn('isEditMode || holdConfirmed', block)
|
||
self.assertIn("!== 'hamahangi'", block)
|
||
self.assertIn('prop("checked")', block)
|
||
self.assertIn('event.preventDefault()', block)
|
||
|
||
def test_cancelling_confirmation_unchecks(self):
|
||
block = self.html.split('$("#holdConfirmNo").click', 1)[1]
|
||
block = block.split('});', 1)[0]
|
||
self.assertIn('prop("checked", false)', block)
|
||
|
||
def test_edit_mode_skips_confirmation(self):
|
||
# با کلیک روی ردیف موجود، isEditMode روشن میشود ⇒ مسیر تأیید رد میشود.
|
||
self.assertIn('isEditMode = true', self.html)
|
||
self.assertIn('isEditMode = false', self.html)
|
||
|
||
def test_other_action_types_keep_old_done_toggle(self):
|
||
# تیک «انجام شده» همچنان فیلدهای اتمام/نتیجه را نشان میدهد.
|
||
self.assertIn('function syncDoneFields', self.html)
|
||
self.assertIn('showWhenDone', self.html)
|
||
|
||
def test_every_picker_selector_matches_markup(self):
|
||
"""هر کلاسی که در JS برای ساخت تقویم استفاده میشود باید در قالب باشد.
|
||
|
||
این تست جلوی باگ «تقویم باز نمیشود» را میگیرد: اگر نام کلاس در قالب
|
||
با سلکتور جاوااسکریپت یکی نباشد، ``pDatepicker`` روی مجموعهٔ خالی
|
||
اجرا میشود، ``undefined`` برمیگرداند و ``setDate`` با TypeError
|
||
میشکند — که باعث از کار افتادن بقیهٔ منطق مودال هم میشد.
|
||
"""
|
||
selectors = re.findall(r'\$\("\.([\w-]+)"\)\.pDatepicker\(', self.html)
|
||
self.assertTrue(selectors, 'هیچ پیکری در قالب پیدا نشد')
|
||
for selector in selectors:
|
||
self.assertRegex(
|
||
self.html,
|
||
rf'class="[^"]*\b{re.escape(selector)}\b',
|
||
f'تقویم .{selector} در JS هست ولی در قالب هیچ المانی با این کلاس نیست',
|
||
)
|
||
|
||
def test_every_alt_field_class_exists_in_markup(self):
|
||
"""کلاس ``altField`` هر پیکر باید در قالب وجود داشته باشد."""
|
||
alt_fields = re.findall(r"altField:\s*'\.([\w-]+)'", self.html)
|
||
self.assertTrue(alt_fields, 'هیچ altField ای پیدا نشد')
|
||
for selector in alt_fields:
|
||
self.assertRegex(
|
||
self.html,
|
||
rf'class="[^"]*\b{re.escape(selector)}\b',
|
||
f'altField .{selector} در قالب وجود ندارد',
|
||
)
|
||
|
||
def test_hold_picker_calls_are_guarded(self):
|
||
"""فراخوانی setDate پیکر «تاریخ پیگیری» باید فقط از مسیر محافظشده باشد.
|
||
|
||
چون ``pDatepicker`` در صورت عدم تطبیق سلکتور ``undefined`` میدهد،
|
||
فراخوانی مستقیم ``.setDate()`` کل هندلر را میشکند. پس تنها فراخوانی
|
||
مجاز، همان یکی داخل ``resetHoldPicker`` است و بقیهٔ نقاط باید از آن
|
||
استفاده کنند.
|
||
"""
|
||
self.assertIn('function resetHoldPicker', self.html)
|
||
calls = re.findall(r'dateTimePickerHoldUntil\.setDate\(\)', self.html)
|
||
self.assertEqual(len(calls), 1,
|
||
'همهٔ ریستها باید از resetHoldPicker عبور کنند')
|
||
|
||
# آن یک فراخوانی باید داخل تابع محافظ باشد (بعد از بررسی typeof).
|
||
func = self.html.split('function resetHoldPicker', 1)[1].split('\n }', 1)[0]
|
||
self.assertIn('typeof dateTimePickerHoldUntil.setDate === \'function\'', func)
|
||
self.assertIn('dateTimePickerHoldUntil.setDate()', func)
|
||
|
||
# نقاط استفاده باید resetHoldPicker را صدا بزنند.
|
||
self.assertGreaterEqual(self.html.count('resetHoldPicker()'), 3)
|
||
|
||
|
||
class EntezarLayoutTests(TestCase):
|
||
"""ثابتهای چیدمان صفحهٔ انتظار (جلوگیری از رگرسیون UI).
|
||
|
||
این تستها چیزهایی را قفل میکنند که با یک ویرایش ساده میشکنند و در
|
||
تستهای منطقی دیده نمیشوند: تناسب ستونها، حذف ستون «ردیف»، و همترازی
|
||
مرز جدول/کارت.
|
||
"""
|
||
|
||
URL = '/panel/entezar/'
|
||
|
||
def setUp(self):
|
||
self.user = make_user('layout_viewer')
|
||
self.user.user_permissions.add(*Permission.objects.filter(
|
||
content_type__app_label='admin_panel'))
|
||
self.client.force_login(self.user)
|
||
customer = make_customer('مشتری چیدمان')
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
installDate=(timezone.now() - timedelta(days=900)).date(),
|
||
phone1='09121234567', idCode='C-1',
|
||
)
|
||
make_visit(customer, action_dt=at(-400), finish_dt=at(-400))
|
||
self.html = self.client.get(self.URL, {'view': 'all'}).content.decode()
|
||
|
||
def test_column_widths_sum_to_one_hundred(self):
|
||
widths = [float(w) for w in re.findall(
|
||
r'<col style="width: (\d+(?:\.\d+)?)%">', self.html)]
|
||
self.assertEqual(len(widths), 9, 'تعداد ستونها باید ۹ باشد')
|
||
self.assertAlmostEqual(sum(widths), 100.0, places=1)
|
||
|
||
def test_row_number_is_not_a_column(self):
|
||
# شمارهٔ ردیف به چیپ کنار نام تبدیل شده، نه ستونِ جداگانه.
|
||
self.assertNotIn('<th>ردیف</th>', self.html)
|
||
self.assertIn('ez-idx', self.html)
|
||
|
||
def test_header_cells_match_body_cells(self):
|
||
thead = self.html.split('<tbody>')[0]
|
||
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
|
||
# سرستونها اکنون لینک مرتبسازی دارند، پس کلاس میگیرند نه <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 روی ستونها باعث اسکرول افقی و بیتناسبی میشد.
|
||
self.assertEqual(re.findall(r'<th[^>]*min-width', self.html), [])
|
||
|
||
def test_card_and_table_breakpoints_agree(self):
|
||
# اگر مرز کارت (max-width) و جدول (min-width) یکی نباشند، محدودهای
|
||
# از عرض صفحه هر دو حالت را همزمان میگیرد و چیدمان میشکند.
|
||
cards = re.findall(r'@media \(max-width: (\d+)px\)', self.html)
|
||
tables = [w for w in re.findall(r'@media \(min-width: (\d+)px\)', self.html)
|
||
if w != '1024'] # 1024 مخصوص نوار آمار است
|
||
self.assertTrue(cards, 'بلوک کارت موبایل پیدا نشد')
|
||
self.assertIn(str(int(cards[0]) + 1), tables)
|
||
|
||
def test_phone_renders_left_to_right(self):
|
||
self.assertIn('ez-phone', self.html)
|
||
# شمارهٔ تلفن باید در یک تکه و بدون شکست نمایش داده شود.
|
||
self.assertIn('ez-nowrap', self.html)
|
||
|
||
|
||
class EntezarPageControlsTests(TestCase):
|
||
"""کنترلهای صفحهٔ *انتظار*: فیلتر نما، مرتبسازی، جستوجو و کارتهای آمار.
|
||
|
||
این تستها رفتار کاربری را قفل میکنند: هر عدد کارت باید با تعداد همان
|
||
نما بخواند و جستوجو/مرتبسازی نباید ردیفها را از دست بدهد.
|
||
"""
|
||
|
||
URL = '/panel/entezar/'
|
||
|
||
def setUp(self):
|
||
self.user = make_user('entezar_viewer')
|
||
self.user.user_permissions.add(*Permission.objects.filter(
|
||
content_type__app_label='admin_panel'))
|
||
self.client.force_login(self.user)
|
||
|
||
def _get(self, **params):
|
||
return self.client.get(self.URL, params)
|
||
|
||
def _overdue_customer(self, name, install_days_ago=900, visit_days_ago=None):
|
||
customer = make_customer(name)
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
installDate=(timezone.now() - timedelta(days=install_days_ago)).date())
|
||
if visit_days_ago is not None:
|
||
make_visit(customer, action_dt=at(-visit_days_ago),
|
||
finish_dt=at(-visit_days_ago))
|
||
return Customer.objects.get(pk=customer.pk)
|
||
|
||
def test_default_view_is_today(self):
|
||
"""پیشفرض صفحه = «نیازمند پیگیری امروز»."""
|
||
response = self._get()
|
||
self.assertEqual(response.status_code, 200)
|
||
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', delay='bogus')
|
||
self.assertEqual(response.status_code, 200)
|
||
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(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_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))
|
||
|
||
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)
|
||
|
||
manual_ids = [obj.id for obj in self._get(view='manual').context['customers']]
|
||
self.assertIn(customer.id, manual_ids)
|
||
|
||
def test_cancelled_coordination_returns_customer_to_list(self):
|
||
customer = self._overdue_customer('هماهنگی لغو شده', visit_days_ago=400)
|
||
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(view='uncoordinated').context['customers']])
|
||
|
||
coordination.status = 'cancell'
|
||
coordination.save(update_fields=['status'])
|
||
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)
|
||
PhoneNumber.objects.create(customer=customer, title='تلفن',
|
||
phone_number='09121234567')
|
||
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
|
||
|
||
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
|
||
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)
|
||
|
||
ids = [obj.id for obj in self._get(view='all', sort='overdue').context['customers']]
|
||
self.assertLess(ids.index(oldest.id), ids.index(newer.id))
|
||
|
||
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):
|
||
# مشتری بدون هیچ قرار: در «تعیین تکلیف نشده» ردیف دارد، پس سلول ستون
|
||
# «تأخیر» رندر میشود. (نسخهٔ قبلی از یک هماهنگیِ «امروز ساعت ۱۲»
|
||
# استفاده میکرد؛ اگر تست پیش از ظهر اجرا میشد آن هماهنگی «آینده»
|
||
# شمرده میشد، مشتری از فهرست خارج میشد، جدول خالی میماند و تست
|
||
# به ساعتِ اجرا وابسته بود.)
|
||
self._customer('ستون تأخیر')
|
||
|
||
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)
|
||
|
||
|
||
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)
|