excel2moodle 0.4.1__py3-none-any.whl → 0.4.3__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 +0 -7
- excel2moodle/__main__.py +2 -2
- excel2moodle/core/__init__.py +0 -10
- excel2moodle/core/category.py +4 -3
- excel2moodle/core/dataStructure.py +116 -61
- excel2moodle/core/etHelpers.py +2 -2
- excel2moodle/core/exceptions.py +2 -2
- excel2moodle/core/globals.py +10 -27
- excel2moodle/core/parser.py +24 -30
- excel2moodle/core/question.py +147 -63
- excel2moodle/core/settings.py +107 -111
- excel2moodle/core/validator.py +36 -55
- excel2moodle/logger.py +7 -4
- excel2moodle/question_types/__init__.py +2 -0
- excel2moodle/question_types/cloze.py +207 -0
- excel2moodle/question_types/mc.py +26 -16
- excel2moodle/question_types/nf.py +17 -3
- excel2moodle/question_types/nfm.py +60 -17
- excel2moodle/ui/{windowEquationChecker.py → UI_equationChecker.py} +98 -78
- excel2moodle/ui/{exportSettingsDialog.py → UI_exportSettingsDialog.py} +55 -4
- excel2moodle/ui/{windowMain.py → UI_mainWindow.py} +32 -39
- excel2moodle/ui/appUi.py +66 -86
- excel2moodle/ui/dialogs.py +40 -2
- excel2moodle/ui/equationChecker.py +70 -0
- excel2moodle/ui/treewidget.py +4 -4
- {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/METADATA +2 -3
- excel2moodle-0.4.3.dist-info/RECORD +38 -0
- {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/entry_points.txt +0 -3
- excel2moodle/ui/questionPreviewDialog.py +0 -115
- excel2moodle-0.4.1.dist-info/RECORD +0 -37
- /excel2moodle/ui/{variantDialog.py → UI_variantDialog.py} +0 -0
- {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/WHEEL +0 -0
- {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/licenses/LICENSE +0 -0
- {excel2moodle-0.4.1.dist-info → excel2moodle-0.4.3.dist-info}/top_level.txt +0 -0
excel2moodle/core/settings.py
CHANGED
@@ -5,16 +5,13 @@ from enum import StrEnum
|
|
5
5
|
from pathlib import Path
|
6
6
|
from typing import ClassVar, Literal, overload
|
7
7
|
|
8
|
-
from PySide6.QtCore import QSettings, QTimer, Signal
|
9
|
-
|
10
|
-
import excel2moodle
|
11
|
-
|
12
8
|
logger = logging.getLogger(__name__)
|
13
9
|
|
14
10
|
|
15
|
-
class
|
16
|
-
"""Settings Keys are needed to always acess the correct Value.
|
11
|
+
class Tags(StrEnum):
|
12
|
+
"""Tags and Settings Keys are needed to always acess the correct Value.
|
17
13
|
|
14
|
+
The Tags can be used to acess the settings or the QuestionData respectively.
|
18
15
|
As the QSettings settings are accesed via strings, which could easily gotten wrong.
|
19
16
|
Further, this Enum defines, which type a setting has to be.
|
20
17
|
"""
|
@@ -22,23 +19,27 @@ class SettingsKey(StrEnum):
|
|
22
19
|
def __new__(
|
23
20
|
cls,
|
24
21
|
key: str,
|
25
|
-
place: str,
|
26
22
|
typ: type,
|
27
23
|
default: str | float | Path | bool | None,
|
24
|
+
place: str = "project",
|
28
25
|
):
|
29
26
|
"""Define new settings class."""
|
30
27
|
obj = str.__new__(cls, key)
|
31
28
|
obj._value_ = key
|
32
|
-
obj._place_ = place
|
33
|
-
obj._default_ = default
|
34
29
|
obj._typ_ = typ
|
30
|
+
obj._default_ = default
|
31
|
+
obj._place_ = place
|
35
32
|
return obj
|
36
33
|
|
37
34
|
def __init__(
|
38
|
-
self,
|
35
|
+
self,
|
36
|
+
_,
|
37
|
+
typ: type,
|
38
|
+
default: str | float | Path | None,
|
39
|
+
place: str = "project",
|
39
40
|
) -> None:
|
40
|
-
self._typ_ = typ
|
41
|
-
self._place_ = place
|
41
|
+
self._typ_: type = typ
|
42
|
+
self._place_: str = place
|
42
43
|
self._default_ = default
|
43
44
|
self._full_ = f"{self._place_}/{self._value_}"
|
44
45
|
|
@@ -56,88 +57,105 @@ class SettingsKey(StrEnum):
|
|
56
57
|
return self._full_
|
57
58
|
|
58
59
|
def typ(self) -> type:
|
59
|
-
"""Get
|
60
|
+
"""Get type of the keys data."""
|
60
61
|
return self._typ_
|
61
62
|
|
62
|
-
QUESTIONVARIANT = "defaultQuestionVariant",
|
63
|
-
INCLUDEINCATS = "includeCats",
|
64
|
-
TOLERANCE = "tolerance", "parser/nf"
|
65
|
-
PICTUREFOLDER = "pictureFolder",
|
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
|
-
|
63
|
+
QUESTIONVARIANT = "defaultQuestionVariant", int, 1, "testgen"
|
64
|
+
INCLUDEINCATS = "includeCats", bool, False, "testgen"
|
65
|
+
TOLERANCE = "tolerance", float, 0.01, "parser/nf"
|
66
|
+
PICTUREFOLDER = "pictureFolder", Path, None, "core"
|
67
|
+
PICTURESUBFOLDER = "imgfolder", str, "Abbildungen", "project"
|
68
|
+
SPREADSHEETPATH = "spreadsheetFolder", Path, None, "core"
|
69
|
+
LOGLEVEL = "loglevel", str, "INFO", "core"
|
70
|
+
LOGFILE = "logfile", str, "excel2moodleLogFile.log", "core"
|
71
|
+
CATEGORIESSHEET = "categoriessheet", str, "Kategorien", "core"
|
72
|
+
|
73
|
+
IMPORTMODULE = "importmodule", str, None
|
74
|
+
TEXT = "text", list, None
|
75
|
+
BPOINTS = "bulletpoint", list, None
|
76
|
+
TRUE = "true", list, None
|
77
|
+
FALSE = "false", list, None
|
78
|
+
TYPE = "type", str, None
|
79
|
+
NAME = "name", str, None
|
80
|
+
RESULT = "result", float, None
|
81
|
+
EQUATION = "formula", str, None
|
82
|
+
PICTURE = "picture", str, None
|
83
|
+
NUMBER = "number", int, None
|
84
|
+
ANSTYPE = "answertype", str, None
|
85
|
+
QUESTIONPART = "part", list, None
|
86
|
+
PARTTYPE = "parttype", str, None
|
87
|
+
VERSION = "version", int, 1
|
88
|
+
POINTS = "points", float, 1.0
|
89
|
+
PICTUREWIDTH = "imgwidth", int, 500
|
90
|
+
ANSPICWIDTH = "answerimgwidth", int, 120
|
91
|
+
WRONGSIGNPERCENT = "wrongsignpercentage", int, 50
|
92
|
+
FIRSTRESULT = "firstresult", float, 0
|
93
|
+
|
94
|
+
|
95
|
+
class Settings:
|
96
|
+
values: ClassVar[dict[str, str | float | Path]] = {}
|
97
|
+
|
98
|
+
@classmethod
|
99
|
+
def clear(cls) -> None:
|
100
|
+
cls.values.clear()
|
95
101
|
|
96
102
|
@overload
|
103
|
+
@classmethod
|
97
104
|
def get(
|
98
|
-
|
105
|
+
cls,
|
106
|
+
key: Literal[Tags.POINTS],
|
107
|
+
) -> float: ...
|
108
|
+
@overload
|
109
|
+
@classmethod
|
110
|
+
def get(
|
111
|
+
cls,
|
99
112
|
key: Literal[
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
113
|
+
Tags.QUESTIONVARIANT,
|
114
|
+
Tags.TOLERANCE,
|
115
|
+
Tags.VERSION,
|
116
|
+
Tags.PICTUREWIDTH,
|
117
|
+
Tags.ANSPICWIDTH,
|
118
|
+
Tags.WRONGSIGNPERCENT,
|
106
119
|
],
|
107
120
|
) -> int: ...
|
108
121
|
@overload
|
109
|
-
|
122
|
+
@classmethod
|
123
|
+
def get(cls, key: Literal[Tags.INCLUDEINCATS]) -> bool: ...
|
110
124
|
@overload
|
125
|
+
@classmethod
|
111
126
|
def get(
|
112
|
-
|
127
|
+
cls,
|
113
128
|
key: Literal[
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
129
|
+
Tags.PICTURESUBFOLDER,
|
130
|
+
Tags.LOGLEVEL,
|
131
|
+
Tags.LOGFILE,
|
132
|
+
Tags.CATEGORIESSHEET,
|
133
|
+
Tags.IMPORTMODULE,
|
118
134
|
],
|
119
135
|
) -> str: ...
|
120
136
|
@overload
|
137
|
+
@classmethod
|
121
138
|
def get(
|
122
|
-
|
123
|
-
key: Literal[
|
139
|
+
cls,
|
140
|
+
key: Literal[Tags.PICTUREFOLDER, Tags.SPREADSHEETPATH],
|
124
141
|
) -> Path: ...
|
125
142
|
|
126
|
-
|
143
|
+
@classmethod
|
144
|
+
def get(cls, key: Tags):
|
127
145
|
"""Get the typesafe settings value.
|
128
146
|
|
129
|
-
If local Settings are stored, they are returned.
|
130
147
|
If no setting is made, the default value is returned.
|
131
148
|
"""
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
149
|
+
try:
|
150
|
+
raw = cls.values[key]
|
151
|
+
except KeyError:
|
152
|
+
default = key.default
|
153
|
+
if default is None:
|
154
|
+
return None
|
155
|
+
logger.info("Returning the default value for %s", key)
|
156
|
+
return default
|
139
157
|
if key.typ() is Path:
|
140
|
-
path: Path =
|
158
|
+
path: Path = Path(raw)
|
141
159
|
try:
|
142
160
|
path.resolve(strict=True)
|
143
161
|
except ValueError:
|
@@ -147,10 +165,7 @@ class Settings(QSettings):
|
|
147
165
|
return key.default
|
148
166
|
logger.debug("Returning path setting: %s = %s", key, path)
|
149
167
|
return path
|
150
|
-
raw = self.value(key.full, defaultValue=key.default, type=key.typ())
|
151
|
-
logger.debug("read a settings Value: %s of type: %s", key, key.typ())
|
152
168
|
try:
|
153
|
-
logger.debug("Returning global setting: %s = %s", key, raw)
|
154
169
|
return key.typ()(raw)
|
155
170
|
except (ValueError, TypeError):
|
156
171
|
logger.warning(
|
@@ -158,44 +173,25 @@ class Settings(QSettings):
|
|
158
173
|
)
|
159
174
|
return key.default
|
160
175
|
|
176
|
+
@classmethod
|
161
177
|
def set(
|
162
|
-
|
163
|
-
key:
|
178
|
+
cls,
|
179
|
+
key: Tags | str,
|
164
180
|
value: float | bool | Path | str,
|
165
|
-
local: bool = False,
|
166
181
|
) -> None:
|
167
|
-
"""Set the setting to value.
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
if local:
|
180
|
-
if key in SettingsKey:
|
181
|
-
self.localSettings[key] = value
|
182
|
-
logger.info("Saved the project setting %s = %s", key, value)
|
183
|
-
else:
|
184
|
-
logger.warning("got invalid local Setting %s = %s", key, value)
|
185
|
-
return
|
186
|
-
if not local and isinstance(key, SettingsKey):
|
187
|
-
if not isinstance(value, key.typ()):
|
188
|
-
logger.error("trying to save setting with wrong type not possible")
|
182
|
+
"""Set the setting to value."""
|
183
|
+
if key in Tags:
|
184
|
+
tag = Tags(key) if not isinstance(key, Tags) else key
|
185
|
+
try:
|
186
|
+
cls.values[tag] = tag.typ()(value)
|
187
|
+
except TypeError:
|
188
|
+
logger.exception(
|
189
|
+
"trying to save %s = %s %s with wrong type not possible.",
|
190
|
+
tag,
|
191
|
+
value,
|
192
|
+
type(value),
|
193
|
+
)
|
189
194
|
return
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
def setSpreadsheet(self, sheet: Path) -> None:
|
194
|
-
"""Save spreadsheet path and emit the changed event."""
|
195
|
-
if isinstance(sheet, Path):
|
196
|
-
self.sheet = sheet.resolve(strict=True)
|
197
|
-
logpath = str(self.sheet.parent / "excel2moodleLogFile.log")
|
198
|
-
self.set(SettingsKey.LOGFILE, logpath)
|
199
|
-
self.set(SettingsKey.SPREADSHEETFOLDER, self.sheet)
|
200
|
-
self.shPathChanged.emit(sheet)
|
201
|
-
return
|
195
|
+
logger.info("Saved %s = %s, type %s", key, value, tag.typ())
|
196
|
+
else:
|
197
|
+
logger.warning("got invalid local Setting %s = %s", key, value)
|
excel2moodle/core/validator.py
CHANGED
@@ -10,19 +10,20 @@ which can be accessed via ``Validator.question``
|
|
10
10
|
"""
|
11
11
|
|
12
12
|
import logging
|
13
|
-
from typing import TYPE_CHECKING
|
14
13
|
|
15
14
|
import pandas as pd
|
16
15
|
|
16
|
+
from excel2moodle.core import stringHelpers
|
17
17
|
from excel2moodle.core.exceptions import InvalidFieldException
|
18
|
-
from excel2moodle.core.globals import
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
18
|
+
from excel2moodle.core.globals import QUESTION_TYPES, Tags
|
19
|
+
from excel2moodle.core.question import QuestionData
|
20
|
+
from excel2moodle.core.settings import Settings
|
21
|
+
from excel2moodle.question_types import QuestionTypeMapping
|
23
22
|
|
24
23
|
logger = logging.getLogger(__name__)
|
25
24
|
|
25
|
+
settings = Settings()
|
26
|
+
|
26
27
|
|
27
28
|
class Validator:
|
28
29
|
"""Validate the question data from the spreadsheet.
|
@@ -30,52 +31,15 @@ class Validator:
|
|
30
31
|
Creates a dictionary with the data, for easier access later.
|
31
32
|
"""
|
32
33
|
|
33
|
-
def __init__(self) -> None:
|
34
|
-
self.allMandatory: dict[DFIndex, type | UnionType] = {
|
35
|
-
DFIndex.TEXT: str,
|
36
|
-
DFIndex.NAME: str,
|
37
|
-
DFIndex.TYPE: str,
|
38
|
-
}
|
39
|
-
self.allOptional: dict[DFIndex, type | UnionType] = {
|
40
|
-
DFIndex.PICTURE: int | str,
|
41
|
-
}
|
42
|
-
self.nfOpt: dict[DFIndex, type | UnionType] = {
|
43
|
-
DFIndex.BPOINTS: str,
|
44
|
-
}
|
45
|
-
self.nfMand: dict[DFIndex, type | UnionType] = {
|
46
|
-
DFIndex.RESULT: float | int,
|
47
|
-
}
|
48
|
-
self.nfmOpt: dict[DFIndex, type | UnionType] = {}
|
49
|
-
self.nfmMand: dict[DFIndex, type | UnionType] = {
|
50
|
-
DFIndex.RESULT: str,
|
51
|
-
DFIndex.BPOINTS: str,
|
52
|
-
}
|
53
|
-
self.mcOpt: dict[DFIndex, type | UnionType] = {}
|
54
|
-
self.mcMand: dict[DFIndex, type | UnionType] = {
|
55
|
-
DFIndex.TRUE: str,
|
56
|
-
DFIndex.FALSE: str,
|
57
|
-
DFIndex.ANSTYPE: str,
|
58
|
-
}
|
59
|
-
|
60
|
-
self.mapper: dict = {
|
61
|
-
"NF": (self.nfOpt, self.nfMand),
|
62
|
-
"MC": (self.mcOpt, self.mcMand),
|
63
|
-
"NFM": (self.nfmOpt, self.nfmMand),
|
64
|
-
}
|
65
|
-
|
66
34
|
def setup(self, df: pd.Series, index: int) -> None:
|
67
35
|
self.df = df
|
68
36
|
self.index = index
|
69
|
-
|
70
|
-
|
71
|
-
typ = self.df.loc[DFIndex.TYPE]
|
72
|
-
if typ not in self.mapper:
|
37
|
+
typ = self.df.loc[Tags.TYPE]
|
38
|
+
if typ not in QUESTION_TYPES:
|
73
39
|
msg = f"No valid question type provided. {typ} is not a known type"
|
74
|
-
raise InvalidFieldException(msg, "index:02d",
|
75
|
-
self.mandatory.
|
76
|
-
self.optional.
|
77
|
-
self.mandatory.update(self.mapper[typ][1])
|
78
|
-
self.optional.update(self.mapper[typ][0])
|
40
|
+
raise InvalidFieldException(msg, "index:02d", Tags.TYPE)
|
41
|
+
self.mandatory = QuestionTypeMapping[typ].value.mandatoryTags
|
42
|
+
self.optional = QuestionTypeMapping[typ].value.optionalTags
|
79
43
|
|
80
44
|
def validate(self) -> None:
|
81
45
|
qid = f"{self.index:02d}"
|
@@ -90,9 +54,9 @@ class Validator:
|
|
90
54
|
if missing is not None:
|
91
55
|
raise InvalidFieldException(msg, qid, missing)
|
92
56
|
|
93
|
-
def
|
57
|
+
def getQuestionData(self) -> QuestionData:
|
94
58
|
"""Get the data from the spreadsheet as a dictionary."""
|
95
|
-
self.qdata: dict[str,
|
59
|
+
self.qdata: dict[str, int | float | list[str] | str] = {}
|
96
60
|
for idx, val in self.df.items():
|
97
61
|
if not isinstance(idx, str):
|
98
62
|
continue
|
@@ -106,9 +70,26 @@ class Validator:
|
|
106
70
|
self.qdata[idx] = [existing, val]
|
107
71
|
else:
|
108
72
|
self.qdata[idx] = val
|
109
|
-
return self.
|
110
|
-
|
111
|
-
def
|
73
|
+
return self.formatQData()
|
74
|
+
|
75
|
+
def formatQData(self) -> QuestionData:
|
76
|
+
"""Format the dictionary to The types for QuestionData."""
|
77
|
+
listTags = (Tags.BPOINTS, Tags.TRUE, Tags.FALSE, Tags.TEXT, Tags.QUESTIONPART)
|
78
|
+
for tag in listTags:
|
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])
|
82
|
+
tol = float(self.qdata.get(Tags.TOLERANCE, 0))
|
83
|
+
if tol <= 0 or tol > 99:
|
84
|
+
self.qdata[Tags.TOLERANCE] = settings.get(Tags.TOLERANCE)
|
85
|
+
else:
|
86
|
+
self.qdata[Tags.TOLERANCE] = tol if tol < 1 else tol / 100
|
87
|
+
|
88
|
+
if self.qdata[Tags.TYPE] == "NFM":
|
89
|
+
self.qdata[Tags.EQUATION] = str(self.qdata[Tags.RESULT])
|
90
|
+
return QuestionData(self.qdata)
|
91
|
+
|
92
|
+
def _mandatory(self) -> tuple[bool, Tags | None]:
|
112
93
|
"""Detects if all keys of mandatory are filled with values."""
|
113
94
|
checker = pd.Series.notna(self.df)
|
114
95
|
for k in self.mandatory:
|
@@ -123,8 +104,8 @@ class Validator:
|
|
123
104
|
return False, k
|
124
105
|
return True, None
|
125
106
|
|
126
|
-
def _typeCheck(self) -> tuple[bool, list[
|
127
|
-
invalid: list[
|
107
|
+
def _typeCheck(self) -> tuple[bool, list[Tags] | None]:
|
108
|
+
invalid: list[Tags] = []
|
128
109
|
for field, typ in self.mandatory.items():
|
129
110
|
if field in self.df and isinstance(self.df[field], pd.Series):
|
130
111
|
for f in self.df[field]:
|
excel2moodle/logger.py
CHANGED
@@ -6,10 +6,11 @@ This includes emitting the signals for the main Window, to fast forward all logs
|
|
6
6
|
|
7
7
|
import logging
|
8
8
|
|
9
|
-
from PySide6.QtCore import QObject, Signal
|
9
|
+
from PySide6.QtCore import QObject, QSettings, Signal
|
10
10
|
|
11
|
-
from excel2moodle.core.settings import Settings,
|
11
|
+
from excel2moodle.core.settings import Settings, Tags
|
12
12
|
|
13
|
+
qSettings = QSettings("jbosse3", "excel2moodle")
|
13
14
|
settings = Settings()
|
14
15
|
|
15
16
|
loggerConfig = {
|
@@ -36,7 +37,9 @@ loggerConfig = {
|
|
36
37
|
"formatter": "file",
|
37
38
|
"class": "logging.FileHandler",
|
38
39
|
# "class": "logging.handlers.TimedRotatingFileHandler",
|
39
|
-
"filename":
|
40
|
+
"filename": qSettings.value(
|
41
|
+
Tags.LOGFILE, defaultValue=Tags.LOGFILE.default
|
42
|
+
),
|
40
43
|
# "when": "M",
|
41
44
|
# "interval": 1,
|
42
45
|
# "backupCount": "3",
|
@@ -75,7 +78,7 @@ class LogWindowHandler(logging.Handler):
|
|
75
78
|
log_format = "[%(levelname)s] %(module)s: %(message)s"
|
76
79
|
self.formatter = logging.Formatter(log_format)
|
77
80
|
self.setFormatter(self.formatter)
|
78
|
-
loglevel = settings.get(
|
81
|
+
loglevel = settings.get(Tags.LOGLEVEL)
|
79
82
|
self.setLevel(loglevel)
|
80
83
|
self.logLevelColors = {
|
81
84
|
"DEBUG": "gray",
|
@@ -12,6 +12,7 @@ Both go into a module named ``excel2moodle.question_types.type.py``
|
|
12
12
|
from enum import Enum
|
13
13
|
|
14
14
|
from excel2moodle.core.category import Category
|
15
|
+
from excel2moodle.question_types.cloze import ClozeQuestion
|
15
16
|
from excel2moodle.question_types.mc import MCQuestion
|
16
17
|
from excel2moodle.question_types.nf import NFQuestion
|
17
18
|
from excel2moodle.question_types.nfm import NFMQuestion
|
@@ -23,6 +24,7 @@ class QuestionTypeMapping(Enum):
|
|
23
24
|
MC = MCQuestion
|
24
25
|
NF = NFQuestion
|
25
26
|
NFM = NFMQuestion
|
27
|
+
CLOZE = ClozeQuestion
|
26
28
|
|
27
29
|
def create(
|
28
30
|
self,
|