276 lines
15 KiB
Python
276 lines
15 KiB
Python
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_<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
|