from datetime import datetime from django import forms from django.conf import settings from django.contrib import admin, messages from django.contrib.admin import helpers from django.core.exceptions import PermissionDenied from django.shortcuts import render from django.utils import timezone from .models import OTPCode, Profile, Session, User from .services import SMSDeliveryError, get_inactive_users, send_sms class SendSMSForm(forms.Form): TARGET_SELECTED = 'selected' TARGET_INACTIVE = 'inactive' TARGET_CHOICES = [ (TARGET_SELECTED, 'کاربرانِ انتخاب‌شده (همان‌هایی که در لیست تیک خورده‌اند)'), (TARGET_INACTIVE, 'کاربرانِ غیرفعال (بر اساسِ تاریخِ آخرین فعالیت)'), ] message = forms.CharField( label='متن پیامک', widget=forms.Textarea(attrs={'rows': 4}), max_length=500, ) target = forms.ChoiceField( label='گیرندگان', choices=TARGET_CHOICES, widget=forms.RadioSelect, initial=TARGET_SELECTED, required=False, ) inactive_since = forms.DateField( label='غیرفعال از این تاریخ به بعد', help_text='فقط وقتی گزینه‌ی «کاربرانِ غیرفعال» انتخاب شده باشد استفاده می‌شود.', required=False, widget=forms.DateInput(attrs={'type': 'date'}), ) def clean(self): cleaned_data = super().clean() target = cleaned_data.get('target') inactive_since = cleaned_data.get('inactive_since') if target == self.TARGET_INACTIVE and not inactive_since: self.add_error( 'inactive_since', 'برای گیرندگانِ «کاربرانِ غیرفعال» وارد کردنِ تاریخ الزامی است.', ) return cleaned_data @admin.register(User) class UserAdmin(admin.ModelAdmin): list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') search_fields = ('name', 'phone', 'device_id') list_filter = ('is_guest',) actions = ['send_sms_to_selected'] # permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ # has_change_permission فیلتر نمی‌کنه (فقط اکشن‌هایی که با پارامترِ # permissions تعریف شده باشن فیلتر می‌شن)، یعنی کاربری که فقط # view_user داره (مثلِ گروهِ Supervisor) می‌تونه با یک POSTِ مستقیم # همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمه‌ی # اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action # خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو # فقط برای کاربرانِ دارایِ change_user در دسترس می‌ذاره؛ برای بقیه # (مثلِ Supervisor) اصلاً در actions ثبت‌شده نیست، پس درخواستِ مستقیم # هم بی‌اثر می‌مونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»). @admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change']) def send_sms_to_selected(self, request, queryset): """ قدمِ اولیه‌ی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ معمولی روی UserAdmin که یک صفحه‌ی میانیِ ساده (فرمِ متنِ پیامک) رو نشون می‌ده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال می‌کنه. گیرنده‌ها با فیلدِ «target» در فرم انتخاب می‌شن: - 'selected' (پیش‌فرض): همون queryset که ادمین در changelist انتخاب کرده — رفتارِ قبلی، بدون تغییر. - 'inactive': کاربرانِ غیرفعال، با استفاده از accounts.services.get_inactive_users(since) — cutoff (تاریخ) رو خودِ ادمین در فرم وارد می‌کنه، هیچ عددِ روزی hardcode نشده. عمداً محدود نگه داشته شده: - هیچ مدلِ کمپین/بدنه‌ی جدیدی ساخته/persist نمی‌شه (نه رکوردی از پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه. - هیچ زمان‌بندی، صفِ پس‌زمینه، یا فیلترِ هدف‌گیریِ دیگه‌ای (غیر از selected/inactive) نیست. - محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون request انجام می‌شه (نه صفِ پس‌زمینه)، اگر تعدادِ گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی ارسال نمی‌کنه و فقط خطا نشون می‌ده — تا یک انتخاب/تاریخِ خیلی بزرگ باعثِ تایم‌اوتِ request یا overload شدنِ پروایدر نشه. - هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه که OTP و campaigns هم ازش استفاده می‌کنن. - کاربرانِ بدونِ شماره‌تلفن (مثلِ مهمان) به‌سادگی skip می‌شن. - شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف نمی‌کنه؛ فقط شمارشِ failed رو زیاد می‌کنه و برای بقیه ادامه می‌ده. """ if 'apply' in request.POST: form = SendSMSForm(request.POST) if form.is_valid(): message = form.cleaned_data['message'] target = form.cleaned_data['target'] if target == SendSMSForm.TARGET_INACTIVE: since = timezone.make_aware( datetime.combine(form.cleaned_data['inactive_since'], datetime.min.time()) ) recipients = get_inactive_users(since) else: recipients = queryset max_recipients = settings.ADMIN_SMS_MAX_RECIPIENTS recipients_count = recipients.count() if recipients_count > max_recipients: self.message_user( request, ( f'تعدادِ گیرندگان ({recipients_count} کاربر) بیش از سقفِ مجازِ ' f'ارسالِ همزمان از ادمین ({max_recipients} کاربر) است؛ هیچ ' f'پیامکی ارسال نشد. لطفاً بازه/انتخاب را محدودتر کنید.' ), level=messages.ERROR, ) # به‌جای return None، به رندرِ مشترکِ پایینِ تابع سقوط # می‌کنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون # داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر # استفاده می‌شه. else: sent_count = 0 failed_count = 0 skipped_no_phone = 0 for user in recipients: if not user.phone: skipped_no_phone += 1 continue try: send_sms(phone=user.phone, message=message) except SMSDeliveryError: failed_count += 1 else: sent_count += 1 if sent_count: self.message_user( request, f'پیامک با موفقیت به {sent_count} کاربر ارسال شد.', level=messages.SUCCESS, ) if failed_count: self.message_user( request, f'ارسالِ پیامک برای {failed_count} کاربر ناموفق بود.', level=messages.ERROR, ) if skipped_no_phone: self.message_user( request, f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).', level=messages.WARNING, ) # توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در # جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک # ریدایرکتِ ۳۰۲ واقعی می‌شه (نه رندرِ مستقیمِ ۲۰۰)، # درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود. # حالا هم‌الگو با شاخه‌ی cap-exceeded، سقوط می‌کنیم به # رندرِ مشترکِ پایینِ تابع. else: form = SendSMSForm() context = { **self.admin_site.each_context(request), 'title': 'ارسال پیامک به کاربران انتخاب‌شده', 'opts': self.model._meta, 'users': queryset, 'form': form, 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, } return render(request, 'accounts/send_sms_form.html', context) @admin.register(Profile) class ProfileAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'birth_year', 'parent_phone') @admin.register(Session) class SessionAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked') list_filter = ('revoked',) @admin.register(OTPCode) class OTPCodeAdmin(admin.ModelAdmin): list_display = ('id', 'phone', 'code', 'expires_at', 'is_used') list_filter = ('is_used',) # ───────────────────────────────────────────────────────────────────────── # صفحه‌ی «ناظر» (Supervisor) # # یک صفحه‌ی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیش‌فرضِ جنگو # (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی. # این صفحه صرفاً یک ایندکسِ گروهی از لینک‌هایی به همون changelistهای # ModelAdmin موجودِ پروژه‌ست: کاربران (accounts.UserAdmin)، پرداخت‌ها # (payment.OrderAdmin / payment.TransactionAdmin) و اشتراک‌ها # (subscription.SubscriptionAdmin). هیچ منطقِ کسب‌وکار یا ModelAdmin # جدیدی اینجا ساخته نمی‌شه. # # محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission # جنگو تأمین می‌شه، نه با پنهون‌کردنِ لینک: # - هر لینک در این صفحه فقط وقتی نشون داده می‌شه که کاربر پرمیشنِ # view_ مربوطه رو داشته باشه (request.user.has_perm(...)). # - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ # changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ # override‌ای نشدن) طبقِ has_view_permission/has_change_permission/ # has_add_permission/has_delete_permission پیش‌فرضِ ModelAdmin — که # مستقیماً از Permission مدلِ کاربر می‌خونن — چک می‌کنن؛ یعنی گروهِ # Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ # view_* می‌گیره) حتی با دسترسیِ مستقیم به URL هم نمی‌تونه add/change/ # delete کنه. # - خودِ صفحه هم مثلِ هر صفحه‌ی دیگه‌ی Admin با admin.site.admin_view # wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛ # ولی is_staff به‌تنهایی برای *ورود به خودِ صفحه* کافی نیست — پایین‌تر # قبل از هر کارِ دیگه‌ای عضویتِ کاربر در گروهِ Supervisor هم چک می‌شه # (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه # PermissionDenied (۴۰۳) بالا می‌ره. این یک سیستمِ پرمیشنِ جدید نیست؛ # فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group # می‌سازه) برای گیت‌کیپینگِ ورود به صفحه استفاده می‌کنه. # ───────────────────────────────────────────────────────────────────────── SUPERVISOR_GROUP_NAME = 'Supervisor' def supervisor_dashboard(request): from payment.models import Order, Transaction from subscription.models import Subscription if not request.user.groups.filter(name=SUPERVISOR_GROUP_NAME).exists(): raise PermissionDenied def _link(label, model): opts = model._meta return { 'label': label, 'url_name': f'admin:{opts.app_label}_{opts.model_name}_changelist', 'has_perm': request.user.has_perm(f'{opts.app_label}.view_{opts.model_name}'), } sections = [ {'title': 'کاربران', 'links': [_link('کاربران', User)]}, {'title': 'پرداخت‌ها', 'links': [_link('سفارش‌ها', Order), _link('تراکنش‌ها', Transaction)]}, {'title': 'اشتراک‌ها', 'links': [_link('اشتراک‌ها', Subscription)]}, ] context = { **admin.site.each_context(request), 'title': 'ناظر (Supervisor)', 'sections': sections, } return render(request, 'admin/supervisor_dashboard.html', context) _original_admin_get_urls = admin.site.get_urls def _admin_get_urls_with_supervisor(): from django.urls import path custom_urls = [ path('supervisor/', admin.site.admin_view(supervisor_dashboard), name='supervisor-dashboard'), ] return custom_urls + _original_admin_get_urls() admin.site.get_urls = _admin_get_urls_with_supervisor