excel2moodle 0.4.1__py3-none-any.whl → 0.4.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.
Files changed (34) hide show
  1. excel2moodle/__init__.py +0 -7
  2. excel2moodle/__main__.py +2 -2
  3. excel2moodle/core/__init__.py +0 -10
  4. excel2moodle/core/category.py +4 -3
  5. excel2moodle/core/dataStructure.py +116 -61
  6. excel2moodle/core/etHelpers.py +2 -2
  7. excel2moodle/core/exceptions.py +2 -2
  8. excel2moodle/core/globals.py +10 -27
  9. excel2moodle/core/parser.py +24 -30
  10. excel2moodle/core/question.py +147 -63
  11. excel2moodle/core/settings.py +107 -111
  12. excel2moodle/core/validator.py +36 -55
  13. excel2moodle/logger.py +7 -4
  14. excel2moodle/question_types/__init__.py +2 -0
  15. excel2moodle/question_types/cloze.py +207 -0
  16. excel2moodle/question_types/mc.py +26 -16
  17. excel2moodle/question_types/nf.py +17 -3
  18. excel2moodle/question_types/nfm.py +60 -17
  19. excel2moodle/ui/{windowEquationChecker.py → UI_equationChecker.py} +98 -78
  20. excel2moodle/ui/{exportSettingsDialog.py → UI_exportSettingsDialog.py} +55 -4
  21. excel2moodle/ui/{windowMain.py → UI_mainWindow.py} +32 -39
  22. excel2moodle/ui/appUi.py +66 -86
  23. excel2moodle/ui/dialogs.py +40 -2
  24. excel2moodle/ui/equationChecker.py +70 -0
  25. excel2moodle/ui/treewidget.py +4 -4
  26. {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/METADATA +2 -3
  27. excel2moodle-0.4.3.dist-info/RECORD +38 -0
  28. {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/entry_points.txt +0 -3
  29. excel2moodle/ui/questionPreviewDialog.py +0 -115
  30. excel2moodle-0.4.1.dist-info/RECORD +0 -37
  31. /excel2moodle/ui/{variantDialog.py → UI_variantDialog.py} +0 -0
  32. {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/WHEEL +0 -0
  33. {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/licenses/LICENSE +0 -0
  34. {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,207 @@
1
+ """Implementation of the cloze question type.
2
+
3
+ This question type is like the NFM but supports multiple fields of answers.
4
+ All Answers are calculated off an equation using the same variables.
5
+ """
6
+
7
+ import math
8
+ import re
9
+
10
+ import lxml.etree as ET
11
+
12
+ from excel2moodle.core.exceptions import QNotParsedException
13
+ from excel2moodle.core.globals import (
14
+ Tags,
15
+ TextElements,
16
+ )
17
+ from excel2moodle.core.question import ParametricQuestion
18
+ from excel2moodle.core.settings import Tags
19
+ from excel2moodle.question_types.nfm import NFMQuestionParser
20
+
21
+
22
+ class ClozeQuestion(ParametricQuestion):
23
+ """Cloze Question Type."""
24
+
25
+ def __init__(self, *args, **kwargs) -> None:
26
+ super().__init__(*args, **kwargs)
27
+ self.answerVariants: dict[int, list[float]] = {}
28
+ self.answerStrings: dict[int, list[str]] = {}
29
+ self.answerTypes: dict[int, str] = {}
30
+ self.questionTexts: dict[int, list[ET.Element]] = {}
31
+ self.partsNum: int = 0
32
+
33
+ def _setAnswerElement(self, variant: int = 1) -> None:
34
+ for part, ans in self.answerVariants.items():
35
+ result = ans[variant - 1]
36
+ if self.answerTypes.get(part, None) == "MC":
37
+ ansStr = ClozeQuestionParser.getMCAnsStr(answers)
38
+ else:
39
+ ansStr = ClozeQuestionParser.getNumericAnsStr(
40
+ result,
41
+ self.rawData.get(Tags.TOLERANCE),
42
+ wrongSignCount=self.rawData.get(Tags.WRONGSIGNPERCENT),
43
+ )
44
+ ul = TextElements.ULIST.create()
45
+ item = TextElements.LISTITEM.create()
46
+ item.text = ansStr
47
+ ul.append(item)
48
+ self.questionTexts[part].append(ul)
49
+ self.logger.debug("Appended Question Parts %s to main text", part)
50
+ self.questionTexts[part].append(ET.Element("hr"))
51
+
52
+ def _assembleMainTextParts(self, variant=0) -> list[ET.Element]:
53
+ textParts = super()._assembleMainTextParts(variant=variant)
54
+ self.logger.debug("Appending QuestionParts to main text")
55
+ for paragraphs in self.questionTexts.values():
56
+ for par in paragraphs:
57
+ textParts.append(par)
58
+ return textParts
59
+
60
+
61
+ class ClozeQuestionParser(NFMQuestionParser):
62
+ """Parser for the cloze question type."""
63
+
64
+ def __init__(self, *args, **kwargs) -> None:
65
+ super().__init__(*args, **kwargs)
66
+ self.question: ClozeQuestion
67
+
68
+ def setup(self, question: ClozeQuestion) -> None:
69
+ self.question: ClozeQuestion = question
70
+ super().setup(question)
71
+
72
+ def _parseAnswers(self) -> None:
73
+ self._parseAnswerParts()
74
+ self._parseQuestionParts()
75
+
76
+ def _parseAnswerParts(self) -> None:
77
+ """Parse the numeric or MC result items."""
78
+ self.question.answerTypes: dict[int, str] = {
79
+ self.getPartNumber(key): self.rawInput[key]
80
+ for key in self.rawInput
81
+ if key.startswith(Tags.PARTTYPE)
82
+ }
83
+ equations: dict[int, str] = {
84
+ self.getPartNumber(key): self.rawInput[key]
85
+ for key in self.rawInput
86
+ if key.startswith(Tags.RESULT)
87
+ }
88
+ self.logger.debug("Got the following answers: %s", equations)
89
+ bps = str(self.rawInput[Tags.BPOINTS])
90
+ varNames: list[str] = self._getVarsList(bps)
91
+ numericAnswers: dict[int, list[float]] = {key: [] for key in equations}
92
+ self.question.variables, number = self._getVariablesDict(varNames)
93
+ for n in range(number):
94
+ self.setupAstIntprt(self.question.variables, n)
95
+ for ansNum, eq in equations.items():
96
+ result = self.astEval(eq)
97
+ if isinstance(result, float):
98
+ firstResult = self.rawInput.get(Tags.FIRSTRESULT)
99
+ if n == 0 and not math.isclose(result, firstResult, rel_tol=0.01):
100
+ self.logger.warning(
101
+ "The calculated result %s differs from given firstResult: %s",
102
+ result,
103
+ firstResult,
104
+ )
105
+ numericAnswers[ansNum].append(result)
106
+ else:
107
+ msg = f"The expression {eq} could not be evaluated."
108
+ raise QNotParsedException(msg, self.question.id)
109
+
110
+ self.question.answerVariants = numericAnswers
111
+ self._setVariants(number)
112
+
113
+ def _parseQuestionParts(self) -> None:
114
+ """Generate the question parts aka the clozes."""
115
+ parts: dict[int, list[str]] = {
116
+ self.getPartNumber(key): self.rawInput[key]
117
+ for key in self.rawInput
118
+ if key.startswith(Tags.QUESTIONPART)
119
+ }
120
+ questionParts: dict[int, list[ET.Element]] = {}
121
+ for number, text in parts.items():
122
+ questionParts[number] = []
123
+ for t in text:
124
+ questionParts[number].append(TextElements.PLEFT.create())
125
+ questionParts[number][-1].text = t
126
+ self.logger.debug("The Question Parts are created:", questionParts)
127
+ self.question.questionTexts = questionParts
128
+ self.question.partsNum = len(questionParts)
129
+ self.logger.info("The Question has %s parts", self.question.partsNum)
130
+
131
+ # def setMainText(self) -> None:
132
+ # super().setMainText()
133
+ # self.question.qtextParagraphs
134
+
135
+ def getPartNumber(self, indexKey: str) -> int:
136
+ """Return the number of the question Part.
137
+
138
+ The number should be given after the `@` sign.
139
+ This is number is used, to reference the question Text
140
+ and the expected answer fields together.
141
+ """
142
+ try:
143
+ num = re.findall(r":(\d+)$", indexKey)[0]
144
+ except IndexError:
145
+ msg = f"No :i question Part value given for {indexKey}"
146
+ raise QNotParsedException(msg, self.question.id)
147
+ else:
148
+ return int(num)
149
+
150
+ @staticmethod
151
+ def getNumericAnsStr(
152
+ result: float,
153
+ tolerance: float,
154
+ weight: int = 1,
155
+ wrongSignCount: int = 50,
156
+ wrongSignFeedback: str = "your result has the wrong sign (+-)",
157
+ ) -> str:
158
+ """Generate the answer string from `result`.
159
+
160
+ Parameters.
161
+ ----------
162
+ weight:
163
+ The weight of the answer relative to the other answer elements.
164
+ Of one answer has `weight=2` and two other answers `weight=1`,
165
+ this answer will be counted as 50% of the questions points.
166
+ The other two will counted as 25% of the questions points.
167
+
168
+ wrongSignCount:
169
+ If the wrong sign `+` or `-` is given, how much of the points should be given.
170
+ Interpreted as percent.
171
+ tolerance:
172
+ The relative tolerance, as fraction
173
+
174
+ """
175
+ absTol = f":{round(result * tolerance, 3)}"
176
+ answerParts: list[str | float] = [
177
+ "{",
178
+ weight,
179
+ ":NUMERICAL:=",
180
+ round(result, 3),
181
+ absTol,
182
+ "~%",
183
+ wrongSignCount,
184
+ "%",
185
+ round(result * (-1), 3),
186
+ absTol,
187
+ f"#{wrongSignFeedback}",
188
+ "}",
189
+ ]
190
+ answerPStrings = [str(part) for part in answerParts]
191
+ return "".join(answerPStrings)
192
+
193
+ @staticmethod
194
+ def getMCAnsStr(
195
+ true: list[str],
196
+ false: list[str],
197
+ weight: int = 1,
198
+ ) -> str:
199
+ """Generate the answer string for the MC answers."""
200
+ answerParts: list[str | float] = [
201
+ "{",
202
+ weight,
203
+ ":MC:",
204
+ "}",
205
+ ]
206
+ answerPStrings = [str(part) for part in answerParts]
207
+ return "".join(answerPString)
@@ -1,21 +1,22 @@
1
1
  """Multiple choice Question implementation."""
2
2
 
3
+ from types import UnionType
3
4
  from typing import ClassVar
4
5
 
5
6
  import lxml.etree as ET
6
7
 
7
8
  import excel2moodle.core.etHelpers as eth
8
9
  from excel2moodle.core import stringHelpers
9
- from excel2moodle.core.exceptions import InvalidFieldException
10
+ from excel2moodle.core.exceptions import InvalidFieldException, QNotParsedException
10
11
  from excel2moodle.core.globals import (
11
- DFIndex,
12
+ Tags,
12
13
  TextElements,
13
14
  XMLTags,
14
15
  feedbackStr,
15
16
  )
16
17
  from excel2moodle.core.parser import QuestionParser
17
18
  from excel2moodle.core.question import Picture, Question
18
- from excel2moodle.core.settings import SettingsKey
19
+ from excel2moodle.core.settings import Tags
19
20
 
20
21
 
21
22
  class MCQuestion(Question):
@@ -28,6 +29,12 @@ class MCQuestion(Question):
28
29
  "showstandardinstruction": "0",
29
30
  "shownumcorrect": "",
30
31
  }
32
+ mcOpt: ClassVar[dict[Tags, type | UnionType]] = {}
33
+ mcMand: ClassVar[dict[Tags, type | UnionType]] = {
34
+ Tags.TRUE: str,
35
+ Tags.FALSE: str,
36
+ Tags.ANSTYPE: str,
37
+ }
31
38
 
32
39
  def __init__(self, *args, **kwargs) -> None:
33
40
  super().__init__(*args, **kwargs)
@@ -89,32 +96,35 @@ class MCQuestionParser(QuestionParser):
89
96
  elementList[-1].append(self.trueImgs[i].element)
90
97
  return elementList
91
98
 
92
- def setAnswers(self) -> list[ET.Element]:
93
- self.answerType = self.rawInput[DFIndex.ANSTYPE]
94
- true = stringHelpers.getListFromStr(self.rawInput[DFIndex.TRUE])
95
- false = stringHelpers.getListFromStr(self.rawInput[DFIndex.FALSE])
99
+ def _parseAnswers(self) -> list[ET.Element]:
100
+ self.answerType = self.rawInput.get(Tags.ANSTYPE)
96
101
  if self.answerType not in self.question.AnsStyles:
97
102
  msg = f"The Answer style: {self.answerType} is not supported"
98
- raise InvalidFieldException(msg, self.question.id, DFIndex.ANSTYPE)
103
+ raise InvalidFieldException(msg, self.question.id, Tags.ANSTYPE)
99
104
  if self.answerType == "picture":
100
- f = self.settings.get(SettingsKey.PICTUREFOLDER)
105
+ f = self.settings.get(Tags.PICTUREFOLDER)
101
106
  imgFolder = (f / self.question.katName).resolve()
102
- w = self.settings.get(SettingsKey.ANSPICWIDTH)
107
+ w = self.settings.get(Tags.ANSPICWIDTH)
103
108
  self.trueImgs: list[Picture] = [
104
- Picture(t, imgFolder, self.question.id, width=w) for t in true
109
+ Picture(t, imgFolder, self.question.id, width=w)
110
+ for t in self.rawInput.get(Tags.TRUE)
105
111
  ]
106
112
  self.falseImgs: list[Picture] = [
107
- Picture(t, imgFolder, self.question.id, width=w) for t in false
113
+ Picture(t, imgFolder, self.question.id, width=w)
114
+ for t in self.rawInput.get(Tags.FALSE)
108
115
  ]
109
- trueAnsList: list[str] = [pic.htmlTag for pic in self.trueImgs]
110
- falseAList: list[str] = [pic.htmlTag for pic in self.falseImgs]
116
+ trueAnsList: list[str] = [pic.htmlTag for pic in self.trueImgs if pic.ready]
117
+ falseAList: list[str] = [pic.htmlTag for pic in self.falseImgs if pic.ready]
118
+ if len(trueAnsList) == 0 or len(falseAList) == 0:
119
+ msg = "No Answer Pictures could be found"
120
+ raise QNotParsedException(msg, self.question.id)
111
121
  else:
112
122
  trueAnsList: list[str] = stringHelpers.texWrapper(
113
- true, style=self.answerType
123
+ self.rawInput.get(Tags.TRUE), style=self.answerType
114
124
  )
115
125
  self.logger.debug(f"got the following true answers \n {trueAnsList=}")
116
126
  falseAList: list[str] = stringHelpers.texWrapper(
117
- false, style=self.answerType
127
+ self.rawInput.get(Tags.FALSE), style=self.answerType
118
128
  )
119
129
  self.logger.debug(f"got the following false answers \n {falseAList=}")
120
130
  truefrac = 1 / len(trueAnsList) * 100
@@ -1,9 +1,12 @@
1
1
  """Numerical question implementation."""
2
2
 
3
+ from types import UnionType
4
+ from typing import ClassVar
5
+
3
6
  import lxml.etree as ET
4
7
 
5
8
  from excel2moodle.core.globals import (
6
- DFIndex,
9
+ Tags,
7
10
  XMLTags,
8
11
  )
9
12
  from excel2moodle.core.parser import QuestionParser
@@ -14,6 +17,13 @@ class NFQuestion(Question):
14
17
  def __init__(self, *args, **kwargs) -> None:
15
18
  super().__init__(*args, **kwargs)
16
19
 
20
+ nfOpt: ClassVar[dict[Tags, type | UnionType]] = {
21
+ Tags.BPOINTS: str,
22
+ }
23
+ nfMand: ClassVar[dict[Tags, type | UnionType]] = {
24
+ Tags.RESULT: float | int,
25
+ }
26
+
17
27
 
18
28
  class NFQuestionParser(QuestionParser):
19
29
  """Subclass for parsing numeric questions."""
@@ -22,8 +32,12 @@ class NFQuestionParser(QuestionParser):
22
32
  super().__init__()
23
33
  self.genFeedbacks = [XMLTags.GENFEEDB]
24
34
 
25
- def setAnswers(self) -> list[ET.Element]:
26
- result = self.rawInput[DFIndex.RESULT]
35
+ def setup(self, question: NFQuestion) -> None:
36
+ self.question: NFQuestion = question
37
+ super().setup(question)
38
+
39
+ def _parseAnswers(self) -> list[ET.Element]:
40
+ result: float = self.rawInput.get(Tags.RESULT)
27
41
  ansEle: list[ET.Element] = []
28
42
  ansEle.append(self.getNumericAnsElement(result=result))
29
43
  return ansEle
@@ -1,51 +1,86 @@
1
1
  """Numerical question multi implementation."""
2
2
 
3
+ import math
3
4
  import re
4
- from typing import TYPE_CHECKING
5
+ from types import UnionType
6
+ from typing import TYPE_CHECKING, ClassVar
5
7
 
6
- import lxml.etree as ET
7
8
  from asteval import Interpreter
8
9
 
9
10
  from excel2moodle.core import stringHelpers
11
+ from excel2moodle.core.exceptions import QNotParsedException
10
12
  from excel2moodle.core.globals import (
11
- DFIndex,
13
+ Tags,
12
14
  XMLTags,
13
15
  )
14
16
  from excel2moodle.core.parser import QuestionParser
15
- from excel2moodle.core.question import Question
17
+ from excel2moodle.core.question import ParametricQuestion
16
18
 
17
19
  if TYPE_CHECKING:
18
20
  import lxml.etree as ET
19
21
 
20
22
 
21
- class NFMQuestion(Question):
23
+ class NFMQuestion(ParametricQuestion):
24
+ nfmMand: ClassVar[dict[Tags, type | UnionType]] = {
25
+ Tags.RESULT: str,
26
+ Tags.BPOINTS: str,
27
+ }
28
+
22
29
  def __init__(self, *args, **kwargs) -> None:
23
30
  super().__init__(*args, **kwargs)
31
+ self.answerVariants: list[ET.Element]
32
+
33
+ def _setAnswerElement(self, variant: int = 1) -> None:
34
+ prevAnsElement = self.element.find(XMLTags.ANSWER)
35
+ if prevAnsElement is not None:
36
+ self.element.remove(prevAnsElement)
37
+ self.logger.debug("removed previous answer element")
38
+ self.element.insert(5, self.answerVariants[variant - 1])
24
39
 
25
40
 
26
41
  class NFMQuestionParser(QuestionParser):
42
+ astEval = Interpreter(with_import=True)
43
+
27
44
  def __init__(self) -> None:
28
45
  super().__init__()
29
46
  self.genFeedbacks = [XMLTags.GENFEEDB]
30
- self.astEval = Interpreter()
47
+ self.question: NFMQuestion
48
+ module = self.settings.get(Tags.IMPORTMODULE)
49
+ if module and not type(self).astEval.symtable.get(module):
50
+ type(self).astEval(f"import {module}")
51
+ self.logger.warning("Imported '%s' to Asteval symtable", module)
52
+
53
+ def setup(self, question: NFMQuestion) -> None:
54
+ self.question: NFMQuestion = question
55
+ super().setup(question)
31
56
 
32
- def setAnswers(self) -> None:
33
- equation = self.rawInput[DFIndex.RESULT]
34
- bps = str(self.rawInput[DFIndex.BPOINTS])
57
+ def _parseAnswers(self) -> None:
58
+ equation = self.rawInput.get(Tags.EQUATION)
59
+ bps = self.rawInput.get(Tags.BPOINTS)
35
60
  ansElementsList: list[ET.Element] = []
36
61
  varNames: list[str] = self._getVarsList(bps)
37
62
  self.question.variables, number = self._getVariablesDict(varNames)
38
63
  for n in range(number):
39
- self._setupAstIntprt(self.question.variables, n)
40
- result = self.astEval(equation)
64
+ type(self).setupAstIntprt(self.question.variables, n)
65
+ result = type(self).astEval(equation)
41
66
  if isinstance(result, float):
67
+ firstResult = self.rawInput.get(Tags.FIRSTRESULT)
68
+ if n == 0 and not math.isclose(result, firstResult, rel_tol=0.01):
69
+ self.logger.warning(
70
+ "The calculated result %s differs from given firstResult: %s",
71
+ result,
72
+ firstResult,
73
+ )
42
74
  ansElementsList.append(
43
75
  self.getNumericAnsElement(result=round(result, 3)),
44
76
  )
77
+ else:
78
+ msg = f"The expression {equation} could not be evaluated."
79
+ raise QNotParsedException(msg, self.question.id)
45
80
  self.question.answerVariants = ansElementsList
46
- self.setVariants(len(ansElementsList))
81
+ self._setVariants(len(ansElementsList))
47
82
 
48
- def setVariants(self, number: int) -> None:
83
+ def _setVariants(self, number: int) -> None:
49
84
  self.question.variants = number
50
85
  mvar = self.question.category.maxVariants
51
86
  if mvar is None:
@@ -53,17 +88,25 @@ class NFMQuestionParser(QuestionParser):
53
88
  else:
54
89
  self.question.category.maxVariants = min(number, mvar)
55
90
 
56
- def _setupAstIntprt(self, var: dict[str, list[float | int]], index: int) -> None:
91
+ @classmethod
92
+ def setupAstIntprt(cls, var: dict[str, list[float | int]], index: int) -> None:
57
93
  """Setup the asteval Interpreter with the variables."""
58
94
  for name, value in var.items():
59
- self.astEval.symtable[name] = value[index]
95
+ cls.astEval.symtable[name] = value[index]
60
96
 
61
97
  def _getVariablesDict(self, keyList: list) -> tuple[dict[str, list[float]], int]:
62
- """Liest alle Variablen-Listen deren Name in ``keyList`` ist aus dem DataFrame im Column[index]."""
98
+ """Read variabel values for vars in `keyList` from `question.rawData`.
99
+
100
+ Returns
101
+ -------
102
+ A dictionary containing a list of values for each variable
103
+ The number of values for each variable
104
+
105
+ """
63
106
  dic: dict = {}
64
107
  num: int = 0
65
108
  for k in keyList:
66
- val = self.rawInput[k]
109
+ val = self.rawInput[k.lower()]
67
110
  if isinstance(val, str):
68
111
  li = stringHelpers.getListFromStr(val)
69
112
  num = len(li)