diff --git a/extras/docker/production/settings.py b/extras/docker/production/settings.py index b3bc64862..39b07c1cf 100644 --- a/extras/docker/production/settings.py +++ b/extras/docker/production/settings.py @@ -101,6 +101,8 @@ WGER_SETTINGS["SYNC_OFF_DAILY_DELTA_CELERY"] = env.bool("SYNC_OFF_DAILY_DELTA_CELERY", False) WGER_SETTINGS["USE_RECAPTCHA"] = env.bool("USE_RECAPTCHA", False) WGER_SETTINGS["USE_CELERY"] = env.bool("USE_CELERY", False) +WGER_SETTINGS["CACHE_API_EXERCISES_CELERY"] = env.bool("CACHE_API_EXERCISES_CELERY", False) +WGER_SETTINGS["CACHE_API_EXERCISES_CELERY_FORCE_UPDATE"] = env.bool("CACHE_API_EXERCISES_CELERY_FORCE_UPDATE", False) # # Auth Proxy Authentication diff --git a/wger/exercises/cache.py b/wger/exercises/cache.py new file mode 100644 index 000000000..6c8ab26f6 --- /dev/null +++ b/wger/exercises/cache.py @@ -0,0 +1,48 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License + +# Standard Library +from typing import Callable + +# wger +from wger.exercises.api.serializers import ExerciseInfoSerializer +from wger.exercises.models import Exercise +from wger.utils.cache import reset_exercise_api_cache + + +def cache_exercise( + exercise: Exercise, force=False, print_fn: Callable = print, style_fn: Callable = lambda x: x +): + """ + Caches a provided exercise. + """ + if force: + print_fn(f'Force updating cache for exercise {exercise.uuid}') + reset_exercise_api_cache(exercise.uuid) + else: + print_fn(f'Warming cache for exercise {exercise.uuid}') + + serializer = ExerciseInfoSerializer(exercise) + serializer.data + + +def cache_api_exercises( + print_fn: Callable, + force: bool, + style_fn: Callable = lambda x: x, +): + print_fn('*** Caching API exercises ***') + for exercise in Exercise.with_translations.all(): + cache_exercise(exercise, force, print_fn, style_fn) + print_fn(style_fn('Exercises cached!\n')) diff --git a/wger/exercises/management/commands/warmup-exercise-api-cache.py b/wger/exercises/management/commands/warmup-exercise-api-cache.py index 2483e5fa3..fd5a7d96b 100644 --- a/wger/exercises/management/commands/warmup-exercise-api-cache.py +++ b/wger/exercises/management/commands/warmup-exercise-api-cache.py @@ -16,9 +16,8 @@ from django.core.management.base import BaseCommand # wger -from wger.exercises.api.serializers import ExerciseInfoSerializer +from wger.exercises.cache import cache_exercise from wger.exercises.models import Exercise -from wger.utils.cache import reset_exercise_api_cache class Command(BaseCommand): @@ -48,20 +47,8 @@ def handle(self, **options): if exercise_id: exercise = Exercise.objects.get(pk=exercise_id) - self.handle_cache(exercise, force) + cache_exercise(exercise, force, self.stdout.write) return for exercise in Exercise.with_translations.all(): - self.handle_cache(exercise, force) - - def handle_cache(self, exercise: Exercise, force: bool): - if force: - self.stdout.write(f'Force updating cache for exercise base {exercise.uuid}') - else: - self.stdout.write(f'Warming cache for exercise base {exercise.uuid}') - - if force: - reset_exercise_api_cache(exercise.uuid) - - serializer = ExerciseInfoSerializer(exercise) - serializer.data + cache_exercise(exercise, force, self.stdout.write) diff --git a/wger/exercises/tasks.py b/wger/exercises/tasks.py index de5d25683..2659bc9f1 100644 --- a/wger/exercises/tasks.py +++ b/wger/exercises/tasks.py @@ -24,6 +24,7 @@ # wger from wger.celery_configuration import app +from wger.exercises.cache import cache_api_exercises from wger.exercises.sync import ( download_exercise_images, download_exercise_videos, @@ -70,6 +71,15 @@ def sync_videos_task(): download_exercise_videos(logger.info) +@app.task +def cache_api_exercises_task(): + """ + Fetches all exercises from database and caches them. + """ + force = settings.WGER_SETTINGS['CACHE_API_EXERCISES_CELERY_FORCE_UPDATE'] + cache_api_exercises(logger.info, force) + + @app.on_after_finalize.connect def setup_periodic_tasks(sender, **kwargs): if settings.WGER_SETTINGS['SYNC_EXERCISES_CELERY']: @@ -104,3 +114,14 @@ def setup_periodic_tasks(sender, **kwargs): sync_videos_task.s(), name='Sync exercise videos', ) + + if settings.WGER_SETTINGS['CACHE_API_EXERCISES_CELERY']: + sender.add_periodic_task( + crontab( + hour=str(random.randint(0, 23)), + minute=str(random.randint(0, 59)), + day_of_week=str(random.randint(0, 6)), + ), + cache_api_exercises_task.s(), + name='Cache API exercises', + ) diff --git a/wger/exercises/tests/test_cache.py b/wger/exercises/tests/test_cache.py new file mode 100644 index 000000000..7903a6ae9 --- /dev/null +++ b/wger/exercises/tests/test_cache.py @@ -0,0 +1,60 @@ +# Standard Library +from unittest.mock import ( + MagicMock, + patch, +) + +# wger +from wger.core.tests.base_testcase import WgerTestCase +from wger.exercises.cache import ( + cache_api_exercises, + cache_exercise, +) +from wger.exercises.models import Exercise + + +class TestCacheExercise(WgerTestCase): + @patch('wger.exercises.cache.reset_exercise_api_cache') + @patch('wger.exercises.cache.ExerciseInfoSerializer') + def test_cache_exercise_force_true(self, mock_serializer, mock_reset_cache): + exercise = Exercise.objects.first() + output = [] + + serializer_instance = MagicMock() + serializer_instance.data = {'mocked': True} + mock_serializer.return_value = serializer_instance + + cache_exercise(exercise, force=True, print_fn=output.append) + + mock_reset_cache.assert_called_once_with(exercise.uuid) + mock_serializer.assert_called_once_with(exercise) + self.assertTrue(any('Force updating cache' in msg for msg in output)) + + @patch('wger.exercises.cache.reset_exercise_api_cache') + @patch('wger.exercises.cache.ExerciseInfoSerializer') + def test_cache_exercise_force_false(self, mock_serializer, mock_reset_cache): + exercise = Exercise.objects.first() + output = [] + + serializer_instance = MagicMock() + serializer_instance.data = {'mocked': True} + mock_serializer.return_value = serializer_instance + + cache_exercise(exercise, force=False, print_fn=output.append) + + mock_reset_cache.assert_not_called() + mock_serializer.assert_called_once_with(exercise) + self.assertTrue(any('Warming cache' in msg for msg in output)) + + @patch('wger.exercises.cache.cache_exercise') + def test_cache_api_exercises_calls_all(self, mock_cache_exercise): + output = [] + called_exercises = [] + + def fake_cache(exercise, force, print_fn, style_fn): + called_exercises.append(exercise) + + mock_cache_exercise.side_effect = fake_cache + cache_api_exercises(print_fn=output.append, force=True) + + self.assertTrue(len(called_exercises) > 0) diff --git a/wger/settings_global.py b/wger/settings_global.py index 22b85557e..7b6478e21 100644 --- a/wger/settings_global.py +++ b/wger/settings_global.py @@ -556,6 +556,8 @@ 'SYNC_EXERCISE_VIDEOS_CELERY': False, 'SYNC_INGREDIENTS_CELERY': False, 'SYNC_OFF_DAILY_DELTA_CELERY': False, + 'CACHE_API_EXERCISES_CELERY': False, + 'CACHE_API_EXERCISES_CELERY_FORCE_UPDATE': False, 'TWITTER': False, 'MASTODON': 'https://fosstodon.org/@wger', 'USE_CELERY': False,