Browse Source

feat(cache): implement automatic cache invalidation and smart cache bypass

- Add utils/cache.py with invalidate_cache_patterns and smart_cached_view
- Support cache bypass on Cache-Control: no-cache headers and query params
- Add automatic signals (post_save, post_delete, m2m_changed) for library, video, podcast, article, calendar, and hadis
- Hook cache invalidation in custom admin viewset actions
master
Mohsen Taba 4 days ago
parent
commit
0dbdad0880
  1. 4
      apps/article/apps.py
  2. 39
      apps/article/signals.py
  3. 23
      apps/article/urls.py
  4. 2
      apps/article/views_admin.py
  5. 4
      apps/dobodbi_calendar/apps.py
  6. 20
      apps/dobodbi_calendar/signals.py
  7. 8
      apps/dobodbi_calendar/urls.py
  8. 26
      apps/hadis/signals.py
  9. 8
      apps/hadis/urls.py
  10. 4
      apps/library/apps.py
  11. 27
      apps/library/signals.py
  12. 21
      apps/library/urls.py
  13. 4
      apps/library/views_admin.py
  14. 3
      apps/podcast/apps.py
  15. 41
      apps/podcast/signals.py
  16. 24
      apps/podcast/urls.py
  17. 7
      apps/podcast/views_admin.py
  18. 4
      apps/video/apps.py
  19. 41
      apps/video/signals.py
  20. 23
      apps/video/urls.py
  21. 7
      apps/video/views_admin.py
  22. 69
      utils/cache.py

4
apps/article/apps.py

@ -4,3 +4,7 @@ from django.apps import AppConfig
class ArticleConfig(AppConfig): class ArticleConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.article' name = 'apps.article'
def ready(self):
import apps.article.signals

39
apps/article/signals.py

@ -0,0 +1,39 @@
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from utils.cache import invalidate_cache_patterns
from .models import (
Article,
ArticleCategory,
ArticleCollection,
ArticleContent,
ArticleInCollection,
)
TARGET_MODELS = [
ArticleCategory,
ArticleCollection,
Article,
ArticleContent,
ArticleInCollection,
]
def invalidate_article_cache():
"""
Invalidates all Redis cache keys for article API views.
"""
return invalidate_cache_patterns("*article_api*", "*article_list*")
@receiver(post_save)
@receiver(post_delete)
def clear_article_cache_on_save_or_delete(sender, instance, **kwargs):
if sender in TARGET_MODELS:
invalidate_article_cache()
@receiver(m2m_changed)
def clear_article_cache_on_m2m(sender, instance, action, **kwargs):
if action in ('post_add', 'post_remove', 'post_clear'):
if isinstance(instance, (Article, ArticleCollection, ArticleCategory)):
invalidate_article_cache()

23
apps/article/urls.py

@ -1,27 +1,12 @@
from functools import wraps
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from utils.cache import smart_cached_view
from .views import * from .views import *
from .views_admin import AdminArticleCategoryViewSet, AdminArticleContentViewSet, AdminArticleViewSet from .views_admin import AdminArticleCategoryViewSet, AdminArticleContentViewSet, AdminArticleViewSet
# Helper function to cache public views
def cached_view(view_func):
return cache_page(60 * 60 * 2, key_prefix='article_api')(vary_on_headers('Accept-Language')(view_func))
# Helper to conditionally cache list views (bypasses cache when is_bookmark=true)
def conditional_cached_view(timeout, key_prefix):
def decorator(view_func):
cached_func = cache_page(timeout, key_prefix=key_prefix)(vary_on_headers('Accept-Language')(view_func))
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
is_bookmark = (request.GET.get('is_bookmark') or request.GET.get('is_bookmarked') or '').lower()
if is_bookmark in ('true', '1'):
return view_func(request, *args, **kwargs)
return cached_func(request, *args, **kwargs)
return _wrapped_view
return decorator
# Helper function to cache public views with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 2, key_prefix='article_api')
conditional_cached_view = smart_cached_view
app_name = 'article' app_name = 'article'

2
apps/article/views_admin.py

@ -10,6 +10,7 @@ from apps.account.permissions import IsSuperAdmin
from utils.pagination import StandardResultsSetPagination from utils.pagination import StandardResultsSetPagination
from .models import Article, ArticleCategory, ArticleContent from .models import Article, ArticleCategory, ArticleContent
from .signals import invalidate_article_cache
from .serializers_admin import ( from .serializers_admin import (
AdminArticleCategorySerializer, AdminArticleCategorySerializer,
AdminArticleContentSerializer, AdminArticleContentSerializer,
@ -88,4 +89,5 @@ class AdminArticleContentViewSet(ModelViewSet):
for index, content_id in enumerate(content_ids, start=1): for index, content_id in enumerate(content_ids, start=1):
ArticleContent.objects.filter(id=content_id).update(priority=index) ArticleContent.objects.filter(id=content_id).update(priority=index)
invalidate_article_cache()
return Response({"status": "success"}) return Response({"status": "success"})

4
apps/dobodbi_calendar/apps.py

@ -4,3 +4,7 @@ from django.apps import AppConfig
class DobodbiCalendarConfig(AppConfig): class DobodbiCalendarConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.dobodbi_calendar' name = 'apps.dobodbi_calendar'
def ready(self):
import apps.dobodbi_calendar.signals

20
apps/dobodbi_calendar/signals.py

@ -0,0 +1,20 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from utils.cache import invalidate_cache_patterns
from .models import CalendarOccasions
TARGET_MODELS = [CalendarOccasions]
def invalidate_calendar_cache():
"""
Invalidates all Redis cache keys for calendar API views.
"""
return invalidate_cache_patterns("*calendar_api*")
@receiver(post_save)
@receiver(post_delete)
def clear_calendar_cache_on_save_or_delete(sender, instance, **kwargs):
if sender in TARGET_MODELS:
invalidate_calendar_cache()

8
apps/dobodbi_calendar/urls.py

@ -1,13 +1,11 @@
from django.urls import include, path from django.urls import include, path
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from utils.cache import smart_cached_view
from apps.dobodbi_calendar.views import CalendarList, AdjustmentConfigView, OccasionsList from apps.dobodbi_calendar.views import CalendarList, AdjustmentConfigView, OccasionsList
from apps.dobodbi_calendar.views_admin import AdminCalendarOccasionViewSet from apps.dobodbi_calendar.views_admin import AdminCalendarOccasionViewSet
# Helper function for caching calendar endpoints
def cached_view(view_func):
return cache_page(60 * 60 * 6, key_prefix='calendar_api')(vary_on_headers('Accept-Language')(view_func))
# Helper function for caching calendar endpoints with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 6, key_prefix='calendar_api')
admin_router = SimpleRouter() admin_router = SimpleRouter()
admin_router.register(r'occasions', AdminCalendarOccasionViewSet, basename='admin-calendar-occasions') admin_router.register(r'occasions', AdminCalendarOccasionViewSet, basename='admin-calendar-occasions')

26
apps/hadis/signals.py

@ -1,8 +1,6 @@
# hadith_app/signals.py
from django.db.models.signals import post_save, post_delete
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver from django.dispatch import receiver
from django.core.cache import cache
from utils.cache import invalidate_cache_patterns
from .models import * from .models import *
# 1. Define all models that affect the list # 1. Define all models that affect the list
@ -20,18 +18,8 @@ TARGET_MODELS = [
def invalidate_hadis_cache(): def invalidate_hadis_cache():
""" """
Deletes all Redis cache keys matching '*hadis_api*'. Deletes all Redis cache keys matching '*hadis_api*'.
Falls back to cache.clear() if cache backend doesn't support delete_pattern.
""" """
try:
if hasattr(cache, 'delete_pattern'):
deleted = cache.delete_pattern("*hadis_api*")
print(f"Cache cleared for hadis_api (deleted {deleted} keys)!")
else:
cache.clear()
print("Cache cleared for hadis_api (fallback clear)!")
except Exception as e:
# Fail silently or log error, don't crash the transaction
print(f"Cache clear failed: {e}")
return invalidate_cache_patterns("*hadis_api*")
@receiver(post_save) @receiver(post_save)
@ -42,4 +30,10 @@ def clear_hadis_cache(sender, instance, **kwargs):
""" """
if sender in TARGET_MODELS: if sender in TARGET_MODELS:
invalidate_hadis_cache() invalidate_hadis_cache()
print(f"Cache cleared for {sender.__name__} update!")
@receiver(m2m_changed)
def clear_hadis_cache_on_m2m(sender, instance, action, **kwargs):
if action in ('post_add', 'post_remove', 'post_clear'):
if isinstance(instance, tuple(TARGET_MODELS)):
invalidate_hadis_cache()

8
apps/hadis/urls.py

@ -23,12 +23,10 @@ from .views_admin import (
AdminContentReleaseViewSet AdminContentReleaseViewSet
) )
from .views.reference_v2 import BookAuthorListView, BookAuthorDetailView, AuthorReferencesListView, BookAuthorSyncView from .views.reference_v2 import BookAuthorListView, BookAuthorDetailView, AuthorReferencesListView, BookAuthorSyncView
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from utils.cache import smart_cached_view
# Helper function to avoid ugly nesting
def cached_view(view_func):
return cache_page(60*60*2,key_prefix='hadis_api')(vary_on_headers('Accept-Language')(view_func))
# Helper function to cache public views with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 2, key_prefix='hadis_api')
admin_router = SimpleRouter() admin_router = SimpleRouter()
admin_router.register(r'hadises', AdminHadisViewSet, basename='admin-hadises') admin_router.register(r'hadises', AdminHadisViewSet, basename='admin-hadises')

4
apps/library/apps.py

@ -7,3 +7,7 @@ class LibraryConfig(AppConfig):
name = 'apps.library' name = 'apps.library'
verbose_name = _('Library') verbose_name = _('Library')
icon = 'mi-library-books' icon = 'mi-library-books'
def ready(self):
import apps.library.signals

27
apps/library/signals.py

@ -0,0 +1,27 @@
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from utils.cache import invalidate_cache_patterns
from .models import Author, Book, BookCollection, Category
TARGET_MODELS = [Author, Book, BookCollection, Category]
def invalidate_library_cache():
"""
Invalidates all Redis cache keys for library API views.
"""
return invalidate_cache_patterns("*library_api*", "*library_books*")
@receiver(post_save)
@receiver(post_delete)
def clear_library_cache_on_save_or_delete(sender, instance, **kwargs):
if sender in TARGET_MODELS:
invalidate_library_cache()
@receiver(m2m_changed)
def clear_library_cache_on_m2m(sender, instance, action, **kwargs):
if action in ('post_add', 'post_remove', 'post_clear'):
if isinstance(instance, (Book, BookCollection, Category)):
invalidate_library_cache()

21
apps/library/urls.py

@ -16,24 +16,11 @@ from apps.library.views import (
BookDownloadCreateAPIView, BookDownloadCreateAPIView,
) )
from functools import wraps
from utils.cache import smart_cached_view
# Helper function to cache public views
def cached_view(view_func):
return cache_page(60 * 60 * 2, key_prefix='library_api')(vary_on_headers('Accept-Language')(view_func))
# Helper to conditionally cache list views (bypasses cache when is_bookmark=true)
def conditional_cached_view(timeout, key_prefix):
def decorator(view_func):
cached_func = cache_page(timeout, key_prefix=key_prefix)(vary_on_headers('Accept-Language')(view_func))
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
is_bookmark = (request.GET.get('is_bookmark') or request.GET.get('is_bookmarked') or '').lower()
if is_bookmark in ('true', '1'):
return view_func(request, *args, **kwargs)
return cached_func(request, *args, **kwargs)
return _wrapped_view
return decorator
# Helper function to cache public views with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 2, key_prefix='library_api')
conditional_cached_view = smart_cached_view
from apps.library.views_admin import ( from apps.library.views_admin import (
AdminAuthorViewSet, AdminAuthorViewSet,
AdminBookViewSet, AdminBookViewSet,

4
apps/library/views_admin.py

@ -10,6 +10,7 @@ from apps.account.permissions import IsSuperAdmin
from utils.pagination import StandardResultsSetPagination from utils.pagination import StandardResultsSetPagination
from .models import Author, Book, BookCollection, Category from .models import Author, Book, BookCollection, Category
from .signals import invalidate_library_cache
from .serializers_admin import ( from .serializers_admin import (
AdminAuthorSerializer, AdminAuthorSerializer,
AdminBookDetailSerializer, AdminBookDetailSerializer,
@ -74,6 +75,7 @@ class AdminLibraryCollectionViewSet(ModelViewSet):
if remove_ids: if remove_ids:
collection.related_collections.remove(*remove_ids) collection.related_collections.remove(*remove_ids)
invalidate_library_cache()
return Response({"status": "success", "attached_count": collection.related_collections.count()}) return Response({"status": "success", "attached_count": collection.related_collections.count()})
@ -113,6 +115,7 @@ class DovodiAdminLibraryCollectionViewSet(ModelViewSet):
if remove_ids: if remove_ids:
collection.related_collections.remove(*remove_ids) collection.related_collections.remove(*remove_ids)
invalidate_library_cache()
return Response({"status": "success", "attached_count": collection.related_collections.count()}) return Response({"status": "success", "attached_count": collection.related_collections.count()})
@ -147,6 +150,7 @@ class AdminAuthorViewSet(ModelViewSet):
if remove_ids: if remove_ids:
Book.objects.filter(id__in=remove_ids, author=author).update(author=None) Book.objects.filter(id__in=remove_ids, author=author).update(author=None)
invalidate_library_cache()
return Response({ return Response({
"status": "success", "status": "success",
"attached_count": author.books.count() "attached_count": author.books.count()

3
apps/podcast/apps.py

@ -5,4 +5,5 @@ class PodcastConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.podcast' name = 'apps.podcast'
def ready(self):
import apps.podcast.signals

41
apps/podcast/signals.py

@ -0,0 +1,41 @@
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from utils.cache import invalidate_cache_patterns
from .models import (
PlaylistItem,
Podcast,
PodcastCategory,
PodcastCollection,
PodcastPlaylist,
PodcastPlaylistInCollection,
)
TARGET_MODELS = [
PodcastCategory,
PodcastCollection,
Podcast,
PodcastPlaylist,
PodcastPlaylistInCollection,
PlaylistItem,
]
def invalidate_podcast_cache():
"""
Invalidates all Redis cache keys for podcast API views.
"""
return invalidate_cache_patterns("*podcast_api*", "*podcast_list*")
@receiver(post_save)
@receiver(post_delete)
def clear_podcast_cache_on_save_or_delete(sender, instance, **kwargs):
if sender in TARGET_MODELS:
invalidate_podcast_cache()
@receiver(m2m_changed)
def clear_podcast_cache_on_m2m(sender, instance, action, **kwargs):
if action in ('post_add', 'post_remove', 'post_clear'):
if isinstance(instance, (Podcast, PodcastPlaylist, PodcastCollection, PodcastCategory)):
invalidate_podcast_cache()

24
apps/podcast/urls.py

@ -1,28 +1,12 @@
from functools import wraps
from django.urls import path, re_path, include from django.urls import path, re_path, include
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from utils.cache import smart_cached_view
from .views import * from .views import *
from .views_admin import * from .views_admin import *
# Helper function to cache public views
def cached_view(view_func):
return cache_page(60 * 60 * 2, key_prefix='podcast_api')(vary_on_headers('Accept-Language')(view_func))
# Helper to conditionally cache list views (bypasses cache when is_bookmark=true or in_playlist=true)
def conditional_cached_view(timeout, key_prefix):
def decorator(view_func):
cached_func = cache_page(timeout, key_prefix=key_prefix)(vary_on_headers('Accept-Language')(view_func))
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
is_bookmark = (request.GET.get('is_bookmark') or request.GET.get('is_bookmarked') or '').lower()
in_playlist = request.GET.get('in_playlist', '').lower()
if is_bookmark in ('true', '1') or in_playlist in ('true', '1'):
return view_func(request, *args, **kwargs)
return cached_func(request, *args, **kwargs)
return _wrapped_view
return decorator
# Helper function to cache public views with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 2, key_prefix='podcast_api')
conditional_cached_view = smart_cached_view
admin_router = SimpleRouter() admin_router = SimpleRouter()
admin_router.register(r'podcasts', AdminPodcastViewSet, basename='admin-podcast') admin_router.register(r'podcasts', AdminPodcastViewSet, basename='admin-podcast')

7
apps/podcast/views_admin.py

@ -12,6 +12,7 @@ from apps.account.permissions import IsSuperAdmin
from utils.pagination import StandardResultsSetPagination from utils.pagination import StandardResultsSetPagination
from .models import PlaylistItem, Podcast, PodcastCollection, PodcastCategory, PodcastPlaylist, PodcastPlaylistInCollection from .models import PlaylistItem, Podcast, PodcastCollection, PodcastCategory, PodcastPlaylist, PodcastPlaylistInCollection
from .signals import invalidate_podcast_cache
from .serializers_admin import ( from .serializers_admin import (
AdminPodcastCategorySerializer, AdminPodcastCategorySerializer,
AdminPodcastCollectionSerializer, AdminPodcastCollectionSerializer,
@ -59,6 +60,7 @@ class AdminPodcastCollectionViewSet(ModelViewSet):
if remove_ids: if remove_ids:
PodcastPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete() PodcastPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete()
invalidate_podcast_cache()
return Response({"status": "success", "attached_count": collection.collection_playlists.count()}) return Response({"status": "success", "attached_count": collection.collection_playlists.count()})
@ -161,6 +163,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet):
serializer.save() serializer.save()
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_podcast_cache()
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P<item_id>\d+)") @action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P<item_id>\d+)")
@ -172,6 +175,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet):
item.delete() item.delete()
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_podcast_cache()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminPodcastPlaylistItemSerializer( serializer = AdminPodcastPlaylistItemSerializer(
@ -184,6 +188,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet):
serializer.save() serializer.save()
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_podcast_cache()
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="items/reorder") @action(detail=True, methods=["post"], url_path="items/reorder")
@ -205,6 +210,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet):
item.priority = index item.priority = index
item.save(update_fields=["priority"]) item.save(update_fields=["priority"])
invalidate_podcast_cache()
serializer = AdminPodcastPlaylistItemSerializer( serializer = AdminPodcastPlaylistItemSerializer(
playlist.playlist_items.select_related("podcast").order_by("priority", "id"), playlist.playlist_items.select_related("podcast").order_by("priority", "id"),
many=True, many=True,
@ -240,6 +246,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet):
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_podcast_cache()
serializer = AdminPodcastPlaylistItemSerializer( serializer = AdminPodcastPlaylistItemSerializer(
playlist.playlist_items.select_related("podcast").order_by("priority", "id"), playlist.playlist_items.select_related("podcast").order_by("priority", "id"),
many=True, many=True,

4
apps/video/apps.py

@ -4,3 +4,7 @@ from django.apps import AppConfig
class VideoConfig(AppConfig): class VideoConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.video' name = 'apps.video'
def ready(self):
import apps.video.signals

41
apps/video/signals.py

@ -0,0 +1,41 @@
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from utils.cache import invalidate_cache_patterns
from .models import (
PlaylistItem,
Video,
VideoCategory,
VideoCollection,
VideoPlaylist,
VideoPlaylistInCollection,
)
TARGET_MODELS = [
VideoCategory,
VideoCollection,
Video,
VideoPlaylist,
VideoPlaylistInCollection,
PlaylistItem,
]
def invalidate_video_cache():
"""
Invalidates all Redis cache keys for video API views.
"""
return invalidate_cache_patterns("*video_api*", "*video_playlists*", "*video_list*")
@receiver(post_save)
@receiver(post_delete)
def clear_video_cache_on_save_or_delete(sender, instance, **kwargs):
if sender in TARGET_MODELS:
invalidate_video_cache()
@receiver(m2m_changed)
def clear_video_cache_on_m2m(sender, instance, action, **kwargs):
if action in ('post_add', 'post_remove', 'post_clear'):
if isinstance(instance, (Video, VideoPlaylist, VideoCollection, VideoCategory)):
invalidate_video_cache()

23
apps/video/urls.py

@ -1,27 +1,12 @@
from functools import wraps
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from rest_framework.routers import SimpleRouter from rest_framework.routers import SimpleRouter
from utils.cache import smart_cached_view
from .views import * from .views import *
from .views_admin import * from .views_admin import *
# Helper function to cache public views
def cached_view(view_func):
return cache_page(60 * 60 * 2, key_prefix='video_api')(vary_on_headers('Accept-Language')(view_func))
# Helper to conditionally cache list views (bypasses cache when is_bookmark=true)
def conditional_cached_view(timeout, key_prefix):
def decorator(view_func):
cached_func = cache_page(timeout, key_prefix=key_prefix)(vary_on_headers('Accept-Language')(view_func))
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
is_bookmark = (request.GET.get('is_bookmark') or request.GET.get('is_bookmarked') or '').lower()
if is_bookmark in ('true', '1'):
return view_func(request, *args, **kwargs)
return cached_func(request, *args, **kwargs)
return _wrapped_view
return decorator
# Helper function to cache public views with smart invalidation and force-refresh support
cached_view = smart_cached_view(60 * 60 * 2, key_prefix='video_api')
conditional_cached_view = smart_cached_view
app_name = 'video' app_name = 'video'

7
apps/video/views_admin.py

@ -12,6 +12,7 @@ from apps.account.permissions import IsSuperAdmin
from utils.pagination import StandardResultsSetPagination from utils.pagination import StandardResultsSetPagination
from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist, VideoPlaylistInCollection from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist, VideoPlaylistInCollection
from .signals import invalidate_video_cache
from .serializers_admin import ( from .serializers_admin import (
AdminVideoCategorySerializer, AdminVideoCategorySerializer,
AdminVideoCollectionSerializer, AdminVideoCollectionSerializer,
@ -59,6 +60,7 @@ class AdminVideoCollectionViewSet(ModelViewSet):
if remove_ids: if remove_ids:
VideoPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete() VideoPlaylistInCollection.objects.filter(collection_id=collection.id, playlist_id__in=remove_ids).delete()
invalidate_video_cache()
return Response({"status": "success", "attached_count": collection.collection_playlists.count()}) return Response({"status": "success", "attached_count": collection.collection_playlists.count()})
@ -158,6 +160,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet):
serializer.save() serializer.save()
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_video_cache()
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P<item_id>\d+)") @action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P<item_id>\d+)")
@ -169,6 +172,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet):
item.delete() item.delete()
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_video_cache()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminVideoPlaylistItemSerializer( serializer = AdminVideoPlaylistItemSerializer(
@ -181,6 +185,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet):
serializer.save() serializer.save()
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_video_cache()
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=["post"], url_path="items/reorder") @action(detail=True, methods=["post"], url_path="items/reorder")
@ -202,6 +207,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet):
item.priority = index item.priority = index
item.save(update_fields=["priority"]) item.save(update_fields=["priority"])
invalidate_video_cache()
serializer = AdminVideoPlaylistItemSerializer( serializer = AdminVideoPlaylistItemSerializer(
playlist.playlist_items.select_related("video").order_by("priority", "id"), playlist.playlist_items.select_related("video").order_by("priority", "id"),
many=True, many=True,
@ -237,6 +243,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet):
playlist.total_time = playlist.calculate_total_time() playlist.total_time = playlist.calculate_total_time()
playlist.save(update_fields=["total_time"]) playlist.save(update_fields=["total_time"])
invalidate_video_cache()
serializer = AdminVideoPlaylistItemSerializer( serializer = AdminVideoPlaylistItemSerializer(
playlist.playlist_items.select_related("video").order_by("priority", "id"), playlist.playlist_items.select_related("video").order_by("priority", "id"),
many=True, many=True,

69
utils/cache.py

@ -0,0 +1,69 @@
from functools import wraps
import logging
from django.core.cache import cache
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
logger = logging.getLogger(__name__)
def invalidate_cache_patterns(*patterns):
"""
Safely invalidates Redis cache keys matching given glob patterns.
Falls back to cache.clear() if the cache backend does not support delete_pattern.
"""
try:
if hasattr(cache, 'delete_pattern'):
total_deleted = 0
for pattern in patterns:
deleted = cache.delete_pattern(pattern)
total_deleted += deleted or 0
logger.info(f"Cache cleared for patterns {patterns} (deleted {total_deleted} keys)")
return total_deleted
else:
cache.clear()
logger.info(f"Cache cleared (fallback clear for patterns: {patterns})")
return 1
except Exception as e:
logger.error(f"Cache invalidation failed for patterns {patterns}: {e}")
return 0
def smart_cached_view(timeout=60 * 60 * 2, key_prefix=''):
"""
Smart cache decorator for API views that:
1. Caches responses per Accept-Language header using Django's cache_page.
2. Bypasses cache when:
- Cache-Control / Pragma: no-cache headers are present (e.g., Pull-to-refresh from mobile / web).
- Query parameters: no_cache=true, refresh=true, is_bookmark=true, in_playlist=true are passed.
"""
def decorator(view_func):
cached_func = cache_page(timeout, key_prefix=key_prefix)(
vary_on_headers('Accept-Language')(view_func)
)
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
# 1. Check HTTP headers for no-cache directives
cache_control = (request.META.get('HTTP_CACHE_CONTROL') or '').lower()
pragma = (request.META.get('HTTP_PRAGMA') or '').lower()
if 'no-cache' in cache_control or 'no-store' in cache_control or 'max-age=0' in cache_control or 'no-cache' in pragma:
return view_func(request, *args, **kwargs)
# 2. Check query params for explicit bypass
query_params = getattr(request, 'GET', {})
no_cache_param = (query_params.get('no_cache') or query_params.get('refresh') or '').lower()
if no_cache_param in ('1', 'true'):
return view_func(request, *args, **kwargs)
# 3. Check bookmark & playlist specific bypasses
is_bookmark = (query_params.get('is_bookmark') or query_params.get('is_bookmarked') or '').lower()
in_playlist = (query_params.get('in_playlist') or '').lower()
if is_bookmark in ('1', 'true') or in_playlist in ('1', 'true'):
return view_func(request, *args, **kwargs)
return cached_func(request, *args, **kwargs)
return _wrapped_view
return decorator
Loading…
Cancel
Save