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', 'public_id', 'name', 'phone', 'grade_id', 'created_at') search_fields = ('name', 'phone') @admin.display(description='شناسه‌ی نمایشی') def public_id(self, obj): from .public_id import encode return encode(obj.pk) def get_search_results(self, request, queryset, search_term): """«User482917» (همان که کاربر در پروفایلش می‌بیند) هم کاربر را پیدا می‌کند.""" from .public_id import decode results, may_have_duplicates = super().get_search_results(request, queryset, search_term) user_id = decode(search_term) if user_id: results |= queryset.filter(pk=user_id) return results, may_have_duplicates actions = ['send_sms_to_selected'] def get_readonly_fields(self, request, obj=None): # شماره کلیدِ ورود است: کارمندی با change_user که شماره‌ی کاربری را به شماره‌ی خودش عوض # کند، کد می‌گیرد و واردِ همان حساب می‌شود؛ پس فقط ابرکاربر آن را عوض می‌کند. fields = tuple(super().get_readonly_fields(request, obj)) return fields if request.user.is_superuser else fields + ('phone',) # 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_date', 'birth_year') @admin.register(Session) class SessionAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'expires_at', 'revoked') list_filter = ('revoked',) # ساختن/ویرایشِ دستیِ نشست یعنی ساختنِ ورود برای هر کاربر؛ فقط ابرکاربر. def has_add_permission(self, request): return request.user.is_superuser def has_change_permission(self, request, obj=None): return request.user.is_superuser @admin.register(OTPCode) class OTPCodeAdmin(admin.ModelAdmin): list_display = ('id', 'phone', 'code', 'expires_at', 'is_used') list_filter = ('is_used',) # ردیفِ OTP که کارمند می‌سازد = ورود بی‌پیامک به هر شماره (راهِ تستر)؛ فقط ابرکاربر. def has_add_permission(self, request): return request.user.is_superuser def has_change_permission(self, request, obj=None): return request.user.is_superuser # صفحه‌ی «ناظر» (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 # حساب‌های کارمندانِ پنل (auth.User/Group جنگو، جدا از accounts.User). در پیش‌فرضِ جنگو کارمندی با # change_group همه‌ی اجازه‌ها را به گروهِ خودش می‌دهد و با change_user (auth) تیکِ ابرکاربر را برای # خودش می‌زند. ساختن/تغییر/حذفِ این دو فقط برای ابرکاربر است؛ دیدن همان اجازه‌ی view را می‌خواهد. from django.contrib.auth import get_user_model # noqa: E402 from django.contrib.auth.admin import GroupAdmin as _DjangoGroupAdmin # noqa: E402 from django.contrib.auth.admin import UserAdmin as _DjangoUserAdmin # noqa: E402 from django.contrib.auth.models import Group # noqa: E402 class _SuperuserWritesOnly: def has_add_permission(self, request): return request.user.is_superuser def has_change_permission(self, request, obj=None): return request.user.is_superuser def has_delete_permission(self, request, obj=None): return request.user.is_superuser class StaffUserAdmin(_SuperuserWritesOnly, _DjangoUserAdmin): pass class StaffGroupAdmin(_SuperuserWritesOnly, _DjangoGroupAdmin): pass admin.site.unregister(get_user_model()) admin.site.register(get_user_model(), StaffUserAdmin) admin.site.unregister(Group) admin.site.register(Group, StaffGroupAdmin)