diff --git a/apps/article/apps.py b/apps/article/apps.py index 1a2757e..a066962 100755 --- a/apps/article/apps.py +++ b/apps/article/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class ArticleConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.article' + + def ready(self): + import apps.article.signals + diff --git a/apps/article/signals.py b/apps/article/signals.py new file mode 100644 index 0000000..18d6b94 --- /dev/null +++ b/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() diff --git a/apps/article/urls.py b/apps/article/urls.py index 2705478..0efb59c 100755 --- a/apps/article/urls.py +++ b/apps/article/urls.py @@ -1,27 +1,12 @@ -from functools import wraps 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 utils.cache import smart_cached_view from .views import * 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' diff --git a/apps/article/views_admin.py b/apps/article/views_admin.py index ddd33fe..a87ab74 100644 --- a/apps/article/views_admin.py +++ b/apps/article/views_admin.py @@ -10,6 +10,7 @@ from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination from .models import Article, ArticleCategory, ArticleContent +from .signals import invalidate_article_cache from .serializers_admin import ( AdminArticleCategorySerializer, AdminArticleContentSerializer, @@ -88,4 +89,5 @@ class AdminArticleContentViewSet(ModelViewSet): for index, content_id in enumerate(content_ids, start=1): ArticleContent.objects.filter(id=content_id).update(priority=index) + invalidate_article_cache() return Response({"status": "success"}) diff --git a/apps/dobodbi_calendar/apps.py b/apps/dobodbi_calendar/apps.py index 0d62830..5b9593d 100644 --- a/apps/dobodbi_calendar/apps.py +++ b/apps/dobodbi_calendar/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class DobodbiCalendarConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.dobodbi_calendar' + + def ready(self): + import apps.dobodbi_calendar.signals + diff --git a/apps/dobodbi_calendar/signals.py b/apps/dobodbi_calendar/signals.py new file mode 100644 index 0000000..05394e5 --- /dev/null +++ b/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() diff --git a/apps/dobodbi_calendar/urls.py b/apps/dobodbi_calendar/urls.py index ec2edfd..e008d93 100644 --- a/apps/dobodbi_calendar/urls.py +++ b/apps/dobodbi_calendar/urls.py @@ -1,13 +1,11 @@ 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 utils.cache import smart_cached_view from apps.dobodbi_calendar.views import CalendarList, AdjustmentConfigView, OccasionsList 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.register(r'occasions', AdminCalendarOccasionViewSet, basename='admin-calendar-occasions') diff --git a/apps/hadis/signals.py b/apps/hadis/signals.py index 6463b5a..4e8998b 100644 --- a/apps/hadis/signals.py +++ b/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.core.cache import cache +from utils.cache import invalidate_cache_patterns from .models import * # 1. Define all models that affect the list @@ -20,18 +18,8 @@ TARGET_MODELS = [ def invalidate_hadis_cache(): """ 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) @@ -42,4 +30,10 @@ def clear_hadis_cache(sender, instance, **kwargs): """ if sender in TARGET_MODELS: invalidate_hadis_cache() - print(f"Cache cleared for {sender.__name__} update!") \ No newline at end of file + + +@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() \ No newline at end of file diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 7b6bebf..8a2017b 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -23,12 +23,10 @@ from .views_admin import ( AdminContentReleaseViewSet ) 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.register(r'hadises', AdminHadisViewSet, basename='admin-hadises') diff --git a/apps/library/apps.py b/apps/library/apps.py index 166aebd..4a304d7 100644 --- a/apps/library/apps.py +++ b/apps/library/apps.py @@ -7,3 +7,7 @@ class LibraryConfig(AppConfig): name = 'apps.library' verbose_name = _('Library') icon = 'mi-library-books' + + def ready(self): + import apps.library.signals + diff --git a/apps/library/signals.py b/apps/library/signals.py new file mode 100644 index 0000000..f026602 --- /dev/null +++ b/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() diff --git a/apps/library/urls.py b/apps/library/urls.py index e9fb9c8..81155c9 100644 --- a/apps/library/urls.py +++ b/apps/library/urls.py @@ -16,24 +16,11 @@ from apps.library.views import ( 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 ( AdminAuthorViewSet, AdminBookViewSet, diff --git a/apps/library/views_admin.py b/apps/library/views_admin.py index caa1144..b354cd2 100644 --- a/apps/library/views_admin.py +++ b/apps/library/views_admin.py @@ -10,6 +10,7 @@ from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination from .models import Author, Book, BookCollection, Category +from .signals import invalidate_library_cache from .serializers_admin import ( AdminAuthorSerializer, AdminBookDetailSerializer, @@ -74,6 +75,7 @@ class AdminLibraryCollectionViewSet(ModelViewSet): if remove_ids: collection.related_collections.remove(*remove_ids) + invalidate_library_cache() return Response({"status": "success", "attached_count": collection.related_collections.count()}) @@ -113,6 +115,7 @@ class DovodiAdminLibraryCollectionViewSet(ModelViewSet): if remove_ids: collection.related_collections.remove(*remove_ids) + invalidate_library_cache() return Response({"status": "success", "attached_count": collection.related_collections.count()}) @@ -147,6 +150,7 @@ class AdminAuthorViewSet(ModelViewSet): if remove_ids: Book.objects.filter(id__in=remove_ids, author=author).update(author=None) + invalidate_library_cache() return Response({ "status": "success", "attached_count": author.books.count() diff --git a/apps/podcast/apps.py b/apps/podcast/apps.py index 076e705..046e735 100644 --- a/apps/podcast/apps.py +++ b/apps/podcast/apps.py @@ -4,5 +4,6 @@ from django.apps import AppConfig class PodcastConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.podcast' - - + + def ready(self): + import apps.podcast.signals diff --git a/apps/podcast/signals.py b/apps/podcast/signals.py new file mode 100644 index 0000000..131cba9 --- /dev/null +++ b/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() diff --git a/apps/podcast/urls.py b/apps/podcast/urls.py index 4706229..745a554 100755 --- a/apps/podcast/urls.py +++ b/apps/podcast/urls.py @@ -1,28 +1,12 @@ -from functools import wraps -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 django.urls import path, re_path, include from rest_framework.routers import SimpleRouter +from utils.cache import smart_cached_view from .views 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.register(r'podcasts', AdminPodcastViewSet, basename='admin-podcast') diff --git a/apps/podcast/views_admin.py b/apps/podcast/views_admin.py index c8adf93..edc95d8 100644 --- a/apps/podcast/views_admin.py +++ b/apps/podcast/views_admin.py @@ -12,6 +12,7 @@ from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination from .models import PlaylistItem, Podcast, PodcastCollection, PodcastCategory, PodcastPlaylist, PodcastPlaylistInCollection +from .signals import invalidate_podcast_cache from .serializers_admin import ( AdminPodcastCategorySerializer, AdminPodcastCollectionSerializer, @@ -59,6 +60,7 @@ class AdminPodcastCollectionViewSet(ModelViewSet): if remove_ids: 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()}) @@ -161,6 +163,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet): serializer.save() playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_podcast_cache() return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P\d+)") @@ -172,6 +175,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet): item.delete() playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_podcast_cache() return Response(status=status.HTTP_204_NO_CONTENT) serializer = AdminPodcastPlaylistItemSerializer( @@ -184,6 +188,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet): serializer.save() playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_podcast_cache() return Response(serializer.data) @action(detail=True, methods=["post"], url_path="items/reorder") @@ -205,6 +210,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet): item.priority = index item.save(update_fields=["priority"]) + invalidate_podcast_cache() serializer = AdminPodcastPlaylistItemSerializer( playlist.playlist_items.select_related("podcast").order_by("priority", "id"), many=True, @@ -240,6 +246,7 @@ class AdminPodcastPlaylistViewSet(ModelViewSet): playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_podcast_cache() serializer = AdminPodcastPlaylistItemSerializer( playlist.playlist_items.select_related("podcast").order_by("priority", "id"), many=True, diff --git a/apps/video/apps.py b/apps/video/apps.py index c091c61..6f1b630 100644 --- a/apps/video/apps.py +++ b/apps/video/apps.py @@ -4,3 +4,7 @@ from django.apps import AppConfig class VideoConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.video' + + def ready(self): + import apps.video.signals + diff --git a/apps/video/signals.py b/apps/video/signals.py new file mode 100644 index 0000000..02a578e --- /dev/null +++ b/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() diff --git a/apps/video/urls.py b/apps/video/urls.py index 163303e..5758906 100644 --- a/apps/video/urls.py +++ b/apps/video/urls.py @@ -1,27 +1,12 @@ -from functools import wraps 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 utils.cache import smart_cached_view from .views 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' diff --git a/apps/video/views_admin.py b/apps/video/views_admin.py index 061ee9a..c26d2d7 100644 --- a/apps/video/views_admin.py +++ b/apps/video/views_admin.py @@ -12,6 +12,7 @@ from apps.account.permissions import IsSuperAdmin from utils.pagination import StandardResultsSetPagination from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist, VideoPlaylistInCollection +from .signals import invalidate_video_cache from .serializers_admin import ( AdminVideoCategorySerializer, AdminVideoCollectionSerializer, @@ -59,6 +60,7 @@ class AdminVideoCollectionViewSet(ModelViewSet): if remove_ids: 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()}) @@ -158,6 +160,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet): serializer.save() playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_video_cache() return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=True, methods=["patch", "delete"], url_path=r"items/(?P\d+)") @@ -169,6 +172,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet): item.delete() playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_video_cache() return Response(status=status.HTTP_204_NO_CONTENT) serializer = AdminVideoPlaylistItemSerializer( @@ -181,6 +185,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet): serializer.save() playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_video_cache() return Response(serializer.data) @action(detail=True, methods=["post"], url_path="items/reorder") @@ -202,6 +207,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet): item.priority = index item.save(update_fields=["priority"]) + invalidate_video_cache() serializer = AdminVideoPlaylistItemSerializer( playlist.playlist_items.select_related("video").order_by("priority", "id"), many=True, @@ -237,6 +243,7 @@ class AdminVideoPlaylistViewSet(ModelViewSet): playlist.total_time = playlist.calculate_total_time() playlist.save(update_fields=["total_time"]) + invalidate_video_cache() serializer = AdminVideoPlaylistItemSerializer( playlist.playlist_items.select_related("video").order_by("priority", "id"), many=True, diff --git a/utils/cache.py b/utils/cache.py new file mode 100644 index 0000000..8017629 --- /dev/null +++ b/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