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.
Files changed (80) hide show
  1. flask_construct_ui/__init__.py +82 -0
  2. flask_construct_ui/auth.py +290 -0
  3. flask_construct_ui/base.py +1259 -0
  4. flask_construct_ui/components.py +812 -0
  5. flask_construct_ui/crud.py +1641 -0
  6. flask_construct_ui/csrf.py +39 -0
  7. flask_construct_ui/data.py +257 -0
  8. flask_construct_ui/data_sqlalchemy.py +304 -0
  9. flask_construct_ui/forms.py +130 -0
  10. flask_construct_ui/headless.py +110 -0
  11. flask_construct_ui/icons.py +158 -0
  12. flask_construct_ui/l10n/en/main.flt +82 -0
  13. flask_construct_ui/l10n/sv/main.flt +82 -0
  14. flask_construct_ui/language.py +100 -0
  15. flask_construct_ui/navbar.py +54 -0
  16. flask_construct_ui/navigation.py +58 -0
  17. flask_construct_ui/orgmode.py +338 -0
  18. flask_construct_ui/pagination.py +39 -0
  19. flask_construct_ui/py.typed +0 -0
  20. flask_construct_ui/sanitize.py +25 -0
  21. flask_construct_ui/schema.py +507 -0
  22. flask_construct_ui/static/css/prosemirror-bundle.min.css +1 -0
  23. flask_construct_ui/static/css/unpoly.min.css +6 -0
  24. flask_construct_ui/static/js/construct-core.min.js +1 -0
  25. flask_construct_ui/static/js/prosemirror-bundle.min.js +34 -0
  26. flask_construct_ui/static/js/unpoly.min.js +1 -0
  27. flask_construct_ui/static/themes/whitelabel/css/whitelabel.css +720 -0
  28. flask_construct_ui/static/themes/whitelabel/designs/adminlte.css +94 -0
  29. flask_construct_ui/static/themes/whitelabel/designs/sandstone.css +64 -0
  30. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/LICENSE.txt +21 -0
  31. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/bootstrap-icons.min.css +5 -0
  32. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  33. flask_construct_ui/static/themes/whitelabel/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  34. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/LICENSE.txt +202 -0
  35. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
  36. flask_construct_ui/static/themes/whitelabel/fonts/material-symbols/material-symbols.css +26 -0
  37. flask_construct_ui/templates/structure/base.html +5 -0
  38. flask_construct_ui/templates/structure/flashes.html +7 -0
  39. flask_construct_ui/templates/structure/public.html +18 -0
  40. flask_construct_ui/templates/themes/whitelabel/components/_form_field.html +69 -0
  41. flask_construct_ui/templates/themes/whitelabel/components/button.html +16 -0
  42. flask_construct_ui/templates/themes/whitelabel/components/calendar.html +30 -0
  43. flask_construct_ui/templates/themes/whitelabel/components/calendarmini.html +30 -0
  44. flask_construct_ui/templates/themes/whitelabel/components/card.html +25 -0
  45. flask_construct_ui/templates/themes/whitelabel/components/chart.html +5 -0
  46. flask_construct_ui/templates/themes/whitelabel/components/form.html +61 -0
  47. flask_construct_ui/templates/themes/whitelabel/components/grid_layout.html +5 -0
  48. flask_construct_ui/templates/themes/whitelabel/components/horizontal_layout.html +5 -0
  49. flask_construct_ui/templates/themes/whitelabel/components/live.html +40 -0
  50. flask_construct_ui/templates/themes/whitelabel/components/main.html +4 -0
  51. flask_construct_ui/templates/themes/whitelabel/components/markup.html +1 -0
  52. flask_construct_ui/templates/themes/whitelabel/components/rich_text.html +19 -0
  53. flask_construct_ui/templates/themes/whitelabel/components/table.html +30 -0
  54. flask_construct_ui/templates/themes/whitelabel/components/tabs.html +30 -0
  55. flask_construct_ui/templates/themes/whitelabel/components/text.html +26 -0
  56. flask_construct_ui/templates/themes/whitelabel/components/vertical_layout.html +5 -0
  57. flask_construct_ui/templates/themes/whitelabel/crud/_macros.html +962 -0
  58. flask_construct_ui/templates/themes/whitelabel/crud/_many_related_options.html +26 -0
  59. flask_construct_ui/templates/themes/whitelabel/crud/_related_child_form.html +98 -0
  60. flask_construct_ui/templates/themes/whitelabel/crud/_related_child_list.html +136 -0
  61. flask_construct_ui/templates/themes/whitelabel/crud/_related_options.html +15 -0
  62. flask_construct_ui/templates/themes/whitelabel/crud/create.html +45 -0
  63. flask_construct_ui/templates/themes/whitelabel/crud/edit.html +125 -0
  64. flask_construct_ui/templates/themes/whitelabel/crud/listindex.html +238 -0
  65. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_child_view.html +33 -0
  66. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_form.html +125 -0
  67. flask_construct_ui/templates/themes/whitelabel/crud/related_crud_tab_list.html +140 -0
  68. flask_construct_ui/templates/themes/whitelabel/crud/tab_view.html +38 -0
  69. flask_construct_ui/templates/themes/whitelabel/errors/forbidden.html +16 -0
  70. flask_construct_ui/templates/themes/whitelabel/navbar/notifications.html +26 -0
  71. flask_construct_ui/templates/themes/whitelabel/navbar/search_results.html +31 -0
  72. flask_construct_ui/templates/themes/whitelabel/style/_flashes.html +16 -0
  73. flask_construct_ui/templates/themes/whitelabel/style/base_full.html +181 -0
  74. flask_construct_ui/templates/themes/whitelabel/style/base_partial.html +16 -0
  75. flask_construct_ui/validation.py +422 -0
  76. flask_construct_ui-0.9.5.dist-info/METADATA +60 -0
  77. flask_construct_ui-0.9.5.dist-info/RECORD +80 -0
  78. flask_construct_ui-0.9.5.dist-info/WHEEL +5 -0
  79. flask_construct_ui-0.9.5.dist-info/licenses/LICENSE +165 -0
  80. 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}")