excel2moodle 0.6.1__py3-none-any.whl → 0.6.3__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/__init__.py +2 -0
- excel2moodle/__main__.py +18 -3
- excel2moodle/core/bullets.py +2 -2
- excel2moodle/core/category.py +27 -8
- excel2moodle/core/dataStructure.py +122 -32
- excel2moodle/core/etHelpers.py +0 -20
- excel2moodle/core/globals.py +0 -9
- excel2moodle/core/parser.py +21 -13
- excel2moodle/core/question.py +43 -23
- excel2moodle/core/settings.py +16 -5
- excel2moodle/extra/equationVerification.py +0 -2
- excel2moodle/extra/updateQuery.py +48 -0
- excel2moodle/extra/variableGenerator.py +73 -49
- excel2moodle/question_types/cloze.py +119 -87
- excel2moodle/question_types/mc.py +15 -10
- excel2moodle/question_types/nf.py +7 -1
- excel2moodle/question_types/nfm.py +14 -10
- excel2moodle/ui/UI_exportSettingsDialog.py +60 -14
- excel2moodle/ui/UI_updateDlg.py +106 -0
- excel2moodle/ui/appUi.py +66 -24
- excel2moodle/ui/dialogs.py +30 -1
- excel2moodle/ui/treewidget.py +30 -10
- {excel2moodle-0.6.1.dist-info → excel2moodle-0.6.3.dist-info}/METADATA +66 -19
- excel2moodle-0.6.3.dist-info/RECORD +41 -0
- excel2moodle-0.6.1.dist-info/RECORD +0 -39
- {excel2moodle-0.6.1.dist-info → excel2moodle-0.6.3.dist-info}/WHEEL +0 -0
- {excel2moodle-0.6.1.dist-info → excel2moodle-0.6.3.dist-info}/entry_points.txt +0 -0
- {excel2moodle-0.6.1.dist-info → excel2moodle-0.6.3.dist-info}/licenses/LICENSE +0 -0
- {excel2moodle-0.6.1.dist-info → excel2moodle-0.6.3.dist-info}/top_level.txt +0 -0
excel2moodle/core/settings.py
CHANGED
@@ -60,8 +60,9 @@ class Tags(StrEnum):
|
|
60
60
|
"""Get type of the keys data."""
|
61
61
|
return self._typ_
|
62
62
|
|
63
|
-
QUESTIONVARIANT = "
|
64
|
-
INCLUDEINCATS = "
|
63
|
+
QUESTIONVARIANT = "defaultquestionvariant", int, 1, "testgen"
|
64
|
+
INCLUDEINCATS = "includecats", bool, False, "testgen"
|
65
|
+
GENEXPORTREPORT = "exportreport", bool, False, "testgen"
|
65
66
|
TOLERANCE = "tolerance", float, 0.01, "parser/nf"
|
66
67
|
PICTUREFOLDER = "pictureFolder", Path, None, "core"
|
67
68
|
PICTURESUBFOLDER = "imgfolder", str, "Abbildungen", "project"
|
@@ -87,8 +88,13 @@ class Tags(StrEnum):
|
|
87
88
|
POINTS = "points", float, 1.0
|
88
89
|
PICTUREWIDTH = "imgwidth", int, 500
|
89
90
|
ANSPICWIDTH = "answerimgwidth", int, 120
|
90
|
-
WRONGSIGNPERCENT = "wrongsignpercent", int, 50
|
91
91
|
FIRSTRESULT = "firstresult", float, 0
|
92
|
+
WRONGSIGNPERCENT = "wrongsignpercent", int, 50
|
93
|
+
WRONGSIGNFB = "wrongsignfeedback", str, "your result has the wrong sign (+-)"
|
94
|
+
TRUEFB = "truefeedback", str, "congratulations!!! your answer is right."
|
95
|
+
FALSEFB = "falsefeedback", str, "Your answer is sadly wrong, try again!!!"
|
96
|
+
PCORRECFB = "partialcorrectfeedback", str, "Your answer is partially right."
|
97
|
+
GENERALFB = "feedback", str, "You answered this question."
|
92
98
|
|
93
99
|
|
94
100
|
class Settings:
|
@@ -101,6 +107,10 @@ class Settings:
|
|
101
107
|
def clear(cls) -> None:
|
102
108
|
cls.values.clear()
|
103
109
|
|
110
|
+
@classmethod
|
111
|
+
def pop(cls, key: str):
|
112
|
+
return cls.values.pop(key)
|
113
|
+
|
104
114
|
@overload
|
105
115
|
@classmethod
|
106
116
|
def get(
|
@@ -121,7 +131,7 @@ class Settings:
|
|
121
131
|
) -> int: ...
|
122
132
|
@overload
|
123
133
|
@classmethod
|
124
|
-
def get(cls, key: Literal[Tags.INCLUDEINCATS]) -> bool: ...
|
134
|
+
def get(cls, key: Literal[Tags.INCLUDEINCATS, Tags.GENEXPORTREPORT]) -> bool: ...
|
125
135
|
@overload
|
126
136
|
@classmethod
|
127
137
|
def get(
|
@@ -132,6 +142,7 @@ class Settings:
|
|
132
142
|
Tags.LOGFILE,
|
133
143
|
Tags.CATEGORIESSHEET,
|
134
144
|
Tags.IMPORTMODULE,
|
145
|
+
Tags.WRONGSIGNFB,
|
135
146
|
],
|
136
147
|
) -> str: ...
|
137
148
|
@overload
|
@@ -153,7 +164,7 @@ class Settings:
|
|
153
164
|
default = key.default
|
154
165
|
if default is None:
|
155
166
|
return None
|
156
|
-
logger.
|
167
|
+
logger.debug("Returning the default value for %s", key)
|
157
168
|
return default
|
158
169
|
if key.typ() is Path:
|
159
170
|
path: Path = Path(raw)
|
@@ -0,0 +1,48 @@
|
|
1
|
+
"""This module provides functions to query the GitLab API for project information."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
import sys
|
5
|
+
import urllib.request
|
6
|
+
|
7
|
+
|
8
|
+
def get_latest_tag(project_id: str) -> str | None:
|
9
|
+
"""Queries the GitLab API for the latest tag of a project.
|
10
|
+
|
11
|
+
Args:
|
12
|
+
project_id: The URL-encoded project ID (e.g., 'jbosse3%2Fexcel2moodle').
|
13
|
+
|
14
|
+
Returns:
|
15
|
+
The name of the latest tag.
|
16
|
+
|
17
|
+
"""
|
18
|
+
url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/tags"
|
19
|
+
try:
|
20
|
+
with urllib.request.urlopen(url) as response:
|
21
|
+
if response.status == 200:
|
22
|
+
data = json.loads(response.read().decode())
|
23
|
+
if data:
|
24
|
+
return data[0]["name"]
|
25
|
+
except urllib.error.URLError as e:
|
26
|
+
print(f"Error fetching latest tag: {e}", file=sys.stderr)
|
27
|
+
return None
|
28
|
+
|
29
|
+
|
30
|
+
def get_changelog(project_id: str, branch: str = "master") -> str:
|
31
|
+
"""Queries the GitLab API for the content of the CHANGELOG.md file.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
project_id: The URL-encoded project ID (e.g., 'jbosse3%2Fexcel2moodle').
|
35
|
+
branch: The branch to get the file from.
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
The content of the CHANGELOG.md file.
|
39
|
+
|
40
|
+
"""
|
41
|
+
url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/files/CHANGELOG.md/raw?ref={branch}"
|
42
|
+
try:
|
43
|
+
with urllib.request.urlopen(url) as response:
|
44
|
+
if response.status == 200:
|
45
|
+
return response.read().decode()
|
46
|
+
except urllib.error.URLError as e:
|
47
|
+
print(f"Error fetching changelog: {e}", file=sys.stderr)
|
48
|
+
return ""
|
@@ -2,9 +2,11 @@ import logging
|
|
2
2
|
import random
|
3
3
|
|
4
4
|
from asteval import Interpreter
|
5
|
+
from PySide6.QtCore import Qt, Slot
|
5
6
|
from PySide6.QtWidgets import (
|
6
7
|
QDialog,
|
7
8
|
QLineEdit,
|
9
|
+
QListWidgetItem,
|
8
10
|
QMainWindow,
|
9
11
|
QMessageBox,
|
10
12
|
QTableWidget,
|
@@ -52,7 +54,7 @@ class VariableGeneratorDialog(QDialog):
|
|
52
54
|
# Add QLineEdit for Min, Max, and Decimal Places
|
53
55
|
min_le = QLineEdit(str(min(values)) if values else "0")
|
54
56
|
max_le = QLineEdit(str(max(values)) if values else "100")
|
55
|
-
dec_le = QLineEdit("
|
57
|
+
dec_le = QLineEdit("1") # Default to 0 decimal places
|
56
58
|
|
57
59
|
self.ui.tableWidget_variables.setCellWidget(row, 1, min_le)
|
58
60
|
self.ui.tableWidget_variables.setCellWidget(row, 2, max_le)
|
@@ -67,11 +69,20 @@ class VariableGeneratorDialog(QDialog):
|
|
67
69
|
self.ui.groupBox_existing_variables.toggled.connect(
|
68
70
|
self.ui.tableWidget_existing_variables.setVisible
|
69
71
|
)
|
72
|
+
self.ui.listWidget_rules.itemDoubleClicked.connect(self._edit_rule)
|
73
|
+
|
74
|
+
@Slot(QListWidgetItem)
|
75
|
+
def _edit_rule(self, item) -> None:
|
76
|
+
"""Move the double-clicked rule into the line edit and remove it from the list."""
|
77
|
+
self.ui.lineEdit_newRule.setText(item.text())
|
78
|
+
self.ui.listWidget_rules.takeItem(self.ui.listWidget_rules.row(item))
|
70
79
|
|
71
80
|
def _add_rule(self) -> None:
|
72
81
|
rule_text = self.ui.lineEdit_newRule.text().strip()
|
73
82
|
if rule_text:
|
74
|
-
|
83
|
+
# Check if the rule already exists. If so, do nothing.
|
84
|
+
if not self.ui.listWidget_rules.findItems(rule_text, Qt.MatchExactly):
|
85
|
+
self.ui.listWidget_rules.addItem(rule_text)
|
75
86
|
self.ui.lineEdit_newRule.clear()
|
76
87
|
|
77
88
|
def _remove_rule(self) -> None:
|
@@ -100,40 +111,70 @@ class VariableGeneratorDialog(QDialog):
|
|
100
111
|
|
101
112
|
num_sets = self.ui.spinBox_numSets.value()
|
102
113
|
|
114
|
+
# Build a set of existing variable combinations to ensure we don't generate duplicates of them.
|
115
|
+
unique_sets_tracker = set()
|
116
|
+
if self.origParametrics.variables:
|
117
|
+
var_names = list(self.origParametrics.variables.keys())
|
118
|
+
if var_names:
|
119
|
+
# Assuming all variable lists have the same length
|
120
|
+
num_variants = len(self.origParametrics.variables[var_names[0]])
|
121
|
+
for i in range(num_variants):
|
122
|
+
existing_set = {
|
123
|
+
var: self.origParametrics.variables[var][i] for var in var_names
|
124
|
+
}
|
125
|
+
unique_sets_tracker.add(frozenset(existing_set.items()))
|
126
|
+
|
127
|
+
generated_sets = [] # This will be a list of dicts
|
103
128
|
try:
|
104
|
-
generated_sets
|
105
|
-
self._findSet(varConstraints, rules
|
106
|
-
|
129
|
+
while len(generated_sets) < num_sets:
|
130
|
+
new_set = self._findSet(varConstraints, rules, unique_sets_tracker)
|
131
|
+
generated_sets.append(new_set)
|
132
|
+
unique_sets_tracker.add(frozenset(new_set.items()))
|
133
|
+
|
107
134
|
except IndexError as e:
|
108
135
|
logger.exception("Invalid variables in Rule:")
|
109
136
|
QMessageBox.critical(self, "Rule Error", f"{e}")
|
137
|
+
return # Stop generation
|
110
138
|
except ValueError as e:
|
111
|
-
logger.warning("
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
139
|
+
logger.warning("Failed to generate a new unique set: %s", e)
|
140
|
+
if len(generated_sets) < num_sets:
|
141
|
+
QMessageBox.warning(
|
142
|
+
self,
|
143
|
+
"Generation Incomplete",
|
144
|
+
f"Could only generate {len(generated_sets)} unique sets out of the requested {num_sets}. "
|
145
|
+
"The space of possible unique combinations may be exhausted.",
|
146
|
+
)
|
147
|
+
|
148
|
+
if not generated_sets:
|
149
|
+
logger.info("No new variable sets were generated.")
|
150
|
+
if not self._rule_error_occurred:
|
151
|
+
QMessageBox.information(
|
152
|
+
self,
|
153
|
+
"No Sets Generated",
|
154
|
+
"No new unique variable sets could be generated with the given constraints and rules.",
|
155
|
+
)
|
156
|
+
return
|
157
|
+
|
158
|
+
# convert the generated_sets list[dict[str, float]] into dict[str, list[float]]
|
159
|
+
# [{A:7, B:8}, {A:11, B:9}] -> {A: [7, 11], B: [8, 9]}
|
160
|
+
newVariables = {}
|
161
|
+
for var in self.origParametrics.variables:
|
162
|
+
newVariables[var] = [dataSet[var] for dataSet in generated_sets]
|
163
|
+
self._generatedParametrics.variables = newVariables
|
164
|
+
self.ui.groupBox_generated_variables.show()
|
165
|
+
populateDataSetTable(
|
166
|
+
self.ui.tableWidget_generated_variables,
|
167
|
+
parametrics=self._generatedParametrics,
|
168
|
+
)
|
129
169
|
|
130
170
|
def _findSet(
|
131
171
|
self,
|
132
172
|
constraints: dict[str, dict[str, float | int]],
|
133
173
|
rules: list[str],
|
174
|
+
existing_sets: set[frozenset],
|
134
175
|
maxAttempts: int = 1000,
|
135
176
|
) -> dict[str, float]:
|
136
|
-
"""Generate
|
177
|
+
"""Generate a random set of variables that satisfies the rules and is not in existing_sets.
|
137
178
|
|
138
179
|
Raises
|
139
180
|
------
|
@@ -143,6 +184,7 @@ class VariableGeneratorDialog(QDialog):
|
|
143
184
|
"""
|
144
185
|
attempts = 0
|
145
186
|
while attempts < maxAttempts:
|
187
|
+
attempts += 1
|
146
188
|
current_set: dict[str, float] = {}
|
147
189
|
# Generate initial values based on min/max constraints
|
148
190
|
for var_name, constr in constraints.items():
|
@@ -158,11 +200,15 @@ class VariableGeneratorDialog(QDialog):
|
|
158
200
|
current_set[var_name] = round(
|
159
201
|
random.uniform(min_val, max_val), dec_places
|
160
202
|
)
|
203
|
+
|
204
|
+
# Check for uniqueness first, as it's a cheaper check than evaluating rules.
|
205
|
+
if frozenset(current_set.items()) in existing_sets:
|
206
|
+
continue # It's a duplicate, try again.
|
207
|
+
|
161
208
|
if self._check_rules(current_set, rules):
|
162
|
-
logger.info("Found matching set after %s
|
209
|
+
logger.info("Found matching unique set after %s attempts", attempts)
|
163
210
|
return current_set
|
164
|
-
|
165
|
-
msg = f"Could not generate a valid set after {maxAttempts} attempts."
|
211
|
+
msg = f"Could not generate a valid unique set after {maxAttempts} attempts."
|
166
212
|
raise ValueError(msg)
|
167
213
|
|
168
214
|
def _check_rules(
|
@@ -226,25 +272,3 @@ def populateDataSetTable(
|
|
226
272
|
)
|
227
273
|
tableWidget.resizeColumnsToContents()
|
228
274
|
|
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,6 +5,7 @@ All Answers are calculated off an equation using the same variables.
|
|
5
5
|
"""
|
6
6
|
|
7
7
|
import logging
|
8
|
+
import math
|
8
9
|
import re
|
9
10
|
from typing import Literal, overload
|
10
11
|
|
@@ -18,6 +19,7 @@ from excel2moodle.core.globals import (
|
|
18
19
|
from excel2moodle.core.question import (
|
19
20
|
ParametricQuestion,
|
20
21
|
Parametrics,
|
22
|
+
QuestionData,
|
21
23
|
)
|
22
24
|
from excel2moodle.core.settings import Tags
|
23
25
|
from excel2moodle.logger import LogAdapterQuestionID
|
@@ -67,7 +69,8 @@ class ClozePart:
|
|
67
69
|
return
|
68
70
|
if self.typ == "NFM":
|
69
71
|
result = self.result.getResult(variant)
|
70
|
-
self._element.text =
|
72
|
+
self._element.text = self.getNumericAnsStr(
|
73
|
+
self.question.rawData,
|
71
74
|
result,
|
72
75
|
self.question.rawData.get(Tags.TOLERANCE),
|
73
76
|
wrongSignCount=self.question.rawData.get(Tags.WRONGSIGNPERCENT),
|
@@ -99,16 +102,20 @@ class ClozePart:
|
|
99
102
|
return f"{self.question.id}-{self.num}"
|
100
103
|
|
101
104
|
@property
|
102
|
-
def points(self) ->
|
105
|
+
def points(self) -> int:
|
106
|
+
"""Points of clozes can be only integers.
|
107
|
+
|
108
|
+
Otherwise the moodle import fails.
|
109
|
+
"""
|
103
110
|
if hasattr(self, "_points"):
|
104
111
|
return self._points
|
105
|
-
return 0
|
112
|
+
return 0
|
106
113
|
self.question.logger.error("Invalid call to points of unparsed cloze part")
|
107
|
-
return 0
|
114
|
+
return 0
|
108
115
|
|
109
116
|
@points.setter
|
110
|
-
def points(self, points:
|
111
|
-
self._points =
|
117
|
+
def points(self, points: int) -> None:
|
118
|
+
self._points = max(0, points)
|
112
119
|
|
113
120
|
@property
|
114
121
|
def mcAnswerString(self) -> str:
|
@@ -131,6 +138,73 @@ class ClozePart:
|
|
131
138
|
def __repr__(self) -> str:
|
132
139
|
return f"Cloze Part {self.id}-{self.typ}"
|
133
140
|
|
141
|
+
@staticmethod
|
142
|
+
def getNumericAnsStr(
|
143
|
+
questionData: QuestionData,
|
144
|
+
result: float,
|
145
|
+
tolerance: float = 0.0,
|
146
|
+
points: int = 1,
|
147
|
+
wrongSignCount: int = 0,
|
148
|
+
wrongSignFeedback: str | None = None,
|
149
|
+
) -> str:
|
150
|
+
"""Generate the answer string from `result`.
|
151
|
+
|
152
|
+
Parameters.
|
153
|
+
----------
|
154
|
+
wrongSignCount:
|
155
|
+
If the wrong sign `+` or `-` is given, how much of the points should be given.
|
156
|
+
Interpreted as percent.
|
157
|
+
tolerance:
|
158
|
+
The relative tolerance, as fraction
|
159
|
+
|
160
|
+
"""
|
161
|
+
if wrongSignFeedback is None:
|
162
|
+
wrongSignFeedback = questionData.get(Tags.WRONGSIGNFB)
|
163
|
+
if wrongSignCount == 0:
|
164
|
+
wrongSignCount = questionData.get(Tags.WRONGSIGNPERCENT)
|
165
|
+
if tolerance == 0.0:
|
166
|
+
tolerance = questionData.get(Tags.TOLERANCE)
|
167
|
+
absTol = f":{round(result * tolerance, 3)}"
|
168
|
+
answerParts: list[str | float] = [
|
169
|
+
"{",
|
170
|
+
points,
|
171
|
+
":NUMERICAL:=",
|
172
|
+
round(result, 3),
|
173
|
+
absTol,
|
174
|
+
"~%",
|
175
|
+
wrongSignCount,
|
176
|
+
"%",
|
177
|
+
round(result * (-1), 3),
|
178
|
+
absTol,
|
179
|
+
f"#{wrongSignFeedback}",
|
180
|
+
"}",
|
181
|
+
]
|
182
|
+
answerPStrings = [str(part) for part in answerParts]
|
183
|
+
return "".join(answerPStrings)
|
184
|
+
|
185
|
+
@staticmethod
|
186
|
+
def getMCAnsStr(
|
187
|
+
true: list[str],
|
188
|
+
false: list[str],
|
189
|
+
points: int = 1,
|
190
|
+
) -> str:
|
191
|
+
"""Generate the answer string for the MC answers."""
|
192
|
+
truePercent: float = round(100 / len(true), 1)
|
193
|
+
falsePercent: float = round(100 / len(false), 1)
|
194
|
+
falseList: list[str] = [f"~%-{falsePercent}%{ans}" for ans in false]
|
195
|
+
trueList: list[str] = [f"~%{truePercent}%{ans}" for ans in true]
|
196
|
+
answerParts: list[str | float] = [
|
197
|
+
"{",
|
198
|
+
points,
|
199
|
+
":MULTIRESPONSE:",
|
200
|
+
]
|
201
|
+
answerParts.extend(trueList)
|
202
|
+
answerParts.extend(falseList)
|
203
|
+
answerParts.append("}")
|
204
|
+
|
205
|
+
answerPStrings = [str(part) for part in answerParts]
|
206
|
+
return "".join(answerPStrings)
|
207
|
+
|
134
208
|
|
135
209
|
class ClozeQuestion(ParametricQuestion):
|
136
210
|
"""Cloze Question Type."""
|
@@ -146,13 +220,18 @@ class ClozeQuestion(ParametricQuestion):
|
|
146
220
|
return len(self.questionParts)
|
147
221
|
|
148
222
|
@property
|
149
|
-
def points(self) ->
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
223
|
+
def points(self) -> int:
|
224
|
+
"""Points for the cloze question. The sum of all its parts points.
|
225
|
+
|
226
|
+
Returns only integer values.
|
227
|
+
"""
|
228
|
+
pts: int = 0
|
229
|
+
if not self.isParsed:
|
230
|
+
msg = "The Cloze question has no points because it is not yet parsed"
|
231
|
+
self.logger.warning(msg)
|
232
|
+
return pts
|
233
|
+
for p in self.questionParts.values():
|
234
|
+
pts = pts + p.points
|
156
235
|
return pts
|
157
236
|
|
158
237
|
def getUpdatedElement(self, variant: int = 0) -> ET.Element:
|
@@ -232,17 +311,30 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
232
311
|
raise QNotParsedException(msg, self.question.id)
|
233
312
|
if len(points) == 0:
|
234
313
|
pts = round(self.rawInput.get(Tags.POINTS) / partsNum, 3)
|
314
|
+
point = self._roundClozePartPoints(pts)
|
235
315
|
for part in parts.values():
|
236
|
-
part.points =
|
237
|
-
|
238
|
-
|
316
|
+
part.points = point
|
317
|
+
else:
|
318
|
+
loclogger.warning(
|
239
319
|
"Some Answer parts are missing the points, they will get the standard points"
|
240
320
|
)
|
241
321
|
for num, part in parts.items():
|
242
|
-
|
243
|
-
part.points = p if p is not None else self.rawInput.get(Tags.POINTS)
|
322
|
+
part.points = self._roundClozePartPoints(points=points.get(num))
|
244
323
|
self.question.questionParts = parts
|
245
324
|
|
325
|
+
def _roundClozePartPoints(self, points: float | None = None) -> int:
|
326
|
+
"""Get the integer points for the cloze part."""
|
327
|
+
if points is None:
|
328
|
+
points = self.rawInput.get(Tags.POINTS)
|
329
|
+
corrPoints: int = round(points)
|
330
|
+
if not math.isclose(corrPoints, points):
|
331
|
+
self.logger.warning(
|
332
|
+
"Type cloze supports only integers as points. %s was round to %s",
|
333
|
+
points,
|
334
|
+
corrPoints,
|
335
|
+
)
|
336
|
+
return corrPoints
|
337
|
+
|
246
338
|
@overload
|
247
339
|
def _getPartValues(self, Tag: Literal[Tags.RESULT]) -> dict[int, str]: ...
|
248
340
|
@overload
|
@@ -270,20 +362,20 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
270
362
|
for partNum, part in self.question.questionParts.items():
|
271
363
|
if part.typ == "NFM":
|
272
364
|
result = self.question.parametrics.getResult(1, partNum)
|
273
|
-
ansStr =
|
274
|
-
|
275
|
-
|
276
|
-
wrongSignCount=self.rawInput.get(Tags.WRONGSIGNPERCENT),
|
365
|
+
ansStr = ClozePart.getNumericAnsStr(
|
366
|
+
self.rawInput,
|
367
|
+
result=result,
|
277
368
|
points=part.points,
|
278
369
|
)
|
279
|
-
self.logger.
|
280
|
-
logger.debug("Appended NF part %s result", partNum)
|
370
|
+
self.logger.debug("Generated %s answer part: %s ", partNum, ansStr)
|
281
371
|
elif part.typ == "MC":
|
282
|
-
ansStr =
|
283
|
-
part.trueAnswers,
|
372
|
+
ansStr = ClozePart.getMCAnsStr(
|
373
|
+
part.trueAnswers,
|
374
|
+
part.falseAnswers,
|
375
|
+
points=part.points,
|
284
376
|
)
|
285
377
|
part.mcAnswerString = ansStr
|
286
|
-
logger.debug("Appended MC part %s: %s", partNum, ansStr)
|
378
|
+
self.logger.debug("Appended MC part %s: %s", partNum, ansStr)
|
287
379
|
else:
|
288
380
|
msg = "Type of the answer part is invalid"
|
289
381
|
raise QNotParsedException(msg, self.id)
|
@@ -311,63 +403,3 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
311
403
|
raise QNotParsedException(msg, self.question.id)
|
312
404
|
else:
|
313
405
|
return int(num)
|
314
|
-
|
315
|
-
@staticmethod
|
316
|
-
def getNumericAnsStr(
|
317
|
-
result: float,
|
318
|
-
tolerance: float,
|
319
|
-
points: float = 1,
|
320
|
-
wrongSignCount: int = 50,
|
321
|
-
wrongSignFeedback: str = "your result has the wrong sign (+-)",
|
322
|
-
) -> str:
|
323
|
-
"""Generate the answer string from `result`.
|
324
|
-
|
325
|
-
Parameters.
|
326
|
-
----------
|
327
|
-
wrongSignCount:
|
328
|
-
If the wrong sign `+` or `-` is given, how much of the points should be given.
|
329
|
-
Interpreted as percent.
|
330
|
-
tolerance:
|
331
|
-
The relative tolerance, as fraction
|
332
|
-
|
333
|
-
"""
|
334
|
-
absTol = f":{round(result * tolerance, 3)}"
|
335
|
-
answerParts: list[str | float] = [
|
336
|
-
"{",
|
337
|
-
points,
|
338
|
-
":NUMERICAL:=",
|
339
|
-
round(result, 3),
|
340
|
-
absTol,
|
341
|
-
"~%",
|
342
|
-
wrongSignCount,
|
343
|
-
"%",
|
344
|
-
round(result * (-1), 3),
|
345
|
-
absTol,
|
346
|
-
f"#{wrongSignFeedback}",
|
347
|
-
"}",
|
348
|
-
]
|
349
|
-
answerPStrings = [str(part) for part in answerParts]
|
350
|
-
return "".join(answerPStrings)
|
351
|
-
|
352
|
-
@staticmethod
|
353
|
-
def getMCAnsStr(
|
354
|
-
true: list[str],
|
355
|
-
false: list[str],
|
356
|
-
points: float = 1,
|
357
|
-
) -> str:
|
358
|
-
"""Generate the answer string for the MC answers."""
|
359
|
-
truePercent: float = round(100 / len(true), 1)
|
360
|
-
falsePercent: float = round(100 / len(false), 1)
|
361
|
-
falseList: list[str] = [f"~%-{falsePercent}%{ans}" for ans in false]
|
362
|
-
trueList: list[str] = [f"~%{truePercent}%{ans}" for ans in true]
|
363
|
-
answerParts: list[str | float] = [
|
364
|
-
"{",
|
365
|
-
points,
|
366
|
-
":MULTIRESPONSE:",
|
367
|
-
]
|
368
|
-
answerParts.extend(trueList)
|
369
|
-
answerParts.extend(falseList)
|
370
|
-
answerParts.append("}")
|
371
|
-
|
372
|
-
answerPStrings = [str(part) for part in answerParts]
|
373
|
-
return "".join(answerPStrings)
|
@@ -12,7 +12,6 @@ from excel2moodle.core.globals import (
|
|
12
12
|
Tags,
|
13
13
|
TextElements,
|
14
14
|
XMLTags,
|
15
|
-
feedbackStr,
|
16
15
|
)
|
17
16
|
from excel2moodle.core.parser import QuestionParser
|
18
17
|
from excel2moodle.core.question import Picture, Question
|
@@ -46,11 +45,6 @@ class MCQuestionParser(QuestionParser):
|
|
46
45
|
|
47
46
|
def __init__(self) -> None:
|
48
47
|
super().__init__()
|
49
|
-
self.genFeedbacks = [
|
50
|
-
XMLTags.CORFEEDB,
|
51
|
-
XMLTags.PCORFEEDB,
|
52
|
-
XMLTags.INCORFEEDB,
|
53
|
-
]
|
54
48
|
|
55
49
|
def setup(self, question: MCQuestion) -> None:
|
56
50
|
self.question: MCQuestion = question
|
@@ -76,9 +70,9 @@ class MCQuestionParser(QuestionParser):
|
|
76
70
|
elementList[-1].append(text)
|
77
71
|
if fraction < 0:
|
78
72
|
elementList[-1].append(
|
79
|
-
|
73
|
+
self.getFeedBEle(
|
80
74
|
XMLTags.ANSFEEDBACK,
|
81
|
-
text=
|
75
|
+
text=self.rawInput.get(Tags.FALSEFB),
|
82
76
|
style=TextElements.SPANRED,
|
83
77
|
),
|
84
78
|
)
|
@@ -86,9 +80,9 @@ class MCQuestionParser(QuestionParser):
|
|
86
80
|
elementList[-1].append(self.falseImgs[i].element)
|
87
81
|
elif fraction > 0:
|
88
82
|
elementList[-1].append(
|
89
|
-
|
83
|
+
self.getFeedBEle(
|
90
84
|
XMLTags.ANSFEEDBACK,
|
91
|
-
text=
|
85
|
+
text=self.rawInput.get(Tags.TRUEFB),
|
92
86
|
style=TextElements.SPANGREEN,
|
93
87
|
),
|
94
88
|
)
|
@@ -135,3 +129,14 @@ class MCQuestionParser(QuestionParser):
|
|
135
129
|
self.getAnsElementsList(falseAList, fraction=round(falsefrac, 4)),
|
136
130
|
)
|
137
131
|
return ansList
|
132
|
+
|
133
|
+
def parse(self) -> None:
|
134
|
+
super().parse()
|
135
|
+
feedBacks = {
|
136
|
+
XMLTags.CORFEEDB: Tags.TRUEFB,
|
137
|
+
XMLTags.PCORFEEDB: Tags.PCORRECFB,
|
138
|
+
XMLTags.INCORFEEDB: Tags.FALSEFB,
|
139
|
+
}
|
140
|
+
for feedb, tag in feedBacks.items():
|
141
|
+
self.tmpEle.append(self.getFeedBEle(feedb, text=self.rawInput.get(tag)))
|
142
|
+
self._finalizeParsing()
|
@@ -30,7 +30,7 @@ class NFQuestionParser(QuestionParser):
|
|
30
30
|
|
31
31
|
def __init__(self) -> None:
|
32
32
|
super().__init__()
|
33
|
-
self.
|
33
|
+
self.feedBackList = {XMLTags.GENFEEDB: Tags.GENERALFB}
|
34
34
|
|
35
35
|
def setup(self, question: NFQuestion) -> None:
|
36
36
|
self.question: NFQuestion = question
|
@@ -41,3 +41,9 @@ class NFQuestionParser(QuestionParser):
|
|
41
41
|
ansEle: list[ET.Element] = []
|
42
42
|
ansEle.append(self.getNumericAnsElement(result=result))
|
43
43
|
return ansEle
|
44
|
+
|
45
|
+
def _finalizeParsing(self) -> None:
|
46
|
+
self.tmpEle.append(
|
47
|
+
self.getFeedBEle(XMLTags.GENFEEDB, text=self.rawInput.get(Tags.GENERALFB))
|
48
|
+
)
|
49
|
+
return super()._finalizeParsing()
|