niceguicrud 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2024 <Dronakurl <tbo73oij5@mozmail.com>>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.1
2
+ Name: niceguicrud
3
+ Version: 0.1.0
4
+ Summary: Edit your pydantic models with a nice GUI - CRUD Create Update Replace Delete
5
+ License: MIT
6
+ Author: Dronakurl
7
+ Author-email: tbo73oij5@mozmail.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: nicegui (>=1.4.26,<2.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # NiceCRUD
16
+
17
+ NiceCRUD is a CRUD (Create, Read, Update, Delete) interface built with python. The library integrates with pydantic models and [NiceGUI](https://nicegui.io) to handle data manipulation with a browser interface.
18
+
19
+ ## Features
20
+
21
+ - Automatically generate CRUD interfaces from pydantic models.
22
+ - Field options, ranges, contraints, descriptions etc. are directly taken from your pydantic model
23
+ - Integrated validation and error handling with pydantic.
24
+ - Support for nested models and selection options.
25
+ - Inject your own (database?) update methods
26
+ - Minimal configuration required with sensible defaults.
27
+
28
+ ## Screenshots
29
+
30
+ Taken from the [input_choices](/examples/input_choices.py) example:
31
+
32
+ ![CRUD Interface Screenshot - Grid of objects](/examples/input_choices.png)
33
+ ![CRUD Interface Screenshot - Editing objects](/examples/input_choices_add.png)
34
+
35
+ ## Installation
36
+
37
+ To install NiceCRUD, use pip:
38
+
39
+ <!-- pip install 'git+https://github.com/Dronakurl/nicecrud.git'-->
40
+
41
+ ```bash
42
+ pip install niceguicrud
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ Here is a very basic example:
48
+
49
+ ```python
50
+
51
+ from nicegui import ui
52
+ from pydantic import BaseModel, Field
53
+
54
+ from niceguicrud import NiceCRUD
55
+
56
+
57
+ class MyModel(BaseModel, title="User"):
58
+ id: int
59
+ name: str = Field(title="Name")
60
+ age: int = Field(gt=0, title="Age")
61
+
62
+
63
+ instance1 = MyModel(id=1, name="Alice", age=30)
64
+ instance2 = MyModel(id=2, name="Bob", age=25)
65
+ crud_app = NiceCRUD(basemodels=[instance1, instance2], id_field="id", heading="User Management")
66
+
67
+ ui.run()
68
+ ```
69
+
70
+ Find more in the [examples](/examples) folder.
71
+ | Example Name | Description |
72
+ |-----------------|-----------------------------------------|
73
+ | [minimal](/examples/minimal.py) | The above minimal example |
74
+ | [validation](/examples/validation.py) | Example showcasing how pydantic validation features are used in the GUI |
75
+ | [submodel](/examples/submodel.py) | Demonstrates usage of a pydantic submodel that can also be used in the GUI. |
76
+ | [input_choices](/examples/input_choices.py) | Shows the different input choices. |
77
+ | [database](/examples/database.py) | Shows how to customize the update, create and delete operations |
78
+
79
+ ## Contributing
80
+
81
+ Contributions are welcome!
82
+
83
+ ## License
84
+
85
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE).
86
+
@@ -0,0 +1,71 @@
1
+ # NiceCRUD
2
+
3
+ NiceCRUD is a CRUD (Create, Read, Update, Delete) interface built with python. The library integrates with pydantic models and [NiceGUI](https://nicegui.io) to handle data manipulation with a browser interface.
4
+
5
+ ## Features
6
+
7
+ - Automatically generate CRUD interfaces from pydantic models.
8
+ - Field options, ranges, contraints, descriptions etc. are directly taken from your pydantic model
9
+ - Integrated validation and error handling with pydantic.
10
+ - Support for nested models and selection options.
11
+ - Inject your own (database?) update methods
12
+ - Minimal configuration required with sensible defaults.
13
+
14
+ ## Screenshots
15
+
16
+ Taken from the [input_choices](/examples/input_choices.py) example:
17
+
18
+ ![CRUD Interface Screenshot - Grid of objects](/examples/input_choices.png)
19
+ ![CRUD Interface Screenshot - Editing objects](/examples/input_choices_add.png)
20
+
21
+ ## Installation
22
+
23
+ To install NiceCRUD, use pip:
24
+
25
+ <!-- pip install 'git+https://github.com/Dronakurl/nicecrud.git'-->
26
+
27
+ ```bash
28
+ pip install niceguicrud
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ Here is a very basic example:
34
+
35
+ ```python
36
+
37
+ from nicegui import ui
38
+ from pydantic import BaseModel, Field
39
+
40
+ from niceguicrud import NiceCRUD
41
+
42
+
43
+ class MyModel(BaseModel, title="User"):
44
+ id: int
45
+ name: str = Field(title="Name")
46
+ age: int = Field(gt=0, title="Age")
47
+
48
+
49
+ instance1 = MyModel(id=1, name="Alice", age=30)
50
+ instance2 = MyModel(id=2, name="Bob", age=25)
51
+ crud_app = NiceCRUD(basemodels=[instance1, instance2], id_field="id", heading="User Management")
52
+
53
+ ui.run()
54
+ ```
55
+
56
+ Find more in the [examples](/examples) folder.
57
+ | Example Name | Description |
58
+ |-----------------|-----------------------------------------|
59
+ | [minimal](/examples/minimal.py) | The above minimal example |
60
+ | [validation](/examples/validation.py) | Example showcasing how pydantic validation features are used in the GUI |
61
+ | [submodel](/examples/submodel.py) | Demonstrates usage of a pydantic submodel that can also be used in the GUI. |
62
+ | [input_choices](/examples/input_choices.py) | Shows the different input choices. |
63
+ | [database](/examples/database.py) | Shows how to customize the update, create and delete operations |
64
+
65
+ ## Contributing
66
+
67
+ Contributions are welcome!
68
+
69
+ ## License
70
+
71
+ This project is licensed under the MIT License. See the [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ from .nicecrud import FieldOptions, NiceCRUD, NiceCRUDCard, NiceCRUDConfig
@@ -0,0 +1,55 @@
1
+ from typing import Optional, Sequence
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ def basemodel_to_columns(
7
+ bm: type[BaseModel],
8
+ exclude: set[str] = set(),
9
+ include: Optional[set[str]] = None,
10
+ ) -> list[dict]:
11
+ """Given a pydantic BaseModel type (class derived from BaseModel) return
12
+ columns in list of dict format to be used with nicegui `ui.table`
13
+
14
+ Args:
15
+ bm: pydantic BaseModel type
16
+ exclude: set of fields to be excluded
17
+ include: set of fields to be included, None if all fields should be
18
+ included (if not specifically excluded with the exclude argument)
19
+
20
+ Returns:
21
+ list of dict with column information for nicegui (name, label, field)
22
+ """
23
+ columns = []
24
+ for fieldname, fieldinfo in bm.model_fields.items():
25
+ if fieldname in exclude:
26
+ continue
27
+ if include is not None and fieldname not in include:
28
+ continue
29
+ if not fieldinfo.exclude:
30
+ columns.append(dict(name=fieldname, label=fieldinfo.title or fieldname, field=fieldname))
31
+ return columns
32
+
33
+
34
+ def basemodellist_to_rows(
35
+ bmlist: Sequence[BaseModel],
36
+ exclude: set[str] = set(),
37
+ include: Optional[set[str]] = None,
38
+ ) -> list[dict]:
39
+ rows = []
40
+ for bm in bmlist:
41
+ rr = bm.model_dump(include=include, exclude=exclude, context=dict(gui=True))
42
+ rows.append(rr)
43
+ return rows
44
+
45
+
46
+ def basemodellist_to_rows_and_cols(
47
+ bmlist: Sequence[BaseModel],
48
+ exclude: set[str] = set(),
49
+ include: Optional[set[str]] = None,
50
+ ) -> tuple[list[dict], list[dict]]:
51
+ if not bmlist:
52
+ return [], []
53
+ columns = basemodel_to_columns(bmlist[0].__class__, exclude, include)
54
+ rows = basemodellist_to_rows(bmlist, exclude, include)
55
+ return rows, columns
@@ -0,0 +1,830 @@
1
+ import html
2
+ import logging
3
+ import re
4
+ import typing
5
+ from functools import partial
6
+ from typing import (Awaitable, Callable, Generic, Literal, Optional, Type,
7
+ TypeVar, Union)
8
+
9
+ import annotated_types
10
+ import httpx
11
+ from nicegui import events, ui
12
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
13
+ from pydantic.fields import FieldInfo
14
+
15
+ from .basemodel_to_table import basemodellist_to_rows_and_cols
16
+ from .show_error import show_error
17
+
18
+ log = logging.getLogger(__name__)
19
+ log.addHandler(logging.NullHandler())
20
+
21
+
22
+ class FieldOptions(BaseModel, title="Options that can be set in each Field in json_schema_extra"):
23
+ """Options that can be set in each Field in json_schema_extra"""
24
+
25
+ # NOTE: It would be nice to inherit from FieldInfo class
26
+ step: Optional[float] = Field(default=None, title="Step size for numeric Fields")
27
+ input_type: Optional[Literal["slider", "number", "select"]] = Field(default=None)
28
+ readonly: Optional[bool] = Field(default=None)
29
+ exclude: Optional[bool] = Field(default=None)
30
+
31
+
32
+ class NiceCRUDConfig(BaseModel, title="Options for a NiceCRUD instance"):
33
+ """General nicecrud config. This is put in a BaseModel objects so it is easier to use the editor to manage options"""
34
+
35
+ id_field: str = ""
36
+ id_label: Optional[str] = None
37
+ no_data_label: str = "No data given"
38
+ search_input_label: Optional[str] = None
39
+ heading: str | None = None
40
+ add_button_text: str = "Add new item"
41
+ delete_button_text: str = "Delete selected items"
42
+ model_config: ConfigDict = ConfigDict(validate_assignment=True)
43
+ new_item_dialog_heading: Optional[str] = None
44
+ update_item_dialog_heading: Optional[str] = None
45
+ additional_exclude: list[str] = Field(
46
+ default_factory=list,
47
+ description="fields that should be excluded from the CRUD application additionally to those excluded in pydantic",
48
+ )
49
+ additional_exclude_submodels: list[str] = Field(
50
+ default_factory=list,
51
+ description="fields that should be excluded from the CRUD application in the submodel additionally ",
52
+ )
53
+ heading_classes: str = Field(default="text-xl font-bold", description="CSS (tailwind) classes for headings")
54
+ subheading_classes: str = Field(default="text-lg font-bold", description="CSS (tailwind) classes for subheadings")
55
+
56
+ def update(self, data: dict):
57
+ for k, v in data.items():
58
+ setattr(self, k, v)
59
+
60
+
61
+ # NiceCRUD can be used with any pydantic basemodel class. T is the generic type
62
+ # that is a placeholder for the specific class.
63
+ T = TypeVar("T", bound=BaseModel)
64
+
65
+
66
+ class FieldHelperMixin(Generic[T]):
67
+ """Mixin class to provide field functions to be used in NiceCRUD and NiceCRUDCard alike"""
68
+
69
+ config: NiceCRUDConfig
70
+ basemodeltype: Type[T]
71
+ included_fields: list[tuple[str, FieldInfo]]
72
+
73
+ def __init__(self) -> None:
74
+ self.get_included_fields()
75
+
76
+ def is_excluded(self, field_name: str, field_info: FieldInfo) -> bool:
77
+ """Checks if a given field should be excluded from the card"""
78
+ if not hasattr(self, "config") or not (isinstance(getattr(self, "config"), NiceCRUDConfig)):
79
+ raise AttributeError("config not found")
80
+ return (
81
+ field_info.exclude
82
+ or field_name in self.config.additional_exclude_submodels
83
+ or (
84
+ field_info.json_schema_extra is not None
85
+ and isinstance(field_info.json_schema_extra, bool)
86
+ and field_info.json_schema_extra.get("exclude", False)
87
+ )
88
+ )
89
+
90
+ def get_included_fields(self):
91
+ """Get a list of fields to be included in the card"""
92
+ self.included_fields = []
93
+ for field_name, field_info in self.basemodeltype.model_fields.items():
94
+ if not self.is_excluded(field_name=field_name, field_info=field_info):
95
+ self.included_fields.append((field_name, field_info))
96
+
97
+ @property
98
+ def column_count(self):
99
+ return max((int(len(self.included_fields) / 4), 1))
100
+
101
+ def field_exists(self, field_name: str):
102
+ return field_name in self.basemodeltype.model_fields.keys()
103
+
104
+
105
+ class NiceCRUDCard(FieldHelperMixin, Generic[T]):
106
+ """One card with inputs for one BaseModel object
107
+
108
+ This is separate, so that it can be used stand alone.
109
+
110
+ Attributes:
111
+ item: The BaseModel object
112
+ select_options: awaitable coroutine taking the field_name and one BaseModel object,
113
+ returning the dictionary with the select options for that field
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ item: T,
119
+ select_options: Optional[Callable[[str, T], Awaitable[dict]]] = None,
120
+ config: NiceCRUDConfig = NiceCRUDConfig(),
121
+ id_editable: bool = True,
122
+ on_change_extra: Optional[Callable[[str, T], None]] = None,
123
+ on_validation_result: Callable[[bool], None] = lambda x: None,
124
+ **kwargs,
125
+ ):
126
+ self.item: T = item
127
+ self.config = config
128
+ self.config.update(kwargs)
129
+ self.id_editable = id_editable
130
+ self.errormsg = dict(msg="", visible=False)
131
+ self.select_options: Callable[[str, T], Awaitable[dict]]
132
+ if select_options is not None:
133
+ self.select_options = select_options
134
+ else:
135
+
136
+ async def default_select_options(field_name: str, obj: T):
137
+ return dict()
138
+
139
+ self.select_options = default_select_options
140
+ self.on_change_extra = on_change_extra
141
+ self.on_validation_result = on_validation_result
142
+ self.subitem_dialog = None
143
+ self.basemodeltype = type(item)
144
+ super().__init__()
145
+ # As create_card needs to be async, use timer to run it in the nicegui
146
+ # asyncio event loop
147
+ ui.timer(0, self.create_card, once=True)
148
+
149
+ def onchange(self, value, attr: str = "", refresh: bool = False):
150
+ """Called on every change of an input, tries to validate the BaseModel"""
151
+ if hasattr(value, "value"):
152
+ value = value.value
153
+ try:
154
+ self.errormsg["msg"] = ""
155
+ self.errormsg["visible"] = False
156
+ if isinstance(getattr(self.item, attr), int):
157
+ value = None if value is None else int(value)
158
+ setattr(self.item, attr, value)
159
+ val_result = True
160
+ except ValidationError as e:
161
+ self.errormsg["msg"] = e.errors()[0]["msg"]
162
+ self.errormsg["msg"] = re.sub(r"^Value error, ", "", self.errormsg["msg"])
163
+ self.errormsg["msg"] = re.sub(
164
+ r"^Input should be a valid string",
165
+ str(e.errors()[0]["loc"]).replace("(", "").replace(")", "").replace(",", "") + ": not a string",
166
+ self.errormsg["msg"],
167
+ )
168
+ self.errormsg["visible"] = True
169
+ val_result = False
170
+ if self.on_change_extra is not None:
171
+ self.on_change_extra(attr, self.item)
172
+ self.on_validation_result(val_result)
173
+ if refresh:
174
+ self.create_card.refresh()
175
+
176
+ @ui.refreshable
177
+ async def create_card(self):
178
+ # with ui.column().classes("w-full"):
179
+ grid_class = "gap-1 gap-x-6 w-full items-center"
180
+ columns = "minmax(100px,max-content) 1fr " * self.column_count
181
+ with ui.grid(columns=columns).classes(grid_class):
182
+ for field_name, field_info in self.included_fields:
183
+ if field_name == self.config.id_field and not self.id_editable:
184
+ continue
185
+ await self.get_input(field_name, field_info)
186
+ errlabel, errrow = show_error("")
187
+ errlabel.bind_text_from(self.errormsg, "msg")
188
+ errrow.bind_visibility_from(self.errormsg, "visible").classes("w-full")
189
+
190
+ @staticmethod
191
+ def get_min_max_from_field_info(field_info: FieldInfo) -> tuple[Optional[float], Optional[float]]:
192
+ _min, _max = None, None
193
+ for m in field_info.metadata:
194
+ if isinstance(m, annotated_types.Gt):
195
+ _min = m.gt + 0 # type: ignore
196
+ elif isinstance(m, annotated_types.Ge):
197
+ _min = m.ge + 0 # type: ignore
198
+ elif isinstance(m, annotated_types.Lt):
199
+ _max = m.lt - 0 # type: ignore
200
+ elif isinstance(m, annotated_types.Le):
201
+ _max = m.le + 0 # type: ignore
202
+ return _max, _min
203
+
204
+ async def get_input(self, field_name: str, field_info: FieldInfo):
205
+ """From the field_info, derive the appropriate NiceGUI input element"""
206
+ typ = field_info.annotation
207
+ curval = getattr(self.item, field_name)
208
+ # curval: Any = self.item.model_dump(mode="json").get(field_name)
209
+ validation = partial(self.onchange, attr=field_name)
210
+ validation_refresh = partial(self.onchange, attr=field_name, refresh=True)
211
+ with ui.label((field_info.title or field_name) + ":"):
212
+ if field_info.description is not None:
213
+ with ui.tooltip():
214
+ ui.html(field_info.description)
215
+ else:
216
+ pass
217
+ _max, _min = self.get_min_max_from_field_info(field_info)
218
+ # Metadata in json_schema_extra
219
+ _step = None
220
+ _input_type = None
221
+ _readonly = False
222
+ extra = field_info.json_schema_extra
223
+ if extra:
224
+ _step = extra.get("step")
225
+ _input_type = extra.get("input_type")
226
+ _readonly = extra.get("readonly", False)
227
+ _optional = False
228
+ # Generate the UI elements
229
+ ele = None
230
+ if typing.get_origin(typ) == Union:
231
+ # Optional Fields
232
+ if len(typing.get_args(typ)) > 1 and typing.get_args(typ)[1] == type(None):
233
+ typ = typing.get_args(typ)[0]
234
+ _optional = True
235
+ # Literal[BaseModel1, BaseModel2]
236
+ elif all([issubclass(x, BaseModel) for x in typing.get_args(typ)]):
237
+ _input_type = "basemodelswitcher"
238
+ if _input_type == "select":
239
+ selections = await self.select_options(field_name, self.item)
240
+ if len(selections) == 0:
241
+ selections = {curval: curval}
242
+ log.debug(f"{field_name=}: selections = {selections}")
243
+ if curval not in selections and len(selections) > 0:
244
+ curval = next(iter(selections.keys()))
245
+ ele = ui.select(options=selections, value=curval, validation=validation)
246
+ elif _input_type == "basemodelswitcher":
247
+ typemapper = {x.__name__: x for x in typing.get_args(typ)}
248
+ selections = {x.__name__: x.model_config.get("title", x.__name__) for x in typing.get_args((typ))}
249
+ log.debug(f"{field_name=}: selections = {selections}")
250
+ if curval.__class__.__name__ not in selections and len(selections) > 0:
251
+ log.warning(f"{curval.__class__.__name__=}: not found in selections")
252
+ curval = next(iter(selections.keys()))
253
+
254
+ with ui.row().classes("items-center justify-shrink w-full flex-nowrap"):
255
+ # This is needed, to bin it to the label object
256
+ label = dict(label=str(curval.model_dump(context=dict(gui=True))))
257
+ # This is used to store settings for each BaseModel type
258
+ stordict = dict()
259
+
260
+ def handle_base_model_switch():
261
+ """Submodel is selected. Check if class changed to change to dialog later"""
262
+ nonlocal curval
263
+ if curval.__class__.__name__ != selecta.value:
264
+ # Save the settings for the current class for later use
265
+ stordict[curval.__class__.__name__] = curval.model_dump()
266
+ # Create a new object and make sure, that it is stored within main item
267
+ # Restore data from previous edits if possible
268
+ opts = stordict.get(selecta.value or "", dict())
269
+ # Load settings of previously selected class
270
+ curval = typemapper.get(selecta.value)(**opts) # pyright: ignore
271
+ # Create a new object of the newly selected class
272
+ setattr(self.item, field_name, curval)
273
+ label["label"] = str(curval.model_dump(context=dict(gui=True)))
274
+
275
+ selecta = ui.select(
276
+ options=selections,
277
+ value=curval.__class__.__name__,
278
+ on_change=lambda: handle_base_model_switch(),
279
+ )
280
+ lab = ui.label().classes("text-slate-500 hidden").bind_text(label, "label")
281
+
282
+ ele = (
283
+ ui.button(
284
+ icon="edit", on_click=lambda: self.handle_edit_subitem(getattr(self.item, field_name), lab)
285
+ )
286
+ .props("flat round")
287
+ .classes("text-lightprimary dark:primary")
288
+ )
289
+ elif typ is None:
290
+ log.error(f"no type found for {self.item}")
291
+ ui.label("ERROR")
292
+ elif typ == str:
293
+ # String Inputs
294
+ ele = ui.input(value=curval, validation=validation, placeholder=field_info.description or "")
295
+ if _optional:
296
+ ele.props("clearable")
297
+ elif typ in (int, float):
298
+ # Number inputs
299
+ if _input_type == "number" or _input_type is None or _min is None or _max is None:
300
+ ele = ui.number(
301
+ value=curval,
302
+ validation=validation,
303
+ min=_min,
304
+ max=_max,
305
+ step=_step, # type: ignore
306
+ )
307
+ if _optional:
308
+ ele.props("clearable")
309
+ elif _input_type == "slider":
310
+ # slider is only available when min and max where set (see condition for number)
311
+ ui.slider(
312
+ value=curval,
313
+ on_change=validation,
314
+ min=_min,
315
+ max=_max,
316
+ step=_step, # type: ignore
317
+ ).props("label-always").classes("my-4")
318
+ elif typing.get_origin(typ) == Literal:
319
+ ele = ui.select(
320
+ [x for x in typing.get_args(typ)],
321
+ value=curval,
322
+ validation=validation_refresh,
323
+ )
324
+ elif typ == bool:
325
+ ele = ui.switch(value=curval, on_change=validation_refresh)
326
+ elif typ == BaseModel or (isinstance(typ, type) and issubclass(typ, BaseModel)):
327
+ with ui.row().classes("items-center justify-shrink w-full flex-nowrap"):
328
+ lab = ui.label(str(curval.model_dump(context=dict(gui=True)))).classes("text-slate-500")
329
+ clickfun = partial(self.handle_edit_subitem, curval, lab)
330
+ ele = (
331
+ ui.button(icon="edit", on_click=clickfun)
332
+ .props("flat round")
333
+ .classes("text-lightprimary dark:primary")
334
+ )
335
+ elif typing.get_origin(typ) == list and typing.get_args(typ)[0] == str:
336
+ ele = ui.input(value=",".join(curval), validation=lambda v: validation(v.split(",")))
337
+ elif typing.get_origin(typ) == list and issubclass(typing.get_args(typ)[0], (int, float)):
338
+ ele = ui.input(value=",".join(map(str, curval)), validation=lambda v: validation(v.split(",")))
339
+ else:
340
+ log.warning(f"Unknown input for {field_name=} of {typ=}")
341
+ ele = ui.input(value="ERROR", validation=validation)
342
+ if (_readonly and ele is not None) or (
343
+ ele is not None and field_name == self.config.id_field and not self.id_editable
344
+ ):
345
+ ele.disable()
346
+
347
+ def handle_edit_subitem(self, curval: BaseModel, lab: ui.label):
348
+ log.debug(f"handle_edit_subitem {curval.model_dump(context=dict(gui=True))}")
349
+ self.get_subitem_dialog(curval)
350
+ if self.subitem_dialog is None:
351
+ log.error(f"Dialog for {curval} will not open")
352
+ return
353
+ self.subitem_dialog.open()
354
+ self.subitem_dialog.on("before-hide", lambda: lab.set_text(str(curval.model_dump(context=dict(gui=True)))))
355
+
356
+ def get_subitem_dialog(self, item: BaseModel):
357
+ log.debug("get_subitem_dialog")
358
+ with ui.dialog() as self.subitem_dialog, ui.card():
359
+ title = item.model_config.get("title")
360
+ if title is not None:
361
+ ui.label(title).classes("text-lg")
362
+ with ui.row():
363
+ NiceCRUDCard(item=item, config=self.config)
364
+
365
+
366
+ class NiceCRUD(FieldHelperMixin[T], Generic[T]):
367
+ """NiceGUI implementation of a CRUD application: CreateReplaceUpdateDelete
368
+
369
+ Usage:
370
+ Create a list of your pydantic.BaseModel objects.
371
+ The BaseModel class has to be configured to validate_assignment:
372
+ `model_dict = ConfigDict(validate_assignment=True)`
373
+
374
+ Inherit from this class to adapt to your usecase. Overload the methods
375
+ `create`, `update` and `delete`. These methods should return None and
376
+ raise KeyError if the id_field of the updated or deleted item cannot be
377
+ found.
378
+ """
379
+
380
+ def __init__(
381
+ self,
382
+ basemodeltype: Optional[Type[T]] = None,
383
+ basemodels: list[T] = [],
384
+ id_field: Optional[str] = None,
385
+ config: NiceCRUDConfig | dict = NiceCRUDConfig(),
386
+ **kwargs, # Config parameters can be given by keyword arguments as well
387
+ ):
388
+ self.basemodeltype = basemodeltype or self.infer_basemodeltype(basemodels)
389
+ if isinstance(config, dict):
390
+ config = NiceCRUDConfig(**config, **kwargs)
391
+ self.config: NiceCRUDConfig = config
392
+ self.config.update(kwargs)
393
+ if id_field is not None:
394
+ self.config.id_field = id_field
395
+ self.basemodels = basemodels
396
+ super().__init__()
397
+ self.rows: list[dict] = []
398
+ self.columns: list[dict] = []
399
+ self.assert_id_field_in_model()
400
+ self.create_rows_and_cols()
401
+ self.item_dialog: ui.dialog
402
+ self.button_row: ui.row
403
+ self.table: ui.table
404
+ self.add_resize_trigger()
405
+ self.get_button_row()
406
+ self.show_table() # type: ignore
407
+
408
+ @classmethod
409
+ def infer_basemodeltype(cls, basemodels: list[T] | dict[str, T]) -> Type[T]:
410
+ x = cls.getfirst(basemodels)
411
+ return type(x)
412
+
413
+ @staticmethod
414
+ def getfirst(basemodels: list[T] | dict[str, T]) -> T:
415
+ if isinstance(basemodels, list):
416
+ return basemodels[0]
417
+ elif isinstance(basemodels, dict):
418
+ return next(iter(basemodels.values()))
419
+ else:
420
+ raise KeyError("No basemodels given")
421
+
422
+ def add_resize_trigger(self):
423
+ """When the width of the browser window is reduced, the event "smaller"
424
+ is emitted, "bigger" otherwise"""
425
+ # TODO: Make the resize trigger in nicecrud more specific so it
426
+ # triggers when the table is not visibale in full.
427
+ # https://www.javascripttutorial.net/dom/css/check-if-an-element-is-visible-in-the-viewport/
428
+ ui.add_head_html("""
429
+ <script>
430
+ let previousWidth = window.innerWidth;
431
+ window.addEventListener('resize', function() {
432
+ var currentWidth = window.innerWidth;
433
+ if (currentWidth < 400 && previousWidth >= 400) {
434
+ console.log("Smaller")
435
+ emitEvent("smaller");
436
+ }
437
+ if (currentWidth > 400 && previousWidth <= 400) {
438
+ console.log("Bigger")
439
+ emitEvent("bigger");
440
+ }
441
+ previousWidth = currentWidth;
442
+ });
443
+ </script>
444
+ """)
445
+
446
+ def assert_id_field_in_model(self):
447
+ if self.config.id_field not in self.basemodeltype.model_fields.keys():
448
+ raise KeyError(f"id field {self.config.id_field} not in basemodel")
449
+ if not self.basemodeltype.model_config.get("validate_assignment"):
450
+ log.info(f"Set validate_assignment for {self.basemodeltype.__name__} for nicecrud validation")
451
+ self.basemodeltype.model_config["validate_assignment"] = True
452
+
453
+ @classmethod
454
+ async def from_http_request(
455
+ cls,
456
+ url: str = "http://localhost:8000/something",
457
+ basemodeltype: type[BaseModel] = BaseModel,
458
+ config: NiceCRUDConfig = NiceCRUDConfig(),
459
+ **kwargs,
460
+ ):
461
+ async with httpx.AsyncClient() as client:
462
+ response = await client.get(url)
463
+ listofdicts = response.json()
464
+ if not isinstance(listofdicts, list):
465
+ ui.notify(f"Invalid response from {url}", color="negative")
466
+ return None
467
+ try:
468
+ basemodels = [basemodeltype(**data) for data in listofdicts]
469
+ except ValidationError as e:
470
+ ui.notify(f"Invalid data from {url}", color="negative")
471
+ log.error(f"ValidationError: {str(e)}")
472
+ return None
473
+ res = cls(basemodeltype=basemodeltype, basemodels=basemodels, config=config, **kwargs)
474
+ return res
475
+
476
+ @property
477
+ def id_label(self):
478
+ if self.config.id_label is not None:
479
+ return self.config.id_label
480
+ if self.config.id_field is None:
481
+ return "ID:"
482
+ id_title = self.basemodeltype.model_fields[self.config.id_field].title
483
+ if id_title is not None:
484
+ return id_title + ":"
485
+ title = self.basemodeltype.model_config.get("title")
486
+ if title is not None:
487
+ return title + " ID:"
488
+ return "ID:"
489
+
490
+ def create_rows_and_cols(self):
491
+ """Create the list of dicts self.rows and self.columns that are used for nicegui table"""
492
+ rows, columns = basemodellist_to_rows_and_cols(self.basemodels)
493
+ columns = [
494
+ c | dict(sortable=True)
495
+ for c in columns
496
+ if c["name"] != self.config.id_field and c["name"] not in self.config.additional_exclude
497
+ ]
498
+ for r in rows:
499
+ r["obj_id"] = self.id_label + " <b>" + html.escape(str(r[self.config.id_field])) + "</b>"
500
+ for k, v in r.items():
501
+ # loop over the fields in each row
502
+ if not v and v != 0:
503
+ r[k] = "No value set"
504
+ self.rows = rows
505
+ self.columns = columns
506
+
507
+ def get_by_id(self, id) -> Optional[T]:
508
+ for m in self.basemodels:
509
+ if getattr(m, self.config.id_field) == id:
510
+ return m
511
+ return None
512
+
513
+ @property
514
+ def defaults_given(self):
515
+ for k, v in self.basemodeltype.model_fields.items():
516
+ if v.exclude:
517
+ continue
518
+ if v.is_required():
519
+ return False
520
+ return True
521
+
522
+ async def handle_create(self):
523
+ """Add button was pressed"""
524
+ try:
525
+ self.get_item_dialog()
526
+ self.item_dialog.open()
527
+ except NotImplementedError as e:
528
+ log.error(str(e))
529
+ ui.notify(str(e), color="negative")
530
+
531
+ async def save_create(self, model: T):
532
+ """Save button in item_dialog was pressed"""
533
+ try:
534
+ await self.create(model)
535
+ model_id = getattr(model, self.config.id_field)
536
+ except KeyError as e:
537
+ log.error(f"An error occurred while adding the model: {model}")
538
+ ui.notify("Error adding model: " + str(e), color="negative")
539
+ else:
540
+ log.debug(f"Added {model_id=}")
541
+ ui.notify(f"Added {self.basemodeltype.model_config.get("title")} with new ID: {model_id}")
542
+ finally:
543
+ self.create_rows_and_cols()
544
+ self.item_dialog.close()
545
+ self.show_table.refresh()
546
+
547
+ async def handle_update(self, e: events.GenericEventArguments) -> None:
548
+ """Edit icon was pressed
549
+
550
+ Args:
551
+ e: Event argument with the row information passed from javascript
552
+ """
553
+ obj_id: str = e.args[self.config.id_field]
554
+ log.debug(f"edit row with {obj_id=}")
555
+ model = self.get_by_id(obj_id)
556
+ if model is None:
557
+ log.error(f"Error, could not find {obj_id=}")
558
+ return
559
+ try:
560
+ self.get_item_dialog(model)
561
+ self.item_dialog.open()
562
+ except NotImplementedError as er:
563
+ ui.notify(f"The {self.basemodeltype.__name__} objects have not template", color="negative")
564
+ log.error(str(er))
565
+
566
+ async def save_update(self, model: T):
567
+ """Save button in item_dialog was pressed"""
568
+ try:
569
+ await self.update(model)
570
+ except KeyError as e:
571
+ log.error(f"An error occurred while updating the model: {model}: {str(e)}")
572
+ ui.notify(f"Error deleting: {str(e)}", color="negative")
573
+ else:
574
+ obj_id: str = getattr(model, self.config.id_field)
575
+ title = self.basemodeltype.model_config.get("title") or self.config.id_label
576
+ log.debug(f"Updated {obj_id=}")
577
+ ui.notify(f"Updated {title} {obj_id}")
578
+ finally:
579
+ self.create_rows_and_cols()
580
+ self.item_dialog.close()
581
+ self.show_table.refresh()
582
+
583
+ async def handle_delete(self, e: events.GenericEventArguments) -> None:
584
+ """Delete icon was pressed
585
+
586
+ Args:
587
+ e: Event argument with the row information passed from javascript
588
+ """
589
+ obj_id: str = e.args[self.config.id_field]
590
+ log.debug(f"delete row with {obj_id=}")
591
+ try:
592
+ await self.delete(obj_id)
593
+ except KeyError as er:
594
+ log.error(f"Deletion operation failed for object with id: {obj_id} {str(er)}")
595
+ ui.notify(f"Error deleting: {str(er)}", color="negative")
596
+ else:
597
+ title = self.basemodeltype.model_config.get("title")
598
+ ui.notify(f"Deleted {title} {obj_id}")
599
+ finally:
600
+ self.create_rows_and_cols()
601
+ self.show_table.refresh()
602
+
603
+ async def handle_delete_selected(self) -> None:
604
+ """Delete selected icon was pressed"""
605
+ ndel = len(self.table.selected)
606
+ error = False
607
+ for x in self.table.selected:
608
+ obj_id = x.get(self.config.id_field)
609
+ if obj_id is None:
610
+ continue
611
+ try:
612
+ await self.delete(obj_id)
613
+ except KeyError as e:
614
+ log.error(f"delete row with {obj_id=} failed")
615
+ ui.notify(f"Error deleting: {str(e)}", color="negative")
616
+ error = True
617
+ break
618
+ else:
619
+ log.debug(f"delete row with {obj_id=}")
620
+ if not error:
621
+ ui.notify(f"{ndel} deleted")
622
+ self.create_rows_and_cols()
623
+ self.show_table.refresh()
624
+
625
+ async def create(self, model: T):
626
+ """Add an item: Extend or this method and include database commands"""
627
+ if getattr(model, self.config.id_field) in [getattr(m, self.config.id_field) for m in self.basemodels]:
628
+ raise KeyError(
629
+ f"{self.basemodeltype.model_config.get('title', self.basemodeltype.__name__)}"
630
+ f"({self.config.id_label}={getattr(model, self.config.id_field)}) already exists"
631
+ )
632
+ self.basemodels.append(model)
633
+
634
+ async def update(self, model: T):
635
+ """Update an item: Extend or overwrite this method and include database commands"""
636
+ exists = False
637
+ for m in self.basemodels:
638
+ if getattr(m, self.config.id_field) == getattr(model, self.config.id_field):
639
+ if exists:
640
+ raise KeyError(
641
+ f"{self.basemodeltype.model_config.get('title', self.basemodeltype.__name__)}"
642
+ f"({self.config.id_label}={getattr(model, self.config.id_field)}) has duplicates"
643
+ )
644
+ exists = True
645
+ for field, value in model.model_dump().items():
646
+ setattr(m, field, value)
647
+ if not exists:
648
+ raise KeyError(
649
+ f"{self.basemodeltype.model_config.get('title', self.basemodeltype.__name__)}"
650
+ f"({self.config.id_label}={getattr(model, self.config.id_field)}) does not exist"
651
+ )
652
+
653
+ async def delete(self, obj_id):
654
+ """Delete item: Extend or overwrite this method and include database commands"""
655
+ exists = False
656
+ for b in self.basemodels:
657
+ if getattr(b, self.config.id_field) == obj_id:
658
+ exists = True
659
+ self.basemodels.remove(b)
660
+ break
661
+ if not exists:
662
+ raise KeyError(
663
+ f"{self.basemodeltype.model_config.get('title', self.basemodeltype.__name__)}"
664
+ f"({self.config.id_label}={obj_id}) does not exist"
665
+ )
666
+
667
+ async def select_options(self, field_name: str, obj: T) -> dict:
668
+ """Get the select options for a field: Extend / Overwrite this method and include database commands
669
+
670
+ By default, this just gives all the different occurrences in the list
671
+ """
672
+ log.debug(f"Getting default select_options for {field_name=}")
673
+ options = dict()
674
+ if not self.field_exists(field_name):
675
+ log.error(f"Trying to get select options for {field_name=}, non-exist on {self.basemodeltype}")
676
+ return dict()
677
+ for m in self.basemodels:
678
+ options[getattr(m, field_name)] = getattr(m, field_name)
679
+ return options
680
+
681
+ def on_change_extra(self, field_name: str, obj: T) -> None:
682
+ """Extra callback that is triggered when field_name str was changed in
683
+ the input Overwrite this method and include some changes to the
684
+ object"""
685
+ return
686
+
687
+ @ui.refreshable
688
+ def show_table(self):
689
+ """Show the grid of elements"""
690
+ with ui.card().classes("w-full sm:w-full"):
691
+ if self.config.heading:
692
+ ui.label(self.config.heading).classes(self.config.heading_classes)
693
+ search_input = ui.input(
694
+ label=self.config.search_input_label
695
+ or ("Search " + (self.basemodeltype.model_config.get("title") or "table"))
696
+ ).classes("card-content w-full")
697
+ self.table = (
698
+ ui.table(columns=self.columns, rows=self.rows, row_key="obj_id", selection="multiple")
699
+ .props("grid")
700
+ .props(f"no-data-label='{self.config.no_data_label}'")
701
+ # .props("card-container-class='bg-slate-700'")
702
+ .classes("w-full")
703
+ ).bind_filter_from(search_input, "value")
704
+
705
+ self.table.add_slot(
706
+ "item",
707
+ r"""
708
+ <q-card bordered flat :class="props.selected ? 'dark:bg-background bg-slate-200' : 'dark:bg-card bg-slate-100'"
709
+ class="sm:w-[calc(50%-20px)] w-full m-2 relative">
710
+ <div class="absolute top-0 right-0 z-10">
711
+ <q-btn class="mr-2 mt-2 z-10" size="sm" color="primary" round dense icon="delete"
712
+ @click="() => $parent.$emit('delete', props.row)"
713
+ />
714
+ <q-btn class="mr-2 mt-2 z-10" size="sm" color="primary" round dense icon="edit"
715
+ @click="() => $parent.$emit('edit', props.row)"
716
+ />
717
+ </div>
718
+ <q-card-section class="z-1 bg-white">
719
+ <q-checkbox dense v-model="props.selected" >
720
+ <span v-html="props.row.obj_id"></span>
721
+ </q-checkbox>
722
+ </q-card-section>
723
+ <q-card-section>
724
+ <div class="flex flex-row p-0 m-1 w-full gap-y-1">
725
+ <div class="p-2 border-l-2" v-for="col in props.cols.filter(col => col.name !== 'obj_id')" :key="col.obj_id" >
726
+ <q-item-label caption>{{ col.label }}</q-item-label>
727
+ <q-item-label >{{ col.value }}</q-item-label>
728
+ </div>
729
+ </div>
730
+ </q-card-section>
731
+ </q-card>
732
+ """,
733
+ )
734
+
735
+ self.table.on("delete", self.handle_delete)
736
+ self.table.on("edit", self.handle_update)
737
+ if False:
738
+ ui.on("smaller", lambda: self.table.props("grid"))
739
+ ui.on("bigger", lambda: self.table.props(remove="grid"))
740
+
741
+ def get_button_row(self):
742
+ with ui.row() as self.button_row:
743
+ ui.button(text=self.config.add_button_text, icon="add", on_click=self.handle_create)
744
+ ui.button(text=self.config.delete_button_text, icon="delete", on_click=self.handle_delete_selected)
745
+
746
+ @property
747
+ def new_item_dialog_heading(self):
748
+ if self.config.new_item_dialog_heading is not None:
749
+ return self.config.new_item_dialog_heading
750
+ title = self.basemodeltype.model_config.get("title")
751
+ if title is not None:
752
+ return "Add " + title
753
+ else:
754
+ return "Add item"
755
+
756
+ @property
757
+ def update_item_dialog_heading(self):
758
+ if self.config.update_item_dialog_heading is not None:
759
+ return self.config.update_item_dialog_heading
760
+ title = self.basemodeltype.model_config.get("title")
761
+ if title is not None:
762
+ return "Update " + title
763
+ else:
764
+ return "Update item"
765
+
766
+ def get_item_dialog(self, item: Optional[T] = None):
767
+ if self.column_count > 1:
768
+ props = "full-width"
769
+ else:
770
+ props = ""
771
+ with ui.dialog().props(props) as self.item_dialog, ui.card().classes("w-full"):
772
+ if item is None:
773
+ edit = False
774
+ ui.label(self.new_item_dialog_heading).classes(self.config.subheading_classes)
775
+ if self.defaults_given:
776
+ item = self.basemodeltype()
777
+ elif len(self.basemodels) > 0:
778
+ log.debug(
779
+ f"model {self.basemodeltype} does not contain defaults for"
780
+ "all attributes, choose first element as reference"
781
+ )
782
+ item = self.basemodels[0].model_copy(deep=True)
783
+ if isinstance(getattr(item, self.config.id_field), str):
784
+ setattr(
785
+ item,
786
+ self.config.id_field,
787
+ "New " + (self.basemodeltype.model_config.get("title", "item") or "item"),
788
+ )
789
+ else:
790
+ raise NotImplementedError(f"No template for {self.basemodeltype.__name__}")
791
+
792
+ async def save_action():
793
+ if item is None:
794
+ raise TypeError(f"Item {item} is non-existent")
795
+ await self.save_create(item)
796
+ else:
797
+ edit = True
798
+ ui.label(self.update_item_dialog_heading + " " + str(getattr(item, self.config.id_field))).classes(
799
+ self.config.subheading_classes
800
+ )
801
+
802
+ async def save_action():
803
+ await self.save_update(item)
804
+
805
+ # Card with all input elements
806
+ val_result = dict(val_result=True)
807
+ with ui.row().classes("w-full"):
808
+ NiceCRUDCard(
809
+ item,
810
+ self.select_options,
811
+ config=self.config,
812
+ id_editable=not edit,
813
+ on_change_extra=self.on_change_extra,
814
+ on_validation_result=lambda x: val_result.update(dict(val_result=x)),
815
+ )
816
+
817
+ # Save and Cancel buttons
818
+ with ui.row().classes("w-full justify-end"):
819
+ with ui.grid(columns=2).classes("inline-grid"):
820
+ ui.button(
821
+ "Cancel",
822
+ icon="cancel",
823
+ on_click=self.item_dialog.close,
824
+ color=None,
825
+ ).classes("w-full bg-disabled")
826
+ ui.button(
827
+ "Save",
828
+ icon="check_circle",
829
+ on_click=save_action,
830
+ ).classes("w-full").bind_enabled_from(val_result, "val_result")
@@ -0,0 +1,19 @@
1
+ from nicegui import ui
2
+
3
+
4
+ def show_error(message: str = "Somethin went horribly wrong"):
5
+ with ui.row().classes("align-center text-red-500 bg-yellow-100 items-center gap-2 flex-nowrap") as row_element:
6
+ ui.icon("error", size="lg").classes("p-2 border-l-8 border-yellow-500 ")
7
+ label_element = ui.label(message).classes("pl-0 pr-3 font-bold")
8
+ return label_element, row_element
9
+
10
+
11
+ def show_warn(message: str = "Something went not quite as horribly wrong, but still not good"):
12
+ with ui.row().classes("gap-3 text-orange-500 bg-white border-l-4 border-orange-500 p-4"):
13
+ ui.icon("warning")
14
+ ui.label(message)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ show_error()
19
+ ui.run()
@@ -0,0 +1,50 @@
1
+ [tool.poetry]
2
+ name = "niceguicrud"
3
+ version = "0.1.0"
4
+ description = "Edit your pydantic models with a nice GUI - CRUD Create Update Replace Delete"
5
+ authors = ["Dronakurl <tbo73oij5@mozmail.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.12"
11
+ nicegui = "^1.4.26"
12
+
13
+ [tool.poetry.group.dev.dependencies]
14
+ pytest = "^8.2.1"
15
+ pytest-asyncio = "^0.23.7"
16
+
17
+ [tool.pytest.ini_options]
18
+ addopts = [
19
+ "--import-mode=importlib",
20
+ "-ra -q",
21
+ "--disable-warnings",
22
+ "--ignore=.archiv",
23
+ "--ignore=scripts",
24
+ ]
25
+ asyncio_mode = "auto"
26
+ pythonpath = ["."]
27
+ testpaths = ["tests"]
28
+
29
+ [tool.ruff]
30
+ line-length = 120
31
+ exclude = ["**/__init__.py"]
32
+
33
+ [tool.pyright]
34
+ include = ["**/*.py"]
35
+ exclude = ["**/__pycache__"]
36
+ ignore = ["**/__init__.py"]
37
+ reportUnusedFunction = false
38
+ reportUnusedVariable = false
39
+ reportUnusedImport = false
40
+ useLibraryCodeForTypes = true
41
+ reportUnnecessaryTypeIgnoreComment = true
42
+ enableTypeIgnoreComments = true
43
+ reportUndefinedVariable = false
44
+
45
+ [tool.black]
46
+ line-length = 120
47
+
48
+ [build-system]
49
+ requires = ["poetry-core"]
50
+ build-backend = "poetry.core.masonry.api"