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.
- niceguicrud-0.1.0/LICENSE +9 -0
- niceguicrud-0.1.0/PKG-INFO +86 -0
- niceguicrud-0.1.0/README.md +71 -0
- niceguicrud-0.1.0/niceguicrud/__init__.py +1 -0
- niceguicrud-0.1.0/niceguicrud/basemodel_to_table.py +55 -0
- niceguicrud-0.1.0/niceguicrud/nicecrud.py +830 -0
- niceguicrud-0.1.0/niceguicrud/show_error.py +19 -0
- niceguicrud-0.1.0/pyproject.toml +50 -0
|
@@ -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
|
+

|
|
33
|
+

|
|
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
|
+

|
|
19
|
+

|
|
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"
|