Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS
This commit is contained in:
@@ -1,6 +1,51 @@
|
||||
from django.contrib import admin
|
||||
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)
|
||||
@@ -8,6 +53,124 @@ 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)
|
||||
@@ -25,3 +188,83 @@ class SessionAdmin(admin.ModelAdmin):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user