Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS

This commit is contained in:
2026-08-25 03:39:41 +03:30
parent 80298b47b1
commit a9200772a3
27 changed files with 2337 additions and 49 deletions

View File

@@ -1,14 +1,20 @@
from unittest.mock import Mock, patch
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, TransactionTestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from .models import OTPCode, Session, User
from .services import OTPDeliveryError, send_otp_sms
from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
class SendOtpSmsTests(TestCase):
@@ -867,3 +873,563 @@ class GuestSessionThrottleTests(TestCase):
format='json',
)
self.assertEqual(blocked_response.status_code, 429)
class UserProfileViewTests(TestCase):
"""تست‌های GET /api/user/profile — پروفایل کاربر جاری."""
def setUp(self):
self.client = APIClient()
def _get_profile(self, token=None):
kwargs = {}
if token:
kwargs['HTTP_AUTHORIZATION'] = f'Bearer {token}'
return self.client.get('/api/user/profile', **kwargs)
def test_requires_authentication(self):
response = self._get_profile()
self.assertEqual(response.status_code, 401)
def test_returns_profile_for_guest_without_existing_profile_row(self):
user = User.objects.create(device_id='device-profile-1', is_guest=True, name='')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['userId'], str(user.id))
self.assertEqual(data['isGuest'], True)
self.assertIsNone(data['phone'])
self.assertIsNone(data['gradeId'])
self.assertIsNone(data['birthYear'])
self.assertEqual(data['parentPhone'], '')
self.assertEqual(data['bio'], '')
# get_or_create باید یک رکورد Profile واقعی ساخته باشه.
self.assertTrue(Profile.objects.filter(user_id=user.id).exists())
def test_returns_existing_profile_fields_for_registered_user(self):
user = User.objects.create(
phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
)
Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['phone'], '+989120000001')
self.assertEqual(data['name'], 'علی')
self.assertEqual(data['avatar'], 'avatar.png')
self.assertEqual(data['isGuest'], False)
self.assertEqual(data['gradeId'], 2)
self.assertEqual(data['birthYear'], 2005)
self.assertEqual(data['parentPhone'], '+989120000002')
self.assertEqual(data['bio'], 'سلام')
def test_does_not_leak_other_users_profile(self):
other = User.objects.create(device_id='device-other', is_guest=True, name='')
Profile.objects.create(user=other, bio='محرمانه')
user = User.objects.create(device_id='device-profile-2', is_guest=True, name='')
token = issue_access_token(user.id)
response = self._get_profile(token)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['userId'], str(user.id))
self.assertNotEqual(response.json()['bio'], 'محرمانه')
class SendSmsAdminActionTests(TestCase):
"""
اکشنِ ادمینِ send_sms_to_selected در UserAdmin — قدمِ اولیه‌ی مینیمالِ
فیچرِ «ارسالِ پیامک از ادمین»: بدونِ مدلِ کمپین، زمان‌بندی یا صفِ
پس‌زمینه؛ فقط ارسالِ بلادرنگِ یک پیام به کاربرانِ انتخاب‌شده در
changelist، با استفاده از accounts.services.send_sms موجود.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='sms-admin', email='sms-admin@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
self.guest_no_phone = User.objects.create(name='guest', is_guest=True, phone=None)
self.changelist_url = reverse('admin:accounts_user_changelist')
def test_action_registered_on_user_admin(self):
model_admin = admin.site._registry[User]
self.assertIn('send_sms_to_selected', model_admin.actions)
@patch('accounts.admin.send_sms')
def test_intermediate_page_shown_without_sending(self, mock_send_sms):
"""
اولین POST (فقط انتخابِ اکشن، بدونِ 'apply') باید صفحه‌ی میانیِ
فرم رو نشون بده، نه ارسالِ فوریِ پیامک.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk],
'index': 0,
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
self.assertIn('form', response.context)
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_sends_sms_to_users_with_phone_and_skips_guest(self, mock_send_sms):
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
'apply': 'ارسال پیامک',
'message': 'سلام از طرف ادمین',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
mock_send_sms.assert_any_call(phone='0910000001', message='سلام از طرف ادمین')
mock_send_sms.assert_any_call(phone='0910000002', message='سلام از طرف ادمین')
@patch('accounts.admin.send_sms')
def test_apply_with_empty_message_does_not_send_and_reshows_form(self, mock_send_sms):
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': '',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_continues_after_one_failed_delivery(self, mock_send_sms):
"""
شکستِ ارسال برای یک کاربر (SMSDeliveryError) نباید کلِ اکشن رو
متوقف کنه؛ باقیِ کاربرانِ انتخاب‌شده باید همچنان امتحان بشن.
"""
mock_send_sms.side_effect = [SMSDeliveryError('boom'), None]
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
@patch('accounts.admin.send_sms')
def test_apply_defaults_to_selected_target_when_field_missing(self, mock_send_sms):
"""
سازگاری با قبل: اگر فرم بدونِ فیلدِ 'target' پست بشه (مثلِ کلاینتِ
قدیمی)، initial='selected' باعثِ می‌شه رفتارِ قبلی (ارسال به
queryset انتخاب‌شده) حفظ بشه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_called_once_with(phone='0910000001', message='متن')
@patch('accounts.admin.send_sms')
def test_apply_with_inactive_target_uses_get_inactive_users(self, mock_send_sms):
"""
وقتی target='inactive' انتخاب شده، گیرنده‌ها باید از
accounts.services.get_inactive_users(since) بیان — نه از queryset
انتخاب‌شده در changelist — و cutoff باید از تاریخِ واردشده توسطِ
ادمین ساخته بشه (بدونِ عددِ روزِ hardcode‌شده).
"""
now = timezone.now()
active_user = User.objects.create(
name='active', is_guest=False, phone='0910000009', last_activity_at=now,
)
inactive_user = User.objects.create(
name='inactive', is_guest=False, phone='0910000010',
last_activity_at=now - timezone.timedelta(days=90),
)
never_active_user = User.objects.create(
name='never-active', is_guest=False, phone='0910000011', last_activity_at=None,
)
cutoff_date = (now - timezone.timedelta(days=30)).date()
# انتخابِ changelist عمداً کاربرِ فعال رو هم شامل می‌شه تا مطمئن
# بشیم با target='inactive' نادیده گرفته می‌شه.
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [active_user.pk],
'apply': 'ارسال پیامک',
'message': 'یادآوری غیرفعال‌ها',
'target': 'inactive',
'inactive_since': cutoff_date.isoformat(),
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
mock_send_sms.assert_any_call(phone='0910000010', message='یادآوری غیرفعال‌ها')
mock_send_sms.assert_any_call(phone='0910000011', message='یادآوری غیرفعال‌ها')
called_phones = {call.kwargs['phone'] for call in mock_send_sms.call_args_list}
self.assertNotIn('0910000009', called_phones)
@patch('accounts.admin.send_sms')
def test_apply_with_inactive_target_requires_date(self, mock_send_sms):
"""
اگر target='inactive' باشه ولی تاریخ خالی باشه، فرم نباید valid
بشه و هیچ پیامکی ارسال نشه (بدونِ fallback به عددِ روزِ پیش‌فرض).
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'inactive',
'inactive_since': '',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
self.assertFalse(response.context['form'].is_valid())
mock_send_sms.assert_not_called()
@patch('accounts.admin.send_sms')
def test_apply_with_inactive_target_still_skips_users_without_phone(self, mock_send_sms):
"""
رفتارِ skip کاربرانِ بدونِ شماره‌تلفن باید برای هدف‌گیریِ
'inactive' هم مثلِ 'selected' حفظ بشه.
"""
User.objects.create(
name='inactive-no-phone', is_guest=True, phone=None,
last_activity_at=timezone.now() - timezone.timedelta(days=90),
)
cutoff_date = (timezone.now() - timezone.timedelta(days=30)).date()
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'inactive',
'inactive_since': cutoff_date.isoformat(),
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_not_called()
from django.contrib.messages import get_messages
message_texts = [m.message for m in get_messages(response.wsgi_request)]
self.assertTrue(any('بدونِ شماره‌تلفن' in m for m in message_texts))
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=2)
@patch('accounts.admin.send_sms')
def test_apply_blocks_send_when_selected_recipients_exceed_cap(self, mock_send_sms):
"""
محافظتِ سقف باید برای target='selected' هم اعمال بشه: اگر تعدادِ
کاربرانِ انتخاب‌شده در changelist از ADMIN_SMS_MAX_RECIPIENTS
بیشتر باشه، هیچ پیامکی ارسال نشه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk, self.guest_no_phone.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
mock_send_sms.assert_not_called()
from django.contrib.messages import get_messages
message_texts = [m.message for m in get_messages(response.wsgi_request)]
self.assertTrue(any('سقفِ مجاز' in m for m in message_texts))
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=1)
@patch('accounts.admin.send_sms')
def test_apply_blocks_send_when_inactive_recipients_exceed_cap(self, mock_send_sms):
"""
محافظتِ سقف برای target='inactive' هم اعمال بشه: اگر
get_inactive_users(since) بیش از سقف برگردونه، هیچ پیامکی ارسال
نشه — بدون تغییر در خودِ منطقِ get_inactive_users.
"""
now = timezone.now()
User.objects.create(
name='inactive-1', is_guest=False, phone='0910000021',
last_activity_at=now - timezone.timedelta(days=90),
)
User.objects.create(
name='inactive-2', is_guest=False, phone='0910000022',
last_activity_at=now - timezone.timedelta(days=90),
)
cutoff_date = (now - timezone.timedelta(days=30)).date()
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'inactive',
'inactive_since': cutoff_date.isoformat(),
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'accounts/send_sms_form.html')
mock_send_sms.assert_not_called()
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=2)
@patch('accounts.admin.send_sms')
def test_apply_sends_when_recipients_are_within_cap(self, mock_send_sms):
"""
سقف نباید رفتارِ باتچ‌های نرمال (زیرِ سقف) رو تغییر بده — ارسال
باید مثلِ قبل انجام بشه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.user2.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_send_sms.call_count, 2)
@override_settings(ADMIN_SMS_MAX_RECIPIENTS=1)
@patch('accounts.admin.send_sms')
def test_apply_cap_check_does_not_count_phoneless_users_differently(self, mock_send_sms):
"""
سقف بر اساسِ تعدادِ کلِ گیرنده‌ها (قبل از فیلترِ داشتنِ شماره‌تلفن)
محاسبه می‌شه؛ این تست فقط تأیید می‌کنه که با سقفِ ۱، یک queryset دو
نفره (even شاملِ کاربرِ بدونِ شماره) هنوز block می‌شه.
"""
response = self.client.post(self.changelist_url, {
'action': 'send_sms_to_selected',
'_selected_action': [self.user1.pk, self.guest_no_phone.pk],
'apply': 'ارسال پیامک',
'message': 'متن',
'target': 'selected',
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_not_called()
class SupervisorGroupAccessTests(TestCase):
"""
تست‌های صفحه‌ی «ناظر» (Supervisor) داخلِ همون Adminِ موجود:
- دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ view_* رو ست
می‌کنه (بدونِ add/change/delete)، روی User/Order/Transaction/
Subscription.
- عضوِ گروهِ Supervisor می‌تونه changelist این چهار مدل رو ببینه، ولی
نمی‌تونه add/change/delete کنه — نه با کلیک روی دکمه (چون اصلاً
نشون داده نمی‌شه)، نه با درخواستِ مستقیمِ URL.
- این محدودیت از طریقِ Permission/Group واقعیِ جنگو تأمینه، نه صرفاً
پنهون‌کردنِ لینک.
"""
def setUp(self):
from payment.models import Order
from subscription.models import Plan, Subscription
call_command('setup_supervisor_group')
self.supervisor_group = Group.objects.get(name='Supervisor')
AuthUser = get_user_model()
self.supervisor = AuthUser.objects.create_user(
username='supervisor-1', email='supervisor@example.com', password='pass12345',
is_staff=True,
)
self.supervisor.groups.add(self.supervisor_group)
self.plain_staff = AuthUser.objects.create_user(
username='plain-staff-1', email='plain-staff@example.com', password='pass12345',
is_staff=True,
)
self.target_user = User.objects.create(name='target', is_guest=False, phone='0910000099')
self.plan = Plan.objects.create(title='یک‌ماهه', price=10000)
self.order = Order.objects.create(
user_id=self.target_user.id, amount=10000, idempotency_key='sup-order-1',
)
self.subscription = Subscription.objects.create(
user_id=self.target_user.id, plan=self.plan, status='active',
)
def test_setup_command_grants_only_view_permissions(self):
codenames = set(
self.supervisor_group.permissions.values_list('content_type__app_label', 'codename')
)
self.assertEqual(
codenames,
{
('accounts', 'view_user'),
('payment', 'view_order'),
('payment', 'view_transaction'),
('subscription', 'view_subscription'),
},
)
for _, codename in codenames:
self.assertTrue(codename.startswith('view_'))
def test_setup_command_is_idempotent_and_strips_extra_permissions(self):
"""
اگه یک پرمیشنِ change/delete دستی به گروه اضافه شده باشه، اجرای
دوباره‌ی دستور باید اون رو حذف کنه (چون گروه باید فقط view_*
داشته باشه).
"""
change_user_perm = Permission.objects.get(
content_type__app_label='accounts', codename='change_user',
)
self.supervisor_group.permissions.add(change_user_perm)
call_command('setup_supervisor_group')
self.supervisor_group.refresh_from_db()
codenames = set(self.supervisor_group.permissions.values_list('codename', flat=True))
self.assertNotIn('change_user', codenames)
self.assertIn('view_user', codenames)
def test_supervisor_can_view_user_changelist(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:accounts_user_changelist'))
self.assertEqual(response.status_code, 200)
def test_supervisor_can_view_order_and_transaction_changelist(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:payment_order_changelist'))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('admin:payment_transaction_changelist'))
self.assertEqual(response.status_code, 200)
def test_supervisor_can_view_subscription_changelist(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:subscription_subscription_changelist'))
self.assertEqual(response.status_code, 200)
def test_supervisor_cannot_access_user_add_page(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:accounts_user_add'))
self.assertEqual(response.status_code, 403)
@patch('accounts.admin.send_sms')
def test_supervisor_cannot_execute_send_sms_action_via_direct_post(self, mock_send_sms):
"""
عضوِ Supervisor فقط view_user داره، نه change_user. اکشنِ
send_sms_to_selected باید permissions=['change'] داشته باشه تا
جنگو خودش (get_actions/response_action) این اکشن رو برای
Supervisor فیلتر کنه — یعنی حتی با POSTِ مستقیم و دستیِ 'apply'
(بدونِ دیدنِ دکمه‌ی اکشن در UI)، هیچ پیامکی ارسال نشه.
"""
self.client.force_login(self.supervisor)
response = self.client.post(reverse('admin:accounts_user_changelist'), {
'action': 'send_sms_to_selected',
'_selected_action': [self.target_user.pk],
'apply': 'ارسال پیامک',
'message': 'تلاشِ غیرمجاز برای ارسال',
})
self.assertEqual(response.status_code, 200)
mock_send_sms.assert_not_called()
def test_supervisor_can_view_user_change_page_readonly(self):
"""
طبقِ رفتارِ پیش‌فرضِ ModelAdmin جنگو (از نسخه‌ی ۲٫۱ به بعد)، وقتی
کاربر فقط view permission داره (نه change)، GET به change_view
باید 200 برگردونه ولی همه‌ی فیلدها read-only باشن — این دقیقاً
همون رفتارِ استانداردی‌ست که ما ازش استفاده می‌کنیم و هیچ override
سفارشی‌ای اضافه نکردیم.
"""
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:accounts_user_change', args=[self.target_user.pk]))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'name="_save"')
self.assertNotContains(response, 'name="_continue"')
def test_supervisor_post_to_user_change_does_not_modify_record(self):
"""
حتی اگه سوپروایزر مستقیماً به URL ویرایش POST بزنه (بدونِ دیدنِ
دکمه‌ی ذخیره)، چون change permission نداره باید 403 بگیره و
رکورد دست‌نخورده بمونه.
"""
self.client.force_login(self.supervisor)
original_name = self.target_user.name
response = self.client.post(
reverse('admin:accounts_user_change', args=[self.target_user.pk]),
{'name': 'دستکاری‌شده', 'is_guest': 'on'},
)
self.assertEqual(response.status_code, 403)
self.target_user.refresh_from_db()
self.assertEqual(self.target_user.name, original_name)
def test_supervisor_cannot_access_order_delete_page(self):
from payment.models import Order
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:payment_order_delete', args=[self.order.pk]))
self.assertEqual(response.status_code, 403)
self.assertTrue(Order.objects.filter(pk=self.order.pk).exists())
def test_supervisor_cannot_access_subscription_add_page(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:subscription_subscription_add'))
self.assertEqual(response.status_code, 403)
def test_supervisor_dashboard_shows_only_permitted_links(self):
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse('admin:accounts_user_changelist'))
self.assertContains(response, reverse('admin:payment_order_changelist'))
self.assertContains(response, reverse('admin:payment_transaction_changelist'))
self.assertContains(response, reverse('admin:subscription_subscription_changelist'))
def test_plain_staff_without_group_gets_403_on_dashboard(self):
"""
یک کاربرِ staff که عضوِ گروهِ Supervisor نیست باید هنگامِ باز کردنِ
/admin/supervisor/ با ۴۰۳ (PermissionDenied) روبه‌رو بشه — صرفِ
is_staff بودن دیگه برای ورود به این صفحه کافی نیست.
"""
self.client.force_login(self.plain_staff)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 403)
def test_superuser_without_group_gets_403_on_dashboard(self):
"""
حتی یک superuser هم اگه عضوِ گروهِ Supervisor نباشه، باید ۴۰۳
بگیره — قانونِ دسترسی صرفاً بر اساسِ عضویتِ گروهه، نه سطحِ
دسترسیِ کلیِ کاربر.
"""
AuthUser = get_user_model()
superuser = AuthUser.objects.create_superuser(
username='super-1', email='super@example.com', password='pass12345',
)
self.client.force_login(superuser)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 403)
def test_supervisor_can_still_access_dashboard_after_fix(self):
"""
سنجه‌ی کنترل: کاربرِ staff که واقعاً عضوِ گروهِ Supervisor هست،
باید همچنان ۲۰۰ بگیره و لینک‌های مجاز رو ببینه.
"""
self.client.force_login(self.supervisor)
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse('admin:accounts_user_changelist'))
def test_anonymous_user_redirected_from_supervisor_dashboard(self):
response = self.client.get(reverse('admin:supervisor-dashboard'))
self.assertEqual(response.status_code, 302)
def test_supervisor_group_membership_alone_is_not_enough_without_is_staff(self):
"""
عضویتِ گروه به‌تنهایی کافی نیست؛ سیستمِ Admin هنوز is_staff رو هم
لازم داره — این تضمین می‌کنه محدودیت واقعاً از مسیرِ استانداردِ
Admin/Permission جنگو رد می‌شه، نه یک چک سفارشیِ جدا.
"""
AuthUser = get_user_model()
non_staff_supervisor = AuthUser.objects.create_user(
username='non-staff-supervisor', email='ns@example.com', password='pass12345',
is_staff=False,
)
non_staff_supervisor.groups.add(self.supervisor_group)
self.client.force_login(non_staff_supervisor)
response = self.client.get(reverse('admin:accounts_user_changelist'))
self.assertEqual(response.status_code, 302)