33from typing import Any , Dict , List , Optional , TYPE_CHECKING
44
55from django .conf import settings
6+ from django .core .cache import cache
67from django .http .request import HttpRequest
78
89from lib .cache import CachedAbstract
@@ -29,6 +30,7 @@ def compress(data):
2930class ExerciseCache (CachedAbstract ):
3031 """ Exercise HTML content """
3132 KEY_PREFIX = "exercisepage"
33+ VERSION_MAX_AGE = 15 * 60
3234
3335 def __init__ ( # pylint: disable=too-many-arguments
3436 self ,
@@ -44,14 +46,24 @@ def __init__( # pylint: disable=too-many-arguments
4446 super ().__init__ (exercise , modifiers = [language ])
4547
4648 def _needs_generation (self , data : Dict [str , Any ]) -> bool :
49+ if data and 'exercise_version' not in data :
50+ # Cache entries created before exercise version stamping was added must be refreshed
51+ # so update detection can work.
52+ return True
4753 expires = data ['expires' ] if data else None
4854 return not expires or time .time () > expires
4955 # pylint: disable-next=arguments-differ
5056 def _generate_data (self , exercise : 'BaseExercise' , data : Optional [Dict [str , Any ]] = None ) -> Dict [str , Any ]:
5157 try :
5258 page = exercise .load_page (
5359 * self .load_args ,
54- last_modified = data ['last_modified' ] if data else None
60+ # A versionless cache entry must receive a full response. Reusing
61+ # its timestamp could produce a 304 that can not add the version.
62+ last_modified = (
63+ data ['last_modified' ]
64+ if data and 'exercise_version' in data
65+ else None
66+ )
5567 )
5668
5769 content = compress (page .content .encode ('utf-8' ))
@@ -60,6 +72,7 @@ def _generate_data(self, exercise: 'BaseExercise', data: Optional[Dict[str, Any]
6072 'head' : page .head ,
6173 'content' : content ,
6274 'last_modified' : page .last_modified ,
75+ 'exercise_version' : page .exercise_version ,
6376 'expires' : page .expires if page .is_loaded else 0 ,
6477 }
6578 except RemotePageNotModified as e :
@@ -74,6 +87,29 @@ def content(self) -> str:
7487 content = decompress (self .data ['content' ]).decode ('utf-8' )
7588 return content
7689
90+ def exercise_version (self ) -> str :
91+ return self .data .get ('exercise_version' ) or ''
92+
93+ @classmethod
94+ def cached_exercise_version (
95+ cls ,
96+ exercise : 'BaseExercise' ,
97+ language : str ,
98+ max_age : Optional [int ] = None ,
99+ ) -> str :
100+ """Return a recently cached version without regenerating exercise HTML."""
101+ raw = cache .get (cls ._key (exercise , modifiers = [language ]))
102+ if not isinstance (raw , tuple ) or len (raw ) != 2 :
103+ return ''
104+ updated , data = raw
105+ if updated is None or not isinstance (data , dict ):
106+ return ''
107+ if max_age is not None :
108+ expires = data .get ('expires' ) or 0
109+ if time .time () - updated > max_age or (expires and time .time () > expires ):
110+ return ''
111+ return data .get ('exercise_version' ) or ''
112+
77113
78114def invalidate_instance (instance : 'CourseInstance' ) -> None :
79115 for module in instance .course_modules .all ():
0 commit comments