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