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,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)
|