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,812 @@
1
+ from __future__ import annotations
2
+ from typing import List, Dict, Any, Optional, Tuple, Union, Callable
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from abc import ABC, abstractmethod
6
+ from datetime import datetime, date
7
+ import calendar
8
+ import inspect
9
+ import locale
10
+ import uuid
11
+ from copy import copy
12
+ from markupsafe import Markup
13
+ from textwrap import dedent
14
+ import jinja2
15
+ import pygal
16
+ from markdown_it import MarkdownIt
17
+ from flask import current_app, g, url_for
18
+ from flask_construct_ui.language import LOCALE_MAPPING
19
+ from .sanitize import sanitize_html
20
+
21
+
22
+ class ChartType(Enum):
23
+ BAR = "bar"
24
+ LINE = "line"
25
+ PIE = "pie"
26
+ STACKED_BAR = "stacked_bar"
27
+
28
+
29
+ # default_for_string=True so from_string templates (MarkupComponent args,
30
+ # LiveComponent content_template) escape interpolated values — they can carry
31
+ # user data, unlike the developer-authored template source itself.
32
+ jinja_env = jinja2.Environment( # nosemgrep: direct-use-of-jinja2
33
+ loader=jinja2.PackageLoader("flask_construct_ui", "templates"),
34
+ autoescape=jinja2.select_autoescape(["html", "xml"], default_for_string=True),
35
+ )
36
+ jinja_env.tests["list"] = lambda x: isinstance(x, list)
37
+
38
+
39
+ @dataclass
40
+ class Field:
41
+ type: str
42
+ key: str
43
+ label: str
44
+ required: bool = False
45
+ options: Optional[Union[List[str], str, Dict[str, str]]] = None
46
+ value: Any = None
47
+ secret: bool = False
48
+
49
+
50
+ class FieldRendererRegistry:
51
+ _registry = {}
52
+
53
+ @classmethod
54
+ def register(cls, field_type, renderer):
55
+ cls._registry[field_type] = renderer
56
+
57
+ @classmethod
58
+ def get_renderer(cls, field_type):
59
+ return cls._registry.get(field_type)
60
+
61
+
62
+ def render_richtext_field(field_obj, data, theme):
63
+ value = data.get(field_obj.key, "") if data else ""
64
+ rt_component = RichTextComponent(
65
+ name=field_obj.key, label=field_obj.label, content=value
66
+ )
67
+ rendered_html = Markup(rt_component.render()) # nosemgrep: explicit-unescape-with-markup # nosec B704
68
+ placeholder_field = Field(type="custom", key=field_obj.key, label=field_obj.label)
69
+ return rendered_html, placeholder_field
70
+
71
+
72
+ FieldRendererRegistry.register("richtext", render_richtext_field)
73
+
74
+
75
+ @dataclass
76
+ class Trigger:
77
+ action: str
78
+ validate: bool = False
79
+
80
+
81
+ @dataclass
82
+ class Component(ABC):
83
+ id: Optional[str] = None
84
+ css_class: Optional[str] = None
85
+
86
+ @abstractmethod
87
+ def render(self) -> str:
88
+ theme = getattr(g, "construct_theme", "whitelabel")
89
+ """Generate HTML for this component"""
90
+ pass
91
+
92
+ @abstractmethod
93
+ def get_component_type(self) -> str:
94
+ """Return the component type identifier"""
95
+ pass
96
+
97
+ def get_children(self) -> list[Component]:
98
+ """Return a list of child components"""
99
+ return []
100
+
101
+ def to_dict(self) -> Dict[str, Any]:
102
+ """Convert component to dictionary representation"""
103
+ result = {"type": self.get_component_type()}
104
+ if self.id:
105
+ result["id"] = self.id
106
+ return result
107
+
108
+
109
+ # Layout components - containers that organize other components
110
+ @dataclass
111
+ class VerticalLayoutComponent(Component):
112
+ components: List[Component] = field(default_factory=list)
113
+ spacing: str = "1rem"
114
+ alignment: str = "stretch"
115
+
116
+ def get_component_type(self) -> str:
117
+ return "vertical_layout"
118
+
119
+ def get_children(self) -> list[Component]:
120
+ return self.components
121
+
122
+ def add_component(self, component: Component) -> "VerticalLayoutComponent":
123
+ self.components.append(component)
124
+ return self
125
+
126
+ def render(self) -> str:
127
+ theme = getattr(g, "construct_theme", "whitelabel")
128
+ template = jinja_env.get_template(
129
+ f"themes/{theme}/components/vertical_layout.html"
130
+ )
131
+ component_html = Markup("\n".join(comp.render() for comp in self.components)) # nosemgrep: explicit-unescape-with-markup # nosec B704
132
+ return template.render(c=self, component_html=component_html) # nosemgrep: direct-use-of-jinja2
133
+
134
+ def to_dict(self) -> Dict[str, Any]:
135
+ result = super().to_dict()
136
+ result.update(
137
+ {
138
+ "components": [c.to_dict() for c in self.components],
139
+ "spacing": self.spacing,
140
+ "alignment": self.alignment,
141
+ }
142
+ )
143
+ return result
144
+
145
+
146
+ @dataclass
147
+ class HorizontalLayoutComponent(Component):
148
+ components: List[Component] = field(default_factory=list)
149
+ spacing: str = "1rem"
150
+ wrap: bool = False
151
+
152
+ def get_component_type(self) -> str:
153
+ return "horizontal_layout"
154
+
155
+ def get_children(self) -> list[Component]:
156
+ return self.components
157
+
158
+ def add_component(self, component: Component) -> "HorizontalLayoutComponent":
159
+ self.components.append(component)
160
+ return self
161
+
162
+ def render(self) -> str:
163
+ theme = getattr(g, "construct_theme", "whitelabel")
164
+ template = jinja_env.get_template(
165
+ f"themes/{theme}/components/horizontal_layout.html"
166
+ )
167
+ component_html = Markup("\n".join(comp.render() for comp in self.components)) # nosemgrep: explicit-unescape-with-markup # nosec B704
168
+ return template.render(c=self, component_html=component_html) # nosemgrep: direct-use-of-jinja2
169
+
170
+ def to_dict(self) -> Dict[str, Any]:
171
+ result = super().to_dict()
172
+ result.update(
173
+ {
174
+ "components": [c.to_dict() for c in self.components],
175
+ "spacing": self.spacing,
176
+ "wrap": self.wrap,
177
+ }
178
+ )
179
+ return result
180
+
181
+
182
+ @dataclass
183
+ class GridLayoutComponent(Component):
184
+ components: List[Component] = field(default_factory=list)
185
+ columns: int = 2
186
+ gap: str = "1rem"
187
+
188
+ def get_component_type(self) -> str:
189
+ return "grid_layout"
190
+
191
+ def get_children(self) -> list[Component]:
192
+ return self.components
193
+
194
+ def add_component(self, component: Component) -> "GridLayoutComponent":
195
+ self.components.append(component)
196
+ return self
197
+
198
+ def render(self) -> str:
199
+ theme = getattr(g, "construct_theme", "whitelabel")
200
+ template = jinja_env.get_template(f"themes/{theme}/components/grid_layout.html")
201
+ component_html = Markup("\n".join(comp.render() for comp in self.components)) # nosemgrep: explicit-unescape-with-markup # nosec B704
202
+ return template.render(c=self, component_html=component_html) # nosemgrep: direct-use-of-jinja2
203
+
204
+ def to_dict(self) -> Dict[str, Any]:
205
+ result = super().to_dict()
206
+ result.update(
207
+ {
208
+ "components": [c.to_dict() for c in self.components],
209
+ "columns": self.columns,
210
+ "gap": self.gap,
211
+ }
212
+ )
213
+ return result
214
+
215
+
216
+ # Specialized container components
217
+ @dataclass
218
+ class CardComponent(Component):
219
+ components: List[Component] = field(default_factory=list)
220
+ title: Optional[str] = None
221
+ outline: bool = False
222
+ variant: Optional[str] = None
223
+
224
+ def get_component_type(self) -> str:
225
+ return "card"
226
+
227
+ def get_children(self) -> list[Component]:
228
+ return self.components
229
+
230
+ def add_component(self, component: Component) -> "CardComponent":
231
+ self.components.append(component)
232
+ return self
233
+
234
+ def render(self) -> str:
235
+ theme = getattr(g, "construct_theme", "whitelabel")
236
+ template = jinja_env.get_template(f"themes/{theme}/components/card.html")
237
+ component_html = Markup("\n".join(comp.render() for comp in self.components)) # nosemgrep: explicit-unescape-with-markup # nosec B704
238
+ return template.render(c=self, component_html=component_html) # nosemgrep: direct-use-of-jinja2
239
+
240
+
241
+ @dataclass
242
+ class TabsComponent(Component):
243
+ id: str # Make id mandatory for TabsComponent
244
+ tabs: List[Dict[str, Union[str, Component]]] = field(default_factory=list)
245
+ active_tab: int = 0
246
+
247
+ def get_param_name(self) -> str:
248
+ return f"{self.id}_tab"
249
+
250
+ def get_tab_url(self, index: int) -> str:
251
+ from flask import request, has_request_context
252
+ from urllib.parse import urlencode
253
+
254
+ param_name = self.get_param_name()
255
+ if has_request_context():
256
+ args = request.args.copy()
257
+ args[param_name] = str(index)
258
+ return f"?{urlencode(list(args.items(multi=True)))}"
259
+ return f"?{param_name}={index}"
260
+
261
+ def get_component_type(self) -> str:
262
+ return "tabs"
263
+
264
+ def get_children(self) -> list[Component]:
265
+ return [
266
+ tab["component"]
267
+ for tab in self.tabs
268
+ if isinstance(tab.get("component"), Component)
269
+ ]
270
+
271
+ def add_tab(self, label: str, component: Component) -> "TabsComponent":
272
+ self.tabs.append({"label": label, "component": component})
273
+ return self
274
+
275
+ def render(self) -> str:
276
+ theme = getattr(g, "construct_theme", "whitelabel")
277
+ template = jinja_env.get_template(f"themes/{theme}/components/tabs.html")
278
+
279
+ # Ensure active_tab is a valid index, default to 0 if not
280
+ if not (self.tabs and 0 <= self.active_tab < len(self.tabs)):
281
+ self.active_tab = 0
282
+
283
+ active_tab_content = ""
284
+ # Render content only if there are tabs
285
+ if self.tabs:
286
+ active_tab_component = self.tabs[self.active_tab]["component"]
287
+ active_tab_content = Markup(active_tab_component.render()) # nosemgrep: explicit-unescape-with-markup # nosec B704
288
+
289
+ return template.render( # nosemgrep: direct-use-of-jinja2
290
+ c=self,
291
+ active_tab_content=active_tab_content,
292
+ )
293
+
294
+
295
+ @dataclass
296
+ class FormComponent(Component):
297
+ fields: List[Field] = field(default_factory=list)
298
+ actions: List[ButtonComponent] = field(default_factory=list)
299
+ data: Optional[Dict[str, Any]] = None
300
+
301
+ def get_component_type(self) -> str:
302
+ return "form"
303
+
304
+ def get_children(self) -> list[Component]:
305
+ return self.actions
306
+
307
+ def add_field(self, field: Field) -> "FormComponent":
308
+ self.fields.append(field)
309
+ return self
310
+
311
+ def add_action(self, action: "ButtonComponent") -> "FormComponent":
312
+ self.actions.append(action)
313
+ return self
314
+
315
+ def render(self) -> str:
316
+ theme = getattr(g, "construct_theme", "whitelabel")
317
+ if self.data:
318
+ for f in self.fields:
319
+ if f.key in self.data:
320
+ f.value = self.data.get(f.key)
321
+
322
+ # Resolve and render custom fields via registry
323
+ custom_rendered_fields = {}
324
+ fields_to_render = []
325
+ for f in self.fields:
326
+ renderer = FieldRendererRegistry.get_renderer(f.type)
327
+ if renderer:
328
+ rendered_html, placeholder_field = renderer(f, self.data, theme)
329
+ custom_rendered_fields[f.key] = rendered_html
330
+ fields_to_render.append(placeholder_field)
331
+ else:
332
+ fields_to_render.append(f)
333
+
334
+ template = jinja_env.get_template(f"themes/{theme}/components/form.html")
335
+ # Create a temporary copy of self to modify fields for rendering
336
+ render_context = copy(self)
337
+ render_context.fields = fields_to_render
338
+
339
+ # Create new ButtonComponent objects for rendering to avoid modifying originals
340
+ render_actions = []
341
+ for action in self.actions:
342
+ new_action = copy(action)
343
+ new_action.as_link = False
344
+ new_action.button_type = "submit"
345
+ render_actions.append(new_action)
346
+ render_context.actions = render_actions
347
+
348
+ return template.render( # nosemgrep: direct-use-of-jinja2
349
+ c=render_context, custom_rendered_fields=custom_rendered_fields
350
+ )
351
+
352
+ def to_dict(self) -> Dict[str, Any]:
353
+ result = super().to_dict()
354
+ result.update(
355
+ {
356
+ "fields": [
357
+ {
358
+ "type": f.type,
359
+ "key": f.key,
360
+ "label": f.label,
361
+ "required": f.required,
362
+ **({"options": f.options} if f.options else {}),
363
+ }
364
+ for f in self.fields
365
+ ],
366
+ "actions": [
367
+ {"type": a.type, "label": a.label, "trigger": a.trigger}
368
+ for a in self.actions
369
+ ],
370
+ }
371
+ )
372
+ return result
373
+
374
+
375
+ @dataclass
376
+ class ChartComponent(Component):
377
+ data: List[Tuple[str, List[Any]]] = field(default_factory=list)
378
+ chart_type: ChartType = None
379
+ width: Optional[int] = None
380
+ height: Optional[int] = None
381
+ datasource: Optional[str] = None
382
+
383
+ def get_component_type(self) -> str:
384
+ return "chart"
385
+
386
+ def render(self) -> str:
387
+ theme = getattr(g, "construct_theme", "whitelabel")
388
+ chart_svg = ""
389
+ chart = None
390
+
391
+ is_timeseries = False
392
+ if (
393
+ self.data
394
+ and self.data[0]
395
+ and self.data[0][1]
396
+ and isinstance(self.data[0][1][0], Tuple)
397
+ and isinstance(self.data[0][1][0][0], (datetime, date))
398
+ ):
399
+ is_timeseries = True
400
+
401
+ config_kwargs = {}
402
+ if self.width:
403
+ config_kwargs["width"] = self.width
404
+ if self.height:
405
+ config_kwargs["height"] = self.height
406
+
407
+ if is_timeseries:
408
+ config_kwargs["x_label_rotation"] = 20
409
+ config_kwargs["truncate_label"] = -1
410
+ chart_class = None
411
+
412
+ if isinstance(self.chart_type, str):
413
+ # Convert from string to enum.
414
+ self.chart_type = ChartType(self.chart_type.lower())
415
+
416
+ if self.chart_type == ChartType.BAR:
417
+ chart_class = pygal.Bar
418
+ elif self.chart_type == ChartType.STACKED_BAR:
419
+ chart_class = pygal.StackedBar
420
+ elif self.chart_type == ChartType.LINE:
421
+ chart_class = pygal.DateTimeLine if is_timeseries else pygal.Line
422
+ elif self.chart_type == ChartType.PIE:
423
+ chart_class = pygal.Pie
424
+
425
+ if chart_class:
426
+ chart = chart_class(**config_kwargs)
427
+ else:
428
+ chart_svg = f"<p>Unsupported chart type {self.chart_type}</p>"
429
+
430
+ if chart:
431
+ if (
432
+ self.chart_type in (ChartType.BAR, ChartType.STACKED_BAR)
433
+ and is_timeseries
434
+ ):
435
+ x_labels_set = set()
436
+ for _, series_data in self.data:
437
+ for point in series_data:
438
+ x_labels_set.add(point[0])
439
+
440
+ sorted_x_labels = sorted(list(x_labels_set))
441
+ chart.x_labels = [
442
+ label.strftime("%Y-%m-%d %H:%M") for label in sorted_x_labels
443
+ ]
444
+
445
+ for series_name, series_data in self.data:
446
+ data_map = dict(series_data)
447
+ aligned_values = [data_map.get(label) for label in sorted_x_labels]
448
+ chart.add(series_name, aligned_values)
449
+ else:
450
+ for series_name, series_data in self.data:
451
+ if self.chart_type == ChartType.PIE and series_data:
452
+ chart.add(series_name, series_data[0])
453
+ else:
454
+ chart.add(series_name, series_data)
455
+
456
+ chart_svg = chart.render(is_unicode=True)
457
+
458
+ template = jinja_env.get_template(f"themes/{theme}/components/chart.html")
459
+ return template.render(c=self, chart_svg=chart_svg) # nosemgrep: direct-use-of-jinja2
460
+
461
+ def to_dict(self) -> Dict[str, Any]:
462
+ result = super().to_dict()
463
+ result.update(
464
+ {
465
+ "data": self.data,
466
+ "chart_type": self.chart_type.value,
467
+ "datasource": self.datasource,
468
+ }
469
+ )
470
+ return result
471
+
472
+
473
+ @dataclass
474
+ class TextComponent(Component):
475
+ content: str = ""
476
+ tag: str = "p" # p, h1, h2, span, etc.
477
+
478
+ def get_component_type(self) -> str:
479
+ return "text"
480
+
481
+ def render(self) -> str:
482
+ theme = getattr(g, "construct_theme", "whitelabel")
483
+ template = jinja_env.get_template(f"themes/{theme}/components/text.html")
484
+ return template.render(c=self) # nosemgrep: direct-use-of-jinja2
485
+
486
+
487
+ @dataclass
488
+ class MarkupComponent(Component):
489
+ """Renders Markdown/Org/HTML content, with Jinja interpolation of ``args``.
490
+
491
+ SECURITY: ``content`` is treated as a developer-authored *template* — after
492
+ Markdown/Org conversion (and sanitization) it is still rendered with Jinja
493
+ (``from_string``). Never pass untrusted/user-supplied input as ``content``:
494
+ even after HTML sanitization it would remain a server-side template
495
+ injection (SSTI) vector (e.g. ``{{ config }}``). Pass dynamic or
496
+ user-supplied values through ``args`` instead — those are HTML-escaped and
497
+ are not re-evaluated as templates. ``format="html"`` is additionally a raw
498
+ escape hatch (no sanitization) and must only carry trusted markup.
499
+ """
500
+
501
+ content: str = ""
502
+ format: str = "md"
503
+ args: Dict[str, Any] = field(default_factory=dict)
504
+
505
+ def get_component_type(self) -> str:
506
+ return "markup"
507
+
508
+ def render(self) -> str:
509
+ theme = getattr(g, "construct_theme", "whitelabel")
510
+ # Dedent content to allow for indented triple-quoted strings
511
+ dedented_content = dedent(self.content)
512
+
513
+ if self.format == "html":
514
+ # Raw by design: format="html" is a developer escape hatch and
515
+ # must never carry user-supplied data.
516
+ html_template_str = dedented_content
517
+ elif self.format == "md":
518
+ md = MarkdownIt("js-default", {"html": True})
519
+ html_template_str = sanitize_html(md.render(dedented_content))
520
+ elif self.format in ("org", "orgmode"):
521
+ from .orgmode import org_to_html
522
+ html_template_str = sanitize_html(org_to_html(dedented_content))
523
+ else:
524
+ raise NotImplementedError(
525
+ f"Format '{self.format}' is not supported. Use 'html', 'md', or 'org'."
526
+ )
527
+
528
+ # Render the HTML with Jinja2 to process any embedded variables
529
+ template = jinja_env.from_string(html_template_str)
530
+ rendered_html = template.render(**self.args)
531
+
532
+ component_template = jinja_env.get_template(
533
+ f"themes/{theme}/components/markup.html"
534
+ )
535
+ return component_template.render(c=self, content=Markup(rendered_html)) # nosemgrep: direct-use-of-jinja2, explicit-unescape-with-markup # nosec B704
536
+
537
+ def to_dict(self) -> Dict[str, Any]:
538
+ result = super().to_dict()
539
+ result.update(
540
+ {
541
+ "content": self.content,
542
+ "format": self.format,
543
+ "args": self.args,
544
+ }
545
+ )
546
+ return result
547
+
548
+
549
+ @dataclass
550
+ class RichTextComponent(Component):
551
+ name: str = ""
552
+ content: str = ""
553
+ label: Optional[str] = None
554
+ style_tag: bool = True
555
+
556
+ def get_component_type(self) -> str:
557
+ return "rich_text"
558
+
559
+ def render(self) -> str:
560
+ theme = getattr(g, "construct_theme", "whitelabel")
561
+ editor_id = self.id or f"editor_{self.name}"
562
+ template = jinja_env.get_template(f"themes/{theme}/components/rich_text.html")
563
+ return template.render(c=self, editor_id=editor_id) # nosemgrep: direct-use-of-jinja2
564
+
565
+
566
+ @dataclass
567
+ class ButtonComponent(Component):
568
+ label: str = None
569
+ action: Union[str, Callable] = None
570
+ action_args: Optional[Dict[str, Any]] = None
571
+ variant: str = "primary" # primary, secondary, danger
572
+ as_link: bool = True
573
+ button_type: str = "button"
574
+ target: str = ""
575
+
576
+ def get_component_type(self) -> str:
577
+ return "button"
578
+
579
+ def render(self) -> str:
580
+ theme = getattr(g, "construct_theme", "whitelabel")
581
+
582
+ action_url = "#"
583
+ if self.action:
584
+ if isinstance(self.action, str):
585
+ if self.action.startswith("/") or self.action.startswith("http"):
586
+ action_url = self.action
587
+ else:
588
+ try:
589
+ if self.action_args:
590
+ action_url = url_for(self.action, **self.action_args)
591
+ else:
592
+ action_url = url_for(self.action)
593
+ except Exception:
594
+ if current_app.debug:
595
+ raise
596
+ action_url = "#"
597
+ elif inspect.ismethod(self.action):
598
+ if hasattr(self.action, "__self__"):
599
+ page = self.action.__self__
600
+ if hasattr(page, "endpoint"):
601
+ endpoint = f"{page.endpoint}.{self.action.__name__}"
602
+ try:
603
+ if self.action_args:
604
+ action_url = url_for(endpoint, **self.action_args)
605
+ else:
606
+ action_url = url_for(endpoint)
607
+ except Exception:
608
+ if current_app.debug:
609
+ raise
610
+ action_url = "#"
611
+
612
+ template = jinja_env.get_template(f"themes/{theme}/components/button.html")
613
+
614
+ return template.render(c=self, action_url=action_url) # nosemgrep: direct-use-of-jinja2
615
+
616
+
617
+ @dataclass
618
+ class TableComponent(Component):
619
+ headers: List[str] = None
620
+ rows: Optional[List[List[Any]]] = None
621
+ data_source: Optional[str] = None
622
+ striped: bool = False
623
+ hover: bool = False
624
+
625
+ def get_component_type(self) -> str:
626
+ return "table"
627
+
628
+ def render(self) -> str:
629
+ theme = getattr(g, "construct_theme", "whitelabel")
630
+ template = jinja_env.get_template(f"themes/{theme}/components/table.html")
631
+ return template.render(c=self) # nosemgrep: direct-use-of-jinja2
632
+
633
+
634
+ @dataclass
635
+ class CalendarComponent(Component):
636
+ datasource: Optional[str] = None
637
+ data: List[Dict[str, Any]] = field(default_factory=list)
638
+ start_day: str = "monday" # 'monday' or 'sunday'
639
+ mini: bool = False
640
+
641
+ def get_component_type(self) -> str:
642
+ return "calendar"
643
+
644
+ def render(self) -> str:
645
+ theme = getattr(g, "construct_theme", "whitelabel")
646
+
647
+ if self.start_day.lower() == "sunday":
648
+ calendar.setfirstweekday(calendar.SUNDAY)
649
+ else:
650
+ calendar.setfirstweekday(calendar.MONDAY)
651
+
652
+ if self.data:
653
+ first_date = self.data[0].get("date")
654
+ if isinstance(first_date, (datetime, date)):
655
+ year, month = first_date.year, first_date.month
656
+ else:
657
+ today = date.today()
658
+ year, month = today.year, today.month
659
+ else:
660
+ today = date.today()
661
+ year, month = today.year, today.month
662
+ lang = getattr(g, "language", None)
663
+ target_locale = LOCALE_MAPPING.get(lang)
664
+ day_names = calendar.weekheader(3).split()
665
+ month_name = calendar.month_name[month]
666
+ if target_locale:
667
+ original_locale = locale.getlocale(locale.LC_TIME)
668
+ try:
669
+ locale.setlocale(locale.LC_TIME, target_locale)
670
+ # Re-fetch names with the new locale
671
+ day_names = calendar.weekheader(3).split()
672
+ month_name = calendar.month_name[month]
673
+ except locale.Error:
674
+ # If locale is not installed on the system, it will fail.
675
+ # We just fall back to the default names.
676
+ pass
677
+
678
+ finally:
679
+ # Always restore the original locale
680
+ locale.setlocale(locale.LC_TIME, original_locale)
681
+
682
+ month_calendar = calendar.monthcalendar(year, month)
683
+
684
+ # Cell content may come from a @datasource carrying user data, and the
685
+ # API allows HTML snippets — sanitize before the template marks it safe.
686
+ content_map = {
687
+ item["date"].day: Markup(sanitize_html(str(item["content"]))) # nosemgrep: explicit-unescape-with-markup # nosec B704
688
+ for item in self.data
689
+ if item.get("date") and item.get("content")
690
+ }
691
+
692
+ if self.mini:
693
+ template = jinja_env.get_template(
694
+ f"themes/{theme}/components/calendarmini.html"
695
+ )
696
+ else:
697
+ template = jinja_env.get_template(
698
+ f"themes/{theme}/components/calendar.html"
699
+ )
700
+
701
+ return template.render(
702
+ c=self,
703
+ year=year,
704
+ month_name=month_name,
705
+ day_names=day_names,
706
+ weeks=month_calendar,
707
+ content=content_map,
708
+ )
709
+
710
+
711
+ @dataclass
712
+ class LiveComponent(Component):
713
+ """Component that displays live-updating data from a @livesource method or an explicit SSE URL."""
714
+
715
+ live_source: Optional[str] = None
716
+ interval: int = 10
717
+ title: Optional[str] = None
718
+ content_template: Optional[str] = None
719
+ content_style: Optional[str] = None
720
+ placeholder: str = ""
721
+ sse_url: Optional[str] = None
722
+ poll_url: Optional[str] = None
723
+ format: str = "default"
724
+ max_lines: Optional[int] = 200
725
+
726
+ def __post_init__(self):
727
+ if not self.live_source and self.sse_url is None:
728
+ raise ValueError("LiveComponent requires either live_source or sse_url")
729
+
730
+ if self.sse_url is not None:
731
+ self._sse_url = self.sse_url
732
+ self._poll_url = self.poll_url if self.poll_url is not None else ""
733
+ if self.id is None:
734
+ self.id = f"live-{uuid.uuid4().hex[:8]}"
735
+ else:
736
+ self._sse_url = ""
737
+ self._poll_url = ""
738
+
739
+ self._resolved_interval = self.interval
740
+ self._live_data = None
741
+
742
+ def get_component_type(self) -> str:
743
+ return "live"
744
+
745
+ def render(self) -> str:
746
+ theme = getattr(g, "construct_theme", "whitelabel")
747
+ template = jinja_env.get_template(f"themes/{theme}/components/live.html")
748
+
749
+ content_html = None
750
+ if self._live_data is not None and self.content_template:
751
+ content_html = Markup( # nosemgrep: explicit-unescape-with-markup # nosec B704
752
+ jinja_env.from_string(self.content_template).render(**self._live_data)
753
+ )
754
+
755
+ construct = getattr(g, "construct", None)
756
+ not_updating_text = (
757
+ construct.t("live-not-updating") if construct else "Not updating"
758
+ )
759
+
760
+ return template.render( # nosemgrep: direct-use-of-jinja2
761
+ c=self,
762
+ content_html=content_html,
763
+ live_data=self._live_data,
764
+ interval_ms=self._resolved_interval * 1000,
765
+ not_updating_text=not_updating_text,
766
+ )
767
+
768
+
769
+ class Components:
770
+ def __init__(self):
771
+ self.VerticalLayout = VerticalLayoutComponent
772
+ self.HorizontalLayout = HorizontalLayoutComponent
773
+ self.GridLayout = GridLayoutComponent
774
+ self.Card = CardComponent
775
+ self.Tabs = TabsComponent
776
+ self.Form = FormComponent
777
+ self.Chart = ChartComponent
778
+ self.Text = TextComponent
779
+ self.Markup = MarkupComponent
780
+ self.RichText = RichTextComponent
781
+ self.Button = ButtonComponent
782
+ self.Table = TableComponent
783
+ self.Calendar = CalendarComponent
784
+ self.Live = LiveComponent
785
+ self.Field = Field
786
+
787
+
788
+ components = Components()
789
+
790
+
791
+ def find_component(
792
+ component: Component, condition: Callable[[Component], bool]
793
+ ) -> Optional[Component]:
794
+ """
795
+ Traverses the component tree and returns the first component that satisfies the condition (DFS).
796
+ """
797
+ if condition(component):
798
+ return component
799
+ for child in component.get_children():
800
+ found = find_component(child, condition)
801
+ if found:
802
+ return found
803
+ return None
804
+
805
+
806
+ def find_component_by_id(
807
+ root_component: Component, component_id: str
808
+ ) -> Optional[Component]:
809
+ """
810
+ Finds a component by its ID.
811
+ """
812
+ return find_component(root_component, lambda c: c.id == component_id)