flask-construct-ui 0.9.5__py3-none-any.whl
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/__init__.py +82 -0
- flask_construct_ui/auth.py +290 -0
- flask_construct_ui/base.py +1259 -0
- flask_construct_ui/components.py +812 -0
- flask_construct_ui/crud.py +1641 -0
- flask_construct_ui/csrf.py +39 -0
- flask_construct_ui/data.py +257 -0
- flask_construct_ui/data_sqlalchemy.py +304 -0
- flask_construct_ui/forms.py +130 -0
- flask_construct_ui/headless.py +110 -0
- flask_construct_ui/icons.py +158 -0
- flask_construct_ui/l10n/en/main.flt +82 -0
- flask_construct_ui/l10n/sv/main.flt +82 -0
- flask_construct_ui/language.py +100 -0
- flask_construct_ui/navbar.py +54 -0
- flask_construct_ui/navigation.py +58 -0
- flask_construct_ui/orgmode.py +338 -0
- flask_construct_ui/pagination.py +39 -0
- flask_construct_ui/py.typed +0 -0
- flask_construct_ui/sanitize.py +25 -0
- flask_construct_ui/schema.py +507 -0
- flask_construct_ui/static/css/prosemirror-bundle.min.css +1 -0
- flask_construct_ui/static/css/unpoly.min.css +6 -0
- flask_construct_ui/static/js/construct-core.min.js +1 -0
- flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
- flask_construct_ui/static/js/unpoly.min.js +1 -0
- flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +720 -0
- flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +94 -0
- flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +64 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +21 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +5 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +202 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
- flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +26 -0
- flask_construct_ui/templates/structure/base.html +5 -0
- flask_construct_ui/templates/structure/flashes.html +7 -0
- flask_construct_ui/templates/structure/public.html +18 -0
- flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +69 -0
- flask_construct_ui/templates/themes/whitelabel/components/button.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/components/calendar.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/card.html +25 -0
- flask_construct_ui/templates/themes/whitelabel/components/chart.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/form.html +61 -0
- flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/components/live.html +40 -0
- flask_construct_ui/templates/themes/whitelabel/components/main.html +4 -0
- flask_construct_ui/templates/themes/whitelabel/components/markup.html +1 -0
- flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +19 -0
- flask_construct_ui/templates/themes/whitelabel/components/table.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/tabs.html +30 -0
- flask_construct_ui/templates/themes/whitelabel/components/text.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +5 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +962 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +98 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +136 -0
- flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +15 -0
- flask_construct_ui/templates/themes/whitelabel/crud/create.html +45 -0
- flask_construct_ui/templates/themes/whitelabel/crud/edit.html +125 -0
- flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +238 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +33 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +125 -0
- flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +140 -0
- flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +38 -0
- flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +26 -0
- flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +31 -0
- flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +16 -0
- flask_construct_ui/templates/themes/whitelabel/style/base_full.html +181 -0
- flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +16 -0
- flask_construct_ui/validation.py +422 -0
- flask_construct_ui-0.9.5.dist-info/METADATA +60 -0
- flask_construct_ui-0.9.5.dist-info/RECORD +80 -0
- flask_construct_ui-0.9.5.dist-info/WHEEL +5 -0
- flask_construct_ui-0.9.5.dist-info/licenses/LICENSE +165 -0
- flask_construct_ui-0.9.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
from os import path
|
|
5
|
+
from functools import wraps
|
|
6
|
+
import logging
|
|
7
|
+
from importlib.metadata import entry_points
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("flask_construct_ui")
|
|
10
|
+
|
|
11
|
+
from flask import (
|
|
12
|
+
Flask,
|
|
13
|
+
Blueprint,
|
|
14
|
+
render_template,
|
|
15
|
+
send_from_directory,
|
|
16
|
+
request,
|
|
17
|
+
url_for,
|
|
18
|
+
current_app,
|
|
19
|
+
Response,
|
|
20
|
+
stream_with_context,
|
|
21
|
+
g,
|
|
22
|
+
)
|
|
23
|
+
from markupsafe import Markup
|
|
24
|
+
from .language import Translator, set_language_from_browser
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
from .components import (
|
|
27
|
+
Component,
|
|
28
|
+
components,
|
|
29
|
+
Field,
|
|
30
|
+
LiveComponent,
|
|
31
|
+
ChartComponent,
|
|
32
|
+
CalendarComponent,
|
|
33
|
+
TabsComponent,
|
|
34
|
+
FormComponent,
|
|
35
|
+
jinja_env,
|
|
36
|
+
)
|
|
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
|
+
from .csrf import (
|
|
44
|
+
CSRF_SESSION_KEY,
|
|
45
|
+
CSRF_FORM_FIELD,
|
|
46
|
+
CSRF_HEADER,
|
|
47
|
+
CSRF_UNSAFE_METHODS,
|
|
48
|
+
generate_csrf_token,
|
|
49
|
+
csrf_field,
|
|
50
|
+
validate_csrf_token,
|
|
51
|
+
)
|
|
52
|
+
from .pagination import (
|
|
53
|
+
DEFAULT_PAGE_SIZE,
|
|
54
|
+
MAX_PAGE_SIZE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_action(name, icon):
|
|
59
|
+
def decorator(f):
|
|
60
|
+
f._list_action_info = {"name": name, "icon": icon}
|
|
61
|
+
return f
|
|
62
|
+
|
|
63
|
+
return decorator
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def tab_action(name, icon):
|
|
67
|
+
def decorator(f):
|
|
68
|
+
f._tab_action_info = {"name": name, "icon": icon}
|
|
69
|
+
return f
|
|
70
|
+
|
|
71
|
+
return decorator
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def edit_action(name, icon):
|
|
75
|
+
def decorator(f):
|
|
76
|
+
f._edit_action_info = {"name": name, "icon": icon}
|
|
77
|
+
return f
|
|
78
|
+
|
|
79
|
+
return decorator
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def expose(url="/", methods=None):
|
|
83
|
+
def wrap(f):
|
|
84
|
+
if not hasattr(f, "_urls"):
|
|
85
|
+
f._urls = []
|
|
86
|
+
if methods is None:
|
|
87
|
+
match f.__name__.lower():
|
|
88
|
+
case "patch":
|
|
89
|
+
method = ("PATCH",)
|
|
90
|
+
case "delete":
|
|
91
|
+
method = ("DELETE",)
|
|
92
|
+
case "post":
|
|
93
|
+
method = ("POST",)
|
|
94
|
+
case _:
|
|
95
|
+
method = ("GET",)
|
|
96
|
+
else:
|
|
97
|
+
method = methods
|
|
98
|
+
f._urls.append((url, method))
|
|
99
|
+
return f
|
|
100
|
+
|
|
101
|
+
return wrap
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def datasource(name=None):
|
|
105
|
+
def wrap(f):
|
|
106
|
+
f._datasource_name = name or f.__name__
|
|
107
|
+
return f
|
|
108
|
+
|
|
109
|
+
return wrap
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
SSE_DONE = "done"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def livesource(name=None, interval=10):
|
|
116
|
+
def wrap(f):
|
|
117
|
+
f._livesource_name = name or f.__name__
|
|
118
|
+
f._livesource_interval = interval
|
|
119
|
+
return f
|
|
120
|
+
|
|
121
|
+
return wrap
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Base views
|
|
125
|
+
def _wrap_view(f):
|
|
126
|
+
# Avoid wrapping view method twice
|
|
127
|
+
if hasattr(f, "_wrapped"):
|
|
128
|
+
return f
|
|
129
|
+
|
|
130
|
+
@wraps(f)
|
|
131
|
+
def inner(self, *args, **kwargs):
|
|
132
|
+
# Store current admin view
|
|
133
|
+
# h.set_current_view(self)
|
|
134
|
+
|
|
135
|
+
return self._run_view(f, *args, **kwargs)
|
|
136
|
+
|
|
137
|
+
inner._wrapped = True
|
|
138
|
+
|
|
139
|
+
return inner
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class PageMeta(type):
|
|
143
|
+
def __init__(cls, classname, bases, fields):
|
|
144
|
+
type.__init__(cls, classname, bases, fields)
|
|
145
|
+
|
|
146
|
+
cls._curls = []
|
|
147
|
+
cls._default_view = None
|
|
148
|
+
cls.list_actions = []
|
|
149
|
+
cls.tab_actions = []
|
|
150
|
+
cls.edit_actions = []
|
|
151
|
+
cls._datasources = {}
|
|
152
|
+
cls._livesources = {}
|
|
153
|
+
cls._auth_rules = {}
|
|
154
|
+
|
|
155
|
+
for p in dir(cls):
|
|
156
|
+
attr = getattr(cls, p)
|
|
157
|
+
|
|
158
|
+
if hasattr(attr, "_datasource_name"):
|
|
159
|
+
cls._datasources[attr._datasource_name] = p
|
|
160
|
+
|
|
161
|
+
if hasattr(attr, "_livesource_name"):
|
|
162
|
+
cls._livesources[attr._livesource_name] = (p, attr._livesource_interval)
|
|
163
|
+
|
|
164
|
+
if hasattr(attr, "_construct_auth"):
|
|
165
|
+
cls._auth_rules[p] = attr._construct_auth
|
|
166
|
+
|
|
167
|
+
if hasattr(attr, "_urls"):
|
|
168
|
+
# Collect methods
|
|
169
|
+
for url, methods in attr._urls:
|
|
170
|
+
cls._curls.append((url, p, methods))
|
|
171
|
+
|
|
172
|
+
if url == "/":
|
|
173
|
+
cls._default_view = p
|
|
174
|
+
|
|
175
|
+
if hasattr(attr, "_list_action_info"):
|
|
176
|
+
info = attr._list_action_info
|
|
177
|
+
cls.list_actions.append(
|
|
178
|
+
{
|
|
179
|
+
"endpoint": p,
|
|
180
|
+
"name": info["name"],
|
|
181
|
+
"icon": info["icon"],
|
|
182
|
+
"url": url,
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if hasattr(attr, "_tab_action_info"):
|
|
187
|
+
info = attr._tab_action_info
|
|
188
|
+
cls.tab_actions.append(
|
|
189
|
+
{
|
|
190
|
+
"endpoint": p,
|
|
191
|
+
"name": info["name"],
|
|
192
|
+
"icon": info["icon"],
|
|
193
|
+
"url": url,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if hasattr(attr, "_edit_action_info"):
|
|
198
|
+
info = attr._edit_action_info
|
|
199
|
+
cls.edit_actions.append(
|
|
200
|
+
{
|
|
201
|
+
"endpoint": p,
|
|
202
|
+
"name": info["name"],
|
|
203
|
+
"icon": info["icon"],
|
|
204
|
+
"url": url,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Wrap views
|
|
209
|
+
setattr(cls, p, _wrap_view(attr))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class PageBase(metaclass=PageMeta):
|
|
213
|
+
url = None
|
|
214
|
+
name = None
|
|
215
|
+
# Semantic icon name resolved per icon mode (see icons.ICONS). Apps may
|
|
216
|
+
# override with their own semantic name or a raw icon string.
|
|
217
|
+
icon = "page"
|
|
218
|
+
endpoint = None
|
|
219
|
+
# Page-wide auth defaults (only enforced when an AuthProvider is configured).
|
|
220
|
+
# Set ``login_required = False`` to make every route on the page public, or
|
|
221
|
+
# ``required_groups = [...]`` to gate every route — including RCRUD
|
|
222
|
+
# sub-routes — on group membership. Per-method @public / @require_groups
|
|
223
|
+
# decorators override these.
|
|
224
|
+
login_required = True
|
|
225
|
+
required_groups = None
|
|
226
|
+
|
|
227
|
+
def __init__(self, static_folder=None, static_url_path=None, menu=None):
|
|
228
|
+
self.name = self.name or self.__class__.__name__.capitalize()
|
|
229
|
+
self.endpoint = self.endpoint or self.__class__.__name__.lower()
|
|
230
|
+
|
|
231
|
+
self.static_folder = static_folder
|
|
232
|
+
self.static_url_path = static_url_path
|
|
233
|
+
self._urls = []
|
|
234
|
+
for p in dir(self):
|
|
235
|
+
attr = getattr(self, p)
|
|
236
|
+
if hasattr(attr, "_urls"):
|
|
237
|
+
self._urls = attr._urls
|
|
238
|
+
menu_val = menu if menu is not None else getattr(self, "menu", None)
|
|
239
|
+
if menu_val is None:
|
|
240
|
+
link = self.url.lstrip("/") if self.url else self.endpoint
|
|
241
|
+
self.menu = {
|
|
242
|
+
"type": "item",
|
|
243
|
+
"title": self.name,
|
|
244
|
+
"icon": self.icon,
|
|
245
|
+
"link": link,
|
|
246
|
+
}
|
|
247
|
+
else:
|
|
248
|
+
self.menu = menu_val
|
|
249
|
+
|
|
250
|
+
self.datasources = {}
|
|
251
|
+
for name, method_name in self._datasources.items():
|
|
252
|
+
self.datasources[name] = getattr(self, method_name)
|
|
253
|
+
|
|
254
|
+
self.livesources = {}
|
|
255
|
+
for name, (method_name, interval) in self._livesources.items():
|
|
256
|
+
self.livesources[name] = (getattr(self, method_name), interval)
|
|
257
|
+
|
|
258
|
+
def t(self, msg_id, args=None):
|
|
259
|
+
return self.construct.t(msg_id, args)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def current_user(self):
|
|
263
|
+
"""The logged-in user as a normalized dict, or None.
|
|
264
|
+
|
|
265
|
+
None when no ``AuthProvider`` is configured or nobody is signed in.
|
|
266
|
+
Portable across auth stacks -- see ``AuthProvider.current_user``. Must
|
|
267
|
+
be accessed within a request context.
|
|
268
|
+
"""
|
|
269
|
+
construct = getattr(self, "construct", None)
|
|
270
|
+
auth = construct.auth if construct is not None else None
|
|
271
|
+
return auth.current_user() if auth is not None else None
|
|
272
|
+
|
|
273
|
+
def get_blueprint(self, construct):
|
|
274
|
+
"""
|
|
275
|
+
Create Flask blueprint.
|
|
276
|
+
"""
|
|
277
|
+
# Store construct instance
|
|
278
|
+
self.construct = construct
|
|
279
|
+
|
|
280
|
+
# If the static_url_path is not provided, use the construct's
|
|
281
|
+
if not self.static_url_path:
|
|
282
|
+
self.static_url_path = construct.static_url_path
|
|
283
|
+
|
|
284
|
+
# Generate URL
|
|
285
|
+
if not self.url:
|
|
286
|
+
self.url = f"{construct.url}{self.endpoint}"
|
|
287
|
+
|
|
288
|
+
if getattr(construct, "enforce_trailing_slash", False):
|
|
289
|
+
if self.url and self.url != "/" and not self.url.endswith("/"):
|
|
290
|
+
self.url = self.url + "/"
|
|
291
|
+
|
|
292
|
+
# If we're working from the root of the site, set prefix to None
|
|
293
|
+
if self.url == "/":
|
|
294
|
+
self.url = None
|
|
295
|
+
# prevent construct static files from conflicting with flask static files
|
|
296
|
+
if not self.static_url_path:
|
|
297
|
+
self.static_folder = "static"
|
|
298
|
+
self.static_url_path = "/static/construct"
|
|
299
|
+
|
|
300
|
+
# If name is not provided, use class name
|
|
301
|
+
if self.name is None:
|
|
302
|
+
self.name = self.__class__.__name__
|
|
303
|
+
|
|
304
|
+
# Create blueprint and register rules
|
|
305
|
+
|
|
306
|
+
self.blueprint = Blueprint(
|
|
307
|
+
self.endpoint,
|
|
308
|
+
__name__,
|
|
309
|
+
url_prefix=self.url,
|
|
310
|
+
template_folder="templates",
|
|
311
|
+
static_folder=self.static_folder,
|
|
312
|
+
static_url_path=self.static_url_path,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
for url, name, methods in self._curls:
|
|
316
|
+
kwargs = {"methods": methods}
|
|
317
|
+
if getattr(construct, "strict_slashes", None) is not None:
|
|
318
|
+
kwargs["strict_slashes"] = construct.strict_slashes
|
|
319
|
+
|
|
320
|
+
if getattr(construct, "enforce_trailing_slash", False):
|
|
321
|
+
if url != "/" and url != "" and not url.endswith("/"):
|
|
322
|
+
if "." not in url.split("/")[-1]:
|
|
323
|
+
url = url + "/"
|
|
324
|
+
|
|
325
|
+
self.blueprint.add_url_rule(url, name, getattr(self, name), **kwargs)
|
|
326
|
+
|
|
327
|
+
return self.blueprint
|
|
328
|
+
|
|
329
|
+
def _run_view(self, f, *args, **kwargs):
|
|
330
|
+
return f(self, *args, **kwargs)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class ComponentPageBase(PageBase):
|
|
334
|
+
"""Base class for component-based pages."""
|
|
335
|
+
|
|
336
|
+
layout = None
|
|
337
|
+
|
|
338
|
+
def get_blueprint(self, construct):
|
|
339
|
+
blueprint = super().get_blueprint(construct)
|
|
340
|
+
self._register_live_endpoints(blueprint, construct)
|
|
341
|
+
return blueprint
|
|
342
|
+
|
|
343
|
+
def _register_live_endpoints(self, blueprint, construct):
|
|
344
|
+
|
|
345
|
+
for source_name, (method, interval) in self.livesources.items():
|
|
346
|
+
|
|
347
|
+
def make_poll_view(m, sname, page_self):
|
|
348
|
+
def poll_view():
|
|
349
|
+
root = page_self.definition()
|
|
350
|
+
comp = page_self._find_live_component(root, sname)
|
|
351
|
+
data = m()
|
|
352
|
+
if inspect.isgenerator(data):
|
|
353
|
+
data = next(data, {})
|
|
354
|
+
if comp is not None:
|
|
355
|
+
comp._live_data = data
|
|
356
|
+
page_self._resolve_livesources(root)
|
|
357
|
+
return comp.render()
|
|
358
|
+
return "", 204
|
|
359
|
+
|
|
360
|
+
poll_view.__name__ = f"live_poll_{sname}"
|
|
361
|
+
return poll_view
|
|
362
|
+
|
|
363
|
+
def make_sse_view(m, sname):
|
|
364
|
+
def sse_view():
|
|
365
|
+
def generate():
|
|
366
|
+
result = m()
|
|
367
|
+
if inspect.isgenerator(result):
|
|
368
|
+
for item in result:
|
|
369
|
+
yield f"event: update\ndata: {json.dumps(item)}\n\n"
|
|
370
|
+
yield f"event: {SSE_DONE}\ndata: {json.dumps({'status': SSE_DONE})}\n\n"
|
|
371
|
+
else:
|
|
372
|
+
yield f"event: update\ndata: {json.dumps(result)}\n\n"
|
|
373
|
+
|
|
374
|
+
return Response(
|
|
375
|
+
stream_with_context(generate()),
|
|
376
|
+
mimetype="text/event-stream",
|
|
377
|
+
headers={
|
|
378
|
+
"Cache-Control": "no-cache",
|
|
379
|
+
"X-Accel-Buffering": "no",
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
sse_view.__name__ = f"live_sse_{sname}"
|
|
384
|
+
return sse_view
|
|
385
|
+
|
|
386
|
+
poll_url = f"/live/{source_name}/poll"
|
|
387
|
+
sse_url = f"/live/{source_name}/sse"
|
|
388
|
+
if getattr(construct, "enforce_trailing_slash", False):
|
|
389
|
+
poll_url += "/"
|
|
390
|
+
sse_url += "/"
|
|
391
|
+
|
|
392
|
+
rule_kwargs = {"methods": ["GET"]}
|
|
393
|
+
if getattr(construct, "strict_slashes", None) is not None:
|
|
394
|
+
rule_kwargs["strict_slashes"] = construct.strict_slashes
|
|
395
|
+
|
|
396
|
+
blueprint.add_url_rule(
|
|
397
|
+
poll_url,
|
|
398
|
+
f"live_poll_{source_name}",
|
|
399
|
+
make_poll_view(method, source_name, self),
|
|
400
|
+
**rule_kwargs,
|
|
401
|
+
)
|
|
402
|
+
blueprint.add_url_rule(
|
|
403
|
+
sse_url,
|
|
404
|
+
f"live_sse_{source_name}",
|
|
405
|
+
make_sse_view(method, source_name),
|
|
406
|
+
**rule_kwargs,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def _find_live_component(self, root: Component, source_name: str):
|
|
410
|
+
return self.find_component(
|
|
411
|
+
root,
|
|
412
|
+
lambda c: isinstance(c, LiveComponent) and c.live_source == source_name,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _resolve_livesources(self, component: Component):
|
|
416
|
+
|
|
417
|
+
if isinstance(component, LiveComponent):
|
|
418
|
+
if component.sse_url is not None:
|
|
419
|
+
pass # URLs already set from explicit sse_url / poll_url params
|
|
420
|
+
elif component.live_source and component.live_source in self.livesources:
|
|
421
|
+
method, interval = self.livesources[component.live_source]
|
|
422
|
+
component._poll_url = url_for(
|
|
423
|
+
f"{self.endpoint}.live_poll_{component.live_source}"
|
|
424
|
+
)
|
|
425
|
+
component._resolved_interval = interval
|
|
426
|
+
data = method()
|
|
427
|
+
is_gen = inspect.isgenerator(data)
|
|
428
|
+
if is_gen:
|
|
429
|
+
data = next(data, {})
|
|
430
|
+
component._live_data = data
|
|
431
|
+
|
|
432
|
+
if inspect.isgeneratorfunction(method) or is_gen:
|
|
433
|
+
component._sse_url = url_for(
|
|
434
|
+
f"{self.endpoint}.live_sse_{component.live_source}"
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
component._sse_url = ""
|
|
438
|
+
|
|
439
|
+
for child in component.get_children():
|
|
440
|
+
self._resolve_livesources(child)
|
|
441
|
+
|
|
442
|
+
def _resolve_datasources(self, component: Component):
|
|
443
|
+
|
|
444
|
+
if isinstance(component, (ChartComponent, CalendarComponent)):
|
|
445
|
+
if hasattr(component, "datasource") and component.datasource:
|
|
446
|
+
if component.datasource in self.datasources:
|
|
447
|
+
component.data = self.datasources[component.datasource]()
|
|
448
|
+
|
|
449
|
+
for child in component.get_children():
|
|
450
|
+
self._resolve_datasources(child)
|
|
451
|
+
|
|
452
|
+
def build_component_from_structure(self, structure: dict) -> "Component":
|
|
453
|
+
|
|
454
|
+
component_type_str = structure.get("type")
|
|
455
|
+
if not component_type_str:
|
|
456
|
+
raise ValueError("Component structure must have a 'type'")
|
|
457
|
+
|
|
458
|
+
component_class = getattr(components, component_type_str, None)
|
|
459
|
+
if not component_class:
|
|
460
|
+
raise ValueError(f"Unknown component type: {component_type_str}")
|
|
461
|
+
|
|
462
|
+
kwargs = {}
|
|
463
|
+
for key, value in structure.items():
|
|
464
|
+
if key == "type":
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
if key in ["label", "title", "content"] and isinstance(value, str):
|
|
468
|
+
kwargs[key] = self.t(value)
|
|
469
|
+
elif key == "components":
|
|
470
|
+
kwargs[key] = [self.build_component_from_structure(c) for c in value]
|
|
471
|
+
elif key == "tabs":
|
|
472
|
+
kwargs[key] = []
|
|
473
|
+
for tab in value:
|
|
474
|
+
new_tab = tab.copy()
|
|
475
|
+
if "label" in new_tab:
|
|
476
|
+
new_tab["label"] = self.t(new_tab["label"])
|
|
477
|
+
if "component" in new_tab and isinstance(
|
|
478
|
+
new_tab["component"], dict
|
|
479
|
+
):
|
|
480
|
+
new_tab["component"] = self.build_component_from_structure(
|
|
481
|
+
new_tab["component"]
|
|
482
|
+
)
|
|
483
|
+
kwargs[key].append(new_tab)
|
|
484
|
+
elif key == "fields" and component_class == FormComponent:
|
|
485
|
+
for field in value:
|
|
486
|
+
if "label" in field:
|
|
487
|
+
field["label"] = self.t(field["label"])
|
|
488
|
+
kwargs[key] = [Field(**f) for f in value]
|
|
489
|
+
elif key == "actions" and component_class == FormComponent:
|
|
490
|
+
kwargs[key] = [self.build_component_from_structure(a) for a in value]
|
|
491
|
+
elif key == "action" and isinstance(value, str) and value.startswith("."):
|
|
492
|
+
method_name = value.lstrip(".")
|
|
493
|
+
kwargs[key] = getattr(self, method_name, None)
|
|
494
|
+
else:
|
|
495
|
+
kwargs[key] = value
|
|
496
|
+
|
|
497
|
+
return component_class(**kwargs)
|
|
498
|
+
|
|
499
|
+
def definition(self) -> "Component":
|
|
500
|
+
if self.layout:
|
|
501
|
+
return self.build_component_from_structure(self.layout)
|
|
502
|
+
raise NotImplementedError(
|
|
503
|
+
"ComponentPage must either define a 'layout' attribute "
|
|
504
|
+
"or override the 'definition()' method."
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def _set_active_tabs(self, component: Component):
|
|
508
|
+
|
|
509
|
+
if isinstance(component, TabsComponent) and component.id:
|
|
510
|
+
active_tab = request.args.get(f"{component.id}_tab", default=None, type=int)
|
|
511
|
+
if active_tab is not None:
|
|
512
|
+
component.active_tab = active_tab
|
|
513
|
+
|
|
514
|
+
for child in component.get_children():
|
|
515
|
+
self._set_active_tabs(child)
|
|
516
|
+
|
|
517
|
+
@expose("/")
|
|
518
|
+
def index(self):
|
|
519
|
+
root_component = self.definition()
|
|
520
|
+
self._resolve_datasources(root_component)
|
|
521
|
+
self._resolve_livesources(root_component)
|
|
522
|
+
self._set_active_tabs(root_component)
|
|
523
|
+
return self.render_component(root_component)
|
|
524
|
+
|
|
525
|
+
@expose("/_part/<string:component_id>/<int:part_index>")
|
|
526
|
+
def get_part(self, component_id: str, part_index: int):
|
|
527
|
+
|
|
528
|
+
root_component = self.definition()
|
|
529
|
+
|
|
530
|
+
target_container = self.find_component_by_id(root_component, component_id)
|
|
531
|
+
|
|
532
|
+
if not target_container:
|
|
533
|
+
return "Container component not found", 404
|
|
534
|
+
|
|
535
|
+
part_component = None
|
|
536
|
+
if isinstance(target_container, TabsComponent):
|
|
537
|
+
if 0 <= part_index < len(target_container.tabs):
|
|
538
|
+
part_component = target_container.tabs[part_index].get("component")
|
|
539
|
+
elif hasattr(target_container, "components") and isinstance(
|
|
540
|
+
target_container.components, list
|
|
541
|
+
):
|
|
542
|
+
if 0 <= part_index < len(target_container.components):
|
|
543
|
+
part_component = target_container.components[part_index]
|
|
544
|
+
|
|
545
|
+
if part_component:
|
|
546
|
+
return part_component.render()
|
|
547
|
+
else:
|
|
548
|
+
return "Part not found", 404
|
|
549
|
+
|
|
550
|
+
def render_component(self, component: Component):
|
|
551
|
+
return render_template(
|
|
552
|
+
f"themes/{self.construct.theme}/components/main.html",
|
|
553
|
+
component=component,
|
|
554
|
+
construct=self.construct,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def find_component(
|
|
558
|
+
self, component: Component, condition: Callable[[Component], bool]
|
|
559
|
+
) -> Optional[Component]:
|
|
560
|
+
"""
|
|
561
|
+
Traverses the component tree and returns the first component that satisfies the condition (DFS).
|
|
562
|
+
"""
|
|
563
|
+
if not isinstance(component, Component):
|
|
564
|
+
return None
|
|
565
|
+
if condition(component):
|
|
566
|
+
return component
|
|
567
|
+
for child in component.get_children():
|
|
568
|
+
found = self.find_component(child, condition)
|
|
569
|
+
if found:
|
|
570
|
+
return found
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
def find_component_by_id(
|
|
574
|
+
self, root_component: Component, component_id: str
|
|
575
|
+
) -> Optional[Component]:
|
|
576
|
+
"""
|
|
577
|
+
Finds a component by its ID.
|
|
578
|
+
"""
|
|
579
|
+
return self.find_component(
|
|
580
|
+
root_component, lambda c: hasattr(c, "id") and c.id == component_id
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class BaseIndexView(PageBase):
|
|
585
|
+
endpoint = "construct_index"
|
|
586
|
+
name = "Index"
|
|
587
|
+
|
|
588
|
+
@expose("/")
|
|
589
|
+
def index(self):
|
|
590
|
+
return render_template("structure/base.html", construct=self.construct)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class ExtensionsPage(ComponentPageBase):
|
|
594
|
+
endpoint = "construct_extensions"
|
|
595
|
+
name = "Extensions"
|
|
596
|
+
icon = "extension"
|
|
597
|
+
|
|
598
|
+
def definition(self) -> Component:
|
|
599
|
+
rows = []
|
|
600
|
+
for ext in self.construct._extensions:
|
|
601
|
+
status = ext["status"]
|
|
602
|
+
if status.startswith("Loaded"):
|
|
603
|
+
bg_color = "var(--fcu-success)"
|
|
604
|
+
text_color = "var(--fcu-on-success)"
|
|
605
|
+
elif status == "Error":
|
|
606
|
+
bg_color = "var(--fcu-danger)"
|
|
607
|
+
text_color = "var(--fcu-on-danger)"
|
|
608
|
+
else:
|
|
609
|
+
bg_color = "var(--fcu-secondary)"
|
|
610
|
+
text_color = "var(--fcu-on-secondary)"
|
|
611
|
+
|
|
612
|
+
status_badge = Markup( # nosemgrep: explicit-unescape-with-markup # nosec B704
|
|
613
|
+
f'<span style="background-color: {bg_color}; color: {text_color}; '
|
|
614
|
+
f"padding: 2px 6px; border-radius: var(--fcu-border-radius); font-size: var(--fcu-font-size-sm); "
|
|
615
|
+
f'font-weight: bold; display: inline-block;">{status}</span>'
|
|
616
|
+
)
|
|
617
|
+
details = ext["error"] if ext["error"] else ext["value"]
|
|
618
|
+
rows.append(
|
|
619
|
+
[
|
|
620
|
+
ext["name"],
|
|
621
|
+
ext["package"] or "N/A",
|
|
622
|
+
ext["version"] or "N/A",
|
|
623
|
+
status_badge,
|
|
624
|
+
details,
|
|
625
|
+
]
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
return components.VerticalLayout(
|
|
629
|
+
components=[
|
|
630
|
+
components.Card(
|
|
631
|
+
title="extensions-title",
|
|
632
|
+
components=[
|
|
633
|
+
components.Table(
|
|
634
|
+
headers=[
|
|
635
|
+
self.t("extensions-name-col"),
|
|
636
|
+
self.t("extensions-package-col"),
|
|
637
|
+
self.t("extensions-version-col"),
|
|
638
|
+
self.t("extensions-status-col"),
|
|
639
|
+
self.t("extensions-desc-col"),
|
|
640
|
+
],
|
|
641
|
+
rows=rows,
|
|
642
|
+
)
|
|
643
|
+
],
|
|
644
|
+
)
|
|
645
|
+
]
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class FlaskConstructUI:
|
|
650
|
+
app = None
|
|
651
|
+
index_view_class = BaseIndexView
|
|
652
|
+
|
|
653
|
+
def __init__(
|
|
654
|
+
self,
|
|
655
|
+
app=None,
|
|
656
|
+
static_folder=None,
|
|
657
|
+
url=None,
|
|
658
|
+
static_url_path=None,
|
|
659
|
+
title=None,
|
|
660
|
+
footer=None,
|
|
661
|
+
index_view_class=None,
|
|
662
|
+
theme=None,
|
|
663
|
+
design=None,
|
|
664
|
+
dark_mode=None,
|
|
665
|
+
auth=None,
|
|
666
|
+
search=None,
|
|
667
|
+
notifications=None,
|
|
668
|
+
discover_extensions=True,
|
|
669
|
+
strict_slashes=None,
|
|
670
|
+
check_trailing_slashes=False,
|
|
671
|
+
enforce_trailing_slash=False,
|
|
672
|
+
):
|
|
673
|
+
self._pages = []
|
|
674
|
+
self._menu = []
|
|
675
|
+
self._blueprint_names = set()
|
|
676
|
+
self._pages_by_endpoint = {}
|
|
677
|
+
self._registered_pages = set()
|
|
678
|
+
self.discover_extensions = discover_extensions
|
|
679
|
+
self.strict_slashes = strict_slashes
|
|
680
|
+
self.check_trailing_slashes = check_trailing_slashes
|
|
681
|
+
self.enforce_trailing_slash = enforce_trailing_slash
|
|
682
|
+
self._extensions = []
|
|
683
|
+
# Extra l10n directories registered by companion libraries (see
|
|
684
|
+
# add_l10n_dir). Searched below the app's own `l10n/` but above the
|
|
685
|
+
# library defaults. `_owns_l10n` records whether this construct created
|
|
686
|
+
# `app.l10n` (only then is it safe to rebuild its search path).
|
|
687
|
+
self._l10n_dirs = []
|
|
688
|
+
self._owns_l10n = False
|
|
689
|
+
self._auth = auth
|
|
690
|
+
self._search = search
|
|
691
|
+
self._notifications = notifications
|
|
692
|
+
# The only structural theme is `whitelabel`. When no theme is given we
|
|
693
|
+
# default to it wearing the `adminlte` design, which reproduces the look
|
|
694
|
+
# of the former `adminlte3` theme (the previous default) for apps that
|
|
695
|
+
# never set a theme. An app that *explicitly* selects `whitelabel`
|
|
696
|
+
# without a design gets the plain whitelabel look — so the design
|
|
697
|
+
# default only fills in for the no-theme case.
|
|
698
|
+
if theme is None:
|
|
699
|
+
theme = "whitelabel"
|
|
700
|
+
if design is None:
|
|
701
|
+
design = "adminlte"
|
|
702
|
+
self.theme = theme
|
|
703
|
+
# `design` selects a built-in skin layered on top of the structural
|
|
704
|
+
# theme's stylesheet (e.g. "adminlte" or "sandstone"). It does not
|
|
705
|
+
# change template resolution — only an extra stylesheet is linked, plus
|
|
706
|
+
# an optional icon-mode override. `dark_mode` controls the color scheme:
|
|
707
|
+
# None/"light" (light only, default), "dark" (forced dark) or "auto"
|
|
708
|
+
# (follow the OS `prefers-color-scheme`).
|
|
709
|
+
if dark_mode not in (None, "auto", "light", "dark"):
|
|
710
|
+
raise ValueError(
|
|
711
|
+
"dark_mode must be one of None, 'auto', 'light', 'dark'; "
|
|
712
|
+
f"got {dark_mode!r}"
|
|
713
|
+
)
|
|
714
|
+
self.design = design
|
|
715
|
+
self.dark_mode = dark_mode
|
|
716
|
+
self.static_url_path = static_url_path or "static"
|
|
717
|
+
if static_folder is None:
|
|
718
|
+
self.static_folder = path.join(path.dirname(__file__), "static")
|
|
719
|
+
else:
|
|
720
|
+
self.static_folder = static_folder
|
|
721
|
+
if url is not None:
|
|
722
|
+
self.url = url.rstrip("/") + "/"
|
|
723
|
+
else:
|
|
724
|
+
self.url = "/"
|
|
725
|
+
self.title = title
|
|
726
|
+
self.footer = footer or "Built with Construct UI"
|
|
727
|
+
self.templatefolder = path.join(path.dirname(__file__), "templates")
|
|
728
|
+
|
|
729
|
+
if index_view_class is not None:
|
|
730
|
+
self.index_view_class = index_view_class
|
|
731
|
+
|
|
732
|
+
if app is not None:
|
|
733
|
+
self.app = app
|
|
734
|
+
self.init_app(app)
|
|
735
|
+
|
|
736
|
+
def construct_static_path(self, filename):
|
|
737
|
+
return send_from_directory(self.static_folder, filename)
|
|
738
|
+
|
|
739
|
+
def init_app(self, app: Flask):
|
|
740
|
+
# CSRF is on by default in production and off under TESTING so that
|
|
741
|
+
# test clients can POST directly. Apps may override either way.
|
|
742
|
+
app.config.setdefault("CONSTRUCT_CSRF_ENABLED", not app.testing)
|
|
743
|
+
# Pagination bounds: the default page size used when a view does not
|
|
744
|
+
# set page_size, and the hard cap any requested/declared size is
|
|
745
|
+
# silently clamped to (also enforced inside the data layer).
|
|
746
|
+
app.config.setdefault("CONSTRUCT_DEFAULT_PAGE_SIZE", DEFAULT_PAGE_SIZE)
|
|
747
|
+
app.config.setdefault("CONSTRUCT_MAX_PAGE_SIZE", MAX_PAGE_SIZE)
|
|
748
|
+
# Auth is enforced only when an AuthProvider was passed to the
|
|
749
|
+
# constructor. When enabled, all construct routes require login by
|
|
750
|
+
# default (fail-closed). CONSTRUCT_LOGIN_ENDPOINT overrides the
|
|
751
|
+
# provider's login_endpoint.
|
|
752
|
+
app.config.setdefault("CONSTRUCT_AUTH_ENABLED", self._auth is not None)
|
|
753
|
+
app.config.setdefault("CONSTRUCT_LOGIN_ENDPOINT", None)
|
|
754
|
+
|
|
755
|
+
@app.before_request
|
|
756
|
+
def set_theme():
|
|
757
|
+
g.construct_theme = self.theme
|
|
758
|
+
g.construct = self
|
|
759
|
+
|
|
760
|
+
@app.before_request
|
|
761
|
+
def check_trailing_slashes_once():
|
|
762
|
+
if self.check_trailing_slashes and not getattr(
|
|
763
|
+
self, "_checked_trailing_slashes", False
|
|
764
|
+
):
|
|
765
|
+
self._checked_trailing_slashes = True
|
|
766
|
+
self.check_routes_trailing_slashes(app)
|
|
767
|
+
|
|
768
|
+
@app.after_request
|
|
769
|
+
def add_vary_header(response):
|
|
770
|
+
if "text/html" in response.content_type:
|
|
771
|
+
existing = response.headers.get("Vary", "")
|
|
772
|
+
if "X-Up-Target" not in existing:
|
|
773
|
+
response.headers["Vary"] = (existing + ", X-Up-Target").lstrip(", ")
|
|
774
|
+
return response
|
|
775
|
+
|
|
776
|
+
@app.before_request
|
|
777
|
+
def csrf_protect():
|
|
778
|
+
# Only guard state-changing requests against construct-owned
|
|
779
|
+
# endpoints; the host app's own routes are left untouched.
|
|
780
|
+
if not current_app.config.get("CONSTRUCT_CSRF_ENABLED", True):
|
|
781
|
+
return
|
|
782
|
+
if (
|
|
783
|
+
request.method in CSRF_UNSAFE_METHODS
|
|
784
|
+
and request.blueprint in self._blueprint_names
|
|
785
|
+
):
|
|
786
|
+
validate_csrf_token()
|
|
787
|
+
|
|
788
|
+
@app.before_request
|
|
789
|
+
def auth_protect():
|
|
790
|
+
# Fail-closed guard over every construct-owned route. Host app
|
|
791
|
+
# routes (not in a construct blueprint) are untouched. Dynamically
|
|
792
|
+
# registered RCRUD sub-routes have no per-method rule, so they fall
|
|
793
|
+
# through to the safe default (login required) and inherit the
|
|
794
|
+
# parent page's required_groups.
|
|
795
|
+
if not current_app.config.get("CONSTRUCT_AUTH_ENABLED", False):
|
|
796
|
+
return
|
|
797
|
+
if self._auth is None or request.blueprint not in self._blueprint_names:
|
|
798
|
+
return
|
|
799
|
+
page = self._pages_by_endpoint.get(request.blueprint)
|
|
800
|
+
method = (request.endpoint or "").split(".", 1)[-1]
|
|
801
|
+
rule = (page._auth_rules.get(method) if page is not None else None) or {}
|
|
802
|
+
|
|
803
|
+
# Precedence: per-method rule > class-level > global default.
|
|
804
|
+
page_public = page is not None and not page.login_required
|
|
805
|
+
if rule.get("public") or (page_public and "groups" not in rule):
|
|
806
|
+
return
|
|
807
|
+
if not self._auth.is_authenticated():
|
|
808
|
+
return self._auth.on_unauthenticated()
|
|
809
|
+
groups = rule.get("groups") or (
|
|
810
|
+
page.required_groups if page is not None else None
|
|
811
|
+
)
|
|
812
|
+
if groups and not self._auth.has_groups(groups, rule.get("mode", "any")):
|
|
813
|
+
return self._auth.on_forbidden()
|
|
814
|
+
|
|
815
|
+
@app.template_filter("markdown")
|
|
816
|
+
def markdown_filter(s):
|
|
817
|
+
if s is None:
|
|
818
|
+
return ""
|
|
819
|
+
from markdown_it import MarkdownIt
|
|
820
|
+
|
|
821
|
+
md = MarkdownIt("js-default", {"html": True})
|
|
822
|
+
|
|
823
|
+
# MarkdownIt passes raw HTML through, so the converted output must be
|
|
824
|
+
# sanitized before it is marked safe (stored-XSS boundary).
|
|
825
|
+
return Markup(
|
|
826
|
+
sanitize_html(md.render(s))
|
|
827
|
+
) # nosemgrep: explicit-unescape-with-markup # nosec B704
|
|
828
|
+
|
|
829
|
+
@app.template_filter("orgmode")
|
|
830
|
+
def orgmode_filter(s):
|
|
831
|
+
if s is None:
|
|
832
|
+
return ""
|
|
833
|
+
from .orgmode import org_to_html
|
|
834
|
+
|
|
835
|
+
return Markup(
|
|
836
|
+
sanitize_html(org_to_html(s))
|
|
837
|
+
) # nosemgrep: explicit-unescape-with-markup # nosec B704
|
|
838
|
+
|
|
839
|
+
def translate(msg_id, args=None):
|
|
840
|
+
# App-wide translator exposed to templates as `t(...)`. Resolves
|
|
841
|
+
# app.l10n at call time (it is set up further below, and the app may
|
|
842
|
+
# swap it); the active language is read per-request from g.language.
|
|
843
|
+
# Lets the app's own plain render_template views — which have no
|
|
844
|
+
# `construct` in scope — translate too.
|
|
845
|
+
return app.l10n.t(msg_id, args)
|
|
846
|
+
|
|
847
|
+
@app.context_processor
|
|
848
|
+
def inject_construct():
|
|
849
|
+
# Expose `construct` to every template, including plain
|
|
850
|
+
# render_template views and companion-library pages that don't pass
|
|
851
|
+
# it explicitly (so they can `{% extends "structure/base.html" %}`
|
|
852
|
+
# or "structure/public.html"). Templates that pass construct=...
|
|
853
|
+
# still override this. ``current_user`` is the normalized,
|
|
854
|
+
# auth-stack-agnostic user dict (None when logged out or no auth),
|
|
855
|
+
# so templates can do ``{{ current_user.name }}`` without reaching
|
|
856
|
+
# into a provider's native session.
|
|
857
|
+
user = self._auth.current_user() if self._auth is not None else None
|
|
858
|
+
return {"construct": self, "current_user": user}
|
|
859
|
+
|
|
860
|
+
app.jinja_env.globals.update(
|
|
861
|
+
live_container=live_container,
|
|
862
|
+
live_attributes=live_attributes,
|
|
863
|
+
csrf_field=csrf_field,
|
|
864
|
+
csrf_token=generate_csrf_token,
|
|
865
|
+
t=translate,
|
|
866
|
+
icon=render_icon,
|
|
867
|
+
next_url=resolve_next,
|
|
868
|
+
)
|
|
869
|
+
# Components render through their own standalone Jinja environment,
|
|
870
|
+
# so the CSRF helpers, translator and icon renderer must be registered
|
|
871
|
+
# there too.
|
|
872
|
+
jinja_env.globals.update(
|
|
873
|
+
csrf_field=csrf_field,
|
|
874
|
+
csrf_token=generate_csrf_token,
|
|
875
|
+
t=translate,
|
|
876
|
+
icon=render_icon,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
self.app = app
|
|
880
|
+
app.add_url_rule(
|
|
881
|
+
"/construct_static/<path:filename>",
|
|
882
|
+
"construct_static",
|
|
883
|
+
self.construct_static_path,
|
|
884
|
+
)
|
|
885
|
+
if not hasattr(app, "l10n"):
|
|
886
|
+
# Only add if not already added by the app.
|
|
887
|
+
app.l10n = Translator()
|
|
888
|
+
self._owns_l10n = True
|
|
889
|
+
self._apply_l10n_paths()
|
|
890
|
+
app.before_request(set_language_from_browser)
|
|
891
|
+
|
|
892
|
+
if self.discover_extensions:
|
|
893
|
+
self.load_extensions()
|
|
894
|
+
|
|
895
|
+
if self.index_view_class:
|
|
896
|
+
self.index_view = self.index_view_class()
|
|
897
|
+
self._blueprint_names.add(self.index_view.endpoint)
|
|
898
|
+
self._pages_by_endpoint[self.index_view.endpoint] = self.index_view
|
|
899
|
+
self._register_page_blueprint(self.index_view)
|
|
900
|
+
|
|
901
|
+
for page in self._pages:
|
|
902
|
+
self._blueprint_names.add(page.endpoint)
|
|
903
|
+
self._pages_by_endpoint[page.endpoint] = page
|
|
904
|
+
self._register_page_blueprint(page)
|
|
905
|
+
|
|
906
|
+
if self._search is not None or self._notifications is not None:
|
|
907
|
+
prefix = self.url.rstrip("/") + "/_construct"
|
|
908
|
+
if self.enforce_trailing_slash and not prefix.endswith("/"):
|
|
909
|
+
prefix += "/"
|
|
910
|
+
|
|
911
|
+
features_bp = Blueprint("construct_features", __name__, url_prefix=prefix)
|
|
912
|
+
_self = self
|
|
913
|
+
|
|
914
|
+
search_url = "/search"
|
|
915
|
+
notifications_url = "/notifications"
|
|
916
|
+
if self.enforce_trailing_slash:
|
|
917
|
+
search_url += "/"
|
|
918
|
+
notifications_url += "/"
|
|
919
|
+
|
|
920
|
+
route_kwargs = {}
|
|
921
|
+
if self.strict_slashes is not None:
|
|
922
|
+
route_kwargs["strict_slashes"] = self.strict_slashes
|
|
923
|
+
|
|
924
|
+
if self._search is not None:
|
|
925
|
+
_search = self._search
|
|
926
|
+
|
|
927
|
+
@features_bp.route(search_url, **route_kwargs)
|
|
928
|
+
def search_results():
|
|
929
|
+
q = request.args.get("q", "")
|
|
930
|
+
results = _search.search(q)
|
|
931
|
+
return render_template(
|
|
932
|
+
f"themes/{g.construct_theme}/navbar/search_results.html",
|
|
933
|
+
construct=_self,
|
|
934
|
+
results=results,
|
|
935
|
+
query=q,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
if self._notifications is not None:
|
|
939
|
+
|
|
940
|
+
@features_bp.route(notifications_url, **route_kwargs)
|
|
941
|
+
def notifications_fragment():
|
|
942
|
+
# Just the #fcu-notifications fragment; Unpoly's up-poll
|
|
943
|
+
# reloads it on a timer to keep the count + items fresh.
|
|
944
|
+
return render_template(
|
|
945
|
+
f"themes/{g.construct_theme}/navbar/notifications.html",
|
|
946
|
+
construct=_self,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
self.app.register_blueprint(features_bp)
|
|
950
|
+
self._blueprint_names.add("construct_features")
|
|
951
|
+
|
|
952
|
+
def load_extensions(self):
|
|
953
|
+
"""Discover and load companion libraries via entry points in the
|
|
954
|
+
group 'flask_construct_ui.extensions'.
|
|
955
|
+
"""
|
|
956
|
+
try:
|
|
957
|
+
eps = entry_points(group="flask_construct_ui.extensions")
|
|
958
|
+
except TypeError:
|
|
959
|
+
# Fallback for older python / importlib_metadata select API compatibility
|
|
960
|
+
try:
|
|
961
|
+
eps = entry_points().select(group="flask_construct_ui.extensions")
|
|
962
|
+
except Exception:
|
|
963
|
+
eps = []
|
|
964
|
+
|
|
965
|
+
for ep in eps:
|
|
966
|
+
ext_info = {
|
|
967
|
+
"name": ep.name,
|
|
968
|
+
"value": ep.value,
|
|
969
|
+
"package": None,
|
|
970
|
+
"version": None,
|
|
971
|
+
"status": "Unknown",
|
|
972
|
+
"error": None,
|
|
973
|
+
}
|
|
974
|
+
try:
|
|
975
|
+
dist = ep.dist
|
|
976
|
+
if dist is not None:
|
|
977
|
+
ext_info["package"] = dist.name
|
|
978
|
+
ext_info["version"] = dist.version
|
|
979
|
+
plugin = ep.load()
|
|
980
|
+
if isinstance(plugin, PageBase):
|
|
981
|
+
self.add_page(plugin)
|
|
982
|
+
ext_info["status"] = "Loaded"
|
|
983
|
+
elif isinstance(plugin, type) and issubclass(plugin, PageBase):
|
|
984
|
+
page_instance = plugin()
|
|
985
|
+
self.add_page(page_instance)
|
|
986
|
+
ext_info["status"] = "Loaded"
|
|
987
|
+
elif callable(plugin):
|
|
988
|
+
plugin(self)
|
|
989
|
+
ext_info["status"] = "Loaded"
|
|
990
|
+
else:
|
|
991
|
+
raise TypeError(
|
|
992
|
+
f"Extension must be a PageBase instance, subclass, "
|
|
993
|
+
f"or callable, got {type(plugin)}"
|
|
994
|
+
)
|
|
995
|
+
except Exception as e:
|
|
996
|
+
ext_info["status"] = "Error"
|
|
997
|
+
ext_info["error"] = str(e)
|
|
998
|
+
logger.error(f"Failed to load extension {ep.name}: {e}", exc_info=True)
|
|
999
|
+
self._extensions.append(ext_info)
|
|
1000
|
+
|
|
1001
|
+
if self._extensions:
|
|
1002
|
+
self.add_page(ExtensionsPage(), submenu="Manage", submenu_icon="settings")
|
|
1003
|
+
|
|
1004
|
+
def _register_page_blueprint(self, page):
|
|
1005
|
+
"""Register ``page``'s blueprint exactly once.
|
|
1006
|
+
|
|
1007
|
+
Pages are tracked by identity, so a page already registered by
|
|
1008
|
+
``add_page`` (e.g. an extension loaded during ``load_extensions``) is
|
|
1009
|
+
skipped when the ``init_app`` loop revisits it — but a genuine endpoint
|
|
1010
|
+
collision between two *different* pages still surfaces as Flask's normal
|
|
1011
|
+
duplicate-blueprint error instead of being silently dropped.
|
|
1012
|
+
"""
|
|
1013
|
+
if page in self._registered_pages:
|
|
1014
|
+
return
|
|
1015
|
+
self._registered_pages.add(page)
|
|
1016
|
+
self.app.register_blueprint(page.get_blueprint(self))
|
|
1017
|
+
|
|
1018
|
+
def check_routes_trailing_slashes(self, app=None):
|
|
1019
|
+
"""Check all registered URL rules in the application for missing trailing slashes.
|
|
1020
|
+
|
|
1021
|
+
Logs a warning for any route in construct blueprints that does not end in a slash,
|
|
1022
|
+
except for static file rules and routes with file extensions.
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
list: A list of tuples containing (rule.rule, rule.endpoint) for non-compliant routes.
|
|
1026
|
+
"""
|
|
1027
|
+
app = app or self.app
|
|
1028
|
+
if app is None:
|
|
1029
|
+
return []
|
|
1030
|
+
|
|
1031
|
+
warnings = []
|
|
1032
|
+
for rule in app.url_map.iter_rules():
|
|
1033
|
+
# Skip static routes
|
|
1034
|
+
if "static" in rule.endpoint or rule.endpoint == "static":
|
|
1035
|
+
continue
|
|
1036
|
+
|
|
1037
|
+
parts = rule.endpoint.split(".", 1)
|
|
1038
|
+
bp_name = parts[0] if len(parts) > 1 else rule.endpoint
|
|
1039
|
+
if bp_name not in self._blueprint_names and bp_name != "construct_features":
|
|
1040
|
+
continue
|
|
1041
|
+
|
|
1042
|
+
path = rule.rule
|
|
1043
|
+
|
|
1044
|
+
# Exclude paths ending with common file extensions or having extension-like patterns
|
|
1045
|
+
if path.split("?")[0].split("/")[-1].count(".") > 0:
|
|
1046
|
+
continue
|
|
1047
|
+
|
|
1048
|
+
if not path.endswith("/"):
|
|
1049
|
+
warnings.append((rule.rule, rule.endpoint))
|
|
1050
|
+
app.logger.warning(
|
|
1051
|
+
f"Construct UI: Route '{rule.rule}' (endpoint '{rule.endpoint}') "
|
|
1052
|
+
f"does not end with a trailing slash '/'. This may cause 308 redirects."
|
|
1053
|
+
)
|
|
1054
|
+
return warnings
|
|
1055
|
+
|
|
1056
|
+
def add_page(self, page, submenu=None, submenu_icon=None):
|
|
1057
|
+
"""Register a page and add it to the navigation menu.
|
|
1058
|
+
|
|
1059
|
+
When ``submenu`` is given, the page's menu entry is nested under a
|
|
1060
|
+
collapsible submenu group with that title instead of being placed at
|
|
1061
|
+
the top level. Pages sharing the same ``submenu`` title are collected
|
|
1062
|
+
into the same group, in the order they are added. ``submenu_icon`` sets
|
|
1063
|
+
the icon for the group the first time it is created (themes that render
|
|
1064
|
+
a submenu icon use it; flat themes show the title as a section header).
|
|
1065
|
+
"""
|
|
1066
|
+
self._pages.append(page)
|
|
1067
|
+
self._blueprint_names.add(page.endpoint)
|
|
1068
|
+
self._pages_by_endpoint[page.endpoint] = page
|
|
1069
|
+
if self.app is not None:
|
|
1070
|
+
self._register_page_blueprint(page)
|
|
1071
|
+
if hasattr(page, "menu") and page.menu is not False:
|
|
1072
|
+
if isinstance(page.menu, list):
|
|
1073
|
+
pass
|
|
1074
|
+
else:
|
|
1075
|
+
menu = page.menu
|
|
1076
|
+
menu["link"] = (
|
|
1077
|
+
self.url
|
|
1078
|
+
+ (
|
|
1079
|
+
"/"
|
|
1080
|
+
if self.url[-1:] != "/" and page.menu["link"][:1] != "/"
|
|
1081
|
+
else ""
|
|
1082
|
+
)
|
|
1083
|
+
+ page.menu["link"]
|
|
1084
|
+
)
|
|
1085
|
+
if submenu is not None:
|
|
1086
|
+
self._add_to_submenu(submenu, menu, submenu_icon)
|
|
1087
|
+
else:
|
|
1088
|
+
self._menu.append(menu)
|
|
1089
|
+
|
|
1090
|
+
def _add_to_submenu(self, title, item, icon="folder"):
|
|
1091
|
+
"""Place ``item`` under the submenu group named ``title``.
|
|
1092
|
+
|
|
1093
|
+
Reuses an existing group with the same title if one was already
|
|
1094
|
+
created, otherwise appends a new submenu entry at the current position.
|
|
1095
|
+
"""
|
|
1096
|
+
for entry in self._menu:
|
|
1097
|
+
if entry.get("type") == "submenu" and entry.get("title") == title:
|
|
1098
|
+
entry["subitems"].append(item)
|
|
1099
|
+
return
|
|
1100
|
+
self._menu.append(
|
|
1101
|
+
{
|
|
1102
|
+
"type": "submenu",
|
|
1103
|
+
"title": title,
|
|
1104
|
+
"icon": icon,
|
|
1105
|
+
"subitems": [item],
|
|
1106
|
+
}
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def _apply_l10n_paths(self):
|
|
1110
|
+
"""(Re)build the translator's search path.
|
|
1111
|
+
|
|
1112
|
+
Precedence, highest first: the application's own ``l10n/``, then any
|
|
1113
|
+
directories registered by companion libraries (in registration order),
|
|
1114
|
+
then the library defaults. Only meaningful when this construct owns
|
|
1115
|
+
``app.l10n`` (the host app didn't supply its own).
|
|
1116
|
+
"""
|
|
1117
|
+
self.app.l10n.set_languages(["l10n", *self._l10n_dirs, self.l10n_dir])
|
|
1118
|
+
|
|
1119
|
+
def add_l10n_dir(self, path):
|
|
1120
|
+
"""Register an extra l10n directory, e.g. one shipped by a companion
|
|
1121
|
+
library.
|
|
1122
|
+
|
|
1123
|
+
Keys there fill in below the application's own ``l10n/`` but override
|
|
1124
|
+
the library defaults, and any language shipped only by ``path`` becomes
|
|
1125
|
+
available. Safe to call before or after ``init_app``: once the app is
|
|
1126
|
+
initialised the search path is rebuilt immediately. It is a no-op when
|
|
1127
|
+
the host app supplied its own ``app.l10n`` (this construct does not own
|
|
1128
|
+
it, so its paths are left untouched).
|
|
1129
|
+
"""
|
|
1130
|
+
if path not in self._l10n_dirs:
|
|
1131
|
+
self._l10n_dirs.append(path)
|
|
1132
|
+
if self.app is not None and self._owns_l10n:
|
|
1133
|
+
self._apply_l10n_paths()
|
|
1134
|
+
|
|
1135
|
+
def t(self, msg_id, args=None):
|
|
1136
|
+
return self.app.l10n.t(msg_id, args)
|
|
1137
|
+
|
|
1138
|
+
@property
|
|
1139
|
+
def notices(self):
|
|
1140
|
+
if self._notifications is not None:
|
|
1141
|
+
return self._notifications.get_notices()
|
|
1142
|
+
return []
|
|
1143
|
+
|
|
1144
|
+
@property
|
|
1145
|
+
def notify_count(self):
|
|
1146
|
+
if self._notifications is not None:
|
|
1147
|
+
return self._notifications.count
|
|
1148
|
+
return 0
|
|
1149
|
+
|
|
1150
|
+
@property
|
|
1151
|
+
def auth(self):
|
|
1152
|
+
return self._auth
|
|
1153
|
+
|
|
1154
|
+
@property
|
|
1155
|
+
def navbar_features_available(self):
|
|
1156
|
+
"""Whether navbar search/notifications should be rendered.
|
|
1157
|
+
|
|
1158
|
+
Hidden when an auth provider is configured and the current user is not
|
|
1159
|
+
authenticated: every construct route (including the search and
|
|
1160
|
+
notification-poll endpoints) is gated behind auth, so for a logged-out
|
|
1161
|
+
user those controls are dead — the search returns a login redirect and
|
|
1162
|
+
the notifications poll fails. Always available when no auth is set.
|
|
1163
|
+
Must be called within a request context (reads the auth state).
|
|
1164
|
+
"""
|
|
1165
|
+
if self._auth is None:
|
|
1166
|
+
return True
|
|
1167
|
+
return self._auth.is_authenticated()
|
|
1168
|
+
|
|
1169
|
+
@property
|
|
1170
|
+
def search(self):
|
|
1171
|
+
return self._search
|
|
1172
|
+
|
|
1173
|
+
@property
|
|
1174
|
+
def notifications(self):
|
|
1175
|
+
return self._notifications
|
|
1176
|
+
|
|
1177
|
+
@property
|
|
1178
|
+
def search_url(self):
|
|
1179
|
+
if self._search is None:
|
|
1180
|
+
return None
|
|
1181
|
+
return self.url.rstrip("/") + "/_construct/search"
|
|
1182
|
+
|
|
1183
|
+
@property
|
|
1184
|
+
def notifications_url(self):
|
|
1185
|
+
"""URL of the notifications fragment endpoint (for Unpoly polling)."""
|
|
1186
|
+
if self._notifications is None:
|
|
1187
|
+
return None
|
|
1188
|
+
return self.url.rstrip("/") + "/_construct/notifications"
|
|
1189
|
+
|
|
1190
|
+
@property
|
|
1191
|
+
def menu(self):
|
|
1192
|
+
return self._menu
|
|
1193
|
+
|
|
1194
|
+
@property
|
|
1195
|
+
def titleFromLink(self):
|
|
1196
|
+
for item in self.menu:
|
|
1197
|
+
if item["type"] == "submenu":
|
|
1198
|
+
for subitem in item["subitems"]:
|
|
1199
|
+
if "link" in subitem and subitem["link"] == self.page:
|
|
1200
|
+
return subitem["title"]
|
|
1201
|
+
|
|
1202
|
+
if "link" in item and item["link"] == self.page:
|
|
1203
|
+
return item["title"]
|
|
1204
|
+
return ""
|
|
1205
|
+
|
|
1206
|
+
@property
|
|
1207
|
+
def l10n_dir(self):
|
|
1208
|
+
return path.join(path.dirname(__file__), "l10n")
|
|
1209
|
+
|
|
1210
|
+
@property
|
|
1211
|
+
def icon_mode(self):
|
|
1212
|
+
"""Active icon-rendering mode ("bootstrap" or "material").
|
|
1213
|
+
|
|
1214
|
+
Resolved from the theme, with any design override applied. Consumed by
|
|
1215
|
+
the ``icon()`` template global (see ``icons.render_icon``).
|
|
1216
|
+
"""
|
|
1217
|
+
from .icons import resolve_icon_mode
|
|
1218
|
+
|
|
1219
|
+
return resolve_icon_mode(self.theme, self.design)
|
|
1220
|
+
|
|
1221
|
+
@property
|
|
1222
|
+
def color_scheme(self):
|
|
1223
|
+
"""Explicit value for the ``data-fcu-color-scheme`` attribute, or None.
|
|
1224
|
+
|
|
1225
|
+
Returns "light" or "dark" when the scheme is pinned, and None for
|
|
1226
|
+
"auto" so the stylesheet's ``prefers-color-scheme`` media query
|
|
1227
|
+
decides. None (feature off) renders as "light" to keep existing
|
|
1228
|
+
whitelabel apps from following the OS into dark unexpectedly.
|
|
1229
|
+
"""
|
|
1230
|
+
if self.dark_mode in (None, "light"):
|
|
1231
|
+
return "light"
|
|
1232
|
+
if self.dark_mode == "dark":
|
|
1233
|
+
return "dark"
|
|
1234
|
+
return None # "auto"
|
|
1235
|
+
|
|
1236
|
+
@property
|
|
1237
|
+
def design_stylesheet(self):
|
|
1238
|
+
"""Static path of the active design stylesheet, or None.
|
|
1239
|
+
|
|
1240
|
+
Designs are shipped per structural theme under
|
|
1241
|
+
``static/themes/<theme>/designs/<design>.css``. Returns None when no
|
|
1242
|
+
design is selected or the file does not exist on disk.
|
|
1243
|
+
"""
|
|
1244
|
+
if not self.design:
|
|
1245
|
+
return None
|
|
1246
|
+
rel = path.join("themes", self.theme, "designs", f"{self.design}.css")
|
|
1247
|
+
if not path.exists(path.join(self.static_folder, rel)):
|
|
1248
|
+
return None
|
|
1249
|
+
return rel.replace(path.sep, "/")
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def __getattr__(name: str):
|
|
1253
|
+
if name in ("CrudPageBase", "RelatedCrudTab"):
|
|
1254
|
+
from .crud import CrudPageBase, RelatedCrudTab
|
|
1255
|
+
|
|
1256
|
+
if name == "CrudPageBase":
|
|
1257
|
+
return CrudPageBase
|
|
1258
|
+
return RelatedCrudTab
|
|
1259
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|