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.
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/PKG-INFO +3 -1
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/__init__.py +13 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/auth.py +130 -3
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/base.py +206 -10
- flask_construct_ui-0.11.2/flask_construct_ui/cli.py +210 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/components.py +65 -7
- flask_construct_ui-0.11.2/flask_construct_ui/contrast.py +356 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/crud.py +304 -13
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/icons.py +34 -5
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/l10n/en/main.flt +42 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/l10n/sv/main.flt +43 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/language.py +26 -1
- flask_construct_ui-0.11.2/flask_construct_ui/resume.py +319 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/schema.py +20 -2
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/grid.css +21 -2
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/whitelabel.css +406 -26
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/designs/adminlte.css +29 -6
- flask_construct_ui-0.11.2/flask_construct_ui/static/js/construct-core.min.js +1 -0
- flask_construct_ui-0.11.2/flask_construct_ui/static/js/construct-grid.min.js +1 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/js/prosemirror-bundle.min.js +6 -6
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/calendar.html +1 -1
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/calendarmini.html +9 -2
- flask_construct_ui-0.11.2/flask_construct_ui/templates/components/chart.html +12 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/form.html +9 -3
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/grid.html +7 -1
- flask_construct_ui-0.11.2/flask_construct_ui/templates/components/live.html +63 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/rich_text.html +3 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/table.html +1 -1
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/tabs.html +15 -5
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/_macros.html +231 -79
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/_many_related_options.html +12 -8
- flask_construct_ui-0.11.2/flask_construct_ui/templates/crud/_related_options.html +18 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/create.html +6 -5
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/edit.html +10 -11
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/listindex.html +100 -45
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/related_crud_tab_form.html +22 -6
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/related_crud_tab_list.html +57 -37
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/crud/tab_view.html +5 -2
- flask_construct_ui-0.11.2/flask_construct_ui/templates/crud/view.html +56 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/errors/forbidden.html +5 -2
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/navbar/notifications.html +11 -3
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/navbar/search_results.html +6 -3
- flask_construct_ui-0.11.2/flask_construct_ui/templates/style/_flashes.html +37 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/style/base_full.html +83 -10
- flask_construct_ui-0.11.2/flask_construct_ui/templates/style/base_partial.html +23 -0
- flask_construct_ui-0.11.2/flask_construct_ui/testing.py +238 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/PKG-INFO +3 -1
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/SOURCES.txt +5 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/requires.txt +3 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/pyproject.toml +11 -2
- flask_construct_ui-0.11.0/flask_construct_ui/static/js/construct-core.min.js +0 -1
- flask_construct_ui-0.11.0/flask_construct_ui/static/js/construct-grid.min.js +0 -1
- flask_construct_ui-0.11.0/flask_construct_ui/templates/components/chart.html +0 -5
- flask_construct_ui-0.11.0/flask_construct_ui/templates/components/live.html +0 -49
- flask_construct_ui-0.11.0/flask_construct_ui/templates/crud/_related_options.html +0 -15
- flask_construct_ui-0.11.0/flask_construct_ui/templates/style/_flashes.html +0 -16
- flask_construct_ui-0.11.0/flask_construct_ui/templates/style/base_partial.html +0 -16
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/LICENSE +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/README.md +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/csrf.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/data.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/data_sqlalchemy.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/forms.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/grid.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/headless.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/navbar.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/navigation.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/orgmode.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/pagination.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/py.typed +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/sanitize.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/css/unpoly.min.css +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/designs/sandstone.css +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
- {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
- {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
- {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
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
- {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
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/static/js/unpoly.min.js +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/_form_field.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/button.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/card.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/grid_layout.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/grid_notice.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/kanban.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/main.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/markup.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/text.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/structure/base.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/templates/structure/public.html +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui/validation.py +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
- {flask_construct_ui-0.11.0 → flask_construct_ui-0.11.2}/flask_construct_ui.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
1425
|
-
for subitem in item
|
|
1426
|
-
|
|
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
|
|
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.
|
|
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
|
|
1662
|
+
when no design is in force (see ``effective_design``) or the file does
|
|
1663
|
+
not exist on disk.
|
|
1469
1664
|
"""
|
|
1470
|
-
|
|
1665
|
+
design = self.effective_design
|
|
1666
|
+
if not design:
|
|
1471
1667
|
return None
|
|
1472
|
-
rel = path.join("designs", f"{
|
|
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, "/")
|