excel2moodle 0.6.2__py3-none-any.whl → 0.6.4__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 +5 -6
- excel2moodle/core/category.py +4 -3
- excel2moodle/core/dataStructure.py +29 -27
- excel2moodle/core/parser.py +19 -1
- excel2moodle/core/question.py +14 -6
- excel2moodle/core/settings.py +9 -2
- excel2moodle/core/validator.py +30 -20
- excel2moodle/extra/updateQuery.py +48 -0
- excel2moodle/extra/variableGenerator.py +73 -49
- excel2moodle/question_types/cloze.py +5 -6
- excel2moodle/ui/UI_updateDlg.py +110 -0
- excel2moodle/ui/appUi.py +46 -18
- excel2moodle/ui/dialogs.py +18 -1
- excel2moodle/ui/treewidget.py +30 -10
- {excel2moodle-0.6.2.dist-info → excel2moodle-0.6.4.dist-info}/METADATA +28 -1
- {excel2moodle-0.6.2.dist-info → excel2moodle-0.6.4.dist-info}/RECORD +22 -20
- {excel2moodle-0.6.2.dist-info → excel2moodle-0.6.4.dist-info}/WHEEL +0 -0
- {excel2moodle-0.6.2.dist-info → excel2moodle-0.6.4.dist-info}/entry_points.txt +0 -0
- {excel2moodle-0.6.2.dist-info → excel2moodle-0.6.4.dist-info}/licenses/LICENSE +0 -0
- {excel2moodle-0.6.2.dist-info → excel2moodle-0.6.4.dist-info}/top_level.txt +0 -0
excel2moodle/__init__.py
CHANGED
@@ -45,6 +45,8 @@ if __package__ is not None:
|
|
45
45
|
"documentation": "https://jbosse3.gitlab.io/excel2moodle",
|
46
46
|
"homepage": meta["project-url"].split()[1],
|
47
47
|
"issues": "https://gitlab.com/jbosse3/excel2moodle/issues",
|
48
|
+
"funding": "https://ko-fi.com/jbosse3",
|
49
|
+
"API_id": "jbosse3%2Fexcel2moodle",
|
48
50
|
}
|
49
51
|
|
50
52
|
|
excel2moodle/__main__.py
CHANGED
@@ -5,17 +5,20 @@ import signal
|
|
5
5
|
import sys
|
6
6
|
from pathlib import Path
|
7
7
|
|
8
|
+
from PySide6.QtCore import QTimer
|
8
9
|
from PySide6.QtWidgets import QApplication
|
9
10
|
|
10
11
|
import excel2moodle
|
11
|
-
from excel2moodle import e2mMetadata, mainLogger
|
12
|
+
from excel2moodle import __version__, e2mMetadata, mainLogger
|
12
13
|
from excel2moodle.core import dataStructure
|
13
14
|
from excel2moodle.core.settings import Settings, Tags
|
15
|
+
from excel2moodle.extra import updateQuery
|
14
16
|
from excel2moodle.logger import loggerConfig
|
15
17
|
from excel2moodle.ui import appUi as ui
|
16
18
|
|
17
19
|
|
18
20
|
def main() -> None:
|
21
|
+
app = QApplication(sys.argv)
|
19
22
|
excel2moodle.isMain = True
|
20
23
|
settings = Settings()
|
21
24
|
logfile = Path(settings.get(Tags.LOGFILE)).resolve()
|
@@ -24,12 +27,24 @@ def main() -> None:
|
|
24
27
|
logfile.replace(f"{logfile}.old")
|
25
28
|
logConfig.dictConfig(config=loggerConfig)
|
26
29
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
27
|
-
app = QApplication(sys.argv)
|
28
|
-
settings = Settings()
|
29
30
|
database: dataStructure.QuestionDB = dataStructure.QuestionDB(settings)
|
30
31
|
window = ui.MainWindow(settings, database)
|
31
32
|
database.window = window
|
32
33
|
window.show()
|
34
|
+
# Update check
|
35
|
+
latestTag = updateQuery.get_latest_tag(e2mMetadata.get("API_id"))
|
36
|
+
if latestTag is None:
|
37
|
+
mainLogger.warning(
|
38
|
+
"Couldn't check for Updates, maybe there is no internet connection"
|
39
|
+
)
|
40
|
+
if latestTag is not None and latestTag.strip("v") != __version__:
|
41
|
+
mainLogger.warning(
|
42
|
+
"A new Update is available: %s --> %s ", __version__, latestTag
|
43
|
+
)
|
44
|
+
changelog = updateQuery.get_changelog(e2mMetadata.get("API_id"))
|
45
|
+
# Delay showing the update dialog slightly
|
46
|
+
QTimer.singleShot(100, lambda: window.showUpdateDialog(changelog, latestTag))
|
47
|
+
|
33
48
|
for k, v in e2mMetadata.items():
|
34
49
|
msg = f"{k:^14s}: {v}"
|
35
50
|
mainLogger.info(msg)
|
excel2moodle/core/bullets.py
CHANGED
@@ -5,7 +5,7 @@ import lxml.etree as ET
|
|
5
5
|
|
6
6
|
from excel2moodle.core import stringHelpers
|
7
7
|
from excel2moodle.core.globals import TextElements
|
8
|
-
from excel2moodle.core.question import ParametricQuestion
|
8
|
+
from excel2moodle.core.question import ParametricQuestion, Parametrics
|
9
9
|
from excel2moodle.logger import LogAdapterQuestionID
|
10
10
|
|
11
11
|
loggerObj = logging.getLogger(__name__)
|
@@ -15,14 +15,13 @@ class BulletList:
|
|
15
15
|
def __init__(self, rawBullets: list[str], qID: str) -> None:
|
16
16
|
self.rawBullets: list[str] = rawBullets
|
17
17
|
self.element: ET.Element = ET.Element("ul")
|
18
|
-
self.bullets: dict[str, BulletP] = {}
|
18
|
+
self.bullets: dict[str | int, BulletP] = {}
|
19
19
|
self.id = qID
|
20
20
|
self.logger = LogAdapterQuestionID(loggerObj, {"qID": self.id})
|
21
21
|
self._setupBullets(rawBullets)
|
22
22
|
|
23
|
-
def
|
24
|
-
|
25
|
-
) -> None:
|
23
|
+
def update(self, parametrics: Parametrics, variant: int = 1) -> None:
|
24
|
+
variables: dict[str, list[float]] = parametrics.variables
|
26
25
|
for var, bullet in self.bullets.items():
|
27
26
|
bullet.update(value=variables[var][variant - 1])
|
28
27
|
|
@@ -70,7 +69,7 @@ class BulletList:
|
|
70
69
|
if match is None:
|
71
70
|
self.logger.debug("Got a normal bulletItem")
|
72
71
|
num: float = float(quant.replace(",", "."))
|
73
|
-
bulletName
|
72
|
+
bulletName = i + 1
|
74
73
|
else:
|
75
74
|
bulletName = match.group(1)
|
76
75
|
num: float = 0.0
|
excel2moodle/core/category.py
CHANGED
@@ -24,8 +24,9 @@ class Category:
|
|
24
24
|
) -> None:
|
25
25
|
"""Instantiate a new Category object."""
|
26
26
|
self.NAME = name
|
27
|
-
match = re.search(r"\d
|
28
|
-
|
27
|
+
match = re.search(r"\d+", str(self.NAME))
|
28
|
+
n = int(match.group(0)) if match else 99
|
29
|
+
self.n: int = n if n <= 99 and n >= 0 else 99
|
29
30
|
self.desc = str(description)
|
30
31
|
self.dataframe: pd.DataFrame = dataframe
|
31
32
|
self.settings: dict[str, float | str] = settings if settings else {}
|
@@ -52,7 +53,7 @@ class Category:
|
|
52
53
|
@property
|
53
54
|
def questions(self) -> dict:
|
54
55
|
if not hasattr(self, "_questions"):
|
55
|
-
msg = "Category
|
56
|
+
msg = f"Category {self.id} doesn't contain any valid questions."
|
56
57
|
raise ValueError(msg)
|
57
58
|
return self._questions
|
58
59
|
|
@@ -105,39 +105,29 @@ class QuestionDB:
|
|
105
105
|
When there is no 'seetings' worksheet in the file.
|
106
106
|
InvalidFieldException
|
107
107
|
When the settings are invalid
|
108
|
+
Or When the categories Sheet doesn't provide the necessary keys.
|
108
109
|
|
109
110
|
Before raising it logges the exceptions with a meaningful message.
|
110
111
|
|
111
112
|
"""
|
112
113
|
sheetPath = sheetPath if sheetPath else self.spreadsheet
|
113
114
|
logger.info("Start Parsing the Excel Metadata Sheet\n")
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
)
|
122
|
-
except ValueError:
|
123
|
-
logger.exception(
|
124
|
-
"Did you forget to specify a 'settings' sheet in the file?"
|
115
|
+
with Path(sheetPath).open("rb") as f:
|
116
|
+
settingDf = pd.read_excel(
|
117
|
+
f,
|
118
|
+
sheet_name="settings",
|
119
|
+
index_col=0,
|
120
|
+
header=None,
|
121
|
+
engine="calamine",
|
125
122
|
)
|
126
|
-
raise
|
127
123
|
logger.debug("Found the settings: \n\t%s", settingDf)
|
128
124
|
settingDf = self.harmonizeDFIndex(settingDf)
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
125
|
+
settingsDict = Validator.dfToDict(settingDf[1])
|
126
|
+
Validator.listify(settingsDict)
|
127
|
+
for tag, value in settingsDict.items():
|
128
|
+
self.settings.set(tag, value)
|
133
129
|
|
134
|
-
|
135
|
-
self._validateProjectSettings(sheetPath=sheetPath)
|
136
|
-
except InvalidFieldException:
|
137
|
-
logger.exception(
|
138
|
-
"Can not create the database with invalid project settings."
|
139
|
-
)
|
140
|
-
raise
|
130
|
+
self._validateProjectSettings(sheetPath=sheetPath)
|
141
131
|
with Path(sheetPath).open("rb") as f:
|
142
132
|
self.categoriesMetaData = pd.read_excel(
|
143
133
|
f,
|
@@ -145,10 +135,20 @@ class QuestionDB:
|
|
145
135
|
index_col=0,
|
146
136
|
engine="calamine",
|
147
137
|
)
|
148
|
-
|
138
|
+
if "description" not in self.categoriesMetaData.columns:
|
139
|
+
msg = f"You need to specify the 'description' tag for each category in the sheet '{self.settings.get(Tags.CATEGORIESSHEET)}'."
|
140
|
+
raise InvalidFieldException(msg, "0000", "description")
|
141
|
+
logger.info("Sucessfully read categoriesMetaData")
|
149
142
|
return self.categoriesMetaData
|
150
143
|
|
151
144
|
def _validateProjectSettings(self, sheetPath: Path) -> None:
|
145
|
+
if Tags.LOGLEVEL in self.settings:
|
146
|
+
level: str = self.settings.get(Tags.LOGLEVEL)
|
147
|
+
if level.upper() not in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
148
|
+
self.settings.pop(Tags.LOGLEVEL)
|
149
|
+
logger.warning("You specified an unsupported Loglevel: %s", level)
|
150
|
+
if self.window is not None:
|
151
|
+
self.window.logHandler.setLevel(self.settings.get(Tags.LOGLEVEL).upper())
|
152
152
|
if Tags.IMPORTMODULE in self.settings:
|
153
153
|
logger.warning(
|
154
154
|
"Appending: %s to sys.path. All names defined by it will be usable",
|
@@ -297,7 +297,7 @@ class QuestionDB:
|
|
297
297
|
for qNum in category.dataframe.columns:
|
298
298
|
try:
|
299
299
|
self.setupAndParseQuestion(category, qNum)
|
300
|
-
except (InvalidFieldException, QNotParsedException):
|
300
|
+
except (InvalidFieldException, QNotParsedException, AttributeError):
|
301
301
|
logger.exception(
|
302
302
|
"Question %s%02d couldn't be parsed. The Question Data: \n %s",
|
303
303
|
category.id,
|
@@ -379,10 +379,10 @@ class QuestionDB:
|
|
379
379
|
catdict: dict[Category, list[Question]] = {}
|
380
380
|
for q in questions:
|
381
381
|
logger.debug(f"got a question to append {q=}")
|
382
|
-
cat = q.parent().
|
382
|
+
cat = q.parent().category
|
383
383
|
if cat not in catdict:
|
384
384
|
catdict[cat] = []
|
385
|
-
catdict[cat].append(q.
|
385
|
+
catdict[cat].append(q.question)
|
386
386
|
for cat, qlist in catdict.items():
|
387
387
|
self._appendQElements(
|
388
388
|
cat,
|
@@ -423,6 +423,8 @@ class QuestionDB:
|
|
423
423
|
else:
|
424
424
|
logger.warning("Keine Fragenvariante wurde gewählt.")
|
425
425
|
tree.append(q.getUpdatedElement(variant=variant))
|
426
|
+
else:
|
427
|
+
tree.append(q.getUpdatedElement(variant=variant))
|
426
428
|
self._exportedQuestions.append(q)
|
427
429
|
|
428
430
|
def generateExportReport(
|
excel2moodle/core/parser.py
CHANGED
@@ -11,8 +11,9 @@ from excel2moodle.core.globals import (
|
|
11
11
|
XMLTags,
|
12
12
|
feedBElements,
|
13
13
|
)
|
14
|
-
from excel2moodle.core.question import Picture, Question
|
14
|
+
from excel2moodle.core.question import ParametricQuestion, Picture, Question
|
15
15
|
from excel2moodle.core.settings import Settings, Tags
|
16
|
+
from excel2moodle.extra.scriptCaller import MediaCall
|
16
17
|
from excel2moodle.logger import LogAdapterQuestionID
|
17
18
|
|
18
19
|
loggerObj = logging.getLogger(__name__)
|
@@ -129,9 +130,13 @@ class QuestionParser:
|
|
129
130
|
)
|
130
131
|
self.htmlRoot.append(bullets.element)
|
131
132
|
self.question.bulletList = bullets
|
133
|
+
if isinstance(self.question, ParametricQuestion):
|
134
|
+
self.question.updateQue = [bullets]
|
132
135
|
if self.hasPicture():
|
133
136
|
self.htmlRoot.append(self.question.picture.htmlTag)
|
134
137
|
textRootElem.append(self.question.picture.element)
|
138
|
+
if Tags.MEDIACALL in self.rawInput:
|
139
|
+
self.insertScriptedMedia()
|
135
140
|
ansList = self._parseAnswers()
|
136
141
|
if ansList is not None:
|
137
142
|
for ele in ansList:
|
@@ -166,6 +171,19 @@ class QuestionParser:
|
|
166
171
|
ele.append(eth.getCdatTxtElement(par))
|
167
172
|
return ele
|
168
173
|
|
174
|
+
def insertScriptedMedia(self) -> None:
|
175
|
+
"""Load the scripts, insert the div and call a Function."""
|
176
|
+
for script in self.rawInput.get(Tags.MEDIASCRIPTS):
|
177
|
+
ET.SubElement(
|
178
|
+
self.htmlRoot, "script", type="text/javascript", src=script
|
179
|
+
).text = ""
|
180
|
+
divId = f"scriptedMedia-{self.question.id}"
|
181
|
+
ET.SubElement(self.htmlRoot, "div", id=divId).text = ""
|
182
|
+
scriptCall = MediaCall(self.rawInput.get(Tags.MEDIACALL), divId=divId)
|
183
|
+
self.htmlRoot.append(scriptCall.element)
|
184
|
+
if isinstance(self.question, ParametricQuestion):
|
185
|
+
self.question.updateQue.append(scriptCall)
|
186
|
+
|
169
187
|
def _parseAnswers(self) -> list[ET.Element] | None:
|
170
188
|
"""Needs to be implemented in the type-specific subclasses."""
|
171
189
|
return None
|
excel2moodle/core/question.py
CHANGED
@@ -53,7 +53,14 @@ class QuestionData(dict):
|
|
53
53
|
@overload
|
54
54
|
def get(
|
55
55
|
self,
|
56
|
-
key: Literal[
|
56
|
+
key: Literal[
|
57
|
+
Tags.BPOINTS,
|
58
|
+
Tags.TRUE,
|
59
|
+
Tags.FALSE,
|
60
|
+
Tags.QUESTIONPART,
|
61
|
+
Tags.TEXT,
|
62
|
+
Tags.MEDIASCRIPTS,
|
63
|
+
],
|
57
64
|
default: object = None,
|
58
65
|
) -> list: ...
|
59
66
|
@overload
|
@@ -115,6 +122,7 @@ class Question:
|
|
115
122
|
}
|
116
123
|
optionalTags: ClassVar[dict[Tags, type | UnionType]] = {
|
117
124
|
Tags.PICTURE: int | str,
|
125
|
+
Tags.MEDIACALL: list,
|
118
126
|
}
|
119
127
|
|
120
128
|
def __init_subclass__(cls, **kwargs) -> None:
|
@@ -202,6 +210,7 @@ class ParametricQuestion(Question):
|
|
202
210
|
self.rules: list[str] = []
|
203
211
|
self.parametrics: Parametrics
|
204
212
|
self._variant: int
|
213
|
+
self.updateQue: list
|
205
214
|
|
206
215
|
@property
|
207
216
|
def currentVariant(self) -> int:
|
@@ -214,13 +223,12 @@ class ParametricQuestion(Question):
|
|
214
223
|
`Question` returns the Element.
|
215
224
|
|
216
225
|
"""
|
217
|
-
if not hasattr(self, "
|
218
|
-
msg = "Can't assemble a parametric question, without the
|
226
|
+
if not hasattr(self, "updateQue"):
|
227
|
+
msg = "Can't assemble a parametric question, without the updateQue"
|
219
228
|
raise QNotParsedException(msg, self.id)
|
220
229
|
|
221
|
-
self.
|
222
|
-
|
223
|
-
)
|
230
|
+
for obj in self.updateQue:
|
231
|
+
obj.update(parametrics=self.parametrics, variant=variant)
|
224
232
|
self._variant = variant
|
225
233
|
return super().getUpdatedElement(variant)
|
226
234
|
|
excel2moodle/core/settings.py
CHANGED
@@ -96,9 +96,12 @@ class Tags(StrEnum):
|
|
96
96
|
PCORRECFB = "partialcorrectfeedback", str, "Your answer is partially right."
|
97
97
|
GENERALFB = "feedback", str, "You answered this question."
|
98
98
|
|
99
|
+
MEDIASCRIPTS = "mediascripts", list, None
|
100
|
+
MEDIACALL = "parametricmedia", str, None
|
101
|
+
|
99
102
|
|
100
103
|
class Settings:
|
101
|
-
values: ClassVar[dict[str, str | float | Path]] = {}
|
104
|
+
values: ClassVar[dict[str, str | float | Path | list]] = {}
|
102
105
|
|
103
106
|
def __contains__(self, tag: Tags) -> bool:
|
104
107
|
return bool(tag in type(self).values)
|
@@ -107,6 +110,10 @@ class Settings:
|
|
107
110
|
def clear(cls) -> None:
|
108
111
|
cls.values.clear()
|
109
112
|
|
113
|
+
@classmethod
|
114
|
+
def pop(cls, key: str):
|
115
|
+
return cls.values.pop(key)
|
116
|
+
|
110
117
|
@overload
|
111
118
|
@classmethod
|
112
119
|
def get(
|
@@ -160,7 +167,7 @@ class Settings:
|
|
160
167
|
default = key.default
|
161
168
|
if default is None:
|
162
169
|
return None
|
163
|
-
logger.
|
170
|
+
logger.debug("Returning the default value for %s", key)
|
164
171
|
return default
|
165
172
|
if key.typ() is Path:
|
166
173
|
path: Path = Path(raw)
|
excel2moodle/core/validator.py
CHANGED
@@ -54,31 +54,29 @@ class Validator:
|
|
54
54
|
if missing is not None:
|
55
55
|
raise InvalidFieldException(msg, qid, missing)
|
56
56
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
57
|
+
@staticmethod
|
58
|
+
def dfToDict(df: pd.Series) -> dict[str, int | float | list[str] | str]:
|
59
|
+
"""Convert a dataframe to dictionary, preserving lists."""
|
60
|
+
dic: dict[str, int | float | list[str] | str] = {}
|
61
|
+
for var, val in df.items():
|
62
|
+
if not isinstance(var, str):
|
62
63
|
continue
|
63
64
|
if pd.isna(val):
|
64
65
|
continue
|
65
|
-
if
|
66
|
-
if isinstance(
|
67
|
-
|
66
|
+
if var in dic:
|
67
|
+
if isinstance(dic[var], list):
|
68
|
+
dic[var].append(val)
|
68
69
|
else:
|
69
|
-
existing =
|
70
|
-
|
70
|
+
existing = dic[var]
|
71
|
+
dic[var] = [existing, val]
|
71
72
|
else:
|
72
|
-
|
73
|
-
return
|
74
|
-
|
75
|
-
def
|
76
|
-
"""
|
77
|
-
|
78
|
-
|
79
|
-
for key in self.qdata:
|
80
|
-
if key.startswith(tag) and not isinstance(self.qdata[key], list):
|
81
|
-
self.qdata[key] = stringHelpers.getListFromStr(self.qdata[key])
|
73
|
+
dic[var] = val
|
74
|
+
return dic
|
75
|
+
|
76
|
+
def getQuestionData(self) -> QuestionData:
|
77
|
+
"""Get the data from the spreadsheet as a dictionary."""
|
78
|
+
self.qdata = self.dfToDict(self.df)
|
79
|
+
self.listify(self.qdata)
|
82
80
|
tol = float(self.qdata.get(Tags.TOLERANCE, 0))
|
83
81
|
if tol < 0 or tol > 99:
|
84
82
|
tol = settings.get(Tags.TOLERANCE)
|
@@ -89,6 +87,18 @@ class Validator:
|
|
89
87
|
self.qdata[Tags.EQUATION] = str(self.qdata[Tags.RESULT])
|
90
88
|
return QuestionData(self.qdata)
|
91
89
|
|
90
|
+
@staticmethod
|
91
|
+
def listify(dictionary: dict) -> None:
|
92
|
+
"""Converts to list all tag values, which are supposed to be a list."""
|
93
|
+
for key in dictionary:
|
94
|
+
k: str = key.split(":")[0]
|
95
|
+
if k in Tags:
|
96
|
+
tag = Tags(k)
|
97
|
+
logger.info("Got the Tag %s from key: %s", tag, key)
|
98
|
+
if tag.typ() is list and not isinstance(dictionary[key], list):
|
99
|
+
dictionary[key] = stringHelpers.getListFromStr(dictionary[key])
|
100
|
+
logger.info("Converted Input to list for %s", key)
|
101
|
+
|
92
102
|
def _mandatory(self) -> tuple[bool, Tags | None]:
|
93
103
|
"""Detects if all keys of mandatory are filled with values."""
|
94
104
|
checker = pd.Series.notna(self.df)
|
@@ -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())
|
@@ -228,7 +228,7 @@ class ClozeQuestion(ParametricQuestion):
|
|
228
228
|
pts: int = 0
|
229
229
|
if not self.isParsed:
|
230
230
|
msg = "The Cloze question has no points because it is not yet parsed"
|
231
|
-
logger.warning(msg)
|
231
|
+
self.logger.warning(msg)
|
232
232
|
return pts
|
233
233
|
for p in self.questionParts.values():
|
234
234
|
pts = pts + p.points
|
@@ -315,7 +315,7 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
315
315
|
for part in parts.values():
|
316
316
|
part.points = point
|
317
317
|
else:
|
318
|
-
|
318
|
+
loclogger.warning(
|
319
319
|
"Some Answer parts are missing the points, they will get the standard points"
|
320
320
|
)
|
321
321
|
for num, part in parts.items():
|
@@ -328,7 +328,7 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
328
328
|
points = self.rawInput.get(Tags.POINTS)
|
329
329
|
corrPoints: int = round(points)
|
330
330
|
if not math.isclose(corrPoints, points):
|
331
|
-
logger.warning(
|
331
|
+
self.logger.warning(
|
332
332
|
"Type cloze supports only integers as points. %s was round to %s",
|
333
333
|
points,
|
334
334
|
corrPoints,
|
@@ -367,8 +367,7 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
367
367
|
result=result,
|
368
368
|
points=part.points,
|
369
369
|
)
|
370
|
-
self.logger.
|
371
|
-
logger.debug("Appended NF part %s result", partNum)
|
370
|
+
self.logger.debug("Generated %s answer part: %s ", partNum, ansStr)
|
372
371
|
elif part.typ == "MC":
|
373
372
|
ansStr = ClozePart.getMCAnsStr(
|
374
373
|
part.trueAnswers,
|
@@ -376,7 +375,7 @@ class ClozeQuestionParser(NFMQuestionParser):
|
|
376
375
|
points=part.points,
|
377
376
|
)
|
378
377
|
part.mcAnswerString = ansStr
|
379
|
-
logger.debug("Appended MC part %s: %s", partNum, ansStr)
|
378
|
+
self.logger.debug("Appended MC part %s: %s", partNum, ansStr)
|
380
379
|
else:
|
381
380
|
msg = "Type of the answer part is invalid"
|
382
381
|
raise QNotParsedException(msg, self.id)
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
################################################################################
|
4
|
+
## Form generated from reading UI file 'UI_updateDlg.ui'
|
5
|
+
##
|
6
|
+
## Created by: Qt User Interface Compiler version 6.9.0
|
7
|
+
##
|
8
|
+
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
9
|
+
################################################################################
|
10
|
+
|
11
|
+
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
12
|
+
QMetaObject, QObject, QPoint, QRect,
|
13
|
+
QSize, QTime, QUrl, Qt)
|
14
|
+
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
|
15
|
+
QFont, QFontDatabase, QGradient, QIcon,
|
16
|
+
QImage, QKeySequence, QLinearGradient, QPainter,
|
17
|
+
QPalette, QPixmap, QRadialGradient, QTransform)
|
18
|
+
from PySide6.QtWidgets import (QAbstractButton, QAbstractScrollArea, QApplication, QDialog,
|
19
|
+
QDialogButtonBox, QFrame, QLabel, QSizePolicy,
|
20
|
+
QTextBrowser, QVBoxLayout, QWidget)
|
21
|
+
|
22
|
+
class Ui_UpdateDialog(object):
|
23
|
+
def setupUi(self, UpdateDialog):
|
24
|
+
if not UpdateDialog.objectName():
|
25
|
+
UpdateDialog.setObjectName(u"UpdateDialog")
|
26
|
+
UpdateDialog.setWindowModality(Qt.WindowModality.NonModal)
|
27
|
+
UpdateDialog.resize(540, 512)
|
28
|
+
UpdateDialog.setModal(True)
|
29
|
+
self.verticalLayout = QVBoxLayout(UpdateDialog)
|
30
|
+
self.verticalLayout.setObjectName(u"verticalLayout")
|
31
|
+
self.titleLabel = QLabel(UpdateDialog)
|
32
|
+
self.titleLabel.setObjectName(u"titleLabel")
|
33
|
+
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
34
|
+
sizePolicy.setHorizontalStretch(0)
|
35
|
+
sizePolicy.setVerticalStretch(0)
|
36
|
+
sizePolicy.setHeightForWidth(self.titleLabel.sizePolicy().hasHeightForWidth())
|
37
|
+
self.titleLabel.setSizePolicy(sizePolicy)
|
38
|
+
|
39
|
+
self.verticalLayout.addWidget(self.titleLabel)
|
40
|
+
|
41
|
+
self.fundingLabel = QLabel(UpdateDialog)
|
42
|
+
self.fundingLabel.setObjectName(u"fundingLabel")
|
43
|
+
sizePolicy.setHeightForWidth(self.fundingLabel.sizePolicy().hasHeightForWidth())
|
44
|
+
self.fundingLabel.setSizePolicy(sizePolicy)
|
45
|
+
self.fundingLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
46
|
+
self.fundingLabel.setOpenExternalLinks(True)
|
47
|
+
|
48
|
+
self.verticalLayout.addWidget(self.fundingLabel)
|
49
|
+
|
50
|
+
self.line = QFrame(UpdateDialog)
|
51
|
+
self.line.setObjectName(u"line")
|
52
|
+
self.line.setFrameShape(QFrame.Shape.HLine)
|
53
|
+
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
54
|
+
|
55
|
+
self.verticalLayout.addWidget(self.line)
|
56
|
+
|
57
|
+
self.changelogLabel = QLabel(UpdateDialog)
|
58
|
+
self.changelogLabel.setObjectName(u"changelogLabel")
|
59
|
+
sizePolicy.setHeightForWidth(self.changelogLabel.sizePolicy().hasHeightForWidth())
|
60
|
+
self.changelogLabel.setSizePolicy(sizePolicy)
|
61
|
+
|
62
|
+
self.verticalLayout.addWidget(self.changelogLabel)
|
63
|
+
|
64
|
+
self.changelogBrowser = QTextBrowser(UpdateDialog)
|
65
|
+
self.changelogBrowser.setObjectName(u"changelogBrowser")
|
66
|
+
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
67
|
+
sizePolicy1.setHorizontalStretch(0)
|
68
|
+
sizePolicy1.setVerticalStretch(0)
|
69
|
+
sizePolicy1.setHeightForWidth(self.changelogBrowser.sizePolicy().hasHeightForWidth())
|
70
|
+
self.changelogBrowser.setSizePolicy(sizePolicy1)
|
71
|
+
self.changelogBrowser.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
72
|
+
self.changelogBrowser.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
73
|
+
self.changelogBrowser.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
|
74
|
+
|
75
|
+
self.verticalLayout.addWidget(self.changelogBrowser)
|
76
|
+
|
77
|
+
self.label = QLabel(UpdateDialog)
|
78
|
+
self.label.setObjectName(u"label")
|
79
|
+
sizePolicy.setHeightForWidth(self.label.sizePolicy().hasHeightForWidth())
|
80
|
+
self.label.setSizePolicy(sizePolicy)
|
81
|
+
font = QFont()
|
82
|
+
font.setPointSize(12)
|
83
|
+
font.setBold(True)
|
84
|
+
self.label.setFont(font)
|
85
|
+
|
86
|
+
self.verticalLayout.addWidget(self.label)
|
87
|
+
|
88
|
+
self.buttonBox = QDialogButtonBox(UpdateDialog)
|
89
|
+
self.buttonBox.setObjectName(u"buttonBox")
|
90
|
+
self.buttonBox.setOrientation(Qt.Orientation.Horizontal)
|
91
|
+
self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Ok)
|
92
|
+
|
93
|
+
self.verticalLayout.addWidget(self.buttonBox)
|
94
|
+
|
95
|
+
|
96
|
+
self.retranslateUi(UpdateDialog)
|
97
|
+
self.buttonBox.accepted.connect(UpdateDialog.accept)
|
98
|
+
self.buttonBox.rejected.connect(UpdateDialog.reject)
|
99
|
+
|
100
|
+
QMetaObject.connectSlotsByName(UpdateDialog)
|
101
|
+
# setupUi
|
102
|
+
|
103
|
+
def retranslateUi(self, UpdateDialog):
|
104
|
+
UpdateDialog.setWindowTitle(QCoreApplication.translate("UpdateDialog", u"Dialog", None))
|
105
|
+
self.titleLabel.setText(QCoreApplication.translate("UpdateDialog", u"<h2>A new <i>excel2moodle</i> version is available!</h2>", None))
|
106
|
+
self.fundingLabel.setText(QCoreApplication.translate("UpdateDialog", u"If you find this project useful, please consider supporting its development.", None))
|
107
|
+
self.changelogLabel.setText(QCoreApplication.translate("UpdateDialog", u"<h3>Changelog:</h3>", None))
|
108
|
+
self.label.setText(QCoreApplication.translate("UpdateDialog", u"To install the update execute: 'uv tool upgrade excel2moodle'", None))
|
109
|
+
# retranslateUi
|
110
|
+
|
excel2moodle/ui/appUi.py
CHANGED
@@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
|
|
21
21
|
from excel2moodle import e2mMetadata, mainLogger
|
22
22
|
from excel2moodle.core.category import Category
|
23
23
|
from excel2moodle.core.dataStructure import QuestionDB
|
24
|
+
from excel2moodle.core.exceptions import InvalidFieldException
|
24
25
|
from excel2moodle.core.question import ParametricQuestion
|
25
26
|
from excel2moodle.core.settings import Settings, Tags
|
26
27
|
from excel2moodle.extra.variableGenerator import VariableGeneratorDialog
|
@@ -34,15 +35,14 @@ from excel2moodle.ui.UI_mainWindow import Ui_MoodleTestGenerator
|
|
34
35
|
|
35
36
|
logger = logging.getLogger(__name__)
|
36
37
|
|
37
|
-
loggerSignal = LogWindowHandler()
|
38
|
-
mainLogger.addHandler(loggerSignal)
|
39
|
-
|
40
38
|
|
41
39
|
class MainWindow(QMainWindow):
|
42
40
|
def __init__(self, settings: Settings, testDB: QuestionDB) -> None:
|
43
41
|
super().__init__()
|
44
42
|
self.settings = settings
|
45
43
|
self.qSettings = QSettings("jbosse3", "excel2moodle")
|
44
|
+
self.logHandler = LogWindowHandler()
|
45
|
+
mainLogger.addHandler(self.logHandler)
|
46
46
|
logger.info("Settings are stored under: %s", self.qSettings.fileName())
|
47
47
|
|
48
48
|
self.excelPath: Path | None = None
|
@@ -93,7 +93,7 @@ class MainWindow(QMainWindow):
|
|
93
93
|
self.ui.checkBoxQuestionListSelectAll.checkStateChanged.connect(
|
94
94
|
self.toggleQuestionSelectionState,
|
95
95
|
)
|
96
|
-
|
96
|
+
self.logHandler.emitter.signal.connect(self.updateLog)
|
97
97
|
self.ui.actionEquationChecker.triggered.connect(self.openEqCheckerDlg)
|
98
98
|
self.ui.actionParseAll.triggered.connect(self.parseSpreadsheetAll)
|
99
99
|
self.testDB.signals.categoryQuestionsReady.connect(self.treeRefreshCategory)
|
@@ -110,6 +110,10 @@ class MainWindow(QMainWindow):
|
|
110
110
|
self.openSpreadsheetExternally
|
111
111
|
)
|
112
112
|
|
113
|
+
def showUpdateDialog(self, changelog, version) -> None:
|
114
|
+
dialog = dialogs.UpdateDialog(self, changelog=changelog, version=version)
|
115
|
+
dialog.exec()
|
116
|
+
|
113
117
|
@Slot()
|
114
118
|
def parseSpreadsheetAll(self) -> None:
|
115
119
|
"""Event triggered by the *Tools/Parse all Questions* Event.
|
@@ -159,7 +163,7 @@ class MainWindow(QMainWindow):
|
|
159
163
|
selection = self.ui.treeWidget.selectedItems()
|
160
164
|
for q in selection:
|
161
165
|
questions += 1
|
162
|
-
count += q.
|
166
|
+
count += q.question.points
|
163
167
|
self.ui.pointCounter.setValue(count)
|
164
168
|
self.ui.questionCounter.setValue(questions)
|
165
169
|
if self.eqChecker.isVisible():
|
@@ -221,18 +225,33 @@ class MainWindow(QMainWindow):
|
|
221
225
|
|
222
226
|
@Slot(Category)
|
223
227
|
def treeRefreshCategory(self, cat: Category) -> None:
|
224
|
-
"""Append Category with its Questions to the treewidget.
|
228
|
+
"""Append Category with its Questions to the treewidget.
|
229
|
+
|
230
|
+
If the category already has an item, refresh it.
|
231
|
+
"""
|
232
|
+
# Find existing item
|
233
|
+
for i in range(self.ui.treeWidget.topLevelItemCount()):
|
234
|
+
item = self.ui.treeWidget.topLevelItem(i)
|
235
|
+
# The top level items are categories
|
236
|
+
if isinstance(item, CategoryItem) and item.category.NAME == cat.NAME:
|
237
|
+
item.refresh()
|
238
|
+
return
|
239
|
+
|
225
240
|
catItem = CategoryItem(self.ui.treeWidget, cat)
|
226
241
|
catItem.setFlags(catItem.flags() & ~Qt.ItemIsSelectable)
|
227
|
-
|
228
|
-
|
242
|
+
try:
|
243
|
+
for q in cat.questions.values():
|
244
|
+
QuestionItem(catItem, q)
|
245
|
+
except ValueError:
|
246
|
+
logger.exception("No Questions to update.")
|
247
|
+
catItem.updateVariantCount()
|
229
248
|
self.ui.treeWidget.sortItems(0, Qt.SortOrder.AscendingOrder)
|
230
249
|
|
231
250
|
@Slot()
|
232
251
|
def updateQuestionPreview(self) -> None:
|
233
252
|
item = self.ui.treeWidget.currentItem()
|
234
253
|
if isinstance(item, QuestionItem):
|
235
|
-
self.questionPreview.setupQuestion(item.
|
254
|
+
self.questionPreview.setupQuestion(item.question)
|
236
255
|
else:
|
237
256
|
logger.info("current Item is not a Question, can't preview")
|
238
257
|
|
@@ -244,12 +263,12 @@ class MainWindow(QMainWindow):
|
|
244
263
|
def openEqCheckerDlg(self) -> None:
|
245
264
|
item = self.ui.treeWidget.currentItem()
|
246
265
|
if isinstance(item, QuestionItem):
|
247
|
-
question = item.
|
266
|
+
question = item.question
|
248
267
|
if isinstance(question, (NFQuestion, MCQuestion)):
|
249
268
|
logger.debug("Can't check an MC or NF Question")
|
250
269
|
else:
|
251
270
|
logger.debug("opening wEquationChecker \n")
|
252
|
-
self.eqChecker.setup(item.
|
271
|
+
self.eqChecker.setup(item.question)
|
253
272
|
self.eqChecker.show()
|
254
273
|
else:
|
255
274
|
logger.debug("No Question Item selected: %s", type(item))
|
@@ -269,11 +288,12 @@ class MainWindow(QMainWindow):
|
|
269
288
|
def openVariableGeneratorDlg(self) -> None:
|
270
289
|
item = self.ui.treeWidget.currentItem()
|
271
290
|
if isinstance(item, QuestionItem):
|
272
|
-
question = item.
|
291
|
+
question = item.question
|
273
292
|
if isinstance(question, ParametricQuestion):
|
274
293
|
dialog = VariableGeneratorDialog(self, parametrics=question.parametrics)
|
275
294
|
if dialog.exec():
|
276
295
|
self.questionPreview.setupQuestion(question)
|
296
|
+
self.treeRefreshCategory(question.category)
|
277
297
|
logger.info("Updated QuestionItem display for %s", question.id)
|
278
298
|
self.copyVariablesToClipboard(
|
279
299
|
variables=question.parametrics.variables
|
@@ -294,7 +314,7 @@ class MainWindow(QMainWindow):
|
|
294
314
|
if not variables:
|
295
315
|
item = self.ui.treeWidget.currentItem()
|
296
316
|
if isinstance(item, QuestionItem):
|
297
|
-
question = item.
|
317
|
+
question = item.question
|
298
318
|
if isinstance(question, ParametricQuestion):
|
299
319
|
variables = question.parametrics.variables
|
300
320
|
varsList = [
|
@@ -317,7 +337,7 @@ class MainWindow(QMainWindow):
|
|
317
337
|
def openSpreadsheetExternally(self) -> None:
|
318
338
|
if self.excelPath is None:
|
319
339
|
return
|
320
|
-
spreadsheetPath = QUrl(f"file://{self.excelPath.
|
340
|
+
spreadsheetPath = QUrl(f"file://{self.excelPath.resolve()}")
|
321
341
|
logger.info("Opening: %s", spreadsheetPath)
|
322
342
|
QDesktopServices.openUrl(spreadsheetPath)
|
323
343
|
|
@@ -335,7 +355,15 @@ class ParseAllThread(QRunnable):
|
|
335
355
|
|
336
356
|
@Slot()
|
337
357
|
def run(self) -> None:
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
358
|
+
try:
|
359
|
+
self.testDB.readCategoriesMetadata()
|
360
|
+
except InvalidFieldException:
|
361
|
+
logger.exception("Youre spreadsheet questionbank isn't correctly setup.")
|
362
|
+
except ValueError:
|
363
|
+
logger.exception(
|
364
|
+
"Did you forget to specify a 'settings' sheet in the file?"
|
365
|
+
)
|
366
|
+
else:
|
367
|
+
self.testDB.asyncInitAllCategories(self.mainApp.excelPath)
|
368
|
+
self.mainApp.setStatus("[OK] Tabellen wurde eingelesen")
|
369
|
+
self.testDB.parseAllQuestions()
|
excel2moodle/ui/dialogs.py
CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
|
|
6
6
|
|
7
7
|
import lxml.etree as ET
|
8
8
|
from PySide6.QtCore import Slot
|
9
|
-
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget
|
9
|
+
from PySide6.QtWidgets import QDialog, QFileDialog, QMainWindow, QMessageBox, QWidget
|
10
10
|
|
11
11
|
from excel2moodle import e2mMetadata
|
12
12
|
from excel2moodle.core.globals import XMLTags
|
@@ -14,6 +14,7 @@ from excel2moodle.core.question import ParametricQuestion, Question
|
|
14
14
|
from excel2moodle.core.settings import Tags
|
15
15
|
from excel2moodle.extra import variableGenerator
|
16
16
|
from excel2moodle.ui.UI_exportSettingsDialog import Ui_ExportDialog
|
17
|
+
from excel2moodle.ui.UI_updateDlg import Ui_UpdateDialog
|
17
18
|
from excel2moodle.ui.UI_variantDialog import Ui_Dialog
|
18
19
|
|
19
20
|
if TYPE_CHECKING:
|
@@ -22,6 +23,22 @@ if TYPE_CHECKING:
|
|
22
23
|
logger = logging.getLogger(__name__)
|
23
24
|
|
24
25
|
|
26
|
+
class UpdateDialog(QDialog):
|
27
|
+
def __init__(
|
28
|
+
self, parent: QMainWindow, changelog: str = "", version: str = ""
|
29
|
+
) -> None:
|
30
|
+
super().__init__(parent)
|
31
|
+
self.ui = Ui_UpdateDialog()
|
32
|
+
self.ui.setupUi(self)
|
33
|
+
self.ui.changelogBrowser.setMarkdown(changelog)
|
34
|
+
self.ui.titleLabel.setText(
|
35
|
+
f"<h2>New Version {version} of <i>exel2moodle</i> just dropped!!</h2>"
|
36
|
+
)
|
37
|
+
self.ui.fundingLabel.setText(
|
38
|
+
f'If you find this project useful, please consider supporting its development. <br> <a href="{e2mMetadata["funding"]}">Buy jbosse3 a coffee</a>, so he stays caffeinated during coding.',
|
39
|
+
)
|
40
|
+
|
41
|
+
|
25
42
|
class QuestionVariantDialog(QDialog):
|
26
43
|
def __init__(self, parent, question: ParametricQuestion) -> None:
|
27
44
|
super().__init__(parent)
|
excel2moodle/ui/treewidget.py
CHANGED
@@ -4,24 +4,33 @@ Those two are subclasses of `QTreeWidgetItem`, to provide an easy interface
|
|
4
4
|
of accessing the corresponding questions from the items.
|
5
5
|
"""
|
6
6
|
|
7
|
+
import logging
|
8
|
+
|
7
9
|
from PySide6.QtCore import Qt
|
8
10
|
from PySide6.QtWidgets import QTreeWidgetItem
|
9
11
|
|
10
12
|
from excel2moodle.core.dataStructure import Category
|
11
13
|
from excel2moodle.core.question import ParametricQuestion, Question
|
12
14
|
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
13
17
|
|
14
18
|
class QuestionItem(QTreeWidgetItem):
|
15
19
|
def __init__(self, parent, question: Question | ParametricQuestion) -> None:
|
16
20
|
super().__init__(parent)
|
17
21
|
self.setData(2, Qt.UserRole, question)
|
22
|
+
self.refresh()
|
23
|
+
|
24
|
+
def refresh(self) -> None:
|
25
|
+
question = self.question
|
18
26
|
self.setText(0, question.id)
|
19
27
|
self.setText(1, question.name)
|
20
28
|
self.setText(2, str(question.points))
|
21
|
-
if
|
22
|
-
self.setText(3, str(question.variants))
|
29
|
+
if isinstance(question, ParametricQuestion):
|
30
|
+
self.setText(3, str(question.parametrics.variants))
|
23
31
|
|
24
|
-
|
32
|
+
@property
|
33
|
+
def question(self) -> Question | ParametricQuestion:
|
25
34
|
"""Return the question Object the QTreeWidgetItem represents."""
|
26
35
|
return self.data(2, Qt.UserRole)
|
27
36
|
|
@@ -30,9 +39,9 @@ class CategoryItem(QTreeWidgetItem):
|
|
30
39
|
def __init__(self, parent, category: Category) -> None:
|
31
40
|
super().__init__(parent)
|
32
41
|
self.setData(2, Qt.UserRole, category)
|
33
|
-
self.
|
34
|
-
|
35
|
-
|
42
|
+
self.refresh()
|
43
|
+
|
44
|
+
def updateVariantCount(self) -> None:
|
36
45
|
var = self.getMaxVariants()
|
37
46
|
if var != 0:
|
38
47
|
self.setText(3, str(var))
|
@@ -44,10 +53,21 @@ class CategoryItem(QTreeWidgetItem):
|
|
44
53
|
def getMaxVariants(self) -> int:
|
45
54
|
count: int = 0
|
46
55
|
for child in self.iterateChildren():
|
47
|
-
q = child.
|
48
|
-
if
|
49
|
-
count = max(q.variants, count)
|
56
|
+
q = child.question
|
57
|
+
if isinstance(q, ParametricQuestion):
|
58
|
+
count = max(q.parametrics.variants, count)
|
50
59
|
return count
|
51
60
|
|
52
|
-
|
61
|
+
@property
|
62
|
+
def category(self) -> Category:
|
53
63
|
return self.data(2, Qt.UserRole)
|
64
|
+
|
65
|
+
def refresh(self) -> None:
|
66
|
+
for child in self.iterateChildren():
|
67
|
+
child.refresh()
|
68
|
+
# Update category data, as it might have changed
|
69
|
+
cat = self.category
|
70
|
+
self.setText(0, cat.NAME)
|
71
|
+
self.setText(1, cat.desc)
|
72
|
+
self.setText(2, str(cat.points))
|
73
|
+
self.updateVariantCount()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: excel2moodle
|
3
|
-
Version: 0.6.
|
3
|
+
Version: 0.6.4
|
4
4
|
Summary: A package for converting questions from a spreadsheet, to valid moodle-xml
|
5
5
|
Author: Jakob Bosse
|
6
6
|
License-Expression: GPL-3.0-or-later
|
@@ -90,6 +90,33 @@ If You want to support my work as well, you can by me a [coffee](https://ko-fi.c
|
|
90
90
|
|
91
91
|
# Changelogs
|
92
92
|
|
93
|
+
## 0.6.4 (2025-09-02)
|
94
|
+
Added Scripted Media Support
|
95
|
+
|
96
|
+
### feature (1 change)
|
97
|
+
|
98
|
+
- [Added support for scripted Media content.](https://gitlab.com/jbosse3/excel2moodle/-/commit/2021942392147d0e9740af5286f469dd6226ffa5)
|
99
|
+
|
100
|
+
## 0.6.3 (2025-08-03)
|
101
|
+
Lots of small improvements made
|
102
|
+
|
103
|
+
### improvement (3 changes)
|
104
|
+
|
105
|
+
- [small logging improvements and error handling](https://gitlab.com/jbosse3/excel2moodle/-/commit/149f8e923a06d9d7077fe90c7005a3e1d5d2d42f)
|
106
|
+
- [Make variable generator rules editable](https://gitlab.com/jbosse3/excel2moodle/-/commit/80ea32d97bdec16b77100bc870a0e0272a739dd4)
|
107
|
+
- [Variable generator only generates unique sets.](https://gitlab.com/jbosse3/excel2moodle/-/commit/d347c91bbac66de1da157fee4f76faf8d4636557)
|
108
|
+
|
109
|
+
### bugfix (3 changes)
|
110
|
+
|
111
|
+
- [mixed parametric and non parametric Bullets are working now](https://gitlab.com/jbosse3/excel2moodle/-/commit/f094b13dffd4b6b7ac1a03fc7e34eec6e8d1bfa7)
|
112
|
+
- [Loglevel setting is respected in spreadsheet file](https://gitlab.com/jbosse3/excel2moodle/-/commit/d6ef89beeec94f24782a00b7564883074badf72d)
|
113
|
+
- [Treewidget variants count updated after variable generation](https://gitlab.com/jbosse3/excel2moodle/-/commit/c48a0d093a0cce85fd3e9c3c091eef936739c02b)
|
114
|
+
|
115
|
+
### feature (2 changes)
|
116
|
+
|
117
|
+
- [Category ID taken from any number in its name](https://gitlab.com/jbosse3/excel2moodle/-/commit/ac7e19af5f25ac2e576b63c478e7b07153e782ef)
|
118
|
+
- [Implemented Update Check on Startup](https://gitlab.com/jbosse3/excel2moodle/-/commit/a143edd47f566c5e731c05612f4ac21dc7728eb7)
|
119
|
+
|
93
120
|
## 0.6.2 (2025-08-02)
|
94
121
|
Adding export options and fixing cloze points bug
|
95
122
|
|
@@ -1,39 +1,41 @@
|
|
1
|
-
excel2moodle/__init__.py,sha256=
|
2
|
-
excel2moodle/__main__.py,sha256=
|
1
|
+
excel2moodle/__init__.py,sha256=W05Gsm3IOcxJnp4C-TPvxRiO3NR2L9g8PSIHDoRJua0,1893
|
2
|
+
excel2moodle/__main__.py,sha256=B55ZK25z-HzIIox2xLYkJXMUwYzITPKGCi9fELMFGaM,1877
|
3
3
|
excel2moodle/logger.py,sha256=fq8ZOkCI1wj38v8IyrZsUlpt16onlSH_phqbVvYUwBQ,3725
|
4
4
|
excel2moodle/core/__init__.py,sha256=87BwhtZse72Tk17Ib-V9X2k9wkhmtVnEj2ZmJ9JBAnI,63
|
5
|
-
excel2moodle/core/bullets.py,sha256=
|
6
|
-
excel2moodle/core/category.py,sha256=
|
7
|
-
excel2moodle/core/dataStructure.py,sha256=
|
5
|
+
excel2moodle/core/bullets.py,sha256=TiRf2EfhsryE-KBfvo43dMtsMWuZi9L9H7TuwXAZ1rg,3550
|
6
|
+
excel2moodle/core/category.py,sha256=fOMj2ynoAy6tXwmFhJ9uST9BQHiRJeU2BrkK1r57ek4,2897
|
7
|
+
excel2moodle/core/dataStructure.py,sha256=f3aqSPSIQxspYf1FmhFnlr4H1tc1gpVG_DQBdD0bZQk,19858
|
8
8
|
excel2moodle/core/etHelpers.py,sha256=LzimWGuX6RH2TbfEnWUoAXT2Tr0z6P7bCANjxuANSX0,1667
|
9
9
|
excel2moodle/core/exceptions.py,sha256=9xfsaIcm6Yej6QAZga0d3DK3jLQejdfgJARuAaG-uZY,739
|
10
10
|
excel2moodle/core/globals.py,sha256=gvkl8Obq4XBW40B1L68Ewg06sonK27l-KIiodwFv8ic,2393
|
11
|
-
excel2moodle/core/parser.py,sha256=
|
12
|
-
excel2moodle/core/question.py,sha256=
|
13
|
-
excel2moodle/core/settings.py,sha256=
|
11
|
+
excel2moodle/core/parser.py,sha256=hjbA0i7N1oHoJHhOmvEtVl4Ryaqd0eqUS26bXS47CBo,8467
|
12
|
+
excel2moodle/core/question.py,sha256=H_4C2hO4Hrb5fMXnIsuxoeAJVWrHn9nSnNR2x4QnaqY,14324
|
13
|
+
excel2moodle/core/settings.py,sha256=_H3TJ67-4Q0hFi8g3JWFILJ6q3mfcEsBRaV74jhbko8,6531
|
14
14
|
excel2moodle/core/stringHelpers.py,sha256=OzFZ6Eu3PeBLKb61K-aeVfUZmVuBerr9KfyOsuNRd7Y,2403
|
15
|
-
excel2moodle/core/validator.py,sha256=
|
15
|
+
excel2moodle/core/validator.py,sha256=6nZIyTwcXPT2jgi31QrBGur3Cq7A3Q9btLp5ALFEsKw,4998
|
16
16
|
excel2moodle/extra/__init__.py,sha256=PM-id60HD21A3IcGC_fCYFihS8osBGZMIJCcN-ZRsIM,293
|
17
17
|
excel2moodle/extra/equationVerification.py,sha256=oQpk-4cM0x_vKGEexC0Z1UyVoGG7w3V3RydtkM0MV_Y,3869
|
18
|
-
excel2moodle/extra/
|
18
|
+
excel2moodle/extra/updateQuery.py,sha256=kD_L23Qea9Cx4zUwfQVNJXXFbybd9cwE8sSbZrz7VF8,1554
|
19
|
+
excel2moodle/extra/variableGenerator.py,sha256=qYY9i872yJAE4_nrxy_M5jCdMtGgVKB7yz1Th31bySY,11486
|
19
20
|
excel2moodle/question_types/__init__.py,sha256=81mss0g7SVtnlb-WkydE28G_dEAAf6oT1uB8lpK2-II,1041
|
20
|
-
excel2moodle/question_types/cloze.py,sha256=
|
21
|
+
excel2moodle/question_types/cloze.py,sha256=xxhsqSNYqt878bjZ8MfGr-Oe7jLy9FdSTIUZ_GPE9PY,14657
|
21
22
|
excel2moodle/question_types/mc.py,sha256=nx6PsbfLLH_4H5eCSjGcfgEC6EEVgseI7xy15jg5JmA,5482
|
22
23
|
excel2moodle/question_types/nf.py,sha256=HAolGy13-FbLVJskAUXCFy76NJu91IG9wtq6OI05Igw,1374
|
23
24
|
excel2moodle/question_types/nfm.py,sha256=D5-aE4C7TAuwHFidXR15DLWNZ4JT-HVbPXI0CzGWOS0,3013
|
24
25
|
excel2moodle/ui/UI_equationChecker.py,sha256=evQDlqCHeooJcAnYjhFCyjlPhfknr7ULGKQwMmqQeJ4,8947
|
25
26
|
excel2moodle/ui/UI_exportSettingsDialog.py,sha256=I0Vqw2TCWoUhDKxTgLoGaAo4_L77vfN8G7_zi7b_5lY,8254
|
26
27
|
excel2moodle/ui/UI_mainWindow.py,sha256=9w8bRgOrVEX7BRGQvMuVhPCiSOsXYkMb4rxLDeRErII,21544
|
28
|
+
excel2moodle/ui/UI_updateDlg.py,sha256=kPv6XyyTqKs2Avsji9peVUrsp0di4B41mXI0Qz_uLfA,5261
|
27
29
|
excel2moodle/ui/UI_variableGenerator.py,sha256=DjpZnBELSqyOjJdwjXNP2V71rCPm3tr6_XkNT9F3e34,11205
|
28
30
|
excel2moodle/ui/UI_variantDialog.py,sha256=snVaF3_YAc7NWjMRg7NzbjL_PzNbOpt4eiqElkE46io,5414
|
29
31
|
excel2moodle/ui/__init__.py,sha256=4EdGtpzwH3rgw4xW9E5x9kdPQYwKbo9rehHRZTNxCrQ,44
|
30
|
-
excel2moodle/ui/appUi.py,sha256=
|
31
|
-
excel2moodle/ui/dialogs.py,sha256=
|
32
|
+
excel2moodle/ui/appUi.py,sha256=woISWvPFegL9e0ntORNaMM1IxQSlP7qAHhYmNsYfI6U,15045
|
33
|
+
excel2moodle/ui/dialogs.py,sha256=du6v17lh6LhgDDK0QltPzD-z8wUn3aD4QzaAQBmiTBQ,7314
|
32
34
|
excel2moodle/ui/equationChecker.py,sha256=RII9DlZAlHqe5udBWzeUaozhtyi3ZkCZs8h3-oO6pEw,2700
|
33
|
-
excel2moodle/ui/treewidget.py,sha256=
|
34
|
-
excel2moodle-0.6.
|
35
|
-
excel2moodle-0.6.
|
36
|
-
excel2moodle-0.6.
|
37
|
-
excel2moodle-0.6.
|
38
|
-
excel2moodle-0.6.
|
39
|
-
excel2moodle-0.6.
|
35
|
+
excel2moodle/ui/treewidget.py,sha256=3hZRLlrhp4FMXFyNY0LGDy7k1RSuKH87QyqB1N4qOqg,2335
|
36
|
+
excel2moodle-0.6.4.dist-info/licenses/LICENSE,sha256=ywQqe6Sitymkf2lV2NRcx_aGsaC-KbSl_EfEsRXmNRw,35135
|
37
|
+
excel2moodle-0.6.4.dist-info/METADATA,sha256=CBlHZ_zdBMe05oXoz-EKawkWz75QFS3la1-AZAyxvJw,12105
|
38
|
+
excel2moodle-0.6.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
39
|
+
excel2moodle-0.6.4.dist-info/entry_points.txt,sha256=myfMLDThuGgWHMJDPPfILiZqo_7D3fhmDdJGqWOAjPw,60
|
40
|
+
excel2moodle-0.6.4.dist-info/top_level.txt,sha256=5V1xRUQ9o7UmOCmNoWCZPAuy5nXp3Qbzyqch8fUGT_c,13
|
41
|
+
excel2moodle-0.6.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|