diff --git a/exercise/static/exercise/chapter.js b/exercise/static/exercise/chapter.js index 659102fc6..d6b64810a 100644 --- a/exercise/static/exercise/chapter.js +++ b/exercise/static/exercise/chapter.js @@ -453,14 +453,16 @@ }, // Submit the formData to given url and then execute the callback. - submitAjax: function(url, formData, callback) { + submitAjax: function(url, formData, ajaxParams, callback) { + ajaxParams = ajaxParams ?? {}; + ajaxParams.dataType = ajaxParams.dataType ?? "html"; var exercise = this; $.ajax(url, { type: "POST", data: formData, contentType: false, processData: false, - dataType: "html" + ...ajaxParams, }).fail(function(xhr, textStatus, errorThrown) { //$(form_element).find(":input").prop("disabled", false); //exercise.showLoader("error"); @@ -596,24 +598,24 @@ out_content.html("
Evaluating
"); var url = exercise.url; - exercise.submitAjax(url, formData, function(data) { - const content = $(data); - // Look for error alerts in the feedback, but skip the hidden element - // that is always included:Failed to submit:
") + for(const err of data.errors) { + out_content.append("" + err + "
") + } } }); }); @@ -636,7 +638,7 @@ } else { formData.set('__aplus__', JSON.stringify({hash: hash})); } - exercise.submitAjax(url, formData, function(data) { + exercise.submitAjax(url, formData, {}, function(data) { //$(form_element).find(":input").prop("disabled", false); //exercise.hideLoader(); var input = $(data); diff --git a/exercise/views.py b/exercise/views.py index ad95a6875..cb18ccce4 100644 --- a/exercise/views.py +++ b/exercise/views.py @@ -16,6 +16,8 @@ from course.models import CourseModule from course.viewbase import CourseInstanceBaseView, EnrollableViewMixin from lib.helpers import query_dict_to_list_of_tuples, safe_file_name +from lib.json import json_response_with_messages +from lib.mime_request import accepts_mimes, MIMERequest from lib.remote_page import RemotePageNotFound, request_for_response from lib.viewbase import BaseRedirectMixin, BaseView from userprofile.models import UserProfile @@ -141,7 +143,8 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: latest_enrollment_submission_data=all_enroll_data, **kwargs) - def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + @accepts_mimes(["text/html", "application/json"]) + def post(self, request: MIMERequest, *args: Any, **kwargs: Any) -> HttpResponse: # Stop submit trials for e.g. chapters. # However, allow posts from exercises switched to maintenance status. if not self.exercise.is_submittable: @@ -149,7 +152,7 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: new_submission = None page = ExercisePage(self.exercise) - _submission_status, submission_allowed, _issues, students = ( + _submission_status, submission_allowed, issues, students = ( self.submission_check(True, request) ) if submission_allowed: @@ -212,7 +215,17 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # Redirect non AJAX content page request back. if not request.is_ajax() and "__r" in request.GET: - return self.redirect(request.GET["__r"], backup=self.exercise); + return self.redirect(request.GET["__r"], backup=self.exercise) + + if request.expected_mime == "application/json": + return json_response_with_messages( + request, + { + "page": page, + "submission": new_submission, + "errors": issues, + } + ) self.get_summary_submissions() return self.render_to_response(self.get_context_data( diff --git a/lib/json.py b/lib/json.py new file mode 100644 index 000000000..fc4d8f48c --- /dev/null +++ b/lib/json.py @@ -0,0 +1,48 @@ +from typing import Any, Type +from json import JSONEncoder + +from django.core.serializers.json import DjangoJSONEncoder +from django.http import HttpRequest, JsonResponse +from django.shortcuts import render + +from exercise.models import ExercisePage, Submission + + +class AJAXJSONEncoder(DjangoJSONEncoder): + """Custom JSON encoder to implement encoding of our own types. + + The purpose is different from the API serializers: this is meant for + returning specialized data about the object that is used in the site + javascript, instead of just serializing the object. + """ + def default(self, obj: Any) -> Any: + if isinstance(obj, Submission): + return { + "id": obj.id, + "poll_url": obj.get_url("submission-poll"), + "ready_url": obj.get_url( + getattr(self, "submission_poll_ready_url_name", "submission") + ), + } + elif isinstance(obj, ExercisePage): + return { + "content": obj.content, + "errors": obj.errors, + } + + return super().default(obj) + + +def json_response_with_messages( + request: HttpRequest, + data: dict, + encoder: Type[JSONEncoder] = AJAXJSONEncoder, + *args, + **kwargs, + ) -> JsonResponse: + data["messages"] = { + "selector": ".site-messages", + "html": render(request, "_messages.html").content.decode(), + } + + return JsonResponse(data, encoder, *args, **kwargs) diff --git a/lib/mime_request.py b/lib/mime_request.py new file mode 100644 index 000000000..4af767267 --- /dev/null +++ b/lib/mime_request.py @@ -0,0 +1,107 @@ +from __future__ import annotations +import functools +from typing import Any, cast, List, Optional, Type + +from django.http import HttpRequest + +_mime_request_classes = {} +def _mime_request_class(request: HttpRequest) -> Type[MIMERequest]: + """Return a MIMERequest class with the request's class as a parent class""" + if request.__class__ not in _mime_request_classes: + class _MIMERequest(MIMERequest, request.__class__): + ... + + _mime_request_classes[request.__class__] = _MIMERequest + + return _mime_request_classes[request.__class__] + + +class MIMERequest(HttpRequest): + """Django HttpRequest but with