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.
Files changed (80) hide show
  1. flask_construct_ui/__init__.py +82 -0
  2. flask_construct_ui/auth.py +290 -0
  3. flask_construct_ui/base.py +1259 -0
  4. flask_construct_ui/components.py +812 -0
  5. flask_construct_ui/crud.py +1641 -0
  6. flask_construct_ui/csrf.py +39 -0
  7. flask_construct_ui/data.py +257 -0
  8. flask_construct_ui/data_sqlalchemy.py +304 -0
  9. flask_construct_ui/forms.py +130 -0
  10. flask_construct_ui/headless.py +110 -0
  11. flask_construct_ui/icons.py +158 -0
  12. flask_construct_ui/l10n/en/main.flt +82 -0
  13. flask_construct_ui/l10n/sv/main.flt +82 -0
  14. flask_construct_ui/language.py +100 -0
  15. flask_construct_ui/navbar.py +54 -0
  16. flask_construct_ui/navigation.py +58 -0
  17. flask_construct_ui/orgmode.py +338 -0
  18. flask_construct_ui/pagination.py +39 -0
  19. flask_construct_ui/py.typed +0 -0
  20. flask_construct_ui/sanitize.py +25 -0
  21. flask_construct_ui/schema.py +507 -0
  22. flask_construct_ui/static/css/prosemirror-bundle.min.css +1 -0
  23. flask_construct_ui/static/css/unpoly.min.css +6 -0
  24. flask_construct_ui/static/js/construct-core.min.js +1 -0
  25. flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
  26. flask_construct_ui/static/js/unpoly.min.js +1 -0
  27. flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +720 -0
  28. flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +94 -0
  29. flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +64 -0
  30. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +21 -0
  31. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +5 -0
  32. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  33. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  34. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +202 -0
  35. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  36. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +26 -0
  37. flask_construct_ui/templates/structure/base.html +5 -0
  38. flask_construct_ui/templates/structure/flashes.html +7 -0
  39. flask_construct_ui/templates/structure/public.html +18 -0
  40. flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +69 -0
  41. flask_construct_ui/templates/themes/whitelabel/components/button.html +16 -0
  42. flask_construct_ui/templates/themes/whitelabel/components/calendar.html +30 -0
  43. flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +30 -0
  44. flask_construct_ui/templates/themes/whitelabel/components/card.html +25 -0
  45. flask_construct_ui/templates/themes/whitelabel/components/chart.html +5 -0
  46. flask_construct_ui/templates/themes/whitelabel/components/form.html +61 -0
  47. flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +5 -0
  48. flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +5 -0
  49. flask_construct_ui/templates/themes/whitelabel/components/live.html +40 -0
  50. flask_construct_ui/templates/themes/whitelabel/components/main.html +4 -0
  51. flask_construct_ui/templates/themes/whitelabel/components/markup.html +1 -0
  52. flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +19 -0
  53. flask_construct_ui/templates/themes/whitelabel/components/table.html +30 -0
  54. flask_construct_ui/templates/themes/whitelabel/components/tabs.html +30 -0
  55. flask_construct_ui/templates/themes/whitelabel/components/text.html +26 -0
  56. flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +5 -0
  57. flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +962 -0
  58. flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +26 -0
  59. flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +98 -0
  60. flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +136 -0
  61. flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +15 -0
  62. flask_construct_ui/templates/themes/whitelabel/crud/create.html +45 -0
  63. flask_construct_ui/templates/themes/whitelabel/crud/edit.html +125 -0
  64. flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +238 -0
  65. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +33 -0
  66. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +125 -0
  67. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +140 -0
  68. flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +38 -0
  69. flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +16 -0
  70. flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +26 -0
  71. flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +31 -0
  72. flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +16 -0
  73. flask_construct_ui/templates/themes/whitelabel/style/base_full.html +181 -0
  74. flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +16 -0
  75. flask_construct_ui/validation.py +422 -0
  76. flask_construct_ui-0.9.5.dist-info/METADATA +60 -0
  77. flask_construct_ui-0.9.5.dist-info/RECORD +80 -0
  78. flask_construct_ui-0.9.5.dist-info/WHEEL +5 -0
  79. flask_construct_ui-0.9.5.dist-info/licenses/LICENSE +165 -0
  80. 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"))