Files
MathApp/accounts/admin.py

271 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)
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,
)
# None یعنی صفحه‌ی changelist دوباره نمایش داده بشه.
return None
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_<model> مربوطه رو داشته باشه (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