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,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class SearchResult:
8
+ title: str
9
+ url: str
10
+ description: str = ""
11
+ icon: str = ""
12
+
13
+
14
+ @dataclass
15
+ class Notice:
16
+ title: str
17
+ message: str = ""
18
+ url: str = ""
19
+ icon: str = ""
20
+ read: bool = False
21
+
22
+
23
+ class NavbarFeature:
24
+ """Common base class for all features that mount on the navbar."""
25
+
26
+ pass
27
+
28
+
29
+ class GlobalSearch(NavbarFeature):
30
+ """Subclass and override ``search`` to provide cross-app search.
31
+
32
+ The framework registers a ``GET /_construct/search?q=...`` route (relative
33
+ to the construct URL prefix) and calls ``search(query)`` for each request.
34
+ Results are rendered using the theme's ``navbar/search_results.html``.
35
+ """
36
+
37
+ def search(self, query: str) -> list[SearchResult]:
38
+ return []
39
+
40
+
41
+ class Notifications(NavbarFeature):
42
+ """Subclass and override ``get_notices`` to drive the notification bell.
43
+
44
+ ``count`` returns the number of unread notices; themes use this for the
45
+ badge. ``get_notices`` is called once per render — cache inside the method
46
+ at ``g``-level if the source is expensive.
47
+ """
48
+
49
+ def get_notices(self) -> list[Notice]:
50
+ return []
51
+
52
+ @property
53
+ def count(self) -> int:
54
+ return sum(1 for n in self.get_notices() if not n.read)
@@ -0,0 +1,58 @@
1
+ """Post-save navigation: the ``next`` request parameter.
2
+
3
+ CRUD success redirects (top-level and RCRUD) resolve their target through
4
+ :func:`resolve_next`, so a link that leads into a form can say where saving,
5
+ deleting or cancelling should land:
6
+
7
+ - ``?next=<app-relative path>`` — return there afterwards. The list view's
8
+ create/edit/delete links pass the current list URL (including filters and
9
+ pagination) automatically, so form round-trips preserve list state.
10
+ - ``?next=:record`` — go to the saved record's edit view. Useful for
11
+ "create, then continue editing". Falls back to the default when no record
12
+ exists (delete, or a failed create).
13
+ - no ``next`` — the pre-existing behavior (back to the list view).
14
+
15
+ Only app-relative paths are honored: anything that does not start with a
16
+ single ``/`` (or contains control characters) is ignored, so a crafted link
17
+ cannot turn the redirect into an open redirect. Page-level policies plug in
18
+ via ``CrudPageBase.get_success_url`` instead of the per-link parameter.
19
+ """
20
+
21
+ from flask import request
22
+
23
+ #: Reserved ``next`` value meaning "the record that was just saved".
24
+ RECORD_TARGET = ":record"
25
+
26
+
27
+ def is_safe_next(url):
28
+ """True when ``url`` is a safe, app-relative redirect target."""
29
+ if not url or not url.startswith("/"):
30
+ return False
31
+ # "//host" and "/\host" are treated as protocol-relative by browsers.
32
+ if url.startswith("//") or url.startswith("/\\"):
33
+ return False
34
+ if any(ord(c) < 0x20 for c in url):
35
+ return False
36
+ return True
37
+
38
+
39
+ def requested_next():
40
+ """The raw ``next`` parameter from the query string or form body."""
41
+ return request.values.get("next")
42
+
43
+
44
+ def resolve_next(default, record_url=None):
45
+ """Resolve the redirect target for a completed form action.
46
+
47
+ ``default`` is used when no usable ``next`` was supplied. ``record_url``
48
+ is the saved record's edit view, substituted for the ``:record``
49
+ sentinel; pass ``None`` when there is no record to go to.
50
+ """
51
+ nxt = requested_next()
52
+ if not nxt:
53
+ return default
54
+ if nxt == RECORD_TARGET:
55
+ return record_url or default
56
+ if is_safe_next(nxt):
57
+ return nxt
58
+ return default
@@ -0,0 +1,338 @@
1
+ """
2
+ Org-mode HTML rendering utility on top of orgparse.
3
+ """
4
+
5
+ import re
6
+ import html
7
+ from orgparse import loads
8
+
9
+ RE_UNORDERED = re.compile(r"^(\s*)([-+*])\s+(.*)$")
10
+ RE_ORDERED = re.compile(r"^(\s*)(\d+)\.\s+(.*)$")
11
+ RE_CHECKBOX = re.compile(r"^\[([ xX-])\]\s*(.*)$")
12
+
13
+
14
+ def escape_html(text: str) -> str:
15
+ return html.escape(text)
16
+
17
+
18
+ def parse_inline_formatting(text: str) -> str:
19
+ """Parse Org-mode inline formatting into HTML."""
20
+ text = escape_html(text)
21
+
22
+ # 1. Links: [[url][label]] or [[url]]
23
+ text = re.sub(r"\[\[([^\]]+)\]\[([^\]]+)\]\]", r'<a href="\1">\2</a>', text)
24
+ text = re.sub(r"\[\[([^\]]+)\]\]", r'<a href="\1">\1</a>', text)
25
+
26
+ # 2. Code/Verbatim
27
+ text = re.sub(
28
+ r'(^|[\s(),.:;\'"!?\-\[\]{}<>])=([^\s=](?:[^=]*?[^\s=])?)=(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
29
+ r"\1<code>\2</code>",
30
+ text,
31
+ )
32
+ text = re.sub(
33
+ r'(^|[\s(),.:;\'"!?\-\[\]{}<>])~([^\s~](?:[^~]*?[^\s~])?)~(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
34
+ r"\1<code>\2</code>",
35
+ text,
36
+ )
37
+
38
+ # 3. Bold *text*
39
+ text = re.sub(
40
+ r'(^|[\s(),.:;\'"!?\-\[\]{}<>])\*([^\s\*](?:[^\*]*?[^\s\*])?)\*(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
41
+ r"\1<strong>\2</strong>",
42
+ text,
43
+ )
44
+
45
+ # 4. Italic /text/
46
+ text = re.sub(
47
+ r'(^|[\s(),.:;\'"!?\-\[\]{}<>])/([^\s/](?:[^/]*?[^\s/])?)/(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
48
+ r"\1<em>\2</em>",
49
+ text,
50
+ )
51
+
52
+ # 5. Underline _text_
53
+ text = re.sub(
54
+ r'(^|[\s(),.:;\'"!?\-\[\]{}<>])_([^\s_](?:[^_]*?[^\s_])?)_(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
55
+ r"\1<u>\2</u>",
56
+ text,
57
+ )
58
+
59
+ # 6. Strikethrough +text+
60
+ text = re.sub(
61
+ r'(^|[\s(),.:;\'"!?\-\[\]{}<>])\+([^\s\+](?:[^\+]*?[^\s\+])?)\+(?=$|[\s(),.:;\'"!?\-\[\]{}<>])',
62
+ r"\1<del>\2</del>",
63
+ text,
64
+ )
65
+
66
+ return text
67
+
68
+
69
+ def render_list(list_lines: list[str]) -> str:
70
+ """Parse list lines into HTML lists (ul/ol) supporting nested elements and checkboxes."""
71
+ items = []
72
+ current_item = None
73
+
74
+ for line in list_lines:
75
+ m_ul = RE_UNORDERED.match(line)
76
+ m_ol = RE_ORDERED.match(line)
77
+ if m_ul:
78
+ indent = len(m_ul.group(1))
79
+ content = m_ul.group(3)
80
+ current_item = {"indent": indent, "type": "ul", "lines": [content]}
81
+ items.append(current_item)
82
+ elif m_ol:
83
+ indent = len(m_ol.group(1))
84
+ content = m_ol.group(3)
85
+ current_item = {"indent": indent, "type": "ol", "lines": [content]}
86
+ items.append(current_item)
87
+ else:
88
+ if current_item is not None and line.strip():
89
+ current_item["lines"].append(line.strip())
90
+
91
+ for item in items:
92
+ full_content = " ".join(item["lines"])
93
+ m_chk = RE_CHECKBOX.match(full_content)
94
+ if m_chk:
95
+ chk_val = m_chk.group(1)
96
+ rest = m_chk.group(2)
97
+ # Use unicode checkboxes (☐/☑) so they render nicely without form input stripping
98
+ box = "☑" if chk_val.upper() == "X" else "☐"
99
+ item["html_content"] = (
100
+ f'<span class="org-checkbox">{box}</span> {parse_inline_formatting(rest)}'
101
+ )
102
+ else:
103
+ item["html_content"] = parse_inline_formatting(full_content)
104
+
105
+ if not items:
106
+ return ""
107
+
108
+ html_out = []
109
+ stack = []
110
+
111
+ for item in items:
112
+ indent = item["indent"]
113
+ ltype = item["type"]
114
+
115
+ while stack and stack[-1][0] > indent:
116
+ _, last_type = stack.pop()
117
+ html_out.append(f"</li></{last_type}>")
118
+
119
+ if not stack or stack[-1][0] < indent:
120
+ stack.append((indent, ltype))
121
+ html_out.append(f"<{ltype}><li>")
122
+ elif stack[-1][0] == indent:
123
+ if stack[-1][1] != ltype:
124
+ _, last_type = stack.pop()
125
+ html_out.append(f"</li></{last_type}>")
126
+ stack.append((indent, ltype))
127
+ html_out.append(f"<{ltype}><li>")
128
+ else:
129
+ html_out.append("</li><li>")
130
+
131
+ html_out.append(item["html_content"])
132
+
133
+ while stack:
134
+ _, last_type = stack.pop()
135
+ html_out.append(f"</li></{last_type}>")
136
+
137
+ return "".join(html_out)
138
+
139
+
140
+ def render_table(table_lines: list[str]) -> str:
141
+ """Parse table lines into an HTML table."""
142
+ parsed_rows = []
143
+ is_separator = lambda s: all(c in "|-+ \t" for c in s)
144
+ has_header = False
145
+
146
+ for line in table_lines:
147
+ if is_separator(line):
148
+ if len(parsed_rows) > 0:
149
+ has_header = True
150
+ continue
151
+ cells = [c.strip() for c in line.split("|")[1:-1]]
152
+ parsed_rows.append(cells)
153
+
154
+ if not parsed_rows:
155
+ return ""
156
+
157
+ html_out = ['<table class="org-table">']
158
+ for idx, row in enumerate(parsed_rows):
159
+ is_h = has_header and idx == 0
160
+ html_out.append("<tr>")
161
+ for cell in row:
162
+ tag = "th" if is_h else "td"
163
+ html_out.append(f"<{tag}>{parse_inline_formatting(cell)}</{tag}>")
164
+ html_out.append("</tr>")
165
+ html_out.append("</table>")
166
+ return "\n".join(html_out)
167
+
168
+
169
+ def parse_body_to_html(body_text: str) -> str:
170
+ """Process node body text block-by-block and convert to HTML."""
171
+ if not body_text.strip():
172
+ return ""
173
+
174
+ lines = body_text.splitlines()
175
+ blocks = []
176
+ i = 0
177
+ n = len(lines)
178
+
179
+ while i < n:
180
+ line = lines[i]
181
+ stripped = line.strip()
182
+
183
+ if not stripped:
184
+ i += 1
185
+ continue
186
+
187
+ # Code block
188
+ if stripped.upper().startswith("#+BEGIN_SRC"):
189
+ parts = stripped.split()
190
+ lang = parts[1].lower() if len(parts) > 1 else ""
191
+ src_lines = []
192
+ i += 1
193
+ while i < n and not lines[i].strip().upper().startswith("#+END_SRC"):
194
+ src_lines.append(lines[i])
195
+ i += 1
196
+ blocks.append(
197
+ f'<pre><code class="language-{lang}">'
198
+ + escape_html("\n".join(src_lines))
199
+ + "</code></pre>"
200
+ )
201
+ i += 1
202
+ continue
203
+
204
+ # Blockquote
205
+ if stripped.upper().startswith("#+BEGIN_QUOTE"):
206
+ quote_lines = []
207
+ i += 1
208
+ while i < n and not lines[i].strip().upper().startswith("#+END_QUOTE"):
209
+ quote_lines.append(lines[i])
210
+ i += 1
211
+ inner_html = parse_body_to_html("\n".join(quote_lines))
212
+ blocks.append(f"<blockquote>\n{inner_html}\n</blockquote>")
213
+ i += 1
214
+ continue
215
+
216
+ # Example
217
+ if stripped.upper().startswith("#+BEGIN_EXAMPLE"):
218
+ ex_lines = []
219
+ i += 1
220
+ while i < n and not lines[i].strip().upper().startswith("#+END_EXAMPLE"):
221
+ ex_lines.append(lines[i])
222
+ i += 1
223
+ blocks.append(
224
+ '<pre class="example">' + escape_html("\n".join(ex_lines)) + "</pre>"
225
+ )
226
+ i += 1
227
+ continue
228
+
229
+ # Lists
230
+ m_ul = RE_UNORDERED.match(line)
231
+ m_ol = RE_ORDERED.match(line)
232
+ if m_ul or m_ol:
233
+ list_lines = []
234
+ while i < n:
235
+ curr_line = lines[i]
236
+ curr_stripped = curr_line.strip()
237
+ if not curr_stripped:
238
+ next_idx = i + 1
239
+ while next_idx < n and not lines[next_idx].strip():
240
+ next_idx += 1
241
+ if next_idx < n and (
242
+ RE_UNORDERED.match(lines[next_idx])
243
+ or RE_ORDERED.match(lines[next_idx])
244
+ ):
245
+ list_lines.append(curr_line)
246
+ i += 1
247
+ continue
248
+ else:
249
+ break
250
+
251
+ is_item = RE_UNORDERED.match(curr_line) or RE_ORDERED.match(curr_line)
252
+ is_indented = curr_line.startswith(" ") or curr_line.startswith("\t")
253
+ if is_item or is_indented:
254
+ list_lines.append(curr_line)
255
+ i += 1
256
+ else:
257
+ break
258
+ blocks.append(render_list(list_lines))
259
+ continue
260
+
261
+ # Tables
262
+ if stripped.startswith("|"):
263
+ table_lines = []
264
+ while i < n and lines[i].strip().startswith("|"):
265
+ table_lines.append(lines[i].strip())
266
+ i += 1
267
+ blocks.append(render_table(table_lines))
268
+ continue
269
+
270
+ # Paragraphs
271
+ para_lines = []
272
+ while i < n:
273
+ curr_line = lines[i]
274
+ curr_stripped = curr_line.strip()
275
+ if not curr_stripped:
276
+ break
277
+ if (
278
+ curr_stripped.upper().startswith("#+BEGIN_")
279
+ or curr_stripped.startswith("|")
280
+ or RE_UNORDERED.match(curr_line)
281
+ or RE_ORDERED.match(curr_line)
282
+ ):
283
+ break
284
+ para_lines.append(curr_stripped)
285
+ i += 1
286
+
287
+ para_text = " ".join(para_lines)
288
+ blocks.append(f"<p>{parse_inline_formatting(para_text)}</p>")
289
+
290
+ return "\n".join(blocks)
291
+
292
+
293
+ def render_node(node) -> str:
294
+ """Recursively render a parsed OrgNode tree to HTML."""
295
+ html_out = []
296
+
297
+ if node.level > 0:
298
+ heading_parts = []
299
+ if node.todo:
300
+ heading_parts.append(
301
+ f'<span class="org-todo org-todo-{node.todo.lower()}">{node.todo}</span>'
302
+ )
303
+ if node.priority:
304
+ heading_parts.append(f'<span class="org-priority">[{node.priority}]</span>')
305
+
306
+ raw_heading = node.get_heading(format="raw")
307
+ heading_parts.append(parse_inline_formatting(raw_heading))
308
+
309
+ if node.shallow_tags:
310
+ tags_html = " ".join(
311
+ f'<span class="org-tag">{escape_html(t)}</span>'
312
+ for t in node.shallow_tags
313
+ )
314
+ heading_parts.append(f'<span class="org-tags">{tags_html}</span>')
315
+
316
+ heading_html = " ".join(heading_parts)
317
+ level_tag = f"h{min(node.level, 6)}"
318
+ html_out.append(f"<{level_tag}>{heading_html}</{level_tag}>")
319
+
320
+ # Use the raw body — node.body normalises away inline link markup
321
+ # (`[[url][label]]` collapses to just `label`), which would strip every
322
+ # link before our own inline parser ever sees it.
323
+ raw_body = node.get_body(format="raw")
324
+ if raw_body:
325
+ html_out.append(parse_body_to_html(raw_body))
326
+
327
+ for child in node.children:
328
+ html_out.append(render_node(child))
329
+
330
+ return "\n".join(html_out)
331
+
332
+
333
+ def org_to_html(org_text: str) -> str:
334
+ """Convert Org-mode raw text to HTML using orgparse and custom recursive generation."""
335
+ if not org_text:
336
+ return ""
337
+ root = loads(org_text)
338
+ return render_node(root)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+ from flask import current_app, request
3
+
4
+ # --- Pagination -------------------------------------------------------------
5
+ # Every list-returning query carries an (offset, count) limit. The page size
6
+ # a view declares is clamped to CONSTRUCT_MAX_PAGE_SIZE so neither a
7
+ # misconfigured view nor a crafted request can force an unbounded scan; the
8
+ # data layer applies the same cap as defense in depth (data.effective_limit).
9
+ DEFAULT_PAGE_SIZE = 10
10
+ MAX_PAGE_SIZE = 100
11
+ # Upper bound for the related-field typeahead dropdowns; results narrow as
12
+ # the user types, so they never need a full page-sized result set.
13
+ RELATED_SEARCH_LIMIT = 50
14
+
15
+
16
+ def get_pagination(page_size=None):
17
+ """Return ``(page, per_page, limit)`` for the current request.
18
+
19
+ ``page`` comes from the query string, floored at 1 (a negative offset is
20
+ a server error on some backends). ``per_page`` is the view's ``page_size``
21
+ (or ``CONSTRUCT_DEFAULT_PAGE_SIZE``) silently clamped to
22
+ ``CONSTRUCT_MAX_PAGE_SIZE``.
23
+ """
24
+ if page_size is None:
25
+ page_size = current_app.config.get(
26
+ "CONSTRUCT_DEFAULT_PAGE_SIZE", DEFAULT_PAGE_SIZE
27
+ )
28
+ try:
29
+ page_size = int(page_size)
30
+ except (TypeError, ValueError):
31
+ page_size = DEFAULT_PAGE_SIZE
32
+ max_page_size = current_app.config.get("CONSTRUCT_MAX_PAGE_SIZE", MAX_PAGE_SIZE)
33
+ try:
34
+ max_page_size = int(max_page_size)
35
+ except (TypeError, ValueError):
36
+ max_page_size = MAX_PAGE_SIZE
37
+ per_page = min(max(1, page_size), max_page_size)
38
+ page = max(1, request.args.get("page", 1, type=int))
39
+ return page, per_page, ((page - 1) * per_page, per_page)
File without changes
@@ -0,0 +1,25 @@
1
+ """
2
+ Central HTML sanitization policy.
3
+
4
+ Every place the library converts stored/user-supplied content to HTML and
5
+ marks it safe (the ``markdown`` template filter, ``MarkupComponent``,
6
+ calendar cell content) must pass the HTML through :func:`sanitize_html`
7
+ first. Developer-authored template HTML does not go through here.
8
+ """
9
+
10
+ import nh3
11
+
12
+ # nh3's defaults (the ammonia allowlist) strip <script>, event handlers,
13
+ # javascript: URLs and style attributes while keeping formatting tags,
14
+ # lists, tables, links and images — a good match for ProseMirror output.
15
+ ALLOWED_TAGS = nh3.ALLOWED_TAGS | {"img"}
16
+ ALLOWED_ATTRIBUTES = {
17
+ **nh3.ALLOWED_ATTRIBUTES,
18
+ "img": {"src", "alt", "title"},
19
+ "*": {"class"},
20
+ }
21
+
22
+
23
+ def sanitize_html(html: str) -> str:
24
+ """Return ``html`` reduced to the allowlisted tags and attributes."""
25
+ return nh3.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)