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']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor) # می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛ # این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش. @admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change']) def send_sms_to_selected(self, request, queryset): """اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخاب‌شده یا غیرفعال؛ گیرنده با فیلدِ «target» انتخاب می‌شه (cutoffِ «غیرفعال» رو خودِ ادمین در فرم وارد می‌کنه، چیزی hardcode نیست). ارسال کاملاً synchronous و توی همون requestه (نه صفِ پس‌زمینه)، پس اگر گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن هیچ پیامکی نمی‌فرسته تا request تایم‌اوت نکنه یا provider overload نشه. شکستِ ارسال برای یک کاربر بقیه را متوقف نمی‌کنه. """ 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) چون 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)، هم‌الگو با # شاخه‌ی 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 از لینک‌ها به # changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite # پیش‌فرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید. # # read-only بودن با پنهون‌کردنِ لینک تضمین نمی‌شه، بلکه با پرمیشنِ استانداردِ # جنگو: هر لینک فقط با view_ نشون داده می‌شه و حتی دسترسیِ مستقیم به # URLِ change/add/delete هم توسطِ پرمیشنِ پیش‌فرضِ همون ModelAdminها رد می‌شه # (گروهِ Supervisor فقط view_* داره). is_staff به‌تنهایی برای ورود کافی # نیست؛ عضویت در گروهِ Supervisor هم چک می‌شه، وگرنه PermissionDenied. 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