excel2moodle 0.3.6__py3-none-any.whl → 0.3.7__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
@@ -56,9 +56,8 @@ if __package__ is not None:
56
56
  settings = Settings()
57
57
  logfile = Path(settings.get(SettingsKey.LOGFILE)).resolve()
58
58
  e2mMetadata["logfile"] = logfile
59
- if logfile.exists():
59
+ if logfile.exists() and logfile.is_file():
60
60
  logfile.replace(f"{logfile}.old")
61
61
 
62
62
  mainLogger = logging.getLogger(__name__)
63
63
  logConfig.dictConfig(config=loggerConfig)
64
- qSignalLogger = LogWindowHandler()
excel2moodle/__main__.py CHANGED
@@ -4,14 +4,13 @@ import signal
4
4
 
5
5
  from PySide6 import QtWidgets, sys
6
6
 
7
- from excel2moodle import e2mMetadata, mainLogger, qSignalLogger
7
+ from excel2moodle import e2mMetadata, mainLogger
8
8
  from excel2moodle.core import dataStructure
9
9
  from excel2moodle.ui import appUi as ui
10
10
  from excel2moodle.ui.settings import Settings
11
11
 
12
12
 
13
13
  def main() -> None:
14
- mainLogger.addHandler(qSignalLogger)
15
14
  signal.signal(signal.SIGINT, signal.SIG_DFL)
16
15
  app = QtWidgets.QApplication(sys.argv)
17
16
  settings = Settings()
@@ -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,19 +4,26 @@ 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.validator import Validator
22
+ from excel2moodle.logger import LogAdapterQuestionID
23
+ from excel2moodle.question_types import QuestionTypeMapping
24
+ from excel2moodle.question_types.mc import MCQuestion, MCQuestionParser
25
+ from excel2moodle.question_types.nf import NFQuestion, NFQuestionParser
26
+ from excel2moodle.question_types.nfm import NFMQuestion, NFMQuestionParser
20
27
  from excel2moodle.ui.dialogs import QuestionVariantDialog
21
28
  from excel2moodle.ui.settings import Settings, SettingsKey
22
29
  from excel2moodle.ui.treewidget import QuestionItem
@@ -27,88 +34,247 @@ 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",
95
+ sheet_name=self.settings.get(SettingsKey.CATEGORIESSHEET),
55
96
  usecols=["Kategorie", "Beschreibung", "Punkte", "Version"],
56
97
  index_col=0,
57
98
  )
58
99
  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
- )
100
+
101
+ def _setProjectSettings(self, settings: pd.DataFrame) -> None:
102
+ for tag, value in settings.iterrows():
103
+ self.settings.set(tag, value.iloc[0], local=True)
104
+
105
+ def initAllCategories(self, sheetPath: Path) -> None:
106
+ """Read all category sheets and initialize all Categories."""
107
+ if not hasattr(self, "categoriesMetaData"):
108
+ logger.error("Can't process the Categories without Metadata")
109
+ return
110
+ if hasattr(self, "categories"):
111
+ self.categories.clear()
112
+ else:
113
+ self.categories: dict[str, Category] = {}
114
+ with Path(sheetPath).open("rb") as f:
115
+ excelFile = pd.ExcelFile(f)
116
+ for categoryName in excelFile.sheet_names:
117
+ logger.debug("Starting to read category %s", categoryName)
118
+ if categoryName.startswith("KAT"):
119
+ self.initCategory(sheetPath, categoryName)
120
+
121
+ def asyncInitAllCategories(self, sheetPath: Path) -> None:
122
+ """Read all category sheets asynchron and initialize all Categories.
123
+
124
+ It does the same as `initAllCategories` but the parsing of the excelfile
125
+ is done asynchron via `concurrent.futures.ProcessPoolExecutor`
126
+ """
127
+ if not hasattr(self, "categoriesMetaData"):
128
+ logger.error("Can't process the Categories without Metadata")
129
+ return
130
+ if hasattr(self, "categories"):
131
+ self.categories.clear()
132
+ else:
133
+ self.categories: dict[str, Category] = {}
134
+ sheet_names = []
135
+ with Path(sheetPath).open("rb") as f:
136
+ excel_file = pd.ExcelFile(f)
137
+ sheet_names = [
138
+ name for name in excel_file.sheet_names if name.startswith("KAT_")
139
+ ]
140
+ logger.debug("found those caetegory sheets: \n %s ", sheet_names)
141
+ with ProcessPoolExecutor() as executor:
142
+ futures = {
143
+ executor.submit(processSheet, str(sheetPath), sheet): sheet
144
+ for sheet in sheet_names
145
+ }
146
+ for future in as_completed(futures):
147
+ categoryName = futures[future]
148
+ try:
149
+ categoryDataF = future.result()
150
+ categoryNumber = int(categoryName[4:])
151
+ self._setupCategory(categoryDataF, categoryName, categoryNumber)
152
+ logger.debug("Finished processing %s", categoryName)
153
+ except Exception as e:
154
+ logger.exception("Error processing sheet %s: %s", categoryName, e)
155
+ logger.debug("Future exception: %s", future.exception())
156
+
157
+ def initCategory(self, sheetPath: Path, categoryName: str) -> None:
158
+ """Read `categoryName` from the file ``sheetPath`` and initialize the category."""
159
+ categoryNumber = int(categoryName[4:])
160
+ katDf = pd.read_excel(
161
+ sheetPath,
162
+ sheet_name=str(categoryName),
163
+ index_col=0,
164
+ header=None,
165
+ )
166
+ if not katDf.empty:
167
+ logger.debug("Sucessfully read the Dataframe for cat %s", categoryName)
168
+ self._setupCategory(katDf, categoryName, categoryNumber)
169
+
170
+ def _setupCategory(
171
+ self, categoryDf: pd.DataFrame, categoryName: str, categoryNumber: int
172
+ ) -> None:
173
+ """Setup the category from the ``dataframe``.
174
+ :emits: categoryReady(self) Signal.
175
+ """ # noqa: D401
176
+ p = self.categoriesMetaData["Punkte"].iloc[categoryNumber - 1]
177
+ points = p if not pd.isna(p) else self.settings.get(SettingsKey.POINTS)
178
+ v = self.categoriesMetaData["Version"].iloc[categoryNumber - 1]
179
+ version = v if not pd.isna(v) else self.settings.get(SettingsKey.VERSION)
180
+ category = Category(
181
+ categoryNumber,
182
+ categoryName,
183
+ self.categoriesMetaData["Beschreibung"].iloc[categoryNumber - 1],
184
+ dataframe=categoryDf,
185
+ points=points,
186
+ version=version,
187
+ )
188
+ self.categories[categoryName] = category
189
+ logger.debug("Category %s is initialized", categoryName)
190
+ self.signals.categoryReady.emit(category)
191
+
192
+ def parseAllQuestions(self) -> None:
193
+ """Parse all question from all categories.
194
+
195
+ The categories need to be initialized first.
196
+ """
197
+ for category in self.categories.values():
198
+ self.parseCategoryQuestions(category)
199
+
200
+ def parseCategoryQuestions(self, category: Category) -> None:
201
+ """Parse all questions inside ``category``.
202
+
203
+ The category has to be initialized first.
204
+ """
205
+ for qNum in category.dataframe.columns:
206
+ try:
207
+ self.parseQuestion(category, qNum)
208
+ except InvalidFieldException as e:
209
+ logger.exception(
210
+ "Question %s%02d is invalid.",
211
+ category.id,
212
+ qNum,
213
+ exc_info=e,
214
+ )
215
+ except QNotParsedException as e:
216
+ logger.exception(
217
+ "Frage %s konnte nicht erstellt werden",
218
+ category.questions[qNum].id,
219
+ exc_info=e,
220
+ )
221
+ self.signals.categoryQuestionsReady.emit(category)
222
+
223
+ @classmethod
224
+ def parseQuestion(cls, category: Category, qNumber: int) -> None:
225
+ """Check if the Question Data is valid. Then parse it.
226
+
227
+ The Question data is accessed from `category.dataframe` via its number
228
+ First it is checked if all mandatory fields for the given question type
229
+ are provided.
230
+ Then in checks, weather the data has the correct type.
231
+ If the data is valid, the corresponding parser is fed with the data and run.
232
+
233
+ Raises
234
+ ------
235
+ QNotParsedException
236
+ If the parsing of the question is not possible this is raised
237
+ InvalidFieldException
238
+ If the data of the question is invalid.
239
+ This gives more information wheather a missing field, or the invalid type
240
+ caused the Exception.
241
+
242
+ """
243
+ locallogger = LogAdapterQuestionID(
244
+ logger, {"qID": f"{category.id}{qNumber:02d}"}
245
+ )
246
+ locallogger.debug("Starting to check Validity")
247
+ qdat = category.dataframe[qNumber]
248
+ if not isinstance(qdat, pd.Series):
249
+ locallogger.error("cannot validate data that isn't a pd.Series")
250
+ msg = "cannot validate data that isn't a pd.Series"
251
+ raise QNotParsedException(msg, f"{category.id}{qNumber}")
252
+ cls.validator.setup(qdat, qNumber)
253
+ cls.validator.validate()
254
+ validData = cls.validator.getQuestionRawData()
255
+ qtype: str = str(validData.get(DFIndex.TYPE))
256
+ category.questions[qNumber] = QuestionTypeMapping[qtype].create(
257
+ category, validData
258
+ )
259
+ question = category.questions[qNumber]
260
+ if question.element is not None:
261
+ locallogger.info("Question already parsed")
262
+ return
263
+ if isinstance(question, NFQuestion):
264
+ cls.nfParser.setup(question)
265
+ cls.nfParser.parse()
266
+ locallogger.debug("setup a new NF parser ")
267
+ elif isinstance(question, MCQuestion):
268
+ cls.mcParser.setup(question)
269
+ cls.mcParser.parse()
270
+ locallogger.debug("setup a new MC parser ")
271
+ elif isinstance(question, NFMQuestion):
272
+ cls.nfmParser.setup(question)
273
+ cls.nfmParser.parse()
274
+ locallogger.debug("setup a new NFM parser ")
275
+ else:
276
+ msg = "couldn't setup Parser"
277
+ raise QNotParsedException(msg, question.id)
112
278
 
113
279
  def appendQuestions(
114
280
  self, questions: list[QuestionItem], file: Path | None = None
@@ -143,18 +309,15 @@ class QuestionDB:
143
309
  logger.debug(f"Appended a new category item {cat=}")
144
310
  variant: int = self.settings.get(SettingsKey.QUESTIONVARIANT)
145
311
  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)
312
+ if q.variants is not None:
313
+ if variant == 0 or variant > q.variants:
314
+ dialog = QuestionVariantDialog(self.window, q)
315
+ if dialog.exec() == QtWidgets.QDialog.Accepted:
316
+ variant = dialog.variant
317
+ logger.debug("Die Fragen-Variante %s wurde gewählt", variant)
318
+ q.assemble(variant)
319
+ else:
320
+ logger.warning("Keine Fragenvariante wurde gewählt.")
159
321
  else:
160
- logger.warning(f"Frage {q} wurde nicht erstellt")
322
+ q.assemble()
323
+ tree.append(q.element)
@@ -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
- }