flask-construct-ui 0.11.6__tar.gz → 0.12.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 (129) hide show
  1. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/PKG-INFO +1 -1
  2. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/__init__.py +18 -0
  3. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/app_hooks.py +74 -4
  4. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/auth.py +5 -5
  5. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/base.py +147 -19
  6. flask_construct_ui-0.12.0/flask_construct_ui/capabilities.py +130 -0
  7. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/cli.py +5 -1
  8. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/__init__.py +17 -0
  9. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/calendar.py +25 -30
  10. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/chart.py +100 -10
  11. flask_construct_ui-0.12.0/flask_construct_ui/components/chart_native.py +362 -0
  12. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/content.py +52 -12
  13. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/core.py +30 -1
  14. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/grid.py +12 -7
  15. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/layout.py +7 -7
  16. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/live.py +26 -6
  17. flask_construct_ui-0.12.0/flask_construct_ui/components/period.py +185 -0
  18. flask_construct_ui-0.12.0/flask_construct_ui/components/pivot.py +379 -0
  19. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/contrast.py +30 -4
  20. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/crud.py +17 -4
  21. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/crud_forms.py +2 -2
  22. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/csrf.py +1 -1
  23. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/data.py +32 -7
  24. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/data_sqlalchemy.py +179 -39
  25. flask_construct_ui-0.12.0/flask_construct_ui/dragdrop.py +109 -0
  26. flask_construct_ui-0.12.0/flask_construct_ui/errors.py +81 -0
  27. flask_construct_ui-0.12.0/flask_construct_ui/flashes.py +43 -0
  28. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/grid.py +32 -9
  29. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/headless.py +4 -3
  30. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/icons.py +153 -4
  31. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/l10n/en/main.flt +47 -0
  32. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/l10n/sv/main.flt +47 -0
  33. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/language.py +10 -4
  34. flask_construct_ui-0.12.0/flask_construct_ui/live.py +255 -0
  35. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/menu.py +7 -0
  36. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/navigation.py +1 -1
  37. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/orgmode.py +5 -4
  38. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/rcrud.py +182 -26
  39. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/schema.py +26 -3
  40. flask_construct_ui-0.12.0/flask_construct_ui/static/css/unpoly.min.css +7 -0
  41. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/css/whitelabel.css +360 -0
  42. flask_construct_ui-0.12.0/flask_construct_ui/static/fonts/material-symbols/names.txt +4264 -0
  43. flask_construct_ui-0.12.0/flask_construct_ui/static/js/construct-core.min.js +2 -0
  44. flask_construct_ui-0.12.0/flask_construct_ui/static/js/construct-grid.min.js +1 -0
  45. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/js/prosemirror-bundle.min.js +1 -1
  46. flask_construct_ui-0.12.0/flask_construct_ui/static/js/unpoly.min.js +1 -0
  47. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/chart.html +2 -2
  48. flask_construct_ui-0.12.0/flask_construct_ui/templates/components/chart_native.html +67 -0
  49. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/live.html +1 -1
  50. flask_construct_ui-0.12.0/flask_construct_ui/templates/components/live_refresh.html +23 -0
  51. flask_construct_ui-0.12.0/flask_construct_ui/templates/components/period_nav.html +61 -0
  52. flask_construct_ui-0.12.0/flask_construct_ui/templates/components/pivot_form.html +99 -0
  53. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/listindex.html +6 -5
  54. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/related_crud_tab_list.html +3 -3
  55. flask_construct_ui-0.12.0/flask_construct_ui/templates/errors/error.html +21 -0
  56. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/errors/forbidden.html +1 -1
  57. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/style/_flashes.html +12 -7
  58. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/style/base_full.html +7 -2
  59. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/style/base_partial.html +8 -4
  60. flask_construct_ui-0.12.0/flask_construct_ui/urls.py +118 -0
  61. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/validation.py +6 -4
  62. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui.egg-info/PKG-INFO +1 -1
  63. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui.egg-info/SOURCES.txt +13 -0
  64. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/pyproject.toml +45 -1
  65. flask_construct_ui-0.11.6/flask_construct_ui/capabilities.py +0 -39
  66. flask_construct_ui-0.11.6/flask_construct_ui/static/css/unpoly.min.css +0 -6
  67. flask_construct_ui-0.11.6/flask_construct_ui/static/js/construct-core.min.js +0 -1
  68. flask_construct_ui-0.11.6/flask_construct_ui/static/js/construct-grid.min.js +0 -1
  69. flask_construct_ui-0.11.6/flask_construct_ui/static/js/unpoly.min.js +0 -1
  70. flask_construct_ui-0.11.6/flask_construct_ui/urls.py +0 -19
  71. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/LICENSE +0 -0
  72. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/README.md +0 -0
  73. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/components/kanban.py +0 -0
  74. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/forms.py +0 -0
  75. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/layout.py +0 -0
  76. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/navbar.py +0 -0
  77. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/pagination.py +0 -0
  78. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/py.typed +0 -0
  79. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/resume.py +0 -0
  80. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/sanitize.py +0 -0
  81. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/css/grid.css +0 -0
  82. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  83. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/designs/adminlte.css +0 -0
  84. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/designs/sandstone.css +0 -0
  85. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
  86. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
  87. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  88. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  89. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
  90. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  91. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
  92. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  93. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/_form_field.html +0 -0
  94. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/button.html +0 -0
  95. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/calendar.html +0 -0
  96. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/calendarmini.html +0 -0
  97. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/card.html +0 -0
  98. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/form.html +0 -0
  99. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/grid.html +0 -0
  100. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/grid_layout.html +0 -0
  101. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/grid_notice.html +0 -0
  102. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
  103. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/kanban.html +0 -0
  104. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/main.html +0 -0
  105. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/markup.html +0 -0
  106. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/rich_text.html +0 -0
  107. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/table.html +0 -0
  108. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/tabs.html +0 -0
  109. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/text.html +0 -0
  110. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
  111. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/_macros.html +0 -0
  112. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
  113. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/_related_options.html +0 -0
  114. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/create.html +0 -0
  115. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/edit.html +0 -0
  116. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
  117. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/tab_view.html +0 -0
  118. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/crud/view.html +0 -0
  119. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/navbar/notifications.html +0 -0
  120. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/navbar/search_results.html +0 -0
  121. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/structure/base.html +0 -0
  122. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/structure/public.html +0 -0
  123. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/style/_footer.html +0 -0
  124. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/templates/style/_menu.html +0 -0
  125. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui/testing.py +0 -0
  126. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  127. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui.egg-info/requires.txt +0 -0
  128. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/flask_construct_ui.egg-info/top_level.txt +0 -0
  129. {flask_construct_ui-0.11.6 → flask_construct_ui-0.12.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.11.6
3
+ Version: 0.12.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
@@ -27,6 +27,7 @@ from .components import (
27
27
  find_component as find_component,
28
28
  find_component_by_id as find_component_by_id,
29
29
  LiveComponent as LiveComponent,
30
+ LiveRefreshComponent as LiveRefreshComponent,
30
31
  VerticalLayoutComponent as VerticalLayoutComponent,
31
32
  HorizontalLayoutComponent as HorizontalLayoutComponent,
32
33
  GridLayoutComponent as GridLayoutComponent,
@@ -45,6 +46,11 @@ from .components import (
45
46
  KanbanColumn as KanbanColumn,
46
47
  KanbanCard as KanbanCard,
47
48
  KanbanCardAction as KanbanCardAction,
49
+ PivotFormComponent as PivotFormComponent,
50
+ PivotRow as PivotRow,
51
+ PivotColumn as PivotColumn,
52
+ PivotCell as PivotCell,
53
+ PeriodNavComponent as PeriodNavComponent,
48
54
  )
49
55
 
50
56
  # Authentication & authorization
@@ -98,6 +104,18 @@ from .headless import (
98
104
  live_container as live_container,
99
105
  )
100
106
 
107
+ # Live refresh on commit (see live.py)
108
+ from .live import (
109
+ ChangeFeed as ChangeFeed,
110
+ MemoryBroker as MemoryBroker,
111
+ RedisBroker as RedisBroker,
112
+ SQLAlchemyChanges as SQLAlchemyChanges,
113
+ )
114
+
115
+ # Declarative drag-and-drop attributes (see dragdrop.py)
116
+ from .dragdrop import drag_attrs as drag_attrs, drop_attrs as drop_attrs
117
+ from .errors import render_error as render_error
118
+
101
119
  # HTML sanitizer (allowlist-based; for trusted-HTML surfaces such as
102
120
  # LiveComponent format="raw" and TextComponent format="html")
103
121
  from .sanitize import sanitize_html as sanitize_html
@@ -6,7 +6,9 @@ decides the order they run in.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from flask import Blueprint, current_app, g, render_template, request
9
+ import logging
10
+
11
+ from flask import Blueprint, current_app, g, jsonify, render_template, request
10
12
  import jinja2
11
13
  from markupsafe import Markup
12
14
 
@@ -18,7 +20,9 @@ from .csrf import (
18
20
  generate_csrf_token,
19
21
  validate_csrf_token,
20
22
  )
21
- from .headless import live_attributes, live_container
23
+ from .flashes import render_flashes
24
+ from .headless import live_attributes, live_container, sse_event, sse_response
25
+ from .live import live_refresh
22
26
  from .icons import render_icon
23
27
  from .navigation import resolve_next
24
28
  from .pagination import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
@@ -32,6 +36,10 @@ from .resume import (
32
36
  resumed_values,
33
37
  )
34
38
  from .sanitize import sanitize_html
39
+ from .dragdrop import drag_attrs, drop_attrs
40
+ from .urls import current_url, hidden_inputs, query_string
41
+
42
+ _log = logging.getLogger(__name__)
35
43
 
36
44
 
37
45
  def set_config_defaults(construct, app):
@@ -59,6 +67,10 @@ def set_config_defaults(construct, app):
59
67
  app.config.setdefault("CONSTRUCT_RESUME_MAX_BYTES", RESUME_MAX_BYTES)
60
68
  app.config.setdefault("CONSTRUCT_RESUME_TTL", RESUME_TTL)
61
69
  app.config.setdefault("CONSTRUCT_RESUME_SKIP_FIELDS", RESUME_SKIP_FIELDS)
70
+ # Seconds a /_construct/live/changes stream waits for a change before
71
+ # ending; the browser reconnects at once. Bounds how long a worker is held
72
+ # by a client that has gone away without a write to notice it by.
73
+ app.config.setdefault("CONSTRUCT_LIVE_WAIT", 25)
62
74
 
63
75
 
64
76
  def install_request_hooks(construct, app):
@@ -171,7 +183,7 @@ def install_template_helpers(construct, app):
171
183
 
172
184
  # MarkdownIt passes raw HTML through, so the converted output must be
173
185
  # sanitized before it is marked safe (stored-XSS boundary).
174
- return Markup(
186
+ return Markup( # noqa: S704
175
187
  sanitize_html(md.render(s))
176
188
  ) # nosemgrep: explicit-unescape-with-markup # nosec B704
177
189
 
@@ -181,7 +193,7 @@ def install_template_helpers(construct, app):
181
193
  return ""
182
194
  from .orgmode import org_to_html
183
195
 
184
- return Markup(
196
+ return Markup( # noqa: S704
185
197
  sanitize_html(org_to_html(s))
186
198
  ) # nosemgrep: explicit-unescape-with-markup # nosec B704
187
199
 
@@ -209,16 +221,28 @@ def install_template_helpers(construct, app):
209
221
  app.jinja_env.globals.update(
210
222
  live_container=live_container,
211
223
  live_attributes=live_attributes,
224
+ live_refresh=live_refresh,
212
225
  csrf_field=csrf_field,
213
226
  csrf_token=generate_csrf_token,
214
227
  t=translate,
215
228
  icon=render_icon,
216
229
  next_url=resolve_next,
230
+ # The flash region, placed where the page wants it; the layout then
231
+ # leaves its own out for this request (see flashes.py).
232
+ flashes=render_flashes,
217
233
  # An app template can put its own form back after a re-login
218
234
  # with no Python change; see resume.py.
219
235
  resumed_value=resumed_value,
220
236
  resumed_values=resumed_values,
221
237
  has_resumable_form=has_resumable_form,
238
+ # Links and GET forms that change one query argument and carry the
239
+ # rest, repeated keys included; see urls.py.
240
+ current_url=current_url,
241
+ query_string=query_string,
242
+ hidden_inputs=hidden_inputs,
243
+ # Drag a thing onto a zone and POST both ends' fields; see dragdrop.py.
244
+ drag_attrs=drag_attrs,
245
+ drop_attrs=drop_attrs,
222
246
  )
223
247
  # Components render through their own standalone Jinja environment,
224
248
  # so the CSRF helpers, translator and icon renderer must be registered
@@ -228,6 +252,12 @@ def install_template_helpers(construct, app):
228
252
  csrf_token=generate_csrf_token,
229
253
  t=translate,
230
254
  icon=render_icon,
255
+ current_url=current_url,
256
+ query_string=query_string,
257
+ hidden_inputs=hidden_inputs,
258
+ drag_attrs=drag_attrs,
259
+ drop_attrs=drop_attrs,
260
+ live_refresh=live_refresh,
231
261
  )
232
262
  if app.jinja_env.loader:
233
263
  jinja_env.loader = jinja2.ChoiceLoader(
@@ -257,11 +287,13 @@ def register_features_blueprint(construct):
257
287
  notifications_url = "/notifications"
258
288
  menu_url = "/menu"
259
289
  footer_url = "/footer"
290
+ live_url = "/live/changes"
260
291
  if construct.enforce_trailing_slash:
261
292
  search_url += "/"
262
293
  notifications_url += "/"
263
294
  menu_url += "/"
264
295
  footer_url += "/"
296
+ live_url += "/"
265
297
 
266
298
  route_kwargs = {}
267
299
  if construct.strict_slashes is not None:
@@ -314,5 +346,43 @@ def register_features_blueprint(construct):
314
346
  footer_fragment=True,
315
347
  )
316
348
 
349
+ @features_bp.route(live_url, **route_kwargs)
350
+ def live_changes():
351
+ # The change feed behind live_refresh(); see live.py. Unconditional
352
+ # for the same reason as the menu route — `construct.live` may be set
353
+ # after init_app — so it 404s while no feed is configured. `since` is
354
+ # the version the page was rendered from; the stream ends with a
355
+ # `change` event as soon as the version differs from it, or quietly
356
+ # after CONSTRUCT_LIVE_WAIT seconds (EventSource reconnects by
357
+ # itself). `?poll=1` answers at once, for browsers that can't stream.
358
+ feed = construct.live
359
+ if feed is None:
360
+ return "", 404
361
+ since = request.args.get("since", "")
362
+ no_store = {"Cache-Control": "no-store"}
363
+ if request.args.get("poll"):
364
+ version = feed.version()
365
+ if version is None:
366
+ return jsonify(error="unavailable"), 503, no_store
367
+ return jsonify(version=version), 200, no_store
368
+ # Fail the request, not the stream, when the broker is down: an
369
+ # EventSource only gives up (and the page falls back to polling and
370
+ # shows "not updating") on a non-200 answer.
371
+ if feed.version() is None:
372
+ return "", 503, no_store
373
+ wait = float(current_app.config.get("CONSTRUCT_LIVE_WAIT", 25))
374
+
375
+ def stream():
376
+ yield "retry: 2000\n\n"
377
+ try:
378
+ version = feed.wait(since, wait)
379
+ except Exception: # noqa: BLE001 - the client reconnects and sees the 503
380
+ _log.warning("live: change feed failed mid-stream", exc_info=True)
381
+ return
382
+ if version != since:
383
+ yield sse_event("change", {"version": version})
384
+
385
+ return sse_response(stream())
386
+
317
387
  construct.app.register_blueprint(features_bp)
318
388
  construct._blueprint_names.add("construct_features")
@@ -124,7 +124,7 @@ class AuthProvider:
124
124
 
125
125
  #: Flask endpoint name (or literal path) to send unauthenticated users to.
126
126
  #: Overridden by the ``CONSTRUCT_LOGIN_ENDPOINT`` config when set.
127
- login_endpoint = None
127
+ login_endpoint: str | None = None
128
128
 
129
129
  def is_authenticated(self) -> bool:
130
130
  raise NotImplementedError
@@ -194,7 +194,7 @@ class AuthProvider:
194
194
  nxt = self.return_target()
195
195
  try:
196
196
  return url_for(target, next=nxt) if nxt else url_for(target)
197
- except Exception:
197
+ except Exception: # noqa: BLE001 - url_for failure means a literal path
198
198
  # Not a known endpoint — treat it as a literal path.
199
199
  if not nxt:
200
200
  return target
@@ -296,7 +296,7 @@ class FlaskSecurityAuth(AuthProvider):
296
296
  "icon": "logout",
297
297
  }
298
298
  ]
299
- except Exception:
299
+ except Exception: # noqa: BLE001 - no sign-out route, no link
300
300
  return []
301
301
 
302
302
 
@@ -418,7 +418,7 @@ class FlaskOIDCAuth(AuthProvider):
418
418
  code would be spent, and the abort that follows is worse than the
419
419
  front page this replaces.
420
420
  """
421
- if request.blueprint == self.login_endpoint.rsplit(".", 1)[0]:
421
+ if request.blueprint == (self.login_endpoint or "").rsplit(".", 1)[0]:
422
422
  nxt = request.args.get("next")
423
423
  if not nxt or not is_safe_next(nxt) or is_fragment_target(nxt):
424
424
  return None
@@ -495,5 +495,5 @@ class FlaskOIDCAuth(AuthProvider):
495
495
  "icon": "logout",
496
496
  }
497
497
  ]
498
- except Exception:
498
+ except Exception: # noqa: BLE001 - no sign-out route, no link
499
499
  return []
@@ -24,6 +24,9 @@ from markupsafe import Markup
24
24
 
25
25
  from . import app_hooks
26
26
  from .auth import auth_stamp
27
+ from .capabilities import UNSET, guard_route, route_capability
28
+ from .errors import check_mapping as check_error_mapping
29
+ from .errors import make_handler as make_error_handler
27
30
  from .components import (
28
31
  CalendarComponent,
29
32
  ChartComponent,
@@ -66,8 +69,25 @@ def edit_action(name, icon):
66
69
  return decorator
67
70
 
68
71
 
69
- def expose(url="/", methods=None):
72
+ def expose(url="/", methods=None, capability=UNSET):
73
+ """Route the decorated page method at *url*.
74
+
75
+ ``capability="<name>"`` asks the page's ``can_<name>`` hook before the
76
+ view runs and answers 403 when it refuses — the route-side half of a
77
+ hidden control. ``"edit"``/``"view"``/``"delete"`` are asked about the
78
+ record whose id is the route's ``<id>``; ``"add"`` and any name of your
79
+ own take no row. ``capability=False`` exempts a writing route from the
80
+ page's ``write_capability``.
81
+ """
82
+ if capability is None:
83
+ raise ValueError(
84
+ "@expose(capability=None) is ambiguous; omit it, or pass False "
85
+ "to opt out of the page's write_capability."
86
+ )
87
+
70
88
  def wrap(f):
89
+ if capability is not UNSET:
90
+ f._expose_capability = capability
71
91
  if not hasattr(f, "_urls"):
72
92
  f._urls = []
73
93
  if methods is None:
@@ -220,12 +240,12 @@ class PageMeta(type):
220
240
 
221
241
 
222
242
  class PageBase(metaclass=PageMeta):
223
- url = None
224
- name = None
243
+ url: str | None = None
244
+ name: str | None = None
225
245
  # Semantic icon name resolved per icon mode (see icons.ICONS). Apps may
226
246
  # override with their own semantic name or a raw icon string.
227
247
  icon = "page"
228
- endpoint = None
248
+ endpoint: str | None = None
229
249
  # Page-wide auth defaults (only enforced when an AuthProvider is configured).
230
250
  # Set ``login_required = False`` to make every route on the page public, or
231
251
  # ``required_groups = [...]`` to gate every route — including RCRUD
@@ -233,6 +253,11 @@ class PageBase(metaclass=PageMeta):
233
253
  # decorators override these.
234
254
  login_required = True
235
255
  required_groups = None
256
+ # Capability asked before every custom @expose route that accepts a
257
+ # writing method (POST/PUT/PATCH/DELETE) and declares none of its own —
258
+ # e.g. ``write_capability = "write"`` with a ``can_write(self)`` hook.
259
+ # Fail-closed for routes added later; ``capability=False`` opts one out.
260
+ write_capability: str | None = None
236
261
 
237
262
  def __init__(self, static_folder=None, static_url_path=None, menu=None):
238
263
  self.name = self.name or self.__class__.__name__.capitalize()
@@ -333,13 +358,32 @@ class PageBase(metaclass=PageMeta):
333
358
 
334
359
  url = apply_trailing_slash(url, construct)
335
360
 
336
- self.blueprint.add_url_rule(url, name, getattr(self, name), **kwargs)
361
+ view = getattr(self, name)
362
+ capability = route_capability(type(self), name, methods)
363
+ if capability is not None:
364
+ view = guard_route(
365
+ self,
366
+ view,
367
+ capability,
368
+ url,
369
+ self._capability_row_reader(),
370
+ f"{type(self).__name__}.{name}",
371
+ )
372
+ self.blueprint.add_url_rule(url, name, view, **kwargs)
337
373
 
338
374
  return self.blueprint
339
375
 
340
376
  def _run_view(self, f, *args, **kwargs):
341
377
  return f(self, *args, **kwargs)
342
378
 
379
+ def _capability_row_reader(self):
380
+ """``read(id)`` for row capabilities on custom routes, or ``None``
381
+ when the page has no dataclass to read a record from."""
382
+ dataclass = getattr(self, "dataclass", None)
383
+ if dataclass is None:
384
+ return None
385
+ return lambda id: dataclass().read(id)
386
+
343
387
 
344
388
  class ComponentPageBase(PageBase):
345
389
  """Base class for component-based pages."""
@@ -488,7 +532,7 @@ class ComponentPageBase(PageBase):
488
532
 
489
533
  def _register_live_endpoints(self, blueprint, construct):
490
534
 
491
- for source_name, (method, interval) in self.livesources.items():
535
+ for source_name, (method, _interval) in self.livesources.items():
492
536
 
493
537
  def make_poll_view(m, sname, page_self):
494
538
  def poll_view():
@@ -766,7 +810,7 @@ class ExtensionsPage(ComponentPageBase):
766
810
  bg_color = "var(--fcu-secondary)"
767
811
  text_color = "var(--fcu-on-secondary)"
768
812
 
769
- status_badge = Markup( # nosemgrep: explicit-unescape-with-markup # nosec B704
813
+ status_badge = Markup( # nosemgrep: explicit-unescape-with-markup # nosec B704 # noqa: S704
770
814
  f'<span style="background-color: {bg_color}; color: {text_color}; '
771
815
  f"padding: 2px 6px; border-radius: var(--fcu-border-radius); font-size: var(--fcu-font-size-sm); "
772
816
  f'font-weight: bold; display: inline-block;">{status}</span>'
@@ -809,7 +853,7 @@ class ExtensionsPage(ComponentPageBase):
809
853
 
810
854
  class FlaskConstructUI(MenuMixin):
811
855
  app = None
812
- index_view_class = BaseIndexView
856
+ index_view_class: type[PageBase] | None = BaseIndexView
813
857
 
814
858
  def __init__(
815
859
  self,
@@ -833,8 +877,15 @@ class FlaskConstructUI(MenuMixin):
833
877
  strict_slashes=None,
834
878
  check_trailing_slashes=False,
835
879
  enforce_trailing_slash=True,
880
+ app_l10n_dir=None,
881
+ live=None,
882
+ error_statuses=None,
836
883
  ):
837
884
  self._pages = []
885
+ # Domain exception class -> status (or callable); see add_error_status.
886
+ self._error_statuses = {}
887
+ for exc_class, status in (error_statuses or {}).items():
888
+ self.add_error_status(exc_class, status)
838
889
  self._init_menu(menu_poll)
839
890
  self._blueprint_names = set()
840
891
  self._pages_by_endpoint = {}
@@ -850,9 +901,18 @@ class FlaskConstructUI(MenuMixin):
850
901
  # `app.l10n` (only then is it safe to rebuild its search path).
851
902
  self._l10n_dirs = []
852
903
  self._owns_l10n = False
904
+ # The application's own catalog directory. None means "find it": see
905
+ # app_l10n_paths. Not to be confused with the `l10n_dir` property,
906
+ # which is the library's catalog.
907
+ self._app_l10n_dir = app_l10n_dir
908
+ self._app_l10n_resolved = None
853
909
  self._auth = auth
854
910
  self._search = search
855
911
  self._notifications = notifications
912
+ # The change feed behind live_refresh() (see live.py), or None. May
913
+ # also be assigned after construction: the route and the marker read
914
+ # it per request.
915
+ self.live = live
856
916
  # The only structural theme is `whitelabel`; `theme=` is still accepted
857
917
  # for backward compatibility but no longer selects templates or assets.
858
918
  # Whenever no design is given we fill in `adminlte`, which reproduces
@@ -933,7 +993,7 @@ class FlaskConstructUI(MenuMixin):
933
993
  app.cli.add_command(make_cli(self))
934
994
  if not hasattr(app, "l10n"):
935
995
  # Only add if not already added by the app.
936
- app.l10n = Translator()
996
+ app.l10n = Translator() # type: ignore[attr-defined]
937
997
  self._owns_l10n = True
938
998
  self._apply_l10n_paths()
939
999
  app.before_request(set_language_from_browser)
@@ -954,6 +1014,9 @@ class FlaskConstructUI(MenuMixin):
954
1014
 
955
1015
  app_hooks.register_features_blueprint(self)
956
1016
 
1017
+ for exc_class, status in self._error_statuses.items():
1018
+ app.register_error_handler(exc_class, make_error_handler(status, self))
1019
+
957
1020
  def load_extensions(self):
958
1021
  """Discover and load companion libraries via entry points in the
959
1022
  group 'flask_construct_ui.extensions'.
@@ -964,7 +1027,7 @@ class FlaskConstructUI(MenuMixin):
964
1027
  # Fallback for older python / importlib_metadata select API compatibility
965
1028
  try:
966
1029
  eps = entry_points().select(group="flask_construct_ui.extensions")
967
- except Exception:
1030
+ except Exception: # noqa: BLE001 - no extensions rather than no app
968
1031
  eps = []
969
1032
 
970
1033
  for ep in eps:
@@ -1000,7 +1063,7 @@ class FlaskConstructUI(MenuMixin):
1000
1063
  except Exception as e:
1001
1064
  ext_info["status"] = "Error"
1002
1065
  ext_info["error"] = str(e)
1003
- logger.error(f"Failed to load extension {ep.name}: {e}", exc_info=True)
1066
+ logger.exception("Failed to load extension %s: %s", ep.name, e)
1004
1067
  self._extensions.append(ext_info)
1005
1068
 
1006
1069
  if self._extensions:
@@ -1053,8 +1116,10 @@ class FlaskConstructUI(MenuMixin):
1053
1116
  if not path.endswith("/"):
1054
1117
  warnings.append((rule.rule, rule.endpoint))
1055
1118
  app.logger.warning(
1056
- f"Construct UI: Route '{rule.rule}' (endpoint '{rule.endpoint}') "
1057
- f"does not end with a trailing slash '/'. This may cause 308 redirects."
1119
+ "Construct UI: Route '%s' (endpoint '%s') does not end with "
1120
+ "a trailing slash '/'. This may cause 308 redirects.",
1121
+ rule.rule,
1122
+ rule.endpoint,
1058
1123
  )
1059
1124
  return warnings
1060
1125
 
@@ -1098,12 +1163,53 @@ class FlaskConstructUI(MenuMixin):
1098
1163
  def _apply_l10n_paths(self):
1099
1164
  """(Re)build the translator's search path.
1100
1165
 
1101
- Precedence, highest first: the application's own ``l10n/``, then any
1102
- directories registered by companion libraries (in registration order),
1103
- then the library defaults. Only meaningful when this construct owns
1166
+ Precedence, highest first: the application's own ``l10n/`` (see
1167
+ ``app_l10n_paths``), then any directories registered by companion
1168
+ libraries (in registration order), then the library defaults. Only meaningful when this construct owns
1104
1169
  ``app.l10n`` (the host app didn't supply its own).
1105
1170
  """
1106
- self.app.l10n.set_languages(["l10n", *self._l10n_dirs, self.l10n_dir])
1171
+ # Resolved once: the cwd fallback means "the cwd at startup", and
1172
+ # add_l10n_dir rebuilds must not repeat the warning.
1173
+ if self._app_l10n_resolved is None:
1174
+ self._app_l10n_resolved = self.app_l10n_paths()
1175
+ self.app.l10n.set_languages(
1176
+ [*self._app_l10n_resolved, *self._l10n_dirs, self.l10n_dir]
1177
+ )
1178
+
1179
+ def app_l10n_paths(self):
1180
+ """The directories searched for the application's own catalogs.
1181
+
1182
+ An explicit ``app_l10n_dir=`` is resolved against ``app.root_path``
1183
+ (so an absolute path is used as-is). Otherwise ``l10n/`` next to the
1184
+ app package (``app.root_path``) is searched first, then ``l10n/`` in
1185
+ the working directory as a compatibility fallback. The fallback is
1186
+ what breaks when a server is started from another directory, so
1187
+ finding the catalog *only* there logs a warning.
1188
+ """
1189
+ root = self.app.root_path
1190
+ if self._app_l10n_dir is not None:
1191
+ explicit = path.abspath(path.join(root, self._app_l10n_dir))
1192
+ if not path.isdir(explicit):
1193
+ logger.warning(
1194
+ "app_l10n_dir %r does not exist; application labels will "
1195
+ "not be translated",
1196
+ explicit,
1197
+ )
1198
+ return [explicit]
1199
+ beside_app = path.join(root, "l10n")
1200
+ in_cwd = path.abspath("l10n")
1201
+ if path.abspath(beside_app) == in_cwd:
1202
+ return [in_cwd]
1203
+ if path.isdir(in_cwd) and not path.isdir(beside_app):
1204
+ logger.warning(
1205
+ "Application l10n directory found only relative to the "
1206
+ "working directory (%s); it will be missed when the app is "
1207
+ "started from elsewhere. Move it to %s or pass "
1208
+ "FlaskConstructUI(app_l10n_dir=...).",
1209
+ in_cwd,
1210
+ beside_app,
1211
+ )
1212
+ return [beside_app, in_cwd]
1107
1213
 
1108
1214
  def add_l10n_dir(self, path):
1109
1215
  """Register an extra l10n directory, e.g. one shipped by a companion
@@ -1121,6 +1227,28 @@ class FlaskConstructUI(MenuMixin):
1121
1227
  if self.app is not None and self._owns_l10n:
1122
1228
  self._apply_l10n_paths()
1123
1229
 
1230
+ def add_error_status(self, exc_class, status):
1231
+ """Answer *exc_class* (and subclasses), raised from any route, with the
1232
+ themed error page instead of a 500.
1233
+
1234
+ *status* is an HTTP status (``404``) or a callable taking the exception
1235
+ and returning a status or a ``(status, message)`` pair; *message* is
1236
+ translated and shown in place of the status's default explanation::
1237
+
1238
+ fcu.add_error_status(NotFound, 404)
1239
+ fcu.add_error_status(CommandError, lambda e: (e.status, e.message))
1240
+
1241
+ The page renders in the current layout, so an Unpoly fragment request
1242
+ gets the partial. 403 is the same access-denied page a capability
1243
+ refusal shows. It is a plain Flask error handler on the app, so
1244
+ Flask's rules apply: the most specific registered class wins, and an
1245
+ ``app.errorhandler`` of your own for the same class replaces it.
1246
+ """
1247
+ check_error_mapping(exc_class, status)
1248
+ self._error_statuses[exc_class] = status
1249
+ if self.app is not None:
1250
+ self.app.register_error_handler(exc_class, make_error_handler(status, self))
1251
+
1124
1252
  def add_stylesheet(self, sheet):
1125
1253
  """Register an application stylesheet to link in every full page render.
1126
1254
 
@@ -1360,7 +1488,7 @@ class FlaskConstructUI(MenuMixin):
1360
1488
  """
1361
1489
  try:
1362
1490
  name = (self.titleFromLink or "").strip()
1363
- except Exception: # pragma: no cover - defensive
1491
+ except Exception: # pragma: no cover # noqa: BLE001 - defensive
1364
1492
  return self.title
1365
1493
  if not name or name == self.title:
1366
1494
  return self.title
@@ -1378,7 +1506,7 @@ class FlaskConstructUI(MenuMixin):
1378
1506
  """
1379
1507
  try:
1380
1508
  name = (self.titleFromLink or "").strip()
1381
- except Exception: # pragma: no cover - defensive
1509
+ except Exception: # pragma: no cover # noqa: BLE001 - defensive
1382
1510
  name = ""
1383
1511
  return self.t(name) if name else self.title
1384
1512