Files
MathApp/accounts/admin.py

348 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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 LoginEvent, 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', 'activity_link')
search_fields = ('name', 'phone')
@admin.display(description='پرونده')
def activity_link(self, obj):
from django.urls import reverse
from django.utils.html import format_html
return format_html('<a href="{}">فعالیت</a>', reverse('admin:accounts_user_activity', args=[obj.pk]))
def get_urls(self):
from django.urls import path
return [
path('<int:user_id>/activity/', self.admin_site.admin_view(self.activity_view),
name='accounts_user_activity'),
] + super().get_urls()
def activity_view(self, request, user_id):
"""ورود/خروج و خریدهای یک کاربر؛ ?format=csv برای اکسل. فقط با اجازه‌ی دیدنِ کاربر و ورود و سفارش."""
import csv
from django.http import Http404, HttpResponse
from django.shortcuts import render
from payment.models import Order
from .activity import COLUMNS, user_activity
from .public_id import encode
perms = ('accounts.view_user', 'accounts.view_loginevent', 'payment.view_order')
if not all(request.user.has_perm(p) for p in perms):
raise PermissionDenied
user = User.objects.filter(pk=user_id).first()
if user is None:
raise Http404
rows = user_activity(user)
if request.GET.get('format') == 'csv':
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = f'attachment; filename="user-{user.pk}-activity.csv"'
response.write('\ufeff') # BOM: اکسل بی‌آن فارسی را خراب می‌خواند
writer = csv.writer(response)
writer.writerow(COLUMNS)
for r in rows:
writer.writerow([r['at_local'], r['kind'], r['detail'], r['amount'], r['ip']])
return response
return render(request, 'admin/accounts_user_activity.html', {
**self.admin_site.each_context(request), 'target': user, 'public_id': encode(user.pk),
'rows': rows, 'columns': COLUMNS, 'title': 'پرونده‌ی کاربر',
})
@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_<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)
@admin.register(LoginEvent)
class LoginEventAdmin(admin.ModelAdmin):
"""سابقه‌ی ورود/خروج؛ فقط‌خواندنی (سابقه نباید از پنل عوض شود)."""
list_display = ('created_at', 'event', 'user_id', 'phone', 'ip')
list_filter = ('event', 'created_at')
search_fields = ('phone', 'user_id', 'ip')
date_hierarchy = 'created_at'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False