flask-construct-ui 0.11.0__tar.gz → 0.11.2__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 (100) hide show
  1. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/PKG-INFO +3 -1
  2. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/__init__.py +13 -0
  3. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/auth.py +130 -3
  4. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/base.py +206 -10
  5. flask_construct_ui-0.11.2/flask_construct_ui/cli.py +210 -0
  6. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/components.py +65 -7
  7. flask_construct_ui-0.11.2/flask_construct_ui/contrast.py +356 -0
  8. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/crud.py +304 -13
  9. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/icons.py +34 -5
  10. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/l10n/en/main.flt +42 -0
  11. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/l10n/sv/main.flt +43 -0
  12. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/language.py +26 -1
  13. flask_construct_ui-0.11.2/flask_construct_ui/resume.py +319 -0
  14. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/schema.py +20 -2
  15. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/grid.css +21 -2
  16. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/whitelabel.css +406 -26
  17. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/designs/adminlte.css +29 -6
  18. flask_construct_ui-0.11.2/flask_construct_ui/static/js/construct-core.min.js +1 -0
  19. flask_construct_ui-0.11.2/flask_construct_ui/static/js/construct-grid.min.js +1 -0
  20. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/js/prosemirror-bundle.min.js +6 -6
  21. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/calendar.html +1 -1
  22. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/calendarmini.html +9 -2
  23. flask_construct_ui-0.11.2/flask_construct_ui/templates/components/chart.html +12 -0
  24. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/form.html +9 -3
  25. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/grid.html +7 -1
  26. flask_construct_ui-0.11.2/flask_construct_ui/templates/components/live.html +63 -0
  27. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/rich_text.html +3 -0
  28. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/table.html +1 -1
  29. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/tabs.html +15 -5
  30. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/_macros.html +231 -79
  31. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/_many_related_options.html +12 -8
  32. flask_construct_ui-0.11.2/flask_construct_ui/templates/crud/_related_options.html +18 -0
  33. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/create.html +6 -5
  34. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/edit.html +10 -11
  35. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/listindex.html +100 -45
  36. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/related_crud_tab_form.html +22 -6
  37. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/related_crud_tab_list.html +57 -37
  38. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/tab_view.html +5 -2
  39. flask_construct_ui-0.11.2/flask_construct_ui/templates/crud/view.html +56 -0
  40. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/errors/forbidden.html +5 -2
  41. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/navbar/notifications.html +11 -3
  42. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/navbar/search_results.html +6 -3
  43. flask_construct_ui-0.11.2/flask_construct_ui/templates/style/_flashes.html +37 -0
  44. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/style/base_full.html +83 -10
  45. flask_construct_ui-0.11.2/flask_construct_ui/templates/style/base_partial.html +23 -0
  46. flask_construct_ui-0.11.2/flask_construct_ui/testing.py +238 -0
  47. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/PKG-INFO +3 -1
  48. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/SOURCES.txt +5 -0
  49. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/requires.txt +3 -0
  50. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/pyproject.toml +11 -2
  51. flask_construct_ui-0.11.0/flask_construct_ui/static/js/construct-core.min.js +0 -1
  52. flask_construct_ui-0.11.0/flask_construct_ui/static/js/construct-grid.min.js +0 -1
  53. flask_construct_ui-0.11.0/flask_construct_ui/templates/components/chart.html +0 -5
  54. flask_construct_ui-0.11.0/flask_construct_ui/templates/components/live.html +0 -49
  55. flask_construct_ui-0.11.0/flask_construct_ui/templates/crud/_related_options.html +0 -15
  56. flask_construct_ui-0.11.0/flask_construct_ui/templates/style/_flashes.html +0 -16
  57. flask_construct_ui-0.11.0/flask_construct_ui/templates/style/base_partial.html +0 -16
  58. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/LICENSE +0 -0
  59. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/README.md +0 -0
  60. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/csrf.py +0 -0
  61. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/data.py +0 -0
  62. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/data_sqlalchemy.py +0 -0
  63. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/forms.py +0 -0
  64. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/grid.py +0 -0
  65. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/headless.py +0 -0
  66. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/navbar.py +0 -0
  67. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/navigation.py +0 -0
  68. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/orgmode.py +0 -0
  69. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/pagination.py +0 -0
  70. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/py.typed +0 -0
  71. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/sanitize.py +0 -0
  72. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  73. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  74. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/designs/sandstone.css +0 -0
  75. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
  76. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
  77. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  78. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  79. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
  80. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  81. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
  82. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  83. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  84. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/_form_field.html +0 -0
  85. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/button.html +0 -0
  86. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/card.html +0 -0
  87. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/grid_layout.html +0 -0
  88. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/grid_notice.html +0 -0
  89. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
  90. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/kanban.html +0 -0
  91. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/main.html +0 -0
  92. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/markup.html +0 -0
  93. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/text.html +0 -0
  94. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
  95. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/structure/base.html +0 -0
  96. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/structure/public.html +0 -0
  97. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/validation.py +0 -0
  98. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  99. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/top_level.txt +0 -0
  100. {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.11.0
3
+ Version: 0.11.2
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
@@ -32,6 +32,8 @@ Requires-Dist: SQLAlchemy
32
32
  Requires-Dist: orgparse
33
33
  Provides-Extra: oidc
34
34
  Requires-Dist: flask-oidc; extra == "oidc"
35
+ Provides-Extra: session
36
+ Requires-Dist: flask-session[redis]; extra == "session"
35
37
  Dynamic: license-file
36
38
 
37
39
  # Flask-Construct-UI
@@ -54,6 +54,19 @@ from .auth import (
54
54
  require_groups as require_groups,
55
55
  )
56
56
 
57
+ # Resuming an interrupted request after re-authentication. Public because an
58
+ # app has links and forms the library never sees; see resume.py.
59
+ from .resume import (
60
+ return_target as return_target,
61
+ stash_form_data as stash_form_data,
62
+ peek_form_data as peek_form_data,
63
+ take_form_data as take_form_data,
64
+ clear_form_data as clear_form_data,
65
+ resumed_value as resumed_value,
66
+ resumed_values as resumed_values,
67
+ has_resumable_form as has_resumable_form,
68
+ )
69
+
57
70
  # Navbar features
58
71
  from .navbar import (
59
72
  NavbarFeature as NavbarFeature,
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
+ from functools import wraps
5
+ from urllib.parse import quote, urlencode, urlparse
4
6
 
5
7
  from flask import (
6
8
  abort,
@@ -14,6 +16,9 @@ from flask import (
14
16
  )
15
17
  from markupsafe import escape
16
18
 
19
+ from .navigation import is_safe_next
20
+ from .resume import SAFE_METHODS, return_target, stash_form_data
21
+
17
22
  # --- Authentication & authorization ---------------------------------------
18
23
  # Construct routes are guarded fail-closed: once an ``AuthProvider`` is passed
19
24
  # to ``FlaskConstructUI(auth=...)`` every construct-owned route requires login
@@ -168,21 +173,37 @@ class AuthProvider:
168
173
 
169
174
  # -- response helpers ---------------------------------------------------
170
175
 
176
+ def return_target(self) -> str | None:
177
+ """Where to send the reader once they are logged in again, or None.
178
+
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
182
+ library builds for an interrupted request goes through here.
183
+ """
184
+ return return_target()
185
+
171
186
  def login_url(self):
172
187
  """Resolve the login URL, carrying ``next`` back to this request."""
173
188
  target = (
174
189
  current_app.config.get("CONSTRUCT_LOGIN_ENDPOINT") or self.login_endpoint
175
190
  )
176
- nxt = request.full_path.rstrip("?")
177
191
  if not target:
178
192
  # No login endpoint configured: fall back to a bare 401.
179
193
  return None
194
+ nxt = self.return_target()
180
195
  try:
181
- return url_for(target, next=nxt)
196
+ return url_for(target, next=nxt) if nxt else url_for(target)
182
197
  except Exception:
183
198
  # Not a known endpoint — treat it as a literal path.
199
+ if not nxt:
200
+ return target
184
201
  sep = "&" if "?" in target else "?"
185
- return f"{target}{sep}next={nxt}"
202
+ # urlencode, not interpolation: a target carrying "&" or "#"
203
+ # (both legal in a path, and "?" appears in every full_path with
204
+ # a query) would otherwise truncate the parameter. "/" stays
205
+ # literal so the common case reads as a path.
206
+ return f"{target}{sep}{urlencode({'next': nxt}, safe='/')}"
186
207
 
187
208
  def on_unauthenticated(self):
188
209
  """Send the user to the login page (401 when none is configured).
@@ -200,7 +221,13 @@ class AuthProvider:
200
221
  :data:`LOGIN_HEADER`, which ``construct-core.js`` turns into a full
201
222
  page navigation. The small body is a no-JS-style fallback for the case
202
223
  where the response is rendered anyway.
224
+
225
+ A refused state-changing request is also the last moment its body
226
+ exists, so it is stashed first (see :mod:`.resume`) — otherwise the
227
+ reader logs back in, lands on the right form, and finds it empty.
203
228
  """
229
+ if request.method not in SAFE_METHODS:
230
+ stash_form_data()
204
231
  url = self.login_url()
205
232
  if url is None:
206
233
  abort(401)
@@ -286,17 +313,117 @@ class FlaskOIDCAuth(AuthProvider):
286
313
  the ``oidc_auth`` blueprint.
287
314
  """
288
315
 
316
+ #: Characters Flask-OIDC's own ``validate_return_url`` accepts in a
317
+ #: ``next``, and the most it accepts of them. Anything outside this — a
318
+ #: comma, a semicolon, a bracket, all legal in a query string and all
319
+ #: reachable from a construct list URL — is *silently* replaced with the
320
+ #: site root, on the way in to ``/oidc/login`` as much as on the way out
321
+ #: through ``/oidc/logout``. So a target is percent-encoded into that
322
+ #: alphabet before it is handed over (see :meth:`_oidc_return_url`).
323
+ OIDC_RETURN_URL_SAFE = ":/.-@%?!&+#_=*~'"
324
+ OIDC_RETURN_URL_MAX = 256
325
+
289
326
  def __init__(
290
327
  self,
291
328
  oidc,
292
329
  groups_claim="groups",
293
330
  login_endpoint="oidc_auth.login",
294
331
  logout_endpoint="oidc_auth.logout",
332
+ return_after_expiry=True,
295
333
  ):
296
334
  self.oidc = oidc
297
335
  self.groups_claim = groups_claim
298
336
  self.login_endpoint = login_endpoint
299
337
  self.logout_endpoint = logout_endpoint
338
+ if return_after_expiry:
339
+ self._keep_return_target(oidc)
340
+
341
+ # -- surviving an expired token -----------------------------------------
342
+
343
+ def _keep_return_target(self, oidc):
344
+ """Carry the requested page through Flask-OIDC's expired-token bounce.
345
+
346
+ Flask-OIDC answers a token it cannot refresh with a redirect to its
347
+ logout route carrying only ``reason=expired``; that route's ``next``
348
+ then defaults to the site root, so the page the reader asked for is
349
+ thrown away before the route guard (which *does* build a ``next``)
350
+ ever sees the request. ``user_loggedin`` is true while the dead token
351
+ still sits in the session, which is why the guard is not consulted at
352
+ all — and why neither hook ordering nor a construct-side check can fix
353
+ this from the outside.
354
+
355
+ Wrapping needs no import of Flask-OIDC: ``_before_request`` calls
356
+ ``self.check_token_expiry()``, which resolves on the instance first,
357
+ and the adapter is handed the instance. That matters because
358
+ ``flask-oidc`` is an optional extra.
359
+
360
+ Opt out with ``return_after_expiry=False`` — for an app that wants
361
+ Flask-OIDC's behavior verbatim, or already patches it itself.
362
+ """
363
+ original = getattr(oidc, "check_token_expiry", None)
364
+ if original is None:
365
+ return # not a Flask-OIDC 2.x instance; nothing to wrap
366
+ if getattr(oidc, "_construct_return_target", False):
367
+ return # already wrapped (two adapters over one instance)
368
+
369
+ @wraps(original)
370
+ def check_token_expiry():
371
+ response = original()
372
+ if response is None:
373
+ return None
374
+ target = self.expiry_return_target()
375
+ if not target:
376
+ return response
377
+ # The body is gone with the session either way, so save it here —
378
+ # this redirect is the last point the request still carries it.
379
+ # Filed under the *unencoded* target, because the GET that comes
380
+ # back to claim it is matched on ``request.path``, which Werkzeug
381
+ # hands over decoded.
382
+ if request.method not in SAFE_METHODS:
383
+ stash_form_data()
384
+ return redirect(
385
+ url_for(self.logout_endpoint, reason="expired", next=target)
386
+ )
387
+
388
+ oidc.check_token_expiry = check_token_expiry
389
+ oidc._construct_return_target = True
390
+
391
+ def _oidc_return_url(self, target: str | None) -> str | None:
392
+ """``target``, shaped so Flask-OIDC's own validator will keep it.
393
+
394
+ Percent-encoding into :attr:`OIDC_RETURN_URL_SAFE` is enough for the
395
+ character rule and changes nothing about where the URL points.
396
+ Length is not fixable that way, so an over-long target gives up its
397
+ query string rather than the page — the reader loses their filters and
398
+ page number, not the form they were filling in (the stash is filed by
399
+ path). ``%`` is left alone so an already-escaped path is not
400
+ double-encoded.
401
+ """
402
+ if not target:
403
+ return None
404
+ encoded = quote(target, safe=self.OIDC_RETURN_URL_SAFE)
405
+ if len(encoded) <= self.OIDC_RETURN_URL_MAX:
406
+ return encoded
407
+ path = quote(urlparse(target).path, safe=self.OIDC_RETURN_URL_SAFE)
408
+ return path if len(path) <= self.OIDC_RETURN_URL_MAX else None
409
+
410
+ def return_target(self) -> str | None:
411
+ return self._oidc_return_url(super().return_target())
412
+
413
+ def expiry_return_target(self) -> str | None:
414
+ """Where to send the reader after an expired session, or None.
415
+
416
+ A request on Flask-OIDC's own blueprint hands over its own ``next``
417
+ rather than itself: by the time we returned to ``/oidc/authorize`` its
418
+ code would be spent, and the abort that follows is worse than the
419
+ front page this replaces.
420
+ """
421
+ if request.blueprint == self.login_endpoint.rsplit(".", 1)[0]:
422
+ nxt = request.args.get("next")
423
+ if not nxt or not is_safe_next(nxt):
424
+ return None
425
+ return self._oidc_return_url(nxt)
426
+ return self.return_target()
300
427
 
301
428
  def is_authenticated(self) -> bool:
302
429
  return bool(getattr(self.oidc, "user_loggedin", False))
@@ -14,6 +14,7 @@ from flask import (
14
14
  Response,
15
15
  current_app,
16
16
  g,
17
+ has_request_context,
17
18
  jsonify,
18
19
  render_template,
19
20
  request,
@@ -25,6 +26,15 @@ import jinja2
25
26
  from markupsafe import Markup
26
27
 
27
28
  from .auth import AUTH_STAMP_HEADER, auth_stamp
29
+ from .resume import DEFAULT_MAX_BYTES as RESUME_MAX_BYTES
30
+ from .resume import DEFAULT_SKIP_FIELDS as RESUME_SKIP_FIELDS
31
+ from .resume import DEFAULT_TTL as RESUME_TTL
32
+ from .resume import (
33
+ clear_on_successful_submit,
34
+ has_resumable_form,
35
+ resumed_value,
36
+ resumed_values,
37
+ )
28
38
  from .components import (
29
39
  CalendarComponent,
30
40
  ChartComponent,
@@ -848,6 +858,7 @@ class FlaskConstructUI:
848
858
  theme=None,
849
859
  design=None,
850
860
  dark_mode=None,
861
+ stylesheets=None,
851
862
  auth=None,
852
863
  search=None,
853
864
  notifications=None,
@@ -881,6 +892,10 @@ class FlaskConstructUI:
881
892
  # the look of the former `adminlte3` theme (the previous default). Pass
882
893
  # a falsy design (e.g. `design=""`) for the plain whitelabel look.
883
894
  theme = theme or "whitelabel"
895
+ # Remember whether the design was *filled in* rather than asked for.
896
+ # `effective_design` drops the implicit default once the app registers
897
+ # stylesheets of its own; an explicit choice is always honoured.
898
+ self._design_is_default = design is None
884
899
  if design is None:
885
900
  design = "adminlte"
886
901
  self.theme = theme
@@ -897,6 +912,12 @@ class FlaskConstructUI:
897
912
  )
898
913
  self.design = design
899
914
  self.dark_mode = dark_mode
915
+ # App-supplied stylesheets, linked last in <head> so they win the
916
+ # cascade over whitelabel.css and the active design. See
917
+ # `add_stylesheet` for the accepted entry forms.
918
+ self._stylesheets = []
919
+ for sheet in stylesheets or []:
920
+ self.add_stylesheet(sheet)
900
921
  self.static_url_path = static_url_path or "static"
901
922
  if static_folder is None:
902
923
  self.static_folder = path.join(path.dirname(__file__), "static")
@@ -935,6 +956,15 @@ class FlaskConstructUI:
935
956
  # provider's login_endpoint.
936
957
  app.config.setdefault("CONSTRUCT_AUTH_ENABLED", self._auth is not None)
937
958
  app.config.setdefault("CONSTRUCT_LOGIN_ENDPOINT", None)
959
+ # Resuming an interrupted request after re-authentication (see
960
+ # resume.py). The form stash rides in the Flask session, so it is
961
+ # bounded three ways: a byte cap (the cookie backend drops anything
962
+ # over 4 KB, taking the CSRF token with it), a lifetime, and a
963
+ # name-based skip list for values that must not be written there.
964
+ app.config.setdefault("CONSTRUCT_RESUME_FORMS", True)
965
+ app.config.setdefault("CONSTRUCT_RESUME_MAX_BYTES", RESUME_MAX_BYTES)
966
+ app.config.setdefault("CONSTRUCT_RESUME_TTL", RESUME_TTL)
967
+ app.config.setdefault("CONSTRUCT_RESUME_SKIP_FIELDS", RESUME_SKIP_FIELDS)
938
968
 
939
969
  @app.before_request
940
970
  def set_theme():
@@ -972,6 +1002,11 @@ class FlaskConstructUI:
972
1002
  response.headers.setdefault(AUTH_STAMP_HEADER, auth_stamp(self._auth))
973
1003
  return response
974
1004
 
1005
+ # A stash the library's own CRUD forms consume when they render it,
1006
+ # but an app template using resumed_value() only reads — so retire it
1007
+ # here once the form it belongs to has been submitted successfully.
1008
+ app.after_request(clear_on_successful_submit)
1009
+
975
1010
  @app.before_request
976
1011
  def csrf_protect():
977
1012
  # Only guard state-changing requests against construct-owned
@@ -1064,6 +1099,11 @@ class FlaskConstructUI:
1064
1099
  t=translate,
1065
1100
  icon=render_icon,
1066
1101
  next_url=resolve_next,
1102
+ # An app template can put its own form back after a re-login
1103
+ # with no Python change; see resume.py.
1104
+ resumed_value=resumed_value,
1105
+ resumed_values=resumed_values,
1106
+ has_resumable_form=has_resumable_form,
1067
1107
  )
1068
1108
  # Components render through their own standalone Jinja environment,
1069
1109
  # so the CSRF helpers, translator and icon renderer must be registered
@@ -1088,6 +1128,14 @@ class FlaskConstructUI:
1088
1128
  "construct_static",
1089
1129
  self.construct_static_path,
1090
1130
  )
1131
+
1132
+ # `flask fcu ...`. The commands resolve pages when they run, not here,
1133
+ # so this does not care that add_page may be called later. An app that
1134
+ # has already defined its own `fcu` group keeps it.
1135
+ if "fcu" not in app.cli.commands:
1136
+ from .cli import make_cli
1137
+
1138
+ app.cli.add_command(make_cli(self))
1091
1139
  if not hasattr(app, "l10n"):
1092
1140
  # Only add if not already added by the app.
1093
1141
  app.l10n = Translator()
@@ -1345,6 +1393,55 @@ class FlaskConstructUI:
1345
1393
  if self.app is not None and self._owns_l10n:
1346
1394
  self._apply_l10n_paths()
1347
1395
 
1396
+ def add_stylesheet(self, sheet):
1397
+ """Register an application stylesheet to link in every full page render.
1398
+
1399
+ This is the supported way to get your own CSS into the ``<head>``:
1400
+ overriding ``structure/base.html`` just to add a ``<link>`` means
1401
+ restating the library's internal full/partial layout selection, which
1402
+ breaks whenever those layout paths move.
1403
+
1404
+ ``sheet`` is either
1405
+
1406
+ * a **string** URL, emitted verbatim — a root-relative path
1407
+ (``"/static/css/brand.css"``) or an absolute one
1408
+ (``"https://fonts.example/…"``); or
1409
+ * a **callable** taking no arguments and returning such a URL. It is
1410
+ invoked per request, inside the request context, so it can use
1411
+ ``url_for`` — which is what you want when the app mounts its static
1412
+ files somewhere non-default::
1413
+
1414
+ fcu.add_stylesheet(lambda: url_for("static", filename="css/brand.css"))
1415
+
1416
+ Sheets are linked in registration order, after every library-owned
1417
+ stylesheet (whitelabel.css, the active design, grid.css), so app rules
1418
+ and ``--fcu-*`` overrides win the cascade without ``!important``.
1419
+ Duplicates are ignored. Only the full-page layout has a ``<head>``;
1420
+ Unpoly partial swaps reuse the links already in the document.
1421
+ """
1422
+ if not isinstance(sheet, str) and not callable(sheet):
1423
+ raise TypeError(
1424
+ "stylesheet must be a URL string or a callable returning one; "
1425
+ f"got {type(sheet).__name__}"
1426
+ )
1427
+ if sheet not in self._stylesheets:
1428
+ self._stylesheets.append(sheet)
1429
+
1430
+ @property
1431
+ def stylesheets(self):
1432
+ """Resolved hrefs of the app stylesheets, in registration order.
1433
+
1434
+ Callables registered via ``add_stylesheet`` are evaluated here, i.e. at
1435
+ render time within the request context. Entries resolving to something
1436
+ falsy are skipped, so a callable may return None to opt out per request.
1437
+ """
1438
+ hrefs = []
1439
+ for sheet in self._stylesheets:
1440
+ href = sheet() if callable(sheet) else sheet
1441
+ if href:
1442
+ hrefs.append(href)
1443
+ return hrefs
1444
+
1348
1445
  def t(self, msg_id, args=None):
1349
1446
  return self.app.l10n.t(msg_id, args)
1350
1447
 
@@ -1420,13 +1517,25 @@ class FlaskConstructUI:
1420
1517
 
1421
1518
  @property
1422
1519
  def titleFromLink(self):
1520
+ """Title of the menu entry matching the current route, or "".
1521
+
1522
+ Compared against ``request.path`` with trailing slashes normalised, the
1523
+ same way the sidebar decides which link is current. (This used to read
1524
+ a ``self.page`` attribute that has never existed on the extension, so
1525
+ it raised on every call — harmless while nothing used it, and the cause
1526
+ of an empty ``<title>`` once ``page_title`` started depending on it.)
1527
+ """
1528
+ if not has_request_context():
1529
+ return ""
1530
+ here = request.path.rstrip("/")
1423
1531
  for item in self.menu:
1424
- if item["type"] == "submenu":
1425
- for subitem in item["subitems"]:
1426
- if "link" in subitem and subitem["link"] == self.page:
1532
+ if item.get("type") == "submenu":
1533
+ for subitem in item.get("subitems", []):
1534
+ link = subitem.get("link")
1535
+ if link and link.rstrip("/") == here:
1427
1536
  return subitem["title"]
1428
-
1429
- if "link" in item and item["link"] == self.page:
1537
+ link = item.get("link")
1538
+ if link and link.rstrip("/") == here:
1430
1539
  return item["title"]
1431
1540
  return ""
1432
1541
 
@@ -1434,16 +1543,101 @@ class FlaskConstructUI:
1434
1543
  def l10n_dir(self):
1435
1544
  return path.join(path.dirname(__file__), "l10n")
1436
1545
 
1546
+ @property
1547
+ def effective_design(self):
1548
+ """The design actually in force, which may differ from ``self.design``.
1549
+
1550
+ The one difference is the *implicit* ``adminlte`` default that fills in
1551
+ for ``design=None``. An app that registers its own stylesheets is
1552
+ skinning whitelabel itself, and a default skin underneath would fight
1553
+ it: ``adminlte`` hard-codes a dark sidebar and light menu text that
1554
+ ignore the ``--fcu-*`` tokens, so an app design setting its own sidebar
1555
+ colour inherits unreadable menu text. Registering a stylesheet
1556
+ therefore drops the default.
1557
+
1558
+ An *explicitly* requested design is always honoured — including
1559
+ ``design="adminlte"`` — so layering app CSS on top of a library skin
1560
+ stays possible. This reads ``_stylesheets`` (what was registered), not
1561
+ ``stylesheets`` (what resolved this request), so a callable returning
1562
+ None cannot make the default skin flicker back in.
1563
+ """
1564
+ if self._design_is_default and self._stylesheets:
1565
+ return ""
1566
+ return self.design
1567
+
1437
1568
  @property
1438
1569
  def icon_mode(self):
1439
1570
  """Active icon-rendering mode ("bootstrap" or "material").
1440
1571
 
1441
1572
  Resolved from the theme, with any design override applied. Consumed by
1442
- the ``icon()`` template global (see ``icons.render_icon``).
1573
+ the ``icon()`` template global (see ``icons.render_icon``). Keyed on
1574
+ ``effective_design``: a design that is not applied must not impose its
1575
+ icon library either, or the UI gets Bootstrap Icons with no AdminLTE
1576
+ skin behind them.
1443
1577
  """
1444
1578
  from .icons import resolve_icon_mode
1445
1579
 
1446
- return resolve_icon_mode("whitelabel", self.design)
1580
+ return resolve_icon_mode("whitelabel", self.effective_design)
1581
+
1582
+ @property
1583
+ def language(self):
1584
+ """BCP-47 code for the ``<html lang>`` attribute.
1585
+
1586
+ The negotiated request language (see ``language.set_language_from_browser``),
1587
+ falling back to English outside a request or before negotiation has run.
1588
+ Hard-coding ``lang="en"`` while serving translated content misdeclares
1589
+ the page to screen readers, which pick their voice and pronunciation
1590
+ rules from it (WCAG 3.1.1).
1591
+ """
1592
+ return g.get("language", None) or "en"
1593
+
1594
+ @property
1595
+ def page_title(self):
1596
+ """Default contents of ``<title>``: the current page plus the app name.
1597
+
1598
+ A distinct title per page is how a screen reader user confirms where a
1599
+ navigation landed (WCAG 2.4.2). This is the fallback used by
1600
+ ``base_full.html``; templates with more context (the CRUD list, create
1601
+ and edit views) override ``{% block title %}`` instead, since they know
1602
+ the record and action and this property does not.
1603
+
1604
+ The page name comes from the menu entry matching the current route, so
1605
+ any menu-linked page gets a useful title with no view-layer changes.
1606
+
1607
+ Falls back to the app title on any resolution error: Jinja swallows an
1608
+ exception raised in a property and renders nothing, and an empty
1609
+ ``<title>`` is itself a Level A failure (2.4.2).
1610
+ """
1611
+ try:
1612
+ name = (self.titleFromLink or "").strip()
1613
+ except Exception: # pragma: no cover - defensive
1614
+ return self.title
1615
+ if not name or name == self.title:
1616
+ return self.title
1617
+ return self.compose_title(self.t(name))
1618
+
1619
+ @property
1620
+ def page_heading(self):
1621
+ """Default ``<h1>`` text: the current page's name, else the app title.
1622
+
1623
+ Component pages compose their own content, so the library cannot know
1624
+ which of their headings is the page heading — but every page needs
1625
+ exactly one ``<h1>`` (2.4.6 / 1.3.1). The layouts render this one
1626
+ visually hidden; a template that shows its own heading overrides
1627
+ ``{% block page_heading %}`` instead (the CRUD views all do).
1628
+ """
1629
+ try:
1630
+ name = (self.titleFromLink or "").strip()
1631
+ except Exception: # pragma: no cover - defensive
1632
+ name = ""
1633
+ return self.t(name) if name else self.title
1634
+
1635
+ def compose_title(self, name):
1636
+ """Join a page-specific name to the app title for ``<title>``."""
1637
+ name = (name or "").strip()
1638
+ if not name or name == self.title:
1639
+ return self.title
1640
+ return f"{name} – {self.title}"
1447
1641
 
1448
1642
  @property
1449
1643
  def color_scheme(self):
@@ -1465,11 +1659,13 @@ class FlaskConstructUI:
1465
1659
  """Static path of the active design stylesheet, or None.
1466
1660
 
1467
1661
  Designs are shipped under ``static/designs/<design>.css``. Returns None
1468
- when no design is selected or the file does not exist on disk.
1662
+ when no design is in force (see ``effective_design``) or the file does
1663
+ not exist on disk.
1469
1664
  """
1470
- if not self.design:
1665
+ design = self.effective_design
1666
+ if not design:
1471
1667
  return None
1472
- rel = path.join("designs", f"{self.design}.css")
1668
+ rel = path.join("designs", f"{design}.css")
1473
1669
  if not path.exists(path.join(self.static_folder, rel)):
1474
1670
  return None
1475
1671
  return rel.replace(path.sep, "/")