import datetime from django.core.exceptions import ValidationError from django.db.models import Q from django.shortcuts import render, redirect, get_object_or_404 from django.http import HttpResponse from django.contrib import messages from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required from django.utils import timezone from persiantools.jdatetime import JalaliDate from ..models import Action, Customer from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT, ActionDeleteError, as_aware, cancel_pending_sms_for_action, clear_visit_hold, default_hold_until, normalize_finish_datetime, normalize_hold_until, refresh_last_bazdid, set_visit_hold, soft_delete_action, validate_action_payload) @login_required(login_url='/panel/p_login/') def customerAction(request, c_id): # استفاده از get_object_or_404 برای امنیت بیشتر customer = get_object_or_404(Customer, id=c_id) # دریافت پارامترهای فیلتر از URL (پیش‌فرض: all) filter_type = request.GET.get('actionType', 'all') filter_status = request.GET.get('status', 'all') # کوئری پایه برای اقدامات این مشتری. # اقدام‌های حذف‌شده (soft-delete) به‌طور پیش‌فرض نمایش داده نمی‌شوند؛ با # فیلتر صریح `status=cancell` می‌توان آن‌ها را دید (برای حسابرسی). actions = Action.objects.filter(customer=customer) if filter_status != 'cancell': actions = actions.filter(deleted_at__isnull=True) # اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...) if filter_type != 'all': actions = actions.filter(actionType=filter_type) # اعمال فیلتر بر اساس وضعیت (انجام شده، در انتظار و ...) if filter_status != 'all': actions = actions.filter(status=filter_status) # مرتب‌سازی نزولی بر اساس تاریخ ایجاد actions = actions.order_by("-createdDT") allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = { 'customer': customer, 'actions': actions, 'allUsers': allUsers, # ارسال مقادیر فیلتر به قالب برای حفظ وضعیت انتخاب‌شده در دراپ‌داون‌ها 'filter_type': filter_type, 'filter_status': filter_status } return render(request, 'admin_panel/customerAction.html', data) @login_required(login_url='/panel/p_login/') def customerNazar(request ,c_id): customer = Customer.objects.get(id=c_id) actions = Action.objects.filter( customer=customer, nazarDT__isnull=False, deleted_at__isnull=True, ).order_by("-createdDT") allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = {'customer': customer, 'actions': actions, 'allUsers': allUsers} return render(request, 'admin_panel/customer/nazar.html', data) def _parse_form_datetime(raw): """timestamp میلی‌ثانیه‌ای فرم → دیتاتایم aware (یا ``None``). کد قبلی ``datetime.datetime.fromtimestamp`` (naive) می‌ساخت که هنگام ذخیره با ``USE_TZ=True`` هشدار ``RuntimeWarning`` می‌داد؛ اینجا صریح aware می‌شود. """ try: return as_aware(datetime.datetime.fromtimestamp(int(raw) / 1e3)) except (TypeError, ValueError, OverflowError, OSError): return None def _action_payload_from_request(request): """استخراج و نرمال‌سازی ورودی فرم اقدام (مشترک ایجاد و ویرایش).""" is_done = bool(request.POST.get('is_done')) return { 'actionType': (request.POST.get('actionType') or '').strip(), 'status': Action.Status.FINISH if is_done else Action.Status.WAITE, 'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')), 'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')), # فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود». 'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')), # تأیید صریح کاربر برای «عقب بردن» تاریخ قرار دستی. 'force_hold': bool(request.POST.get('force_hold')), 'desc': request.POST.get('desc', None), 'result': request.POST.get('result', None), 'userID': request.POST.get('userID', None), 'is_done': is_done, } def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False): """هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده. قواعد: * برای اقدام نوع «هماهنگی»: - تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو، مگر تأیید کاربر) - «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز - لغو شده ⇒ (در ایجاد) پاک می‌شود * برای **هر اقدام دیگری** (از جمله «بازدید»): - در حالت **ایجاد**، این اقدام آخرین فعالیت مشتری است ⇒ قرار دستی پاک می‌شود (سناریوی گزارش‌شده: ثبت بازدید پس از هماهنگی). - در حالت **ویرایش**، رکوردی تاریخی است و ممکن است قرارِ فعلی از یک هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی :func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد. خروجی (دیکشنری، برای بازخورد شفاف به کاربر):: {'ok': bool, 'reason': 'written'|'default'|'forward_only'|'cleared'|'', 'current': datetime|None, 'target': datetime|None} ``reason='forward_only'`` یعنی تاریخ درخواستی از قرار فعلی عقب‌تر است و نوشته **نشد**؛ لایهٔ ویو باید همین را با تاریخ‌ها به کاربر نشان بدهد و اگر خودش تأیید کرد، با ``force_hold=True`` دوباره صدا بزند. """ result = {'ok': True, 'reason': '', 'current': None, 'target': None, 'force_allowed': not is_new} # «عقب‌بردن قرار» فقط در ویرایش مجاز است. در ایجادِ وظیفهٔ تازه، کاربر قصد # اصلاح قرارِ موجود را ندارد و اجازه دادن به آن می‌تواند قولِ قبلیِ ثبت‌شده # (شاید توسط کارشناس دیگر) را بی‌آنکه کسی بخواهد کوتاه کند. force_hold = bool(force_hold) and not is_new is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI if not is_coordination or status == Action.Status.CANCELL: if is_new: result['ok'] = bool(clear_visit_hold(customer_obj)) result['reason'] = 'cleared' if result['ok'] else '' return result raw = payload.get('holdRaw') if raw is not None: target = normalize_hold_until(raw) reason = 'written' elif payload['status'] == Action.Status.FINISH: target = default_hold_until() reason = 'default' else: return result current = as_aware(customer_obj.visit_hold_until) result['current'] = current result['target'] = target written = set_visit_hold(customer_obj, target, force=force_hold) result['ok'] = written result['reason'] = reason if written else 'forward_only' return result # نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر # «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمی‌زد). _ACTION_EVENT_SMS = { Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'), Action.ActionType.NASB: (notify_install_scheduled, 'install_created'), } def _sync_action_event_sms(customer, action, *, previous_type=None, user=None): """هم‌راستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش). * نوع جدید **رویدادی** است (بازدید/نصب): - اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده می‌شود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است. - اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته نمی‌شود، پس ویرایش‌های پیاپی اسپم تولید نمی‌کنند. * نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد لغو می‌شود (کاربر عملاً بازدید/نصب را پس گرفته است). خروجی: تعداد پیامک‌های ساخته‌شده. """ current = _ACTION_EVENT_SMS.get(action.actionType) previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None # نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود، # وگرنه هر دو پیامک در صف می‌مانند. if previous is not None and previous_type != action.actionType: cancel_pending_sms_for_action( customer, action_id=action.pk, sms_type=previous[1], ) if current is None: # نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد. return 0 if previous_type == action.actionType: return 0 # ویرایش عادی همان نوع ⇒ پیامک تازه‌ای لازم نیست return current[0](customer, action, created_by=user) def _jalali_day(value): """تاریخ شمسی روز (به وقت محلی) برای پیام‌های کاربر.""" if value is None: return '—' return JalaliDate(timezone.localtime(value)).strftime('%Y/%m/%d') def _report_hold_result(request, result): """بازخورد شفافِ نتیجهٔ نوشتن «تا تاریخ پیگیری نشود» به کاربر. قاعدهٔ «فقط رو به جلو» بی‌صدا نیست: اگر تاریخ درخواستی از قرار فعلی عقب‌تر باشد، اینجا گفته می‌شود که ذخیره نشد و کاربر چطور می‌تواند خودش انجامش دهد. """ if result.get('reason') != 'forward_only': return current, target = result.get('current'), result.get('target') tz = timezone.get_current_timezone() if current is not None and target is not None \ and target.astimezone(tz).date() >= current.astimezone(tz).date(): # همان روز ⇒ تغییر واقعی‌ای لازم نیست؛ پیام دادن گمراه‌کننده است. return tail = ('برای ثبت، گزینهٔ «تاریخ قرار را عقب ببر» را تیک بزنید.' if result.get('force_allowed') else 'برای تغییر قرار، همان وظیفهٔ هماهنگی را ویرایش کنید.') messages.warning( request, 'تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (%s) عقب‌تر است. %s' % (_jalali_day(current), tail) ) def _reject(request, error, redirect_to): """پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی.""" messages.error(request, '؛ '.join(error.messages)) return redirect(redirect_to) @login_required(login_url='/panel/p_login/') def customerNewAction(request ,c_id): payload = _action_payload_from_request(request) try: validate_action_payload(payload) # اعتبارسنجی «قرار بعدی» پیش از هر نوشتنی (سقف ۶ ماه و تاریخ گذشته). if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']: normalize_hold_until(payload['holdRaw']) except ValidationError as error: return _reject(request, error, '/panel/customer/' + str(c_id) + '/action') customer_obj = Customer.objects.get(id=int(c_id)) newAction = Action() newAction.actionType = payload['actionType'] newAction.actionDateTime = payload['actionDateTime'] newAction.desc = payload['desc'] newAction.forUser = User.objects.get(id=int(payload['userID'])) newAction.customer = customer_obj if payload['is_done']: newAction.status = Action.Status.FINISH # تاریخ اتمام خالی نباید None ذخیره شود (سابقه را پاک می‌کرد). newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime']) newAction.result = payload['result'] else: newAction.status = Action.Status.WAITE newAction.save() # last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime. refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user) # «قرار بعدی» فقط برای هماهنگی. hold_result = _apply_visit_hold( customer_obj, payload, status=newAction.status, is_new=True, force_hold=payload.get('force_hold', False), ) _report_hold_result(request, hold_result) # پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب). _sync_action_event_sms(customer_obj, newAction, user=request.user) return redirect('/panel/customer/'+str(c_id)+'/action') @login_required(login_url='/panel/p_login/') def ActionList(request): actions = Action.objects.filter(forUser__id=request.user.id, status='waite').order_by("-createdDT") allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = {'actions': actions, 'allUsers': allUsers} return render(request, 'admin_panel/ActionList.html', data) @login_required(login_url='/panel/p_login/') def ActionListUser(request, u_id): actions = Action.objects.filter(forUser__id=u_id, status='waite').order_by("-createdDT") allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = {'actions': actions, 'allUsers': allUsers} return render(request, 'admin_panel/ActionList.html', data) @login_required(login_url='/panel/p_login/') def customerEditAction(request ,c_id, action_id): payload = _action_payload_from_request(request) try: validate_action_payload(payload) if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']: normalize_hold_until(payload['holdRaw']) except ValidationError as error: return _reject( request, error, request.META.get('HTTP_REFERER', '/panel/customer/' + str(c_id) + '/action'), ) customer_obj = Customer.objects.get(id=int(c_id)) newAction = Action.objects.get(id=action_id) was_done = newAction.status == Action.Status.FINISH # نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید» # و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است. previous_type = newAction.actionType newAction.actionType = payload['actionType'] newAction.actionDateTime = payload['actionDateTime'] newAction.desc = payload['desc'] newAction.forUser = User.objects.get(id=int(payload['userID'])) newAction.customer = customer_obj if payload['is_done']: newAction.status = Action.Status.FINISH newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime']) newAction.result = payload['result'] else: newAction.status = Action.Status.WAITE newAction.save() # بازمحاسبهٔ واحد: هم پس از ویرایش، هم وقتی رکورد از «انجام‌شده» برگشت می‌خورد # (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود). reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT refresh_last_bazdid(customer_obj, reason=reason, user=request.user) # «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً # پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر # باشد و پاک‌کردن، قرار معتبر را از بین ببرد. hold_result = _apply_visit_hold( customer_obj, payload, status=newAction.status, is_new=False, force_hold=payload.get('force_hold', False), ) _report_hold_result(request, hold_result) # پیامک «بازدید/نصب شما برای تاریخ … ثبت شد». # فقط *گذار* نوع اقدام بررسی می‌شود، نه هر ویرایش (جزئیات در # ``_sync_action_event_sms``): # * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده می‌شود. # * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو می‌شود. _sync_action_event_sms( customer_obj, newAction, previous_type=previous_type, user=request.user, ) #return redirect('/panel/customer/'+str(c_id)+'/action') return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found')) @login_required(login_url='/panel/p_login/') def customerActionDelete(request, c_id, action_id): """«حذف» یک اقدام (soft-delete). فقط POST مجاز است (تغییر وضعیت داده). اقدام فیزیکی حذف **نمی‌شود**؛ ``status`` به ``cancell`` می‌رود و ``deleted_at``/``deleted_by`` پر می‌شوند. چرا soft-delete؟ چون سابقهٔ CRM باید قابل حسابرسی بماند و منطق محاسبات (entezar / ActionList / داشبورد) از قبل فقط ``waite``/``finish`` را می‌بیند؛ پس همین مقدار، اقدام را از همهٔ خروجی‌ها کنار می‌گذارد. """ customer_obj = get_object_or_404(Customer, id=c_id) action = get_object_or_404(Action, id=action_id, customer=customer_obj) back_url = '/panel/customer/%d/action' % customer_obj.pk if request.method != 'POST': # دسترسی مستقیم با GET نباید چیزی را عوض کند. return redirect(back_url) try: cancelled = soft_delete_action(action, user=request.user) except ActionDeleteError as error: messages.error(request, str(error)) return redirect(back_url) label = action.get_actionType_display() or 'اقدام' message = '«%s» حذف شد و از فهرست‌های انتظار/وظایف خارج شد.' % label if cancelled: message += ' %d پیامک ارسال‌نشدهٔ مرتبط هم لغو شد.' % cancelled messages.success(request, message) return redirect(back_url) @login_required(login_url='/panel/p_login/') def ActionListAll(request): # اقدام‌های حذف‌شده (soft-delete) در فهرست «همه» نمایش داده نمی‌شوند. actions = Action.objects.filter(deleted_at__isnull=True).order_by("-id") allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = {'actions': actions, 'allUsers': allUsers} return render(request, 'admin_panel/ActionList.html', data) def _local_day_range(day=None): """بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی و aware. قبلاً این دو ویو رشته‌های naive می‌ساختند (``'%Y-%m-%d 00:00:00'``) و Django آن‌ها را با منطقهٔ زمانی پیش‌فرض تفسیر می‌کرد؛ هر تغییر ساعت/تایم‌زون باعث جابه‌جایی مرز «وظایف امروز» می‌شد. حالا مرز صریح و aware است. در روزهای تغییر ساعت (مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته باشد؛ آن حالت با ``is_dst=False`` پوشش داده می‌شود تا یک تاریخ مرزی، صفحهٔ وظایف را نشکند (همان کاری که ``visit_policy.as_aware`` می‌کند). """ day = day or timezone.localdate() bounds = [] for moment in (datetime.datetime.combine(day, datetime.time.min), datetime.datetime.combine(day, datetime.time.max)): try: bounds.append(timezone.make_aware(moment)) except Exception: bounds.append(timezone.make_aware(moment, is_dst=False)) return bounds[0], bounds[1] @login_required(login_url='/panel/p_login/') def ActionListToday(request): startTodayDT, endTodayDT = _local_day_range() actions = Action.objects.filter( actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=request.user.id, status='waite', ).order_by("-actionDateTime")[:50] allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = {'actions': actions, 'allUsers': allUsers} return render(request, 'admin_panel/ActionList.html', data) @login_required(login_url='/panel/p_login/') def ActionListUserToday(request, u_id): dateTimeAction = request.GET.get('dateTime', None) status = request.GET.get('status', None) unixADT = dateTimeAction if dateTimeAction: # timestamp میلی‌ثانیه‌ای GET → تاریخ محلی (نه naive UTC). moment = as_aware(datetime.datetime.fromtimestamp(int(dateTimeAction) / 1e3)) day = moment.astimezone(timezone.get_current_timezone()).date() else: day = timezone.localdate() startTodayDT, endTodayDT = _local_day_range(day) if status == 'waite' or status == None or status == '' or status == ' ': status = 'waite' actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50] actionPr = Action.objects.filter(actionDateTime__lt=startTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:200] elif status == 'finish': actionPr = None actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50] allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat') data = {'actions': actions, 'allUsers': allUsers, 'u_id': u_id, "unixADT": unixADT, "status": status, 'actionPr': actionPr} return render(request, 'admin_panel/ActionList.html', data) @login_required(login_url='/panel/p_login/') def rezayat(request): statusRezayat = request.GET.get('status', 'waite') now = timezone.now() # محاسبه بازه‌های زمانی ۳۰ روز و ۶۰ روز قبل dt_30_days_ago = now - datetime.timedelta(days=30) dt_60_days_ago = now - datetime.timedelta(days=60) if statusRezayat == 'finish': # نمایش نظرسنجی‌های تعیین‌تکلیف شده (ثبت شده یا کنسل شده) actions = Action.objects.exclude(actionType='vosol').filter( status='finish', nazarDT__isnull=False ).order_by("-nazarDT")[:500] else: statusRezayat = 'waite' # نمایش نظرسنجی‌های در انتظار با اعمال شرط عدم انقضای زمان actions = Action.objects.exclude(actionType='vosol').filter( status='finish', nazarDT__isnull=True, # نظرسنجی هنوز نهایی نشده createdDT__gte=dt_60_days_ago # حداکثر ۶۰ روز از ایجاد گذشته باشد ).filter( Q(finishDateTime__isnull=True) | Q(finishDateTime__gte=dt_30_days_ago) # حداکثر ۳۰ روز از اتمام گذشته باشد ).filter( Q(nextNazarDT__isnull=True) | Q(nextNazarDT__lte=now) # موعد پیگیری رسیده یا تعویقی نداشته باشد ).order_by("-id")[:500] data = {'actions': actions, "status": statusRezayat} return render(request, 'admin_panel/customerRezayat.html', data) @login_required(login_url='/panel/p_login/') def rezayatEdit(request, ac_id): csGrade = request.POST.get('csGrade', None) csDesc = request.POST.get('csDesc', None) if csDesc and csGrade: action = get_object_or_404(Action, id=ac_id) if csGrade == '-1': # عدم موفقیت: تنظیم nextNazarDT برای ۳ روز بعد (nazarDT پر نمی‌شود تا نهایی نشود) action.nazar = csDesc action.grade = int(csGrade) action.nextNazarDT = timezone.now() + datetime.timedelta(days=3) elif csGrade == '-2': # کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار action.nazar = csDesc action.grade = int(csGrade) action.nazarDT = timezone.now() action.nextNazarDT = None else: # ثبت امتیاز عادی action.nazar = csDesc action.grade = int(csGrade) action.nazarDT = timezone.now() action.nextNazarDT = None action.save() return redirect('/panel/rezayat/') else: return HttpResponse('لطفا همه فیلد ها را پر کنید')