Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions exercise/async_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from notification.models import Notification
from lti_tool.utils import send_lti_points

from .cache.exercise import ExerciseCache
from .forms import SubmissionCallbackForm
from .models import SubmissionTagging

Expand Down Expand Up @@ -94,6 +95,15 @@ def _post_async_submission(request, exercise, submission, errors=None): # pylint
# the LTI Platform.
if submission.meta_data == "":
submission.meta_data = {}
exercise_version = form.cleaned_data["exercise_version"]
if exercise_version:
language = submission.lang or exercise.course_instance.default_language
cached_version = ExerciseCache.cached_exercise_version(exercise, language)
if cached_version and cached_version != exercise_version:
ExerciseCache.invalidate(exercise, modifiers=[language])
if not isinstance(submission.meta_data, dict):
submission.meta_data = {}
submission.meta_data["exercise_version"] = exercise_version
if (form.cleaned_data["lti_launch_id"]
and submission.meta_data.get("lti-launch-id") is None):
submission.meta_data["lti-launch-id"] = form.cleaned_data["lti_launch_id"]
Expand Down
40 changes: 38 additions & 2 deletions exercise/cache/exercise.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from django.conf import settings
from django.core.cache import cache
from django.http.request import HttpRequest

from lib.cache import CachedAbstract
Expand All @@ -29,6 +30,7 @@ def compress(data):
class ExerciseCache(CachedAbstract):
""" Exercise HTML content """
KEY_PREFIX = "exercisepage"
VERSION_MAX_AGE = 15 * 60

def __init__( # pylint: disable=too-many-arguments
self,
Expand All @@ -43,15 +45,25 @@ def __init__( # pylint: disable=too-many-arguments
self.load_args = [language, request, students, url_name, ordinal]
super().__init__(exercise, modifiers=[language])

def _needs_generation(self, data: Dict[str, Any]) -> bool:
def _needs_generation(self, data: Optional[Dict[str, Any]]) -> bool:
if data and 'exercise_version' not in data:
# Cache entries created before exercise version stamping was added must be refreshed
# so update detection can work.
return True
expires = data['expires'] if data else None
return not expires or time.time() > expires
# pylint: disable-next=arguments-differ
def _generate_data(self, exercise: 'BaseExercise', data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
try:
page = exercise.load_page(
*self.load_args,
last_modified=data['last_modified'] if data else None
# A versionless cache entry must receive a full response. Reusing
# its timestamp could produce a 304 that can not add the version.
last_modified=(
data['last_modified']
if data and 'exercise_version' in data
else None
)
)

content = compress(page.content.encode('utf-8'))
Expand All @@ -60,6 +72,7 @@ def _generate_data(self, exercise: 'BaseExercise', data: Optional[Dict[str, Any]
'head': page.head,
'content': content,
'last_modified': page.last_modified,
'exercise_version': page.exercise_version,
'expires': page.expires if page.is_loaded else 0,
}
except RemotePageNotModified as e:
Expand All @@ -74,6 +87,29 @@ def content(self) -> str:
content = decompress(self.data['content']).decode('utf-8')
return content

def exercise_version(self) -> str:
return self.data.get('exercise_version') or ''

@classmethod
def cached_exercise_version(
cls,
exercise: 'BaseExercise | int',
language: str,
max_age: Optional[int] = None,
) -> str:
"""Return a recently cached version without regenerating exercise HTML."""
raw = cache.get(cls._key(exercise, modifiers=[language]))
if not isinstance(raw, tuple) or len(raw) != 2:
return ''
updated, data = raw
if updated is None or not isinstance(data, dict):
return ''
if max_age is not None:
expires = data.get('expires') or 0
if time.time() - updated > max_age or (expires and time.time() > expires):
return ''
return data.get('exercise_version') or ''


def invalidate_instance(instance: 'CourseInstance') -> None:
for module in instance.course_modules.all():
Expand Down
1 change: 1 addition & 0 deletions exercise/exercise_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ def load(
cache = ExerciseCache(self, language, request, students, url_name, ordinal)
page.head = cache.head()
page.content = cache.content()
page.exercise_version = cache.exercise_version()
page.is_loaded = True
return page

Expand Down
1 change: 1 addition & 0 deletions exercise/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SubmissionCallbackForm(forms.Form):
notify = forms.CharField(required=False)
regrade_when_notification_seen = forms.BooleanField(required=False)
grading_payload = forms.CharField(required=False)
exercise_version = forms.CharField(required=False)
lti_launch_id = forms.CharField(required=False)
lti_session_id = forms.CharField(required=False)
error = forms.BooleanField(required=False)
Expand Down
10 changes: 10 additions & 0 deletions exercise/protocol/aplus.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from lib.email_messages import email_course_error
from lib.remote_page import RemotePage, RemotePageException
from ..cache.exercise import ExerciseCache
from .exercise_page import ExercisePage

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

if page.is_loaded:
submission.feedback = page.clean_content
if page.exercise_version:
language = submission.lang or exercise.course_instance.default_language
cached_version = ExerciseCache.cached_exercise_version(exercise, language)
if cached_version and cached_version != page.exercise_version:
ExerciseCache.invalidate(exercise, modifiers=[language])
if not isinstance(submission.meta_data, dict):
submission.meta_data = {}
submission.meta_data['exercise_version'] = page.exercise_version
if page.is_accepted:
submission.set_waiting()
if page.is_graded:
Expand Down Expand Up @@ -181,4 +190,5 @@ def parse_page_content(
id_attrs_to_remove = ('exercise', 'chapter', 'aplus')
page.content, page.clean_content = remote_page.element_or_body(element_selectors, id_attrs_to_remove)
page.last_modified = remote_page.last_modified()
page.exercise_version = remote_page.meta("aplus-exercise-version") or ""
page.expires = remote_page.expires()
1 change: 1 addition & 0 deletions exercise/protocol/exercise_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def __init__(self, exercise):
self.content = ""
self.clean_content = ""
self.last_modified = ""
self.exercise_version = "" # Content hash supplied by MOOC-Grader
self.expires = 0
self.meta = {
"title": exercise.name,
Expand Down
1 change: 1 addition & 0 deletions exercise/submission_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def create_from_post(self, exercise, submitters, request):
meta_data_dict = json.loads(request.POST.get('__aplus__', '{}'))
except json.JSONDecodeError as exc:
raise ValueError("The content of the field __aplus__ is not valid json") from exc
meta_data_dict.pop('exercise_version', None)
if 'lang' not in meta_data_dict:
meta_data_dict['lang'] = get_language()

Expand Down
Loading
Loading