flask-admin-tabler 0.1.1__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_admin_tabler/__init__.py +18 -0
- flask_admin_tabler/json_widget.py +363 -0
- flask_admin_tabler/static/admin/css/tabler/admin.css +90 -0
- flask_admin_tabler/static/admin/css/tabler/rediscli.css +48 -0
- flask_admin_tabler/static/tabler/css/tabler-themes.min.css +8 -0
- flask_admin_tabler/static/tabler/css/tabler.min.css +9 -0
- flask_admin_tabler/static/tabler/icons/fonts/tabler-icons.ttf +0 -0
- flask_admin_tabler/static/tabler/icons/fonts/tabler-icons.woff +0 -0
- flask_admin_tabler/static/tabler/icons/fonts/tabler-icons.woff2 +0 -0
- flask_admin_tabler/static/tabler/icons/tabler-icons.min.css +4 -0
- flask_admin_tabler/static/tabler/js/tabler.min.js +13 -0
- flask_admin_tabler/static/vendor/highlightjs/highlight.min.js +1244 -0
- flask_admin_tabler/static/vendor/highlightjs/styles/googlecode.min.css +1 -0
- flask_admin_tabler/static/vendor/highlightjs/styles/monokai.min.css +1 -0
- flask_admin_tabler/templates/tabler/admin/actions.html +38 -0
- flask_admin_tabler/templates/tabler/admin/base.html +219 -0
- flask_admin_tabler/templates/tabler/admin/file/form.html +29 -0
- flask_admin_tabler/templates/tabler/admin/file/list.html +210 -0
- flask_admin_tabler/templates/tabler/admin/file/modals/form.html +19 -0
- flask_admin_tabler/templates/tabler/admin/index.html +4 -0
- flask_admin_tabler/templates/tabler/admin/layout.html +109 -0
- flask_admin_tabler/templates/tabler/admin/lib.html +290 -0
- flask_admin_tabler/templates/tabler/admin/master.html +1 -0
- flask_admin_tabler/templates/tabler/admin/model/create.html +30 -0
- flask_admin_tabler/templates/tabler/admin/model/details.html +52 -0
- flask_admin_tabler/templates/tabler/admin/model/edit.html +40 -0
- flask_admin_tabler/templates/tabler/admin/model/inline_field_list.html +15 -0
- flask_admin_tabler/templates/tabler/admin/model/inline_form.html +4 -0
- flask_admin_tabler/templates/tabler/admin/model/inline_list_base.html +45 -0
- flask_admin_tabler/templates/tabler/admin/model/layout.html +107 -0
- flask_admin_tabler/templates/tabler/admin/model/list.html +198 -0
- flask_admin_tabler/templates/tabler/admin/model/modals/create.html +26 -0
- flask_admin_tabler/templates/tabler/admin/model/modals/details.html +40 -0
- flask_admin_tabler/templates/tabler/admin/model/modals/edit.html +27 -0
- flask_admin_tabler/templates/tabler/admin/model/row_actions.html +38 -0
- flask_admin_tabler/templates/tabler/admin/rediscli/console.html +27 -0
- flask_admin_tabler/templates/tabler/admin/rediscli/response.html +32 -0
- flask_admin_tabler/templates/tabler/admin/static.html +3 -0
- flask_admin_tabler/theme.py +78 -0
- flask_admin_tabler-0.1.1.dist-info/METADATA +86 -0
- flask_admin_tabler-0.1.1.dist-info/RECORD +43 -0
- flask_admin_tabler-0.1.1.dist-info/WHEEL +4 -0
- flask_admin_tabler-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from flask_admin_tabler.json_widget import (
|
|
2
|
+
JsonColumnsMixin,
|
|
3
|
+
JsonTextAreaWidget,
|
|
4
|
+
json_detail_formatter,
|
|
5
|
+
json_list_formatter,
|
|
6
|
+
)
|
|
7
|
+
from flask_admin_tabler.theme import TablerTheme
|
|
8
|
+
|
|
9
|
+
tabler_bool_formatter = TablerTheme.bool_formatter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TablerTheme",
|
|
13
|
+
"tabler_bool_formatter",
|
|
14
|
+
"JsonColumnsMixin",
|
|
15
|
+
"JsonTextAreaWidget",
|
|
16
|
+
"json_detail_formatter",
|
|
17
|
+
"json_list_formatter",
|
|
18
|
+
]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""flask_admin_tabler.json_widget
|
|
2
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
3
|
+
Reusable JSON column support for Flask-Admin ModelViews.
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from flask_admin_tabler import JsonColumnsMixin
|
|
8
|
+
|
|
9
|
+
class MyView(JsonColumnsMixin, SecureModelView):
|
|
10
|
+
json_columns = ["config", "metadata"]
|
|
11
|
+
|
|
12
|
+
The mixin automatically:
|
|
13
|
+
- Shows a compact badge in the list view for JSON objects/arrays.
|
|
14
|
+
- Shows pretty-printed, syntax-highlighted JSON in the details view.
|
|
15
|
+
- Adds a "Preview JSON" button in the edit / create forms.
|
|
16
|
+
- Minifies JSON values before saving (strips unnecessary whitespace).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import typing as t
|
|
21
|
+
|
|
22
|
+
from markupsafe import Markup, escape
|
|
23
|
+
from wtforms.widgets import TextArea
|
|
24
|
+
|
|
25
|
+
_HLJS = "/static/flask_admin_tabler/vendor/highlightjs"
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# One-time per-page initialisation block.
|
|
29
|
+
# window.__jsonToolsInit guards against double-execution when multiple JSON
|
|
30
|
+
# fields or formatters inject this snippet on the same page.
|
|
31
|
+
# All DOM construction uses createElement/appendChild — no innerHTML.
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
_ONCE_SCRIPT = Markup(
|
|
34
|
+
"""<script>
|
|
35
|
+
(function () {
|
|
36
|
+
if (window.__jsonToolsInit) return;
|
|
37
|
+
window.__jsonToolsInit = true;
|
|
38
|
+
|
|
39
|
+
/* ---- helpers --------------------------------------------------------- */
|
|
40
|
+
function mkIcon(cls) {
|
|
41
|
+
var el = document.createElement('i');
|
|
42
|
+
el.className = cls;
|
|
43
|
+
return el;
|
|
44
|
+
}
|
|
45
|
+
function loadLink(id, href) {
|
|
46
|
+
var existing = document.getElementById(id);
|
|
47
|
+
if (existing) return existing;
|
|
48
|
+
var link = document.createElement('link');
|
|
49
|
+
link.id = id; link.rel = 'stylesheet'; link.href = href;
|
|
50
|
+
document.head.appendChild(link);
|
|
51
|
+
return link;
|
|
52
|
+
}
|
|
53
|
+
function copyText(text, btn) {
|
|
54
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
55
|
+
btn.textContent = '';
|
|
56
|
+
btn.appendChild(mkIcon('ti ti-check text-success'));
|
|
57
|
+
btn.appendChild(document.createTextNode(' Copied!'));
|
|
58
|
+
setTimeout(function () {
|
|
59
|
+
btn.textContent = '';
|
|
60
|
+
btn.appendChild(mkIcon('ti ti-copy'));
|
|
61
|
+
}, 1500);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ---- inject styles --------------------------------------------------- */
|
|
66
|
+
var st = document.createElement('style');
|
|
67
|
+
st.textContent = [
|
|
68
|
+
'.json-viewer{border-radius:4px;overflow:auto;font-size:.82em;margin:0;padding:1rem}',
|
|
69
|
+
'.json-viewer-wrapper{position:relative}',
|
|
70
|
+
'.json-copy-btn{position:absolute;top:.4rem;right:.4rem;opacity:.55;transition:opacity .15s;z-index:1}',
|
|
71
|
+
'.json-copy-btn:hover{opacity:1}',
|
|
72
|
+
'#json-preview-modal .json-viewer{max-height:70vh}',
|
|
73
|
+
].join('');
|
|
74
|
+
document.head.appendChild(st);
|
|
75
|
+
|
|
76
|
+
/* ---- inject modal (DOM only) ----------------------------------------- */
|
|
77
|
+
if (!document.getElementById('json-preview-modal')) {
|
|
78
|
+
/* hidden trigger for Bootstrap's data-bs-toggle delegation */
|
|
79
|
+
var trigger = document.createElement('button');
|
|
80
|
+
trigger.type = 'button';
|
|
81
|
+
trigger.id = 'json-preview-trigger';
|
|
82
|
+
trigger.setAttribute('data-bs-toggle', 'modal');
|
|
83
|
+
trigger.setAttribute('data-bs-target', '#json-preview-modal');
|
|
84
|
+
trigger.style.display = 'none';
|
|
85
|
+
trigger.setAttribute('aria-hidden', 'true');
|
|
86
|
+
document.body.appendChild(trigger);
|
|
87
|
+
|
|
88
|
+
var modal = document.createElement('div');
|
|
89
|
+
modal.className = 'modal fade';
|
|
90
|
+
modal.id = 'json-preview-modal';
|
|
91
|
+
modal.tabIndex = -1;
|
|
92
|
+
modal.setAttribute('aria-hidden', 'true');
|
|
93
|
+
|
|
94
|
+
var dialog = document.createElement('div');
|
|
95
|
+
dialog.className = 'modal-dialog modal-xl modal-dialog-scrollable';
|
|
96
|
+
|
|
97
|
+
var content = document.createElement('div');
|
|
98
|
+
content.className = 'modal-content';
|
|
99
|
+
|
|
100
|
+
/* header */
|
|
101
|
+
var hdr = document.createElement('div');
|
|
102
|
+
hdr.className = 'modal-header';
|
|
103
|
+
var ttl = document.createElement('h5');
|
|
104
|
+
ttl.className = 'modal-title';
|
|
105
|
+
ttl.appendChild(mkIcon('ti ti-code me-2'));
|
|
106
|
+
ttl.appendChild(document.createTextNode('JSON Preview'));
|
|
107
|
+
var xBtn = document.createElement('button');
|
|
108
|
+
xBtn.type = 'button';
|
|
109
|
+
xBtn.className = 'btn-close';
|
|
110
|
+
xBtn.setAttribute('data-bs-dismiss', 'modal');
|
|
111
|
+
hdr.appendChild(ttl);
|
|
112
|
+
hdr.appendChild(xBtn);
|
|
113
|
+
|
|
114
|
+
/* body */
|
|
115
|
+
var bdy = document.createElement('div');
|
|
116
|
+
bdy.className = 'modal-body p-0';
|
|
117
|
+
var pre = document.createElement('pre');
|
|
118
|
+
pre.className = 'json-viewer mb-0 rounded-0';
|
|
119
|
+
var code = document.createElement('code');
|
|
120
|
+
code.className = 'language-json';
|
|
121
|
+
code.id = 'json-preview-code';
|
|
122
|
+
pre.appendChild(code);
|
|
123
|
+
bdy.appendChild(pre);
|
|
124
|
+
|
|
125
|
+
/* footer */
|
|
126
|
+
var ftr = document.createElement('div');
|
|
127
|
+
ftr.className = 'modal-footer';
|
|
128
|
+
var cpBtn = document.createElement('button');
|
|
129
|
+
cpBtn.type = 'button';
|
|
130
|
+
cpBtn.id = 'json-preview-copy-btn';
|
|
131
|
+
cpBtn.className = 'btn btn-sm btn-secondary';
|
|
132
|
+
cpBtn.appendChild(mkIcon('ti ti-copy me-1'));
|
|
133
|
+
cpBtn.appendChild(document.createTextNode('Copy'));
|
|
134
|
+
cpBtn.addEventListener('click', function () {
|
|
135
|
+
copyText(document.getElementById('json-preview-code').innerText, cpBtn);
|
|
136
|
+
});
|
|
137
|
+
var clBtn = document.createElement('button');
|
|
138
|
+
clBtn.type = 'button';
|
|
139
|
+
clBtn.className = 'btn btn-sm btn-secondary';
|
|
140
|
+
clBtn.setAttribute('data-bs-dismiss', 'modal');
|
|
141
|
+
clBtn.textContent = 'Close';
|
|
142
|
+
ftr.appendChild(cpBtn);
|
|
143
|
+
ftr.appendChild(clBtn);
|
|
144
|
+
|
|
145
|
+
content.appendChild(hdr);
|
|
146
|
+
content.appendChild(bdy);
|
|
147
|
+
content.appendChild(ftr);
|
|
148
|
+
dialog.appendChild(content);
|
|
149
|
+
modal.appendChild(dialog);
|
|
150
|
+
document.body.appendChild(modal);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---- CSS themes ------------------------------------------------------ */
|
|
154
|
+
var lightCss = loadLink('hljs-light', '%%HLJS%%/styles/googlecode.min.css');
|
|
155
|
+
var darkCss = loadLink('hljs-dark', '%%HLJS%%/styles/monokai.min.css');
|
|
156
|
+
function applyTheme() {
|
|
157
|
+
var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
|
158
|
+
lightCss.disabled = dark;
|
|
159
|
+
darkCss.disabled = !dark;
|
|
160
|
+
}
|
|
161
|
+
applyTheme();
|
|
162
|
+
new MutationObserver(applyTheme).observe(document.documentElement, {
|
|
163
|
+
attributes: true, attributeFilter: ['data-bs-theme']
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/* ---- load highlight.js then wire everything up ----------------------- */
|
|
167
|
+
var hljsEl = document.createElement('script');
|
|
168
|
+
hljsEl.src = '%%HLJS%%/highlight.min.js';
|
|
169
|
+
hljsEl.onload = function () {
|
|
170
|
+
/* highlight existing .json-viewer blocks and add copy buttons */
|
|
171
|
+
document.querySelectorAll('.json-viewer code').forEach(function (c) {
|
|
172
|
+
hljs.highlightElement(c);
|
|
173
|
+
var p = c.parentElement;
|
|
174
|
+
var w = document.createElement('div');
|
|
175
|
+
w.className = 'json-viewer-wrapper';
|
|
176
|
+
p.parentNode.insertBefore(w, p);
|
|
177
|
+
w.appendChild(p);
|
|
178
|
+
var b = document.createElement('button');
|
|
179
|
+
b.type = 'button';
|
|
180
|
+
b.className = 'json-copy-btn btn btn-sm btn-ghost-secondary';
|
|
181
|
+
b.title = 'Copy JSON';
|
|
182
|
+
b.appendChild(mkIcon('ti ti-copy'));
|
|
183
|
+
b.addEventListener('click', function () { copyText(c.innerText, b); });
|
|
184
|
+
w.appendChild(b);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/* event delegation for .json-preview-btn (edit / create forms) */
|
|
188
|
+
document.addEventListener('click', function (e) {
|
|
189
|
+
var btn = e.target.closest('.json-preview-btn');
|
|
190
|
+
if (!btn) return;
|
|
191
|
+
var field = document.getElementById(btn.dataset.jsonTarget);
|
|
192
|
+
if (!field) return;
|
|
193
|
+
var raw = field.value.trim();
|
|
194
|
+
if (!raw) { alert('The value field is empty.'); return; }
|
|
195
|
+
var parsed;
|
|
196
|
+
try { parsed = JSON.parse(raw); }
|
|
197
|
+
catch (err) { alert('Invalid JSON:\\n' + err.message); return; }
|
|
198
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
199
|
+
alert('Value is not a JSON object or array.'); return;
|
|
200
|
+
}
|
|
201
|
+
var codeEl = document.getElementById('json-preview-code');
|
|
202
|
+
codeEl.textContent = JSON.stringify(parsed, null, 2);
|
|
203
|
+
delete codeEl.dataset.highlighted;
|
|
204
|
+
hljs.highlightElement(codeEl);
|
|
205
|
+
document.getElementById('json-preview-trigger').click();
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
document.head.appendChild(hljsEl);
|
|
209
|
+
})();
|
|
210
|
+
</script>""".replace("%%HLJS%%", _HLJS)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# WTForms widget
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class JsonTextAreaWidget:
|
|
220
|
+
"""WTForms widget that renders a textarea with a JSON preview button.
|
|
221
|
+
|
|
222
|
+
Injects the one-time initialisation script on first use per page.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __call__(self, field: t.Any, **kwargs: t.Any) -> Markup:
|
|
226
|
+
textarea = TextArea()(field, **kwargs)
|
|
227
|
+
btn = Markup(
|
|
228
|
+
f'<button type="button"'
|
|
229
|
+
f' class="btn btn-sm btn-secondary mt-1 json-preview-btn"'
|
|
230
|
+
f' data-json-target="{escape(field.id)}">'
|
|
231
|
+
f'<i class="ti ti-eye me-1"></i>Preview JSON</button>'
|
|
232
|
+
)
|
|
233
|
+
return textarea + btn + _ONCE_SCRIPT
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Internal helper
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_json_value(value: t.Any) -> dict | list | None:
|
|
242
|
+
"""Return a parsed JSON object/array from *value*, or None.
|
|
243
|
+
|
|
244
|
+
Handles both:
|
|
245
|
+
- ``str`` — Text columns that store JSON as a string (uses ``json.loads``).
|
|
246
|
+
- ``dict`` / ``list`` — SQLAlchemy JSON columns that return Python objects directly.
|
|
247
|
+
"""
|
|
248
|
+
if isinstance(value, (dict, list)):
|
|
249
|
+
return value
|
|
250
|
+
if isinstance(value, str) and value:
|
|
251
|
+
try:
|
|
252
|
+
parsed = json.loads(value)
|
|
253
|
+
if isinstance(parsed, (dict, list)):
|
|
254
|
+
return parsed
|
|
255
|
+
except (ValueError, TypeError):
|
|
256
|
+
pass
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Column formatters
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def json_list_formatter(view: t.Any, context: t.Any, model: t.Any, name: str) -> Markup:
|
|
266
|
+
"""List-view formatter: compact badge for JSON objects/arrays."""
|
|
267
|
+
value = getattr(model, name, None)
|
|
268
|
+
if value is None:
|
|
269
|
+
return Markup("")
|
|
270
|
+
parsed = _parse_json_value(value)
|
|
271
|
+
if parsed is not None:
|
|
272
|
+
tip = escape(json.dumps(parsed, separators=(",", ":"), ensure_ascii=False))
|
|
273
|
+
if isinstance(parsed, dict):
|
|
274
|
+
label = f"{{ {len(parsed)} keys }}"
|
|
275
|
+
return Markup(f'<span class="badge bg-green-lt" title="{tip}">{escape(label)}</span>')
|
|
276
|
+
label = f"[ {len(parsed)} items ]"
|
|
277
|
+
return Markup(f'<span class="badge bg-blue-lt" title="{tip}">{escape(label)}</span>')
|
|
278
|
+
# Plain string fallback
|
|
279
|
+
text = str(value)
|
|
280
|
+
if len(text) > 80:
|
|
281
|
+
return Markup(f'<span title="{escape(text)}">{escape(text[:77])}…</span>')
|
|
282
|
+
return Markup(escape(text))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def json_detail_formatter(view: t.Any, context: t.Any, model: t.Any, name: str) -> Markup:
|
|
286
|
+
"""Details-view formatter: pretty-printed, syntax-highlighted JSON."""
|
|
287
|
+
value = getattr(model, name, None)
|
|
288
|
+
if value is None:
|
|
289
|
+
return Markup("")
|
|
290
|
+
parsed = _parse_json_value(value)
|
|
291
|
+
if parsed is not None:
|
|
292
|
+
formatted = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
293
|
+
return (
|
|
294
|
+
Markup(f'<pre class="json-viewer"><code class="language-json">{escape(formatted)}</code></pre>')
|
|
295
|
+
+ _ONCE_SCRIPT
|
|
296
|
+
)
|
|
297
|
+
return Markup(escape(str(value)))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# ModelView mixin
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class JsonColumnsMixin:
|
|
306
|
+
"""Mixin for Flask-Admin ModelView that adds JSON column support.
|
|
307
|
+
|
|
308
|
+
Set ``json_columns`` to the names of columns that hold JSON values::
|
|
309
|
+
|
|
310
|
+
class MyView(JsonColumnsMixin, ModelView):
|
|
311
|
+
json_columns = ["config", "metadata"]
|
|
312
|
+
|
|
313
|
+
The mixin merges formatters and widget args at instantiation time so it
|
|
314
|
+
never mutates shared class-level dicts. Any formatters or widget args
|
|
315
|
+
already defined on the subclass take precedence (``setdefault`` semantics).
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
json_columns: t.ClassVar[list[str]] = []
|
|
319
|
+
|
|
320
|
+
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
|
|
321
|
+
if self.json_columns:
|
|
322
|
+
# Copy existing dicts — never mutate shared class-level objects.
|
|
323
|
+
cf = dict(getattr(self, "column_formatters", {}) or {})
|
|
324
|
+
cfd = dict(getattr(self, "column_formatters_detail", {}) or {})
|
|
325
|
+
fwa = dict(getattr(self, "form_widget_args", {}) or {})
|
|
326
|
+
|
|
327
|
+
for col in self.json_columns:
|
|
328
|
+
cf.setdefault(col, json_list_formatter)
|
|
329
|
+
cfd.setdefault(col, json_detail_formatter)
|
|
330
|
+
fwa.setdefault(col, {"widget": JsonTextAreaWidget()})
|
|
331
|
+
|
|
332
|
+
# Set as instance attributes so Flask-Admin's __init__ picks them up.
|
|
333
|
+
self.column_formatters = cf
|
|
334
|
+
self.column_formatters_detail = cfd
|
|
335
|
+
self.form_widget_args = fwa
|
|
336
|
+
|
|
337
|
+
super().__init__(*args, **kwargs)
|
|
338
|
+
|
|
339
|
+
def on_model_change(self, form: t.Any, model: t.Any, is_created: bool) -> None:
|
|
340
|
+
"""Minify JSON string values before persisting to the database.
|
|
341
|
+
|
|
342
|
+
Only applies to Text columns that store JSON as a string. Native
|
|
343
|
+
dict/list values (SQLAlchemy JSON columns) are left untouched —
|
|
344
|
+
SQLAlchemy serialises those itself.
|
|
345
|
+
"""
|
|
346
|
+
for col in self.json_columns:
|
|
347
|
+
value = getattr(model, col, None)
|
|
348
|
+
if isinstance(value, str) and value:
|
|
349
|
+
try:
|
|
350
|
+
parsed = json.loads(value)
|
|
351
|
+
if isinstance(parsed, (dict, list)):
|
|
352
|
+
setattr(
|
|
353
|
+
model,
|
|
354
|
+
col,
|
|
355
|
+
json.dumps(
|
|
356
|
+
parsed,
|
|
357
|
+
separators=(",", ":"),
|
|
358
|
+
ensure_ascii=False,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
except (ValueError, TypeError):
|
|
362
|
+
pass
|
|
363
|
+
super().on_model_change(form, model, is_created)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* Flask-Admin Tabler Theme — admin.css
|
|
2
|
+
Minimal overrides to make Flask-Admin widgets blend with Tabler 1.4.0.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/* --- General tweaks --- */
|
|
6
|
+
.admin-form fieldset {
|
|
7
|
+
border: none;
|
|
8
|
+
padding: 0;
|
|
9
|
+
margin: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.submit-row {
|
|
13
|
+
display: flex;
|
|
14
|
+
gap: 0.5rem;
|
|
15
|
+
flex-wrap: wrap;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* --- List view --- */
|
|
19
|
+
.model-list th.list-checkbox-column,
|
|
20
|
+
.model-list td.list-checkbox-column {
|
|
21
|
+
width: 2rem;
|
|
22
|
+
text-align: center;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.list-buttons-column {
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.list-buttons-column form.icon {
|
|
30
|
+
display: inline;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.list-buttons-column form.icon button {
|
|
34
|
+
background: none;
|
|
35
|
+
border: none;
|
|
36
|
+
padding: 0 0.25rem;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
color: var(--tblr-secondary);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.list-buttons-column form.icon button:hover {
|
|
42
|
+
color: var(--tblr-danger);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.list-buttons-column a.icon {
|
|
46
|
+
padding: 0 0.25rem;
|
|
47
|
+
color: var(--tblr-secondary);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.list-buttons-column a.icon:hover {
|
|
51
|
+
color: var(--tblr-primary);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* --- Inline fields --- */
|
|
55
|
+
.inline-field .inline-field-list .inline-field {
|
|
56
|
+
margin-bottom: 1rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.inline-remove-field {
|
|
60
|
+
color: var(--tblr-danger);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* --- Filters --- */
|
|
64
|
+
.filters td {
|
|
65
|
+
padding: 0.25rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.field-filters .filter {
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* --- Select2 integration --- */
|
|
73
|
+
.select2-container {
|
|
74
|
+
min-width: 100%;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* --- Nav tabs used in model views --- */
|
|
78
|
+
.nav-tabs {
|
|
79
|
+
margin-bottom: 1rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* --- Pagination alignment --- */
|
|
83
|
+
.pagination {
|
|
84
|
+
margin-top: 1rem;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* --- Flash messages --- */
|
|
88
|
+
.alert-dismissible .btn-close {
|
|
89
|
+
padding: 0.75rem 1rem;
|
|
90
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
.console {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
min-height: 400px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.console-container {
|
|
8
|
+
border-radius: 4px;
|
|
9
|
+
position: absolute;
|
|
10
|
+
border: 1px solid #d4d4d4;
|
|
11
|
+
padding: 2px;
|
|
12
|
+
overflow: scroll;
|
|
13
|
+
top: 2px;
|
|
14
|
+
left: 2px;
|
|
15
|
+
right: 2px;
|
|
16
|
+
bottom: 5em;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.console-line {
|
|
20
|
+
position: absolute;
|
|
21
|
+
left: 2px;
|
|
22
|
+
right: 2px;
|
|
23
|
+
bottom: 2px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.console-line input {
|
|
27
|
+
width: 100%;
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
-moz-box-sizing: border-box;
|
|
30
|
+
-webkit-box-sizing: border-box;
|
|
31
|
+
height: 2em;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.console .cmd {
|
|
35
|
+
background-color: #f5f5f5;
|
|
36
|
+
padding: 2px;
|
|
37
|
+
margin: 1px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.console .response {
|
|
41
|
+
background-color: #f0f0f0;
|
|
42
|
+
padding: 2px;
|
|
43
|
+
margin: 1px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.console .error {
|
|
47
|
+
color: red;
|
|
48
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Tabler v1.4.0 (https://tabler.io)
|
|
3
|
+
* Copyright 2018-2025 The Tabler Authors
|
|
4
|
+
* Copyright 2018-2025 codecalm.net Paweł Kuna
|
|
5
|
+
* Licensed under MIT (https://github.com/tabler/tabler/blob/master/LICENSE)
|
|
6
|
+
*/
|
|
7
|
+
[data-bs-theme-base=slate]{--tblr-gray-50:#f8fafc;--tblr-gray-100:#f1f5f9;--tblr-gray-200:#e2e8f0;--tblr-gray-300:#cbd5e1;--tblr-gray-400:#94a3b8;--tblr-gray-500:#64748b;--tblr-gray-600:#475569;--tblr-gray-700:#334155;--tblr-gray-800:#1e293b;--tblr-gray-900:#0f172a;--tblr-gray-950:#020617}[data-bs-theme-base=gray]{--tblr-gray-50:#f9fafb;--tblr-gray-100:#f3f4f6;--tblr-gray-200:#e5e7eb;--tblr-gray-300:#d1d5db;--tblr-gray-400:#9ca3af;--tblr-gray-500:#6b7280;--tblr-gray-600:#4b5563;--tblr-gray-700:#374151;--tblr-gray-800:#1f2937;--tblr-gray-900:#111827;--tblr-gray-950:#030712}[data-bs-theme-base=zinc]{--tblr-gray-50:#fafafa;--tblr-gray-100:#f4f4f5;--tblr-gray-200:#e4e4e7;--tblr-gray-300:#d4d4d8;--tblr-gray-400:#a1a1aa;--tblr-gray-500:#71717a;--tblr-gray-600:#52525b;--tblr-gray-700:#3f3f46;--tblr-gray-800:#27272a;--tblr-gray-900:#18181b;--tblr-gray-950:#09090b}[data-bs-theme-base=neutral]{--tblr-gray-50:#fafafa;--tblr-gray-100:#f5f5f5;--tblr-gray-200:#e5e5e5;--tblr-gray-300:#d4d4d4;--tblr-gray-400:#a3a3a3;--tblr-gray-500:#737373;--tblr-gray-600:#525252;--tblr-gray-700:#404040;--tblr-gray-800:#262626;--tblr-gray-900:#171717;--tblr-gray-950:#0a0a0a}[data-bs-theme-base=stone]{--tblr-gray-50:#fafaf9;--tblr-gray-100:#f5f5f4;--tblr-gray-200:#e7e5e4;--tblr-gray-300:#d6d3d1;--tblr-gray-400:#a8a29e;--tblr-gray-500:#78716c;--tblr-gray-600:#57534e;--tblr-gray-700:#44403c;--tblr-gray-800:#292524;--tblr-gray-900:#1c1917;--tblr-gray-950:#0c0a09}[data-bs-theme-base=pink]{--tblr-gray-50:#fdf2f8;--tblr-gray-100:#fce7f3;--tblr-gray-200:#fbcfe8;--tblr-gray-300:#f9a8d4;--tblr-gray-400:#f472b6;--tblr-gray-500:#ec4899;--tblr-gray-600:#db2777;--tblr-gray-700:#be185d;--tblr-gray-800:#9d174d;--tblr-gray-900:#831843;--tblr-gray-950:#500724}[data-bs-theme-primary=blue]{--tblr-primary:#066fd1;--tblr-primary-rgb:6,111,209}[data-bs-theme-primary=azure]{--tblr-primary:#4299e1;--tblr-primary-rgb:66,153,225}[data-bs-theme-primary=indigo]{--tblr-primary:#4263eb;--tblr-primary-rgb:66,99,235}[data-bs-theme-primary=purple]{--tblr-primary:#ae3ec9;--tblr-primary-rgb:174,62,201}[data-bs-theme-primary=pink]{--tblr-primary:#d6336c;--tblr-primary-rgb:214,51,108}[data-bs-theme-primary=red]{--tblr-primary:#d63939;--tblr-primary-rgb:214,57,57}[data-bs-theme-primary=orange]{--tblr-primary:#f76707;--tblr-primary-rgb:247,103,7}[data-bs-theme-primary=yellow]{--tblr-primary:#f59f00;--tblr-primary-rgb:245,159,0}[data-bs-theme-primary=lime]{--tblr-primary:#74b816;--tblr-primary-rgb:116,184,22}[data-bs-theme-primary=green]{--tblr-primary:#2fb344;--tblr-primary-rgb:47,179,68}[data-bs-theme-primary=teal]{--tblr-primary:#0ca678;--tblr-primary-rgb:12,166,120}[data-bs-theme-primary=cyan]{--tblr-primary:#17a2b8;--tblr-primary-rgb:23,162,184}[data-bs-theme-radius="0"]{--tblr-border-radius-scale:0}[data-bs-theme-radius="0.5"]{--tblr-border-radius-scale:0.5}[data-bs-theme-radius="1"]{--tblr-border-radius-scale:1}[data-bs-theme-radius="1.5"]{--tblr-border-radius-scale:1.5}[data-bs-theme-radius="2"]{--tblr-border-radius-scale:2}[data-bs-theme-primary=inverted]{--tblr-primary:var(--tblr-gray-800);--tblr-primary-fg:var(--tblr-light);--tblr-primary-rgb:31,41,55}[data-bs-theme-primary=inverted] [data-bs-theme=dark],[data-bs-theme-primary=inverted][data-bs-theme=dark]{--tblr-primary:#f9fafb;--tblr-primary-fg:var(--tblr-dark);--tblr-primary-rgb:249,250,251}[data-bs-theme-font=monospace]{--tblr-body-font-family:var(--tblr-font-monospace);--tblr-body-font-size:80%}[data-bs-theme-font=sans-serif]{--tblr-body-font-family:var(--tblr-font-sans-serif)}[data-bs-theme-font=serif]{--tblr-body-font-family:var(--tblr-font-serif)}[data-bs-theme-font=comic]{--tblr-body-font-family:var(--tblr-font-comic)}
|
|
8
|
+
/*# sourceMappingURL=tabler-themes.min.css.map */
|