flask-construct-ui 0.10.2__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/PKG-INFO +1 -1
  2. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/__init__.py +2 -0
  3. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/auth.py +1 -4
  4. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/base.py +249 -54
  5. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/components.py +523 -46
  6. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/crud.py +21 -18
  7. flask_construct_ui-0.11.0/flask_construct_ui/data.py +495 -0
  8. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/data_sqlalchemy.py +127 -42
  9. flask_construct_ui-0.11.0/flask_construct_ui/grid.py +819 -0
  10. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/headless.py +21 -4
  11. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/icons.py +3 -3
  12. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/l10n/en/main.flt +27 -0
  13. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/l10n/sv/main.flt +27 -0
  14. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/orgmode.py +7 -2
  15. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/schema.py +101 -4
  16. flask_construct_ui-0.11.0/flask_construct_ui/static/css/grid.css +287 -0
  17. flask_construct_ui-0.11.0/flask_construct_ui/static/js/construct-core.min.js +1 -0
  18. flask_construct_ui-0.11.0/flask_construct_ui/static/js/construct-grid.min.js +1 -0
  19. flask_construct_ui-0.11.0/flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
  20. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/form.html +4 -4
  21. flask_construct_ui-0.11.0/flask_construct_ui/templates/components/grid.html +208 -0
  22. flask_construct_ui-0.11.0/flask_construct_ui/templates/components/grid_notice.html +7 -0
  23. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/live.html +9 -0
  24. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/create.html +1 -1
  25. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/edit.html +1 -1
  26. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/related_crud_tab_form.html +2 -2
  27. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/related_crud_tab_list.html +1 -1
  28. flask_construct_ui-0.11.0/flask_construct_ui/templates/structure/base.html +4 -0
  29. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/style/base_full.html +7 -5
  30. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/style/base_partial.html +2 -2
  31. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui.egg-info/PKG-INFO +1 -1
  32. flask_construct_ui-0.11.0/flask_construct_ui.egg-info/SOURCES.txt +86 -0
  33. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/pyproject.toml +1 -1
  34. flask_construct_ui-0.10.2/flask_construct_ui/data.py +0 -264
  35. flask_construct_ui-0.10.2/flask_construct_ui/static/js/construct-core.min.js +0 -1
  36. flask_construct_ui-0.10.2/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -34
  37. flask_construct_ui-0.10.2/flask_construct_ui/templates/structure/base.html +0 -5
  38. flask_construct_ui-0.10.2/flask_construct_ui/templates/structure/flashes.html +0 -7
  39. flask_construct_ui-0.10.2/flask_construct_ui.egg-info/SOURCES.txt +0 -82
  40. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/LICENSE +0 -0
  41. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/README.md +0 -0
  42. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/csrf.py +0 -0
  43. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/forms.py +0 -0
  44. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/language.py +0 -0
  45. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/navbar.py +0 -0
  46. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/navigation.py +0 -0
  47. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/pagination.py +0 -0
  48. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/py.typed +0 -0
  49. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/sanitize.py +0 -0
  50. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  51. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  52. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/css/whitelabel.css +0 -0
  53. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/designs/adminlte.css +0 -0
  54. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/designs/sandstone.css +0 -0
  55. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/bootstrap-icons/LICENSE.txt +0 -0
  56. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
  57. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  58. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  59. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/material-symbols/LICENSE.txt +0 -0
  60. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  61. {flask_construct_ui-0.10.2/flask_construct_ui/static/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/static}/fonts/material-symbols/material-symbols.css +0 -0
  62. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  63. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  64. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/_form_field.html +0 -0
  65. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/button.html +0 -0
  66. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/calendar.html +0 -0
  67. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/calendarmini.html +0 -0
  68. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/card.html +0 -0
  69. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/chart.html +0 -0
  70. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/grid_layout.html +0 -0
  71. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/horizontal_layout.html +0 -0
  72. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/kanban.html +0 -0
  73. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/main.html +0 -0
  74. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/markup.html +0 -0
  75. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/rich_text.html +0 -0
  76. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/table.html +0 -0
  77. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/tabs.html +0 -0
  78. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/text.html +0 -0
  79. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/components/vertical_layout.html +0 -0
  80. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/_macros.html +0 -0
  81. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/_many_related_options.html +0 -0
  82. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/_related_options.html +0 -0
  83. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/listindex.html +0 -0
  84. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/crud/tab_view.html +0 -0
  85. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/errors/forbidden.html +0 -0
  86. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/navbar/notifications.html +0 -0
  87. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/navbar/search_results.html +0 -0
  88. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/templates/structure/public.html +0 -0
  89. {flask_construct_ui-0.10.2/flask_construct_ui/templates/themes/whitelabel → flask_construct_ui-0.11.0/flask_construct_ui/templates}/style/_flashes.html +0 -0
  90. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui/validation.py +0 -0
  91. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  92. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui.egg-info/requires.txt +0 -0
  93. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/flask_construct_ui.egg-info/top_level.txt +0 -0
  94. {flask_construct_ui-0.10.2 → flask_construct_ui-0.11.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.10.2
3
+ Version: 0.11.0
4
4
  Summary: A UI toolkit for Flask with CRUD and component features.
5
5
  Author-email: Olof Tjerngren <olof@tjerngren.net>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -8,6 +8,7 @@ from .base import tab_action as tab_action
8
8
  from .base import edit_action as edit_action
9
9
  from .base import datasource as datasource
10
10
  from .base import livesource as livesource
11
+ from .base import gridsource as gridsource
11
12
  from .base import SSE_DONE as SSE_DONE
12
13
 
13
14
  # Component-based pages and the component library. Exposed at the package root
@@ -36,6 +37,7 @@ from .components import (
36
37
  RichTextComponent as RichTextComponent,
37
38
  ButtonComponent as ButtonComponent,
38
39
  TableComponent as TableComponent,
40
+ GridComponent as GridComponent,
39
41
  CalendarComponent as CalendarComponent,
40
42
  KanbanComponent as KanbanComponent,
41
43
  KanbanColumn as KanbanColumn,
@@ -216,10 +216,7 @@ class AuthProvider:
216
216
  def on_forbidden(self):
217
217
  """Default: render the themed access-denied page with HTTP 403."""
218
218
  construct = getattr(g, "construct", None)
219
- theme = getattr(construct, "theme", None) or "whitelabel"
220
- return render_template(
221
- f"themes/{theme}/errors/forbidden.html", construct=construct
222
- ), 403
219
+ return render_template("errors/forbidden.html", construct=construct), 403
223
220
 
224
221
 
225
222
  class FlaskSecurityAuth(AuthProvider):
@@ -1,59 +1,58 @@
1
1
  from __future__ import annotations
2
+
3
+ from functools import wraps
4
+ from importlib.metadata import entry_points
2
5
  import inspect
3
6
  import json
4
- from os import path
5
- from functools import wraps
6
7
  import logging
7
- from importlib.metadata import entry_points
8
-
9
- logger = logging.getLogger("flask_construct_ui")
8
+ from os import path
9
+ from typing import Callable, Optional
10
10
 
11
11
  from flask import (
12
- Flask,
13
12
  Blueprint,
13
+ Flask,
14
+ Response,
15
+ current_app,
16
+ g,
17
+ jsonify,
14
18
  render_template,
15
- send_from_directory,
16
19
  request,
17
- url_for,
18
- current_app,
19
- Response,
20
+ send_from_directory,
20
21
  stream_with_context,
21
- g,
22
+ url_for,
22
23
  )
24
+ import jinja2
23
25
  from markupsafe import Markup
24
- from .language import Translator, set_language_from_browser
25
- from typing import Callable, Optional
26
+
27
+ from .auth import AUTH_STAMP_HEADER, auth_stamp
26
28
  from .components import (
29
+ CalendarComponent,
30
+ ChartComponent,
27
31
  Component,
28
- components,
29
32
  Field,
33
+ FormComponent,
30
34
  LiveComponent,
31
- ChartComponent,
32
- CalendarComponent,
33
35
  TabsComponent,
34
- FormComponent,
36
+ components,
35
37
  jinja_env,
36
38
  )
37
- from .sanitize import sanitize_html
38
- from .headless import live_container, live_attributes
39
- from .navigation import resolve_next
40
- from .icons import render_icon
41
-
42
-
43
39
  from .csrf import (
44
- CSRF_SESSION_KEY,
45
- CSRF_FORM_FIELD,
46
- CSRF_HEADER,
47
40
  CSRF_UNSAFE_METHODS,
48
- generate_csrf_token,
49
41
  csrf_field,
42
+ generate_csrf_token,
50
43
  validate_csrf_token,
51
44
  )
45
+ from .headless import live_attributes, live_container
46
+ from .icons import render_icon
47
+ from .language import Translator, set_language_from_browser
48
+ from .navigation import resolve_next
52
49
  from .pagination import (
53
50
  DEFAULT_PAGE_SIZE,
54
51
  MAX_PAGE_SIZE,
55
52
  )
56
- from .auth import AUTH_STAMP_HEADER, auth_stamp
53
+ from .sanitize import sanitize_html
54
+
55
+ logger = logging.getLogger("flask_construct_ui")
57
56
 
58
57
 
59
58
  def list_action(name, icon):
@@ -113,6 +112,24 @@ def datasource(name=None):
113
112
  SSE_DONE = "done"
114
113
 
115
114
 
115
+ def apply_trailing_slash(url, construct):
116
+ """Append a trailing slash to a generated route when the construct asks for it.
117
+
118
+ Left alone: the root, urls that already end in a slash, and last segments
119
+ that look like a filename — a static-file route must stay slash-free.
120
+ """
121
+ if not getattr(construct, "enforce_trailing_slash", False):
122
+ return url
123
+ if not url or url == "/" or url.endswith("/"):
124
+ return url
125
+ if not url.startswith("/"):
126
+ # An absolute or external link (a menu entry pointing off-site).
127
+ return url
128
+ if "." in url.rsplit("/", 1)[-1]:
129
+ return url
130
+ return url + "/"
131
+
132
+
116
133
  def livesource(name=None, interval=10):
117
134
  def wrap(f):
118
135
  f._livesource_name = name or f.__name__
@@ -122,6 +139,25 @@ def livesource(name=None, interval=10):
122
139
  return wrap
123
140
 
124
141
 
142
+ def gridsource(name=None):
143
+ """Declare the datasource behind a :class:`GridComponent`.
144
+
145
+ The decorated method returns a :class:`~flask_construct_ui.data.DataBase`
146
+ instance; the page then registers the grid's row endpoints on its own
147
+ blueprint, so a component naming ``grid_source="tasks"`` needs no routes of
148
+ its own. An app that must own the routes (to apply its own authorization,
149
+ or to re-derive the configuration from somewhere the client cannot reach)
150
+ passes ``dataclass=``/``endpoint=`` to the component instead and drives a
151
+ :class:`~flask_construct_ui.grid.GridController` itself.
152
+ """
153
+
154
+ def wrap(f):
155
+ f._gridsource_name = name or f.__name__
156
+ return f
157
+
158
+ return wrap
159
+
160
+
125
161
  # Base views
126
162
  def _wrap_view(f):
127
163
  # Avoid wrapping view method twice
@@ -151,6 +187,7 @@ class PageMeta(type):
151
187
  cls.edit_actions = []
152
188
  cls._datasources = {}
153
189
  cls._livesources = {}
190
+ cls._gridsources = {}
154
191
  cls._auth_rules = {}
155
192
 
156
193
  for p in dir(cls):
@@ -162,6 +199,9 @@ class PageMeta(type):
162
199
  if hasattr(attr, "_livesource_name"):
163
200
  cls._livesources[attr._livesource_name] = (p, attr._livesource_interval)
164
201
 
202
+ if hasattr(attr, "_gridsource_name"):
203
+ cls._gridsources[attr._gridsource_name] = p
204
+
165
205
  if hasattr(attr, "_construct_auth"):
166
206
  cls._auth_rules[p] = attr._construct_auth
167
207
 
@@ -256,6 +296,10 @@ class PageBase(metaclass=PageMeta):
256
296
  for name, (method_name, interval) in self._livesources.items():
257
297
  self.livesources[name] = (getattr(self, method_name), interval)
258
298
 
299
+ self.gridsources = {}
300
+ for name, method_name in self._gridsources.items():
301
+ self.gridsources[name] = getattr(self, method_name)
302
+
259
303
  def t(self, msg_id, args=None):
260
304
  return self.construct.t(msg_id, args)
261
305
 
@@ -318,10 +362,7 @@ class PageBase(metaclass=PageMeta):
318
362
  if getattr(construct, "strict_slashes", None) is not None:
319
363
  kwargs["strict_slashes"] = construct.strict_slashes
320
364
 
321
- if getattr(construct, "enforce_trailing_slash", False):
322
- if url != "/" and url != "" and not url.endswith("/"):
323
- if "." not in url.split("/")[-1]:
324
- url = url + "/"
365
+ url = apply_trailing_slash(url, construct)
325
366
 
326
367
  self.blueprint.add_url_rule(url, name, getattr(self, name), **kwargs)
327
368
 
@@ -339,8 +380,143 @@ class ComponentPageBase(PageBase):
339
380
  def get_blueprint(self, construct):
340
381
  blueprint = super().get_blueprint(construct)
341
382
  self._register_live_endpoints(blueprint, construct)
383
+ self._register_grid_endpoints(blueprint, construct)
342
384
  return blueprint
343
385
 
386
+ # -- grids ------------------------------------------------------------
387
+
388
+ def grid_can_write(self, source_name):
389
+ """Whether the current user may write through grid *source_name*.
390
+
391
+ The page's own ``login_required``/``required_groups`` already gate
392
+ every route on the blueprint, so this is the place for a narrower
393
+ rule — read for everyone, write for some. It is asked by the
394
+ endpoints, not only by the render, because a hidden button is not a
395
+ guard.
396
+ """
397
+ return True
398
+
399
+ def _grid_component(self, source_name):
400
+ """The grid naming *source_name*, located in a fresh ``definition()``.
401
+
402
+ Rebuilt per request rather than cached: the spec is the page's own
403
+ declaration and a client must never be able to hand one in, so the
404
+ only trustworthy copy is the one the page just built.
405
+ """
406
+ from .components import GridComponent
407
+
408
+ root = self.definition()
409
+ return self.find_component(
410
+ root,
411
+ lambda c: isinstance(c, GridComponent) and c.grid_source == source_name,
412
+ )
413
+
414
+ def _grid_controller(self, source_name):
415
+ """``(controller, error_response)`` for one grid request."""
416
+ from .grid import GridController, GridError
417
+
418
+ component = self._grid_component(source_name)
419
+ if component is None or source_name not in self.gridsources:
420
+ return None, ({"error": "not_found", "message": "No such grid"}, 404)
421
+ ds = self.gridsources[source_name]()
422
+ component.resolved_datasource = ds
423
+ try:
424
+ # apply_request=False: sort and column filters are the reader's
425
+ # display choices. Letting them reach a write would hand the
426
+ # client the filters that decide which rows it may touch.
427
+ spec = component.spec(ds, apply_request=False)
428
+ except GridError as error:
429
+ return None, ({"error": "bad_config", "message": error.fallback}, 500)
430
+ return (
431
+ GridController(
432
+ ds,
433
+ spec,
434
+ grid_id=component.state_id,
435
+ can_write=self.grid_can_write(source_name),
436
+ ),
437
+ None,
438
+ )
439
+
440
+ def _register_grid_endpoints(self, blueprint, construct):
441
+ """Row endpoints for every ``@gridsource`` on the page.
442
+
443
+ Three rules, mirroring the live endpoints: one JSON collection URL
444
+ (window + insert), one JSON member URL (patch + delete) and one form
445
+ URL for the JavaScript-free path. The form URL is a separate rule
446
+ because it answers with a redirect rather than JSON — a browser
447
+ submitting a plain form has to land on a page.
448
+ """
449
+ rule_kwargs = {}
450
+ if getattr(construct, "strict_slashes", None) is not None:
451
+ rule_kwargs["strict_slashes"] = construct.strict_slashes
452
+
453
+ def make_json_view(sname, with_row):
454
+ def view(row_id=None):
455
+ controller, failure = self._grid_controller(sname)
456
+ if failure is not None:
457
+ return jsonify(failure[0]), failure[1]
458
+ return controller.handle_json(row_id)
459
+
460
+ view.__name__ = f"grid_{'row' if with_row else 'rows'}_{sname}"
461
+ return view
462
+
463
+ def make_form_view(sname):
464
+ def view():
465
+ controller, failure = self._grid_controller(sname)
466
+ if failure is not None:
467
+ return jsonify(failure[0]), failure[1]
468
+ return controller.handle_form()
469
+
470
+ view.__name__ = f"grid_form_{sname}"
471
+ return view
472
+
473
+ for source_name in self.gridsources:
474
+ base = f"/grid/{source_name}/rows"
475
+ blueprint.add_url_rule(
476
+ base,
477
+ f"grid_rows_{source_name}",
478
+ make_json_view(source_name, False),
479
+ methods=["GET", "POST"],
480
+ **rule_kwargs,
481
+ )
482
+ blueprint.add_url_rule(
483
+ f"{base}/<row_id>",
484
+ f"grid_row_{source_name}",
485
+ make_json_view(source_name, True),
486
+ methods=["PATCH", "DELETE"],
487
+ **rule_kwargs,
488
+ )
489
+ blueprint.add_url_rule(
490
+ f"{base}/form",
491
+ f"grid_form_{source_name}",
492
+ make_form_view(source_name),
493
+ methods=["POST"],
494
+ **rule_kwargs,
495
+ )
496
+
497
+ def _resolve_gridsources(self, component: Component):
498
+ """Attach each grid's datasource and endpoint before it renders."""
499
+ from .components import GridComponent
500
+
501
+ if isinstance(component, GridComponent) and component.grid_source:
502
+ source_name = component.grid_source
503
+ if source_name in self.gridsources:
504
+ component.resolved_datasource = self.gridsources[source_name]()
505
+ if not component.endpoint:
506
+ component.endpoint = url_for(
507
+ f"{self.endpoint}.grid_rows_{source_name}"
508
+ )
509
+ if not component.form_endpoint:
510
+ component.form_endpoint = url_for(
511
+ f"{self.endpoint}.grid_form_{source_name}"
512
+ )
513
+ component.can_write = component.can_write and self.grid_can_write(
514
+ source_name
515
+ )
516
+
517
+ for child in component.get_children():
518
+ self._resolve_gridsources(child)
519
+
344
520
  def _register_live_endpoints(self, blueprint, construct):
345
521
 
346
522
  for source_name, (method, interval) in self.livesources.items():
@@ -368,7 +544,15 @@ class ComponentPageBase(PageBase):
368
544
  if inspect.isgenerator(result):
369
545
  for item in result:
370
546
  yield f"event: update\ndata: {json.dumps(item)}\n\n"
371
- yield f"event: {SSE_DONE}\ndata: {json.dumps({'status': SSE_DONE})}\n\n"
547
+ # `auto` tells the client this done was appended by
548
+ # the library because the generator ran dry, not
549
+ # yielded by the app to signal a finished job. The
550
+ # client must not navigate on it: a finite generator
551
+ # would otherwise reload its own page forever.
552
+ yield (
553
+ f"event: {SSE_DONE}\n"
554
+ f"data: {json.dumps({'status': SSE_DONE, 'auto': True})}\n\n"
555
+ )
372
556
  else:
373
557
  yield f"event: update\ndata: {json.dumps(result)}\n\n"
374
558
 
@@ -520,6 +704,7 @@ class ComponentPageBase(PageBase):
520
704
  root_component = self.definition()
521
705
  self._resolve_datasources(root_component)
522
706
  self._resolve_livesources(root_component)
707
+ self._resolve_gridsources(root_component)
523
708
  self._set_active_tabs(root_component)
524
709
  return self.render_component(root_component)
525
710
 
@@ -550,7 +735,7 @@ class ComponentPageBase(PageBase):
550
735
 
551
736
  def render_component(self, component: Component):
552
737
  return render_template(
553
- f"themes/{self.construct.theme}/components/main.html",
738
+ "components/main.html",
554
739
  component=component,
555
740
  construct=self.construct,
556
741
  )
@@ -669,7 +854,7 @@ class FlaskConstructUI:
669
854
  discover_extensions=True,
670
855
  strict_slashes=None,
671
856
  check_trailing_slashes=False,
672
- enforce_trailing_slash=False,
857
+ enforce_trailing_slash=True,
673
858
  ):
674
859
  self._pages = []
675
860
  self._menu = []
@@ -690,16 +875,14 @@ class FlaskConstructUI:
690
875
  self._auth = auth
691
876
  self._search = search
692
877
  self._notifications = notifications
693
- # The only structural theme is `whitelabel`. When no theme is given we
694
- # default to it wearing the `adminlte` design, which reproduces the look
695
- # of the former `adminlte3` theme (the previous default) for apps that
696
- # never set a theme. An app that *explicitly* selects `whitelabel`
697
- # without a design gets the plain whitelabel look — so the design
698
- # default only fills in for the no-theme case.
699
- if theme is None:
700
- theme = "whitelabel"
701
- if design is None:
702
- design = "adminlte"
878
+ # The only structural theme is `whitelabel`; `theme=` is still accepted
879
+ # for backward compatibility but no longer selects templates or assets.
880
+ # Whenever no design is given we fill in `adminlte`, which reproduces
881
+ # the look of the former `adminlte3` theme (the previous default). Pass
882
+ # a falsy design (e.g. `design=""`) for the plain whitelabel look.
883
+ theme = theme or "whitelabel"
884
+ if design is None:
885
+ design = "adminlte"
703
886
  self.theme = theme
704
887
  # `design` selects a built-in skin layered on top of the structural
705
888
  # theme's stylesheet (e.g. "adminlte" or "sandstone"). It does not
@@ -891,6 +1074,13 @@ class FlaskConstructUI:
891
1074
  t=translate,
892
1075
  icon=render_icon,
893
1076
  )
1077
+ if app.jinja_env.loader:
1078
+ jinja_env.loader = jinja2.ChoiceLoader(
1079
+ [
1080
+ app.jinja_env.loader,
1081
+ jinja2.PackageLoader("flask_construct_ui", "templates"),
1082
+ ]
1083
+ )
894
1084
 
895
1085
  self.app = app
896
1086
  app.add_url_rule(
@@ -945,7 +1135,7 @@ class FlaskConstructUI:
945
1135
  q = request.args.get("q", "")
946
1136
  results = _search.search(q)
947
1137
  return render_template(
948
- f"themes/{g.construct_theme}/navbar/search_results.html",
1138
+ "navbar/search_results.html",
949
1139
  construct=_self,
950
1140
  results=results,
951
1141
  query=q,
@@ -958,7 +1148,7 @@ class FlaskConstructUI:
958
1148
  # Just the #fcu-notifications fragment; Unpoly's up-poll
959
1149
  # reloads it on a timer to keep the count + items fresh.
960
1150
  return render_template(
961
- f"themes/{g.construct_theme}/navbar/notifications.html",
1151
+ "navbar/notifications.html",
962
1152
  construct=_self,
963
1153
  )
964
1154
 
@@ -1098,6 +1288,10 @@ class FlaskConstructUI:
1098
1288
  )
1099
1289
  + page.menu["link"]
1100
1290
  )
1291
+ # Menu links are assembled by hand rather than via url_for, so
1292
+ # they need the same normalization the route patterns get —
1293
+ # otherwise every sidebar entry 308-redirects to its own page.
1294
+ menu["link"] = apply_trailing_slash(menu["link"], self)
1101
1295
  if submenu is not None:
1102
1296
  if submenu_icon is None:
1103
1297
  self._add_to_submenu(submenu, menu)
@@ -1209,14 +1403,16 @@ class FlaskConstructUI:
1209
1403
  def search_url(self):
1210
1404
  if self._search is None:
1211
1405
  return None
1212
- return self.url.rstrip("/") + "/_construct/search"
1406
+ return apply_trailing_slash(self.url.rstrip("/") + "/_construct/search", self)
1213
1407
 
1214
1408
  @property
1215
1409
  def notifications_url(self):
1216
1410
  """URL of the notifications fragment endpoint (for Unpoly polling)."""
1217
1411
  if self._notifications is None:
1218
1412
  return None
1219
- return self.url.rstrip("/") + "/_construct/notifications"
1413
+ return apply_trailing_slash(
1414
+ self.url.rstrip("/") + "/_construct/notifications", self
1415
+ )
1220
1416
 
1221
1417
  @property
1222
1418
  def menu(self):
@@ -1247,7 +1443,7 @@ class FlaskConstructUI:
1247
1443
  """
1248
1444
  from .icons import resolve_icon_mode
1249
1445
 
1250
- return resolve_icon_mode(self.theme, self.design)
1446
+ return resolve_icon_mode("whitelabel", self.design)
1251
1447
 
1252
1448
  @property
1253
1449
  def color_scheme(self):
@@ -1268,13 +1464,12 @@ class FlaskConstructUI:
1268
1464
  def design_stylesheet(self):
1269
1465
  """Static path of the active design stylesheet, or None.
1270
1466
 
1271
- Designs are shipped per structural theme under
1272
- ``static/themes/<theme>/designs/<design>.css``. Returns None when no
1273
- design is selected or the file does not exist on disk.
1467
+ Designs are shipped under ``static/designs/<design>.css``. Returns None
1468
+ when no design is selected or the file does not exist on disk.
1274
1469
  """
1275
1470
  if not self.design:
1276
1471
  return None
1277
- rel = path.join("themes", self.theme, "designs", f"{self.design}.css")
1472
+ rel = path.join("designs", f"{self.design}.css")
1278
1473
  if not path.exists(path.join(self.static_folder, rel)):
1279
1474
  return None
1280
1475
  return rel.replace(path.sep, "/")