Add campaigns/ Add supervisor/ Add messaging features/ Separate SMS
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user