excel2moodle 0.3.6__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
excel2moodle/__init__.py CHANGED
@@ -25,13 +25,8 @@ Functionality
25
25
  """
26
26
 
27
27
  import logging
28
- import logging.config as logConfig
29
28
  from importlib import metadata
30
29
  from importlib.metadata import version
31
- from pathlib import Path
32
-
33
- from excel2moodle.logger import LogWindowHandler, loggerConfig
34
- from excel2moodle.ui.settings import Settings, SettingsKey
35
30
 
36
31
  try:
37
32
  __version__ = version("excel2moodle")
@@ -53,12 +48,11 @@ if __package__ is not None:
53
48
  }
54
49
 
55
50
 
56
- settings = Settings()
57
- logfile = Path(settings.get(SettingsKey.LOGFILE)).resolve()
58
- e2mMetadata["logfile"] = logfile
59
- if logfile.exists():
60
- logfile.replace(f"{logfile}.old")
51
+ isMain: bool = False
61
52
 
62
53
  mainLogger = logging.getLogger(__name__)
63
- logConfig.dictConfig(config=loggerConfig)
64
- qSignalLogger = LogWindowHandler()
54
+
55
+
56
+ def isMainState() -> bool:
57
+ mainLogger.debug("Returning mainState %s", isMain)
58
+ return isMain
excel2moodle/__main__.py CHANGED
@@ -1,17 +1,20 @@
1
1
  """Main Function to make the Package executable."""
2
2
 
3
+ import logging.config as logConfig
3
4
  import signal
5
+ from pathlib import Path
4
6
 
5
7
  from PySide6 import QtWidgets, sys
6
8
 
7
- from excel2moodle import e2mMetadata, mainLogger, qSignalLogger
9
+ import excel2moodle
10
+ from excel2moodle import e2mMetadata, mainLogger
8
11
  from excel2moodle.core import dataStructure
12
+ from excel2moodle.core.settings import Settings, SettingsKey
13
+ from excel2moodle.logger import loggerConfig
9
14
  from excel2moodle.ui import appUi as ui
10
- from excel2moodle.ui.settings import Settings
11
15
 
12
16
 
13
17
  def main() -> None:
14
- mainLogger.addHandler(qSignalLogger)
15
18
  signal.signal(signal.SIGINT, signal.SIG_DFL)
16
19
  app = QtWidgets.QApplication(sys.argv)
17
20
  settings = Settings()
@@ -26,4 +29,12 @@ def main() -> None:
26
29
 
27
30
 
28
31
  if __name__ == "__main__":
32
+ excel2moodle.isMain = True
33
+ settings = Settings()
34
+ logfile = Path(settings.get(SettingsKey.LOGFILE)).resolve()
35
+ e2mMetadata["logfile"] = logfile
36
+ if logfile.exists() and logfile.is_file():
37
+ logfile.replace(f"{logfile}.old")
38
+
39
+ logConfig.dictConfig(config=loggerConfig)
29
40
  main()
@@ -1,21 +1,18 @@
1
1
  import logging
2
+ from typing import TYPE_CHECKING
2
3
 
3
4
  import lxml.etree as ET
4
5
  import pandas as pd
5
6
 
6
- from excel2moodle.core.parser import (
7
- MCQuestionParser,
8
- NFMQuestionParser,
9
- NFQuestionParser,
10
- QNotParsedException,
11
- )
12
- from excel2moodle.core.question import Question
13
- from excel2moodle.logger import LogAdapterQuestionID
7
+ if TYPE_CHECKING:
8
+ from excel2moodle.core.question import Question
14
9
 
15
10
  loggerObj = logging.getLogger(__name__)
16
11
 
17
12
 
18
13
  class Category:
14
+ """Category stores a list of question. And holds shared information for all."""
15
+
19
16
  def __init__(
20
17
  self,
21
18
  n: int,
@@ -25,6 +22,7 @@ class Category:
25
22
  points: float = 0,
26
23
  version: int = 0,
27
24
  ) -> None:
25
+ """Instantiate a new Category object."""
28
26
  self.n = n
29
27
  self.NAME = name
30
28
  self.desc = str(description)
@@ -51,42 +49,6 @@ class Category:
51
49
  return self.NAME == other.NAME
52
50
  return False
53
51
 
54
- def parseQ(
55
- self,
56
- q: Question,
57
- questionData: dict | None = None,
58
- xmlTree: ET._Element | None = None,
59
- ) -> bool:
60
- """Parse the given question."""
61
- logger = LogAdapterQuestionID(loggerObj, {"qID": q.id})
62
- if q.element is not None:
63
- logger.info("Question already parsed")
64
- return True
65
- if q.qtype == "NF":
66
- parser = NFQuestionParser(q, questionData)
67
- logger.debug("setup a new NF parser ")
68
- elif q.qtype == "MC":
69
- parser = MCQuestionParser(q, questionData)
70
- logger.debug("setup a new MC parser ")
71
- elif q.qtype == "NFM":
72
- parser = NFMQuestionParser(q, questionData)
73
- logger.debug("setup a new NFM parser ")
74
- else:
75
- logger.error("couldn't setup Parser")
76
- return False
77
- try:
78
- parser.parse(xmlTree=xmlTree)
79
- return True
80
- except QNotParsedException as e:
81
- logger.critical(
82
- "Question couldn't be parsed",
83
- exc_info=e,
84
- stack_info=True,
85
- )
86
- return False
87
- finally:
88
- del parser
89
-
90
52
  def getCategoryHeader(self) -> ET.Element:
91
53
  """Insert an <question type='category'> before all Questions of this Category."""
92
54
  header = ET.Element("question", type="category")
@@ -4,21 +4,28 @@ At the heart is the class ``xmlTest``
4
4
  """
5
5
 
6
6
  import logging
7
+ from concurrent.futures import ProcessPoolExecutor, as_completed
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING
9
10
 
10
11
  import lxml.etree as ET # noqa: N812
11
12
  import pandas as pd
12
13
  from PySide6 import QtWidgets
14
+ from PySide6.QtCore import QObject, Signal
13
15
 
14
16
  from excel2moodle.core import stringHelpers
15
17
  from excel2moodle.core.category import Category
16
18
  from excel2moodle.core.exceptions import InvalidFieldException, QNotParsedException
19
+ from excel2moodle.core.globals import DFIndex
17
20
  from excel2moodle.core.question import Question
18
- from excel2moodle.core.questionValidator import Validator
19
- from excel2moodle.logger import QSignaler
21
+ from excel2moodle.core.settings import Settings, SettingsKey
22
+ from excel2moodle.core.validator import Validator
23
+ from excel2moodle.logger import LogAdapterQuestionID
24
+ from excel2moodle.question_types import QuestionTypeMapping
25
+ from excel2moodle.question_types.mc import MCQuestion, MCQuestionParser
26
+ from excel2moodle.question_types.nf import NFQuestion, NFQuestionParser
27
+ from excel2moodle.question_types.nfm import NFMQuestion, NFMQuestionParser
20
28
  from excel2moodle.ui.dialogs import QuestionVariantDialog
21
- from excel2moodle.ui.settings import Settings, SettingsKey
22
29
  from excel2moodle.ui.treewidget import QuestionItem
23
30
 
24
31
  if TYPE_CHECKING:
@@ -27,88 +34,251 @@ if TYPE_CHECKING:
27
34
  logger = logging.getLogger(__name__)
28
35
 
29
36
 
37
+ class QuestionDBSignals(QObject):
38
+ categoryReady = Signal(Category)
39
+ categoryQuestionsReady = Signal(Category)
40
+
41
+
42
+ def processSheet(sheetPath: str, categoryName: str) -> pd.DataFrame:
43
+ """Parse `categoryName` from the file ``sheetPath`` into the dataframe.
44
+
45
+ This Function is meant to be run asynchron for increased speed.
46
+ """
47
+ return pd.read_excel(
48
+ Path(sheetPath),
49
+ sheet_name=str(categoryName),
50
+ index_col=0,
51
+ header=None,
52
+ )
53
+
54
+
30
55
  class QuestionDB:
31
- """oberste Klasse für den Test."""
56
+ """The QuestionDB is the main class for processing the Spreadsheet.
57
+
58
+ It provides the functionality, for setting up the categories and Questions.
59
+ Any interaction with the questions are done by its methods.
60
+ """
32
61
 
33
- dataChanged = QSignaler()
62
+ signals = QuestionDBSignals()
63
+ validator: Validator = Validator()
64
+ nfParser: NFQuestionParser = NFQuestionParser()
65
+ nfmParser: NFMQuestionParser = NFMQuestionParser()
66
+ mcParser: MCQuestionParser = MCQuestionParser()
34
67
 
35
68
  def __init__(self, settings: Settings) -> None:
36
69
  self.settings = settings
37
70
  self.window: QMainWindow | None = None
38
71
  self.version = None
39
- self.categoriesMetaData = pd.DataFrame()
40
- self.categories: dict[str, Category] = {}
72
+ self.categoriesMetaData: pd.DataFrame
73
+ self.categories: dict[str, Category]
41
74
 
42
- def readSpreadsheetData(self, sheet: Path) -> None:
75
+ def readCategoriesMetadata(self, sheetPath: Path) -> None:
43
76
  """Read the metadata and questions from the spreadsheet.
44
77
 
45
- This method gathers this information and stores it in the
78
+ Get the category data from the spreadsheet and stores it in the
46
79
  ``categoriesMetaData`` dataframe
47
- It also reads the question data and stores it in ``self.categories = {}``
80
+ Setup the categories and store them in ``self.categories = {}``
81
+ Pass the question data to the categories.
48
82
  """
49
83
  logger.info("Start Parsing the Excel Metadata Sheet\n")
50
- with Path(sheet).open("rb") as f:
51
- excelFile = pd.ExcelFile(f)
84
+ with Path(sheetPath).open("rb") as f:
85
+ settingDf = pd.read_excel(
86
+ f,
87
+ sheet_name="settings",
88
+ index_col=0,
89
+ )
90
+ logger.debug("Found the settings: \n\t%s", settingDf)
91
+ self._setProjectSettings(settingDf)
92
+ with Path(sheetPath).open("rb") as f:
52
93
  self.categoriesMetaData = pd.read_excel(
53
94
  f,
54
- sheet_name="Kategorien",
55
- usecols=["Kategorie", "Beschreibung", "Punkte", "Version"],
95
+ sheet_name=self.settings.get(SettingsKey.CATEGORIESSHEET),
56
96
  index_col=0,
57
97
  )
58
- logger.info("Sucessfully read categoriesMetaData")
59
- self.categories = {}
60
- for sh in excelFile.sheet_names:
61
- if sh.startswith("KAT"):
62
- n = int(sh[4:])
63
- katDf = pd.read_excel(
64
- f,
65
- sheet_name=str(sh),
66
- index_col=0,
67
- header=None,
68
- )
69
- if not katDf.empty:
70
- p = self.categoriesMetaData["Punkte"].iloc[n - 1]
71
- points = p if not pd.isna(p) else 1
72
- v = self.categoriesMetaData["Version"].iloc[n - 1]
73
- version = v if not pd.isna(v) else 0
74
- self.categories[sh] = Category(
75
- n,
76
- sh,
77
- self.categoriesMetaData["Beschreibung"].iloc[n - 1],
78
- dataframe=katDf,
79
- points=points,
80
- version=version,
81
- )
82
- # self.dataChanged.signal.emit("whoo")
83
-
84
- def parseAll(self) -> None:
85
- self.mainTree = ET.Element("quiz")
86
- for c in self.categories.values():
87
- validator = Validator(c)
88
- for q in c.dataframe.columns:
89
- logger.debug(f"Starting to check Validity of {q}")
90
- qdat = c.dataframe[q]
91
- if isinstance(qdat, pd.Series):
92
- validator.setup(qdat, q)
93
- check = False
94
- try:
95
- check = validator.validate()
96
- except InvalidFieldException as e:
97
- logger.exception(
98
- f"Question {c.id}{q:02d} is invalid.",
99
- exc_info=e,
100
- )
101
- if check:
102
- c.questions[q] = validator.question
103
- try:
104
- c.parseQ(c.questions[q], validator.qdata)
105
- except QNotParsedException as e:
106
- logger.exception(
107
- f"Frage {
108
- c.questions[q].id
109
- } konnte nicht erstellt werden",
110
- exc_info=e,
111
- )
98
+ logger.info(
99
+ "Sucessfully read categoriesMetaData \n %s", self.categoriesMetaData
100
+ )
101
+
102
+ def _setProjectSettings(self, settings: pd.DataFrame) -> None:
103
+ for tag, value in settings.iterrows():
104
+ self.settings.set(tag, value.iloc[0], local=True)
105
+
106
+ def initAllCategories(self, sheetPath: Path) -> None:
107
+ """Read all category sheets and initialize all Categories."""
108
+ if not hasattr(self, "categoriesMetaData"):
109
+ logger.error("Can't process the Categories without Metadata")
110
+ return
111
+ if hasattr(self, "categories"):
112
+ self.categories.clear()
113
+ else:
114
+ self.categories: dict[str, Category] = {}
115
+ with Path(sheetPath).open("rb") as f:
116
+ excelFile = pd.ExcelFile(f)
117
+ for categoryName in excelFile.sheet_names:
118
+ logger.debug("Starting to read category %s", categoryName)
119
+ if categoryName.startswith("KAT"):
120
+ self.initCategory(sheetPath, categoryName)
121
+
122
+ def asyncInitAllCategories(self, sheetPath: Path) -> None:
123
+ """Read all category sheets asynchron and initialize all Categories.
124
+
125
+ It does the same as `initAllCategories` but the parsing of the excelfile
126
+ is done asynchron via `concurrent.futures.ProcessPoolExecutor`
127
+ """
128
+ if not hasattr(self, "categoriesMetaData"):
129
+ logger.error("Can't process the Categories without Metadata")
130
+ return
131
+ if hasattr(self, "categories"):
132
+ self.categories.clear()
133
+ else:
134
+ self.categories: dict[str, Category] = {}
135
+ sheet_names = []
136
+ with Path(sheetPath).open("rb") as f:
137
+ excel_file = pd.ExcelFile(f)
138
+ sheet_names = [
139
+ name for name in excel_file.sheet_names if name.startswith("KAT_")
140
+ ]
141
+ logger.debug("found those caetegory sheets: \n %s ", sheet_names)
142
+ with ProcessPoolExecutor() as executor:
143
+ futures = {
144
+ executor.submit(processSheet, str(sheetPath), sheet): sheet
145
+ for sheet in sheet_names
146
+ }
147
+ for future in as_completed(futures):
148
+ categoryName = futures[future]
149
+ try:
150
+ categoryDataF = future.result()
151
+ categoryNumber = int(categoryName[4:])
152
+ self._setupCategory(categoryDataF, categoryName, categoryNumber)
153
+ logger.debug("Finished processing %s", categoryName)
154
+ except Exception as e:
155
+ logger.exception("Error processing sheet %s: %s", categoryName, e)
156
+ logger.debug("Future exception: %s", future.exception())
157
+
158
+ def initCategory(self, sheetPath: Path, categoryName: str) -> None:
159
+ """Read `categoryName` from the file ``sheetPath`` and initialize the category."""
160
+ categoryNumber = int(categoryName[4:])
161
+ katDf = pd.read_excel(
162
+ sheetPath,
163
+ sheet_name=str(categoryName),
164
+ index_col=0,
165
+ header=None,
166
+ )
167
+ if not katDf.empty:
168
+ logger.debug("Sucessfully read the Dataframe for cat %s", categoryName)
169
+ self._setupCategory(katDf, categoryName, categoryNumber)
170
+
171
+ def _setupCategory(
172
+ self, categoryDf: pd.DataFrame, categoryName: str, categoryNumber: int
173
+ ) -> None:
174
+ """Setup the category from the ``dataframe``.
175
+ :emits: categoryReady(self) Signal.
176
+ """ # noqa: D401
177
+ points = (
178
+ self.categoriesMetaData["points"].iloc[categoryNumber - 1]
179
+ if "points" in self.categoriesMetaData
180
+ and not pd.isna(self.categoriesMetaData["points"]).iloc[categoryNumber - 1]
181
+ else self.settings.get(SettingsKey.POINTS)
182
+ )
183
+ version = (
184
+ self.categoriesMetaData["version"].iloc[categoryNumber - 1]
185
+ if "version" in self.categoriesMetaData
186
+ and not pd.isna(self.categoriesMetaData["version"].iloc[categoryNumber - 1])
187
+ else self.settings.get(SettingsKey.VERSION)
188
+ )
189
+ category = Category(
190
+ categoryNumber,
191
+ categoryName,
192
+ self.categoriesMetaData["description"].iloc[categoryNumber - 1],
193
+ dataframe=categoryDf,
194
+ points=points,
195
+ version=version,
196
+ )
197
+ self.categories[categoryName] = category
198
+ logger.debug("Category %s is initialized", categoryName)
199
+ self.signals.categoryReady.emit(category)
200
+
201
+ def parseAllQuestions(self) -> None:
202
+ """Parse all question from all categories.
203
+
204
+ The categories need to be initialized first.
205
+ """
206
+ for category in self.categories.values():
207
+ self.parseCategoryQuestions(category)
208
+
209
+ def parseCategoryQuestions(self, category: Category) -> None:
210
+ """Parse all questions inside ``category``.
211
+
212
+ The category has to be initialized first.
213
+ """
214
+ for qNum in category.dataframe.columns:
215
+ try:
216
+ self.setupAndParseQuestion(category, qNum)
217
+ except (InvalidFieldException, QNotParsedException, ValueError) as e:
218
+ logger.exception(
219
+ "Question %s%02d couldn't be parsed. The Question Data: \n %s",
220
+ category.id,
221
+ qNum,
222
+ category.dataframe[qNum],
223
+ exc_info=e,
224
+ )
225
+ self.signals.categoryQuestionsReady.emit(category)
226
+
227
+ @classmethod
228
+ def setupAndParseQuestion(cls, category: Category, qNumber: int) -> None:
229
+ """Check if the Question Data is valid. Then parse it.
230
+
231
+ The Question data is accessed from `category.dataframe` via its number
232
+ First it is checked if all mandatory fields for the given question type
233
+ are provided.
234
+ Then in checks, weather the data has the correct type.
235
+ If the data is valid, the corresponding parser is fed with the data and run.
236
+
237
+ Raises
238
+ ------
239
+ QNotParsedException
240
+ If the parsing of the question is not possible this is raised
241
+ InvalidFieldException
242
+ If the data of the question is invalid.
243
+ This gives more information wheather a missing field, or the invalid type
244
+ caused the Exception.
245
+
246
+ """
247
+ locallogger = LogAdapterQuestionID(
248
+ logger, {"qID": f"{category.id}{qNumber:02d}"}
249
+ )
250
+ locallogger.debug("Starting to check Validity")
251
+ qdat = category.dataframe[qNumber]
252
+ if not isinstance(qdat, pd.Series):
253
+ locallogger.error("cannot validate data that isn't a pd.Series")
254
+ msg = "cannot validate data that isn't a pd.Series"
255
+ raise QNotParsedException(msg, f"{category.id}{qNumber}")
256
+ cls.validator.setup(qdat, qNumber)
257
+ cls.validator.validate()
258
+ validData = cls.validator.getQuestionRawData()
259
+ qtype: str = str(validData.get(DFIndex.TYPE))
260
+ category.questions[qNumber] = QuestionTypeMapping[qtype].create(
261
+ category, validData
262
+ )
263
+ question = category.questions[qNumber]
264
+ if question.element is not None:
265
+ locallogger.info("Question already parsed")
266
+ return
267
+ if isinstance(question, NFQuestion):
268
+ cls.nfParser.setup(question)
269
+ cls.nfParser.parse()
270
+ locallogger.debug("setup a new NF parser ")
271
+ elif isinstance(question, MCQuestion):
272
+ cls.mcParser.setup(question)
273
+ cls.mcParser.parse()
274
+ locallogger.debug("setup a new MC parser ")
275
+ elif isinstance(question, NFMQuestion):
276
+ cls.nfmParser.setup(question)
277
+ cls.nfmParser.parse()
278
+ locallogger.debug("setup a new NFM parser ")
279
+ else:
280
+ msg = "couldn't setup Parser"
281
+ raise QNotParsedException(msg, question.id)
112
282
 
113
283
  def appendQuestions(
114
284
  self, questions: list[QuestionItem], file: Path | None = None
@@ -143,18 +313,15 @@ class QuestionDB:
143
313
  logger.debug(f"Appended a new category item {cat=}")
144
314
  variant: int = self.settings.get(SettingsKey.QUESTIONVARIANT)
145
315
  for q in qList:
146
- if cat.parseQ(q):
147
- if q.variants is not None:
148
- if variant == 0 or variant > q.variants:
149
- dialog = QuestionVariantDialog(self.window, q)
150
- if dialog.exec() == QtWidgets.QDialog.Accepted:
151
- variant = dialog.variant
152
- logger.debug(f"Die Fragen-Variante {variant} wurde gewählt")
153
- q.assemble(variant)
154
- else:
155
- pass
156
- else:
157
- q.assemble()
158
- tree.append(q.element)
316
+ if q.variants is not None:
317
+ if variant == 0 or variant > q.variants:
318
+ dialog = QuestionVariantDialog(self.window, q)
319
+ if dialog.exec() == QtWidgets.QDialog.Accepted:
320
+ variant = dialog.variant
321
+ logger.debug("Die Fragen-Variante %s wurde gewählt", variant)
322
+ else:
323
+ logger.warning("Keine Fragenvariante wurde gewählt.")
324
+ q.assemble(variant)
159
325
  else:
160
- logger.warning(f"Frage {q} wurde nicht erstellt")
326
+ q.assemble()
327
+ tree.append(q.element)
@@ -17,7 +17,7 @@ class DFIndex(StrEnum):
17
17
  """
18
18
 
19
19
  TEXT = "text"
20
- BPOINTS = "bulletPoints"
20
+ BPOINTS = "bulletPoint"
21
21
  TRUE = "true"
22
22
  FALSE = "false"
23
23
  TYPE = "type"
@@ -120,23 +120,3 @@ feedbackStr = {
120
120
  "wrong": "falsch",
121
121
  "right1Percent": "Gratultaion, die Frage wurde im Rahmen der Toleranz richtig beantwortet",
122
122
  }
123
-
124
- parserSettings = {
125
- "Parser": {
126
- "standards": {
127
- "hidden": 0,
128
- },
129
- },
130
- "MCParser": {
131
- "standards": {
132
- "single": "false",
133
- "shuffleanswers": "true",
134
- "answernumbering": "abc",
135
- "showstandardinstruction": "0",
136
- "shownumcorrect": "",
137
- },
138
- },
139
- "NFParser": {
140
- "standards": {},
141
- },
142
- }