flask-construct-ui 0.11.5__tar.gz → 0.11.7__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 (111) hide show
  1. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/PKG-INFO +1 -1
  2. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/__init__.py +1 -0
  3. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/app_hooks.py +14 -1
  4. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/auth.py +2 -2
  5. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/base.py +62 -5
  6. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/calendar.py +24 -29
  7. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/crud.py +3 -1
  8. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/data_sqlalchemy.py +21 -8
  9. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/l10n/en/main.flt +20 -0
  10. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/l10n/sv/main.flt +20 -0
  11. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/navigation.py +31 -4
  12. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/rcrud.py +33 -7
  13. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/resume.py +14 -8
  14. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/css/whitelabel.css +20 -0
  15. flask_construct_ui-0.11.7/flask_construct_ui/static/js/construct-core.min.js +1 -0
  16. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/form.html +4 -4
  17. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/live.html +1 -1
  18. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/_macros.html +8 -10
  19. flask_construct_ui-0.11.7/flask_construct_ui/templates/style/_footer.html +15 -0
  20. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/style/base_full.html +14 -4
  21. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui.egg-info/PKG-INFO +1 -1
  22. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui.egg-info/SOURCES.txt +1 -0
  23. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/pyproject.toml +1 -1
  24. flask_construct_ui-0.11.5/flask_construct_ui/static/js/construct-core.min.js +0 -1
  25. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/LICENSE +0 -0
  26. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/README.md +0 -0
  27. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/capabilities.py +0 -0
  28. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/cli.py +0 -0
  29. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/__init__.py +0 -0
  30. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/chart.py +0 -0
  31. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/content.py +0 -0
  32. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/core.py +0 -0
  33. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/grid.py +0 -0
  34. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/kanban.py +0 -0
  35. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/layout.py +0 -0
  36. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/components/live.py +0 -0
  37. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/contrast.py +0 -0
  38. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/crud_forms.py +0 -0
  39. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/csrf.py +0 -0
  40. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/data.py +0 -0
  41. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/forms.py +0 -0
  42. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/grid.py +0 -0
  43. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/headless.py +0 -0
  44. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/icons.py +0 -0
  45. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/language.py +0 -0
  46. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/layout.py +0 -0
  47. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/menu.py +0 -0
  48. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/navbar.py +0 -0
  49. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/orgmode.py +0 -0
  50. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/pagination.py +0 -0
  51. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/py.typed +0 -0
  52. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/sanitize.py +0 -0
  53. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/schema.py +0 -0
  54. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/css/grid.css +0 -0
  55. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  56. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  57. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/designs/adminlte.css +0 -0
  58. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/designs/sandstone.css +0 -0
  59. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
  60. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
  61. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  62. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  63. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
  64. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  65. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
  66. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/js/construct-grid.min.js +0 -0
  67. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  68. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -0
  69. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  70. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/_form_field.html +0 -0
  71. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/button.html +0 -0
  72. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/calendar.html +0 -0
  73. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/calendarmini.html +0 -0
  74. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/card.html +0 -0
  75. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/chart.html +0 -0
  76. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/grid.html +0 -0
  77. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/grid_layout.html +0 -0
  78. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/grid_notice.html +0 -0
  79. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
  80. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/kanban.html +0 -0
  81. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/main.html +0 -0
  82. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/markup.html +0 -0
  83. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/rich_text.html +0 -0
  84. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/table.html +0 -0
  85. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/tabs.html +0 -0
  86. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/text.html +0 -0
  87. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
  88. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
  89. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/_related_options.html +0 -0
  90. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/create.html +0 -0
  91. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/edit.html +0 -0
  92. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/listindex.html +0 -0
  93. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
  94. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/related_crud_tab_list.html +0 -0
  95. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/tab_view.html +0 -0
  96. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/crud/view.html +0 -0
  97. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/errors/forbidden.html +0 -0
  98. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/navbar/notifications.html +0 -0
  99. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/navbar/search_results.html +0 -0
  100. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/structure/base.html +0 -0
  101. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/structure/public.html +0 -0
  102. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/style/_flashes.html +0 -0
  103. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/style/_menu.html +0 -0
  104. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/templates/style/base_partial.html +0 -0
  105. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/testing.py +0 -0
  106. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/urls.py +0 -0
  107. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui/validation.py +0 -0
  108. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  109. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui.egg-info/requires.txt +0 -0
  110. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/flask_construct_ui.egg-info/top_level.txt +0 -0
  111. {flask_construct_ui-0.11.5 → flask_construct_ui-0.11.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.11.5
3
+ Version: 0.11.7
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
@@ -10,6 +10,7 @@ from .base import edit_action as edit_action
10
10
  from .base import datasource as datasource
11
11
  from .base import livesource as livesource
12
12
  from .base import gridsource as gridsource
13
+ from .navigation import fragment as fragment
13
14
  from .base import SSE_DONE as SSE_DONE
14
15
 
15
16
  # Component-based pages and the component library. Exposed at the package root
@@ -239,7 +239,7 @@ def install_template_helpers(construct, app):
239
239
 
240
240
 
241
241
  def register_features_blueprint(construct):
242
- """Register ``construct_features``: the search, notifications and menu fragments."""
242
+ """Register ``construct_features``: the search, notifications, menu and footer fragments."""
243
243
  # The features blueprint is registered unconditionally because the menu
244
244
  # fragment route cannot be conditional: providers are normally added
245
245
  # after init_app (`fcu = FlaskConstructUI(app)` first, decorators
@@ -256,10 +256,12 @@ def register_features_blueprint(construct):
256
256
  search_url = "/search"
257
257
  notifications_url = "/notifications"
258
258
  menu_url = "/menu"
259
+ footer_url = "/footer"
259
260
  if construct.enforce_trailing_slash:
260
261
  search_url += "/"
261
262
  notifications_url += "/"
262
263
  menu_url += "/"
264
+ footer_url += "/"
263
265
 
264
266
  route_kwargs = {}
265
267
  if construct.strict_slashes is not None:
@@ -301,5 +303,16 @@ def register_features_blueprint(construct):
301
303
  construct=construct,
302
304
  )
303
305
 
306
+ @features_bp.route(footer_url, **route_kwargs)
307
+ def footer_fragment():
308
+ # Just the #fcu-footer-content element that up-poll reloads. Always
309
+ # rendered, even empty: a poll response lacking its target is an
310
+ # Unpoly error, and an empty element lets the text come back later.
311
+ return render_template(
312
+ "style/_footer.html",
313
+ construct=construct,
314
+ footer_fragment=True,
315
+ )
316
+
304
317
  construct.app.register_blueprint(features_bp)
305
318
  construct._blueprint_names.add("construct_features")
@@ -177,8 +177,8 @@ class AuthProvider:
177
177
  """Where to send the reader once they are logged in again, or None.
178
178
 
179
179
  Delegates to :func:`flask_construct_ui.resume.return_target`: a GET is
180
- its own target, anything else falls back to the page it was submitted
181
- from. Override to apply a policy of your own — every redirect the
180
+ its own target, anything else — and a GET to a fragment endpoint —
181
+ falls back to the page it was made from. Override to apply a policy of your own — every redirect the
182
182
  library builds for an interrupted request goes through here.
183
183
  """
184
184
  return return_target()
@@ -36,6 +36,7 @@ from .components import (
36
36
  )
37
37
  from .language import Translator, set_language_from_browser
38
38
  from .menu import MENU_STAMP_HEADER as MENU_STAMP_HEADER, MenuMixin
39
+ from .navigation import fragment
39
40
  from .urls import apply_trailing_slash as apply_trailing_slash
40
41
 
41
42
  logger = logging.getLogger("flask_construct_ui")
@@ -426,7 +427,7 @@ class ComponentPageBase(PageBase):
426
427
  return controller.handle_json(row_id)
427
428
 
428
429
  view.__name__ = f"grid_{'row' if with_row else 'rows'}_{sname}"
429
- return view
430
+ return fragment(view)
430
431
 
431
432
  def make_form_view(sname):
432
433
  def view():
@@ -503,7 +504,7 @@ class ComponentPageBase(PageBase):
503
504
  return "", 204
504
505
 
505
506
  poll_view.__name__ = f"live_poll_{sname}"
506
- return poll_view
507
+ return fragment(poll_view)
507
508
 
508
509
  def make_sse_view(m, sname):
509
510
  def sse_view():
@@ -534,7 +535,7 @@ class ComponentPageBase(PageBase):
534
535
  )
535
536
 
536
537
  sse_view.__name__ = f"live_sse_{sname}"
537
- return sse_view
538
+ return fragment(sse_view)
538
539
 
539
540
  poll_url = f"/live/{source_name}/poll"
540
541
  sse_url = f"/live/{source_name}/sse"
@@ -677,6 +678,7 @@ class ComponentPageBase(PageBase):
677
678
  self._set_active_tabs(root_component)
678
679
  return self.render_component(root_component)
679
680
 
681
+ @fragment
680
682
  @expose("/_part/<string:component_id>/<int:part_index>")
681
683
  def get_part(self, component_id: str, part_index: int):
682
684
 
@@ -816,7 +818,8 @@ class FlaskConstructUI(MenuMixin):
816
818
  url=None,
817
819
  static_url_path=None,
818
820
  title=None,
819
- footer=None,
821
+ footer="footer-built-with",
822
+ footer_poll=None,
820
823
  index_view_class=None,
821
824
  theme=None,
822
825
  design=None,
@@ -892,7 +895,11 @@ class FlaskConstructUI(MenuMixin):
892
895
  else:
893
896
  self.url = "/"
894
897
  self.title = title
895
- self.footer = footer or "Built with Construct UI"
898
+ # See footer_content. Any falsy value (None, "", False) drops the
899
+ # footer; the default is an l10n key, so it translates like the rest.
900
+ self.footer = footer
901
+ # Seconds between Unpoly reloads of the footer content; see footer_poll.
902
+ self._footer_poll = footer_poll
896
903
  self.templatefolder = path.join(path.dirname(__file__), "templates")
897
904
 
898
905
  if index_view_class is not None:
@@ -1166,6 +1173,56 @@ class FlaskConstructUI(MenuMixin):
1166
1173
  def t(self, msg_id, args=None):
1167
1174
  return self.app.l10n.t(msg_id, args)
1168
1175
 
1176
+ @property
1177
+ def footer_content(self):
1178
+ """The footer to render on full pages, or None for no footer at all.
1179
+
1180
+ ``footer`` (the constructor argument, also settable as ``fcu.footer``)
1181
+ is one of
1182
+
1183
+ * a **string** -- an l10n key or plain text, passed through ``t()``
1184
+ and escaped. The default, ``"footer-built-with"``, reads "Built with
1185
+ Construct UI";
1186
+ * a ``markupsafe.Markup`` -- trusted HTML, emitted as-is (links,
1187
+ icons, several lines);
1188
+ * a **callable** taking no arguments and returning either of the above
1189
+ (or None). It runs per request, inside the request context, so it can
1190
+ use ``url_for``, ``current_user`` or the date;
1191
+ * anything **falsy** (``None``, ``""``, ``False``) -- no footer: the
1192
+ ``<footer>`` element itself is omitted, not left empty.
1193
+
1194
+ For markup beyond one fragment, override ``{% block footer %}`` in the
1195
+ app's ``structure/base.html``; an empty override also removes it.
1196
+ """
1197
+ footer = self.footer
1198
+ if callable(footer):
1199
+ footer = footer()
1200
+ if not footer:
1201
+ return None
1202
+ if isinstance(footer, Markup):
1203
+ return footer
1204
+ return self.t(str(footer))
1205
+
1206
+ @property
1207
+ def footer_url(self):
1208
+ """URL of the footer fragment endpoint (for Unpoly polling)."""
1209
+ return apply_trailing_slash(self.url.rstrip("/") + "/_construct/footer", self)
1210
+
1211
+ @property
1212
+ def footer_poll(self):
1213
+ """Interval in ms for polling the footer content, or None.
1214
+
1215
+ ``footer_poll=<seconds>`` on the constructor keeps a live footer fresh:
1216
+ the footer lives outside ``#main-content``, so in-app navigation never
1217
+ re-renders it, and a callable ``footer`` would otherwise only update on
1218
+ a full page load. Off when no footer is configured -- there is nothing
1219
+ to refresh -- and it only covers ``footer_content``: an app that
1220
+ overrides ``{% block footer %}`` drops the polled element with it.
1221
+ """
1222
+ if self._footer_poll is None or not self.footer:
1223
+ return None
1224
+ return int(self._footer_poll * 1000)
1225
+
1169
1226
  @property
1170
1227
  def notices(self):
1171
1228
  if self._notifications is not None:
@@ -3,20 +3,26 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import calendar
6
- import locale
7
6
  from dataclasses import dataclass, field
8
7
  from datetime import date, datetime
9
8
  from typing import Any, Dict, List, Optional
10
9
 
11
- from flask import g
12
10
  from markupsafe import Markup
13
11
 
14
- from flask_construct_ui.language import LOCALE_MAPPING
15
-
16
12
  from ..sanitize import sanitize_html
17
13
  from .core import Component, _t, jinja_env
18
14
 
19
15
 
16
+ _DAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
17
+
18
+
19
+ def _name(key, default):
20
+ """A translated day/month name, or ``default`` (the C-locale English name)
21
+ when the active language's catalogue has no such key."""
22
+ value = _t(key, default=default)
23
+ return default if value == key else value
24
+
25
+
20
26
  @dataclass
21
27
  class CalendarComponent(Component):
22
28
  datasource: Optional[str] = None
@@ -28,10 +34,13 @@ class CalendarComponent(Component):
28
34
  return "calendar"
29
35
 
30
36
  def render(self) -> str:
31
- if self.start_day.lower() == "sunday":
32
- calendar.setfirstweekday(calendar.SUNDAY)
33
- else:
34
- calendar.setfirstweekday(calendar.MONDAY)
37
+ # A local Calendar, not calendar.setfirstweekday(): that one (like
38
+ # locale.setlocale) is process-global, so concurrent requests on a
39
+ # threaded server would see each other's settings.
40
+ first = (
41
+ calendar.SUNDAY if self.start_day.lower() == "sunday" else calendar.MONDAY
42
+ )
43
+ cal = calendar.Calendar(first)
35
44
 
36
45
  if self.data:
37
46
  first_date = self.data[0].get("date")
@@ -43,27 +52,13 @@ class CalendarComponent(Component):
43
52
  else:
44
53
  today = date.today()
45
54
  year, month = today.year, today.month
46
- lang = getattr(g, "language", None)
47
- target_locale = LOCALE_MAPPING.get(lang)
48
- day_names = calendar.weekheader(3).split()
49
- month_name = calendar.month_name[month]
50
- if target_locale:
51
- original_locale = locale.getlocale(locale.LC_TIME)
52
- try:
53
- locale.setlocale(locale.LC_TIME, target_locale)
54
- # Re-fetch names with the new locale
55
- day_names = calendar.weekheader(3).split()
56
- month_name = calendar.month_name[month]
57
- except locale.Error:
58
- # If locale is not installed on the system, it will fail.
59
- # We just fall back to the default names.
60
- pass
61
-
62
- finally:
63
- # Always restore the original locale
64
- locale.setlocale(locale.LC_TIME, original_locale)
65
-
66
- month_calendar = calendar.monthcalendar(year, month)
55
+
56
+ day_names = [
57
+ _name(f"calendar-day-{_DAY_KEYS[d]}", calendar.day_abbr[d])
58
+ for d in cal.iterweekdays()
59
+ ]
60
+ month_name = _name(f"calendar-month-{month}", calendar.month_name[month])
61
+ month_calendar = cal.monthdayscalendar(year, month)
67
62
 
68
63
  # Cell content may come from a @datasource carrying user data, and the
69
64
  # API allows HTML snippets — sanitize before the template marks it safe.
@@ -19,7 +19,7 @@ from .crud_forms import (
19
19
  parse_form_data,
20
20
  )
21
21
  from .forms import FormData, validate_form_data
22
- from .navigation import resolve_next
22
+ from .navigation import fragment, resolve_next
23
23
  from .pagination import RELATED_SEARCH_LIMIT, get_pagination
24
24
  from .base import PageBase, expose
25
25
  from .rcrud import RelatedCrudTab as RelatedCrudTab
@@ -341,6 +341,7 @@ class CrudPageBase(PageBase):
341
341
  **kwargs,
342
342
  )
343
343
 
344
+ @fragment
344
345
  @expose("/search/<field_name>")
345
346
  def search_related(self, field_name):
346
347
  ds = self.dataclass()
@@ -359,6 +360,7 @@ class CrudPageBase(PageBase):
359
360
  field_name=field_name,
360
361
  )
361
362
 
363
+ @fragment
362
364
  @expose("/search-many/<field_name>")
363
365
  def search_many_related(self, field_name):
364
366
  ds = self.dataclass()
@@ -52,11 +52,16 @@ class DataSQLAlchemy(DataBase):
52
52
  val_str = str(member.value).lower()
53
53
  wrapper_str = str(ftype.ValueWrapper(member.name, member.value)).lower()
54
54
  if op == "like":
55
- pattern = value.replace("%", ".*").replace("_", ".")
55
+ # Only the SQL wildcards are operators; everything else the
56
+ # user typed is literal (an unescaped "(" was a 500).
57
+ pattern = "".join(
58
+ ".*" if ch == "%" else "." if ch == "_" else re.escape(ch)
59
+ for ch in value
60
+ )
56
61
  if (
57
- re.match(f"^{pattern}$", name, re.IGNORECASE)
58
- or re.match(f"^{pattern}$", val_str, re.IGNORECASE)
59
- or re.match(f"^{pattern}$", wrapper_str, re.IGNORECASE)
62
+ re.fullmatch(pattern, name, re.IGNORECASE)
63
+ or re.fullmatch(pattern, val_str, re.IGNORECASE)
64
+ or re.fullmatch(pattern, wrapper_str, re.IGNORECASE)
60
65
  ):
61
66
  matched.append(member)
62
67
  else:
@@ -330,18 +335,26 @@ class DataSQLAlchemy(DataBase):
330
335
 
331
336
  sub_rec = list_class_ds.model(**sub_kwargs)
332
337
  getattr(rec, ftype.list_field).append(sub_rec)
338
+ # Sub-row ids come from the form, so resolve them against
339
+ # this record's own collection — never a bare read(), which
340
+ # would reach any other parent's rows. Unknown ids are ignored.
341
+ owned = {}
342
+ if "delete" in actions or "update" in actions:
343
+ list_pk = ftype.list_class()._primary_key
344
+ owned = {
345
+ str(getattr(item, list_pk)): item
346
+ for item in getattr(rec, ftype.list_field)
347
+ }
333
348
  if "delete" in actions:
334
- list_class_ds = ftype.list_class()
335
349
  for item_id_to_delete in actions["delete"]:
336
- item_to_delete = list_class_ds.read(item_id_to_delete)
350
+ item_to_delete = owned.get(str(item_id_to_delete))
337
351
  if item_to_delete:
338
352
  self.session.delete(item_to_delete)
339
353
 
340
354
  if "update" in actions:
341
- list_class_ds = ftype.list_class()
342
355
  for item_to_update_data in actions["update"]:
343
356
  item_id_to_update = item_to_update_data.pop("id")
344
- item_to_update = list_class_ds.read(item_id_to_update)
357
+ item_to_update = owned.get(str(item_id_to_update))
345
358
  if item_to_update:
346
359
  for sub_key, sub_value in item_to_update_data.items():
347
360
  setattr(item_to_update, sub_key, sub_value)
@@ -60,6 +60,7 @@ nav-skip-to-content = Skip to content
60
60
  nav-main = Main navigation
61
61
  nav-sidebar = Sidebar
62
62
  nav-user-tools = User tools
63
+ footer-built-with = Built with Construct UI
63
64
  nav-search = Search
64
65
  nav-search-no-results = No results for "{$query}".
65
66
  nav-notifications = Notifications
@@ -72,6 +73,25 @@ live-resume = Resume updates
72
73
  live-reload = Reload for the latest
73
74
 
74
75
  calendar-has-events = Has events
76
+ calendar-day-mon = Mon
77
+ calendar-day-tue = Tue
78
+ calendar-day-wed = Wed
79
+ calendar-day-thu = Thu
80
+ calendar-day-fri = Fri
81
+ calendar-day-sat = Sat
82
+ calendar-day-sun = Sun
83
+ calendar-month-1 = January
84
+ calendar-month-2 = February
85
+ calendar-month-3 = March
86
+ calendar-month-4 = April
87
+ calendar-month-5 = May
88
+ calendar-month-6 = June
89
+ calendar-month-7 = July
90
+ calendar-month-8 = August
91
+ calendar-month-9 = September
92
+ calendar-month-10 = October
93
+ calendar-month-11 = November
94
+ calendar-month-12 = December
75
95
 
76
96
  chart-label = Chart
77
97
  chart-unsupported-type = Unsupported chart type
@@ -61,6 +61,7 @@ nav-skip-to-content = Hoppa till innehåll
61
61
  nav-main = Huvudnavigering
62
62
  nav-sidebar = Sidopanel
63
63
  nav-user-tools = Användarverktyg
64
+ footer-built-with = Byggd med Construct UI
64
65
  nav-search = Sök
65
66
  nav-search-no-results = Inga resultat för "{$query}".
66
67
  nav-notifications = Notifieringar
@@ -73,6 +74,25 @@ live-resume = Återuppta uppdateringar
73
74
  live-reload = Ladda om för senaste
74
75
 
75
76
  calendar-has-events = Har händelser
77
+ calendar-day-mon = mån
78
+ calendar-day-tue = tis
79
+ calendar-day-wed = ons
80
+ calendar-day-thu = tor
81
+ calendar-day-fri = fre
82
+ calendar-day-sat = lör
83
+ calendar-day-sun = sön
84
+ calendar-month-1 = januari
85
+ calendar-month-2 = februari
86
+ calendar-month-3 = mars
87
+ calendar-month-4 = april
88
+ calendar-month-5 = maj
89
+ calendar-month-6 = juni
90
+ calendar-month-7 = juli
91
+ calendar-month-8 = augusti
92
+ calendar-month-9 = september
93
+ calendar-month-10 = oktober
94
+ calendar-month-11 = november
95
+ calendar-month-12 = december
76
96
 
77
97
  chart-label = Diagram
78
98
  chart-unsupported-type = Diagramtypen stöds inte
@@ -38,8 +38,12 @@ RECORD_TARGET = ":record"
38
38
  #: OIDC authorize route out of ``next`` — a route can be perfectly legitimate
39
39
  #: to *request* and still be nonsense to *land on*.
40
40
  #:
41
+ #: Per-page fragment routes (a CRUD page's related-search dropdowns, a
42
+ #: component page's parts and live polls) have one endpoint per page, so they
43
+ #: cannot be listed here; their view functions carry :func:`fragment` instead.
41
44
  #: Apps list their own fragment routes (anything driven by ``up-poll`` or
42
- #: reloaded through ``up-source``) in ``CONSTRUCT_FRAGMENT_ENDPOINTS``.
45
+ #: reloaded through ``up-source``) in ``CONSTRUCT_FRAGMENT_ENDPOINTS``, or mark
46
+ #: the view with ``@fragment``.
43
47
  FRAGMENT_ENDPOINTS = frozenset(
44
48
  {
45
49
  "construct_features.menu_fragment",
@@ -49,6 +53,22 @@ FRAGMENT_ENDPOINTS = frozenset(
49
53
  )
50
54
 
51
55
 
56
+ #: Attribute :func:`fragment` sets on a view function.
57
+ FRAGMENT_ATTR = "_construct_fragment"
58
+
59
+
60
+ def fragment(view):
61
+ """Mark ``view`` as answering with a bare fragment, never a whole page.
62
+
63
+ The per-view spelling of :data:`FRAGMENT_ENDPOINTS`, for routes whose
64
+ endpoint name is not known up front — every ``CrudPageBase`` gets its own
65
+ ``<endpoint>.search_related``, and RCRUD forwards it again under the
66
+ parent's blueprint. Order relative to ``@expose`` does not matter.
67
+ """
68
+ setattr(view, FRAGMENT_ATTR, True)
69
+ return view
70
+
71
+
52
72
  def fragment_endpoints():
53
73
  """Every endpoint that must not be landed on: built-ins plus the app's."""
54
74
  extra = current_app.config.get("CONSTRUCT_FRAGMENT_ENDPOINTS") or ()
@@ -69,9 +89,8 @@ def is_fragment_target(url=None):
69
89
  there".
70
90
  """
71
91
  try:
72
- endpoints = fragment_endpoints()
73
92
  if url is None:
74
- return request.endpoint in endpoints
93
+ return _is_fragment_endpoint(request.endpoint)
75
94
  path = urlparse(url).path
76
95
  adapter = current_app.url_map.bind(request.host)
77
96
  try:
@@ -81,11 +100,19 @@ def is_fragment_target(url=None):
81
100
  # to the same view, so follow it rather than reporting the URL as
82
101
  # harmless because it was spelled without the slash.
83
102
  endpoint, _ = adapter.match(urlparse(redirect.new_url).path, method="GET")
84
- return endpoint in endpoints
103
+ return _is_fragment_endpoint(endpoint)
85
104
  except Exception:
86
105
  return False
87
106
 
88
107
 
108
+ def _is_fragment_endpoint(endpoint):
109
+ """True when ``endpoint`` is listed as a fragment or its view is marked."""
110
+ if endpoint in fragment_endpoints():
111
+ return True
112
+ view = current_app.view_functions.get(endpoint)
113
+ return bool(getattr(view, FRAGMENT_ATTR, False))
114
+
115
+
89
116
  def is_safe_next(url):
90
117
  """True when ``url`` is a safe, app-relative redirect target."""
91
118
  if not url or not url.startswith("/"):
@@ -22,7 +22,7 @@ from .crud_forms import (
22
22
  )
23
23
  from .data import DataBase
24
24
  from .forms import FormData, validate_form_data
25
- from .navigation import resolve_next
25
+ from .navigation import FRAGMENT_ATTR, resolve_next
26
26
  from .pagination import get_pagination
27
27
 
28
28
 
@@ -246,6 +246,25 @@ class RelatedCrudTab:
246
246
  rct=rct,
247
247
  )
248
248
 
249
+ def _read_child(id, child_id):
250
+ """The child record, or ``None`` if it is missing *or belongs to
251
+ another parent*. ``child_id`` comes from the URL, so without this a
252
+ user allowed on parent A could open, edit (and so re-parent) or
253
+ delete parent B's children. A mismatch reads as not-found so the
254
+ response doesn't reveal that the id exists elsewhere."""
255
+ ds = child_dataclass()
256
+ rec = ds.read(child_id)
257
+ if rec is None:
258
+ return ds, None
259
+ owner = (
260
+ rec.get(filter_field)
261
+ if isinstance(rec, dict)
262
+ else getattr(rec, filter_field, None)
263
+ )
264
+ if owner is None or str(owner) != str(id):
265
+ return ds, None
266
+ return ds, rec
267
+
249
268
  def list_view(id):
250
269
  ds = child_dataclass()
251
270
  page, per_page, limit = get_pagination(
@@ -418,8 +437,7 @@ class RelatedCrudTab:
418
437
  )
419
438
 
420
439
  def edit_view(id, child_id):
421
- ds = child_dataclass()
422
- rec = ds.read(child_id)
440
+ ds, rec = _read_child(id, child_id)
423
441
  if rec is None:
424
442
  flash("crud-error-not-found", "danger")
425
443
  return redirect(url_for(f"{parent_view.endpoint}.{list_ep}", id=id))
@@ -525,8 +543,7 @@ class RelatedCrudTab:
525
543
  )
526
544
 
527
545
  def view_view(id, child_id):
528
- ds = child_dataclass()
529
- rec = ds.read(child_id)
546
+ ds, rec = _read_child(id, child_id)
530
547
  if rec is None:
531
548
  flash("crud-error-not-found", "danger")
532
549
  return redirect(url_for(f"{parent_view.endpoint}.{list_ep}", id=id))
@@ -571,11 +588,10 @@ class RelatedCrudTab:
571
588
  )
572
589
 
573
590
  def delete_view(id, child_id):
574
- ds = child_dataclass()
575
591
  back = redirect(
576
592
  resolve_next(url_for(f"{parent_view.endpoint}.{list_ep}", id=id))
577
593
  )
578
- row = ds.read(child_id)
594
+ ds, row = _read_child(id, child_id)
579
595
  if row is None:
580
596
  flash("crud-error-not-found", "danger")
581
597
  return back
@@ -632,6 +648,12 @@ class RelatedCrudTab:
632
648
  for p in prefix_params:
633
649
  kwargs.pop(p, None)
634
650
  if child_id_kwarg in kwargs:
651
+ # Same parent check as the built-in member routes.
652
+ if _read_child(id, kwargs[child_id_kwarg])[1] is None:
653
+ flash("crud-error-not-found", "danger")
654
+ return redirect(
655
+ url_for(f"{parent_view.endpoint}.{list_ep}", id=id)
656
+ )
635
657
  kwargs["id"] = kwargs.pop(child_id_kwarg)
636
658
  child._rcrud_context = {
637
659
  "parent_endpoint": parent_view.endpoint,
@@ -643,6 +665,10 @@ class RelatedCrudTab:
643
665
  return getattr(child, method_name)(**kwargs)
644
666
 
645
667
  exposed_view.__name__ = ep_name
668
+ # A forwarded fragment (the child's related-search dropdown)
669
+ # is still a fragment under the parent's blueprint.
670
+ if getattr(getattr(child_cls, method_name), FRAGMENT_ATTR, False):
671
+ setattr(exposed_view, FRAGMENT_ATTR, True)
646
672
  return exposed_view
647
673
 
648
674
  for url, name, methods in child_cls._curls:
@@ -107,17 +107,23 @@ def return_target() -> str | None:
107
107
  ``next`` parameter uses (:func:`~flask_construct_ui.navigation.is_safe_next`).
108
108
 
109
109
  Either way the target must be somewhere a reader can actually *be*. A
110
- fragment endpoint (the sidebar nav, the notifications dropdown) is a
111
- guarded route like any other, so a logged-out request to one would
112
- otherwise name itself here and the login round trip would end with a bare
113
- ``<nav>`` filling the window — see
114
- :func:`~flask_construct_ui.navigation.is_fragment_target`.
110
+ fragment endpoint (the sidebar nav, a related-search dropdown) is a
111
+ guarded route like any other, so a logged-out GET to one would otherwise
112
+ name itself here and the login round trip would end with the bare
113
+ fragment filling the window — see
114
+ :func:`~flask_construct_ui.navigation.is_fragment_target`. A fragment is
115
+ requested *from* a page, though, so it takes the referer branch too: the
116
+ reader comes back to the form that held the dropdown, not to the fragment
117
+ and not to the site root.
115
118
  """
116
- if request.method in SAFE_METHODS:
117
- if is_fragment_target():
118
- return None
119
+ if request.method in SAFE_METHODS and not is_fragment_target():
119
120
  target = request.full_path.rstrip("?") or "/"
120
121
  return target if is_safe_next(target) else None
122
+ return _referer_target()
123
+
124
+
125
+ def _referer_target() -> str | None:
126
+ """The same-host page this request was made from, if it is a landing page."""
121
127
  parsed = urlparse(request.referrer or "")
122
128
  if not parsed.path or (parsed.netloc and parsed.netloc != request.host):
123
129
  return None
@@ -400,6 +400,18 @@ a:hover {
400
400
  text-decoration: none;
401
401
  }
402
402
 
403
+ /* App-owned slot between the brand and the actions (`{% block navbar_start %}`).
404
+ It takes the free width, so its content starts next to the brand; an app
405
+ wanting it centred adds `justify-content: center` on this class. */
406
+ .fcu-navbar__start {
407
+ flex: 1 1 auto;
408
+ min-width: 0;
409
+ display: flex;
410
+ align-items: center;
411
+ gap: var(--fcu-gap);
412
+ margin: 0 var(--fcu-gap);
413
+ }
414
+
403
415
  /* Right-hand navbar action group: search, notifications, user menu — all on
404
416
  one row, vertically centered. */
405
417
  .fcu-navbar__actions {
@@ -1597,6 +1609,14 @@ fieldset.fcu-form-section__body {
1597
1609
  flex-wrap: wrap;
1598
1610
  }
1599
1611
 
1612
+ .fcu-navbar__start {
1613
+ /* Its own full-width row between the brand and the actions, still in DOM
1614
+ order, rather than squeezing between them. */
1615
+ flex-basis: 100%;
1616
+ flex-wrap: wrap;
1617
+ margin: 0;
1618
+ }
1619
+
1600
1620
  .fcu-navbar__brand {
1601
1621
  /* A long app title is one unbreakable flex item otherwise, and it sets the
1602
1622
  floor for the whole page width. */