Skip to content

Commit 8c19aa6

Browse files
reppuli92ihalaij1
authored andcommitted
Add questionnaire reset functionality
Fixes #494
1 parent 7b6fccd commit 8c19aa6

9 files changed

Lines changed: 351 additions & 11 deletions

File tree

exercise/async_views.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from notification.models import Notification
1010
from lti_tool.utils import send_lti_points
1111

12+
from .cache.exercise import ExerciseCache
1213
from .forms import SubmissionCallbackForm
1314
from .models import SubmissionTagging
1415

@@ -94,6 +95,15 @@ def _post_async_submission(request, exercise, submission, errors=None): # pylint
9495
# the LTI Platform.
9596
if submission.meta_data == "":
9697
submission.meta_data = {}
98+
exercise_version = form.cleaned_data["exercise_version"]
99+
if exercise_version:
100+
language = submission.lang or exercise.course_instance.default_language
101+
cached_version = ExerciseCache.cached_exercise_version(exercise, language)
102+
if cached_version and cached_version != exercise_version:
103+
ExerciseCache.invalidate(exercise, modifiers=[language])
104+
if not isinstance(submission.meta_data, dict):
105+
submission.meta_data = {}
106+
submission.meta_data["exercise_version"] = exercise_version
97107
if (form.cleaned_data["lti_launch_id"]
98108
and submission.meta_data.get("lti-launch-id") is None):
99109
submission.meta_data["lti-launch-id"] = form.cleaned_data["lti_launch_id"]

exercise/cache/exercise.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any, Dict, List, Optional, TYPE_CHECKING
44

55
from django.conf import settings
6+
from django.core.cache import cache
67
from django.http.request import HttpRequest
78

89
from lib.cache import CachedAbstract
@@ -29,6 +30,7 @@ def compress(data):
2930
class 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

78114
def invalidate_instance(instance: 'CourseInstance') -> None:
79115
for module in instance.course_modules.all():

exercise/exercise_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ def load(
405405
cache = ExerciseCache(self, language, request, students, url_name, ordinal)
406406
page.head = cache.head()
407407
page.content = cache.content()
408+
page.exercise_version = cache.exercise_version()
408409
page.is_loaded = True
409410
return page
410411

exercise/forms.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class SubmissionCallbackForm(forms.Form):
2121
notify = forms.CharField(required=False)
2222
regrade_when_notification_seen = forms.BooleanField(required=False)
2323
grading_payload = forms.CharField(required=False)
24+
exercise_version = forms.CharField(required=False)
2425
lti_launch_id = forms.CharField(required=False)
2526
lti_session_id = forms.CharField(required=False)
2627
error = forms.BooleanField(required=False)

exercise/protocol/aplus.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from lib.email_messages import email_course_error
99
from lib.remote_page import RemotePage, RemotePageException
10+
from ..cache.exercise import ExerciseCache
1011
from .exercise_page import ExercisePage
1112

1213
from lti_tool.utils import send_lti_points
@@ -64,6 +65,14 @@ def load_feedback_page(request, url, exercise, submission, no_penalties=False):
6465

6566
if page.is_loaded:
6667
submission.feedback = page.clean_content
68+
if page.exercise_version:
69+
language = submission.lang or exercise.course_instance.default_language
70+
cached_version = ExerciseCache.cached_exercise_version(exercise, language)
71+
if cached_version and cached_version != page.exercise_version:
72+
ExerciseCache.invalidate(exercise, modifiers=[language])
73+
if not isinstance(submission.meta_data, dict):
74+
submission.meta_data = {}
75+
submission.meta_data['exercise_version'] = page.exercise_version
6776
if page.is_accepted:
6877
submission.set_waiting()
6978
if page.is_graded:
@@ -181,4 +190,5 @@ def parse_page_content(
181190
id_attrs_to_remove = ('exercise', 'chapter', 'aplus')
182191
page.content, page.clean_content = remote_page.element_or_body(element_selectors, id_attrs_to_remove)
183192
page.last_modified = remote_page.last_modified()
193+
page.exercise_version = remote_page.meta("aplus-exercise-version") or ""
184194
page.expires = remote_page.expires()

exercise/protocol/exercise_page.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def __init__(self, exercise):
2323
self.content = ""
2424
self.clean_content = ""
2525
self.last_modified = ""
26+
self.exercise_version = "" # Content hash supplied by MOOC-Grader
2627
self.expires = 0
2728
self.meta = {
2829
"title": exercise.name,

exercise/submission_models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def create_from_post(self, exercise, submitters, request):
252252
meta_data_dict = json.loads(request.POST.get('__aplus__', '{}'))
253253
except json.JSONDecodeError as exc:
254254
raise ValueError("The content of the field __aplus__ is not valid json") from exc
255+
meta_data_dict.pop('exercise_version', None)
255256
if 'lang' not in meta_data_dict:
256257
meta_data_dict['lang'] = get_language()
257258

0 commit comments

Comments
 (0)