accounts.0008 drops the columns and refuses to run while guest rows exist. Docs describe the current state; tests that only proved guest behaviour are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
273 lines
12 KiB
Python
273 lines
12 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', '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',)
|
||
|
||
|
||
@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_<model> نشون داده میشه و حتی دسترسیِ مستقیم به
|
||
# 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)
|