Skip to content
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2015 Andreas Kloeckner, Dong Zhuang"
......@@ -24,33 +23,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import re
from typing import cast
from django.utils.translation import (
ugettext_lazy as _, ugettext, string_concat)
from django.utils.safestring import mark_safe
from course.validation import validate_struct, validate_markup, ValidationError
from course.content import remove_prefix
import django.forms as forms
from crispy_forms.bootstrap import PrependedAppendedText
from crispy_forms.layout import HTML, Layout
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _
from relate.utils import Struct, StyledInlineForm
from course.content import remove_prefix
from course.page.base import (
AnswerFeedback, PageBaseWithValue, markup_to_html)
AnswerFeedback,
PageBaseWithoutHumanGrading,
PageBaseWithValue,
markup_to_html,
)
from course.page.text import TextQuestionBase, parse_matcher
from course.validation import ValidationError, validate_markup, validate_struct
from relate.utils import Struct, string_concat
import re
# {{{ for mypy
# {{{ multiple text question
from crispy_forms.layout import Layout, Field, HTML
# }}}
# {{{ multiple text question
class InlineMultiQuestionForm(StyledInlineForm):
class InlineMultiQuestionForm(forms.Form):
no_offset_labels = True
def __init__(self, read_only, dict_for_form, page_context, *args, **kwargs):
super(InlineMultiQuestionForm, self).__init__(*args, **kwargs)
from crispy_forms.helper import FormHelper
self.helper = FormHelper()
super().__init__(*args, **kwargs)
html_list = dict_for_form["html_list"]
self.answer_instance_list = answer_instance_list = \
dict_for_form["answer_instance_list"]
......@@ -89,17 +96,17 @@ class InlineMultiQuestionForm(StyledInlineForm):
correctness=correctness_list[idx])])
if read_only:
if isinstance(self.fields[field_name].widget,
forms.widgets.TextInput):
self.fields[field_name].widget.attrs['readonly'] \
= "readonly"
elif isinstance(self.fields[field_name].widget,
forms.widgets.Select):
self.fields[field_name].widget.attrs['disabled'] \
= "disabled"
self.helper.layout.extend([HTML("<br/><br/>")])
forms.widgets.Select):
# This will also disable the option dropdown
self.fields[field_name].widget.attrs["disabled"] \
= "disabled"
else:
# Then it should be a TextInput widget
self.fields[field_name].widget.attrs["readonly"] \
= "readonly"
def clean(self):
cleaned_data = super(InlineMultiQuestionForm, self).clean()
cleaned_data = super().clean()
answer_name_list = [answer_instance.name
for answer_instance in self.answer_instance_list]
......@@ -108,19 +115,18 @@ class InlineMultiQuestionForm(StyledInlineForm):
instance_idx = self.answer_instance_list[idx]
field_name_idx = instance_idx.name
if hasattr(instance_idx, "matchers"):
for i, validator in enumerate(instance_idx.matchers):
if answer in cleaned_data:
try:
validator.validate(cleaned_data[answer])
except forms.ValidationError:
if i + 1 == len(instance_idx.matchers):
# last one, and we flunked -> not valid
import sys
tp, e, _ = sys.exc_info()
self.add_error(field_name_idx, e)
else:
# Found one that will take the input. Good enough.
break
for i, validator in enumerate(instance_idx.matchers): # pragma: no branch # noqa
try:
validator.validate(cleaned_data[answer])
except forms.ValidationError:
if i + 1 == len(instance_idx.matchers):
# last one, and we flunked -> not valid
import sys
_tp, e, _ = sys.exc_info()
self.add_error(field_name_idx, e)
else:
# Found one that will take the input. Good enough.
break
def get_question_class(location, q_type, answers_desc):
......@@ -133,8 +139,8 @@ def get_question_class(location, q_type, answers_desc):
"%(location)s: ",
_("unknown embedded question type '%(type)s'"))
% {
'location': location,
'type': q_type})
"location": location,
"type": q_type})
def parse_question(vctx, location, name, answers_desc):
......@@ -145,11 +151,11 @@ def parse_question(vctx, location, name, answers_desc):
raise ValidationError(
string_concat(
"%s: ",
_("must be struct"))
_("Embedded question '{}' must be a struct".format(name)))
% location)
class AnswerBase(object):
class AnswerBase:
"""Abstract interface for answer class of different type.
.. attribute:: type
.. attribute:: form_field_class
......@@ -161,6 +167,9 @@ class AnswerBase(object):
self.required = getattr(answers_desc, "required", False)
def get_answer_text(self, page_context, answer):
raise NotImplementedError()
def get_correct_answer_text(self, page_context):
raise NotImplementedError()
......@@ -168,29 +177,25 @@ class AnswerBase(object):
raise NotImplementedError()
def get_weight(self, answer):
if answer is not None:
return self.weight * self.get_correctness(answer)
else:
if answer is None or answer == "":
return 0
return self.weight * self.get_correctness(answer)
def get_field_layout(self, correctness=None):
kwargs = {
"template": "course/custom_crispy_inline_prepended_appended_text.html",
"prepended_text": getattr(self.answers_desc, "prepended_text", ""),
"appended_text": getattr(self.answers_desc, "appended_text", ""),
"use_popover": "true",
"popover_title": getattr(self.answers_desc, "hint_title", ""),
"popover_content": getattr(self.answers_desc, "hint", "")}
if correctness is None:
return Field(
self.name,
use_popover="true",
popover_title=getattr(self.answers_desc, "hint_title", ""),
popover_content=getattr(self.answers_desc, "hint", ""),
style=self.get_width_str()
)
kwargs["style"] = self.get_width_str()
else:
return Field(
self.name,
use_popover="true",
popover_title=getattr(self.answers_desc, "hint_title", ""),
popover_content=getattr(self.answers_desc, "hint", ""),
style=self.get_width_str(self.width + 2),
correctness=correctness
)
kwargs["style"] = self.get_width_str(self.width + 2)
kwargs["correctness"] = correctness
return PrependedAppendedText(self.name, **kwargs)
def get_form_field(self, page_context):
raise NotImplementedError()
......@@ -198,7 +203,7 @@ class AnswerBase(object):
# length unit used is "em"
DEFAULT_WIDTH = 10
MINIMUN_WIDTH = 4
MINIMUM_WIDTH = 4
EM_LEN_DICT = {
"em": 1,
......@@ -209,7 +214,7 @@ EM_LEN_DICT = {
"%": ""}
ALLOWED_LENGTH_UNIT = EM_LEN_DICT.keys()
WIDTH_STR_RE = re.compile("^(\d*\.\d+|\d+)\s*(.*)$")
WIDTH_STR_RE = re.compile(r"^(\d*\.\d+|\d+)\s*(.*)$")
class ShortAnswer(AnswerBase):
......@@ -217,17 +222,17 @@ class ShortAnswer(AnswerBase):
form_field_class = forms.CharField
@staticmethod
def get_length_attr_em(location, width_attr):
def get_length_attr_em(location: str, width_attr: str) -> float | None:
"""
generate the length for input box, the unit is 'em'
"""
if isinstance(width_attr, (int, float)):
return width_attr
if width_attr is None:
return None
if isinstance(width_attr, int | float):
return width_attr
width_re_match = WIDTH_STR_RE.match(width_attr)
if width_re_match:
length_value = width_re_match.group(1)
......@@ -236,7 +241,7 @@ class ShortAnswer(AnswerBase):
raise ValidationError(
string_concat(
"%(location)s: ",
_("unrecogonized width attribute string: "
_("unrecognized width attribute string: "
"'%(width_attr)s'"))
% {
"location": location,
......@@ -261,10 +266,10 @@ class ShortAnswer(AnswerBase):
if length_unit == "%":
return float(length_value)*DEFAULT_WIDTH/100.0
else:
return float(length_value)/EM_LEN_DICT[length_unit]
return float(length_value)/cast(float, EM_LEN_DICT[length_unit])
def __init__(self, vctx, location, name, answers_desc):
super(ShortAnswer, self).__init__(
super().__init__(
vctx, location, name, answers_desc)
validate_struct(
......@@ -277,6 +282,8 @@ class ShortAnswer(AnswerBase):
),
allowed_attrs=(
("weight", (int, float)),
("prepended_text", str),
("appended_text", str),
("hint", str),
("hint_title", str),
("width", (str, int, float)),
......@@ -284,23 +291,32 @@ class ShortAnswer(AnswerBase):
),
)
self.weight = getattr(answers_desc, "weight", 0)
weight = getattr(answers_desc, "weight", 0)
if weight < 0:
raise ValidationError(
string_concat(
"%s: %s: ",
_("'weight' must be a non-negative value, "
"got '%s' instead") % str(weight))
% (location, self.name))
self.weight = weight
if len(answers_desc.correct_answer) == 0:
raise ValidationError(
string_concat(
"%s: ",
"%s: %s: ",
_("at least one answer must be provided"))
% location)
% (location, self.name))
self.hint = getattr(self.answers_desc, "hint", "")
self.width = getattr(self.answers_desc, "width", None)
width = getattr(self.answers_desc, "width", None)
parsed_length = self.get_length_attr_em(location, self.width)
parsed_length = self.get_length_attr_em(
f"{location}: {self.name}: 'width'", width)
self.width = 0
if parsed_length is not None:
self.width = max(MINIMUN_WIDTH, parsed_length)
self.width = max(MINIMUM_WIDTH, parsed_length)
else:
self.width = DEFAULT_WIDTH
......@@ -312,7 +328,7 @@ class ShortAnswer(AnswerBase):
string_concat("%s, ",
# Translators: refers to optional
# correct answer for checking
# correctness sumbitted by students.
# correctness submitted by students.
_("answer"),
" %d") % (location, i+1),
answer)
......@@ -322,22 +338,32 @@ class ShortAnswer(AnswerBase):
for matcher in self.matchers):
raise ValidationError(
string_concat(
"%s: ",
"%s: %s: ",
_("no matcher is able to provide a plain-text "
"correct answer"))
% location)
% (location, self.name))
def get_width_str(self, opt_width=0):
return "width: " + str(max(self.width, opt_width)) + "em"
def get_answer_text(self, page_context, answer):
from django.utils.html import escape
return escape(answer)
def get_correct_answer_text(self, page_context):
for matcher in self.matchers:
unspec_correct_answer_text = None
for matcher in self.matchers: # pragma: no branch
unspec_correct_answer_text = matcher.correct_answer_text()
if unspec_correct_answer_text is not None:
break
assert unspec_correct_answer_text
return unspec_correct_answer_text
assert unspec_correct_answer_text is not None
return ("{}{}{}".format(
getattr(self.answers_desc, "prepended_text", "").strip(),
unspec_correct_answer_text,
getattr(self.answers_desc, "appended_text", "").strip())
)
def get_correctness(self, answer):
......@@ -351,7 +377,7 @@ class ShortAnswer(AnswerBase):
except forms.ValidationError:
continue
correctnesses.append(matcher.grade(answer))
correctnesses.append(matcher.grade(answer).correctness)
return max(correctnesses)
......@@ -360,7 +386,7 @@ class ShortAnswer(AnswerBase):
required=self.required or force_required,
widget=None,
help_text=None,
label=self.name
label=""
)
......@@ -386,7 +412,7 @@ class ChoicesAnswer(AnswerBase):
return s
def __init__(self, vctx, location, name, answers_desc):
super(ChoicesAnswer, self).__init__(
super().__init__(
vctx, location, name, answers_desc)
validate_struct(
......@@ -411,15 +437,15 @@ class ChoicesAnswer(AnswerBase):
for choice_idx, choice in enumerate(answers_desc.choices):
try:
choice = str(choice)
except:
except Exception:
raise ValidationError(
string_concat(
"%(location)s: '%(answer_name)s' ",
_("choice %(idx)d: unable to convert to string")
)
% {'location': location,
'answer_name': self.name,
'idx': choice_idx+1})
% {"location": location,
"answer_name": self.name,
"idx": choice_idx+1})
if choice.startswith(self.CORRECT_TAG):
correct_choice_count += 1
......@@ -436,13 +462,19 @@ class ChoicesAnswer(AnswerBase):
" for question '%(question_name)s', "
"%(n_correct)d found"))
% {
'location': location,
'question_name': self.name,
'n_correct': correct_choice_count})
"location": location,
"question_name": self.name,
"n_correct": correct_choice_count})
self.hint = getattr(self.answers_desc, "hint", "")
self.width = 0
def get_answer_text(self, page_context, answer):
if answer == "":
return answer
return self.process_choice_string(
page_context, self.answers_desc.choices[int(answer)])
def get_width_str(self, opt_width=0):
return None
......@@ -455,23 +487,24 @@ class ChoicesAnswer(AnswerBase):
def get_correct_answer_text(self, page_context):
corr_idx = self.correct_indices()[0]
return self.process_choice_string(
page_context, self.answers_desc.choices[corr_idx]).lstrip()
return ("{}{}{}".format(
getattr(self.answers_desc, "prepended_text", "").strip(),
self.process_choice_string(
page_context, self.answers_desc.choices[corr_idx]).lstrip(),
getattr(self.answers_desc, "appended_text", "").strip())
)
def get_max_correct_answer_len(self, page_context):
return max([len(answer) for answer in
return max(len(answer) for answer in
[self.process_choice_string(page_context, processed)
for processed in self.answers_desc.choices]])
for processed in self.answers_desc.choices])
def get_correctness(self, answer):
if answer == "":
correctness = 0
elif int(answer) >= 0:
if int(answer) in self.correct_indices():
correctness = 1
else:
correctness = 0
return correctness
return 0
if int(answer) in self.correct_indices():
return 1
return 0
def get_form_field(self, page_context, force_required=False):
choices = tuple(
......@@ -479,8 +512,8 @@ class ChoicesAnswer(AnswerBase):
page_context, self.answers_desc.choices[i]))
for i, src_i in enumerate(self.answers_desc.choices))
choices = (
(None, "-"*self.get_max_correct_answer_len(page_context)),
) + choices
(None, "-" * self.get_max_correct_answer_len(page_context)),
*choices)
return (self.form_field_class)(
required=self.required or force_required,
choices=tuple(choices),
......@@ -498,10 +531,14 @@ ALLOWED_EMBEDDED_QUESTION_CLASSES = [
WRAPPED_NAME_RE = re.compile(r"[^{](?=(\[\[[^\[\]]*\]\]))[^}]")
NAME_RE = re.compile(r"[^{](?=\[\[([^\[\]]*)\]\])[^}]")
NAME_VALIDATE_RE = re.compile("^[a-zA-Z]+[a-zA-Z0-9_]{0,}$")
NAME_VALIDATE_RE = re.compile(r"^[a-zA-Z]+[a-zA-Z0-9_]{0,}$")
class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
class InlineMultiQuestion(
TextQuestionBase,
PageBaseWithValue,
PageBaseWithoutHumanGrading
):
r"""
An auto-graded page with cloze like questions.
......@@ -513,6 +550,10 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
``InlineMultiQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -536,24 +577,29 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
Text justifying the answer, written in :ref:`markup`.
Here is an example of :class:`InlineMultiQuestion`::
Example:
.. code-block:: yaml
type: InlineMultiQuestion
id: inlinemulti
value: 10
prompt: |
# An example
# An InlineMultiQuestion example
Complete the following paragraph.
question: |
Foo and [[blank1]] are often used in code examples, or
tutorials. The float value of $\frac{1}{5}$ is [[blank_2]].
tutorials. $\frac{1}{5}$ is equivalent to [[blank_2]].
The correct answer for this choice question is [[choice_a]].
The Upper case of "foo" is [[choice2]]
The Upper case of "foo" is [[choice2]].
One dollar is [[blank3]], and five percent is [[blank4]], and "Bar"
wrapped by a pair of parentheses is [[blank5]].
answers:
......@@ -592,19 +638,53 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
- BAR
- fOO
blank3:
type: ShortAnswer
width: 3em
prepended_text: "$"
hint: Blank with prepended text
correct_answer:
- type: float
value: 1
rtol: 0.00001
- <plain> "1"
blank4:
type: ShortAnswer
width: 3em
appended_text: "%"
hint: Blank with appended text
correct_answer:
- type: float
value: 5
rtol: 0.00001
- <plain> "5"
blank5:
type: ShortAnswer
width: 6em
prepended_text: "("
appended_text: ")"
required: True
hint: Blank with both prepended and appended text
correct_answer:
- <plain> BAR
- <plain>bar
"""
def __init__(self, vctx, location, page_desc):
super(InlineMultiQuestion, self).__init__(
super().__init__(
vctx, location, page_desc)
self.embedded_wrapped_name_list = WRAPPED_NAME_RE.findall(
page_desc.question)
self.embedded_name_list = NAME_RE.findall(page_desc.question)
expanded_question = page_desc.question
self.embedded_wrapped_name_list = WRAPPED_NAME_RE.findall(expanded_question)
self.embedded_name_list = NAME_RE.findall(expanded_question)
answer_instance_list = []
for idx, name in enumerate(self.embedded_name_list):
for name in self.embedded_name_list:
answers_desc = getattr(self.page_desc.answers, name)
parsed_answer = parse_question(
......@@ -617,7 +697,6 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
answers_name_list = struct_to_dict(page_desc.answers).keys()
invalid_answer_name = []
invalid_embedded_name = []
if not answer_instance_list:
raise ValidationError(
......@@ -625,12 +704,12 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
"%(location)s: ",
_("InlineMultiQuestion requires at least one "
"answer field to be defined."))
% {'location': location})
% {"location": location})
for answers_name in answers_name_list:
if NAME_VALIDATE_RE.match(answers_name) is None:
invalid_answer_name.append(answers_name)
if len(invalid_answer_name) > 0:
if invalid_answer_name:
raise ValidationError(
string_concat(
"%s: ",
......@@ -645,47 +724,19 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
for name in invalid_answer_name])
))
for embedded_name in self.embedded_name_list:
if NAME_VALIDATE_RE.match(embedded_name) is None:
invalid_embedded_name.append(embedded_name)
if len(invalid_embedded_name) > 0:
raise ValidationError(
string_concat(
"%s: ",
_("invalid embedded question name %s. "),
_("A valid name should start with letters. "
"Alphanumeric with underscores. "
"Do not use spaces."))
% (
location,
", ".join([
"'" + name + "'"
for name in invalid_embedded_name])
))
if len(set(self.embedded_name_list)) < len(self.embedded_name_list):
duplicated = list(
set([x for x in self.embedded_name_list
if self.embedded_name_list.count(x) > 1]))
{x for x in self.embedded_name_list
if self.embedded_name_list.count(x) > 1})
raise ValidationError(
string_concat(
"%s: ",
_("embedded question name %s not unique."))
% (location, ", ".join(duplicated)))
% (location, ", ".join([f"'{d}'" for d in sorted(duplicated)])))
no_answer_set = set(self.embedded_name_list) - set(answers_name_list)
redundant_answer_list = list(set(answers_name_list)
- set(self.embedded_name_list))
if no_answer_set:
raise ValidationError(
string_concat(
"%s: ",
_("correct answer(s) not provided for question %s."))
% (location, ", ".join(
["'" + item + "'"
for item in list(no_answer_set)])))
if redundant_answer_list:
if vctx is not None:
vctx.add_warning(location,
......@@ -698,14 +749,13 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
if vctx is not None:
validate_markup(vctx, location, page_desc.question)
remainder_html = markup_to_html(vctx, page_desc.question)
remainder_html = page_desc.question
html_list = []
for wrapped_name in self.embedded_wrapped_name_list:
[html, remainder_html] = remainder_html.split(wrapped_name)
html_list.append(html)
if remainder_html != "":
if remainder_html.strip():
html_list.append(remainder_html)
# make sure all [[ and ]] are paired.
......@@ -716,35 +766,53 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
raise ValidationError(
string_concat(
"%s: ",
_("have unpaired '%s'."))
_("question has unpaired '%s'."))
% (location, sep))
for idx, name in enumerate(self.embedded_name_list):
for name in self.embedded_name_list:
answers_desc = getattr(page_desc.answers, name)
parse_question(vctx, location, name, answers_desc)
def required_attrs(self):
return super(InlineMultiQuestion, self).required_attrs() + (
("question", "markup"), ("answers", Struct),
)
return (*super().required_attrs(), ("question", "markup"), ("answers", Struct))
def allowed_attrs(self):
return super(InlineMultiQuestion, self).allowed_attrs() + (
("answer_explanation", "markup"),
)
return (*super().allowed_attrs(), ("answer_explanation", "markup"))
def body(self, page_context, page_data):
return markup_to_html(page_context, self.page_desc.prompt)
def get_question(self, page_context, page_data):
# for correct render of question with more than one
# paragraph, remove heading <p> tags and change </p>
# to line break.
return markup_to_html(
page_context,
self.page_desc.question,
).replace("<p>", "").replace("</p>", "<br/>")
# paragraph, replace <p> tags to new input-group.
div_start_css_class_list = [
"input-group",
# ensure spacing between input and text, mathjax and text
"gap-1",
"align-items-center"
]
replace_p_start = f"<div class=\"{' '.join(div_start_css_class_list)}\">"
question_html = markup_to_html(
page_context,
self.page_desc.question
).replace(
"<p>",
replace_p_start
).replace("</p>", "</div>")
# add mb-4 class to the last paragraph so as to add spacing before
# submit buttons.
last_div_start = (
f"<div class=\"{' '.join([*div_start_css_class_list, 'mb-4'])}\">")
# https://stackoverflow.com/a/59082116/3437454
question_html = last_div_start.join(question_html.rsplit(replace_p_start, 1))
return question_html
def get_dict_for_form(self, page_context, page_data):
remainder_html = self.get_question(page_context, page_data)
......@@ -754,8 +822,10 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
[html, remainder_html] = remainder_html.split(wrapped_name)
html_list.append(html)
if remainder_html != "":
html_list.append(remainder_html)
# remainder_html should at least include "</p>"
assert remainder_html, (
f"remainder_html is unexpected not empty: {remainder_html}")
html_list.append(remainder_html)
return {
"html_list": html_list,
......@@ -773,10 +843,15 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
correctness_list = []
for answer_instance in self.answer_instance_list:
if answer[answer_instance.name] is not None:
try:
correctness_list.append(answer_instance.get_correctness(
answer[answer_instance.name]))
# The answer doesn't exist for newly added question
# for pages which have been submitted.
except KeyError:
correctness_list.append(1)
dict_feedback_form["correctness_list"] = correctness_list
form = InlineMultiQuestionForm(
......@@ -815,7 +890,7 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
wrapped,
"<strong>" + correct_answer_i + "</strong>")
CA_PATTERN = string_concat(_("A correct answer is"), ": <br/> %s") # noqa
CA_PATTERN = string_concat(_("A correct answer is"), ": %s") # noqa
result = CA_PATTERN % cor_answer_output
......@@ -830,68 +905,85 @@ class InlineMultiQuestion(TextQuestionBase, PageBaseWithValue):
def form_to_html(self, request, page_context, form, answer_data):
"""Returns an HTML rendering of *form*."""
from django.template import loader, RequestContext
from django import VERSION as DJANGO_VERSION
from django.template import loader
context = {"form": form}
if DJANGO_VERSION >= (1, 9):
return loader.render_to_string(
"course/custom-crispy-inline-form.html",
context={"form": form},
request=request)
else:
context = RequestContext(request)
context.update({"form": form})
return loader.render_to_string(
"course/custom-crispy-inline-form.html",
context_instance=context)
# This happens when rendering the form in analytics view.
if not request:
context.update({"csrf_token": "None"})
return loader.render_to_string(
"course/custom-crispy-inline-form.html",
context=context,
request=request)
def grade(self, page_context, page_data, answer_data, grade_data):
if answer_data is None:
return AnswerFeedback(correctness=0,
feedback=ugettext("No answer provided."))
feedback=gettext("No answer provided."))
answer_dict = answer_data["answer"]
total_weight = 0
for idx, name in enumerate(self.embedded_name_list):
for idx in range(len(self.embedded_name_list)):
total_weight += self.answer_instance_list[idx].weight
if total_weight > 0:
achieved_weight = 0
for answer_instance in self.answer_instance_list:
if answer_dict[answer_instance.name] is not None:
achieved_weight += answer_instance.get_weight(
answer_dict[answer_instance.name])
achieved_weight += answer_instance.get_weight(
answer_dict[answer_instance.name])
correctness = achieved_weight / total_weight
# for case when all questions have no weight assigned
else:
n_corr = 0
for answer_instance in self.answer_instance_list:
if answer_dict[answer_instance.name] is not None:
n_corr += answer_instance.get_correctness(
answer_dict[answer_instance.name])
n_corr += answer_instance.get_correctness(
answer_dict[answer_instance.name])
correctness = n_corr / len(self.answer_instance_list)
return AnswerFeedback(correctness=correctness)
def normalized_answer(self, page_context, page_data, answer_data):
def analytic_view_body(self, page_context, page_data):
form = InlineMultiQuestionForm(
False,
self.get_dict_for_form(page_context, page_data),
page_context)
return (self.body(page_context, page_data)
+ self.form_to_html(None, page_context, form, None))
def normalized_bytes_answer(self, page_context, page_data, answer_data):
if answer_data is None:
return None
answer_dict = answer_data["answer"]
nml_answer_output = self.get_question(page_context, page_data)
result = {}
for idx, name in enumerate(self.embedded_name_list):
single_answer_str = (
self.answer_instance_list[idx].get_answer_text(
page_context, answer_dict[self.embedded_name_list[idx]]))
# unanswered question result in "" in answer_dict
if single_answer_str != "":
result[name] = single_answer_str
for idx, wrapped_name in enumerate(self.embedded_wrapped_name_list):
nml_answer_output = nml_answer_output.replace(
wrapped_name,
"<strong>"
+ answer_dict[self.embedded_name_list[idx]]
+ "</strong>")
import json
return ".json", json.dumps(result)
return nml_answer_output
def normalized_answer(self, page_context, page_data, answer_data):
if answer_data is None:
return None
answer_dict = answer_data["answer"]
return ", ".join(
[self.answer_instance_list[idx].get_answer_text(
page_context, answer_dict[self.embedded_name_list[idx]])
for idx, name in enumerate(self.embedded_name_list)]
)
# }}}
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,12 +23,23 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from typing import Any
from course.page.base import (
PageBaseWithCorrectAnswer, PageBaseWithTitle, markup_to_html)
from django import forms
class Page(PageBaseWithCorrectAnswer, PageBaseWithTitle):
from course.page.base import (
PageBaseUngraded,
PageBaseWithCorrectAnswer,
PageBaseWithTitle,
PageBehavior,
PageContext,
markup_to_html,
)
from course.validation import AttrSpec
from relate.utils import StyledForm
class Page(PageBaseWithCorrectAnswer, PageBaseWithTitle, PageBaseUngraded):
"""
A page showing static content.
......@@ -60,16 +70,45 @@ class Page(PageBaseWithCorrectAnswer, PageBaseWithTitle):
(see :ref:`flow-permissions`). Written in :ref:`markup`.
"""
def required_attrs(self):
return super(Page, self).required_attrs() + (
("content", "markup"),
)
def required_attrs(self) -> AttrSpec:
return (*super().required_attrs(), ("content", "markup"))
def markup_body_for_title(self):
def markup_body_for_title(self) -> str:
return self.page_desc.content
def body(self, page_context, page_data):
def body(self, page_context, page_data) -> str:
return markup_to_html(page_context, self.page_desc.content)
def expects_answer(self):
def expects_answer(self) -> bool:
return False
def max_points(self, page_data: Any) -> float:
raise NotImplementedError()
def answer_data(
self,
page_context: PageContext,
page_data: Any,
form: forms.Form,
files_data: Any,
) -> Any:
raise NotImplementedError()
def make_form(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
page_behavior: Any,
) -> StyledForm:
raise NotImplementedError()
def process_form_post(
self,
page_context: PageContext,
page_data: Any,
post_data: Any,
files_data: Any,
page_behavior: PageBehavior,
) -> StyledForm:
raise NotImplementedError()
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,21 +24,31 @@ THE SOFTWARE.
"""
import six
from django.utils.translation import (
ugettext_lazy as _, ugettext, string_concat)
from course.validation import validate_struct, ValidationError
import re
import sys
from typing import Any
import django.forms as forms
from django.utils.translation import gettext, gettext_lazy as _
from relate.utils import StyledForm, Struct
from course.page.base import (
AnswerFeedback, PageBaseWithTitle, PageBaseWithValue, markup_to_html,
PageBaseWithHumanTextFeedback, PageBaseWithCorrectAnswer,
AnswerFeedback,
PageBaseUngraded,
PageBaseWithCorrectAnswer,
PageBaseWithHumanTextFeedback,
PageBaseWithoutHumanGrading,
PageBaseWithTitle,
PageBaseWithValue,
PageBehavior,
PageContext,
get_editor_interaction_mode,
markup_to_html,
)
from course.validation import AttrSpec, ValidationError, validate_struct
from relate.utils import Struct, StyledForm, string_concat
get_editor_interaction_mode)
import re
import sys
CORRECT_ANSWER_PATTERN = string_concat(_("A correct answer is"), ": '%s'.")
class TextAnswerForm(StyledForm):
......@@ -48,60 +57,57 @@ class TextAnswerForm(StyledForm):
@staticmethod
def get_text_widget(widget_type, read_only=False, check_only=False,
interaction_mode=None):
interaction_mode=None, initial_text=None):
"""Returns None if no widget found."""
help_text = None
if widget_type in [None, "text_input"]:
if check_only:
return True
widget = forms.TextInput()
widget.attrs["autofocus"] = None
if read_only:
widget.attrs["readonly"] = None
return widget, None
elif widget_type == "textarea":
if check_only:
return True
widget = forms.Textarea()
# widget.attrs["autofocus"] = None
if read_only:
widget.attrs["readonly"] = None
return widget, None
elif widget_type.startswith("editor:"):
if check_only:
return True
from course.utils import get_codemirror_widget
cm_widget, cm_help_text = get_codemirror_widget(
widget, help_text = get_codemirror_widget(
language_mode=widget_type[widget_type.find(":")+1:],
interaction_mode=interaction_mode,
read_only=read_only)
return cm_widget, cm_help_text
interaction_mode=interaction_mode)
else:
return None, None
widget.attrs["autofocus"] = None
if read_only:
widget.attrs["readonly"] = None
return widget, help_text
def __init__(self, read_only, interaction_mode, validators, *args, **kwargs):
widget_type = kwargs.pop("widget_type", "text_input")
initial_text = kwargs.pop("initial_text", None)
super(TextAnswerForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
widget, help_text = self.get_text_widget(
widget_type, read_only,
interaction_mode=interaction_mode)
self.validators = validators
self.fields["answer"] = forms.CharField(
required=True,
initial=initial_text,
widget=widget,
help_text=help_text,
label=_("Answer"))
def clean(self):
cleaned_data = super(TextAnswerForm, self).clean()
cleaned_data = super().clean()
answer = cleaned_data.get("answer", "")
for i, validator in enumerate(self.validators):
......@@ -118,7 +124,7 @@ class TextAnswerForm(StyledForm):
# {{{ validators
class RELATEPageValidator(object):
class RELATEPageValidator:
type = "relate_page"
def __init__(self, vctx, location, validator_desc):
......@@ -137,14 +143,14 @@ class RELATEPageValidator(object):
)
def validate(self, new_page_source):
from relate.utils import dict_to_struct
import yaml
from relate.utils import dict_to_struct
try:
page_desc = dict_to_struct(yaml.load(new_page_source))
page_desc = dict_to_struct(yaml.safe_load(new_page_source))
from course.validation import (
validate_flow_page, ValidationContext)
from course.validation import ValidationContext, validate_flow_page
vctx = ValidationContext(
# FIXME
repo=None,
......@@ -153,14 +159,13 @@ class RELATEPageValidator(object):
validate_flow_page(vctx, "submitted page", page_desc)
if page_desc.type != self.validator_desc.page_type:
raise ValidationError(ugettext("page must be of type '%s'")
raise ValidationError(gettext("page must be of type '%s'")
% self.validator_desc.page_type)
except:
except Exception:
tp, e, _ = sys.exc_info()
raise forms.ValidationError("%(err_type)s: %(err_str)s"
% {"err_type": tp.__name__, "err_str": str(e)})
raise forms.ValidationError(f"{tp.__name__}: {e!s}")
TEXT_ANSWER_VALIDATOR_CLASSES = [
......@@ -178,7 +183,7 @@ def get_validator_class(location, validator_type):
"%(location)s: ",
_("unknown validator type"),
"'%(type)s'")
% {'location': location, 'type': validator_type})
% {"location": location, "type": validator_type})
def parse_validator(vctx, location, validator_desc):
......@@ -186,7 +191,7 @@ def parse_validator(vctx, location, validator_desc):
raise ValidationError(
string_concat(
"%s: ",
_("must be struct or string"))
_("must be struct"))
% location)
if not hasattr(validator_desc, "type"):
......@@ -204,18 +209,48 @@ def parse_validator(vctx, location, validator_desc):
# {{{ matchers
class TextAnswerMatcher(object):
class TextAnswerMatcher:
"""Abstract interface for matching text answers.
.. attribute:: type
.. attribute:: is_case_sensitive
.. attribute:: pattern_type
"struct" or "string"
Only used for answer normalization. Matchers are responsible for
case sensitivity themselves.
"""
ALLOWED_ATTRIBUTES: tuple[Any, ...] = ()
def __init__(self, vctx, location, pattern):
pass
def __init__(self, vctx, location, matcher_desc):
self.matcher_desc = matcher_desc
validate_struct(
vctx, location, matcher_desc,
required_attrs=(
("type", str),
("value", self.VALUE_VALIDATION_TYPE),
),
allowed_attrs=(
("correctness", (int, float)),
("feedback", str),
*self.ALLOWED_ATTRIBUTES),
)
assert matcher_desc.type == self.type
self.value = matcher_desc.value
if hasattr(matcher_desc, "correctness"):
from course.constants import MAX_EXTRA_CREDIT_FACTOR
if not 0 <= matcher_desc.correctness <= MAX_EXTRA_CREDIT_FACTOR:
raise ValidationError(
string_concat(
"%s: ",
_("correctness value is out of bounds"))
% (location))
self.correctness = matcher_desc.correctness
else:
self.correctness = 1
self.feedback = getattr(matcher_desc, "feedback", None)
def validate(self, s):
"""Called to validate form input against simple input mistakes.
......@@ -223,7 +258,7 @@ class TextAnswerMatcher(object):
Should raise :exc:`django.forms.ValidationError` on error.
"""
pass
pass # pragma: no cover
def grade(self, s):
raise NotImplementedError()
......@@ -243,44 +278,86 @@ def multiple_to_single_spaces(s):
class CaseSensitivePlainMatcher(TextAnswerMatcher):
type = "case_sens_plain"
is_case_sensitive = True
pattern_type = "string"
def __init__(self, vctx, location, pattern):
self.pattern = pattern
VALUE_VALIDATION_TYPE = str
def __init__(self, vctx, location, matcher_desc):
super().__init__(vctx, location, matcher_desc)
def grade(self, s):
return int(
multiple_to_single_spaces(self.pattern)
==
multiple_to_single_spaces(s))
if multiple_to_single_spaces(self.value) == multiple_to_single_spaces(s):
return AnswerFeedback(self.correctness, self.feedback)
else:
return AnswerFeedback(0)
def correct_answer_text(self):
return self.pattern
if self.correctness >= 1:
return self.value
else:
return None
class PlainMatcher(CaseSensitivePlainMatcher):
type = "plain"
is_case_sensitive = False
pattern_type = "string"
def grade(self, s):
return int(
multiple_to_single_spaces(self.pattern.lower())
==
multiple_to_single_spaces(s.lower()))
if (multiple_to_single_spaces(self.value.lower())
== multiple_to_single_spaces(s.lower())):
return AnswerFeedback(self.correctness, self.feedback)
else:
return AnswerFeedback(0)
class RegexMatcher(TextAnswerMatcher):
type = "regex"
re_flags = re.I
is_case_sensitive = False
pattern_type = "string"
def __init__(self, vctx, location, pattern):
VALUE_VALIDATION_TYPE = str
ALLOWED_ATTRIBUTES = (
("flags", list),
)
RE_FLAGS = [
"A", "ASCII", "DOTALL", "I", "IGNORECASE", "M", "MULTILINE", "S",
"U", "UNICODE", "VERBOSE", "X",
# omitted, grade should be locale-independent
# "L", "LOCALE"
]
def __init__(self, vctx, location, matcher_desc):
super().__init__(vctx, location, matcher_desc)
flags = getattr(self.matcher_desc, "flags", None)
if flags is None:
self.is_case_sensitive = type(self) is CaseSensitiveRegexMatcher
if self.is_case_sensitive:
re_flags = 0
else:
re_flags = re.IGNORECASE
else:
if type(self) is CaseSensitiveRegexMatcher:
raise ValidationError(
string_concat("%s: ",
_("may not specify flags in CaseSensitiveRegexMatcher"))
% (location))
re_flags = 0
for flag in flags:
if not isinstance(flag, str):
raise ValidationError(
string_concat("%s: ", _("regex flag is not a string"))
% (location))
if flag not in self.RE_FLAGS:
raise ValidationError(
string_concat("%s: ", _("regex flag is invalid"))
% (location))
re_flags |= getattr(re, flag)
self.is_case_sensitive = "I" in flags or "IGNORECASE" in flags
try:
self.pattern = re.compile(pattern, self.re_flags)
except:
tp, e, _ = sys.exc_info()
self.regex = re.compile(self.value, re_flags)
except Exception:
tp, e, __ = sys.exc_info()
raise ValidationError(
string_concat(
......@@ -289,16 +366,16 @@ class RegexMatcher(TextAnswerMatcher):
": %(err_type)s: %(err_str)s")
% {
"location": location,
"pattern": pattern,
"pattern": self.value,
"err_type": tp.__name__,
"err_str": str(e)})
def grade(self, s):
match = self.pattern.match(s)
match = self.regex.match(s)
if match is not None:
return 1
return AnswerFeedback(self.correctness, self.feedback)
else:
return 0
return AnswerFeedback(0)
def correct_answer_text(self):
return None
......@@ -306,17 +383,17 @@ class RegexMatcher(TextAnswerMatcher):
class CaseSensitiveRegexMatcher(RegexMatcher):
type = "case_sens_regex"
re_flags = 0
is_case_sensitive = True
pattern_type = "string"
def __init__(self, vctx, location, matcher_desc):
super().__init__(vctx, location, matcher_desc)
if vctx is not None:
vctx.add_warning(location, _("Uses 'case_sens_regex' matcher. "
"This will go away in 2022. Use 'regex' with specified flags "
"instead."))
def parse_sympy(s):
if six.PY2:
if isinstance(s, unicode): # noqa -- has Py2/3 guard
# Sympy is not spectacularly happy with unicode function names
s = s.encode()
def parse_sympy(s):
from pymbolic import parse
from pymbolic.interop.sympy import PymbolicToSympyMapper
......@@ -327,15 +404,16 @@ def parse_sympy(s):
class SymbolicExpressionMatcher(TextAnswerMatcher):
type = "sym_expr"
is_case_sensitive = True
pattern_type = "string"
def __init__(self, vctx, location, pattern):
self.pattern = pattern
VALUE_VALIDATION_TYPE = str
def __init__(self, vctx, location, matcher_desc):
super().__init__(vctx, location, matcher_desc)
try:
self.pattern_sym = parse_sympy(pattern)
self.value_sym = parse_sympy(self.value)
except ImportError:
tp, e, _ = sys.exc_info()
tp, e, __ = sys.exc_info()
if vctx is not None:
vctx.add_warning(
location,
......@@ -344,52 +422,52 @@ class SymbolicExpressionMatcher(TextAnswerMatcher):
_("unable to check symbolic expression"),
"(%(err_type)s: %(err_str)s)")
% {
'location': location,
"location": location,
"err_type": tp.__name__,
"err_str": str(e)
})
except:
tp, e, _ = sys.exc_info()
except Exception:
tp, e, __ = sys.exc_info()
raise ValidationError(
"%(location)s: %(err_type)s: %(err_str)s"
% {
"location": location,
"err_type": tp.__name__,
"err_str": str(e)
})
f"{location}: {tp.__name__}: {e!s}")
def validate(self, s):
try:
parse_sympy(s)
except:
except Exception:
tp, e, _ = sys.exc_info()
raise forms.ValidationError("%(err_type)s: %(err_str)s"
% {"err_type": tp.__name__, "err_str": str(e)})
raise forms.ValidationError(f"{tp.__name__}: {e!s}")
def grade(self, s):
from sympy import simplify
answer_sym = parse_sympy(s)
try:
answer_sym = parse_sympy(s)
except Exception:
return AnswerFeedback(0)
from sympy import simplify
try:
simp_result = simplify(answer_sym - self.pattern_sym)
simp_result = simplify(answer_sym - self.value_sym)
except Exception:
return 0
return AnswerFeedback(0)
if simp_result == 0:
return 1
return AnswerFeedback(self.correctness, self.feedback)
else:
return 0
return AnswerFeedback(0)
def correct_answer_text(self):
return self.pattern
if self.correctness >= 1:
return self.value
else:
return None
def float_or_sympy_evalf(s):
if isinstance(s, six.integer_types + (float,)):
if isinstance(s, int | float):
return s
if not isinstance(s, six.string_types):
if not isinstance(s, str):
raise TypeError("expected string, int or float for floating point "
"literal")
......@@ -398,50 +476,30 @@ def float_or_sympy_evalf(s):
except ValueError:
pass
# avoiding IO error if empty input when
# the is field not required
if s == "":
return s
raise ValueError("floating point value expected, empty string found")
# return a float type value, expression not allowed
return float(parse_sympy(s).evalf())
def _is_valid_float(s):
try:
float_or_sympy_evalf(s)
except:
return False
else:
return True
class FloatMatcher(TextAnswerMatcher):
type = "float"
is_case_sensitive = False
pattern_type = "struct"
def __init__(self, vctx, location, matcher_desc):
self.matcher_desc = matcher_desc
VALUE_VALIDATION_TYPE = (int, float, str)
ALLOWED_ATTRIBUTES = (
("rtol", (int, float, str)),
("atol", (int, float, str)),
)
validate_struct(
vctx,
location,
matcher_desc,
required_attrs=(
("type", str),
("value", six.integer_types + (float, str)),
),
allowed_attrs=(
("rtol", six.integer_types + (float, str)),
("atol", six.integer_types + (float, str)),
),
)
def __init__(self, vctx, location, matcher_desc):
super().__init__(vctx, location, matcher_desc)
try:
self.matcher_desc.value = \
float_or_sympy_evalf(matcher_desc.value)
except:
float_or_sympy_evalf(self.value)
except Exception:
raise ValidationError(
string_concat(
"%s: 'value' ",
......@@ -452,7 +510,7 @@ class FloatMatcher(TextAnswerMatcher):
try:
self.matcher_desc.rtol = \
float_or_sympy_evalf(matcher_desc.rtol)
except:
except Exception:
raise ValidationError(
string_concat(
"%s: 'rtol' ",
......@@ -470,7 +528,7 @@ class FloatMatcher(TextAnswerMatcher):
try:
self.matcher_desc.atol = \
float_or_sympy_evalf(matcher_desc.atol)
except:
except Exception:
raise ValidationError(
string_concat(
"%s: 'atol' ",
......@@ -484,12 +542,9 @@ class FloatMatcher(TextAnswerMatcher):
if (
not matcher_desc.value == 0
and
not hasattr(matcher_desc, "atol")
and
not hasattr(matcher_desc, "rtol")
and
vctx is not None):
and not hasattr(matcher_desc, "atol")
and not hasattr(matcher_desc, "rtol")
and vctx is not None):
vctx.add_warning(location,
_("Float match should have either rtol or atol--"
"otherwise it will match any number"))
......@@ -497,31 +552,45 @@ class FloatMatcher(TextAnswerMatcher):
def validate(self, s):
try:
float_or_sympy_evalf(s)
except:
except Exception:
tp, e, _ = sys.exc_info()
raise forms.ValidationError("%(err_type)s: %(err_str)s"
% {"err_type": tp.__name__, "err_str": str(e)})
raise forms.ValidationError(f"{tp.__name__}: {e!s}")
def grade(self, s):
if s == "":
return 0
try:
answer_float = float_or_sympy_evalf(s)
except Exception:
# Should not happen, no need to give verbose feedback.
return AnswerFeedback(0)
good_afb = AnswerFeedback(self.correctness, self.feedback)
bad_afb = AnswerFeedback(0)
answer_float = float_or_sympy_evalf(s)
from math import isinf, isnan
if isinf(self.matcher_desc.value):
return good_afb if isinf(answer_float) else bad_afb
if isnan(self.matcher_desc.value):
return good_afb if isnan(answer_float) else bad_afb
if isinf(answer_float) or isnan(answer_float):
return bad_afb
if hasattr(self.matcher_desc, "atol"):
if (abs(answer_float - self.matcher_desc.value)
> self.matcher_desc.atol):
return 0
return bad_afb
if hasattr(self.matcher_desc, "rtol"):
if (abs(answer_float - self.matcher_desc.value)
/ abs(self.matcher_desc.value)
> self.matcher_desc.rtol):
return 0
return bad_afb
return 1
return good_afb
def correct_answer_text(self):
return str(self.matcher_desc.value)
if self.correctness >= 1:
return str(self.matcher_desc.value)
else:
return None
TEXT_ANSWER_MATCHER_CLASSES = [
......@@ -535,85 +604,55 @@ TEXT_ANSWER_MATCHER_CLASSES = [
MATCHER_RE = re.compile(r"^\<([a-zA-Z0-9_:.]+)\>(.*)$")
MATCHER_RE_2 = re.compile(r"^([a-zA-Z0-9_.]+):(.*)$")
def get_matcher_class(location, matcher_type, pattern_type):
def get_matcher_class(location, matcher_type):
for matcher_class in TEXT_ANSWER_MATCHER_CLASSES:
if matcher_class.type == matcher_type:
if matcher_class.pattern_type != pattern_type:
raise ValidationError(
string_concat(
"%(location)s: ",
# Translators: a "matcher" is used to determine
# if the answer to text question (blank filling
# question) is correct.
_("%(matcherclassname)s only accepts "
"'%(matchertype)s' patterns"))
% {
'location': location,
'matcherclassname': matcher_class.__name__,
'matchertype': matcher_class.pattern_type})
return matcher_class
raise ValidationError(
string_concat(
"%(location)s: ",
_("unknown match type '%(matchertype)s'"))
_("unknown matcher type '%(matchertype)s'"))
% {
'location': location,
'matchertype': matcher_type})
"location": location,
"matchertype": matcher_type})
def parse_matcher_string(vctx, location, matcher_desc):
match = MATCHER_RE.match(matcher_desc)
if match is not None:
matcher_type = match.group(1)
pattern = match.group(2)
else:
match = MATCHER_RE_2.match(matcher_desc)
def parse_matcher(vctx, location, matcher_desc):
if isinstance(matcher_desc, str):
match = MATCHER_RE.match(matcher_desc)
if match is None:
if match is not None:
matcher_desc = Struct({
"type": match.group(1),
"value": match.group(2),
})
else:
raise ValidationError(
string_concat(
"%s: ",
_("does not specify match type"))
_("matcher string does not have expected format, "
"expecting '<matcher type>matched string'"))
% location)
matcher_type = match.group(1)
pattern = match.group(2)
if vctx is not None:
vctx.add_warning(location,
_("uses deprecated 'matcher:answer' style"))
return (get_matcher_class(location, matcher_type, "string")
(vctx, location, pattern))
def parse_matcher(vctx, location, matcher_desc):
if isinstance(matcher_desc, six.string_types):
return parse_matcher_string(vctx, location, matcher_desc)
else:
if not isinstance(matcher_desc, Struct):
raise ValidationError(
string_concat(
"%s: ",
_("must be struct or string"))
% location)
if not isinstance(matcher_desc, Struct):
raise ValidationError(
string_concat(
"%s: ",
_("must be struct or string"))
% location)
if not hasattr(matcher_desc, "type"):
raise ValidationError(
string_concat(
"%s: ",
_("matcher must supply 'type'"))
% location)
if not hasattr(matcher_desc, "type"):
raise ValidationError(
string_concat(
"%s: ",
_("matcher must supply 'type'"))
% location)
return (get_matcher_class(location, matcher_desc.type, "struct")
(vctx, location, matcher_desc))
return (get_matcher_class(location, matcher_desc.type)
(vctx, location, matcher_desc))
# }}}
......@@ -632,6 +671,10 @@ class TextQuestionBase(PageBaseWithTitle):
``TextQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -648,9 +691,12 @@ class TextQuestionBase(PageBaseWithTitle):
|text-widget-page-attr|
.. attribute:: initial_text
Text with which to prepopulate the input widget.
"""
def __init__(self, vctx, location, page_desc):
super(TextQuestionBase, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
widget = TextAnswerForm.get_text_widget(
getattr(page_desc, "widget", None),
......@@ -663,18 +709,14 @@ class TextQuestionBase(PageBaseWithTitle):
_("unrecognized widget type"),
"'%(type)s'")
% {
'location': location,
'type': getattr(page_desc, "widget")})
"location": location,
"type": page_desc.widget})
def required_attrs(self):
return super(TextQuestionBase, self).required_attrs() + (
("prompt", "markup"),
)
def required_attrs(self) -> AttrSpec:
return (*super().required_attrs(), ("prompt", "markup"))
def allowed_attrs(self):
return super(TextQuestionBase, self).allowed_attrs() + (
("widget", str),
)
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("widget", str), ("initial_text", str))
def markup_body_for_title(self):
return self.page_desc.prompt
......@@ -682,26 +724,24 @@ class TextQuestionBase(PageBaseWithTitle):
def body(self, page_context, page_data):
return markup_to_html(page_context, self.page_desc.prompt)
def get_validators(self):
raise NotImplementedError()
def make_form(self, page_context, page_data,
answer_data, page_behavior):
read_only = not page_behavior.may_change_answer
kwargs = {
"read_only": not page_behavior.may_change_answer,
"interaction_mode": getattr(self.page_desc, "widget", None),
"validators": self.get_validators(),
"widget_type": getattr(self.page_desc, "widget", None),
"initial_text": getattr(self.page_desc, "initial_text", None),
}
if answer_data is not None:
answer = {"answer": answer_data["answer"]}
form = TextAnswerForm(
read_only,
get_editor_interaction_mode(page_context),
self.get_validators(), answer,
widget_type=getattr(self.page_desc, "widget", None))
else:
answer = None
form = TextAnswerForm(
read_only,
get_editor_interaction_mode(page_context),
self.get_validators(),
widget_type=getattr(self.page_desc, "widget", None))
kwargs.update({"data": {"answer": answer_data["answer"]}})
return form
return TextAnswerForm(**kwargs)
def process_form_post(self, page_context, page_data, post_data, files_data,
page_behavior):
......@@ -714,7 +754,7 @@ class TextQuestionBase(PageBaseWithTitle):
def answer_data(self, page_context, page_data, form, files_data):
return {"answer": form.cleaned_data["answer"].strip()}
def is_case_sensitive(self):
def _is_case_sensitive(self):
return True
def normalized_answer(self, page_context, page_data, answer_data):
......@@ -723,7 +763,7 @@ class TextQuestionBase(PageBaseWithTitle):
normalized_answer = answer_data["answer"]
if not self.is_case_sensitive():
if not self._is_case_sensitive():
normalized_answer = normalized_answer.lower()
from django.utils.html import escape
......@@ -734,12 +774,13 @@ class TextQuestionBase(PageBaseWithTitle):
return None
return (".txt", answer_data["answer"].encode("utf-8"))
# }}}
# {{{ survey text question
class SurveyTextQuestion(TextQuestionBase):
class SurveyTextQuestion(TextQuestionBase, PageBaseUngraded):
"""
A page asking for a textual answer, without any notion of 'correctness'
......@@ -751,6 +792,10 @@ class SurveyTextQuestion(TextQuestionBase):
``TextQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -767,6 +812,10 @@ class SurveyTextQuestion(TextQuestionBase):
|text-widget-page-attr|
.. attribute:: initial_text
Text with which to prepopulate the input widget.
.. attribute:: answer_comment
A comment that is shown in the same situations a 'correct answer' would
......@@ -776,10 +825,8 @@ class SurveyTextQuestion(TextQuestionBase):
def get_validators(self):
return []
def allowed_attrs(self):
return super(SurveyTextQuestion, self).allowed_attrs() + (
("answer_comment", "markup"),
)
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("answer_comment", "markup"))
def correct_answer(self, page_context, page_data, answer_data, grade_data):
if hasattr(self.page_desc, "answer_comment"):
......@@ -790,17 +837,37 @@ class SurveyTextQuestion(TextQuestionBase):
def expects_answer(self):
return True
def is_answer_gradable(self):
return False
# }}}
# {{{ text question
class TextQuestion(TextQuestionBase, PageBaseWithValue):
class TextQuestion(TextQuestionBase, PageBaseWithValue, PageBaseWithoutHumanGrading):
"""
A page asking for a textual answer
A page asking for a textual answer.
Example:
.. code-block:: yaml
type: TextQuestion
id: fwd_err
prompt: |
# Forward Error
Consider the function $f(x)=1/x$, which we approximate by its Taylor
series about 1:
$$
f(x)\\approx 1-(x-1)+\\cdots
$$
What is the **forward error** of using this approximation at $x=0.5$?
answers:
- type: float
value: 0.5
rtol: 0.01
- <plain>HI THERE
answer_explanation: |
That's just what it is.
.. attribute:: id
......@@ -810,6 +877,10 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
``TextQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -830,10 +901,13 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
|text-widget-page-attr|
.. attribute:: initial_text
Text with which to prepopulate the input widget.
.. attribute:: answers
A list of answers. If the participant's response matches one of these
answers, it is considered fully correct. Each answer consists of a 'matcher'
A list of answers. Each answer consists of a 'matcher'
and an answer template for that matcher to use. Each type of matcher
requires one of two syntax variants to be used. The
'simple/abbreviated' syntax::
......@@ -846,6 +920,15 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
value: 1.25
rtol: 0.2
# All structured-form matchers allow (but do not require) these:
correctness: 0.5
feedback: "Close, but not quite"
If ``correctness`` is not explicitly given, the answer is considered
fully correct. The ``answers`` list of answers is evaluated in order.
The first applicable matcher yielding the highest correctness value
will determine the result shown to the user.
Here are examples of all the supported simple/abbreviated matchers:
- ``<plain>some_text`` Matches exactly ``some_text``, in a
......@@ -860,16 +943,17 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
(Python-style) regular expression that
follows. Case-insensitive, i.e. capitalization does not matter.
- ``<case_sens_regex>[a-z]+`` Matches anything matched by the given
(Python-style) regular expression that
follows. Case-sensitive, i.e. capitalization matters.
- ``<sym_expr>x+2*y`` Matches anything that :mod:`sympy` considers
equivalent to the given expression. Equivalence is determined
by simplifying ``user_answer - given_expr`` and testing the result
against 0 using :mod:`sympy`.
Here are examples of all the supported structured matchers:
Each simple matcher may also be given in structured form, e.g.::
- type: sym_expr
value: x+2*y
Additionally, the following structured-only matchers exist:
- Floating point. Example::
......@@ -878,7 +962,12 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
rtol: 0.2 # relative tolerance
atol: 0.2 # absolute tolerance
One of ``rtol`` or ``atol`` must be given.
- Regular expression. Example::
- type: regex
value: [a-z]+
flags: [IGNORECASE, DOTALL] # see python regex documentation
# if not given, defaults to "[IGNORECASE]"
.. attribute:: answer_explanation
......@@ -886,7 +975,7 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
"""
def __init__(self, vctx, location, page_desc):
super(TextQuestion, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
if len(page_desc.answers) == 0:
raise ValidationError(
......@@ -912,14 +1001,10 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
% location)
def required_attrs(self):
return super(TextQuestion, self).required_attrs() + (
("answers", list),
)
return (*super().required_attrs(), ("answers", list))
def allowed_attrs(self):
return super(TextQuestion, self).allowed_attrs() + (
("answer_explanation", "markup"),
)
return (*super().allowed_attrs(), ("answer_explanation", "markup"))
def get_validators(self):
return self.matchers
......@@ -927,11 +1012,13 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
def grade(self, page_context, page_data, answer_data, grade_data):
if answer_data is None:
return AnswerFeedback(correctness=0,
feedback=ugettext("No answer provided."))
feedback=gettext("No answer provided."))
answer = answer_data["answer"]
correctness = 0
# Must start with 'None' to allow matcher to set feedback for zero
# correctness.
afb = None
for matcher in self.matchers:
try:
......@@ -939,33 +1026,37 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
except forms.ValidationError:
continue
matcher_correctness = matcher.grade(answer)
if (matcher_correctness is not None
and matcher_correctness >= correctness):
correctness = matcher_correctness
matcher_afb = matcher.grade(answer)
if matcher_afb.correctness is not None:
if afb is None:
afb = matcher_afb
elif matcher_afb.correctness > afb.correctness:
afb = matcher_afb
return AnswerFeedback(correctness=correctness)
if afb is None:
afb = AnswerFeedback(0)
return afb
def correct_answer(self, page_context, page_data, answer_data, grade_data):
# FIXME: Could use 'best' match to answer
CA_PATTERN = string_concat(_("A correct answer is"), ": '%s'.") # noqa
for matcher in self.matchers:
unspec_correct_answer_text = None
for matcher in self.matchers: # pragma: no branch
unspec_correct_answer_text = matcher.correct_answer_text()
if unspec_correct_answer_text is not None:
break
assert unspec_correct_answer_text
result = CA_PATTERN % unspec_correct_answer_text
result = CORRECT_ANSWER_PATTERN % unspec_correct_answer_text
if hasattr(self.page_desc, "answer_explanation"):
result += markup_to_html(page_context, self.page_desc.answer_explanation)
return result
def is_case_sensitive(self):
def _is_case_sensitive(self):
return any(matcher.is_case_sensitive for matcher in self.matchers)
# }}}
......@@ -976,7 +1067,10 @@ class TextQuestion(TextQuestionBase, PageBaseWithValue):
class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue,
PageBaseWithHumanTextFeedback, PageBaseWithCorrectAnswer):
"""
A page asking for a textual answer
A page asking for a textual answer, with human-graded feedback.
Supports automatic computation of point values from textual feedback.
See :ref:`points-from-feedback`.
.. attribute:: id
......@@ -986,6 +1080,10 @@ class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue,
``HumanGradedTextQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -1006,6 +1104,10 @@ class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue,
|text-widget-page-attr|
.. attribute:: initial_text
Text with which to prepopulate the input widget.
.. attribute:: validators
Optional.
......@@ -1024,7 +1126,7 @@ class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue,
"""
def __init__(self, vctx, location, page_desc):
super(HumanGradedTextQuestion, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
self.validators = [
parse_validator(
......@@ -1035,9 +1137,7 @@ class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue,
getattr(page_desc, "validators", []))]
def allowed_attrs(self):
return super(HumanGradedTextQuestion, self).allowed_attrs() + (
("validators", list),
)
return (*super().allowed_attrs(), ("validators", list))
def human_feedback_point_value(self, page_context, page_data):
return self.max_points(page_data)
......@@ -1047,4 +1147,143 @@ class HumanGradedTextQuestion(TextQuestionBase, PageBaseWithValue,
# }}}
# {{{ rich text
class RichTextAnswerForm(StyledForm):
# FIXME: ugh, this should be a PageBase thing
show_save_button = False
def __init__(self, read_only: bool, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
from course.utils import ProseMirrorTextarea
self.fields["answer"] = forms.JSONField(
required=True,
widget=ProseMirrorTextarea(attrs={"readonly": read_only}),
help_text=ProseMirrorTextarea.math_help_text,
label=_("Answer"))
class HumanGradedRichTextQuestion(PageBaseWithValue, PageBaseWithTitle,
PageBaseWithHumanTextFeedback, PageBaseWithCorrectAnswer):
"""
A page asking for a textual answer, with human-graded feedback.
Supports automatic computation of point values from textual feedback.
See :ref:`points-from-feedback`.
.. attribute:: id
|id-page-attr|
.. attribute:: type
``HumanGradedRichTextQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
.. attribute:: title
|title-page-attr|
.. attribute:: value
|value-page-attr|
.. attribute:: prompt
The page's prompt, written in :ref:`markup`.
.. attribute:: correct_answer
Optional.
Content that is revealed when answers are visible
(see :ref:`flow-permissions`). Written in :ref:`markup`.
.. attribute:: rubric
Required.
The grading guideline for this question, in :ref:`markup`.
"""
def required_attrs(self) -> AttrSpec:
return (*super().required_attrs(), ("prompt", "markup"))
def body(self, page_context: PageContext, page_data: Any) -> str:
return markup_to_html(page_context, self.page_desc.prompt)
def markup_body_for_title(self) -> str:
return self.page_desc.prompt
def human_feedback_point_value(self,
page_context: PageContext,
page_data: Any
) -> float:
return self.max_points(page_data)
def make_form(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
page_behavior: Any,
) -> StyledForm:
kwargs = {}
if answer_data is not None:
from json import dumps
kwargs.update({"data": {"answer": dumps(answer_data["answer"])}})
return RichTextAnswerForm(
read_only=not page_behavior.may_change_answer,
**kwargs)
def process_form_post(
self,
page_context: PageContext,
page_data: Any,
post_data: Any,
files_data: Any,
page_behavior: PageBehavior,
) -> StyledForm:
return RichTextAnswerForm(
not page_behavior.may_change_answer,
post_data, files_data,
)
def answer_data(self, page_context, page_data, form, files_data):
data = form.cleaned_data["answer"]
assert isinstance(data, dict)
return {"answer": data}
def normalized_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any
) -> str | None:
if answer_data is None:
return None
from json import dumps
from django.utils.html import escape
return escape(dumps(answer_data["answer"]))
def normalized_bytes_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
) -> tuple[str, bytes] | None:
return None
# }}}
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,19 +23,22 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from typing import Any
import django.forms as forms
from django.utils.translation import ugettext as _, ugettext_lazy, string_concat
from crispy_forms.layout import Field, Layout
from django.utils.translation import gettext as _, gettext_lazy
from course.page.base import (
PageBaseWithTitle, PageBaseWithValue, PageBaseWithHumanTextFeedback,
PageBaseWithCorrectAnswer,
markup_to_html)
from course.validation import ValidationError
from relate.utils import StyledForm
from crispy_forms.layout import Layout, Field
PageBaseWithCorrectAnswer,
PageBaseWithHumanTextFeedback,
PageBaseWithTitle,
PageBaseWithValue,
PageContext,
markup_to_html,
)
from course.validation import AttrSpec, ValidationError
from relate.utils import StyledForm, string_concat
# {{{ upload question
......@@ -44,10 +46,10 @@ from crispy_forms.layout import Layout, Field
class FileUploadForm(StyledForm):
show_save_button = False
uploaded_file = forms.FileField(required=True,
label=ugettext_lazy('Uploaded file'))
label=gettext_lazy("Uploaded file"))
def __init__(self, maximum_megabytes, mime_types, *args, **kwargs):
super(FileUploadForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.max_file_size = maximum_megabytes * 1024**2
self.mime_types = mime_types
......@@ -66,15 +68,15 @@ class FileUploadForm(StyledForm):
Field("uploaded_file", **field_kwargs))
def clean_uploaded_file(self):
uploaded_file = self.cleaned_data['uploaded_file']
uploaded_file = self.cleaned_data["uploaded_file"]
from django.template.defaultfilters import filesizeformat
if uploaded_file._size > self.max_file_size:
if uploaded_file.size > self.max_file_size:
raise forms.ValidationError(
_("Please keep file size under %(allowedsize)s. "
"Current filesize is %(uploadedsize)s.")
% {'allowedsize': filesizeformat(self.max_file_size),
'uploadedsize': filesizeformat(uploaded_file._size)})
% {"allowedsize": filesizeformat(self.max_file_size),
"uploadedsize": filesizeformat(uploaded_file.size)})
if self.mime_types is not None and self.mime_types == ["application/pdf"]:
if uploaded_file.read()[:4] != b"%PDF":
......@@ -89,6 +91,9 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue,
A page allowing the submission of a file upload that will be
graded with text feedback by a human grader.
Supports automatic computation of point values from textual feedback.
See :ref:`points-from-feedback`.
.. attribute:: id
|id-page-attr|
......@@ -97,6 +102,10 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue,
``Page``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -139,12 +148,6 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue,
Content that is revealed when answers are visible
(see :ref:`flow-permissions`). Written in :ref:`markup`.
.. attribute:: correct_answer
Optional.
Content that is revealed when answers are visible
(see :ref:`flow-permissions`). Written in :ref:`markup`.
.. attribute:: rubric
Required.
......@@ -158,36 +161,41 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue,
]
def __init__(self, vctx, location, page_desc):
super(FileUploadQuestion, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
if not (set(page_desc.mime_types) <= set(self.ALLOWED_MIME_TYPES)):
raise ValidationError(
string_concat(
"%(location)s: ",
_("unrecognized mime types"),
" '%(presenttype)s'")
% {
'location': location,
'presenttype': ", ".join(
set(page_desc.mime_types)
- set(self.ALLOWED_MIME_TYPES))})
string_concat(
location, ": ",
_("unrecognized mime types"),
" '%(presenttype)s'")
% {
"presenttype": ", ".join(
set(page_desc.mime_types)
- set(self.ALLOWED_MIME_TYPES))})
if page_desc.maximum_megabytes <= 0:
raise ValidationError(
string_concat(
location, ": ",
_("'maximum_megabytes' expects a positive value, "
"got %(value)s instead")
% {"value": str(page_desc.maximum_megabytes)}))
if vctx is not None:
if not hasattr(page_desc, "value"):
vctx.add_warning(location, _("upload question does not have "
"assigned point value"))
def required_attrs(self):
return super(FileUploadQuestion, self).required_attrs() + (
("prompt", "markup"),
("mime_types", list),
("maximum_megabytes", (int, float)),
)
def required_attrs(self) -> AttrSpec:
return (
*super().required_attrs(),
("prompt", "markup"),
("mime_types", list),
("maximum_megabytes", (int, float)))
def allowed_attrs(self):
return super(FileUploadQuestion, self).allowed_attrs() + (
("correct_answer", "markup"),
)
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("correct_answer", "markup"))
def human_feedback_point_value(self, page_context, page_data):
return self.max_points(page_data)
......@@ -198,20 +206,62 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue,
def body(self, page_context, page_data):
return markup_to_html(page_context, self.page_desc.prompt)
def files_data_to_answer_data(self, files_data):
files_data["uploaded_file"].seek(0)
buf = files_data["uploaded_file"].read()
def get_submission_filename_pattern(self, page_context, mime_type):
from mimetypes import guess_extension
if mime_type is not None:
ext = guess_extension(mime_type)
else:
ext = ".bin"
username = "anon"
flow_id = "unk_flow"
if page_context.flow_session is not None:
if page_context.flow_session.participation is not None:
username = page_context.flow_session.participation.user.username
if page_context.flow_session.flow_id:
flow_id = page_context.flow_session.flow_id
return ("submission/"
f"{page_context.course.identifier}/"
"file-upload/"
f"{flow_id}/"
f"{self.page_desc.id}/"
f"{username}"
f"{ext}")
def file_to_answer_data(self, page_context, uploaded_file, mime_type):
if len(self.page_desc.mime_types) == 1:
mime_type, = self.page_desc.mime_types
else:
mime_type = files_data["uploaded_file"].content_type
from base64 import b64encode
from django.conf import settings
uploaded_file.seek(0)
saved_name = settings.RELATE_BULK_STORAGE.save(
self.get_submission_filename_pattern(page_context, mime_type),
uploaded_file)
return {
"base64_data": b64encode(buf).decode(),
"storage_filename": saved_name,
"mime_type": mime_type,
}
@staticmethod
def get_content_from_answer_data(answer_data):
mime_type = answer_data.get("mime_type", "application/octet-stream")
if "storage_filename" in answer_data:
from django.conf import settings
with settings.RELATE_BULK_STORAGE.open(
answer_data["storage_filename"]) as inf:
return inf.read(), mime_type
elif "base64_data" in answer_data:
from base64 import b64decode
return b64decode(answer_data["base64_data"]), mime_type
else:
raise ValueError("could not get submitted data from answer_data JSON")
def make_form(self, page_context, page_data,
answer_data, page_behavior):
form = FileUploadForm(
......@@ -228,34 +278,41 @@ class FileUploadQuestion(PageBaseWithTitle, PageBaseWithValue,
def form_to_html(self, request, page_context, form, answer_data):
ctx = {"form": form}
if answer_data is not None:
ctx["mime_type"] = answer_data["mime_type"]
ctx["data_url"] = "data:%s;base64,%s" % (
answer_data["mime_type"],
answer_data["base64_data"],
)
from base64 import b64encode
subm_data, subm_mime = self.get_content_from_answer_data(answer_data)
ctx["mime_type"] = subm_mime
ctx["data_url"] = f"data:{subm_mime};base64,{b64encode(subm_data).decode()}"
from django.template.loader import render_to_string
return render_to_string(
"course/file-upload-form.html", ctx, request)
def answer_data(self, page_context, page_data, form, files_data):
return self.files_data_to_answer_data(files_data)
uploaded_file = files_data["uploaded_file"]
return self.file_to_answer_data(page_context, uploaded_file,
mime_type=uploaded_file.content_type)
def normalized_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any
) -> str | None:
return None
def normalized_bytes_answer(self, page_context, page_data, answer_data):
if answer_data is None:
return None
ext = None
if len(self.page_desc.mime_types) == 1:
mtype, = self.page_desc.mime_types
from mimetypes import guess_extension
ext = guess_extension(mtype)
subm_data, subm_mime = self.get_content_from_answer_data(answer_data)
from mimetypes import guess_extension
ext = guess_extension(subm_mime)
if ext is None:
ext = ".dat"
from base64 import b64decode
return (ext, b64decode(answer_data["base64_data"]))
return (ext, subm_data)
# }}}
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2016 Dong Zhuang, Andreas Kloeckner"
......@@ -24,17 +23,20 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from django.db.models.signals import post_save
from typing import Any
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from accounts.models import User
from course.models import (
Course, Participation, participation_status,
ParticipationPreapproval,
)
from typing import List, Union, Text, Optional, Tuple, Any # noqa
Course,
Participation,
ParticipationPreapproval,
ParticipationRole,
participation_status,
)
# {{{ Update enrollment status when a User/Course instance is saved
......@@ -42,46 +44,47 @@ from typing import List, Union, Text, Optional, Tuple, Any # noqa
@receiver(post_save, sender=User)
@receiver(post_save, sender=Course)
@transaction.atomic
def update_requested_participation_status(sender, created, instance,
**kwargs):
# type: (Any, bool, Union[Course, User], **Any) -> None
def update_requested_participation_status(
sender: Any,
created: bool,
instance: Course | User,
**kwargs: Any) -> None:
if created:
return
user_updated = False
course_updated = False
if isinstance(instance, Course):
course_updated = True
course = instance
requested_qset = Participation.objects.filter(
course=course, status=participation_status.requested)
elif isinstance(instance, User):
else:
assert isinstance(instance, User)
user_updated = True
user = instance
requested_qset = Participation.objects.filter(
user=user, status=participation_status.requested)
else:
return
if requested_qset:
for requested in requested_qset:
if isinstance(instance, Course):
user = requested.user
elif isinstance(instance, User):
course = requested.course
else:
continue
may_preapprove, roles = may_preapprove_role(course, user)
for requested in requested_qset:
if course_updated:
user = requested.user
else:
assert user_updated
course = requested.course
if may_preapprove:
from course.enrollment import handle_enrollment_request
may_preapprove, roles = may_preapprove_role(course, user)
handle_enrollment_request(
course, user, participation_status.active, roles)
if may_preapprove:
from course.enrollment import handle_enrollment_request
handle_enrollment_request(
course, user, participation_status.active, roles)
def may_preapprove_role(course, user):
# type: (Course, User) -> Tuple[bool, Optional[List[Text]]]
def may_preapprove_role(
course: Course, user: User) -> tuple[bool, list[ParticipationRole] | None]:
if not user.is_active:
return False, None
......@@ -91,15 +94,17 @@ def may_preapprove_role(course, user):
preapproval = ParticipationPreapproval.objects.get(
course=course, email__iexact=user.email)
except ParticipationPreapproval.DoesNotExist:
if user.institutional_id:
if not (course.preapproval_require_verified_inst_id
and not user.institutional_id_verified):
try:
preapproval = ParticipationPreapproval.objects.get(
course=course,
institutional_id__iexact=user.institutional_id)
except ParticipationPreapproval.DoesNotExist:
pass
pass
if preapproval is None:
if user.institutional_id:
if not (course.preapproval_require_verified_inst_id
and not user.institutional_id_verified):
try:
preapproval = ParticipationPreapproval.objects.get(
course=course,
institutional_id__iexact=user.institutional_id)
except ParticipationPreapproval.DoesNotExist:
pass
if preapproval:
return True, list(preapproval.roles.all())
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,17 +23,34 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from collections.abc import Mapping
from typing import Any, cast
import django.forms as forms
from django.utils.safestring import mark_safe
from django.contrib import messages # noqa
from crispy_forms.layout import Submit
from django import http
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext, ugettext_lazy as _
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _
from crispy_forms.layout import Submit
from course.constants import participation_permission as pperm
from course.content import FlowPageDesc
from course.utils import CoursePageContext, course_view, render_course_page
from course.utils import course_view, render_course_page
from course.constants import participation_permission as pperm
# {{{ for mypy
# }}}
# {{{ sandbox session key prefix
PAGE_SESSION_KEY_PREFIX = "cf_validated_sandbox_page"
ANSWER_DATA_SESSION_KEY_PREFIX = "cf_page_sandbox_answer_data"
PAGE_DATA_SESSION_KEY_PREFIX = "cf_page_sandbox_page_data"
# }}}
# {{{ sandbox form
......@@ -43,9 +59,10 @@ class SandboxForm(forms.Form):
# prevents form submission with codemirror's empty textarea
use_required_attribute = False
def __init__(self, initial_text,
language_mode, interaction_mode, help_text, *args, **kwargs):
super(SandboxForm, self).__init__(*args, **kwargs)
def __init__(self, initial_text: str | None,
language_mode: str, interaction_mode: str, help_text: str,
*args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
from crispy_forms.helper import FormHelper
self.helper = FormHelper()
......@@ -54,7 +71,8 @@ class SandboxForm(forms.Form):
from course.utils import get_codemirror_widget
cm_widget, cm_help_text = get_codemirror_widget(
language_mode=language_mode,
interaction_mode=interaction_mode)
interaction_mode=interaction_mode,
autofocus=True)
self.fields["content"] = forms.CharField(
required=False,
......@@ -63,21 +81,17 @@ class SandboxForm(forms.Form):
help_text=mark_safe(
help_text
+ " "
+ ugettext("Press Alt/Cmd+(Shift+)P to preview.")
+ gettext("Press Alt/Cmd+(Shift+)P to preview.")
+ " "
+ cm_help_text),
label=_("Content"))
# 'strip' attribute was added to CharField in Django 1.9
# with 'True' as default value.
if hasattr(self.fields["content"], "strip"):
self.fields["content"].strip = False
label=_("Content"),
strip=False)
self.helper.add_input(
Submit("preview", _("Preview"), accesskey="p"),
)
self.helper.add_input(
Submit("clear", _("Clear"), css_class="btn-default"),
Submit("clear", _("Clear"), css_class="btn-secondary"),
)
# }}}
......@@ -94,8 +108,8 @@ def view_markup_sandbox(pctx):
preview_text = ""
def make_form(data=None):
help_text = (ugettext("Enter <a href=\"http://documen.tician.de/"
"relate/content.html#relate-markup\">"
help_text = (gettext('Enter <a href="http://documen.tician.de/'
'relate/content.html#relate-markup">'
"RELATE markup</a>."))
return SandboxForm(
None, "markdown", request.user.editor_mode,
......@@ -111,15 +125,14 @@ def view_markup_sandbox(pctx):
preview_text = markup_to_html(
pctx.course, pctx.repo, pctx.course_commit_sha,
form.cleaned_data["content"])
except:
except Exception:
import sys
tp, e, _ = sys.exc_info()
messages.add_message(pctx.request, messages.ERROR,
ugettext("Markup failed to render")
gettext("Markup failed to render")
+ ": "
+ "%(err_type)s: %(err_str)s" % {
"err_type": tp.__name__, "err_str": e})
+ f"{tp.__name__}: {e}")
form = make_form(request.POST)
......@@ -136,19 +149,19 @@ def view_markup_sandbox(pctx):
# {{{ page sandbox data retriever
def get_sandbox_data_for_page(pctx, page_desc, key):
def get_sandbox_data_for_page(
pctx: CoursePageContext, page_desc: Any, key: str) -> Any:
stored_data_tuple = pctx.request.session.get(key)
# Session storage uses JSON and may turn tuples into lists.
if (isinstance(stored_data_tuple, (list, tuple))
if (isinstance(stored_data_tuple, list | tuple)
and len(stored_data_tuple) == 3):
stored_data_page_type, stored_data_page_id, \
stored_data = stored_data_tuple
stored_data = cast(tuple, stored_data_tuple)
if (
stored_data_page_type == page_desc.type
and
stored_data_page_id == page_desc.id):
and stored_data_page_id == page_desc.id):
return stored_data
return None
......@@ -159,15 +172,16 @@ def get_sandbox_data_for_page(pctx, page_desc, key):
# {{{ page sandbox form
class PageSandboxForm(SandboxForm):
def __init__(self, initial_text,
language_mode, interaction_mode, help_text, *args, **kwargs):
super(PageSandboxForm, self).__init__(
def __init__(self, initial_text: str | None,
language_mode: str, interaction_mode: str, help_text: str,
*args: Any, **kwargs: Any) -> None:
super().__init__(
initial_text, language_mode, interaction_mode, help_text,
*args, **kwargs)
self.helper.add_input(
Submit("clear_response", _("Clear Response Data"),
css_class="btn-default"),
css_class="btn-secondary"),
)
# }}}
......@@ -175,24 +189,44 @@ class PageSandboxForm(SandboxForm):
# {{{ page sandbox
def make_sandbox_session_key(prefix: str, course_identifier: str) -> str:
return f"{prefix}:{course_identifier}"
def page_desc_from_yaml_string(pctx: CoursePageContext, source: str) -> FlowPageDesc:
import yaml
from pytools.py_codegen import remove_common_indentation
from course.content import expand_yaml_macros
from relate.utils import dict_to_struct
new_page_source = remove_common_indentation(
source, require_leading_newline=False)
new_page_source = expand_yaml_macros(
pctx.repo, pctx.course_commit_sha, new_page_source)
yaml_data = yaml.safe_load(new_page_source) # type: ignore
return cast(FlowPageDesc, dict_to_struct(yaml_data))
@course_view
def view_page_sandbox(pctx):
def view_page_sandbox(pctx: CoursePageContext) -> http.HttpResponse:
if not pctx.has_permission(pperm.use_page_sandbox):
raise PermissionDenied()
from course.validation import ValidationError
from relate.utils import dict_to_struct, Struct
import yaml
from relate.utils import Struct
PAGE_SESSION_KEY = ( # noqa
"cf_validated_sandbox_page:" + pctx.course.identifier)
ANSWER_DATA_SESSION_KEY = ( # noqa
"cf_page_sandbox_answer_data:" + pctx.course.identifier)
PAGE_DATA_SESSION_KEY = ( # noqa
"cf_page_sandbox_page_data:" + pctx.course.identifier)
page_session_key = make_sandbox_session_key(
PAGE_SESSION_KEY_PREFIX, pctx.course.identifier)
answer_data_session_key = make_sandbox_session_key(
ANSWER_DATA_SESSION_KEY_PREFIX, pctx.course.identifier)
page_data_session_key = make_sandbox_session_key(
PAGE_DATA_SESSION_KEY_PREFIX, pctx.course.identifier)
request = pctx.request
page_source = pctx.request.session.get(PAGE_SESSION_KEY)
page_source = cast(str | None, pctx.request.session.get(page_session_key))
page_errors = None
page_warnings = None
......@@ -202,10 +236,11 @@ def view_page_sandbox(pctx):
and "clear_response" in request.POST)
is_preview_post = (request.method == "POST" and "preview" in request.POST)
def make_form(data=None):
def make_form(data: Mapping[str, Any] | None = None) -> PageSandboxForm:
assert request.user.is_authenticated
return PageSandboxForm(
page_source, "yaml", request.user.editor_mode,
ugettext("Enter YAML markup for a flow page."),
gettext("Enter YAML markup for a flow page."),
data)
if is_preview_post:
......@@ -213,19 +248,16 @@ def view_page_sandbox(pctx):
new_page_source = None
if edit_form.is_valid():
form_content = edit_form.cleaned_data["content"]
try:
from pytools.py_codegen import remove_common_indentation
new_page_source = remove_common_indentation(
edit_form.cleaned_data["content"],
require_leading_newline=False)
page_desc = dict_to_struct(yaml.load(new_page_source))
page_desc = page_desc_from_yaml_string(pctx, form_content)
if not isinstance(page_desc, Struct):
raise ValidationError("Provided page source code is not "
"a dictionary. Do you need to remove a leading "
"list marker ('-') or some stray indentation?")
from course.validation import validate_flow_page, ValidationContext
from course.validation import ValidationContext, validate_flow_page
vctx = ValidationContext(
repo=pctx.repo,
commit_sha=pctx.course_commit_sha)
......@@ -234,38 +266,38 @@ def view_page_sandbox(pctx):
page_warnings = vctx.warnings
except:
except Exception:
import sys
tp, e, _ = sys.exc_info()
page_errors = (
ugettext("Page failed to load/validate")
gettext("Page failed to load/validate")
+ ": "
+ "%(err_type)s: %(err_str)s" % {
"err_type": tp.__name__, "err_str": e})
+ f"{tp.__name__}: {e}") # type: ignore
else:
# Yay, it did validate.
request.session[PAGE_SESSION_KEY] = page_source = new_page_source
request.session[page_session_key] = page_source = form_content
del new_page_source
del form_content
edit_form = make_form(pctx.request.POST)
elif is_clear_post:
page_source = None
pctx.request.session[PAGE_DATA_SESSION_KEY] = None
pctx.request.session[ANSWER_DATA_SESSION_KEY] = None
del pctx.request.session[PAGE_DATA_SESSION_KEY]
del pctx.request.session[ANSWER_DATA_SESSION_KEY]
pctx.request.session[page_data_session_key] = None
pctx.request.session[answer_data_session_key] = None
del pctx.request.session[page_data_session_key]
del pctx.request.session[answer_data_session_key]
edit_form = make_form()
elif is_clear_response_post:
page_source = None
pctx.request.session[PAGE_DATA_SESSION_KEY] = None
pctx.request.session[ANSWER_DATA_SESSION_KEY] = None
del pctx.request.session[PAGE_DATA_SESSION_KEY]
del pctx.request.session[ANSWER_DATA_SESSION_KEY]
pctx.request.session[page_data_session_key] = None
pctx.request.session[answer_data_session_key] = None
del pctx.request.session[page_data_session_key]
del pctx.request.session[answer_data_session_key]
edit_form = make_form(pctx.request.POST)
else:
......@@ -273,30 +305,32 @@ def view_page_sandbox(pctx):
have_valid_page = page_source is not None
if have_valid_page:
page_desc = dict_to_struct(yaml.load(page_source))
assert page_source is not None
page_desc = page_desc_from_yaml_string(pctx, page_source)
from course.content import instantiate_flow_page
try:
page = instantiate_flow_page("sandbox", pctx.repo, page_desc,
pctx.course_commit_sha)
except:
except Exception:
import sys
tp, e, _ = sys.exc_info()
page_errors = (
ugettext("Page failed to load/validate")
gettext("Page failed to load/validate")
+ ": "
+ "%(err_type)s: %(err_str)s" % {
"err_type": tp.__name__, "err_str": e})
+ f"{tp.__name__}: {e}") # type: ignore
have_valid_page = False
if have_valid_page:
page_desc = cast(FlowPageDesc, page_desc)
# Try to recover page_data, answer_data
page_data = get_sandbox_data_for_page(
pctx, page_desc, PAGE_DATA_SESSION_KEY)
pctx, page_desc, page_data_session_key)
answer_data = get_sandbox_data_for_page(
pctx, page_desc, ANSWER_DATA_SESSION_KEY)
pctx, page_desc, answer_data_session_key)
from course.models import FlowSession
from course.page import PageContext
......@@ -314,7 +348,7 @@ def view_page_sandbox(pctx):
if page_data is None:
page_data = page.initialize_page_data(page_context)
pctx.request.session[PAGE_DATA_SESSION_KEY] = (
pctx.request.session[page_data_session_key] = (
page_desc.type, page_desc.id, page_data)
title = page.title(page_context, page_data)
......@@ -343,7 +377,7 @@ def view_page_sandbox(pctx):
feedback = page.grade(page_context, page_data, answer_data,
grade_data=None)
pctx.request.session[ANSWER_DATA_SESSION_KEY] = (
pctx.request.session[answer_data_session_key] = (
page_desc.type, page_desc.id, answer_data)
else:
......@@ -351,24 +385,22 @@ def view_page_sandbox(pctx):
page_form = page.make_form(page_context, page_data,
answer_data, page_behavior)
except:
except Exception:
import sys
tp, e, _ = sys.exc_info()
page_errors = (
ugettext("Page failed to load/validate "
gettext("Page failed to load/validate "
"(change page ID to clear faults)")
+ ": "
+ "%(err_type)s: %(err_str)s" % {
"err_type": tp.__name__, "err_str": e})
have_valid_page = False
+ f"{tp.__name__}: {e}") # type: ignore
page_form = None
if page_form is not None:
page_form.helper.add_input(
Submit("submit",
ugettext("Submit answer"),
gettext("Submit answer"),
accesskey="g"))
page_form_html = page.form_to_html(
pctx.request, page_context, page_form, answer_data)
......@@ -377,12 +409,14 @@ def view_page_sandbox(pctx):
page_context, page_data, answer_data,
grade_data=None)
have_valid_page = have_valid_page and not page_errors
return render_course_page(pctx, "course/sandbox-page.html", {
"edit_form": edit_form,
"page_errors": page_errors,
"page_warnings": page_warnings,
"form": edit_form, # to placate form.media
"have_valid_page": True,
"have_valid_page": have_valid_page,
"title": title,
"body": body,
"page_form_html": page_form_html,
......@@ -395,7 +429,7 @@ def view_page_sandbox(pctx):
return render_course_page(pctx, "course/sandbox-page.html", {
"edit_form": edit_form,
"form": edit_form, # to placate form.media
"have_valid_page": False,
"have_valid_page": have_valid_page,
"page_errors": page_errors,
"page_warnings": page_warnings,
})
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division, absolute_import
__copyright__ = "Copyright (C) 2015 Andreas Kloeckner"
......@@ -25,11 +24,11 @@ THE SOFTWARE.
"""
from celery import shared_task
from django.db import transaction
from django.utils.translation import gettext as _
from django.utils.translation import ugettext as _
from course.models import (Course, FlowSession)
from course.content import get_course_repo
from course.models import Course, FlowPageVisit, FlowSession
@shared_task(bind=True)
......@@ -58,8 +57,8 @@ def expire_in_progress_sessions(self, course_id, flow_id, rule_tag, now_datetime
count += 1
self.update_state(
state='PROGRESS',
meta={'current': i, 'total': nsessions})
state="PROGRESS",
meta={"current": i, "total": nsessions})
repo.close()
......@@ -95,8 +94,8 @@ def finish_in_progress_sessions(self, course_id, flow_id, rule_tag, now_datetime
count += 1
self.update_state(
state='PROGRESS',
meta={'current': i, 'total': nsessions})
state="PROGRESS",
meta={"current": i, "total": nsessions})
repo.close()
......@@ -126,8 +125,8 @@ def recalculate_ended_sessions(self, course_id, flow_id, rule_tag):
count += 1
self.update_state(
state='PROGRESS',
meta={'current': count, 'total': nsessions})
state="PROGRESS",
meta={"current": count, "total": nsessions})
repo.close()
......@@ -160,12 +159,25 @@ def regrade_flow_sessions(self, course_id, flow_id, access_rules_tag, inprog_val
count += 1
self.update_state(
state='PROGRESS',
meta={'current': count, 'total': nsessions})
state="PROGRESS",
meta={"current": count, "total": nsessions})
repo.close()
return {"message": _("%d sessions regraded.") % count}
@shared_task(bind=True)
@transaction.atomic
def purge_page_view_data(self, course_id):
course = Course.objects.get(id=course_id)
_num_total, num_deleted_by_kind = FlowPageVisit.objects.filter(
flow_session__course=course,
answer__isnull=True).delete()
return {"message": _("%d page views purged.")
% num_deleted_by_kind.get("course.FlowPageVisit", 0)}
# vim: foldmethod=marker
{% extends "course/course-base.html" %}
{% extends "course/course-base-with-markup.html" %}
{% load i18n %}
{% block title %}
{% trans "Analytics" %} - {% trans "RELATE" %}
{% trans "Analytics" %} - {{ relate_site_name }}
{% endblock %}
{% block content %}
......@@ -57,7 +57,7 @@
{% else %}
<tt>{{ astats.group_id }}/{{ astats.page_id }}</tt>:
{% endif %}
{{ astats.title }}
{{ astats.title|safe }}
{% blocktrans trimmed with answer_count=astats.answer_count count counter=astats.answer_count %}
({{ answer_count }} non-empty response,
{% plural %}
......@@ -70,17 +70,17 @@
{% endblocktrans %}
<div class="progress">
<div class="progress-bar progress-bar-success" role="progressbar"
<div class="progress-bar bg-success" role="progressbar"
aria-valuenow="{{ astats.average_correctness_percent |floatformat:1 }}" aria-valuemin="0" aria-valuemax="100"
style="width: {{ astats.average_correctness_percent|stringformat:".9f" }}%">
<span class="stats-percentage">{{ astats.average_correctness_percent|floatformat:1 }}%</span>
</div>
<div class="progress-bar progress-bar-danger" role="progressbar"
<div class="progress-bar bg-danger" role="progressbar"
aria-valuenow="{{ astats.average_wrongness_percent|floatformat:1 }}" aria-valuemin="0" aria-valuemax="100"
style="width: {{ astats.average_wrongness_percent|stringformat:".9f" }}%">
<span class="stats-percentage">{{ astats.average_wrongness_percent|floatformat:1 }}%</span>
</div>
<div class="progress-bar progress-bar-warning" role="progressbar"
<div class="progress-bar bg-warning" role="progressbar"
aria-valuenow="{{ astats.average_emptiness_percent|floatformat:1 }}" aria-valuemin="0" aria-valuemax="100"
style="width: {{ astats.average_emptiness_percent|stringformat:".9f" }}%">
<span class="stats-percentage">{{ astats.average_emptiness_percent|floatformat:1 }}%</span>
......
......@@ -2,7 +2,7 @@
{% load i18n %}
{% block title %}
{% trans "Analytics" %} - {% trans "RELATE" %}
{% trans "Analytics" %} - {{ relate_site_name }}
{% endblock %}
{% block content %}
......
{% extends "course/course-base.html" %}
{% extends "course/course-base-with-markup.html" %}
{% load i18n %}
{% block title %}
{% trans "Analytics" %} - {% trans "RELATE" %}
{% trans "Analytics" %} - {{ relate_site_name }}
{% endblock %}
{% block content %}
<h1>{% trans "Analytics" %}: <tt>{{ flow_identifier}} - {{ group_id }}/{{ page_id }}</tt></h1>
<div class="well">
<div class="relate-well">
{{ body|safe }}
</div>
......@@ -48,13 +48,13 @@
<div class="progress">
<div class="progress-bar
{% if astats.correctness == 0 %}
progress-bar-danger
bg-danger
{% elif astats.correctness == 1 %}
progress-bar-success
bg-success
{% elif astats.correctness == None %}
progress-bar-info
bg-info
{% else %}
progress-bar-warning
bg-warning
{% endif %}"
role="progressbar"
aria-valuenow="{{ astats.percentage }}" aria-valuemin="0" aria-valuemax="100"
......
......@@ -10,12 +10,12 @@
<style type="text/css">
@media print {
.well { display: none; }
.relate-well { display: none; }
}
</style>
{% if form %}
<div class="well">
<div class="relate-well">
{% crispy form %}
</div>
{% endif %}
......
{% load i18n %}{% blocktrans with page_id=page_id course_identifier=course.identifier error_message=error_message|safe %}Hi there,
This message was sent from {{ site }} at {{ time }}.
Bad news! A code question with ID '{{ page_id }}' in '{{ course_identifier }}' has just failed while a user was trying to get his or her code graded.
Details of the problem are below:
{{ error_message }}
{% endblocktrans %}
- {% trans "RELATE" %}
{% if review_uri %}
{% trans "You can navigate to the following url to check the problem:" %}
----------------
{{ review_uri }}
----------------
{% endif %}
- {{ relate_site_name }}
{% extends "course/course-base.html" %}
{% extends "course/course-base-with-markup.html" %}
{% load i18n %}
{% load static %}
{% block title %}
{{ course.number}}
{% trans "Calendar" %} - {% trans "RELATE" %}
{% trans "Calendar" %} - {{ relate_site_name }}
{% endblock %}
{%block header_extra %}
<link rel='stylesheet' href='{% static "fullcalendar/dist/fullcalendar.css" %}' />
<script src='{% static "moment/moment.js" %}'></script>
<script src='{% static "fullcalendar/dist/fullcalendar.js" %}'></script>
{# load calendar with local language #}
{% get_current_language as LANGUAGE_CODE %}
<script src='{% static "fullcalendar/dist/lang/" %}{{ LANGUAGE_CODE }}.js'></script>
{% block bundle_loads %}
{{ block.super }}
<script src="{% static 'bundle-fullcalendar.js' %}"></script>
{% endblock %}
{% block content %}
......@@ -22,21 +17,14 @@
<div id="coursecal" style="margin-top:3em"></div>
<script type="text/javascript">
$(document).ready(function() {
$('#coursecal').fullCalendar({
header: {
left: 'prev,next today',
center: 'title',
right: 'month,agendaWeek,agendaDay'
},
defaultDate: '{{ default_date }}',
timezone: "local",
events: {{ events_json|safe }}
})
});
$(document).ready(function() {
rlFullCalendar.setupCalendar(
document.getElementById("coursecal"),
{{ events_json|safe }},
'{{ default_date }}',
'{{ fullcalendar_lang_code }}');
});
</script>
{% blocktrans trimmed %}
......
{% extends "course/course-base.html" %}
{% load static %}
{% block bundle_loads %}
{# contains mathjax config #}
<script src="{% static 'bundle-base-with-markup.js' %}"></script>
<script src="{% static "tex-svg.js" %}" id="MathJax-script" async></script>
{% endblock %}
......@@ -5,7 +5,7 @@
{{ course.number}}:
{{ course.time_period}}
-
{% trans "RELATE" %}
{{ relate_site_name }}
{% endblock %}
{% block brand %}
......@@ -25,154 +25,172 @@
{% block page_navbar %}
{% endblock %}
{% if not relate_exam_lockdown and pperm.view_calendar %}
<li class="dropdown">
{% comment %} Translators: The following are names for menu items in course page {% endcomment %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{% trans "Student" context "menu item" %}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% if course.course_xmpp_id and pperm.send_instant_message %}
<li><a href="{% url "relate-send_instant_message" course.identifier %}">{% trans "Send instant message" %}</a></li>
{% endif %}
<li><a href="{% url "relate-view_participant_grades" course.identifier %}">{% trans "View grades" %}</a></li>
{% if pperm.view_calendar %}
<li><a href="{% url "relate-view_calendar" course.identifier %}">{% trans "View calendar" %}</a></li>
{% endif %}
<li><a href="{% url "relate-course_page" course.identifier %}">{% trans "Back to course page" %}</a></li>
</ul>
</li>
{% if not relate_exam_lockdown %}
<li class="nav-item relate-dropdown-menu">
<a href="#" id="dropdownParticipantMenu" data-bs-toggle="dropdown" aria-expanded="false">
{% trans "Participant" context "menu item" %}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownParticipantMenu">
{% if course.course_xmpp_id and pperm.send_instant_message %}
<li><a class="dropdown-item" href="{% url "relate-send_instant_message" course.identifier %}">{% trans "Send instant message" %}</a></li>
{% endif %}
{% if not request.user.is_anonymous %}
<li><a class="dropdown-item" href="{% url "relate-view_participant_grades" course.identifier %}">{% trans "View grades" %}</a></li>
{% endif %}
{% if pperm.view_calendar %}
<li><a class="dropdown-item" href="{% url "relate-view_calendar" course.identifier %}">{% trans "View calendar" %}</a></li>
{% endif %}
<li><a class="dropdown-item" href="{% url "relate-course_page" course.identifier %}">{% trans "Back to course page" %}</a></li>
{% if pperm.manage_authentication_tokens %}
<li class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url "relate-manage_authentication_tokens" course.identifier %}">{% trans "Manage Authentication Tokens" %}</a></li>
{% endif %}
<li class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url "relate-access_exam" course.identifier %}">{% trans "Access an exam" %}</a></li>
</ul>
</li>
{% endif %}
{% if pperm.view_gradebook %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{% trans "Grading" context "menu item" %}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% if pperm.view_analytics %}
<li><a href="{% url "relate-flow_list" course.identifier %}">{% trans "Analytics Overview" %}</a></li>
{% endif %}
{% if pperm.view_gradebook %}
<li role="presentation" class="divider"></li>
<li role="presentation" class="dropdown-header">{% trans "Grade Book" %}</li>
<li><a href="{% url "relate-view_participant_list" course.identifier %}">{% trans "List of Participants" %}</a></li>
<li><a href="{% url "relate-view_grading_opportunity_list" course.identifier %}">{% trans "List of Grading Opportunities" %}</a></li>
<li><a href="{% url "relate-view_gradebook" course.identifier %}">{% trans "Grade book" %}</a></li>
{% if pperm.batch_export_grade %}
<li><a href="{% url "relate-export_gradebook_csv" course.identifier %}">{% trans "Grade book (CSV export)" %}</a></li>
<li class="nav-item relate-dropdown-menu">
<a href="#" id="dropdownGradingMenu" data-bs-toggle="dropdown" aria-expanded="false">
{% trans "Grading" context "menu item" %}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownGradingMenu">
{% if pperm.view_analytics %}
<li><a class="dropdown-item" href="{% url "relate-flow_list" course.identifier %}">{% trans "Analytics Overview" %}</a></li>
{% endif %}
{% endif %}
{% if pperm.batch_import_grade %}
<li><a href="{% url "relate-import_grades" course.identifier %}">{% trans "Import Grades" %}</a></li>
{% endif %}
<li role="presentation" class="divider"></li>
{% if pperm.batch_regrade_flow_session %}
<li><a href="{% url "relate-regrade_flows_view" course.identifier %}">{% trans "Regrade flows" %}</a></li>
{% endif %}
{% if pperm.grant_exception %}
<li role="presentation" class="divider"></li>
<li role="presentation" class="dropdown-header">{% trans "Exceptions" %}</li>
<li><a href="{% url "relate-grant_exception" course.identifier %}">{% trans "Grant exception" %}</a></li>
{% if user.is_staff %}
<li><a href="{% url "admin:course_flowruleexception_changelist" %}?participation__course__id__exact={{ course.id }}" target="_blank">{% trans "Show exceptions" %}</a></li>
{% if pperm.view_gradebook %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Grade Book" %}</h6></li>
<li><a class="dropdown-item" href="{% url "relate-view_participant_list" course.identifier %}">{% trans "List of Participants" %}</a></li>
<li><a class="dropdown-item" href="{% url "relate-view_grading_opportunity_list" course.identifier %}">{% trans "List of Grading Opportunities" %}</a></li>
<li><a class="dropdown-item" href="{% url "relate-view_gradebook" course.identifier %}">{% trans "Grade book" %}</a></li>
{% if pperm.batch_export_grade %}
<li><a class="dropdown-item" href="{% url "relate-export_gradebook_csv" course.identifier %}">{% trans "Grade book (CSV export)" %}</a></li>
{% endif %}
{% endif %}
{% if pperm.batch_import_grade %}
<li><a class="dropdown-item" href="{% url "relate-import_grades" course.identifier %}">{% trans "Import Grades" %}</a></li>
{% endif %}
{% if pperm.batch_regrade_flow_session %}
<li class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url "relate-regrade_flows_view" course.identifier %}">{% trans "Regrade flows" %}</a></li>
{% endif %}
{% endif %}
<li role="presentation" class="divider"></li>
<li role="presentation" class="dropdown-header">{% trans "Exams" %}</li>
{% if user.is_staff %}
<li><a href="{% url "admin:course_exam_changelist" %}?course__id__exact={{ course.id }}" target="_blank">{% trans "Edit exams" %}</a></li>
{% endif %}
{% if pperm.batch_issue_exam_tickets %}
<li><a href="{% url "relate-batch_issue_exam_tickets" course.identifier %}">{% trans "Batch-issue exam tickets" %}</a></li>
{% endif %}
{% if pperm.grant_exception %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Exceptions" %}</h6></li>
<li><a class="dropdown-item" href="{% url "relate-grant_exception" course.identifier %}">{% trans "Grant exception" %}</a></li>
{% if user.is_staff %}
<li><a class="dropdown-item" href="{% url "admin:course_flowruleexception_changelist" %}?participation__course__id__exact={{ course.id }}" target="_blank">{% trans "Show exceptions" %}</a></li>
{% endif %}
{% endif %}
</ul>
</li>
{% if user.is_staff or pperm.batch_issue_exam_ticket %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Exams" %}</h6></li>
{% if user.is_staff %}
<li><a class="dropdown-item" href="{% url "admin:course_exam_changelist" %}?course__id__exact={{ course.id }}" target="_blank">{% trans "Edit exams" %}</a></li>
{% endif %}
{% if pperm.batch_issue_exam_ticket %}
<li><a class="dropdown-item" href="{% url "relate-batch_issue_exam_tickets" course.identifier %}">{% trans "Batch-issue exam tickets" %}</a></li>
{% endif %}
{% endif %}
</ul>
</li>
{% endif %}
{% if pperm.preview_content or pperm.use_markup_sandbox or pperm.use_page_sandbox %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{% trans "Content" context "menu item" %}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% if pperm.preview_content or pperm.update_content %}
<li><a href="{% url "relate-update_course" course.identifier %}">{% trans "Retrieve/preview new course revisions" %}</a></li>
<li><a href="{% url "relate-manage_authentication_token" %}">{% trans "Manage Git Authentication Token" %}</a></li>
{% endif %}
<li class="nav-item relate-dropdown-menu">
<a href="#" id="dropdownContentMenu" data-bs-toggle="dropdown" aria-expanded="false">
{% trans "Content" context "menu item" %}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownContentMenu">
{% if pperm.preview_content or pperm.update_content %}
<li><a class="dropdown-item" href="{% url "relate-update_course" course.identifier %}">{% trans "Retrieve/preview new course revisions" %}</a></li>
{% endif %}
{% if pperm.edit_course %}
<li><a href="{% url "relate-edit_course" course.identifier %}">{% trans "Edit course" %}</a></li>
<li role="presentation" class="divider"></li>
{% endif %}
{% if pperm.edit_course %}
<li><a class="dropdown-item" href="{% url "relate-edit_course" course.identifier %}">{% trans "Edit course" %}</a></li>
{% endif %}
<li role="presentation" class="dropdown-header">{% trans "Content creation" %}</li>
{% if pperm.use_page_sandbox %}
<li><a href="{% url "relate-view_page_sandbox" course.identifier %}">{% trans "Page sandbox" %}</a></li>
{% endif %}
{% if pperm.use_markup_sandbox %}
<li><a href="{% url "relate-view_markup_sandbox" course.identifier %}">{% trans "Markup sandbox" %}</a></li>
{% endif %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Content creation" %}</h6></li>
{% if pperm.use_page_sandbox %}
<li><a class="dropdown-item" href="{% url "relate-view_page_sandbox" course.identifier %}">{% trans "Page sandbox" %}</a></li>
{% endif %}
{% if pperm.use_markup_sandbox %}
<li><a class="dropdown-item" href="{% url "relate-view_markup_sandbox" course.identifier %}">{% trans "Markup sandbox" %}</a></li>
{% endif %}
<li role="presentation" class="divider"></li>
{% if pperm.test_flow %}
<li><a href="{% url "relate-test_flow" course.identifier %}">{% trans "Test flow" %}</a></li>
{% endif %}
{% if pperm.test_flow %}
<li class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url "relate-test_flow" course.identifier %}">{% trans "Test flow" %}</a></li>
{% endif %}
<li role="presentation" class="divider"></li>
<li role="presentation" class="dropdown-header">{% trans "Calendar" %}</li>
{% if user.is_staff %}
<li><a href="{% url "admin:course_event_changelist" %}?course__id__exact={{ course.id }}" target="_blank">{% trans "Edit events" %}</a></li>
{% endif %}
{% if pperm.edit_events %}
<li><a href="{% url "relate-create_recurring_events" course.identifier %}">{% trans "Create recurring events" %}</a></li>
<li><a href="{% url "relate-renumber_events" course.identifier %}">{% trans "Renumber events" %}</a></li>
{% endif %}
{% if pperm.edit_events %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Calendar" %}</h6></li>
{% if user.is_staff %}
<li><a class="dropdown-item" href="{% url "admin:course_event_changelist" %}?course__id__exact={{ course.id }}" target="_blank">{% trans "Edit events" %}</a></li>
{% endif %}
<li><a class="dropdown-item" href="{% url "relate-create_recurring_events" course.identifier %}">{% trans "Create recurring events" %}</a></li>
<li><a class="dropdown-item" href="{% url "relate-renumber_events" course.identifier %}">{% trans "Renumber events" %}</a></li>
{% endif %}
</ul>
</li>
</ul>
</li>
{% endif %}
{% if pperm.query_participations or pperm.manage_instant_flow_requests or pperm.preapprove_participation %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{% trans "Instructor" context "menu item" %}<b class="caret"></b></a>
<ul class="dropdown-menu">
<li role="presentation" class="dropdown-header">{% trans "Participation" %}</li>
{% if pperm.preapprove_participation %}
<li><a href="{% url "relate-create_preapprovals" course.identifier %}">{% trans "Preapprove enrollments" context "menu item" %}</a></li>
{% endif %}
{% if pperm.query_participations %}
<li><a href="{% url "relate-query_participations" course.identifier %}">{% trans "Query participations" context "menu item" %}</a></li>
{% endif %}
{% if pperm.manage_instant_flow_requests %}
<li role="presentation" class="divider"></li>
<li role="presentation" class="dropdown-header">{% trans "Instant flow requests" %}</li>
<li><a href="{% url "relate-manage_instant_flow_requests" course.identifier %}">{% trans "Manage instant flow requests" %}</a></li>
{% endif %}
</ul>
</li>
{% if pperm.query_participation or pperm.manage_instant_flow_requests or pperm.preapprove_participation %}
{% if not pperm.view_participant_masked_profile %}
<li class="nav-item relate-dropdown-menu">
<a href="#" id="dropdownInstructorMenu" data-bs-toggle="dropdown" aria-expanded="false">
{% trans "Instructor" context "menu item" %}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownInstructorMenu">
<li><h6 class="dropdown-header">{% trans "Participation" %}</h6></li>
{% if pperm.preapprove_participation %}
<li><a class="dropdown-item" href="{% url "relate-create_preapprovals" course.identifier %}">{% trans "Preapprove enrollments" context "menu item" %}</a></li>
{% endif %}
{% if pperm.query_participation %}
<li><a class="dropdown-item" href="{% url "relate-query_participations" course.identifier %}">{% trans "Query participations" context "menu item" %}</a></li>
{% endif %}
{% if pperm.manage_instant_flow_requests %}
<li class="dropdown-divider"></li>
<li><h6 class="dropdown-header">{% trans "Instant flow requests" %}</h6></li>
<li><a class="dropdown-item" href="{% url "relate-manage_instant_flow_requests" course.identifier %}">{% trans "Manage instant flow requests" %}</a></li>
{% endif %}
</ul>
</li>
{% endif %}
{% endif %}
{% endblock %}
{% block preview_status %}
{% if participation.preview_git_commit_sha %}
<li>
<a style="font-weight: bold" href="{% url "relate-update_course" course.identifier %}"
role="button">[{% trans "Preview" %}]</a>
</li>
<a class="link-secondary" href="{% url "relate-update_course" course.identifier %}">[{% trans "Preview" %}]</a>
{% endif %}
{% endblock %}
{% block nav_recommendations %}
{% if instant_flow_requests %}
<div class="alert alert-info">
<i class='fa fa-info-circle'></i>
<i class='bi bi-info-circle'></i>
{% if num_instant_flow_requests == 1 %}
{% trans "There is an interactive activity going on right now." %}
{% for i, req in instant_flow_requests %}
<a class="btn btn-primary"
href="{% url "relate-view_start_flow" course.identifier req.flow_id %}"
role="button"><b>{% trans "Go to activity" %} &raquo;</b></a>
{% if pperm.view_analytics %}
<a class="btn btn-primary"
href="{% url "relate-flow_analytics" course.identifier req.flow_id %}"
role="button"><b>{% trans "View activity analytics" %} &raquo;</b></a>
{% endif %}
{% endfor %}
{% else %}
{% trans "There are some interactive activities going on right now." %}
......@@ -180,6 +198,11 @@
<a class="btn btn-primary"
href="{% url "relate-view_start_flow" course.identifier req.flow_id %}"
role="button"><b>{% trans "Go to activity" %} {{ i }} &raquo;</b></a>
{% if pperm.view_analytics %}
<a class="btn btn-primary"
href="{% url "relate-flow_analytics" course.identifier req.flow_id %}"
role="button"><b>{% trans "View activity" %} {{ i }} {% trans "analytics" %} &raquo;</b></a>
{% endif %}
{% endfor %}
{% endif %}
</div>
......
......@@ -6,7 +6,7 @@
<pre>{{ log }}</pre>
{% if was_successful %}
<div class="alert alert-success">
<i class="fa fa-hand-scissors-o"></i>
<i class="bi bi-check"></i>
{{ status|safe }}
</div>
{% else %}
......
{% extends "course/course-base.html" %}
{% load static %}
{% block bundle_loads %}
<script src="{% static 'bundle-datatables.js' %}"></script>
{% endblock %}
......@@ -4,25 +4,25 @@
{% block page_content_advisories %}
{% if course.hidden %}
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
<i class="bi bi-info-circle"></i>
{% trans "This course is only visible to course staff at the moment." %}
<a class="btn btn-default" href="{% url "relate-edit_course" course.identifier %}"
role="button" target="_blank">{% trans "Change" context "change the visibility of a course" %}&nbsp;&raquo;</a>
<a class="btn btn-outline-secondary" href="{% url "relate-edit_course" course.identifier %}"
role="button">{% trans "Change" context "change the visibility of a course" %}&nbsp;&raquo;</a>
</div>
{% endif %}
{% if not user.is_authenticated %}
<div class="well">
<div class="relate-well">
{% trans "You're not currently signed in." %}
<a class="btn-lg btn-primary" href="{% url student_sign_in_view %}?next={{ request.path }}"
<a class="btn btn-lg btn-primary" href="{% url student_sign_in_view %}?next={{ request.path }}"
role="button">{% trans "Sign in" %}&nbsp;&raquo;</a>
</div>
{% else %}
{% if show_enroll_button %}
<div class="well">
<div class="relate-well">
<form method="POST" action="{% url "relate-enroll" course.identifier %}">
{% csrf_token %}
<button type="submit" name="enroll" class="btn-lg btn-primary">
<button type="submit" name="enroll" class="btn btn-lg btn-primary">
{% trans "Enroll" %}&nbsp;&raquo;
</button>
</form>
......
......@@ -3,78 +3,33 @@
<script type="text/javascript">
{# responsive for small screen #}
if (window.matchMedia("(max-width: 768px)").matches) {
$(".relate-interaction-container").find(".form-control").removeAttr("style");
}
$('[use-popover="true"]').each(function(){
// enable popovers
$(this).popover({
trigger: "focus",
placement: "top",
html: true,
title: $(this).attr("popover-title"),
content: $(this).attr("popover-content")
})
// render TeX in popovers
.on("shown.bs.popover", function () {
$(".popover-content").each(function() {
MathJax.Hub.Queue(["Typeset", MathJax.Hub, this]);
});
});
});
// {{{ move form error messages into tooltips
$('span[id^="error_1"]').each(function(){
var all_error_block = $(this).siblings('.help-block').andSelf();
all_error_block.hide();
if(all_error_block.length > 1){
var error_message = "";
$(all_error_block).each(function(){
error_message += "<li align='left'>" + $(this).html() + "</li>";
});
error_message = "<ul>" + error_message + "</ul>";
}
else{
error_message = $(all_error_block[0]).html();
};
$(this).siblings('[use-popover]')
.tooltip({
placement : 'bottom',
title : error_message,
html: true
});
});
$('.form-inline').ready(function(){
MathJax.Hub.Register.StartupHook("End", function() {
$('span[id^="error_1"]').each(function(){
$(this).siblings('[use-popover]').tooltip("show");
$('.tooltip-inner')
.css("background-color", "#a94442");
$('.tooltip-arrow')
.css("border-bottom-color", "#a94442");
});
const popover = bootstrap.Popover.getOrCreateInstance(this, {
trigger: "focus",
placement: "top",
html: true,
title: $(this).attr("popover-title"),
content: $(this).attr("popover-content")}
);
{# render TeX in popovers #}
this.addEventListener("shown.bs.popover", function () {
MathJax.typeset([popover.tip]);
});
});
// }}}
$("[correctness='1']").addClass("is-valid");
$("[correctness='0']").addClass("is-invalid");
$('[correctness="1"]').parent("div")
.addClass("has-success")
.addClass("has-feedback")
.append('<span class="glyphicon glyphicon-ok \
form-control-feedback" aria-hidden="true">\
</span>');
{# Remove feedback icons/styles when answer is edited #}
$(".relate-interaction-container> * :input.is-valid,:input.is-invalid")
.on("change paste", function () {
$(this).removeClass("is-valid is-invalid");
});
$('[correctness="0"]').parent("div")
.addClass("has-error")
.addClass("has-feedback")
.append('<span class="glyphicon glyphicon-remove \
form-control-feedback" aria-hidden="true">\
</span>');
</script>
{# This file is modified from crispy_bootstrap5/templates/bootstrap5/layout/prepended_appended_text.html #}
{% load crispy_forms_field %}
{% if field.is_hidden %}
{{ field }}
{% else %}
{# class="mb-3" is changed to class="mb-0" #}
<div id="div_{{ field.auto_id }}" class="mb-0{% if wrapper_class %} {{ wrapper_class }}{% endif %}{% if 'form-horizontal' in form_class %} row{% endif %}{% if form_group_wrapper_class %} {{ form_group_wrapper_class }}{% endif %}{% if form_show_errors and field.errors %} has-danger{% endif %}{% if field.css_classes %} {{ field.css_classes }}{% endif %}">
{% if field.label and form_show_labels %}
<label for="{{ field.id_for_label }}" class="{% if 'form-horizontal' in form_class %}col-form-label {% else %}form-label{% endif %}{{ label_class }}{% if field.field.required %} requiredField{% endif %}">
{{ field.label }}{% if field.field.required %}<span class="asteriskField">*</span>{% endif %}
</label>
{% endif %}
<div {% if field_class %}class="{{ field_class }}"{% endif %}>
<div class="input-group{% if input_size %} {{ input_size }}{% endif %}">
{# prepend #}
{% if crispy_prepended_text %}
<span class="input-group-text">{{ crispy_prepended_text }}</span>
{% endif %}
{# input #}
{% if field|is_select %}
{% if field.errors %}
{% crispy_field field 'class' 'form-select is-invalid' %}
{% else %}
{% crispy_field field 'class' 'form-select' %}
{% endif %}
{% elif field.errors %}
{% crispy_field field 'class' 'form-control is-invalid' %}
{% else %}
{% crispy_field field 'class' 'form-control' %}
{% endif %}
{# append #}
{% if crispy_appended_text %}
<span class="input-group-text">{{ crispy_appended_text }}</span>
{% endif %}
{% if error_text_inline %}
{% include 'bootstrap5/layout/field_errors.html' %}
{% else %}
{% include 'bootstrap5/layout/field_errors_block.html' %}
{% endif %}
</div>
{% if not help_text_inline %}
{% include 'bootstrap5/layout/help_text.html' %}
{% endif %}
</div>
</div>
{% endif %}