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 +1 -2
- excel2moodle/__main__.py +1 -2
- excel2moodle/core/category.py +6 -44
- excel2moodle/core/dataStructure.py +242 -79
- excel2moodle/core/globals.py +0 -20
- excel2moodle/core/parser.py +15 -171
- excel2moodle/core/question.py +30 -13
- excel2moodle/core/stringHelpers.py +8 -32
- excel2moodle/core/{questionValidator.py → validator.py} +26 -31
- excel2moodle/question_types/__init__.py +33 -0
- excel2moodle/question_types/mc.py +93 -0
- excel2moodle/question_types/nf.py +30 -0
- excel2moodle/question_types/nfm.py +92 -0
- excel2moodle/ui/appUi.py +38 -33
- excel2moodle/ui/dialogs.py +8 -7
- excel2moodle/ui/settings.py +98 -35
- {excel2moodle-0.3.6.dist-info → excel2moodle-0.3.7.dist-info}/METADATA +2 -2
- excel2moodle-0.3.7.dist-info/RECORD +37 -0
- {excel2moodle-0.3.6.dist-info → excel2moodle-0.3.7.dist-info}/WHEEL +1 -1
- excel2moodle-0.3.6.dist-info/RECORD +0 -33
- {excel2moodle-0.3.6.dist-info → excel2moodle-0.3.7.dist-info}/entry_points.txt +0 -0
- {excel2moodle-0.3.6.dist-info → excel2moodle-0.3.7.dist-info}/licenses/LICENSE +0 -0
- {excel2moodle-0.3.6.dist-info → excel2moodle-0.3.7.dist-info}/top_level.txt +0 -0
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
|
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()
|
excel2moodle/core/category.py
CHANGED
@@ -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
|
-
|
7
|
-
|
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.
|
19
|
-
from excel2moodle.logger import
|
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
|
-
"""
|
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
|
-
|
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
|
40
|
-
self.categories: dict[str, Category]
|
72
|
+
self.categoriesMetaData: pd.DataFrame
|
73
|
+
self.categories: dict[str, Category]
|
41
74
|
|
42
|
-
def
|
75
|
+
def readCategoriesMetadata(self, sheetPath: Path) -> None:
|
43
76
|
"""Read the metadata and questions from the spreadsheet.
|
44
77
|
|
45
|
-
|
78
|
+
Get the category data from the spreadsheet and stores it in the
|
46
79
|
``categoriesMetaData`` dataframe
|
47
|
-
|
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(
|
51
|
-
|
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=
|
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
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
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
|
147
|
-
if q.variants
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
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
|
-
|
322
|
+
q.assemble()
|
323
|
+
tree.append(q.element)
|
excel2moodle/core/globals.py
CHANGED
@@ -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
|
-
}
|