Skip to content

Commit f36704d

Browse files
committed
Small improvement to deletion log handling
When an exercise was replaced by another upstream, the sync would correctly update existing routines and logs, but would not update the cache, causing the structure data to point at stale exercise data. Closes wger-project/react#1253
1 parent 2ee51f1 commit f36704d

4 files changed

Lines changed: 76 additions & 9 deletions

File tree

wger/exercises/models/base.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,11 @@ def delete(
272272
from wger.exercises.models.image import ExerciseImage
273273
from wger.exercises.models.translation import Translation
274274
from wger.exercises.models.video import ExerciseVideo
275-
from wger.manager.models import WorkoutLog
275+
from wger.manager.helpers import reset_routine_cache
276+
from wger.manager.models import (
277+
Routine,
278+
WorkoutLog,
279+
)
276280
from wger.manager.models.slot_entry import SlotEntry
277281

278282
replacement = None
@@ -299,9 +303,27 @@ def delete(
299303
# Replace references in workout logs and routines before deleting,
300304
# so that user data is not lost on this instance
301305
if replacement:
306+
# Repointing the references with a bulk .update() bypasses the
307+
# signals that keep the routine caches fresh. Collect the
308+
# affected ones first, then reset their caches explicitly,
309+
# otherwise the cached routine structure keeps pointing at this
310+
# now-deleted exercise.
311+
routine_ids = set(
312+
SlotEntry.objects.filter(exercise=self).values_list(
313+
'slot__day__routine_id', flat=True
314+
)
315+
)
316+
routine_ids.update(
317+
WorkoutLog.objects.filter(exercise=self).values_list('routine_id', flat=True)
318+
)
319+
routine_ids.discard(None)
320+
302321
SlotEntry.objects.filter(exercise=self).update(exercise=replacement)
303322
WorkoutLog.objects.filter(exercise=self).update(exercise=replacement)
304323

324+
for routine in Routine.objects.filter(id__in=routine_ids):
325+
reset_routine_cache(routine)
326+
305327
if transfer_media:
306328
ExerciseImage.objects.filter(exercise=self).update(exercise=replacement)
307329
ExerciseVideo.objects.filter(exercise=self).update(exercise=replacement)

wger/exercises/sync.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,17 +320,17 @@ def print_fn(_):
320320
try:
321321
old_exercise = Exercise.objects.get(uuid=uuid)
322322

323-
# Replace exercise in routines and logs
323+
# Count the references before they are repointed, so we can
324+
# report how many were moved to the replacement
324325
if obj_replaced:
325-
nr_slot_entries = SlotEntry.objects.filter(exercise=old_exercise).update(
326-
exercise=obj_replaced
327-
)
326+
nr_slot_entries = SlotEntry.objects.filter(exercise=old_exercise).count()
327+
nr_logs = WorkoutLog.objects.filter(exercise=old_exercise).count()
328328

329-
nr_logs = WorkoutLog.objects.filter(exercise=old_exercise).update(
330-
exercise=obj_replaced
331-
)
329+
# Let the model repoint the references in routines and logs to
330+
# the replacement (and reset the affected routine caches) before
331+
# deleting, so that user data is not lost on this instance
332+
old_exercise.delete(replace_by=replaced_by_uuid if obj_replaced else None)
332333

333-
old_exercise.delete()
334334
replaced_by_info = f' (replaced by {obj_replaced.uuid})' if obj_replaced else ''
335335
print_fn(f'Deleted exercise {uuid}{replaced_by_info}')
336336
if nr_slot_entries:

wger/exercises/tests/test_deletion_log.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from unittest.mock import patch
1717
from uuid import UUID
1818

19+
# Django
20+
from django.core.cache import cache
21+
1922
# wger
2023
from wger.core.tests.base_testcase import WgerTestCase
2124
from wger.exercises.models import (
@@ -27,6 +30,7 @@
2730
from wger.exercises.models.video import ExerciseVideo
2831
from wger.manager.models import WorkoutLog
2932
from wger.manager.models.slot_entry import SlotEntry
33+
from wger.utils.cache import CacheKeyMapper
3034

3135

3236
class DeletionLogTestCase(WgerTestCase):
@@ -116,6 +120,28 @@ def test_exercise_replace_by_updates_slot_entries(self):
116120
# The slot entry should now point to the replacement
117121
self.assertEqual(SlotEntry.objects.filter(exercise=replacement).count(), 2)
118122

123+
def test_exercise_replace_by_resets_routine_cache(self):
124+
"""
125+
Test that replacing a deleted exercise invalidates the cache of every
126+
routine that referenced it.
127+
128+
The references are repointed with a bulk update that does not fire the
129+
signals which normally refresh the routine caches. If the cache is not
130+
reset explicitly, the cached routine structure keeps pointing at the
131+
now-deleted exercise.
132+
"""
133+
exercise_to_delete = Exercise.objects.get(pk=1)
134+
replacement = Exercise.objects.get(pk=2)
135+
136+
routine = SlotEntry.objects.get(exercise=exercise_to_delete).slot.day.routine
137+
cache_key = CacheKeyMapper.routine_api_structure_key(routine.id, routine.user_id)
138+
cache.set(cache_key, {'stale': 'data'})
139+
self.assertIsNotNone(cache.get(cache_key))
140+
141+
exercise_to_delete.delete(replace_by=str(replacement.uuid))
142+
143+
self.assertIsNone(cache.get(cache_key))
144+
119145
def test_exercise_replace_by_delete_is_atomic_on_failure(self):
120146
"""
121147
Test that a failure during the delete rolls back the deletion log and

wger/exercises/tests/test_sync.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from unittest.mock import patch
1818

1919
# Django
20+
from django.core.cache import cache
2021
from django.core.exceptions import ValidationError
2122

2223
# Third Party
@@ -52,6 +53,7 @@
5253
SlotEntry,
5354
WorkoutLog,
5455
)
56+
from wger.utils.cache import CacheKeyMapper
5557
from wger.utils.requests import wger_headers
5658

5759

@@ -822,6 +824,23 @@ def test_deletion_log(self, mock_request):
822824
for pk in logs:
823825
self.assertEqual(WorkoutLog.objects.get(pk=pk).exercise_id, 2)
824826

827+
@patch('requests.get', return_value=MockDeletionLogResponse())
828+
def test_deletion_log_resets_routine_cache(self, mock_request):
829+
"""
830+
Test that syncing a deletion that replaces an exercise invalidates the
831+
cache of every routine that referenced it. Otherwise the cached routine
832+
structure keeps pointing at the now-deleted exercise.
833+
"""
834+
exercise1 = Exercise.objects.get(pk=1)
835+
routine = SlotEntry.objects.filter(exercise=exercise1).first().slot.day.routine
836+
cache_key = CacheKeyMapper.routine_api_structure_key(routine.id, routine.user_id)
837+
cache.set(cache_key, {'stale': 'data'})
838+
self.assertIsNotNone(cache.get(cache_key))
839+
840+
handle_deleted_entries(print)
841+
842+
self.assertIsNone(cache.get(cache_key))
843+
825844
@patch('requests.get', return_value=MockExerciseResponse())
826845
def test_exercise_sync(self, mock_request):
827846
self.assertEqual(Exercise.objects.count(), 8)

0 commit comments

Comments
 (0)