Skip to content

Commit

Permalink
feat: add context to options form
Browse files Browse the repository at this point in the history
- ref: questionpy-org/moodle-qtype_questionpy#38
- removes as much logic from the jinja forms as possible
- allows {qpy:repno} to be replaced with the current repetition number
  • Loading branch information
alexanderschmitz committed Dec 11, 2023
1 parent 05c3e93 commit 0007bf5
Show file tree
Hide file tree
Showing 13 changed files with 369 additions and 119 deletions.
7 changes: 3 additions & 4 deletions questionpy_sdk/webserver/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from questionpy_server.worker.exception import WorkerUnknownError
from questionpy_server.worker.worker.thread import ThreadWorker

from questionpy_sdk.webserver.context import contextualize
from questionpy_sdk.webserver.state_storage import QuestionStateStorage, add_repetition, parse_form_data

routes = web.RouteTableDef()
Expand Down Expand Up @@ -59,8 +60,7 @@ async def render_options(request: web.Request) -> web.Response:

context = {
'manifest': manifest,
'options': form_definition.model_dump(),
'form_data': form_data
'options': contextualize(form_definition=form_definition, form_data=form_data).model_dump()
}

return aiohttp_jinja2.render_template('options.html.jinja2', request, context)
Expand Down Expand Up @@ -116,8 +116,7 @@ async def repeat_element(request: web.Request) -> web.Response:

context = {
'manifest': manifest,
'options': form_definition.model_dump(),
'form_data': form_data
'options': contextualize(form_definition=form_definition, form_data=form_data).model_dump()
}

return aiohttp_jinja2.render_template('options.html.jinja2', request, context)
92 changes: 92 additions & 0 deletions questionpy_sdk/webserver/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

from typing import List, Any, Optional, Dict

from questionpy_common.elements import StaticTextElement, CheckboxElement, TextInputElement, FormElement, \
GroupElement, RepetitionElement, OptionsFormDefinition, CheckboxGroupElement, RadioGroupElement, SelectElement, \
HiddenElement

from questionpy_sdk.webserver.elements import CxdOptionsFormDefinition, CxdFormSection, CxdFormElement, \
CxdGroupElement, CxdRepetitionElement, CxdStaticTextElement, CxdTextInputElement, \
CxdCheckboxElement, CxdCheckboxGroupElement, CxdRadioGroupElement, CxdSelectElement, CxdHiddenElement

element_mapping: Dict[type, type] = {
StaticTextElement: CxdStaticTextElement,
TextInputElement: CxdTextInputElement,
CheckboxElement: CxdCheckboxElement,
CheckboxGroupElement: CxdCheckboxGroupElement,
RadioGroupElement: CxdRadioGroupElement,
SelectElement: CxdSelectElement,
HiddenElement: CxdHiddenElement
}


def _contextualize_element(element: FormElement, form_data: Optional[dict[str, Any]], path: List[str],
context: Optional[dict] = None) -> CxdFormElement:
path.append(element.name)
element_form_data = None
if form_data:
element_form_data = form_data.get(element.name)

if isinstance(element, GroupElement):
cxd_gr_element = CxdGroupElement(path=path.copy(), **element.model_dump(exclude={'elements'}))
cxd_gr_element.cxd_elements = _contextualize_element_list(element.elements, element_form_data, path)
path.pop()
return cxd_gr_element

if isinstance(element, RepetitionElement):
cxd_rep_element = CxdRepetitionElement(path=path.copy(), **element.model_dump(exclude={'elements'}))
if not element_form_data:
for i in range(element.initial_repetitions):
path.append(str(i))
element_list = _contextualize_element_list(element.elements, None, path, {'repno': i})
cxd_rep_element.cxd_elements.append(element_list)
path.pop()
path.pop()
return cxd_rep_element
for i, repetition in enumerate(element_form_data):
path.append(str(i))
element_list = _contextualize_element_list(element.elements, repetition, path, {'repno': i})
cxd_rep_element.cxd_elements.append(element_list)
path.pop()
path.pop()
return cxd_rep_element

if element.__class__ not in element_mapping:
raise ValueError(f"No corresponding CxdFormElement found for {element.__class__}")

cxd_element_class = element_mapping[element.__class__]
cxd_element = cxd_element_class(**element.model_dump(), path=path)
if context:
cxd_element.contextualize(context.get('repno'))
cxd_element.add_form_data_value(element_form_data)

path.pop()
return cxd_element


def _contextualize_element_list(element_list: List[FormElement], form_data: Optional[dict[str, Any]],
path: list[str], context: Optional[dict] = None) -> List[CxdFormElement]:
cxd_element_list: List[CxdFormElement] = []
for element in element_list:
cxd_element = _contextualize_element(element, form_data, path, context)
cxd_element_list.append(cxd_element)
return cxd_element_list


def contextualize(form_definition: OptionsFormDefinition, form_data: Optional[dict[str, Any]]) \
-> CxdOptionsFormDefinition:
path: list[str] = ['general']
cxd_options_form = CxdOptionsFormDefinition()
cxd_options_form.general = _contextualize_element_list(form_definition.general, form_data, path)
for section in form_definition.sections:
path = [section.name]
cxd_section = CxdFormSection(name=section.name, header=section.header, path=path)
section_data = None
if form_data:
section_data = form_data.get(section.name)
cxd_section.cxd_elements = _contextualize_element_list(section.elements, section_data, path)
cxd_options_form.sections.append(cxd_section)
return cxd_options_form
180 changes: 180 additions & 0 deletions questionpy_sdk/webserver/elements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>

import re
from typing import List, Union, Annotated, Any, Optional
from typing_extensions import TypeAlias
from pydantic import Field, BaseModel

from questionpy_common.elements import StaticTextElement, GroupElement, HiddenElement, RepetitionElement, \
SelectElement, RadioGroupElement, Option, CheckboxGroupElement, CheckboxElement, TextInputElement, FormSection
# pylint: disable=unused-import
from questionpy_common.elements import FormElement # noqa: F401


class _CxdFormElement(BaseModel):
path: list[str]
id: str = ''

def contextualize(self, text: str) -> None:
pass

def add_form_data_value(self, element_form_data: Any) -> None:
pass


class CxdTextInputElement(TextInputElement, _CxdFormElement):
value: Optional[str] = None

def contextualize(self, text: str) -> None:
self.label = re.sub(r'\{ qpy:repno }', f'{text}', self.label)
self.default = re.sub(r'\{ qpy:repno }', f'{text}', self.default)
self.placeholder = re.sub(r'\{ qpy:repno }', f'{text}', self.placeholder)

def add_form_data_value(self, element_form_data: Any) -> None:
if element_form_data:
self.value = element_form_data


class CxdStaticTextElement(StaticTextElement, _CxdFormElement):

def contextualize(self, text: str) -> None:
self.label = re.sub(r'\{ qpy:repno }', f'{text}', self.label)
self.text = re.sub(r'\{ qpy:repno }', f'{text}', self.text)


class CxdCheckboxElement(CheckboxElement, _CxdFormElement):
def contextualize(self, text: str) -> None:
self.left_label = re.sub(r'\{ qpy:repno }', f'{text}', self.left_label)
self.right_label = re.sub(r'\{ qpy:repno }', f'{text}', self.right_label)

def add_form_data_value(self, element_form_data: Any) -> None:
if element_form_data:
self.selected = element_form_data


class CxdCheckboxGroupElement(CheckboxGroupElement, _CxdFormElement):
cxd_checkboxes: List[CxdCheckboxElement] = []

def __init__(self, **data: Any):
super().__init__(**data)
for checkbox in self.checkboxes:
path = data.get('path')
if not isinstance(path, list):
raise TypeError(f"Path should be of type list but is {type(path)}")
path.append(checkbox.name)
self.cxd_checkboxes.append(CxdCheckboxElement(**checkbox.model_dump(), path=path))
path.pop()
self.checkboxes = []

def contextualize(self, text: str) -> None:
for cxd_checkbox in self.cxd_checkboxes:
cxd_checkbox.contextualize(text)

def add_form_data_value(self, element_form_data: Any) -> None:
if not element_form_data:
return
selected_checkboxes = [chk for chk in self.cxd_checkboxes if chk.name in element_form_data]

for checkbox in self.cxd_checkboxes:
checkbox.selected = checkbox in selected_checkboxes


class CxdOption(Option, _CxdFormElement):
def contextualize(self, text: str) -> None:
self.label = re.sub(r'\{ qpy:repno }', f'{text}', self.label)


class CxdRadioGroupElement(RadioGroupElement, _CxdFormElement):
cxd_options: List[CxdOption] = []

def __init__(self, **data: Any):
super().__init__(**data)
for option in self.options:
path = data.get('path')
if not isinstance(path, list):
raise TypeError(f"Path should be of type list but is {type(path)}")
path.append(option.value)
self.cxd_options.append(CxdOption(**option.model_dump(), path=path))
path.pop()
self.options = []

def contextualize(self, text: str) -> None:
self.label = re.sub(r'\{ qpy:repno }', f'{text}', self.label)
for cxd_option in self.cxd_options:
cxd_option.contextualize(text)

def add_form_data_value(self, element_form_data: Any) -> None:
if not element_form_data:
return

for option in self.cxd_options:
option.selected = option.value == element_form_data


class CxdSelectElement(SelectElement, _CxdFormElement):
cxd_options: List[CxdOption] = []

def __init__(self, **data: Any):
super().__init__(**data)
for option in self.options:
path = data.get('path')
if not isinstance(path, list):
raise TypeError(f"Path should be of type list but is {type(path)}")
path.append(option.value)
self.cxd_options.append(CxdOption(**option.model_dump(), path=path))
path.pop()
self.options = []

def contextualize(self, text: str) -> None:
self.label = re.sub(r'\{ qpy:repno }', f'{text}', self.label)

def add_form_data_value(self, element_form_data: Any) -> None:
if not element_form_data:
return
selected_options = [opt for opt in self.cxd_options if opt.value in element_form_data]

for option in self.cxd_options:
option.selected = option in selected_options


class CxdHiddenElement(HiddenElement, _CxdFormElement):
def contextualize(self, text: str) -> None:
pass

def add_form_data_value(self, element_form_data: Any) -> None:
if element_form_data:
self.value = element_form_data


class CxdGroupElement(GroupElement, _CxdFormElement):
cxd_elements: List["CxdFormElement"] = []

def __init__(self, **data: Any):
super().__init__(**data, elements=[])


class CxdRepetitionElement(RepetitionElement, _CxdFormElement):
cxd_elements: list[list["CxdFormElement"]] = []

def __init__(self, **data: Any):
super().__init__(**data, elements=[])


CxdFormElement: TypeAlias = Annotated[Union[
CxdStaticTextElement, CxdTextInputElement, CxdCheckboxElement, CxdCheckboxGroupElement,
CxdRadioGroupElement, CxdSelectElement, CxdHiddenElement, CxdGroupElement, CxdRepetitionElement
], Field(discriminator="kind")]


class CxdFormSection(FormSection):
cxd_elements: List[CxdFormElement] = []
"""Elements contained in the section."""


class CxdOptionsFormDefinition(BaseModel):
general: List[CxdFormElement] = []
"""Elements to add to the main section, after the LMS' own elements."""
sections: List[CxdFormSection] = []
"""Sections to add after the main section."""
28 changes: 14 additions & 14 deletions questionpy_sdk/webserver/templates/elements/checkbox.html.jinja2
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
{% extends "elements/element.html.jinja2" %}

{% block info %}
{% if element_options.left_label %}
<p class="element-label">{{ element_options.left_label }}{{ '*' if element_options.required }}</p>
{% if element.left_label %}
<p class="element-label">{{ element.left_label }}{{ '*' if element.required }}</p>
{% endif %}
{% if element_options.help %}
{% if element_options.left_label %}
{{ macros.create_icon_container(element_options.left_label, element_options.help) }}
{% elif element_options.right_label %}
{{ macros.create_icon_container(element_options.right_label, element_options.help) }}
{% if element.help %}
{% if element.left_label %}
{{ macros.create_icon_container(element.left_label, element.help) }}
{% elif element.right_label %}
{{ macros.create_icon_container(element.right_label, element.help) }}
{% endif %}
{% endif %}
{% endblock %}

{% block content %}
<label class="answer-label">
<input class="answer-checkbox" type="checkbox" id="{{ section_name }}_{{ element_options.name }}"
<input class="answer-checkbox" type="checkbox" id="{{ element.id }}"
name="{{ element_reference }}"
{{ {'data-absolute_path': absolute_path|tojson|forceescape}|xmlattr }}
{{ 'required' if element_options.required }}
{{ 'checked' if element_options.selected and not form_data
or form_data is defined and form_data}}>
{% if element_options.right_label %}
{{ element_options.right_label }}{{ '*' if element_options.required }}
{{ {'data-absolute_path': element.path|tojson|forceescape}|xmlattr }}
{{ 'required' if element.required }}
{{ 'checked' if element.selected and not element.value
or element.value is defined and element.value}}>
{% if element.right_label %}
{{ element.right_label }}{{ '*' if element.required }}
{% endif %}
</label>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

{% block element %}
{# TODO: see https://moodledev.io/docs/apis/subsystems/form/advanced/checkbox-controller #}
<div class="element {{ element_options.kind }}"
{{ {'data-hide_if': element_options.hide_if|tojson|forceescape}|xmlattr if element_options.hide_if}}
{{ {'data-disable_if': element_options.disable_if|tojson|forceescape}|xmlattr if element_options.disable_if}}>
{% for checkbox_element in element_options.checkboxes %}
<div class="element {{ element.definition.kind }}"
{{ {'data-hide_if': element.definition.hide_if|tojson|forceescape}|xmlattr if element.definition.hide_if}}
{{ {'data-disable_if': element.definition.disable_if|tojson|forceescape}|xmlattr if element.definition.disable_if}}>
{% for checkbox_element in element.definition.checkboxes %}

<div class="info">
{% if checkbox_element.left_label %}
Expand All @@ -15,10 +15,10 @@

<div class="answer-option">
<label class="answer-label">
<input class="answer-checkbox" type="checkbox" id="{{ checkbox_element.name }}"
{{ 'required' if element_options.required }}
{{ 'checked' if element_options.selected and not form_data
or form_data is defined and form_data}}>
<input class="answer-checkbox" type="checkbox" id="{{ checkbox_element.id }}"
{{ 'required' if checkbox_element.required }}
{{ 'checked' if checkbox_element.selected and not element.value
or element.value is defined and element.value}}>
{% if checkbox_element.right_label %}
{{ checkbox_element.right_label }}
{% endif %}
Expand Down
Loading

0 comments on commit 0007bf5

Please sign in to comment.