excel2moodle 0.3.3__py3-none-any.whl → 0.3.5__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 +38 -96
- excel2moodle/__main__.py +5 -5
- excel2moodle/core/__init__.py +1 -3
- excel2moodle/core/category.py +67 -47
- excel2moodle/core/dataStructure.py +89 -73
- excel2moodle/core/etHelpers.py +26 -26
- excel2moodle/core/exceptions.py +12 -5
- excel2moodle/core/globals.py +43 -29
- excel2moodle/core/numericMultiQ.py +28 -24
- excel2moodle/core/parser.py +228 -147
- excel2moodle/core/question.py +100 -69
- excel2moodle/core/questionValidator.py +56 -54
- excel2moodle/core/questionWriter.py +232 -139
- excel2moodle/core/stringHelpers.py +38 -34
- excel2moodle/extra/__init__.py +1 -3
- excel2moodle/extra/equationVerification.py +37 -33
- excel2moodle/logger.py +102 -0
- excel2moodle/ui/appUi.py +133 -125
- excel2moodle/ui/dialogs.py +71 -18
- excel2moodle/ui/settings.py +108 -21
- excel2moodle/ui/treewidget.py +13 -10
- excel2moodle/ui/windowMain.py +18 -57
- {excel2moodle-0.3.3.dist-info → excel2moodle-0.3.5.dist-info}/METADATA +4 -3
- excel2moodle-0.3.5.dist-info/RECORD +33 -0
- {excel2moodle-0.3.3.dist-info → excel2moodle-0.3.5.dist-info}/WHEEL +1 -1
- excel2moodle-0.3.5.dist-info/entry_points.txt +2 -0
- excel2moodle-0.3.3.dist-info/RECORD +0 -31
- {excel2moodle-0.3.3.dist-info → excel2moodle-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {excel2moodle-0.3.3.dist-info → excel2moodle-0.3.5.dist-info}/top_level.txt +0 -0
excel2moodle/core/question.py
CHANGED
@@ -1,19 +1,33 @@
|
|
1
|
-
import
|
2
|
-
import
|
1
|
+
import base64
|
2
|
+
import logging
|
3
|
+
import re
|
3
4
|
from pathlib import Path
|
4
|
-
|
5
|
-
|
6
|
-
from excel2moodle.core import category, etHelpers
|
7
|
-
from excel2moodle.core.globals import XMLTags, TextElements, DFIndex, questionTypes, parserSettings
|
8
|
-
from excel2moodle.core.exceptions import QNotParsedException
|
9
|
-
from typing import Match
|
10
|
-
import re as re
|
5
|
+
from re import Match
|
11
6
|
|
7
|
+
import lxml.etree as ET
|
12
8
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
9
|
+
from excel2moodle.core import etHelpers
|
10
|
+
from excel2moodle.core.exceptions import QNotParsedException
|
11
|
+
from excel2moodle.core.globals import (
|
12
|
+
TextElements,
|
13
|
+
XMLTags,
|
14
|
+
questionTypes,
|
15
|
+
)
|
16
|
+
from excel2moodle.logger import LogAdapterQuestionID
|
17
|
+
|
18
|
+
loggerObj = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class Question:
|
22
|
+
def __init__(
|
23
|
+
self,
|
24
|
+
category,
|
25
|
+
name: str,
|
26
|
+
number: int,
|
27
|
+
parent=None,
|
28
|
+
qtype: str = "type",
|
29
|
+
points: float = 0,
|
30
|
+
) -> None:
|
17
31
|
self.category = category
|
18
32
|
self.katName = self.category.name
|
19
33
|
self.name = name
|
@@ -21,73 +35,75 @@ class Question():
|
|
21
35
|
self.parent = parent
|
22
36
|
self.qtype: str = qtype
|
23
37
|
self.moodleType = questionTypes[qtype]
|
24
|
-
self.points =
|
25
|
-
self.element: ET.Element|None=None
|
26
|
-
self.picture:Picture
|
27
|
-
self.id:str
|
38
|
+
self.points = points if points != 0 else self.category.points
|
39
|
+
self.element: ET.Element | None = None
|
40
|
+
self.picture: Picture
|
41
|
+
self.id: str
|
28
42
|
self.qtextParagraphs: list[ET.Element] = []
|
29
|
-
self.bulletList: ET.Element|None = None
|
43
|
+
self.bulletList: ET.Element | None = None
|
30
44
|
self.answerVariants: list[ET.Element] = []
|
31
|
-
self.variants:int|None = None
|
32
|
-
self.variables: dict[str, list[float|int]] = {}
|
45
|
+
self.variants: int | None = None
|
46
|
+
self.variables: dict[str, list[float | int]] = {}
|
33
47
|
self.setID()
|
34
|
-
self.standardTags = {
|
35
|
-
|
36
|
-
|
37
|
-
logger.debug(f"Question {self.id} is initialized")
|
48
|
+
self.standardTags = {"hidden": "false"}
|
49
|
+
self.logger = LogAdapterQuestionID(loggerObj, {"qID": self.id})
|
50
|
+
self.logger.debug("Sucess initializing")
|
38
51
|
|
39
|
-
def __repr__(self)->str:
|
40
|
-
li:list[str] = []
|
52
|
+
def __repr__(self) -> str:
|
53
|
+
li: list[str] = []
|
41
54
|
li.append(f"Question v{self.category.version}")
|
42
|
-
li.append(f
|
43
|
-
li.append(f
|
55
|
+
li.append(f"{self.id=}")
|
56
|
+
li.append(f"{self.parent=}")
|
44
57
|
return "\n".join(li)
|
45
58
|
|
46
|
-
def assemble(self, variant:int=1)->None:
|
47
|
-
textElements:list[ET.Element] = []
|
59
|
+
def assemble(self, variant: int = 1) -> None:
|
60
|
+
textElements: list[ET.Element] = []
|
48
61
|
textElements.extend(self.qtextParagraphs)
|
49
|
-
logger.debug(
|
62
|
+
self.logger.debug("Starting assembly")
|
50
63
|
if self.element is not None:
|
51
64
|
mainText = self.element.find(XMLTags.QTEXT)
|
52
|
-
logger.debug(f"found existing Text in element {mainText
|
65
|
+
self.logger.debug(f"found existing Text in element {mainText=}")
|
53
66
|
txtele = mainText.find("text")
|
54
67
|
if txtele is not None:
|
55
68
|
mainText.remove(txtele)
|
56
|
-
logger.debug(
|
57
|
-
else: raise QNotParsedException("Cant assamble, if element is none", self.id)
|
58
|
-
if self.variants is not None:
|
59
|
-
textElements.append(self.getBPointVariant(variant-1))
|
69
|
+
self.logger.debug("removed previously existing questiontext")
|
60
70
|
else:
|
71
|
+
msg = "Cant assamble, if element is none"
|
72
|
+
raise QNotParsedException(msg, self.id)
|
73
|
+
if self.variants is not None:
|
74
|
+
textElements.append(self.getBPointVariant(variant - 1))
|
75
|
+
elif self.bulletList is not None:
|
61
76
|
textElements.append(self.bulletList)
|
62
77
|
if hasattr(self, "picture") and self.picture.ready:
|
63
78
|
textElements.append(self.picture.htmlTag)
|
64
79
|
mainText.append(self.picture.element)
|
65
80
|
mainText.append(etHelpers.getCdatTxtElement(textElements))
|
66
81
|
# self.element.insert(3, mainText)
|
67
|
-
logger.debug(
|
68
|
-
if len(
|
82
|
+
self.logger.debug("inserted MainText to element")
|
83
|
+
if len(self.answerVariants) > 0:
|
69
84
|
ans = self.element.find(XMLTags.ANSWER)
|
70
85
|
if ans is not None:
|
71
86
|
self.element.remove(ans)
|
72
|
-
logger.debug("removed previous answer element")
|
73
|
-
self.element.insert(5, self.answerVariants[variant-1])
|
74
|
-
return None
|
87
|
+
self.logger.debug("removed previous answer element")
|
88
|
+
self.element.insert(5, self.answerVariants[variant - 1])
|
75
89
|
|
76
|
-
def setID(self, id
|
90
|
+
def setID(self, id=0) -> None:
|
77
91
|
if id == 0:
|
78
92
|
self.id: str = f"{self.category.id}{self.number:02d}"
|
79
|
-
else:
|
93
|
+
else:
|
94
|
+
self.id: str = str(id)
|
80
95
|
|
81
|
-
def getBPointVariant(self, variant:int)->ET.Element:
|
96
|
+
def getBPointVariant(self, variant: int) -> ET.Element:
|
82
97
|
if self.bulletList is None:
|
83
98
|
return None
|
84
|
-
|
99
|
+
# matches {a}, {some_var}, etc.
|
100
|
+
varPlaceholder = re.compile(r"{(\w+)}")
|
85
101
|
|
86
|
-
def replaceMatch(match: Match[str])->str|int|float:
|
102
|
+
def replaceMatch(match: Match[str]) -> str | int | float:
|
87
103
|
key = match.group(1)
|
88
104
|
if key in self.variables:
|
89
105
|
value = self.variables[key][variant]
|
90
|
-
return f"{value}".replace(".",",\\!")
|
106
|
+
return f"{value}".replace(".", ",\\!")
|
91
107
|
return match.group(0) # keep original if no match
|
92
108
|
|
93
109
|
unorderedList = TextElements.ULIST.create()
|
@@ -95,52 +111,67 @@ class Question():
|
|
95
111
|
listItemText = li.text or ""
|
96
112
|
bullet = TextElements.LISTITEM.create()
|
97
113
|
bullet.text = varPlaceholder.sub(replaceMatch, listItemText)
|
98
|
-
logger.debug(f"Inserted Variables into List: {bullet}")
|
114
|
+
self.logger.debug(f"Inserted Variables into List: {bullet}")
|
99
115
|
unorderedList.append(bullet)
|
100
116
|
return unorderedList
|
101
117
|
|
102
118
|
|
103
|
-
class Picture
|
104
|
-
def __init__(self, picKey:str, imgFolder:Path, question:Question):
|
119
|
+
class Picture:
|
120
|
+
def __init__(self, picKey: str, imgFolder: Path, question: Question) -> None:
|
105
121
|
self.pic = picKey
|
106
|
-
self.ready:bool = False
|
122
|
+
self.ready: bool = False
|
107
123
|
self.question = question
|
124
|
+
self.logger = LogAdapterQuestionID(loggerObj, {"qID": self.question.id})
|
108
125
|
self.imgFolder = (imgFolder / question.katName).resolve()
|
109
|
-
self.htmlTag:ET.Element
|
110
|
-
self.path:Path
|
126
|
+
self.htmlTag: ET.Element
|
127
|
+
self.path: Path
|
111
128
|
self._setPath()
|
112
|
-
if hasattr(self,
|
129
|
+
if hasattr(self, "picID"):
|
113
130
|
self.ready = self.__getImg()
|
114
131
|
|
115
|
-
def _setPath(self):
|
132
|
+
def _setPath(self) -> None:
|
116
133
|
if self.pic == 1:
|
117
134
|
self.picID = self.question.id
|
118
135
|
else:
|
119
136
|
selectedPic = self.pic[2:]
|
120
|
-
logger.debug(
|
137
|
+
self.logger.debug("Got the picture key: %s", selectedPic)
|
121
138
|
try:
|
122
139
|
self.picID = f"{self.question.category.id}{int(selectedPic):02d}"
|
123
140
|
except ValueError as e:
|
124
|
-
logger.warning(
|
141
|
+
self.logger.warning(
|
142
|
+
msg=f"Bild-ID konnte aus dem Key: {self.pic} nicht festgestellt werden",
|
143
|
+
exc_info=e,
|
144
|
+
)
|
125
145
|
|
126
146
|
def __getBase64Img(self, imgPath):
|
127
|
-
with open(imgPath,
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
self.
|
134
|
-
|
147
|
+
with open(imgPath, "rb") as img:
|
148
|
+
return base64.b64encode(img.read()).decode("utf-8")
|
149
|
+
|
150
|
+
def __setImgElement(self, dir: Path, picID: int) -> None:
|
151
|
+
"""Gibt das Bild im dirPath mit dir qID als base64 encodiert mit den entsprechenden XML-Tags zurück."""
|
152
|
+
self.path: Path = (dir / str(picID)).with_suffix(".svg")
|
153
|
+
self.element: ET.Element = ET.Element(
|
154
|
+
"file",
|
155
|
+
name=f"{self.path.name}",
|
156
|
+
path="/",
|
157
|
+
encoding="base64",
|
158
|
+
)
|
135
159
|
self.element.text = self.__getBase64Img(self.path)
|
136
160
|
|
137
|
-
|
138
|
-
def __getImg(self)->bool:
|
161
|
+
def __getImg(self) -> bool:
|
139
162
|
try:
|
140
163
|
self.__setImgElement(self.imgFolder, int(self.picID))
|
141
|
-
self.htmlTag = ET.Element(
|
164
|
+
self.htmlTag = ET.Element(
|
165
|
+
"img",
|
166
|
+
src=f"@@PLUGINFILE@@/{self.path.name}",
|
167
|
+
alt=f"Bild {self.path.name}",
|
168
|
+
width="500",
|
169
|
+
)
|
142
170
|
return True
|
143
171
|
except FileNotFoundError as e:
|
144
|
-
logger.warning(
|
172
|
+
self.logger.warning(
|
173
|
+
msg=f"Bild {self.picID} konnte nicht gefunden werden ",
|
174
|
+
exc_info=e,
|
175
|
+
)
|
145
176
|
self.element = None
|
146
177
|
return False
|
@@ -1,51 +1,57 @@
|
|
1
|
-
"""This Module checks if the data inside the Spreadsheet is valid
|
1
|
+
"""This Module checks if the data inside the Spreadsheet is valid.
|
2
2
|
|
3
3
|
Those things are considered:
|
4
4
|
|
5
5
|
#. The mandatory entries must not be ``Nan``
|
6
6
|
#. All fields must have the right data-type
|
7
7
|
|
8
|
-
If Those checks pass, a question is created,
|
8
|
+
If Those checks pass, a question is created,
|
9
|
+
which can be accessed via ``Validator.question``
|
9
10
|
"""
|
10
11
|
|
11
|
-
|
12
|
+
import logging
|
13
|
+
from typing import TYPE_CHECKING
|
12
14
|
|
13
|
-
from pandas.core.series import notna
|
14
|
-
from excel2moodle.core.question import Question
|
15
|
-
from excel2moodle.core.globals import DFIndex
|
16
|
-
from excel2moodle.core.exceptions import InvalidFieldException, NanException
|
17
15
|
import pandas as pd
|
18
|
-
|
16
|
+
|
17
|
+
from excel2moodle.core.exceptions import InvalidFieldException
|
18
|
+
from excel2moodle.core.globals import DFIndex
|
19
|
+
from excel2moodle.core.question import Question
|
20
|
+
|
21
|
+
if TYPE_CHECKING:
|
22
|
+
from types import UnionType
|
19
23
|
|
20
24
|
logger = logging.getLogger(__name__)
|
21
25
|
|
22
26
|
|
23
|
-
class Validator
|
27
|
+
class Validator:
|
28
|
+
"""Validate the question data from the spreadsheet.
|
29
|
+
|
30
|
+
Creates a dictionary with the data, for easier access later.
|
31
|
+
"""
|
32
|
+
|
24
33
|
def __init__(self, category) -> None:
|
25
|
-
self.question:Question
|
34
|
+
self.question: Question
|
26
35
|
self.category = category
|
27
|
-
self.mandatory: dict[DFIndex, type|UnionType] = {
|
36
|
+
self.mandatory: dict[DFIndex, type | UnionType] = {
|
28
37
|
DFIndex.TEXT: str,
|
29
38
|
DFIndex.NAME: str,
|
30
39
|
DFIndex.TYPE: str,
|
31
40
|
}
|
32
|
-
self.optional: dict[DFIndex, type|UnionType] = {
|
33
|
-
DFIndex.BPOINTS
|
34
|
-
DFIndex.
|
35
|
-
DFIndex.PICTURE: int|str,
|
36
|
-
}
|
37
|
-
self.nfOpt: dict[DFIndex, type|UnionType] = {
|
38
|
-
DFIndex.RESULT: float|int,
|
39
|
-
}
|
40
|
-
self.nfMand: dict[DFIndex, type|UnionType] = {
|
41
|
-
}
|
42
|
-
self.nfmOpt: dict[DFIndex, type|UnionType] = {
|
41
|
+
self.optional: dict[DFIndex, type | UnionType] = {
|
42
|
+
DFIndex.BPOINTS: str,
|
43
|
+
DFIndex.PICTURE: int | str,
|
43
44
|
}
|
44
|
-
self.
|
45
|
+
self.nfOpt: dict[DFIndex, type | UnionType] = {}
|
46
|
+
self.nfMand: dict[DFIndex, type | UnionType] = {
|
47
|
+
DFIndex.RESULT: float | int,
|
45
48
|
}
|
46
|
-
self.
|
49
|
+
self.nfmOpt: dict[DFIndex, type | UnionType] = {}
|
50
|
+
self.nfmMand: dict[DFIndex, type | UnionType] = {
|
51
|
+
DFIndex.RESULT: str,
|
47
52
|
}
|
48
|
-
self.
|
53
|
+
self.mcOpt: dict[DFIndex, type | UnionType] = {}
|
54
|
+
self.mcMand: dict[DFIndex, type | UnionType] = {
|
49
55
|
DFIndex.TRUE: str,
|
50
56
|
DFIndex.FALSE: str,
|
51
57
|
DFIndex.ANSTYPE: str,
|
@@ -57,15 +63,15 @@ class Validator():
|
|
57
63
|
"NFM": (self.nfmOpt, self.nfmMand),
|
58
64
|
}
|
59
65
|
|
60
|
-
def setup(self, df:pd.Series, index:int)->bool:
|
66
|
+
def setup(self, df: pd.Series, index: int) -> bool:
|
61
67
|
self.df = df
|
62
68
|
self.index = index
|
63
69
|
typ = self.df.loc[DFIndex.TYPE]
|
64
70
|
self.mandatory.update(self.mapper[typ][1])
|
65
71
|
self.optional.update(self.mapper[typ][0])
|
66
72
|
return True
|
67
|
-
|
68
|
-
def validate(self
|
73
|
+
|
74
|
+
def validate(self) -> bool:
|
69
75
|
id = f"{self.category.id}{self.index:02d}"
|
70
76
|
checker, missing = self._mandatory()
|
71
77
|
if not checker:
|
@@ -74,32 +80,31 @@ class Validator():
|
|
74
80
|
raise InvalidFieldException(msg, id, missing)
|
75
81
|
checker, missing = self._typeCheck()
|
76
82
|
if not checker:
|
77
|
-
msg = f"Question {id}
|
83
|
+
msg = f"Question {id} has wrong typed data {missing}"
|
78
84
|
if missing is not None:
|
79
85
|
raise InvalidFieldException(msg, id, missing)
|
80
86
|
self._getQuestion()
|
81
87
|
self._getData()
|
82
88
|
return True
|
83
89
|
|
84
|
-
def _getData(self)->None:
|
85
|
-
self.qdata:dict[str, str|float|int|list]={}
|
90
|
+
def _getData(self) -> None:
|
91
|
+
self.qdata: dict[str, str | float | int | list] = {}
|
86
92
|
for idx, val in self.df.items():
|
87
93
|
if not isinstance(idx, str):
|
88
|
-
logger.debug(f"Got a non String key in the spreadsheet, skipping it")
|
89
94
|
continue
|
90
95
|
if idx in self.qdata:
|
91
96
|
if isinstance(self.qdata[idx], list):
|
92
|
-
self.qdata[idx].append(val)
|
97
|
+
self.qdata[idx].append(val)
|
93
98
|
else:
|
94
99
|
existing = self.qdata[idx]
|
95
100
|
self.qdata[idx] = [existing, val]
|
96
101
|
else:
|
97
|
-
self.qdata[idx]=val
|
102
|
+
self.qdata[idx] = val
|
98
103
|
|
99
|
-
def _mandatory(self)->tuple[bool,DFIndex|None]:
|
100
|
-
"""
|
104
|
+
def _mandatory(self) -> tuple[bool, DFIndex | None]:
|
105
|
+
"""Detects if all keys of mandatory are filled with values."""
|
101
106
|
checker = pd.Series.notna(self.df)
|
102
|
-
for k in self.mandatory
|
107
|
+
for k in self.mandatory:
|
103
108
|
try:
|
104
109
|
c = checker[k]
|
105
110
|
except KeyError:
|
@@ -108,35 +113,32 @@ class Validator():
|
|
108
113
|
if not c.any():
|
109
114
|
return False, k
|
110
115
|
elif not c:
|
111
|
-
return False, k
|
116
|
+
return False, k
|
112
117
|
return True, None
|
113
118
|
|
114
|
-
def _typeCheck(self)->tuple[bool, list[DFIndex]|None]:
|
115
|
-
invalid:list[DFIndex] = []
|
119
|
+
def _typeCheck(self) -> tuple[bool, list[DFIndex] | None]:
|
120
|
+
invalid: list[DFIndex] = []
|
116
121
|
for field, typ in self.mandatory.items():
|
117
122
|
if isinstance(self.df[field], pd.Series):
|
118
123
|
for f in self.df[field]:
|
119
|
-
if pd.notna(f):
|
120
|
-
|
121
|
-
invalid.append(field)
|
124
|
+
if pd.notna(f) and not isinstance(f, typ):
|
125
|
+
invalid.append(field)
|
122
126
|
elif not isinstance(self.df[field], typ):
|
123
127
|
invalid.append(field)
|
124
128
|
for field, typ in self.optional.items():
|
125
129
|
if field in self.df:
|
126
|
-
if not isinstance(self.df[field], typ):
|
130
|
+
if not isinstance(self.df[field], typ) and pd.notna(self.df[field]):
|
127
131
|
invalid.append(field)
|
128
132
|
if len(invalid) == 0:
|
129
133
|
return True, None
|
130
|
-
|
131
|
-
return False, invalid
|
134
|
+
return False, invalid
|
132
135
|
|
133
|
-
|
134
|
-
def _getQuestion(self)->None:
|
136
|
+
def _getQuestion(self) -> None:
|
135
137
|
name = self.df[DFIndex.NAME]
|
136
138
|
qtype = self.df[DFIndex.TYPE]
|
137
|
-
self.question=Question(
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
139
|
+
self.question = Question(
|
140
|
+
self.category,
|
141
|
+
name=str(name),
|
142
|
+
number=self.index,
|
143
|
+
qtype=str(qtype),
|
144
|
+
)
|