excel2moodle 0.5.2__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- excel2moodle/core/bullets.py +98 -0
- excel2moodle/core/dataStructure.py +3 -4
- excel2moodle/core/globals.py +3 -8
- excel2moodle/core/parser.py +37 -65
- excel2moodle/core/question.py +144 -76
- excel2moodle/extra/variableGenerator.py +250 -0
- excel2moodle/question_types/cloze.py +156 -125
- excel2moodle/question_types/nfm.py +38 -100
- excel2moodle/ui/UI_mainWindow.py +63 -36
- excel2moodle/ui/UI_variableGenerator.py +197 -0
- excel2moodle/ui/appUi.py +90 -23
- excel2moodle/ui/dialogs.py +44 -77
- excel2moodle/ui/equationChecker.py +2 -2
- excel2moodle/ui/treewidget.py +9 -24
- {excel2moodle-0.5.2.dist-info → excel2moodle-0.6.0.dist-info}/METADATA +27 -2
- {excel2moodle-0.5.2.dist-info → excel2moodle-0.6.0.dist-info}/RECORD +20 -19
- excel2moodle/core/numericMultiQ.py +0 -80
- excel2moodle/ui/windowDoc.py +0 -27
- {excel2moodle-0.5.2.dist-info → excel2moodle-0.6.0.dist-info}/WHEEL +0 -0
- {excel2moodle-0.5.2.dist-info → excel2moodle-0.6.0.dist-info}/entry_points.txt +0 -0
- {excel2moodle-0.5.2.dist-info → excel2moodle-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {excel2moodle-0.5.2.dist-info → excel2moodle-0.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,250 @@
|
|
1
|
+
import logging
|
2
|
+
import random
|
3
|
+
|
4
|
+
from asteval import Interpreter
|
5
|
+
from PySide6.QtWidgets import (
|
6
|
+
QDialog,
|
7
|
+
QLineEdit,
|
8
|
+
QMainWindow,
|
9
|
+
QMessageBox,
|
10
|
+
QTableWidget,
|
11
|
+
QTableWidgetItem,
|
12
|
+
)
|
13
|
+
|
14
|
+
from excel2moodle.core.question import Parametrics
|
15
|
+
from excel2moodle.ui.UI_variableGenerator import Ui_VariableGeneratorDialog
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
class VariableGeneratorDialog(QDialog):
|
21
|
+
def __init__(self, parent: QMainWindow, parametrics: Parametrics) -> None:
|
22
|
+
super().__init__(parent)
|
23
|
+
self.ui = Ui_VariableGeneratorDialog()
|
24
|
+
self.ui.setupUi(self)
|
25
|
+
self.origParametrics = parametrics
|
26
|
+
self._generatedParametrics: Parametrics = Parametrics(
|
27
|
+
parametrics.equations, parametrics._resultChecker, identifier="genr"
|
28
|
+
)
|
29
|
+
# Load existing rules
|
30
|
+
for rule in self.origParametrics.variableRules:
|
31
|
+
self.ui.listWidget_rules.addItem(rule)
|
32
|
+
|
33
|
+
self._populate_variables_table()
|
34
|
+
populateDataSetTable(
|
35
|
+
self.ui.tableWidget_existing_variables,
|
36
|
+
parametrics=self.origParametrics,
|
37
|
+
)
|
38
|
+
self._connect_signals()
|
39
|
+
|
40
|
+
# Initially hide the existing variables table and generated variables table
|
41
|
+
self.ui.tableWidget_existing_variables.hide()
|
42
|
+
self.ui.groupBox_generated_variables.hide()
|
43
|
+
|
44
|
+
self.aeval = Interpreter(minimal=True) # Initialize asteval interpreter
|
45
|
+
|
46
|
+
def _populate_variables_table(self) -> None:
|
47
|
+
self.ui.tableWidget_variables.setRowCount(len(self.origParametrics.variables))
|
48
|
+
for row, (var_name, values) in enumerate(
|
49
|
+
self.origParametrics.variables.items()
|
50
|
+
):
|
51
|
+
self.ui.tableWidget_variables.setItem(row, 0, QTableWidgetItem(var_name))
|
52
|
+
# Add QLineEdit for Min, Max, and Decimal Places
|
53
|
+
min_le = QLineEdit(str(min(values)) if values else "0")
|
54
|
+
max_le = QLineEdit(str(max(values)) if values else "100")
|
55
|
+
dec_le = QLineEdit("0") # Default to 0 decimal places
|
56
|
+
|
57
|
+
self.ui.tableWidget_variables.setCellWidget(row, 1, min_le)
|
58
|
+
self.ui.tableWidget_variables.setCellWidget(row, 2, max_le)
|
59
|
+
self.ui.tableWidget_variables.setCellWidget(row, 3, dec_le)
|
60
|
+
|
61
|
+
def _connect_signals(self) -> None:
|
62
|
+
self.ui.pushButton_addRule.clicked.connect(self._add_rule)
|
63
|
+
self.ui.pushButton_removeRule.clicked.connect(self._remove_rule)
|
64
|
+
self.ui.pushButton_generate.clicked.connect(self.generateSets)
|
65
|
+
self.ui.pushButton_cancel.clicked.connect(self.reject)
|
66
|
+
self.ui.pushButton_save.clicked.connect(self._save_variables_and_close)
|
67
|
+
self.ui.groupBox_existing_variables.toggled.connect(
|
68
|
+
self.ui.tableWidget_existing_variables.setVisible
|
69
|
+
)
|
70
|
+
|
71
|
+
def _add_rule(self) -> None:
|
72
|
+
rule_text = self.ui.lineEdit_newRule.text().strip()
|
73
|
+
if rule_text:
|
74
|
+
self.ui.listWidget_rules.addItem(rule_text)
|
75
|
+
self.ui.lineEdit_newRule.clear()
|
76
|
+
|
77
|
+
def _remove_rule(self) -> None:
|
78
|
+
for item in self.ui.listWidget_rules.selectedItems():
|
79
|
+
self.ui.listWidget_rules.takeItem(self.ui.listWidget_rules.row(item))
|
80
|
+
|
81
|
+
def generateSets(self) -> None:
|
82
|
+
self._generatedParametrics.resetVariables() # Clear previous generated sets
|
83
|
+
self._rule_error_occurred = False # Reset error flag
|
84
|
+
|
85
|
+
varConstraints = {}
|
86
|
+
for row in range(self.ui.tableWidget_variables.rowCount()):
|
87
|
+
var_name = self.ui.tableWidget_variables.item(row, 0).text()
|
88
|
+
varConstraints[var_name] = {
|
89
|
+
"min": float(self.ui.tableWidget_variables.cellWidget(row, 1).text()),
|
90
|
+
"max": float(self.ui.tableWidget_variables.cellWidget(row, 2).text()),
|
91
|
+
"decimal_places": int(
|
92
|
+
self.ui.tableWidget_variables.cellWidget(row, 3).text()
|
93
|
+
),
|
94
|
+
}
|
95
|
+
|
96
|
+
rules = [
|
97
|
+
self.ui.listWidget_rules.item(i).text()
|
98
|
+
for i in range(self.ui.listWidget_rules.count())
|
99
|
+
]
|
100
|
+
|
101
|
+
num_sets = self.ui.spinBox_numSets.value()
|
102
|
+
|
103
|
+
try:
|
104
|
+
generated_sets = [
|
105
|
+
self._findSet(varConstraints, rules) for _ in range(num_sets)
|
106
|
+
]
|
107
|
+
except IndexError as e:
|
108
|
+
logger.exception("Invalid variables in Rule:")
|
109
|
+
QMessageBox.critical(self, "Rule Error", f"{e}")
|
110
|
+
except ValueError as e:
|
111
|
+
logger.warning("No variable set found:")
|
112
|
+
QMessageBox.warning(
|
113
|
+
self,
|
114
|
+
"Generation Failed",
|
115
|
+
f"{e} Consider relaxing your rules or increasing the number of attempts.",
|
116
|
+
)
|
117
|
+
else:
|
118
|
+
# convert the generated_sets list[dict[str, float]] into dict[str, list[float]]
|
119
|
+
# [{A:7, B:8}, {A:11, B:9}] -> {A: [7, 11], B: [8, 9]}
|
120
|
+
newVariables = {}
|
121
|
+
for var in self.origParametrics.variables:
|
122
|
+
newVariables[var] = [dataSet[var] for dataSet in generated_sets]
|
123
|
+
self._generatedParametrics.variables = newVariables
|
124
|
+
self.ui.groupBox_generated_variables.show()
|
125
|
+
populateDataSetTable(
|
126
|
+
self.ui.tableWidget_generated_variables,
|
127
|
+
parametrics=self._generatedParametrics,
|
128
|
+
)
|
129
|
+
|
130
|
+
def _findSet(
|
131
|
+
self,
|
132
|
+
constraints: dict[str, dict[str, float | int]],
|
133
|
+
rules: list[str],
|
134
|
+
maxAttempts: int = 1000,
|
135
|
+
) -> dict[str, float]:
|
136
|
+
"""Generate Random numbers for each variable and check if the rules apply.
|
137
|
+
|
138
|
+
Raises
|
139
|
+
------
|
140
|
+
`IndexError`: if the evaluation of the rule returns `None`
|
141
|
+
`ValueError`: if fater `maxAttemps` no set is found
|
142
|
+
|
143
|
+
"""
|
144
|
+
attempts = 0
|
145
|
+
while attempts < maxAttempts:
|
146
|
+
current_set: dict[str, float] = {}
|
147
|
+
# Generate initial values based on min/max constraints
|
148
|
+
for var_name, constr in constraints.items():
|
149
|
+
min_val: float = constr["min"]
|
150
|
+
max_val: float = constr["max"]
|
151
|
+
dec_places: int = constr["decimal_places"]
|
152
|
+
|
153
|
+
if dec_places == 0:
|
154
|
+
current_set[var_name] = float(
|
155
|
+
random.randint(int(min_val), int(max_val))
|
156
|
+
)
|
157
|
+
else:
|
158
|
+
current_set[var_name] = round(
|
159
|
+
random.uniform(min_val, max_val), dec_places
|
160
|
+
)
|
161
|
+
if self._check_rules(current_set, rules):
|
162
|
+
logger.info("Found matching set after %s attemps", attempts)
|
163
|
+
return current_set
|
164
|
+
attempts += 1
|
165
|
+
msg = f"Could not generate a valid set after {maxAttempts} attempts."
|
166
|
+
raise ValueError(msg)
|
167
|
+
|
168
|
+
def _check_rules(
|
169
|
+
self, varSet: dict[str, float], rules: list[str], show_error: bool = True
|
170
|
+
) -> bool:
|
171
|
+
# Create a local scope for evaluating rules
|
172
|
+
self.aeval.symtable.update(varSet)
|
173
|
+
|
174
|
+
for rule in rules:
|
175
|
+
# Evaluate the rule using asteval
|
176
|
+
res = self.aeval(rule)
|
177
|
+
if res is None:
|
178
|
+
msg = f"Error evaluating rule '{rule}'"
|
179
|
+
raise IndexError(msg)
|
180
|
+
if res is False:
|
181
|
+
return False
|
182
|
+
return True
|
183
|
+
|
184
|
+
def _save_variables_and_close(self) -> None:
|
185
|
+
"""Format variables set to fit `Parametrics`."""
|
186
|
+
logger.info("Saving new variables to the question")
|
187
|
+
newVars = self.origParametrics.variables.copy()
|
188
|
+
for varName in newVars:
|
189
|
+
newVars[varName].extend(self._generatedParametrics.variables[varName])
|
190
|
+
self.origParametrics.variableRules = [
|
191
|
+
self.ui.listWidget_rules.item(i).text()
|
192
|
+
for i in range(self.ui.listWidget_rules.count())
|
193
|
+
]
|
194
|
+
self.origParametrics.variables = newVars
|
195
|
+
logger.info("Rules saved to Parametrics.")
|
196
|
+
self.accept()
|
197
|
+
|
198
|
+
|
199
|
+
def populateDataSetTable(
|
200
|
+
tableWidget: QTableWidget,
|
201
|
+
parametrics: Parametrics | None = None,
|
202
|
+
) -> None:
|
203
|
+
"""Insert all Variables with their values into `tableWidget`."""
|
204
|
+
if parametrics is None:
|
205
|
+
return
|
206
|
+
variables = parametrics.variables
|
207
|
+
variants = parametrics.variants
|
208
|
+
tableWidget.setRowCount(len(variables) + len(parametrics.results))
|
209
|
+
tableWidget.setColumnCount(variants + 1) # Variable Name + Variants
|
210
|
+
headers = ["Variable"] + [f"Set {i + 1}" for i in range(variants)]
|
211
|
+
tableWidget.setHorizontalHeaderLabels(headers)
|
212
|
+
for row, (var, values) in enumerate(variables.items()):
|
213
|
+
tableWidget.setItem(row, 0, QTableWidgetItem(var))
|
214
|
+
for col, value in enumerate(values):
|
215
|
+
tableWidget.setItem(row, col + 1, QTableWidgetItem(str(value)))
|
216
|
+
for row, results in parametrics.results.items():
|
217
|
+
logger.debug("adding Results to the DataSetTable: %s", results)
|
218
|
+
tableWidget.setItem(
|
219
|
+
len(variables) + row - 1, 0, QTableWidgetItem(f"Results: {row}")
|
220
|
+
)
|
221
|
+
for variant, res in enumerate(results):
|
222
|
+
tableWidget.setItem(
|
223
|
+
len(variables) + row - 1,
|
224
|
+
variant + 1,
|
225
|
+
QTableWidgetItem(str(res)),
|
226
|
+
)
|
227
|
+
tableWidget.resizeColumnsToContents()
|
228
|
+
|
229
|
+
|
230
|
+
# This part is for testing the UI independently
|
231
|
+
if __name__ == "__main__":
|
232
|
+
import sys
|
233
|
+
|
234
|
+
from PySide6.QtWidgets import QApplication
|
235
|
+
|
236
|
+
# Mock ParametricQuestion for testing
|
237
|
+
class MockParametricQuestion:
|
238
|
+
def __init__(self) -> None:
|
239
|
+
self.origParametrics.variables = {
|
240
|
+
"a": [1.0, 2.0, 3.0],
|
241
|
+
"b": [10, 20, 30],
|
242
|
+
"c": [0.5, 1.5, 2.5],
|
243
|
+
}
|
244
|
+
|
245
|
+
app = QApplication(sys.argv)
|
246
|
+
mock_question = MockParametricQuestion()
|
247
|
+
dialog = VariableGeneratorDialog(paramQuestion=mock_question)
|
248
|
+
if dialog.exec():
|
249
|
+
print("Generated Sets:", dialog.generatedVarSets())
|
250
|
+
sys.exit(app.exec())
|
@@ -5,7 +5,6 @@ All Answers are calculated off an equation using the same variables.
|
|
5
5
|
"""
|
6
6
|
|
7
7
|
import logging
|
8
|
-
import math
|
9
8
|
import re
|
10
9
|
from typing import Literal, overload
|
11
10
|
|
@@ -16,8 +15,12 @@ from excel2moodle.core.globals import (
|
|
16
15
|
Tags,
|
17
16
|
TextElements,
|
18
17
|
)
|
19
|
-
from excel2moodle.core.question import
|
18
|
+
from excel2moodle.core.question import (
|
19
|
+
ParametricQuestion,
|
20
|
+
Parametrics,
|
21
|
+
)
|
20
22
|
from excel2moodle.core.settings import Tags
|
23
|
+
from excel2moodle.logger import LogAdapterQuestionID
|
21
24
|
from excel2moodle.question_types.nfm import NFMQuestionParser
|
22
25
|
|
23
26
|
logger = logging.getLogger(__name__)
|
@@ -28,12 +31,72 @@ class ClozePart:
|
|
28
31
|
self,
|
29
32
|
question: ParametricQuestion,
|
30
33
|
text: list[str],
|
34
|
+
number: int,
|
31
35
|
) -> None:
|
32
36
|
self.question = question
|
33
|
-
self.text:
|
37
|
+
self.text: ET.Element = self._setupText(text)
|
38
|
+
self.num: int = number
|
34
39
|
if not self.text:
|
35
40
|
msg = f"Answer part for cloze question {self.question.id} is invalid without partText"
|
36
41
|
raise ValueError(msg)
|
42
|
+
self.logger = LogAdapterQuestionID(
|
43
|
+
logger, {"qID": f"{self.question.id}-{self.num}"}
|
44
|
+
)
|
45
|
+
self._typ: Literal["NFM", "MC", "UNSET"]
|
46
|
+
self._element: ET.Element
|
47
|
+
self.result: Parametrics
|
48
|
+
|
49
|
+
@property
|
50
|
+
def clozeElement(self) -> ET.Element:
|
51
|
+
if not hasattr(self, "_clozeElement"):
|
52
|
+
msg = "Cloze Part has no _clozeElement"
|
53
|
+
raise QNotParsedException(msg, f"{self.question.id}-{self.num}")
|
54
|
+
return self._element
|
55
|
+
|
56
|
+
@clozeElement.setter
|
57
|
+
def clozeElement(self, element: ET.Element) -> None:
|
58
|
+
self._element = element
|
59
|
+
|
60
|
+
def updateCloze(self, variant: int = 1) -> None:
|
61
|
+
self.logger.info("Updating cloze to variant %s", variant)
|
62
|
+
if not hasattr(self, "_element"):
|
63
|
+
msg = "Cloze Part has no _clozeElement"
|
64
|
+
raise QNotParsedException(msg, f"{self.question.id}-{self.num}")
|
65
|
+
if self.typ == "MC":
|
66
|
+
self.logger.debug("MC Answer Part already up to date.")
|
67
|
+
return
|
68
|
+
if self.typ == "NFM":
|
69
|
+
result = self.result.getResult(variant)
|
70
|
+
self._element.text = ClozeQuestionParser.getNumericAnsStr(
|
71
|
+
result,
|
72
|
+
self.question.rawData.get(Tags.TOLERANCE),
|
73
|
+
wrongSignCount=self.question.rawData.get(Tags.WRONGSIGNPERCENT),
|
74
|
+
points=self.points,
|
75
|
+
)
|
76
|
+
self.logger.debug("Updated NFM cloze: %s", self._element.text)
|
77
|
+
return
|
78
|
+
|
79
|
+
@property
|
80
|
+
def typ(self) -> Literal["NFM", "MC", "UNSET"]:
|
81
|
+
if not hasattr(self, "_typ"):
|
82
|
+
self.logger.warning("Type not set")
|
83
|
+
return "UNSET"
|
84
|
+
return self._typ
|
85
|
+
|
86
|
+
@typ.setter
|
87
|
+
def typ(self, partType: Literal["NFM", "MC", "UNSET"]) -> None:
|
88
|
+
if not hasattr(self, "_typ"):
|
89
|
+
self._typ = partType
|
90
|
+
self.logger.info("Set type to: %s", self._typ)
|
91
|
+
if self._typ == "NFM":
|
92
|
+
self.result: Parametrics
|
93
|
+
elif self._typ == "MC":
|
94
|
+
self.falseAnswers: list[str] = []
|
95
|
+
self.trueAnswers: list[str] = []
|
96
|
+
|
97
|
+
@property
|
98
|
+
def id(self) -> str:
|
99
|
+
return f"{self.question.id}-{self.num}"
|
37
100
|
|
38
101
|
@property
|
39
102
|
def points(self) -> float:
|
@@ -47,12 +110,6 @@ class ClozePart:
|
|
47
110
|
def points(self, points: float) -> None:
|
48
111
|
self._points = points if points > 0 else 0.0
|
49
112
|
|
50
|
-
@property
|
51
|
-
def typ(self) -> Literal["MC", "NFM"] | None:
|
52
|
-
if hasattr(self, "_typ"):
|
53
|
-
return self._typ
|
54
|
-
return None
|
55
|
-
|
56
113
|
@property
|
57
114
|
def mcAnswerString(self) -> str:
|
58
115
|
if hasattr(self, "_mcAnswer"):
|
@@ -65,44 +122,14 @@ class ClozePart:
|
|
65
122
|
self._mcAnswer: str = answerString
|
66
123
|
|
67
124
|
def _setupText(self, text: list[str]) -> ET.Element:
|
68
|
-
|
125
|
+
textItem: ET.Element = TextElements.LISTITEM.create()
|
69
126
|
for t in text:
|
70
|
-
|
71
|
-
|
72
|
-
return
|
73
|
-
|
74
|
-
def setAnswer(
|
75
|
-
self,
|
76
|
-
equation: str | None = None,
|
77
|
-
trueAns: list[str] | None = None,
|
78
|
-
falseAns: list[str] | None = None,
|
79
|
-
) -> bool:
|
80
|
-
if falseAns is not None:
|
81
|
-
self.falseAnswers: list[str] = falseAns
|
82
|
-
if trueAns is not None:
|
83
|
-
self.trueAnswers: list[str] = trueAns
|
84
|
-
if equation is not None:
|
85
|
-
self.equation: str = equation
|
86
|
-
check = False
|
87
|
-
t = hasattr(self, "trueAnswers")
|
88
|
-
f = hasattr(self, "falseAnswers")
|
89
|
-
eq = hasattr(self, "equation")
|
90
|
-
if t and f and not eq:
|
91
|
-
self._typ: Literal["MC", "NFM"] = "MC"
|
92
|
-
return True
|
93
|
-
if eq and not t and not f:
|
94
|
-
self._typ: Literal["MC", "NFM"] = "NFM"
|
95
|
-
self.nfResults: list[float] = []
|
96
|
-
return True
|
97
|
-
return False
|
127
|
+
textItem.append(TextElements.PLEFT.create())
|
128
|
+
textItem[-1].text = t
|
129
|
+
return textItem
|
98
130
|
|
99
131
|
def __repr__(self) -> str:
|
100
|
-
|
101
|
-
self.equation
|
102
|
-
if self.typ == "NFM"
|
103
|
-
else f"{self.trueAnswers}\n {self.falseAnswers}"
|
104
|
-
)
|
105
|
-
return f"Cloze Part {self.typ}\n Answers: '{answers}'"
|
132
|
+
return f"Cloze Part {self.id}-{self.typ}"
|
106
133
|
|
107
134
|
|
108
135
|
class ClozeQuestion(ParametricQuestion):
|
@@ -112,6 +139,7 @@ class ClozeQuestion(ParametricQuestion):
|
|
112
139
|
super().__init__(*args, **kwargs)
|
113
140
|
self.questionParts: dict[int, ClozePart] = {}
|
114
141
|
self.questionTexts: list[ET.Element] = []
|
142
|
+
self.parametrics: Parametrics
|
115
143
|
|
116
144
|
@property
|
117
145
|
def partsNum(self) -> int:
|
@@ -127,37 +155,16 @@ class ClozeQuestion(ParametricQuestion):
|
|
127
155
|
pts = self.rawData.get(Tags.POINTS)
|
128
156
|
return pts
|
129
157
|
|
130
|
-
def
|
131
|
-
|
132
|
-
if part.typ == "MC":
|
133
|
-
ansStr = part.mcAnswerString
|
134
|
-
self.logger.info("MC answer part: %s ", ansStr)
|
135
|
-
elif part.typ == "NFM":
|
136
|
-
result = part.nfResults[variant - 1]
|
137
|
-
ansStr = ClozeQuestionParser.getNumericAnsStr(
|
138
|
-
result,
|
139
|
-
self.rawData.get(Tags.TOLERANCE),
|
140
|
-
wrongSignCount=self.rawData.get(Tags.WRONGSIGNPERCENT),
|
141
|
-
points=part.points,
|
142
|
-
)
|
143
|
-
self.logger.info("NF answer part: %s ", ansStr)
|
144
|
-
else:
|
145
|
-
msg = "Type of the answer part is invalid"
|
146
|
-
raise QNotParsedException(msg, self.id)
|
147
|
-
ul = TextElements.ULIST.create()
|
148
|
-
item = TextElements.LISTITEM.create()
|
149
|
-
item.text = ansStr
|
150
|
-
ul.append(item)
|
151
|
-
part.text.append(ul)
|
152
|
-
self.logger.debug("Appended part %s %s to main text", partNum, part)
|
153
|
-
part.text.append(ET.Element("hr"))
|
154
|
-
self.questionTexts.extend(part.text)
|
158
|
+
def getUpdatedElement(self, variant: int = 0) -> ET.Element:
|
159
|
+
"""Update and get the Question Elements to reflect the version.
|
155
160
|
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
+
`ClozeQuestion` Updates the text.
|
162
|
+
`ParametricQuestion` updates the bullet points.
|
163
|
+
`Question` returns the element.
|
164
|
+
"""
|
165
|
+
for part in self.questionParts.values():
|
166
|
+
part.updateCloze(variant=variant)
|
167
|
+
return super().getUpdatedElement(variant=variant)
|
161
168
|
|
162
169
|
|
163
170
|
class ClozeQuestionParser(NFMQuestionParser):
|
@@ -176,21 +183,53 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
176
183
|
self._parseAnswerParts()
|
177
184
|
|
178
185
|
def _setupParts(self) -> None:
|
179
|
-
parts: dict[int, ClozePart] = {
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
186
|
+
parts: dict[int, ClozePart] = {}
|
187
|
+
for key in self.rawInput:
|
188
|
+
if key.startswith(Tags.QUESTIONPART):
|
189
|
+
partNumber = self.getPartNumber(key)
|
190
|
+
parts[partNumber] = ClozePart(
|
191
|
+
self.question, self.rawInput[key], partNumber
|
192
|
+
)
|
184
193
|
partsNum = len(parts)
|
185
194
|
equations: dict[int, str] = self._getPartValues(Tags.RESULT)
|
186
195
|
trueAnsws: dict[int, list[str]] = self._getPartValues(Tags.TRUE)
|
187
196
|
falseAnsws: dict[int, list[str]] = self._getPartValues(Tags.FALSE)
|
188
197
|
points: dict[int, float] = self._getPartValues(Tags.POINTS)
|
198
|
+
firstResult: dict[int, float] = self._getPartValues(Tags.FIRSTRESULT)
|
189
199
|
for num, part in parts.items():
|
200
|
+
loclogger = LogAdapterQuestionID(
|
201
|
+
logger, {"qID": f"{self.question.id}-{num}"}
|
202
|
+
)
|
190
203
|
eq = equations.get(num)
|
191
|
-
|
192
|
-
|
193
|
-
|
204
|
+
trueAns = trueAnsws.get(num)
|
205
|
+
falseAns = falseAnsws.get(num)
|
206
|
+
if falseAns is not None and trueAns is not None and eq is None:
|
207
|
+
loclogger.info("Setting up MC answer part...")
|
208
|
+
part.typ = "MC"
|
209
|
+
part.falseAnswers = falseAns
|
210
|
+
part.trueAnswers = trueAns
|
211
|
+
elif eq is not None and falseAns is None and trueAns is None:
|
212
|
+
loclogger.info("Seting up NFM part..")
|
213
|
+
if not hasattr(self.question, "parametrics"):
|
214
|
+
loclogger.info("Adding new Parametrics Object to cloze question")
|
215
|
+
self.question.parametrics = Parametrics(
|
216
|
+
equation=eq,
|
217
|
+
firstResult=firstResult.get(num, 0.0),
|
218
|
+
identifier=f"{self.question.id}-{num}",
|
219
|
+
)
|
220
|
+
else:
|
221
|
+
loclogger.info("Adding new equation to parametrics")
|
222
|
+
self.question.parametrics.equations[num] = eq
|
223
|
+
self.question.parametrics._resultChecker[num] = firstResult.get(
|
224
|
+
num, 0.0
|
225
|
+
)
|
226
|
+
if not hasattr(part, "result"):
|
227
|
+
part.result = self.question.parametrics
|
228
|
+
part.typ = "NFM"
|
229
|
+
loclogger.info("Set up NFM answer part.")
|
230
|
+
else:
|
231
|
+
msg = f"Unclear Parts are defined. Either define `true:{num}` and `false:{num}` or `result:{num}` "
|
232
|
+
raise QNotParsedException(msg, self.question.id)
|
194
233
|
if len(points) == 0:
|
195
234
|
pts = round(self.rawInput.get(Tags.POINTS) / partsNum, 3)
|
196
235
|
for part in parts.values():
|
@@ -202,13 +241,14 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
202
241
|
for num, part in parts.items():
|
203
242
|
p = points.get(num)
|
204
243
|
part.points = p if p is not None else self.rawInput.get(Tags.POINTS)
|
205
|
-
|
206
244
|
self.question.questionParts = parts
|
207
245
|
|
208
246
|
@overload
|
209
247
|
def _getPartValues(self, Tag: Literal[Tags.RESULT]) -> dict[int, str]: ...
|
210
248
|
@overload
|
211
|
-
def _getPartValues(
|
249
|
+
def _getPartValues(
|
250
|
+
self, Tag: Literal[Tags.POINTS, Tags.FIRSTRESULT]
|
251
|
+
) -> dict[int, float]: ...
|
212
252
|
@overload
|
213
253
|
def _getPartValues(
|
214
254
|
self, Tag: Literal[Tags.TRUE, Tags.FALSE]
|
@@ -219,57 +259,48 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
219
259
|
for key in self.rawInput
|
220
260
|
if key.startswith(Tag)
|
221
261
|
}
|
222
|
-
self.logger.warning("Found part data %s: %s", Tag, tagValues)
|
223
262
|
return tagValues
|
224
263
|
|
225
264
|
def _parseAnswerParts(self) -> None:
|
226
265
|
"""Parse the numeric or MC result items."""
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
for variant in range(number):
|
236
|
-
self.setupAstIntprt(self.question.variables, variant)
|
237
|
-
for partNum, part in self.question.questionParts.items():
|
238
|
-
if part.typ == "NFM":
|
239
|
-
result = self._calculateNFMPartResult(part, partNum, variant)
|
240
|
-
part.nfResults.append(result)
|
241
|
-
logger.debug("Appended NF part %s result: %s", partNum, result)
|
242
|
-
elif part.typ == "MC":
|
243
|
-
ansStr = self.getMCAnsStr(
|
244
|
-
part.trueAnswers, part.falseAnswers, points=part.points
|
245
|
-
)
|
246
|
-
part.mcAnswerString = ansStr
|
247
|
-
logger.debug("Appended MC part %s: %s", partNum, ansStr)
|
248
|
-
self._setVariants(number)
|
249
|
-
|
250
|
-
def _calculateNFMPartResult(
|
251
|
-
self, part: ClozePart, partNum: int, variant: int
|
252
|
-
) -> float:
|
253
|
-
result = self.astEval(part.equation)
|
254
|
-
if isinstance(result, float):
|
255
|
-
try:
|
256
|
-
firstResult = self.rawInput[f"{Tags.FIRSTRESULT}:{partNum}"]
|
257
|
-
except KeyError:
|
258
|
-
firstResult = 0.0
|
259
|
-
if variant == 0 and not math.isclose(result, firstResult, rel_tol=0.002):
|
260
|
-
self.logger.warning(
|
261
|
-
"The calculated result %s differs from given firstResult: %s",
|
266
|
+
answersList = ET.Element("ol")
|
267
|
+
self.question.parametrics.variables = self.question.bulletList.getVariablesDict(
|
268
|
+
self.question
|
269
|
+
)
|
270
|
+
for partNum, part in self.question.questionParts.items():
|
271
|
+
if part.typ == "NFM":
|
272
|
+
result = self.question.parametrics.getResult(1, partNum)
|
273
|
+
ansStr = self.getNumericAnsStr(
|
262
274
|
result,
|
263
|
-
|
275
|
+
self.rawInput.get(Tags.TOLERANCE),
|
276
|
+
wrongSignCount=self.rawInput.get(Tags.WRONGSIGNPERCENT),
|
277
|
+
points=part.points,
|
278
|
+
)
|
279
|
+
self.logger.info("NF answer part: %s ", ansStr)
|
280
|
+
logger.debug("Appended NF part %s result", partNum)
|
281
|
+
elif part.typ == "MC":
|
282
|
+
ansStr = self.getMCAnsStr(
|
283
|
+
part.trueAnswers, part.falseAnswers, points=part.points
|
264
284
|
)
|
265
|
-
|
266
|
-
|
267
|
-
|
285
|
+
part.mcAnswerString = ansStr
|
286
|
+
logger.debug("Appended MC part %s: %s", partNum, ansStr)
|
287
|
+
else:
|
288
|
+
msg = "Type of the answer part is invalid"
|
289
|
+
raise QNotParsedException(msg, self.id)
|
290
|
+
unorderedList = TextElements.ULIST.create()
|
291
|
+
answerItem = TextElements.LISTITEM.create()
|
292
|
+
answerItem.text = ansStr
|
293
|
+
part.clozeElement = answerItem
|
294
|
+
unorderedList.append(answerItem)
|
295
|
+
part.text.append(unorderedList)
|
296
|
+
self.logger.debug("Appended part %s %s to main text", partNum, part)
|
297
|
+
answersList.append(part.text)
|
298
|
+
self.htmlRoot.append(answersList)
|
268
299
|
|
269
300
|
def getPartNumber(self, indexKey: str) -> int:
|
270
301
|
"""Return the number of the question Part.
|
271
302
|
|
272
|
-
The number should be given after the
|
303
|
+
The number should be given after the `:` colon.
|
273
304
|
This is number is used, to reference the question Text
|
274
305
|
and the expected answer fields together.
|
275
306
|
"""
|