optional short-TTL cache for the auth user row
TadrisinoTokenAuthentication ran one SELECT on accounts_user for every
authenticated request. Off by default (AUTH_USER_CACHE_TTL=0) so behaviour
is unchanged until a deployment opts in.
accounts/authentication.py
- _get_request_user(): when AUTH_USER_CACHE_TTL>0, cache all concrete
field values under auth:user:<id> and rebuild a full User instance
(so save(update_fields=[...]) stays safe); otherwise load from DB as
before. invalidate_user_cache() / _load_user_or_fail() helpers.
accounts/receivers.py (new)
- post_save/post_delete on User -> invalidate_user_cache(pk); covers
every in-app write path (SetUserGrade, login, refresh, last_activity).
accounts/apps.py
- AccountsConfig.ready() imports receivers.
accounts/tests.py
- AuthUserRowCacheTests: disabled -> loads every request; enabled ->
loads once then cache; User.save() invalidates; SetUserGradeView still
persists and the next request sees the new grade.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,4 @@ class AccountsConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
# Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button.
|
# Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button.
|
||||||
from . import schema # noqa: F401
|
from . import schema # noqa: F401
|
||||||
|
from . import receivers # noqa: F401 (باطلکردنِ کشِ ردیفِ کاربر)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
|
from django.core.cache import cache
|
||||||
from rest_framework.authentication import BaseAuthentication
|
from rest_framework.authentication import BaseAuthentication
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
@@ -7,6 +8,48 @@ from .models import User
|
|||||||
|
|
||||||
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
|
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
|
||||||
|
|
||||||
|
# فیلدهای غیرِرابطهایِ User که کش میشن؛ همهی concrete fieldها تا بازسازیِ
|
||||||
|
# instance برای save(update_fields=...) امن بمونه.
|
||||||
|
_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields]
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_user_cache_key(user_id: int) -> str:
|
||||||
|
return f'auth:user:{user_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_cache(user_id: int) -> None:
|
||||||
|
cache.delete(_auth_user_cache_key(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_user_or_fail(user_id: int) -> User:
|
||||||
|
try:
|
||||||
|
return User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise AuthenticationFailed('کاربر پیدا نشد.')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_user(user_id: int) -> User:
|
||||||
|
"""
|
||||||
|
ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده
|
||||||
|
میشه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازیشده
|
||||||
|
کاملِ همهی فیلدهاست، پس save(update_fields=[...]) روش امنه.
|
||||||
|
"""
|
||||||
|
ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0)
|
||||||
|
if not ttl:
|
||||||
|
return _load_user_or_fail(user_id)
|
||||||
|
|
||||||
|
key = _auth_user_cache_key(user_id)
|
||||||
|
data = cache.get(key)
|
||||||
|
if data is None:
|
||||||
|
user = _load_user_or_fail(user_id)
|
||||||
|
cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl)
|
||||||
|
return user
|
||||||
|
|
||||||
|
user = User(**data)
|
||||||
|
user._state.adding = False
|
||||||
|
user._state.db = 'default'
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def issue_access_token(user_id: int) -> str:
|
def issue_access_token(user_id: int) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -48,13 +91,7 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
|
|||||||
|
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
user_id = decode_access_token(token)
|
user_id = decode_access_token(token)
|
||||||
|
return (_get_request_user(user_id), token)
|
||||||
try:
|
|
||||||
user = User.objects.get(pk=user_id)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise AuthenticationFailed('کاربر پیدا نشد.')
|
|
||||||
|
|
||||||
return (user, token)
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
return self.keyword
|
return self.keyword
|
||||||
|
|||||||
22
accounts/receivers.py
Normal file
22
accounts/receivers.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
باطلکردنِ کشِ ردیفِ کاربر (auth:user:<id>) با هر تغییرِ User.
|
||||||
|
|
||||||
|
این سیگنالها همهی مسیرهای نوشتنِ داخلِ اپ (SetUserGrade، لاگین، رفرشتوکن،
|
||||||
|
last_activity_at، ...) را پوشش میدهند؛ فقط تغییری که کاملاً بیرون از ORM
|
||||||
|
اعمال شود (مثلاً UPDATE مستقیمِ SQL) تا سقفِ AUTH_USER_CACHE_TTL کهنه میماند.
|
||||||
|
"""
|
||||||
|
from django.db.models.signals import post_delete, post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .authentication import invalidate_user_cache
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_save')
|
||||||
|
def _invalidate_on_save(sender, instance, **kwargs):
|
||||||
|
invalidate_user_cache(instance.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_delete')
|
||||||
|
def _invalidate_on_delete(sender, instance, **kwargs):
|
||||||
|
invalidate_user_cache(instance.pk)
|
||||||
@@ -1430,3 +1430,66 @@ class SupervisorGroupAccessTests(TestCase):
|
|||||||
self.client.force_login(non_staff_supervisor)
|
self.client.force_login(non_staff_supervisor)
|
||||||
response = self.client.get(reverse('admin:accounts_user_changelist'))
|
response = self.client.get(reverse('admin:accounts_user_changelist'))
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthUserRowCacheTests(TestCase):
|
||||||
|
"""کشِ کوتاهِ ردیفِ کاربر در TadrisinoTokenAuthentication."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n')
|
||||||
|
self.token = issue_access_token(self.user.id)
|
||||||
|
# هر endpointِ authenticated؛ /api/user/hearts سبک است.
|
||||||
|
self.url = '/api/user/hearts'
|
||||||
|
|
||||||
|
def _hit(self):
|
||||||
|
return self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||||
|
|
||||||
|
@override_settings(AUTH_USER_CACHE_TTL=0)
|
||||||
|
def test_disabled_by_default_loads_every_request(self):
|
||||||
|
with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m:
|
||||||
|
self._hit(); self._hit()
|
||||||
|
self.assertEqual(m.call_count, 2)
|
||||||
|
|
||||||
|
@override_settings(AUTH_USER_CACHE_TTL=30)
|
||||||
|
def test_enabled_caches_after_first_load(self):
|
||||||
|
with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m:
|
||||||
|
self.assertEqual(self._hit().status_code, 200)
|
||||||
|
self.assertEqual(self._hit().status_code, 200)
|
||||||
|
self.assertEqual(m.call_count, 1)
|
||||||
|
|
||||||
|
@override_settings(AUTH_USER_CACHE_TTL=30)
|
||||||
|
def test_user_save_invalidates_cache(self):
|
||||||
|
self._hit() # cache populated
|
||||||
|
with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m:
|
||||||
|
self.user.name = 'changed'
|
||||||
|
self.user.save(update_fields=['name']) # post_save → invalidate
|
||||||
|
self._hit()
|
||||||
|
self.assertEqual(m.call_count, 1)
|
||||||
|
|
||||||
|
@override_settings(AUTH_USER_CACHE_TTL=30)
|
||||||
|
def test_set_grade_still_works_with_cache_on(self):
|
||||||
|
r = self.client.post(
|
||||||
|
'/api/user/grade', {'gradeId': 3}, format='json',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.grade_id, 3)
|
||||||
|
# درخواستِ بعدی باید گریدِ تازه را ببیند (کش پس از save باطل شده).
|
||||||
|
prof = self.client.get(
|
||||||
|
'/api/user/profile', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||||
|
self.assertEqual(prof.data['gradeId'], 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _orig_loader():
|
||||||
|
"""نسخهی اصلیِ لودر برای wraps کردنِ Mock (بدونِ recursion)."""
|
||||||
|
from accounts.models import User as _U
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
def _load(user_id):
|
||||||
|
try:
|
||||||
|
return _U.objects.get(pk=user_id)
|
||||||
|
except _U.DoesNotExist:
|
||||||
|
raise AuthenticationFailed('کاربر پیدا نشد.')
|
||||||
|
return _load
|
||||||
|
|||||||
Reference in New Issue
Block a user