Skip to content
Draft
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
40 changes: 21 additions & 19 deletions exercise/static/exercise/chapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -596,24 +598,24 @@
out_content.html("<p>Evaluating</p>");

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: <div class="quiz-submit-error alert alert-danger hide">
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("<p>Failed to submit:</p>")
for(const err of data.errors) {
out_content.append("<p>" + err + "</p>")
}
}
});
});
Expand All @@ -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);
Expand Down
19 changes: 16 additions & 3 deletions exercise/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,15 +143,16 @@ 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:
return self.http_method_not_allowed(request, *args, **kwargs)

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:
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions lib/json.py
Original file line number Diff line number Diff line change
@@ -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)
107 changes: 107 additions & 0 deletions lib/mime_request.py
Original file line number Diff line number Diff line change
@@ -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 <expected_mime> 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 <expected_mime> 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 <acceptable> matches <accept_header> first.

Match priority order is the following:
1. Exact match over a wild card match
2. Earlier types in <acceptable> are prioritized

Defaults to the first item in <acceptable> 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]
14 changes: 8 additions & 6 deletions templates/_messages.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{% if messages %}
{% for message in messages %}
<div class="alert alert-{% if message.level == 10 %}success{% elif message.level == 40 %}danger{% else %}{{ message.tags }}{% endif %} clearfix site-message" role="alert">
<span class="glyphicon glyphicon-{% if message.level == 25 %}check{% else %}warning-sign{% endif %}" aria-hidden="true"></span>
<div class="message">{{ message|safe }}</div>
</div>
{% endfor %}
<div class="site-messages">
{% for message in messages %}
<div class="alert alert-{% if message.level == 10 %}success{% elif message.level == 40 %}danger{% else %}{{ message.tags }}{% endif %} clearfix site-message" role="alert">
<span class="glyphicon glyphicon-{% if message.level == 25 %}check{% else %}warning-sign{% endif %}" aria-hidden="true"></span>
<div class="message">{{ message|safe }}</div>
</div>
{% endfor %}
</div>
{% endif %}