flask-construct-ui 0.9.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flask_construct_ui/__init__.py +82 -0
- flask_construct_ui/auth.py +290 -0
- flask_construct_ui/base.py +1259 -0
- flask_construct_ui/components.py +812 -0
- flask_construct_ui/crud.py +1641 -0
- flask_construct_ui/csrf.py +39 -0
- flask_construct_ui/data.py +257 -0
- flask_construct_ui/data_sqlalchemy.py +304 -0
- flask_construct_ui/forms.py +130 -0
- flask_construct_ui/headless.py +110 -0
- flask_construct_ui/icons.py +158 -0
- flask_construct_ui/l10n/en/main.flt +82 -0
- flask_construct_ui/l10n/sv/main.flt +82 -0
- flask_construct_ui/language.py +100 -0
- flask_construct_ui/navbar.py +54 -0
- flask_construct_ui/navigation.py +58 -0
- flask_construct_ui/orgmode.py +338 -0
- flask_construct_ui/pagination.py +39 -0
- flask_construct_ui/py.typed +0 -0
- flask_construct_ui/sanitize.py +25 -0
- flask_construct_ui/schema.py +507 -0
- flask_construct_ui/static/css/prosemirror-bundle.min.css +1 -0
- flask_construct_ui/static/css/unpoly.min.css +6 -0
- flask_construct_ui/static/js/construct-core.min.js +1 -0
- flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
- flask_construct_ui/static/js/unpoly.min.js +1 -0
- flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +720 -0
- flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +94 -0
- flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +64 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +21 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +5 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +202 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +26 -0
- flask_construct_ui/templates/structure/base.html +5 -0
- flask_construct_ui/templates/structure/flashes.html +7 -0
- flask_construct_ui/templates/structure/public.html +18 -0
- flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +69 -0
- flask_construct_ui/templates/themes/whitelabel/components/button.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/components/calendar.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/card.html +25 -0
- flask_construct_ui/templates/themes/whitelabel/components/chart.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/form.html +61 -0
- flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/live.html +40 -0
- flask_construct_ui/templates/themes/whitelabel/components/main.html +4 -0
- flask_construct_ui/templates/themes/whitelabel/components/markup.html +1 -0
- flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +19 -0
- flask_construct_ui/templates/themes/whitelabel/components/table.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/tabs.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/text.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +962 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +98 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +136 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +15 -0
- flask_construct_ui/templates/themes/whitelabel/crud/create.html +45 -0
- flask_construct_ui/templates/themes/whitelabel/crud/edit.html +125 -0
- flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +238 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +33 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +125 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +140 -0
- flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +38 -0
- flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +31 -0
- flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/style/base_full.html +181 -0
- flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +16 -0
- flask_construct_ui/validation.py +422 -0
- flask_construct_ui-0.9.5.dist-info/METADATA +60 -0
- flask_construct_ui-0.9.5.dist-info/RECORD +80 -0
- flask_construct_ui-0.9.5.dist-info/WHEEL +5 -0
- flask_construct_ui-0.9.5.dist-info/licenses/LICENSE +165 -0
- flask_construct_ui-0.9.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1641 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from math import ceil
|
|
3
|
+
from flask import (
|
|
4
|
+
current_app,
|
|
5
|
+
flash,
|
|
6
|
+
redirect,
|
|
7
|
+
render_template,
|
|
8
|
+
request,
|
|
9
|
+
url_for,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from . import schema
|
|
13
|
+
from .data import DataBase
|
|
14
|
+
from .forms import FormData, validate_form_data
|
|
15
|
+
from .navigation import resolve_next
|
|
16
|
+
from .pagination import RELATED_SEARCH_LIMIT, get_pagination
|
|
17
|
+
from .base import PageBase, expose
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_many_related_field(field, type_info, form):
|
|
21
|
+
"""Parse a ManyRelated field from submitted form data.
|
|
22
|
+
|
|
23
|
+
Returns ``(pks, staged)`` where ``pks`` is the list of selected
|
|
24
|
+
existing primary keys and ``staged`` is ``{"quick": [values]}`` /
|
|
25
|
+
``{"new": [sub-field dicts]}`` or ``None``. Staged inputs are only
|
|
26
|
+
honored for the creation mode the field opted into, so a stray
|
|
27
|
+
``-quicknew``/``-newrel-`` input can never force record creation on
|
|
28
|
+
a field that didn't enable it.
|
|
29
|
+
"""
|
|
30
|
+
pks = form.getlist(field)
|
|
31
|
+
staged = {}
|
|
32
|
+
if type_info.create == "quick":
|
|
33
|
+
seen = set()
|
|
34
|
+
quick = []
|
|
35
|
+
for value in form.getlist(f"{field}-quicknew"):
|
|
36
|
+
value = value.strip()
|
|
37
|
+
if value and value not in seen:
|
|
38
|
+
seen.add(value)
|
|
39
|
+
quick.append(value)
|
|
40
|
+
if quick:
|
|
41
|
+
staged["quick"] = quick
|
|
42
|
+
elif type_info.create == "form":
|
|
43
|
+
prefix = f"{field}-newrel-"
|
|
44
|
+
indices = set()
|
|
45
|
+
for key in form:
|
|
46
|
+
if key.startswith(prefix):
|
|
47
|
+
try:
|
|
48
|
+
indices.add(int(key[len(prefix) :].split("-", 1)[0]))
|
|
49
|
+
except (ValueError, IndexError):
|
|
50
|
+
continue
|
|
51
|
+
new_items = []
|
|
52
|
+
for i in sorted(indices):
|
|
53
|
+
item_prefix = f"{prefix}{i}-"
|
|
54
|
+
new_item = {}
|
|
55
|
+
has_data = False
|
|
56
|
+
for sub_field, sub_type in type_info.inline_form_fields():
|
|
57
|
+
form_key = item_prefix + sub_field
|
|
58
|
+
if sub_type.field_type == "bool":
|
|
59
|
+
new_item[sub_field] = form_key in form
|
|
60
|
+
if form_key in form:
|
|
61
|
+
has_data = True
|
|
62
|
+
elif sub_type.field_type == "set":
|
|
63
|
+
values = form.getlist(form_key)
|
|
64
|
+
if values:
|
|
65
|
+
new_item[sub_field] = set(values)
|
|
66
|
+
has_data = True
|
|
67
|
+
elif form_key in form and form[form_key]:
|
|
68
|
+
new_item[sub_field] = form[form_key]
|
|
69
|
+
has_data = True
|
|
70
|
+
elif sub_type.default is not None:
|
|
71
|
+
# A default alone never materializes a row (has_data
|
|
72
|
+
# stays False), matching the List new-row parser.
|
|
73
|
+
new_item[sub_field] = sub_type.default
|
|
74
|
+
if has_data:
|
|
75
|
+
new_items.append(new_item)
|
|
76
|
+
if new_items:
|
|
77
|
+
staged["new"] = new_items
|
|
78
|
+
return pks, (staged or None)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RelatedCrudTab:
|
|
82
|
+
"""Advanced related-item tab that embeds a full CRUD experience inside an edit tab.
|
|
83
|
+
|
|
84
|
+
Pass either ``child_dataclass`` (a :class:`DataBase` subclass) or
|
|
85
|
+
``child_crud_class`` (a :class:`CrudPageBase` subclass). When
|
|
86
|
+
``child_crud_class`` is used its ``dataclass`` attribute is extracted
|
|
87
|
+
automatically, and any ``related_crud_tabs`` declared on that class are
|
|
88
|
+
registered as a second nesting level, enabling chains like
|
|
89
|
+
Section → Article → Comment.
|
|
90
|
+
|
|
91
|
+
Simple usage (dataclass)::
|
|
92
|
+
|
|
93
|
+
class ArticleCrudPage(CrudPageBase):
|
|
94
|
+
related_crud_tabs = [
|
|
95
|
+
RelatedCrudTab(
|
|
96
|
+
tab_key="comments",
|
|
97
|
+
child_dataclass=CommentData,
|
|
98
|
+
filter_field="article_id",
|
|
99
|
+
name="demo-comments-name",
|
|
100
|
+
icon="bi-chat-left-text",
|
|
101
|
+
)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
Chained usage (crud class)::
|
|
105
|
+
|
|
106
|
+
class ArticleCrudPage(CrudPageBase):
|
|
107
|
+
related_crud_tabs = [
|
|
108
|
+
RelatedCrudTab(
|
|
109
|
+
tab_key="comments",
|
|
110
|
+
child_dataclass=CommentData,
|
|
111
|
+
filter_field="article_id",
|
|
112
|
+
name="demo-comments-name",
|
|
113
|
+
icon="bi-chat-left-text",
|
|
114
|
+
)
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
class SectionCrudPage(CrudPageBase):
|
|
118
|
+
related_crud_tabs = [
|
|
119
|
+
RelatedCrudTab(
|
|
120
|
+
tab_key="articles",
|
|
121
|
+
child_crud_class=ArticleCrudPage, # pulls dataclass + nested tabs
|
|
122
|
+
filter_field="section_id",
|
|
123
|
+
name="demo-articles-name",
|
|
124
|
+
icon="bi-newspaper",
|
|
125
|
+
)
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
``filter_field`` must be present in the child dataclass schema
|
|
129
|
+
(enforced at construction time). Use ``visible_form=False`` to hide it
|
|
130
|
+
from the child edit form.
|
|
131
|
+
|
|
132
|
+
With Unpoly (JS enabled), navigation within the tab only swaps the tab
|
|
133
|
+
content pane — the rest of the page is untouched. Without JS, standard
|
|
134
|
+
full-page navigation is used as a graceful fallback.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
tab_key,
|
|
140
|
+
filter_field,
|
|
141
|
+
name,
|
|
142
|
+
icon="list",
|
|
143
|
+
child_dataclass=None,
|
|
144
|
+
child_crud_class=None,
|
|
145
|
+
create_url=None,
|
|
146
|
+
):
|
|
147
|
+
if child_crud_class is not None:
|
|
148
|
+
self.child_dataclass = child_crud_class.dataclass
|
|
149
|
+
self.child_crud_class = child_crud_class
|
|
150
|
+
elif child_dataclass is not None:
|
|
151
|
+
self.child_dataclass = child_dataclass
|
|
152
|
+
self.child_crud_class = None
|
|
153
|
+
else:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"Either child_dataclass or child_crud_class must be provided"
|
|
156
|
+
)
|
|
157
|
+
if filter_field not in self.child_dataclass._schema:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"filter_field '{filter_field}' is not declared in the schema "
|
|
160
|
+
f"of {self.child_dataclass.__name__}. Declare it (e.g. "
|
|
161
|
+
f"schema.Integer(..., visible_form=False, visible_list=False)) "
|
|
162
|
+
f"to use it for related-crud filtering."
|
|
163
|
+
)
|
|
164
|
+
self.tab_key = tab_key
|
|
165
|
+
self.filter_field = filter_field
|
|
166
|
+
self.name = name
|
|
167
|
+
self.icon = icon
|
|
168
|
+
self.create_url = create_url
|
|
169
|
+
|
|
170
|
+
def get_list_fields(
|
|
171
|
+
self, ds: DataBase, construct
|
|
172
|
+
) -> list[tuple[str, schema.SchemaField]]:
|
|
173
|
+
if self.child_crud_class:
|
|
174
|
+
child_view = self.child_crud_class()
|
|
175
|
+
child_view.construct = construct
|
|
176
|
+
return child_view.get_list_fields(ds)
|
|
177
|
+
return [
|
|
178
|
+
(k, v)
|
|
179
|
+
for k, v in ds._schema.items()
|
|
180
|
+
if not isinstance(v, (schema.PK, schema.List)) and v.visible_list
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
def get_form_fields(
|
|
184
|
+
self, ds: DataBase, construct, data=None
|
|
185
|
+
) -> list[tuple[str, schema.SchemaField]]:
|
|
186
|
+
if self.child_crud_class:
|
|
187
|
+
child_view = self.child_crud_class()
|
|
188
|
+
child_view.construct = construct
|
|
189
|
+
return child_view.get_form_fields(ds, data)
|
|
190
|
+
return [
|
|
191
|
+
(k, v)
|
|
192
|
+
for k, v in ds._schema.items()
|
|
193
|
+
if not isinstance(v, schema.PK) and v.visible_form
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def _child_nested_tabs(self):
|
|
198
|
+
if self.child_crud_class:
|
|
199
|
+
return list(self.child_crud_class.related_crud_tabs)
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def list_endpoint(self):
|
|
204
|
+
return f"rcrud_{self.tab_key}"
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def create_endpoint(self):
|
|
208
|
+
return f"rcrud_{self.tab_key}_create"
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def edit_endpoint(self):
|
|
212
|
+
return f"rcrud_{self.tab_key}_edit"
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def delete_endpoint(self):
|
|
216
|
+
return f"rcrud_{self.tab_key}_delete"
|
|
217
|
+
|
|
218
|
+
def get_tab_action(self):
|
|
219
|
+
return {
|
|
220
|
+
"endpoint": self.list_endpoint,
|
|
221
|
+
"name": self.name,
|
|
222
|
+
"icon": self.icon,
|
|
223
|
+
"url": f"/rcrud/{self.tab_key}/",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def _process_form_data(self, ds, parent_id, construct, rec=None):
|
|
227
|
+
raw_data = request.form.to_dict()
|
|
228
|
+
form_fields = {k: v for k, v in self.get_form_fields(ds, construct, rec)}
|
|
229
|
+
data = {}
|
|
230
|
+
# Only take fields that are in the form_fields and are not readonly/hidden
|
|
231
|
+
for field, type_info in form_fields.items():
|
|
232
|
+
if type_info.readonly or not type_info.visible_form:
|
|
233
|
+
continue
|
|
234
|
+
if field in raw_data:
|
|
235
|
+
data[field] = raw_data[field]
|
|
236
|
+
data[self.filter_field] = parent_id
|
|
237
|
+
|
|
238
|
+
for field, type_info in ds._schema.items():
|
|
239
|
+
if (
|
|
240
|
+
field not in form_fields
|
|
241
|
+
or type_info.readonly
|
|
242
|
+
or not type_info.visible_form
|
|
243
|
+
):
|
|
244
|
+
continue
|
|
245
|
+
if type_info.secret:
|
|
246
|
+
if f"{field}-change" not in request.form:
|
|
247
|
+
data.pop(field, None)
|
|
248
|
+
continue
|
|
249
|
+
if hasattr(type_info, "field_type"):
|
|
250
|
+
if type_info.field_type == "bool":
|
|
251
|
+
data[field] = field in request.form
|
|
252
|
+
elif type_info.field_type == "set":
|
|
253
|
+
data[field] = set(request.form.getlist(field))
|
|
254
|
+
elif isinstance(type_info, schema.ManyRelated):
|
|
255
|
+
pks, staged = _parse_many_related_field(
|
|
256
|
+
field, type_info, request.form
|
|
257
|
+
)
|
|
258
|
+
data[field] = pks
|
|
259
|
+
if staged:
|
|
260
|
+
data.setdefault("many_related_new", {})[field] = staged
|
|
261
|
+
elif isinstance(type_info, schema.List):
|
|
262
|
+
if type_info.readonly:
|
|
263
|
+
continue
|
|
264
|
+
if "list_items" not in data:
|
|
265
|
+
data["list_items"] = {}
|
|
266
|
+
if field not in data["list_items"]:
|
|
267
|
+
data["list_items"][field] = {}
|
|
268
|
+
|
|
269
|
+
delete_ids = request.form.getlist(f"{field}-delete")
|
|
270
|
+
if delete_ids:
|
|
271
|
+
data["list_items"][field]["delete"] = delete_ids
|
|
272
|
+
|
|
273
|
+
update_items = []
|
|
274
|
+
item_ids = request.form.getlist(f"{field}-id")
|
|
275
|
+
list_cls_schema = type_info.list_class._schema
|
|
276
|
+
for item_id in item_ids:
|
|
277
|
+
if item_id in delete_ids:
|
|
278
|
+
continue
|
|
279
|
+
item_data = {"id": item_id}
|
|
280
|
+
prefix = f"{field}-{item_id}-"
|
|
281
|
+
for sub_field, sub_type in list_cls_schema.items():
|
|
282
|
+
if (
|
|
283
|
+
sub_type.field_type == "pk"
|
|
284
|
+
or sub_type.readonly
|
|
285
|
+
or not sub_type.visible_form
|
|
286
|
+
):
|
|
287
|
+
continue
|
|
288
|
+
form_key = prefix + sub_field
|
|
289
|
+
if sub_type.field_type == "bool":
|
|
290
|
+
item_data[sub_field] = form_key in request.form
|
|
291
|
+
elif sub_type.field_type == "set":
|
|
292
|
+
item_data[sub_field] = set(
|
|
293
|
+
request.form.getlist(form_key)
|
|
294
|
+
)
|
|
295
|
+
elif form_key in request.form:
|
|
296
|
+
item_data[sub_field] = request.form[form_key]
|
|
297
|
+
update_items.append(item_data)
|
|
298
|
+
if update_items:
|
|
299
|
+
data["list_items"][field]["update"] = update_items
|
|
300
|
+
|
|
301
|
+
indices = set()
|
|
302
|
+
new_prefix = f"{field}-new-"
|
|
303
|
+
for key in request.form:
|
|
304
|
+
if key.startswith(new_prefix):
|
|
305
|
+
try:
|
|
306
|
+
indices.add(
|
|
307
|
+
int(key[len(new_prefix) :].split("-", 1)[0])
|
|
308
|
+
)
|
|
309
|
+
except (ValueError, IndexError):
|
|
310
|
+
continue
|
|
311
|
+
new_items = []
|
|
312
|
+
for i in sorted(indices):
|
|
313
|
+
item_prefix = f"{new_prefix}{i}-"
|
|
314
|
+
new_item = {}
|
|
315
|
+
has_data = False
|
|
316
|
+
for sub_field, sub_type in list_cls_schema.items():
|
|
317
|
+
if (
|
|
318
|
+
sub_type.field_type == "pk"
|
|
319
|
+
or sub_type.readonly
|
|
320
|
+
or not sub_type.visible_form
|
|
321
|
+
):
|
|
322
|
+
continue
|
|
323
|
+
form_key = item_prefix + sub_field
|
|
324
|
+
if sub_type.field_type == "bool":
|
|
325
|
+
new_item[sub_field] = form_key in request.form
|
|
326
|
+
if form_key in request.form:
|
|
327
|
+
has_data = True
|
|
328
|
+
elif sub_type.field_type == "set":
|
|
329
|
+
values = request.form.getlist(form_key)
|
|
330
|
+
if values:
|
|
331
|
+
new_item[sub_field] = set(values)
|
|
332
|
+
has_data = True
|
|
333
|
+
elif form_key in request.form and request.form[form_key]:
|
|
334
|
+
new_item[sub_field] = request.form[form_key]
|
|
335
|
+
has_data = True
|
|
336
|
+
if has_data:
|
|
337
|
+
new_items.append(new_item)
|
|
338
|
+
if new_items:
|
|
339
|
+
data["list_items"][field]["new"] = new_items
|
|
340
|
+
return data
|
|
341
|
+
|
|
342
|
+
def _register(self, parent_view, blueprint, construct):
|
|
343
|
+
tab_key = self.tab_key
|
|
344
|
+
filter_field = self.filter_field
|
|
345
|
+
child_dataclass = self.child_dataclass
|
|
346
|
+
theme = construct.theme
|
|
347
|
+
content_id = f"rcrud-content-{tab_key}"
|
|
348
|
+
content_target = f"#{content_id}"
|
|
349
|
+
list_ep = self.list_endpoint
|
|
350
|
+
create_ep = self.create_endpoint
|
|
351
|
+
edit_ep = self.edit_endpoint
|
|
352
|
+
delete_ep = self.delete_endpoint
|
|
353
|
+
rct = self
|
|
354
|
+
child_nested_tabs = self._child_nested_tabs
|
|
355
|
+
|
|
356
|
+
def _ctx(parent_id):
|
|
357
|
+
return dict(
|
|
358
|
+
construct=construct,
|
|
359
|
+
view=parent_view,
|
|
360
|
+
tab_actions=parent_view.tab_actions,
|
|
361
|
+
active_tab=list_ep,
|
|
362
|
+
id=parent_id,
|
|
363
|
+
tab_content_id=content_id,
|
|
364
|
+
content_target=content_target,
|
|
365
|
+
tab_key=tab_key,
|
|
366
|
+
list_ep=list_ep,
|
|
367
|
+
create_ep=create_ep,
|
|
368
|
+
edit_ep=edit_ep,
|
|
369
|
+
delete_ep=delete_ep,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _child_ctx(parent_id, child_id):
|
|
373
|
+
nested_content_id = f"rcrud-child-content-{tab_key}-{child_id}"
|
|
374
|
+
tab_action_list = [
|
|
375
|
+
{
|
|
376
|
+
"endpoint": f"rcrud_{tab_key}_{t.tab_key}",
|
|
377
|
+
"name": t.name,
|
|
378
|
+
"icon": t.icon,
|
|
379
|
+
}
|
|
380
|
+
for t in child_nested_tabs
|
|
381
|
+
]
|
|
382
|
+
if rct.child_crud_class:
|
|
383
|
+
for _ta in rct.child_crud_class.tab_actions:
|
|
384
|
+
tab_action_list.append(
|
|
385
|
+
{
|
|
386
|
+
"endpoint": f"rcrud_{tab_key}_{_ta['endpoint']}",
|
|
387
|
+
"name": _ta["name"],
|
|
388
|
+
"icon": _ta["icon"],
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
return dict(
|
|
392
|
+
**_ctx(parent_id),
|
|
393
|
+
child_id=child_id,
|
|
394
|
+
nested_content_id=nested_content_id,
|
|
395
|
+
nested_content_target=f"#{nested_content_id}",
|
|
396
|
+
child_tab_actions=tab_action_list,
|
|
397
|
+
child_entity_name_key=(
|
|
398
|
+
rct.child_crud_class.name if rct.child_crud_class else rct.name
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def list_view(id):
|
|
403
|
+
ds = child_dataclass()
|
|
404
|
+
page, per_page, limit = get_pagination(
|
|
405
|
+
getattr(rct.child_crud_class, "page_size", None)
|
|
406
|
+
if rct.child_crud_class
|
|
407
|
+
else None
|
|
408
|
+
)
|
|
409
|
+
data, total = ds.filter({filter_field: str(id)}, limit=limit)
|
|
410
|
+
pagination = None
|
|
411
|
+
if total > per_page:
|
|
412
|
+
pages = ceil(total / per_page)
|
|
413
|
+
pw = 2
|
|
414
|
+
pagination = {
|
|
415
|
+
"page": page,
|
|
416
|
+
"per_page": per_page,
|
|
417
|
+
"total": total,
|
|
418
|
+
"pages": int(pages),
|
|
419
|
+
"start_page": max(1, page - pw),
|
|
420
|
+
"end_page": min(int(pages), page + pw),
|
|
421
|
+
}
|
|
422
|
+
fields = [
|
|
423
|
+
(k, v)
|
|
424
|
+
for k, v in rct.get_list_fields(ds, construct)
|
|
425
|
+
if k != filter_field
|
|
426
|
+
]
|
|
427
|
+
if rct.create_url:
|
|
428
|
+
_list_base = url_for(f"{parent_view.endpoint}.{list_ep}", id=id).rstrip(
|
|
429
|
+
"/"
|
|
430
|
+
)
|
|
431
|
+
create_href = f"{_list_base}/{rct.create_url.lstrip('/')}"
|
|
432
|
+
else:
|
|
433
|
+
create_href = url_for(f"{parent_view.endpoint}.{create_ep}", id=id)
|
|
434
|
+
rcrud_list_actions = [
|
|
435
|
+
{
|
|
436
|
+
"endpoint": f"rcrud_{tab_key}_{a['endpoint']}",
|
|
437
|
+
"name": a["name"],
|
|
438
|
+
"icon": a["icon"],
|
|
439
|
+
}
|
|
440
|
+
for a in (
|
|
441
|
+
rct.child_crud_class.list_actions if rct.child_crud_class else []
|
|
442
|
+
)
|
|
443
|
+
]
|
|
444
|
+
return render_template(
|
|
445
|
+
f"themes/{theme}/crud/related_crud_tab_list.html",
|
|
446
|
+
**_ctx(id),
|
|
447
|
+
parent_id=id,
|
|
448
|
+
data=data,
|
|
449
|
+
fields=fields,
|
|
450
|
+
primary_key=ds._primary_key,
|
|
451
|
+
pagination=pagination,
|
|
452
|
+
create_href=create_href,
|
|
453
|
+
list_actions=rcrud_list_actions,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
list_view.__name__ = list_ep
|
|
457
|
+
blueprint.add_url_rule(
|
|
458
|
+
f"/<int:id>/rcrud/{tab_key}/", list_ep, list_view, methods=["GET"]
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def create_view(id):
|
|
462
|
+
ds = child_dataclass()
|
|
463
|
+
errors = None
|
|
464
|
+
form_data = None
|
|
465
|
+
status = 200
|
|
466
|
+
if request.method == "POST":
|
|
467
|
+
data = rct._process_form_data(ds, id, construct)
|
|
468
|
+
errors = validate_form_data(
|
|
469
|
+
ds,
|
|
470
|
+
rct.get_form_fields(ds, construct),
|
|
471
|
+
data,
|
|
472
|
+
row=FormData(data),
|
|
473
|
+
skip=(filter_field,),
|
|
474
|
+
)
|
|
475
|
+
if not errors:
|
|
476
|
+
rec = None
|
|
477
|
+
try:
|
|
478
|
+
rec = ds.create(data)
|
|
479
|
+
flash("crud-item-created", "success")
|
|
480
|
+
except Exception:
|
|
481
|
+
if current_app.debug:
|
|
482
|
+
raise
|
|
483
|
+
flash("crud-error-creating-item", "danger")
|
|
484
|
+
record_url = None
|
|
485
|
+
if rec is not None:
|
|
486
|
+
record_url = url_for(
|
|
487
|
+
f"{parent_view.endpoint}.{edit_ep}",
|
|
488
|
+
id=id,
|
|
489
|
+
child_id=getattr(rec, ds._primary_key),
|
|
490
|
+
)
|
|
491
|
+
return redirect(
|
|
492
|
+
resolve_next(
|
|
493
|
+
url_for(f"{parent_view.endpoint}.{list_ep}", id=id),
|
|
494
|
+
record_url,
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
form_data = FormData(data)
|
|
498
|
+
status = 400
|
|
499
|
+
child_fields = [
|
|
500
|
+
(k, v)
|
|
501
|
+
for k, v in rct.get_form_fields(ds, construct)
|
|
502
|
+
if k != filter_field
|
|
503
|
+
]
|
|
504
|
+
child_url = (
|
|
505
|
+
url_for(f"{parent_view.endpoint}.{list_ep}", id=id).rstrip("/")
|
|
506
|
+
if rct.child_crud_class
|
|
507
|
+
else None
|
|
508
|
+
)
|
|
509
|
+
return render_template(
|
|
510
|
+
f"themes/{theme}/crud/related_crud_tab_form.html",
|
|
511
|
+
**_ctx(id),
|
|
512
|
+
parent_id=id,
|
|
513
|
+
form_data=form_data,
|
|
514
|
+
child_fields=child_fields,
|
|
515
|
+
ds=ds,
|
|
516
|
+
child_primary_key=ds._primary_key,
|
|
517
|
+
is_edit=False,
|
|
518
|
+
child_id=None,
|
|
519
|
+
child_url=child_url,
|
|
520
|
+
edit_actions=[],
|
|
521
|
+
errors=errors,
|
|
522
|
+
), status
|
|
523
|
+
|
|
524
|
+
create_view.__name__ = create_ep
|
|
525
|
+
blueprint.add_url_rule(
|
|
526
|
+
f"/<int:id>/rcrud/{tab_key}/create",
|
|
527
|
+
create_ep,
|
|
528
|
+
create_view,
|
|
529
|
+
methods=["GET", "POST"],
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def edit_view(id, child_id):
|
|
533
|
+
ds = child_dataclass()
|
|
534
|
+
rec = ds.read(child_id)
|
|
535
|
+
if rec is None:
|
|
536
|
+
flash("crud-error-not-found", "danger")
|
|
537
|
+
return redirect(url_for(f"{parent_view.endpoint}.{list_ep}", id=id))
|
|
538
|
+
errors = None
|
|
539
|
+
form_data = rec
|
|
540
|
+
status = 200
|
|
541
|
+
if request.method == "POST":
|
|
542
|
+
data = rct._process_form_data(ds, id, construct, rec)
|
|
543
|
+
errors = validate_form_data(
|
|
544
|
+
ds,
|
|
545
|
+
rct.get_form_fields(ds, construct, rec),
|
|
546
|
+
data,
|
|
547
|
+
row=FormData(data, rec),
|
|
548
|
+
partial=True,
|
|
549
|
+
skip=(filter_field,),
|
|
550
|
+
)
|
|
551
|
+
if not errors:
|
|
552
|
+
try:
|
|
553
|
+
ds.update(child_id, data)
|
|
554
|
+
flash("crud-item-updated", "success")
|
|
555
|
+
except Exception:
|
|
556
|
+
if current_app.debug:
|
|
557
|
+
raise
|
|
558
|
+
flash("crud-error-updating-item", "danger")
|
|
559
|
+
record_url = url_for(
|
|
560
|
+
f"{parent_view.endpoint}.{edit_ep}", id=id, child_id=child_id
|
|
561
|
+
)
|
|
562
|
+
if child_nested_tabs:
|
|
563
|
+
default = record_url
|
|
564
|
+
else:
|
|
565
|
+
default = url_for(f"{parent_view.endpoint}.{list_ep}", id=id)
|
|
566
|
+
return redirect(resolve_next(default, record_url))
|
|
567
|
+
form_data = FormData(data, rec)
|
|
568
|
+
status = 400
|
|
569
|
+
child_fields = [
|
|
570
|
+
(k, v)
|
|
571
|
+
for k, v in rct.get_form_fields(ds, construct, rec)
|
|
572
|
+
if k != filter_field
|
|
573
|
+
]
|
|
574
|
+
child_url = (
|
|
575
|
+
url_for(f"{parent_view.endpoint}.{list_ep}", id=id).rstrip("/")
|
|
576
|
+
if rct.child_crud_class
|
|
577
|
+
else None
|
|
578
|
+
)
|
|
579
|
+
rcrud_edit_actions = []
|
|
580
|
+
if rct.child_crud_class:
|
|
581
|
+
for _ea in rct.child_crud_class.edit_actions:
|
|
582
|
+
_ep = f"rcrud_{tab_key}_{_ea['endpoint']}"
|
|
583
|
+
rcrud_edit_actions.append(
|
|
584
|
+
{
|
|
585
|
+
"href": url_for(
|
|
586
|
+
f"{parent_view.endpoint}.{_ep}",
|
|
587
|
+
id=id,
|
|
588
|
+
child_id=child_id,
|
|
589
|
+
),
|
|
590
|
+
"name": _ea["name"],
|
|
591
|
+
"icon": _ea["icon"],
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
child_has_inner_tabs = child_nested_tabs or (
|
|
595
|
+
rct.child_crud_class and rct.child_crud_class.tab_actions
|
|
596
|
+
)
|
|
597
|
+
if child_has_inner_tabs:
|
|
598
|
+
ctx = _child_ctx(id, child_id)
|
|
599
|
+
form_action = url_for(
|
|
600
|
+
f"{parent_view.endpoint}.{edit_ep}", id=id, child_id=child_id
|
|
601
|
+
)
|
|
602
|
+
cancel_url = url_for(f"{parent_view.endpoint}.{list_ep}", id=id)
|
|
603
|
+
nested_content = render_template(
|
|
604
|
+
f"themes/{theme}/crud/_related_child_form.html",
|
|
605
|
+
construct=construct,
|
|
606
|
+
view=parent_view,
|
|
607
|
+
ds=ds,
|
|
608
|
+
form_data=form_data,
|
|
609
|
+
child_fields=child_fields,
|
|
610
|
+
is_edit=True,
|
|
611
|
+
form_action=form_action,
|
|
612
|
+
cancel_url=cancel_url,
|
|
613
|
+
nested_content_target=ctx["nested_content_target"],
|
|
614
|
+
child_url=child_url,
|
|
615
|
+
edit_actions=rcrud_edit_actions,
|
|
616
|
+
errors=errors,
|
|
617
|
+
)
|
|
618
|
+
return render_template(
|
|
619
|
+
f"themes/{theme}/crud/related_crud_tab_child_view.html",
|
|
620
|
+
**ctx,
|
|
621
|
+
nested_active_tab="edit",
|
|
622
|
+
nested_content=nested_content,
|
|
623
|
+
), status
|
|
624
|
+
return render_template(
|
|
625
|
+
f"themes/{theme}/crud/related_crud_tab_form.html",
|
|
626
|
+
**_ctx(id),
|
|
627
|
+
parent_id=id,
|
|
628
|
+
form_data=form_data,
|
|
629
|
+
child_fields=child_fields,
|
|
630
|
+
ds=ds,
|
|
631
|
+
child_primary_key=ds._primary_key,
|
|
632
|
+
is_edit=True,
|
|
633
|
+
child_id=child_id,
|
|
634
|
+
child_url=child_url,
|
|
635
|
+
edit_actions=rcrud_edit_actions,
|
|
636
|
+
errors=errors,
|
|
637
|
+
), status
|
|
638
|
+
|
|
639
|
+
edit_view.__name__ = edit_ep
|
|
640
|
+
blueprint.add_url_rule(
|
|
641
|
+
f"/<int:id>/rcrud/{tab_key}/<int:child_id>/edit",
|
|
642
|
+
edit_ep,
|
|
643
|
+
edit_view,
|
|
644
|
+
methods=["GET", "POST"],
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def delete_view(id, child_id):
|
|
648
|
+
ds = child_dataclass()
|
|
649
|
+
try:
|
|
650
|
+
ds.delete(child_id)
|
|
651
|
+
flash("crud-item-deleted", "success")
|
|
652
|
+
except Exception:
|
|
653
|
+
if current_app.debug:
|
|
654
|
+
raise
|
|
655
|
+
flash("crud-error-deleting-item", "danger")
|
|
656
|
+
return redirect(
|
|
657
|
+
resolve_next(url_for(f"{parent_view.endpoint}.{list_ep}", id=id))
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
delete_view.__name__ = delete_ep
|
|
661
|
+
blueprint.add_url_rule(
|
|
662
|
+
f"/<int:id>/rcrud/{tab_key}/<int:child_id>/delete",
|
|
663
|
+
delete_ep,
|
|
664
|
+
delete_view,
|
|
665
|
+
methods=["POST"],
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
_STANDARD_CRUD = frozenset({"index", "create", "get", "patch", "delete"})
|
|
669
|
+
|
|
670
|
+
def _url_params(url):
|
|
671
|
+
params = set()
|
|
672
|
+
for part in url.split("/"):
|
|
673
|
+
if part.startswith("<") and part.endswith(">"):
|
|
674
|
+
name = part.strip("<>")
|
|
675
|
+
if ":" in name:
|
|
676
|
+
name = name.split(":", 1)[1]
|
|
677
|
+
params.add(name)
|
|
678
|
+
return params
|
|
679
|
+
|
|
680
|
+
def _register_exposed(
|
|
681
|
+
child_cls, url_prefix, ep_prefix, child_id_kwarg, child_ctx_fn=None
|
|
682
|
+
):
|
|
683
|
+
"""Register all non-standard @expose routes from child_cls on the blueprint."""
|
|
684
|
+
# Params in the URL prefix (beyond the leading 'id') must not be forwarded
|
|
685
|
+
# to the child method — they belong to the parent context.
|
|
686
|
+
prefix_params = _url_params(url_prefix) - {"id"}
|
|
687
|
+
|
|
688
|
+
def _make_view(method_name, ep_name):
|
|
689
|
+
def exposed_view(id, **kwargs):
|
|
690
|
+
child = child_cls()
|
|
691
|
+
child.construct = construct
|
|
692
|
+
for p in prefix_params:
|
|
693
|
+
kwargs.pop(p, None)
|
|
694
|
+
if child_id_kwarg in kwargs:
|
|
695
|
+
kwargs["id"] = kwargs.pop(child_id_kwarg)
|
|
696
|
+
child._rcrud_context = {
|
|
697
|
+
"parent_endpoint": parent_view.endpoint,
|
|
698
|
+
"ep_prefix": ep_prefix,
|
|
699
|
+
"child_id_kwarg": child_id_kwarg,
|
|
700
|
+
"parent_id": id,
|
|
701
|
+
"child_id": kwargs.get("id"),
|
|
702
|
+
"_child_ctx": child_ctx_fn,
|
|
703
|
+
}
|
|
704
|
+
return getattr(child, method_name)(**kwargs)
|
|
705
|
+
|
|
706
|
+
exposed_view.__name__ = ep_name
|
|
707
|
+
return exposed_view
|
|
708
|
+
|
|
709
|
+
for url, name, methods in child_cls._curls:
|
|
710
|
+
if name in _STANDARD_CRUD:
|
|
711
|
+
continue
|
|
712
|
+
ep_name = f"{ep_prefix}_{name}"
|
|
713
|
+
adjusted_url = url.replace(
|
|
714
|
+
"<int:id>", f"<int:{child_id_kwarg}>"
|
|
715
|
+
).replace("<id>", f"<{child_id_kwarg}>")
|
|
716
|
+
view_func = _make_view(name, ep_name)
|
|
717
|
+
blueprint.add_url_rule(
|
|
718
|
+
f"/{url_prefix}{adjusted_url}",
|
|
719
|
+
ep_name,
|
|
720
|
+
view_func,
|
|
721
|
+
methods=list(methods),
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
if self.child_crud_class:
|
|
725
|
+
_register_exposed(
|
|
726
|
+
self.child_crud_class,
|
|
727
|
+
f"<int:id>/rcrud/{tab_key}",
|
|
728
|
+
f"rcrud_{tab_key}",
|
|
729
|
+
"child_id",
|
|
730
|
+
child_ctx_fn=_child_ctx,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def _make_nested_views(nested_rct):
|
|
734
|
+
ntk = nested_rct.tab_key
|
|
735
|
+
n_filter = nested_rct.filter_field
|
|
736
|
+
n_dc = nested_rct.child_dataclass
|
|
737
|
+
n_list = f"rcrud_{tab_key}_{ntk}"
|
|
738
|
+
n_create = f"rcrud_{tab_key}_{ntk}_create"
|
|
739
|
+
n_edit = f"rcrud_{tab_key}_{ntk}_edit"
|
|
740
|
+
n_delete = f"rcrud_{tab_key}_{ntk}_delete"
|
|
741
|
+
n_rct = nested_rct
|
|
742
|
+
|
|
743
|
+
def nested_list(id, child_id):
|
|
744
|
+
ds = n_dc()
|
|
745
|
+
page, per_page, limit = get_pagination(
|
|
746
|
+
getattr(n_rct.child_crud_class, "page_size", None)
|
|
747
|
+
if n_rct.child_crud_class
|
|
748
|
+
else None
|
|
749
|
+
)
|
|
750
|
+
data, total = ds.filter({n_filter: str(child_id)}, limit=limit)
|
|
751
|
+
pagination = None
|
|
752
|
+
if total > per_page:
|
|
753
|
+
pages = ceil(total / per_page)
|
|
754
|
+
pw = 2
|
|
755
|
+
pagination = {
|
|
756
|
+
"page": page,
|
|
757
|
+
"per_page": per_page,
|
|
758
|
+
"total": total,
|
|
759
|
+
"pages": int(pages),
|
|
760
|
+
"start_page": max(1, page - pw),
|
|
761
|
+
"end_page": min(int(pages), page + pw),
|
|
762
|
+
}
|
|
763
|
+
fields = [
|
|
764
|
+
(k, v)
|
|
765
|
+
for k, v in n_rct.get_list_fields(ds, construct)
|
|
766
|
+
if k != n_filter
|
|
767
|
+
]
|
|
768
|
+
ctx = _child_ctx(id, child_id)
|
|
769
|
+
if n_rct.create_url:
|
|
770
|
+
_n_list_base = url_for(
|
|
771
|
+
f"{parent_view.endpoint}.{n_list}", id=id, child_id=child_id
|
|
772
|
+
).rstrip("/")
|
|
773
|
+
n_create_href = f"{_n_list_base}/{n_rct.create_url.lstrip('/')}"
|
|
774
|
+
else:
|
|
775
|
+
n_create_href = url_for(
|
|
776
|
+
f"{parent_view.endpoint}.{n_create}", id=id, child_id=child_id
|
|
777
|
+
)
|
|
778
|
+
n_rcrud_list_actions = [
|
|
779
|
+
{
|
|
780
|
+
"endpoint": f"rcrud_{tab_key}_{ntk}_{a['endpoint']}",
|
|
781
|
+
"name": a["name"],
|
|
782
|
+
"icon": a["icon"],
|
|
783
|
+
}
|
|
784
|
+
for a in (
|
|
785
|
+
n_rct.child_crud_class.list_actions
|
|
786
|
+
if n_rct.child_crud_class
|
|
787
|
+
else []
|
|
788
|
+
)
|
|
789
|
+
]
|
|
790
|
+
nested_content = render_template(
|
|
791
|
+
f"themes/{theme}/crud/_related_child_list.html",
|
|
792
|
+
construct=construct,
|
|
793
|
+
view=parent_view,
|
|
794
|
+
data=data,
|
|
795
|
+
fields=fields,
|
|
796
|
+
primary_key=ds._primary_key,
|
|
797
|
+
pagination=pagination,
|
|
798
|
+
outer_id=id,
|
|
799
|
+
child_id=child_id,
|
|
800
|
+
create_ep=n_create,
|
|
801
|
+
edit_ep=n_edit,
|
|
802
|
+
delete_ep=n_delete,
|
|
803
|
+
list_ep=n_list,
|
|
804
|
+
list_actions=n_rcrud_list_actions,
|
|
805
|
+
nested_content_target=ctx["nested_content_target"],
|
|
806
|
+
create_href=n_create_href,
|
|
807
|
+
)
|
|
808
|
+
return render_template(
|
|
809
|
+
f"themes/{theme}/crud/related_crud_tab_child_view.html",
|
|
810
|
+
**ctx,
|
|
811
|
+
nested_active_tab=n_list,
|
|
812
|
+
nested_content=nested_content,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
nested_list.__name__ = n_list
|
|
816
|
+
blueprint.add_url_rule(
|
|
817
|
+
f"/<int:id>/rcrud/{tab_key}/<int:child_id>/rcrud/{ntk}/",
|
|
818
|
+
n_list,
|
|
819
|
+
nested_list,
|
|
820
|
+
methods=["GET"],
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
def nested_create(id, child_id):
|
|
824
|
+
ds = n_dc()
|
|
825
|
+
errors = None
|
|
826
|
+
form_data = None
|
|
827
|
+
status = 200
|
|
828
|
+
if request.method == "POST":
|
|
829
|
+
data = n_rct._process_form_data(ds, child_id, construct)
|
|
830
|
+
errors = validate_form_data(
|
|
831
|
+
ds,
|
|
832
|
+
n_rct.get_form_fields(ds, construct),
|
|
833
|
+
data,
|
|
834
|
+
row=FormData(data),
|
|
835
|
+
skip=(n_filter,),
|
|
836
|
+
)
|
|
837
|
+
if not errors:
|
|
838
|
+
rec = None
|
|
839
|
+
try:
|
|
840
|
+
rec = ds.create(data)
|
|
841
|
+
flash("crud-item-created", "success")
|
|
842
|
+
except Exception:
|
|
843
|
+
if current_app.debug:
|
|
844
|
+
raise
|
|
845
|
+
flash("crud-error-creating-item", "danger")
|
|
846
|
+
record_url = None
|
|
847
|
+
if rec is not None:
|
|
848
|
+
record_url = url_for(
|
|
849
|
+
f"{parent_view.endpoint}.{n_edit}",
|
|
850
|
+
id=id,
|
|
851
|
+
child_id=child_id,
|
|
852
|
+
gcid=getattr(rec, ds._primary_key),
|
|
853
|
+
)
|
|
854
|
+
return redirect(
|
|
855
|
+
resolve_next(
|
|
856
|
+
url_for(
|
|
857
|
+
f"{parent_view.endpoint}.{n_list}",
|
|
858
|
+
id=id,
|
|
859
|
+
child_id=child_id,
|
|
860
|
+
),
|
|
861
|
+
record_url,
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
form_data = FormData(data)
|
|
865
|
+
status = 400
|
|
866
|
+
child_fields = [
|
|
867
|
+
(k, v)
|
|
868
|
+
for k, v in n_rct.get_form_fields(ds, construct)
|
|
869
|
+
if k != n_filter
|
|
870
|
+
]
|
|
871
|
+
ctx = _child_ctx(id, child_id)
|
|
872
|
+
form_action = url_for(
|
|
873
|
+
f"{parent_view.endpoint}.{n_create}", id=id, child_id=child_id
|
|
874
|
+
)
|
|
875
|
+
cancel_url = url_for(
|
|
876
|
+
f"{parent_view.endpoint}.{n_list}", id=id, child_id=child_id
|
|
877
|
+
)
|
|
878
|
+
n_child_url = (
|
|
879
|
+
url_for(
|
|
880
|
+
f"{parent_view.endpoint}.{n_list}", id=id, child_id=child_id
|
|
881
|
+
).rstrip("/")
|
|
882
|
+
if n_rct.child_crud_class
|
|
883
|
+
else None
|
|
884
|
+
)
|
|
885
|
+
nested_content = render_template(
|
|
886
|
+
f"themes/{theme}/crud/_related_child_form.html",
|
|
887
|
+
construct=construct,
|
|
888
|
+
view=parent_view,
|
|
889
|
+
ds=ds,
|
|
890
|
+
form_data=form_data,
|
|
891
|
+
child_fields=child_fields,
|
|
892
|
+
is_edit=False,
|
|
893
|
+
form_action=form_action,
|
|
894
|
+
cancel_url=cancel_url,
|
|
895
|
+
nested_content_target=ctx["nested_content_target"],
|
|
896
|
+
child_url=n_child_url,
|
|
897
|
+
edit_actions=[],
|
|
898
|
+
errors=errors,
|
|
899
|
+
)
|
|
900
|
+
return render_template(
|
|
901
|
+
f"themes/{theme}/crud/related_crud_tab_child_view.html",
|
|
902
|
+
**ctx,
|
|
903
|
+
nested_active_tab=n_list,
|
|
904
|
+
nested_content=nested_content,
|
|
905
|
+
), status
|
|
906
|
+
|
|
907
|
+
nested_create.__name__ = n_create
|
|
908
|
+
blueprint.add_url_rule(
|
|
909
|
+
f"/<int:id>/rcrud/{tab_key}/<int:child_id>/rcrud/{ntk}/create",
|
|
910
|
+
n_create,
|
|
911
|
+
nested_create,
|
|
912
|
+
methods=["GET", "POST"],
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
def nested_edit(id, child_id, gcid):
|
|
916
|
+
ds = n_dc()
|
|
917
|
+
rec = ds.read(gcid)
|
|
918
|
+
if rec is None:
|
|
919
|
+
flash("crud-error-not-found", "danger")
|
|
920
|
+
return redirect(
|
|
921
|
+
url_for(
|
|
922
|
+
f"{parent_view.endpoint}.{n_list}", id=id, child_id=child_id
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
errors = None
|
|
926
|
+
form_data = rec
|
|
927
|
+
status = 200
|
|
928
|
+
if request.method == "POST":
|
|
929
|
+
data = n_rct._process_form_data(ds, child_id, construct, rec)
|
|
930
|
+
errors = validate_form_data(
|
|
931
|
+
ds,
|
|
932
|
+
n_rct.get_form_fields(ds, construct, rec),
|
|
933
|
+
data,
|
|
934
|
+
row=FormData(data, rec),
|
|
935
|
+
partial=True,
|
|
936
|
+
skip=(n_filter,),
|
|
937
|
+
)
|
|
938
|
+
if not errors:
|
|
939
|
+
try:
|
|
940
|
+
ds.update(gcid, data)
|
|
941
|
+
flash("crud-item-updated", "success")
|
|
942
|
+
except Exception:
|
|
943
|
+
if current_app.debug:
|
|
944
|
+
raise
|
|
945
|
+
flash("crud-error-updating-item", "danger")
|
|
946
|
+
record_url = url_for(
|
|
947
|
+
f"{parent_view.endpoint}.{n_edit}",
|
|
948
|
+
id=id,
|
|
949
|
+
child_id=child_id,
|
|
950
|
+
gcid=gcid,
|
|
951
|
+
)
|
|
952
|
+
return redirect(
|
|
953
|
+
resolve_next(
|
|
954
|
+
url_for(
|
|
955
|
+
f"{parent_view.endpoint}.{n_list}",
|
|
956
|
+
id=id,
|
|
957
|
+
child_id=child_id,
|
|
958
|
+
),
|
|
959
|
+
record_url,
|
|
960
|
+
)
|
|
961
|
+
)
|
|
962
|
+
form_data = FormData(data, rec)
|
|
963
|
+
status = 400
|
|
964
|
+
child_fields = [
|
|
965
|
+
(k, v)
|
|
966
|
+
for k, v in n_rct.get_form_fields(ds, construct, rec)
|
|
967
|
+
if k != n_filter
|
|
968
|
+
]
|
|
969
|
+
ctx = _child_ctx(id, child_id)
|
|
970
|
+
form_action = url_for(
|
|
971
|
+
f"{parent_view.endpoint}.{n_edit}",
|
|
972
|
+
id=id,
|
|
973
|
+
child_id=child_id,
|
|
974
|
+
gcid=gcid,
|
|
975
|
+
)
|
|
976
|
+
cancel_url = url_for(
|
|
977
|
+
f"{parent_view.endpoint}.{n_list}", id=id, child_id=child_id
|
|
978
|
+
)
|
|
979
|
+
n_child_url = (
|
|
980
|
+
url_for(
|
|
981
|
+
f"{parent_view.endpoint}.{n_list}", id=id, child_id=child_id
|
|
982
|
+
).rstrip("/")
|
|
983
|
+
if n_rct.child_crud_class
|
|
984
|
+
else None
|
|
985
|
+
)
|
|
986
|
+
n_rcrud_edit_actions = []
|
|
987
|
+
if n_rct.child_crud_class:
|
|
988
|
+
for _ea in n_rct.child_crud_class.edit_actions:
|
|
989
|
+
_ep = f"rcrud_{tab_key}_{ntk}_{_ea['endpoint']}"
|
|
990
|
+
n_rcrud_edit_actions.append(
|
|
991
|
+
{
|
|
992
|
+
"href": url_for(
|
|
993
|
+
f"{parent_view.endpoint}.{_ep}",
|
|
994
|
+
id=id,
|
|
995
|
+
child_id=child_id,
|
|
996
|
+
gcid=gcid,
|
|
997
|
+
),
|
|
998
|
+
"name": _ea["name"],
|
|
999
|
+
"icon": _ea["icon"],
|
|
1000
|
+
}
|
|
1001
|
+
)
|
|
1002
|
+
nested_content = render_template(
|
|
1003
|
+
f"themes/{theme}/crud/_related_child_form.html",
|
|
1004
|
+
construct=construct,
|
|
1005
|
+
view=parent_view,
|
|
1006
|
+
ds=ds,
|
|
1007
|
+
form_data=form_data,
|
|
1008
|
+
child_fields=child_fields,
|
|
1009
|
+
is_edit=True,
|
|
1010
|
+
form_action=form_action,
|
|
1011
|
+
cancel_url=cancel_url,
|
|
1012
|
+
nested_content_target=ctx["nested_content_target"],
|
|
1013
|
+
child_url=n_child_url,
|
|
1014
|
+
edit_actions=n_rcrud_edit_actions,
|
|
1015
|
+
errors=errors,
|
|
1016
|
+
)
|
|
1017
|
+
return render_template(
|
|
1018
|
+
f"themes/{theme}/crud/related_crud_tab_child_view.html",
|
|
1019
|
+
**ctx,
|
|
1020
|
+
nested_active_tab=n_list,
|
|
1021
|
+
nested_content=nested_content,
|
|
1022
|
+
), status
|
|
1023
|
+
|
|
1024
|
+
nested_edit.__name__ = n_edit
|
|
1025
|
+
blueprint.add_url_rule(
|
|
1026
|
+
f"/<int:id>/rcrud/{tab_key}/<int:child_id>/rcrud/{ntk}/<int:gcid>/edit",
|
|
1027
|
+
n_edit,
|
|
1028
|
+
nested_edit,
|
|
1029
|
+
methods=["GET", "POST"],
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
def nested_delete(id, child_id, gcid):
|
|
1033
|
+
ds = n_dc()
|
|
1034
|
+
try:
|
|
1035
|
+
ds.delete(gcid)
|
|
1036
|
+
flash("crud-item-deleted", "success")
|
|
1037
|
+
except Exception:
|
|
1038
|
+
if current_app.debug:
|
|
1039
|
+
raise
|
|
1040
|
+
flash("crud-error-deleting-item", "danger")
|
|
1041
|
+
return redirect(
|
|
1042
|
+
resolve_next(
|
|
1043
|
+
url_for(
|
|
1044
|
+
f"{parent_view.endpoint}.{n_list}",
|
|
1045
|
+
id=id,
|
|
1046
|
+
child_id=child_id,
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
nested_delete.__name__ = n_delete
|
|
1052
|
+
blueprint.add_url_rule(
|
|
1053
|
+
f"/<int:id>/rcrud/{tab_key}/<int:child_id>/rcrud/{ntk}/<int:gcid>/delete",
|
|
1054
|
+
n_delete,
|
|
1055
|
+
nested_delete,
|
|
1056
|
+
methods=["POST"],
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
if nested_rct.child_crud_class:
|
|
1060
|
+
_register_exposed(
|
|
1061
|
+
nested_rct.child_crud_class,
|
|
1062
|
+
f"<int:id>/rcrud/{tab_key}/<int:child_id>/rcrud/{ntk}",
|
|
1063
|
+
f"rcrud_{tab_key}_{ntk}",
|
|
1064
|
+
"gcid",
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
for nested_rct in child_nested_tabs:
|
|
1068
|
+
_make_nested_views(nested_rct)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
class CrudPageBase(PageBase):
|
|
1072
|
+
dataclass: type[DataBase]
|
|
1073
|
+
related_crud_tabs: list = []
|
|
1074
|
+
|
|
1075
|
+
#: Optional template path overrides for the create/edit forms. Set these to
|
|
1076
|
+
#: a template that ``{% extends %}`` the theme default to override individual
|
|
1077
|
+
#: blocks (e.g. ``form_scripts``) without copying the whole template. Leave
|
|
1078
|
+
#: as ``None`` to use the packaged theme template.
|
|
1079
|
+
create_template: str | None = None
|
|
1080
|
+
edit_template: str | None = None
|
|
1081
|
+
|
|
1082
|
+
def _form_template(self, name: str, override: str | None) -> str:
|
|
1083
|
+
"""Resolve the template for a CRUD form, honouring per-page overrides."""
|
|
1084
|
+
return override or f"themes/{self.construct.theme}/crud/{name}"
|
|
1085
|
+
|
|
1086
|
+
def form_scripts(self, ds: DataBase, data=None) -> str:
|
|
1087
|
+
"""HTML/JS injected after the CRUD create/edit forms.
|
|
1088
|
+
|
|
1089
|
+
Override this to add client-side behaviour (e.g. an Unpoly compiler that
|
|
1090
|
+
toggles field visibility) without overriding the form templates. The
|
|
1091
|
+
returned markup is rendered ``| safe`` inside the ``form_scripts`` block.
|
|
1092
|
+
"""
|
|
1093
|
+
return ""
|
|
1094
|
+
|
|
1095
|
+
def get_blueprint(self, construct):
|
|
1096
|
+
blueprint = super().get_blueprint(construct)
|
|
1097
|
+
if self.__class__.related_crud_tabs:
|
|
1098
|
+
self.tab_actions = list(self.__class__.tab_actions)
|
|
1099
|
+
for rct in self.__class__.related_crud_tabs:
|
|
1100
|
+
self.tab_actions.append(rct.get_tab_action())
|
|
1101
|
+
rct._register(self, blueprint, construct)
|
|
1102
|
+
return blueprint
|
|
1103
|
+
|
|
1104
|
+
def default_index(self):
|
|
1105
|
+
return ""
|
|
1106
|
+
|
|
1107
|
+
def _run_view(self, f, *args, **kwargs):
|
|
1108
|
+
return f(self, *args, **kwargs)
|
|
1109
|
+
|
|
1110
|
+
def get_edit_url(self, new_id: int) -> str:
|
|
1111
|
+
"""Return the edit URL for a record with *new_id*.
|
|
1112
|
+
|
|
1113
|
+
Works in both standalone and rcrud contexts. In an rcrud context the
|
|
1114
|
+
parent URL args are read from the current request automatically, so the
|
|
1115
|
+
only argument needed is the ID of the record to edit.
|
|
1116
|
+
"""
|
|
1117
|
+
ctx = getattr(self, "_rcrud_context", None)
|
|
1118
|
+
if ctx is None:
|
|
1119
|
+
return url_for(f"{self.endpoint}.get", id=new_id)
|
|
1120
|
+
parent_args = dict(request.view_args)
|
|
1121
|
+
parent_args[ctx["child_id_kwarg"]] = new_id
|
|
1122
|
+
return url_for(
|
|
1123
|
+
f"{ctx['parent_endpoint']}.{ctx['ep_prefix']}_edit", **parent_args
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
def get_list_url(self) -> str:
|
|
1127
|
+
"""Return the list URL for this CRUD page.
|
|
1128
|
+
|
|
1129
|
+
Works in both standalone and rcrud contexts. In an rcrud context the
|
|
1130
|
+
parent URL args are read from the current request automatically.
|
|
1131
|
+
"""
|
|
1132
|
+
ctx = getattr(self, "_rcrud_context", None)
|
|
1133
|
+
if ctx is None:
|
|
1134
|
+
return url_for(f"{self.endpoint}.index")
|
|
1135
|
+
return url_for(
|
|
1136
|
+
f"{ctx['parent_endpoint']}.{ctx['ep_prefix']}", **request.view_args
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
def get_success_url(self, action, rec=None):
|
|
1140
|
+
"""Where to redirect once a CRUD form action has been handled.
|
|
1141
|
+
|
|
1142
|
+
``action`` is ``"create"``, ``"update"`` or ``"delete"``; ``rec`` is
|
|
1143
|
+
the affected record — ``None`` after a delete or when the action
|
|
1144
|
+
failed (the redirect still happens, with the outcome flashed).
|
|
1145
|
+
|
|
1146
|
+
The default honors the ``next`` request parameter — including the
|
|
1147
|
+
``:record`` sentinel (see ``navigation.py``) — and falls back to the
|
|
1148
|
+
list view, preserving the historical behavior when no ``next`` is
|
|
1149
|
+
present. Override for page-level policies; e.g. to land on the new
|
|
1150
|
+
record after every create::
|
|
1151
|
+
|
|
1152
|
+
def get_success_url(self, action, rec=None):
|
|
1153
|
+
if action == "create" and rec is not None:
|
|
1154
|
+
return self.get_edit_url(
|
|
1155
|
+
getattr(rec, self.dataclass._primary_key)
|
|
1156
|
+
)
|
|
1157
|
+
return super().get_success_url(action, rec)
|
|
1158
|
+
|
|
1159
|
+
Applies to the page's own create/edit/delete views. RCRUD tab views
|
|
1160
|
+
honor ``next``/``:record`` directly and keep their tab-list default.
|
|
1161
|
+
"""
|
|
1162
|
+
record_url = None
|
|
1163
|
+
if rec is not None:
|
|
1164
|
+
record_url = self.get_edit_url(
|
|
1165
|
+
getattr(rec, self.dataclass._primary_key)
|
|
1166
|
+
)
|
|
1167
|
+
return resolve_next(self.get_list_url(), record_url)
|
|
1168
|
+
|
|
1169
|
+
def get_list_fields(self, ds: DataBase) -> list[tuple[str, schema.SchemaField]]:
|
|
1170
|
+
"""
|
|
1171
|
+
Return a list of (name, field_info) tuples to be shown in the list view.
|
|
1172
|
+
Override this to dynamically customize the list columns.
|
|
1173
|
+
"""
|
|
1174
|
+
return [
|
|
1175
|
+
(k, v)
|
|
1176
|
+
for k, v in ds._schema.items()
|
|
1177
|
+
if not isinstance(v, (schema.PK, schema.List)) and v.visible_list
|
|
1178
|
+
]
|
|
1179
|
+
|
|
1180
|
+
def get_form_fields(
|
|
1181
|
+
self, ds: DataBase, data=None
|
|
1182
|
+
) -> list[tuple[str, schema.SchemaField]]:
|
|
1183
|
+
"""
|
|
1184
|
+
Return a list of (name, field_info) tuples to be shown in the form (create/edit).
|
|
1185
|
+
Override this to dynamically customize the form fields based on the record data.
|
|
1186
|
+
"""
|
|
1187
|
+
return [
|
|
1188
|
+
(k, v)
|
|
1189
|
+
for k, v in ds._schema.items()
|
|
1190
|
+
if not isinstance(v, schema.PK) and v.visible_form
|
|
1191
|
+
]
|
|
1192
|
+
|
|
1193
|
+
@expose("/", methods=["GET"])
|
|
1194
|
+
def index(self):
|
|
1195
|
+
ds = self.dataclass()
|
|
1196
|
+
|
|
1197
|
+
page, per_page, limit = get_pagination(getattr(self, "page_size", None))
|
|
1198
|
+
|
|
1199
|
+
reserved_args = ["page", "query", "advanced_filter"]
|
|
1200
|
+
# Only fields rendered in the list view (and not skipfilter) may be
|
|
1201
|
+
# filtered on; anything else in the query string is a probe on
|
|
1202
|
+
# columns the UI never exposes (e.g. password_hash) and is ignored.
|
|
1203
|
+
filterable_fields = {
|
|
1204
|
+
fld for fld, ftype in self.get_list_fields(ds) if not ftype.skipfilter
|
|
1205
|
+
}
|
|
1206
|
+
filter_params = {
|
|
1207
|
+
k: v
|
|
1208
|
+
for k, v in request.args.items()
|
|
1209
|
+
if k not in reserved_args and k in filterable_fields and v
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
total = 0
|
|
1213
|
+
data = []
|
|
1214
|
+
if "query" in request.args and request.args["query"]:
|
|
1215
|
+
data, total = ds.search(request.args["query"], limit=limit)
|
|
1216
|
+
elif filter_params:
|
|
1217
|
+
data, total = ds.filter(filter_params, limit=limit)
|
|
1218
|
+
else:
|
|
1219
|
+
data, total = ds.search(limit=limit)
|
|
1220
|
+
|
|
1221
|
+
pagination = None
|
|
1222
|
+
if total > per_page:
|
|
1223
|
+
pages = ceil(total / per_page)
|
|
1224
|
+
page_window = 2
|
|
1225
|
+
start_page = max(1, page - page_window)
|
|
1226
|
+
end_page = min(pages, page + page_window)
|
|
1227
|
+
pagination = {
|
|
1228
|
+
"page": page,
|
|
1229
|
+
"per_page": per_page,
|
|
1230
|
+
"total": total,
|
|
1231
|
+
"pages": int(pages),
|
|
1232
|
+
"start_page": start_page,
|
|
1233
|
+
"end_page": int(end_page),
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return render_template(
|
|
1237
|
+
f"themes/{self.construct.theme}/crud/listindex.html",
|
|
1238
|
+
construct=self.construct,
|
|
1239
|
+
view=self,
|
|
1240
|
+
data=data,
|
|
1241
|
+
pagination=pagination,
|
|
1242
|
+
fields=self.get_list_fields(ds),
|
|
1243
|
+
primary_key=ds._primary_key,
|
|
1244
|
+
list_actions=self.list_actions,
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
@expose("/<int:id>")
|
|
1248
|
+
def get(self, id):
|
|
1249
|
+
ds = self.dataclass()
|
|
1250
|
+
data = ds.read(id)
|
|
1251
|
+
return render_template(
|
|
1252
|
+
self._form_template("edit.html", self.edit_template),
|
|
1253
|
+
construct=self.construct,
|
|
1254
|
+
view=self,
|
|
1255
|
+
data=data,
|
|
1256
|
+
ds=ds,
|
|
1257
|
+
fields=self.get_form_fields(ds, data),
|
|
1258
|
+
primary_key=ds._primary_key,
|
|
1259
|
+
tab_actions=self.tab_actions,
|
|
1260
|
+
edit_actions=self.edit_actions,
|
|
1261
|
+
form_scripts=self.form_scripts(ds, data),
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
def render_tab(self, id, active_tab, **kwargs):
|
|
1265
|
+
ctx = getattr(self, "_rcrud_context", None)
|
|
1266
|
+
if ctx and ctx.get("_child_ctx"):
|
|
1267
|
+
child_ctx = ctx["_child_ctx"](ctx["parent_id"], id)
|
|
1268
|
+
return render_template(
|
|
1269
|
+
f"themes/{self.construct.theme}/crud/related_crud_tab_child_view.html",
|
|
1270
|
+
**child_ctx,
|
|
1271
|
+
nested_active_tab=f"{ctx['ep_prefix']}_{active_tab}",
|
|
1272
|
+
nested_content=kwargs.get("tab_content", ""),
|
|
1273
|
+
)
|
|
1274
|
+
return render_template(
|
|
1275
|
+
f"themes/{self.construct.theme}/crud/tab_view.html",
|
|
1276
|
+
construct=self.construct,
|
|
1277
|
+
view=self,
|
|
1278
|
+
id=id,
|
|
1279
|
+
active_tab=active_tab,
|
|
1280
|
+
tab_actions=self.tab_actions,
|
|
1281
|
+
**kwargs,
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
@expose("/search/<field_name>")
|
|
1285
|
+
def search_related(self, field_name):
|
|
1286
|
+
ds = self.dataclass()
|
|
1287
|
+
query = request.args.get("q")
|
|
1288
|
+
field_type = ds._schema[field_name]
|
|
1289
|
+
if not isinstance(field_type, schema.Related):
|
|
1290
|
+
return "Not a related field", 400
|
|
1291
|
+
|
|
1292
|
+
related_ds = field_type.related_class()
|
|
1293
|
+
results, _ = related_ds.search(query, limit=(0, RELATED_SEARCH_LIMIT))
|
|
1294
|
+
|
|
1295
|
+
return render_template(
|
|
1296
|
+
f"themes/{self.construct.theme}/crud/_related_options.html",
|
|
1297
|
+
results=results,
|
|
1298
|
+
field_type=field_type,
|
|
1299
|
+
field_name=field_name,
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
@expose("/search-many/<field_name>")
|
|
1303
|
+
def search_many_related(self, field_name):
|
|
1304
|
+
ds = self.dataclass()
|
|
1305
|
+
query = request.args.get("q")
|
|
1306
|
+
field_type = ds._schema[field_name]
|
|
1307
|
+
if not isinstance(field_type, schema.ManyRelated):
|
|
1308
|
+
return "Not a many-related field", 400
|
|
1309
|
+
|
|
1310
|
+
related_ds = field_type.related_class()
|
|
1311
|
+
search_kwargs = {"limit": (0, RELATED_SEARCH_LIMIT)}
|
|
1312
|
+
if field_type.search_fields:
|
|
1313
|
+
search_kwargs["fields"] = field_type.search_fields
|
|
1314
|
+
results, _ = related_ds.search(query, **search_kwargs)
|
|
1315
|
+
|
|
1316
|
+
# The quick-add row is suppressed on an exact quick_field match —
|
|
1317
|
+
# the same rule the save-time dedupe applies, so the dropdown never
|
|
1318
|
+
# offers to create a record that would only be linked.
|
|
1319
|
+
quick_exact = False
|
|
1320
|
+
if field_type.create == "quick" and query:
|
|
1321
|
+
quick_field = field_type.resolve_quick_field()
|
|
1322
|
+
quick_exact = any(
|
|
1323
|
+
str(getattr(result, quick_field, "")) == query for result in results
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
return render_template(
|
|
1327
|
+
f"themes/{self.construct.theme}/crud/_many_related_options.html",
|
|
1328
|
+
construct=self.construct,
|
|
1329
|
+
results=results,
|
|
1330
|
+
field_type=field_type,
|
|
1331
|
+
field_name=field_name,
|
|
1332
|
+
query=query,
|
|
1333
|
+
quick_exact=quick_exact,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
@expose("/create", methods=["GET", "POST"])
|
|
1337
|
+
def create(self):
|
|
1338
|
+
ds = self.dataclass()
|
|
1339
|
+
if request.method == "POST":
|
|
1340
|
+
raw_data = request.form.to_dict()
|
|
1341
|
+
form_fields = {k: v for k, v in self.get_form_fields(ds)}
|
|
1342
|
+
data = {}
|
|
1343
|
+
# Only take fields that are in the form_fields and are not readonly/hidden
|
|
1344
|
+
for field, type_info in form_fields.items():
|
|
1345
|
+
if type_info.readonly or not type_info.visible_form:
|
|
1346
|
+
continue
|
|
1347
|
+
if field in raw_data:
|
|
1348
|
+
data[field] = raw_data[field]
|
|
1349
|
+
elif type_info.default is not None:
|
|
1350
|
+
# Apply the schema default when the field was omitted entirely
|
|
1351
|
+
# (e.g. JS-hidden or rendered without an input), so defaults
|
|
1352
|
+
# hold even when the browser submits nothing for the field.
|
|
1353
|
+
data[field] = type_info.default
|
|
1354
|
+
|
|
1355
|
+
for field, type_info in ds._schema.items():
|
|
1356
|
+
if (
|
|
1357
|
+
field not in form_fields
|
|
1358
|
+
or type_info.readonly
|
|
1359
|
+
or not type_info.visible_form
|
|
1360
|
+
):
|
|
1361
|
+
continue
|
|
1362
|
+
|
|
1363
|
+
if hasattr(type_info, "field_type"):
|
|
1364
|
+
if type_info.field_type == "bool":
|
|
1365
|
+
if field in raw_data:
|
|
1366
|
+
data[field] = True
|
|
1367
|
+
else:
|
|
1368
|
+
data[field] = False
|
|
1369
|
+
elif type_info.field_type == "set":
|
|
1370
|
+
data[field] = set(request.form.getlist(field))
|
|
1371
|
+
elif isinstance(type_info, schema.ManyRelated):
|
|
1372
|
+
pks, staged = _parse_many_related_field(
|
|
1373
|
+
field, type_info, request.form
|
|
1374
|
+
)
|
|
1375
|
+
data[field] = pks
|
|
1376
|
+
if staged:
|
|
1377
|
+
data.setdefault("many_related_new", {})[field] = staged
|
|
1378
|
+
elif isinstance(type_info, schema.List):
|
|
1379
|
+
indices = set()
|
|
1380
|
+
prefix = f"{field}-new-"
|
|
1381
|
+
for key in request.form:
|
|
1382
|
+
if key.startswith(prefix):
|
|
1383
|
+
try:
|
|
1384
|
+
index_str = key[len(prefix) :].split("-", 1)[0]
|
|
1385
|
+
indices.add(int(index_str))
|
|
1386
|
+
except (ValueError, IndexError):
|
|
1387
|
+
continue
|
|
1388
|
+
|
|
1389
|
+
new_items = []
|
|
1390
|
+
list_cls_schema = type_info.list_class._schema
|
|
1391
|
+
for i in sorted(list(indices)):
|
|
1392
|
+
item_prefix = f"{prefix}{i}-"
|
|
1393
|
+
new_item = {}
|
|
1394
|
+
has_data = False
|
|
1395
|
+
for sub_field, sub_type in list_cls_schema.items():
|
|
1396
|
+
if (
|
|
1397
|
+
sub_type.field_type == "pk"
|
|
1398
|
+
or sub_type.readonly
|
|
1399
|
+
or not sub_type.visible_form
|
|
1400
|
+
):
|
|
1401
|
+
continue
|
|
1402
|
+
|
|
1403
|
+
form_key = item_prefix + sub_field
|
|
1404
|
+
if sub_type.field_type == "bool":
|
|
1405
|
+
if form_key in request.form:
|
|
1406
|
+
new_item[sub_field] = True
|
|
1407
|
+
has_data = True
|
|
1408
|
+
else:
|
|
1409
|
+
new_item[sub_field] = False
|
|
1410
|
+
elif sub_type.field_type == "set":
|
|
1411
|
+
values = request.form.getlist(form_key)
|
|
1412
|
+
if values:
|
|
1413
|
+
new_item[sub_field] = set(values)
|
|
1414
|
+
has_data = True
|
|
1415
|
+
elif (
|
|
1416
|
+
form_key in request.form and request.form[form_key]
|
|
1417
|
+
):
|
|
1418
|
+
new_item[sub_field] = request.form[form_key]
|
|
1419
|
+
has_data = True
|
|
1420
|
+
elif sub_type.default is not None:
|
|
1421
|
+
# Fill the default but don't let it alone
|
|
1422
|
+
# materialise a row: has_data must come from a
|
|
1423
|
+
# real submitted value in this row.
|
|
1424
|
+
new_item[sub_field] = sub_type.default
|
|
1425
|
+
|
|
1426
|
+
if has_data:
|
|
1427
|
+
new_items.append(new_item)
|
|
1428
|
+
|
|
1429
|
+
if new_items:
|
|
1430
|
+
if "list_items" not in data:
|
|
1431
|
+
data["list_items"] = {}
|
|
1432
|
+
if field not in data["list_items"]:
|
|
1433
|
+
data["list_items"][field] = {}
|
|
1434
|
+
data["list_items"][field]["new"] = new_items
|
|
1435
|
+
errors = validate_form_data(ds, form_fields, data, row=FormData(data))
|
|
1436
|
+
if errors:
|
|
1437
|
+
return render_template(
|
|
1438
|
+
self._form_template("create.html", self.create_template),
|
|
1439
|
+
construct=self.construct,
|
|
1440
|
+
view=self,
|
|
1441
|
+
data=FormData(data),
|
|
1442
|
+
ds=ds,
|
|
1443
|
+
errors=errors,
|
|
1444
|
+
fields=self.get_form_fields(ds),
|
|
1445
|
+
primary_key=ds._primary_key,
|
|
1446
|
+
form_scripts=self.form_scripts(ds, FormData(data)),
|
|
1447
|
+
), 400
|
|
1448
|
+
|
|
1449
|
+
rec = None
|
|
1450
|
+
try:
|
|
1451
|
+
rec = ds.create(data)
|
|
1452
|
+
flash("crud-item-created", "success")
|
|
1453
|
+
except Exception:
|
|
1454
|
+
if current_app.debug:
|
|
1455
|
+
raise
|
|
1456
|
+
flash("crud-error-creating-item", "danger")
|
|
1457
|
+
return redirect(self.get_success_url("create", rec))
|
|
1458
|
+
|
|
1459
|
+
return render_template(
|
|
1460
|
+
self._form_template("create.html", self.create_template),
|
|
1461
|
+
construct=self.construct,
|
|
1462
|
+
view=self,
|
|
1463
|
+
data={},
|
|
1464
|
+
ds=ds,
|
|
1465
|
+
fields=self.get_form_fields(ds),
|
|
1466
|
+
primary_key=ds._primary_key,
|
|
1467
|
+
form_scripts=self.form_scripts(ds, None),
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
@expose("/<int:id>", methods=["POST"])
|
|
1471
|
+
def patch(self, id):
|
|
1472
|
+
ds = self.dataclass()
|
|
1473
|
+
rec = ds.read(id)
|
|
1474
|
+
raw_data = request.form.to_dict()
|
|
1475
|
+
form_fields = {k: v for k, v in self.get_form_fields(ds, rec)}
|
|
1476
|
+
data = {}
|
|
1477
|
+
# Only take fields that are in the form_fields and are not readonly/hidden
|
|
1478
|
+
for field, type_info in form_fields.items():
|
|
1479
|
+
if type_info.readonly or not type_info.visible_form:
|
|
1480
|
+
continue
|
|
1481
|
+
if field in raw_data:
|
|
1482
|
+
data[field] = raw_data[field]
|
|
1483
|
+
|
|
1484
|
+
for field, type_info in ds._schema.items():
|
|
1485
|
+
if (
|
|
1486
|
+
field not in form_fields
|
|
1487
|
+
or type_info.readonly
|
|
1488
|
+
or not type_info.visible_form
|
|
1489
|
+
):
|
|
1490
|
+
continue
|
|
1491
|
+
|
|
1492
|
+
if type_info.secret:
|
|
1493
|
+
change_flag = f"{field}-change"
|
|
1494
|
+
if change_flag not in request.form:
|
|
1495
|
+
if field in data:
|
|
1496
|
+
del data[field]
|
|
1497
|
+
if hasattr(type_info, "field_type"):
|
|
1498
|
+
if type_info.field_type == "bool":
|
|
1499
|
+
if field in raw_data:
|
|
1500
|
+
data[field] = True
|
|
1501
|
+
else:
|
|
1502
|
+
data[field] = False
|
|
1503
|
+
elif type_info.field_type == "set":
|
|
1504
|
+
data[field] = set(request.form.getlist(field))
|
|
1505
|
+
elif isinstance(type_info, schema.ManyRelated):
|
|
1506
|
+
pks, staged = _parse_many_related_field(
|
|
1507
|
+
field, type_info, request.form
|
|
1508
|
+
)
|
|
1509
|
+
data[field] = pks
|
|
1510
|
+
if staged:
|
|
1511
|
+
data.setdefault("many_related_new", {})[field] = staged
|
|
1512
|
+
elif isinstance(type_info, schema.List):
|
|
1513
|
+
if "list_items" not in data:
|
|
1514
|
+
data["list_items"] = {}
|
|
1515
|
+
if field not in data["list_items"]:
|
|
1516
|
+
data["list_items"][field] = {}
|
|
1517
|
+
|
|
1518
|
+
# Deletes
|
|
1519
|
+
delete_ids = request.form.getlist(f"{field}-delete")
|
|
1520
|
+
if delete_ids:
|
|
1521
|
+
data["list_items"][field]["delete"] = delete_ids
|
|
1522
|
+
|
|
1523
|
+
# Updates
|
|
1524
|
+
update_items = []
|
|
1525
|
+
item_ids = request.form.getlist(f"{field}-id")
|
|
1526
|
+
list_cls_schema = type_info.list_class._schema
|
|
1527
|
+
|
|
1528
|
+
for item_id in item_ids:
|
|
1529
|
+
if item_id in delete_ids:
|
|
1530
|
+
continue
|
|
1531
|
+
|
|
1532
|
+
item_data = {"id": item_id}
|
|
1533
|
+
prefix = f"{field}-{item_id}-"
|
|
1534
|
+
for sub_field, sub_type in list_cls_schema.items():
|
|
1535
|
+
if (
|
|
1536
|
+
sub_type.field_type == "pk"
|
|
1537
|
+
or sub_type.readonly
|
|
1538
|
+
or not sub_type.visible_form
|
|
1539
|
+
):
|
|
1540
|
+
continue
|
|
1541
|
+
|
|
1542
|
+
form_key = prefix + sub_field
|
|
1543
|
+
if sub_type.field_type == "bool":
|
|
1544
|
+
item_data[sub_field] = form_key in request.form
|
|
1545
|
+
elif sub_type.field_type == "set":
|
|
1546
|
+
item_data[sub_field] = set(
|
|
1547
|
+
request.form.getlist(form_key)
|
|
1548
|
+
)
|
|
1549
|
+
elif form_key in request.form:
|
|
1550
|
+
item_data[sub_field] = request.form[form_key]
|
|
1551
|
+
update_items.append(item_data)
|
|
1552
|
+
|
|
1553
|
+
if update_items:
|
|
1554
|
+
data["list_items"][field]["update"] = update_items
|
|
1555
|
+
|
|
1556
|
+
# New items
|
|
1557
|
+
indices = set()
|
|
1558
|
+
prefix = f"{field}-new-"
|
|
1559
|
+
for key in request.form:
|
|
1560
|
+
if key.startswith(prefix):
|
|
1561
|
+
try:
|
|
1562
|
+
index_str = key[len(prefix) :].split("-", 1)[0]
|
|
1563
|
+
indices.add(int(index_str))
|
|
1564
|
+
except (ValueError, IndexError):
|
|
1565
|
+
continue
|
|
1566
|
+
|
|
1567
|
+
new_items = []
|
|
1568
|
+
for i in sorted(list(indices)):
|
|
1569
|
+
item_prefix = f"{prefix}{i}-"
|
|
1570
|
+
new_item = {}
|
|
1571
|
+
has_data = False
|
|
1572
|
+
for sub_field, sub_type in list_cls_schema.items():
|
|
1573
|
+
if (
|
|
1574
|
+
sub_type.field_type == "pk"
|
|
1575
|
+
or sub_type.readonly
|
|
1576
|
+
or not sub_type.visible_form
|
|
1577
|
+
):
|
|
1578
|
+
continue
|
|
1579
|
+
|
|
1580
|
+
form_key = item_prefix + sub_field
|
|
1581
|
+
if sub_type.field_type == "bool":
|
|
1582
|
+
if form_key in request.form:
|
|
1583
|
+
new_item[sub_field] = True
|
|
1584
|
+
has_data = True
|
|
1585
|
+
else:
|
|
1586
|
+
new_item[sub_field] = False
|
|
1587
|
+
elif sub_type.field_type == "set":
|
|
1588
|
+
values = request.form.getlist(form_key)
|
|
1589
|
+
if values:
|
|
1590
|
+
new_item[sub_field] = set(values)
|
|
1591
|
+
has_data = True
|
|
1592
|
+
elif form_key in request.form and request.form[form_key]:
|
|
1593
|
+
new_item[sub_field] = request.form[form_key]
|
|
1594
|
+
has_data = True
|
|
1595
|
+
|
|
1596
|
+
if has_data:
|
|
1597
|
+
new_items.append(new_item)
|
|
1598
|
+
|
|
1599
|
+
if new_items:
|
|
1600
|
+
if "new" not in data["list_items"][field]:
|
|
1601
|
+
data["list_items"][field]["new"] = []
|
|
1602
|
+
data["list_items"][field]["new"].extend(new_items)
|
|
1603
|
+
|
|
1604
|
+
errors = validate_form_data(
|
|
1605
|
+
ds, form_fields, data, row=FormData(data, rec), partial=True
|
|
1606
|
+
)
|
|
1607
|
+
if errors:
|
|
1608
|
+
return render_template(
|
|
1609
|
+
self._form_template("edit.html", self.edit_template),
|
|
1610
|
+
construct=self.construct,
|
|
1611
|
+
view=self,
|
|
1612
|
+
data=FormData(data, rec),
|
|
1613
|
+
ds=ds,
|
|
1614
|
+
errors=errors,
|
|
1615
|
+
fields=self.get_form_fields(ds, rec),
|
|
1616
|
+
primary_key=ds._primary_key,
|
|
1617
|
+
tab_actions=self.tab_actions,
|
|
1618
|
+
edit_actions=self.edit_actions,
|
|
1619
|
+
form_scripts=self.form_scripts(ds, FormData(data, rec)),
|
|
1620
|
+
), 400
|
|
1621
|
+
|
|
1622
|
+
try:
|
|
1623
|
+
ds.update(id, data)
|
|
1624
|
+
flash("crud-item-updated", "success")
|
|
1625
|
+
except Exception:
|
|
1626
|
+
if current_app.debug:
|
|
1627
|
+
raise
|
|
1628
|
+
flash("crud-error-updating-item", "danger")
|
|
1629
|
+
return redirect(self.get_success_url("update", rec))
|
|
1630
|
+
|
|
1631
|
+
@expose("/<int:id>/delete", methods=["POST"])
|
|
1632
|
+
def delete(self, id):
|
|
1633
|
+
ds = self.dataclass()
|
|
1634
|
+
try:
|
|
1635
|
+
ds.delete(id)
|
|
1636
|
+
flash("crud-item-deleted", "success")
|
|
1637
|
+
except Exception:
|
|
1638
|
+
if current_app.debug:
|
|
1639
|
+
raise
|
|
1640
|
+
flash("crud-error-deleting-item", "danger")
|
|
1641
|
+
return redirect(self.get_success_url("delete"))
|