From 2db2c6b3475cc93037cbf1c4ea1e32debe8b4dba Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 17 Dec 2024 11:46:57 +0100 Subject: [PATCH 1/4] fix: PlaceholderReferenceError with no available placeholders --- questionpy_sdk/webserver/question_ui/errors.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/questionpy_sdk/webserver/question_ui/errors.py b/questionpy_sdk/webserver/question_ui/errors.py index 6e0f296..330b988 100644 --- a/questionpy_sdk/webserver/question_ui/errors.py +++ b/questionpy_sdk/webserver/question_ui/errors.py @@ -16,6 +16,9 @@ def _format_human_readable_list(values: Collection[str], opening: str, closing: str) -> str: + if not values: + return "" + *values, last_value = values last_value = f"{opening}{last_value}{closing}" if not values: @@ -150,16 +153,18 @@ class PlaceholderReferenceError(RenderElementError): """An unknown or no placeholder was referenced.""" def __init__(self, element: etree._Element, placeholder: str | None, available: Collection[str]): + template_kwargs: dict[str, str | Collection[str]] = {} if placeholder is None: template = "No placeholder was referenced." - template_kwargs = {} else: + template = "Referenced placeholder {placeholder} was not found." + template_kwargs["placeholder"] = placeholder + if len(available) == 0: - provided = "No placeholders were provided." + template += " No placeholders were provided." else: - provided = "These are the provided placeholders: {available}." - template = f"Referenced placeholder {{placeholder}} was not found. {provided}" - template_kwargs = {"placeholder": placeholder, "available": available} + template += " These are the provided placeholders: {available}." + template_kwargs["available"] = available super().__init__( element=element, From 1c388fa170b2b1a2015b797310a3430af90af0af Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 19 Dec 2024 19:04:17 +0100 Subject: [PATCH 2/4] feat: add generated_id dsl element See: #140 --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- questionpy/_util.py | 1 + questionpy/form/__init__.py | 4 ++++ questionpy/form/_dsl.py | 18 ++++++++++++++++++ tests/questionpy/form/test_dsl.py | 10 ++++++++++ 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index e72d598..d4a100c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1389,7 +1389,7 @@ files = [ [[package]] name = "questionpy-server" -version = "0.3.0" +version = "0.4.0" description = "QuestionPy application server" optional = false python-versions = "^3.11" @@ -1409,8 +1409,8 @@ watchdog = "^4.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "3ae1fcc79b1c9440d40cbe5b48d16ac4c68061b9" -resolved_reference = "3ae1fcc79b1c9440d40cbe5b48d16ac4c68061b9" +reference = "15db8d8cf59d364e21a226fdf477869c9894bdf9" +resolved_reference = "15db8d8cf59d364e21a226fdf477869c9894bdf9" [[package]] name = "ruff" @@ -1770,4 +1770,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "535aa62ce190f0383bd6ee8fd31e5a07954c3142a81ba23bffbfad0a0054f50a" +content-hash = "e9f3f3977d716d2885d40e0eaaee9b6fe300237c74e05e30102e847ecfafc9f5" diff --git a/pyproject.toml b/pyproject.toml index ea7a159..d1f6077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "3ae1fcc79b1c9440d40cbe5b48d16ac4c68061b9" } +questionpy-server = {git = "https://github.com/questionpy-org/questionpy-server.git", rev = "15db8d8cf59d364e21a226fdf477869c9894bdf9"} jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = "~5.1.0" diff --git a/questionpy/_util.py b/questionpy/_util.py index 2151ce7..77e3bca 100644 --- a/questionpy/_util.py +++ b/questionpy/_util.py @@ -5,6 +5,7 @@ def get_mro_type_hint(klass: type, attr_name: str, bound: _T) -> _T: + # FIXME: This function is called too early, when the classes referenced in forward refs may not be defined yet. for superclass in klass.mro(): hints = get_type_hints(superclass) if attr_name in hints: diff --git a/questionpy/form/__init__.py b/questionpy/form/__init__.py index 3aab020..8304631 100644 --- a/questionpy/form/__init__.py +++ b/questionpy/form/__init__.py @@ -46,6 +46,7 @@ CheckboxGroupElement, FormElement, FormSection, + GeneratedIdElement, GroupElement, HiddenElement, Option, @@ -63,6 +64,7 @@ checkbox, does_not_equal, equals, + generated_id, group, hidden, is_checked, @@ -86,6 +88,7 @@ "FormElement", "FormModel", "FormSection", + "GeneratedIdElement", "GroupElement", "HiddenElement", "Option", @@ -100,6 +103,7 @@ "checkbox", "does_not_equal", "equals", + "generated_id", "group", "hidden", "is_checked", diff --git a/questionpy/form/_dsl.py b/questionpy/form/_dsl.py index 252a877..2a8d349 100644 --- a/questionpy/form/_dsl.py +++ b/questionpy/form/_dsl.py @@ -9,6 +9,7 @@ from questionpy_common.conditions import Condition, DoesNotEqual, Equals, In, IsChecked, IsNotChecked from questionpy_common.elements import ( CheckboxElement, + GeneratedIdElement, GroupElement, HiddenElement, Option, @@ -732,6 +733,9 @@ def repeat( ) -> list[_F]: """Repeats a sub-model, allowing the user to add new repetitions with the click of a button. + Be aware that the index of repetitions may change when earlier ones are removed. In order to distinguish + repetitions, look into adding a [generated_id][] field to the repeated model. + Args: model (type[FormModel]): A `FormModel` subclass containing the fields to repeat. initial: Number of repetitions to show when the form is first loaded. @@ -772,6 +776,20 @@ def repeat( ) +def generated_id() -> str: + """Generates a unique ID which won't change across form saves. + + This is especially useful to distinguish repetitions without relying on their index, which may change when + repetitions are removed. + """ + return cast( + str, + _FieldInfo( + type=str, build=lambda name: GeneratedIdElement(name=name), pydantic_field_info=FieldInfo(frozen=True) + ), + ) + + def is_checked(name: str) -> IsChecked: """Condition on a checkbox being checked. diff --git a/tests/questionpy/form/test_dsl.py b/tests/questionpy/form/test_dsl.py index 43214c8..23e7ebe 100644 --- a/tests/questionpy/form/test_dsl.py +++ b/tests/questionpy/form/test_dsl.py @@ -139,6 +139,7 @@ def test_should_raise_validation_error_when_required_option_is_missing() -> None ) ], ), + (form.generated_id(), [form.GeneratedIdElement(name="field")]), ], ) def test_should_render_correct_form(initializer: object, expected_elements: list[form.FormElement]) -> None: @@ -198,6 +199,8 @@ class TheModel(form.FormModel): (str, form.hidden("value"), "value", "value"), (Literal["value"], form.hidden("value"), "value", "value"), (Optional[Literal["value"]], form.hidden("value", disable_if=form.is_checked("field")), ..., None), # noqa: UP007 + # generated_id + (str, form.generated_id(), "f65a9e2f-5fba-4170-93c0-7f37552d891d", "f65a9e2f-5fba-4170-93c0-7f37552d891d"), # group (SimpleFormModel, form.group("", SimpleFormModel), {"input": "abc"}, SimpleFormModel(input="abc")), # repetition @@ -247,6 +250,10 @@ class TheModel(form.FormModel): # hidden (Literal["value"], form.hidden("value"), "something else"), (str, form.hidden("value"), ...), + # generated_id + (str, form.generated_id(), ...), + (str, form.generated_id(), None), + (str, form.generated_id(), 42), # group (SimpleFormModel, form.group("", SimpleFormModel), ...), # repetition @@ -290,6 +297,9 @@ class TheModel(form.FormModel): (MyOptionEnum, form.select("", MyOptionEnum, multiple=True)), # hidden (str | None, form.hidden("value")), + # generated_id + (int, form.generated_id()), + (str | None, form.generated_id()), # group (dict, form.group("", SimpleFormModel)), # repetition From 61d0b32cbc85ab30fb78fecf2e6d137cc6975949 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 19 Dec 2024 19:06:10 +0100 Subject: [PATCH 3/4] feat(webserver): render GeneratedIdElements See: #140 --- questionpy_sdk/webserver/context.py | 3 +++ questionpy_sdk/webserver/elements.py | 15 +++++++++++++- questionpy_sdk/webserver/routes/options.py | 20 ++++++++++++++++--- .../templates/elements/id.html.jinja2 | 4 ++++ 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 questionpy_sdk/webserver/templates/elements/id.html.jinja2 diff --git a/questionpy_sdk/webserver/context.py b/questionpy_sdk/webserver/context.py index 2af9363..336865d 100644 --- a/questionpy_sdk/webserver/context.py +++ b/questionpy_sdk/webserver/context.py @@ -8,6 +8,7 @@ CheckboxElement, CheckboxGroupElement, FormElement, + GeneratedIdElement, GroupElement, HiddenElement, OptionsFormDefinition, @@ -23,6 +24,7 @@ CxdCheckboxGroupElement, CxdFormElement, CxdFormSection, + CxdGeneratedIdElement, CxdGroupElement, CxdHiddenElement, CxdOptionsFormDefinition, @@ -43,6 +45,7 @@ RadioGroupElement: CxdRadioGroupElement, SelectElement: CxdSelectElement, HiddenElement: CxdHiddenElement, + GeneratedIdElement: CxdGeneratedIdElement, } diff --git a/questionpy_sdk/webserver/elements.py b/questionpy_sdk/webserver/elements.py index 8bc4cd4..d0abd89 100644 --- a/questionpy_sdk/webserver/elements.py +++ b/questionpy_sdk/webserver/elements.py @@ -4,6 +4,7 @@ from re import Pattern, sub from typing import Annotated, Any, TypeAlias +from uuid import uuid4 from pydantic import BaseModel, Field, computed_field @@ -12,6 +13,7 @@ CheckboxGroupElement, FormElement, # noqa: F401 FormSection, + GeneratedIdElement, GroupElement, HiddenElement, Option, @@ -212,6 +214,16 @@ def __init__(self, **data: Any): super().__init__(**data, elements=[]) +class CxdGeneratedIdElement(GeneratedIdElement, _CxdFormElement): + cxd_value: str | None = None + + def add_form_data_value(self, element_form_data: Any) -> None: + if element_form_data: + self.cxd_value = element_form_data + else: + self.cxd_value = str(uuid4()) + + CxdFormElement: TypeAlias = Annotated[ CxdStaticTextElement | CxdTextInputElement @@ -222,7 +234,8 @@ def __init__(self, **data: Any): | CxdSelectElement | CxdHiddenElement | CxdGroupElement - | CxdRepetitionElement, + | CxdRepetitionElement + | CxdGeneratedIdElement, Field(discriminator="kind"), ] diff --git a/questionpy_sdk/webserver/routes/options.py b/questionpy_sdk/webserver/routes/options.py index 706a694..a438e62 100644 --- a/questionpy_sdk/webserver/routes/options.py +++ b/questionpy_sdk/webserver/routes/options.py @@ -1,9 +1,9 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus - from http.client import UNPROCESSABLE_ENTITY from typing import TYPE_CHECKING, Never +from uuid import uuid4 import aiohttp_jinja2 from aiohttp import web @@ -69,8 +69,22 @@ async def repeat_element(request: web.Request) -> web.Response: data = await request.json() question_form_data = parse_form_data(data["form_data"]) repetition_list = get_nested_form_data(question_form_data, data["repetition_name"]) - if isinstance(repetition_list, list) and "increment" in data: - repetition_list.extend([repetition_list[-1]] * int(data["increment"])) + + if not isinstance(repetition_list, list) or "increment" not in data: + raise web.HTTPUnprocessableEntity + + new_repetition_items: list[dict] = [repetition_list[-1].copy() for _ in range(int(data["increment"]))] + # ID elements must be unique, so new items need a new value. + for new_repetition_item in new_repetition_items: + id_element_names = new_repetition_item.get("qpy_id_elements") + if not isinstance(id_element_names, list): + continue + + for id_element_name in id_element_names: + if id_element_name in new_repetition_item: + new_repetition_item[id_element_name] = str(uuid4()) + + repetition_list.extend(new_repetition_items) try: await _save_updated_form_data(question_form_data, webserver) diff --git a/questionpy_sdk/webserver/templates/elements/id.html.jinja2 b/questionpy_sdk/webserver/templates/elements/id.html.jinja2 new file mode 100644 index 0000000..35cee5b --- /dev/null +++ b/questionpy_sdk/webserver/templates/elements/id.html.jinja2 @@ -0,0 +1,4 @@ + + From 667b55824e0bd4d4bb4fd1b7645998ac0830de21 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 19 Dec 2024 19:06:39 +0100 Subject: [PATCH 4/4] docs: add a multichoice example package --- examples/multichoice/.gitignore | 2 ++ .../python/local/multichoice/__init__.py | 8 +++++ .../python/local/multichoice/form.py | 18 ++++++++++ .../python/local/multichoice/question_type.py | 35 +++++++++++++++++++ .../templates/formulation.xhtml.j2 | 23 ++++++++++++ examples/multichoice/qpy_config.yml | 9 +++++ 6 files changed, 95 insertions(+) create mode 100644 examples/multichoice/.gitignore create mode 100644 examples/multichoice/python/local/multichoice/__init__.py create mode 100644 examples/multichoice/python/local/multichoice/form.py create mode 100644 examples/multichoice/python/local/multichoice/question_type.py create mode 100644 examples/multichoice/python/local/multichoice/templates/formulation.xhtml.j2 create mode 100644 examples/multichoice/qpy_config.yml diff --git a/examples/multichoice/.gitignore b/examples/multichoice/.gitignore new file mode 100644 index 0000000..098b60f --- /dev/null +++ b/examples/multichoice/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +/dist diff --git a/examples/multichoice/python/local/multichoice/__init__.py b/examples/multichoice/python/local/multichoice/__init__.py new file mode 100644 index 0000000..48416cd --- /dev/null +++ b/examples/multichoice/python/local/multichoice/__init__.py @@ -0,0 +1,8 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +from questionpy import make_question_type_init + +from .question_type import MultichoiceQuestion + +init = make_question_type_init(MultichoiceQuestion) diff --git a/examples/multichoice/python/local/multichoice/form.py b/examples/multichoice/python/local/multichoice/form.py new file mode 100644 index 0000000..2aa10e5 --- /dev/null +++ b/examples/multichoice/python/local/multichoice/form.py @@ -0,0 +1,18 @@ +from questionpy.form import FormModel, OptionEnum, checkbox, generated_id, option, repeat, select, text_input + + +class ChoiceMode(OptionEnum): + SELECT = option(" element') + + +class Choice(FormModel): + id: str = generated_id() + text: str = text_input("Text", required=True) + correct: bool = checkbox("Korrekt") + + +class MultichoiceFormModel(FormModel): + description: str = text_input("Beschreibung", required=True) + mode: ChoiceMode = select("Auswahlelement", ChoiceMode, required=True) + choices: list[Choice] = repeat(Choice, initial=3, minimum=2) diff --git a/examples/multichoice/python/local/multichoice/question_type.py b/examples/multichoice/python/local/multichoice/question_type.py new file mode 100644 index 0000000..4c319ab --- /dev/null +++ b/examples/multichoice/python/local/multichoice/question_type.py @@ -0,0 +1,35 @@ +from typing import Any + +from questionpy import Attempt, Question, ResponseNotScorableError + +from .form import ChoiceMode, MultichoiceFormModel + + +class MultichoiceAttempt(Attempt): + def _compute_score(self) -> float: + if not self.response or "choice" not in self.response: + msg = "'choice' is missing" + raise ResponseNotScorableError(msg) + + chosen_id = self.response["choice"] + correct_choice_ids = [choice.id for choice in self.question.options.choices if choice.correct] + + if chosen_id in correct_choice_ids: + return 1 + + return 0 + + def __init__(self, *args: Any): + super().__init__(*args) + + self.placeholders["description"] = self.question.options.description + + @property + def formulation(self) -> str: + return self.jinja2.get_template("local.multichoice/formulation.xhtml.j2").render(ChoiceMode=ChoiceMode) + + +class MultichoiceQuestion(Question): + attempt_class = MultichoiceAttempt + + options: MultichoiceFormModel diff --git a/examples/multichoice/python/local/multichoice/templates/formulation.xhtml.j2 b/examples/multichoice/python/local/multichoice/templates/formulation.xhtml.j2 new file mode 100644 index 0000000..53f3a5b --- /dev/null +++ b/examples/multichoice/python/local/multichoice/templates/formulation.xhtml.j2 @@ -0,0 +1,23 @@ +
+

+

+ {% if question.options.mode == ChoiceMode.SELECT %} + + {% else %} +

+ {% for choice in question.options.choices %} + + {% endfor %} +
+ {% endif %} +

+
diff --git a/examples/multichoice/qpy_config.yml b/examples/multichoice/qpy_config.yml new file mode 100644 index 0000000..d731411 --- /dev/null +++ b/examples/multichoice/qpy_config.yml @@ -0,0 +1,9 @@ +short_name: multichoice +namespace: local +version: 0.1.0 +api_version: "0.1" +author: Jane Doe +name: + de: Multiple-Choice + en: Multiple-Choice +languages: [de, en]