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:
+ exercise.submitAjax(url, formData, {dataType: "json"}, function(data) { + output.find(data.messages.selector).replaceWith(data.messages.html); + + // Look for error alerts in the feedback + const content = $(data.page.content); const alerts = content.find('.alert-danger:not(.hide)'); - if (!alerts.length) { - var poll_url = content.find(".exercise-wait").attr("data-poll-url"); - output.attr('data-poll-url', poll_url); - - exercise.updateSubmission(content); - } else if (alerts.contents().text() - .indexOf("The grading queue is not configured.") >= 0) { - output.find(exercise.settings.ae_result_selector) - .html(content.find(".alert:not(.hide)").text()); - output.find(exercise.settings.ae_result_selector).append(content.find(".grading-task").text()); + output.find(data.messages.selector).append(alerts); + + if (data.submission) { + output.attr('data-poll-url', data.submission.poll_url); + output.attr('data-ready-url', data.submission.ready_url); + + exercise.updateSubmission(); } else { - output.find(exercise.settings.ae_result_selector) - .html(alerts.contents()); + out_content.html("

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 field. See accepts_mimes(...)""" + expected_mime: str + + def __init__(self): + raise NotImplementedError("__init__() is not implemented. Use cast() instead.") + + @staticmethod + def cast(request: HttpRequest, acceptable_mimes: List[str]) -> MIMERequest: + """Cast the given request to a MIMERequest, and return it. + + Note that this changes the type of given original request to MIMERequest. + """ + # Some trickery to add expected_mime to the request and change the type + # _mime_request_class is required because the request class is different + # depending on the situation. E.g. WSGIRequest vs HttpRequest + request.__class__ = _mime_request_class(request) + request = cast(MIMERequest, request) + request.expected_mime = accepted_mime(acceptable_mimes, request.headers.get("Accept")) + return request + + +def accepts_mimes(acceptable: List[str]): + """Function/method decorator that changes the request object type to MIMERequest. + + :param acceptable: list of acceptable mime types + + The request object will have a attribute with the mime type + that the client expects the response to be in. See accepted_mime(...) for + how the mime type is chosen. + """ + # We need a class so that the decorator can be applied to both functions and methods + class SignatureChooser(object): + def __init__(self, func): + self.func = func + functools.wraps(func)(self) + def __call__(self, request, *args, **kwargs): + """Normal function call. This is called if self.func is a function""" + return self.call_with_mime(None, request, *args, **kwargs) + def __get__(self, instance: Optional[Any], _): + """Return class instance method. This is called if self.func is a method""" + return functools.partial(self.call_with_mime, instance) + def call_with_mime(self, obj: Optional[Any], request: HttpRequest, *args, **kwargs): + request = MIMERequest.cast(request, acceptable) + if obj is None: + return self.func(request, *args, **kwargs) + else: + return self.func(obj, request, *args, **kwargs) + + return SignatureChooser + + +def accepted_mime(acceptable: List[str], accept_header: Optional[str]): + """Return which mime type in matches first. + + Match priority order is the following: + 1. Exact match over a wild card match + 2. Earlier types in are prioritized + + Defaults to the first item in if no match was found. + + For example, + accepted_mime(["text/html", "application/json"], "text/*, application/json") + and + accepted_mime(["text/html", "application/json"], "application/*") + will return "application/json" but + accepted_mime(["text/html", "application/json"], "text/html, application/json") + and + accepted_mime(["text/html", "application/json"], "text/*, application/*") + will return "text/html". + """ + if accept_header is None or len(acceptable) == 1: + return acceptable[0] + + accepts = [mime.split(";")[0].strip() for mime in accept_header.split(",")] + + # Check for exact match first + for mime in acceptable: + if mime in accepts: + return mime + + # Check for wildcard match + for mime in acceptable: + mime.split("/") + if f"{mime[0]}/*" in accepts or f"*/{mime[1]}" in accepts: + return mime + + # Default to first element + return acceptable[0] diff --git a/templates/_messages.html b/templates/_messages.html index d50532173..2f02a0d03 100644 --- a/templates/_messages.html +++ b/templates/_messages.html @@ -1,8 +1,10 @@ {% if messages %} - {% for message in messages %} - - {% endfor %} +
+ {% for message in messages %} + + {% endfor %} +
{% endif %}