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,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import secrets
|
|
3
|
+
from flask import session, request, abort
|
|
4
|
+
from markupsafe import Markup
|
|
5
|
+
|
|
6
|
+
# --- CSRF protection -------------------------------------------------------
|
|
7
|
+
# Synchronizer-token pattern. The token lives in the session and is rendered
|
|
8
|
+
# into every state-changing form as a hidden field (the authoritative source,
|
|
9
|
+
# so submissions work with JavaScript disabled). JavaScript-driven requests
|
|
10
|
+
# (e.g. Unpoly) may instead send it in the ``X-CSRFToken`` header.
|
|
11
|
+
CSRF_SESSION_KEY = "_construct_csrf_token"
|
|
12
|
+
CSRF_FORM_FIELD = "_csrf_token"
|
|
13
|
+
CSRF_HEADER = "X-CSRFToken"
|
|
14
|
+
CSRF_UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_csrf_token() -> str:
|
|
18
|
+
"""Return the session CSRF token, creating one on first use."""
|
|
19
|
+
if CSRF_SESSION_KEY not in session:
|
|
20
|
+
session[CSRF_SESSION_KEY] = secrets.token_urlsafe(32)
|
|
21
|
+
return session[CSRF_SESSION_KEY]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def csrf_field() -> Markup:
|
|
25
|
+
"""Render the hidden CSRF input for inclusion in a POST form."""
|
|
26
|
+
token = generate_csrf_token()
|
|
27
|
+
return Markup(f'<input type="hidden" name="{CSRF_FORM_FIELD}" value="{token}">') # nosemgrep: explicit-unescape-with-markup # nosec B704
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_csrf_token() -> None:
|
|
31
|
+
"""Abort with 400 if the request lacks a valid CSRF token."""
|
|
32
|
+
expected = session.get(CSRF_SESSION_KEY)
|
|
33
|
+
submitted = request.form.get(CSRF_FORM_FIELD) or request.headers.get(CSRF_HEADER)
|
|
34
|
+
if (
|
|
35
|
+
not expected
|
|
36
|
+
or not submitted
|
|
37
|
+
or not secrets.compare_digest(str(submitted), str(expected))
|
|
38
|
+
):
|
|
39
|
+
abort(400, description="CSRF validation failed")
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from typing import Dict, List, Any
|
|
2
|
+
from flask import current_app, has_app_context
|
|
3
|
+
from .schema import SchemaField, Related, ManyRelated, List as ListField
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# Data base layer.
|
|
7
|
+
|
|
8
|
+
# Hard cap applied when no app context is available to provide
|
|
9
|
+
# CONSTRUCT_MAX_PAGE_SIZE (e.g. data classes used in scripts/tests).
|
|
10
|
+
MAX_PAGE_SIZE_FALLBACK = 100
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def effective_limit(limit=None):
|
|
14
|
+
"""Return a (offset, count) pair that is always bounded.
|
|
15
|
+
|
|
16
|
+
Data backends must run every list-returning query through this so a
|
|
17
|
+
missing or oversized ``limit`` can never produce an unbounded scan:
|
|
18
|
+
``None`` becomes ``(0, max)``, the count is clamped to the configured
|
|
19
|
+
``CONSTRUCT_MAX_PAGE_SIZE`` and the offset is floored at 0.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
if has_app_context():
|
|
23
|
+
max_count = current_app.config.get(
|
|
24
|
+
"CONSTRUCT_MAX_PAGE_SIZE", MAX_PAGE_SIZE_FALLBACK
|
|
25
|
+
)
|
|
26
|
+
else:
|
|
27
|
+
max_count = MAX_PAGE_SIZE_FALLBACK
|
|
28
|
+
try:
|
|
29
|
+
max_count = int(max_count)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
max_count = MAX_PAGE_SIZE_FALLBACK
|
|
32
|
+
|
|
33
|
+
if limit is None:
|
|
34
|
+
return (0, max_count)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
offset, count = limit
|
|
38
|
+
offset = int(offset)
|
|
39
|
+
count = int(count)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return (0, max_count)
|
|
42
|
+
|
|
43
|
+
return (max(0, offset), min(max(1, count), max_count))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# QBE format
|
|
47
|
+
# ---
|
|
48
|
+
# qbe is passed as a dictionary with "fieldname": "qbe_formatted_string"
|
|
49
|
+
# Special characters
|
|
50
|
+
# | or, any of these will match.
|
|
51
|
+
# .. Used in the beginning for >= and the end for <= and .. for inclusive between
|
|
52
|
+
# * is the multi-character wildcard.
|
|
53
|
+
# ? is the single-character wildcard.
|
|
54
|
+
# () groups expressions to be joined with AND
|
|
55
|
+
# \ is escape for the above and will cancel their special behaviour.
|
|
56
|
+
#
|
|
57
|
+
# Example:
|
|
58
|
+
# qbe = {
|
|
59
|
+
# "field1": "match_this",
|
|
60
|
+
# "field2": "!match_all_except_this",
|
|
61
|
+
# "field3": "..below_and_equal_to_this",
|
|
62
|
+
# "field4": "above_and_equal_to_this..",
|
|
63
|
+
# "field5": "between_this..and_this",
|
|
64
|
+
# "field6": "starts_with*",
|
|
65
|
+
# "field7": "*ends_with",
|
|
66
|
+
# "field8": "starts*with", # Starts and ends with, wildcard in middle.
|
|
67
|
+
# "field9": "*somewhere*",# Match with string somewhere in it.
|
|
68
|
+
# "field10": "this|that|other", # Matches all of these
|
|
69
|
+
# "field11": "..this|that..|other..end", # Matches all where less than 'this', greater than 'that' and between other and end.
|
|
70
|
+
# "field12": "starts_with*|*ends_with",
|
|
71
|
+
# "field13": "\*match_with_escape", # Escape special character.
|
|
72
|
+
# "field14": "(ab|ac)|abc", # ab AND ac OR abc
|
|
73
|
+
# "field15": "(ab|ac|abc)", # ab AND ac AND abc
|
|
74
|
+
# "field16": "\(ok!\)|\(mostly ok!\)" # (ok!) OR (mostly ok!)
|
|
75
|
+
# }
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DataBase:
|
|
79
|
+
_schema: Dict[str, SchemaField]
|
|
80
|
+
_primary_key: str
|
|
81
|
+
|
|
82
|
+
def to_dict(self, obj):
|
|
83
|
+
res = {}
|
|
84
|
+
for field, ftype in self._schema.items():
|
|
85
|
+
if isinstance(ftype, Related):
|
|
86
|
+
val = getattr(obj, field, None)
|
|
87
|
+
if val:
|
|
88
|
+
res[field] = ftype.related_class().to_dict(val)
|
|
89
|
+
else:
|
|
90
|
+
res[field] = None
|
|
91
|
+
elif isinstance(ftype, ManyRelated):
|
|
92
|
+
items = list(getattr(obj, ftype.related_field, None) or [])
|
|
93
|
+
res[field] = [ftype.related_class().to_dict(item) for item in items]
|
|
94
|
+
elif isinstance(ftype, ListField):
|
|
95
|
+
res[field] = [
|
|
96
|
+
ftype.list_class().to_dict(item)
|
|
97
|
+
for item in self.get_list(obj, field)
|
|
98
|
+
]
|
|
99
|
+
else:
|
|
100
|
+
res[field] = getattr(obj, field, None)
|
|
101
|
+
return res
|
|
102
|
+
|
|
103
|
+
def get_list(self, obj, field):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _unescape(self, text: str) -> str:
|
|
107
|
+
"""Removes one level of escaping from a string."""
|
|
108
|
+
return re.sub(r"\\(.)", r"\1", text)
|
|
109
|
+
|
|
110
|
+
def _build_like_pattern(self, part: str) -> str:
|
|
111
|
+
pattern = ""
|
|
112
|
+
i = 0
|
|
113
|
+
while i < len(part):
|
|
114
|
+
char = part[i]
|
|
115
|
+
if char == "\\":
|
|
116
|
+
if i + 1 < len(part):
|
|
117
|
+
next_char = part[i + 1]
|
|
118
|
+
pattern += next_char
|
|
119
|
+
i += 2
|
|
120
|
+
else: # dangling backslash
|
|
121
|
+
pattern += "\\"
|
|
122
|
+
i += 1
|
|
123
|
+
elif char == "*":
|
|
124
|
+
pattern += "%"
|
|
125
|
+
i += 1
|
|
126
|
+
elif char == "?":
|
|
127
|
+
pattern += "_"
|
|
128
|
+
i += 1
|
|
129
|
+
elif char == "%":
|
|
130
|
+
pattern += "\\%"
|
|
131
|
+
i += 1
|
|
132
|
+
elif char == "_":
|
|
133
|
+
pattern += "\\_"
|
|
134
|
+
i += 1
|
|
135
|
+
else:
|
|
136
|
+
pattern += char
|
|
137
|
+
i += 1
|
|
138
|
+
return pattern
|
|
139
|
+
|
|
140
|
+
def _parse_single_expr(self, part: str) -> dict:
|
|
141
|
+
"""Parses a string that does not contain any logical operators like `|` or `()`."""
|
|
142
|
+
if re.search(r"(?<!\\)[*?]", part):
|
|
143
|
+
return {"like": self._build_like_pattern(part)}
|
|
144
|
+
|
|
145
|
+
match = re.search(r"(?<!\\)\.\.", part)
|
|
146
|
+
|
|
147
|
+
if match:
|
|
148
|
+
start_index = match.start()
|
|
149
|
+
if start_index == 0:
|
|
150
|
+
return {"<=": self._unescape(part[2:])}
|
|
151
|
+
elif start_index == len(part) - 2:
|
|
152
|
+
return {">=": self._unescape(part[:-2])}
|
|
153
|
+
else:
|
|
154
|
+
start_val = part[:start_index]
|
|
155
|
+
end_val = part[start_index + 2 :]
|
|
156
|
+
return {
|
|
157
|
+
"and": [
|
|
158
|
+
{">=": self._unescape(start_val)},
|
|
159
|
+
{"<=": self._unescape(end_val)},
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if part.startswith("!") and not part.startswith(r"\\!"):
|
|
164
|
+
return {"!=": self._unescape(part[1:])}
|
|
165
|
+
|
|
166
|
+
return {"=": self._unescape(part)}
|
|
167
|
+
|
|
168
|
+
def process_qbe(self, qbe: Dict[str, str]) -> Dict[str, List[Dict[str, Any]]]:
|
|
169
|
+
processed_qbe = {}
|
|
170
|
+
for field, query in qbe.items():
|
|
171
|
+
if isinstance(query, list):
|
|
172
|
+
processed_qbe[field] = query
|
|
173
|
+
continue
|
|
174
|
+
or_parts = []
|
|
175
|
+
level = 0
|
|
176
|
+
last_split = 0
|
|
177
|
+
i = 0
|
|
178
|
+
is_bool = False
|
|
179
|
+
if type(query) is bool:
|
|
180
|
+
query = str(query)
|
|
181
|
+
is_bool = True
|
|
182
|
+
|
|
183
|
+
while i < len(query):
|
|
184
|
+
char = query[i]
|
|
185
|
+
if char == "\\":
|
|
186
|
+
i += 2
|
|
187
|
+
continue
|
|
188
|
+
if char == "(":
|
|
189
|
+
level += 1
|
|
190
|
+
elif char == ")":
|
|
191
|
+
level -= 1
|
|
192
|
+
elif char == "|" and level == 0:
|
|
193
|
+
or_parts.append(query[last_split:i])
|
|
194
|
+
last_split = i + 1
|
|
195
|
+
i += 1
|
|
196
|
+
or_parts.append(query[last_split:])
|
|
197
|
+
|
|
198
|
+
final_conditions = []
|
|
199
|
+
for part in or_parts:
|
|
200
|
+
part = part.strip()
|
|
201
|
+
if (
|
|
202
|
+
part.startswith("(")
|
|
203
|
+
and part.endswith(")")
|
|
204
|
+
and not part.startswith(r"\\(")
|
|
205
|
+
):
|
|
206
|
+
group_content = part[1:-1]
|
|
207
|
+
and_parts = re.split(r"(?<!\\)\|", group_content)
|
|
208
|
+
and_conditions = []
|
|
209
|
+
for and_part in and_parts:
|
|
210
|
+
condition = self._parse_single_expr(and_part.strip())
|
|
211
|
+
and_conditions.append(condition)
|
|
212
|
+
if len(and_conditions) > 1:
|
|
213
|
+
final_conditions.append({"and": and_conditions})
|
|
214
|
+
elif and_conditions:
|
|
215
|
+
final_conditions.append(and_conditions[0])
|
|
216
|
+
else:
|
|
217
|
+
condition = self._parse_single_expr(part)
|
|
218
|
+
final_conditions.append(condition)
|
|
219
|
+
if is_bool:
|
|
220
|
+
if final_conditions[0]["="] == "True":
|
|
221
|
+
final_conditions[0]["="] = True
|
|
222
|
+
else:
|
|
223
|
+
final_conditions[0]["="] = False
|
|
224
|
+
processed_qbe[field] = final_conditions
|
|
225
|
+
return processed_qbe
|
|
226
|
+
|
|
227
|
+
def read(self, id):
|
|
228
|
+
raise NotImplementedError("Read method not implemented.")
|
|
229
|
+
|
|
230
|
+
def create(self, data):
|
|
231
|
+
raise NotImplementedError("Create method not implemented.")
|
|
232
|
+
|
|
233
|
+
def update(self, id, data):
|
|
234
|
+
raise NotImplementedError("Update method not implemented.")
|
|
235
|
+
|
|
236
|
+
def delete(self, id):
|
|
237
|
+
raise NotImplementedError("Delete method not implemented.")
|
|
238
|
+
|
|
239
|
+
def filter(self, qbe=None, limit=None):
|
|
240
|
+
"""Return ``(rows, total)`` for the QBE dict.
|
|
241
|
+
|
|
242
|
+
``limit`` is an ``(offset, count)`` pair. Implementations must pass
|
|
243
|
+
it through :func:`effective_limit` and apply the result, so the
|
|
244
|
+
query is bounded even when the caller omits it.
|
|
245
|
+
"""
|
|
246
|
+
raise NotImplementedError("Filter method not implemented.")
|
|
247
|
+
|
|
248
|
+
def search(self, q=None, limit=None, fields=None):
|
|
249
|
+
"""Return ``(rows, total)`` for a free-text search.
|
|
250
|
+
|
|
251
|
+
Same ``limit`` contract as :meth:`filter`. ``fields``, when given,
|
|
252
|
+
restricts the match to those schema fields; the library only passes
|
|
253
|
+
it for ``ManyRelated`` fields declaring ``search_fields``, so
|
|
254
|
+
backends without support may ignore the feature until an app opts
|
|
255
|
+
a field in.
|
|
256
|
+
"""
|
|
257
|
+
raise NotImplementedError("Search method not implememted.")
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
from sqlalchemy.sql.expression import func
|
|
2
|
+
from .data import DataBase, effective_limit
|
|
3
|
+
from .schema import Related, ManyRelated, List
|
|
4
|
+
from .schema import PK
|
|
5
|
+
|
|
6
|
+
# from flask_sqlalchemy import SQLAlchemy
|
|
7
|
+
from sqlalchemy import and_, or_, select
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataSQLAlchemy(DataBase):
|
|
12
|
+
session = type[Session]
|
|
13
|
+
model = type[DeclarativeBase]
|
|
14
|
+
# def __init__(self, model: Type[DeclarativeBase]):
|
|
15
|
+
# self.model = model
|
|
16
|
+
|
|
17
|
+
def _get_column(self, field):
|
|
18
|
+
if field not in self._schema:
|
|
19
|
+
raise KeyError(
|
|
20
|
+
f"Field '{field}' is not declared in the schema of "
|
|
21
|
+
f"{type(self).__name__}; refusing to filter on it."
|
|
22
|
+
)
|
|
23
|
+
ftype = self._schema.get(field)
|
|
24
|
+
if isinstance(ftype, Related):
|
|
25
|
+
base = self.model
|
|
26
|
+
field_name = ftype.related_field.split(".")[-1]
|
|
27
|
+
for item in ftype.related_field.split(".")[:-1]:
|
|
28
|
+
base = getattr(base, item).property.mapper.class_
|
|
29
|
+
return getattr(base, field_name)
|
|
30
|
+
return getattr(self.model, field)
|
|
31
|
+
|
|
32
|
+
def get_list(self, obj, field):
|
|
33
|
+
ftype = self._schema.get(field)
|
|
34
|
+
if isinstance(ftype, List):
|
|
35
|
+
res = getattr(obj, field)
|
|
36
|
+
getter = getattr(self, f"get_{field}", None)
|
|
37
|
+
if getter:
|
|
38
|
+
return getter(obj)
|
|
39
|
+
return res
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def _build_clause(self, field, op, value):
|
|
43
|
+
column = self._get_column(field)
|
|
44
|
+
if isinstance(column, property):
|
|
45
|
+
return None
|
|
46
|
+
if op == "=":
|
|
47
|
+
return column == value
|
|
48
|
+
elif op == "!=":
|
|
49
|
+
return column != value
|
|
50
|
+
elif op == ">":
|
|
51
|
+
return column > value
|
|
52
|
+
elif op == ">=":
|
|
53
|
+
return column >= value
|
|
54
|
+
elif op == "<":
|
|
55
|
+
return column < value
|
|
56
|
+
elif op == "<=":
|
|
57
|
+
return column <= value
|
|
58
|
+
elif op == "like":
|
|
59
|
+
return column.like(value, escape="\\")
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def get_base_query(self):
|
|
63
|
+
base_query = select(self.model)
|
|
64
|
+
for fldname, ftype in self._schema.items():
|
|
65
|
+
if isinstance(ftype, Related):
|
|
66
|
+
base_query = base_query.outerjoin(ftype.related_class.model)
|
|
67
|
+
|
|
68
|
+
return base_query
|
|
69
|
+
|
|
70
|
+
def filter(self, qbe=None, limit=None):
|
|
71
|
+
limit = effective_limit(limit)
|
|
72
|
+
if qbe is None:
|
|
73
|
+
tot = self.session.scalar(
|
|
74
|
+
select(func.count()).select_from(self.get_base_query().subquery())
|
|
75
|
+
)
|
|
76
|
+
return (
|
|
77
|
+
self.session.scalars(
|
|
78
|
+
self.get_base_query().offset(limit[0]).limit(limit[1])
|
|
79
|
+
).all(),
|
|
80
|
+
tot,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
processed_qbe = self.process_qbe(qbe)
|
|
84
|
+
query = self.get_base_query()
|
|
85
|
+
clauses = []
|
|
86
|
+
|
|
87
|
+
for field, conditions in processed_qbe.items():
|
|
88
|
+
field_clauses = []
|
|
89
|
+
for condition in conditions:
|
|
90
|
+
if "and" in condition:
|
|
91
|
+
and_clauses = []
|
|
92
|
+
for sub_condition in condition["and"]:
|
|
93
|
+
op, value = list(sub_condition.items())[0]
|
|
94
|
+
and_clauses.append(self._build_clause(field, op, value))
|
|
95
|
+
field_clauses.append(and_(*and_clauses))
|
|
96
|
+
else:
|
|
97
|
+
op, value = list(condition.items())[0]
|
|
98
|
+
field_clauses.append(self._build_clause(field, op, value))
|
|
99
|
+
clauses.append(or_(*field_clauses))
|
|
100
|
+
|
|
101
|
+
if clauses:
|
|
102
|
+
query = query.filter(and_(*clauses))
|
|
103
|
+
tot = self.session.scalar(select(func.count()).select_from(query.subquery()))
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
self.session.scalars(query.offset(limit[0]).limit(limit[1])).all(),
|
|
107
|
+
tot,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def read(self, id):
|
|
111
|
+
return self.session.get(self.model, id)
|
|
112
|
+
|
|
113
|
+
def _resolve_many_related(self, ftype, pks, staged):
|
|
114
|
+
"""The full related collection for a ManyRelated field: existing
|
|
115
|
+
records by primary key plus staged creations (``quick`` values and
|
|
116
|
+
``new`` sub-form dicts). New records stay transient — they are
|
|
117
|
+
committed together with the parent."""
|
|
118
|
+
related_ds = ftype.related_class()
|
|
119
|
+
pks = pks if isinstance(pks, list) else []
|
|
120
|
+
staged = staged or {}
|
|
121
|
+
objs = []
|
|
122
|
+
seen = set()
|
|
123
|
+
for pk in pks:
|
|
124
|
+
if not pk:
|
|
125
|
+
continue
|
|
126
|
+
obj = related_ds.read(pk)
|
|
127
|
+
if obj is None:
|
|
128
|
+
continue
|
|
129
|
+
obj_pk = getattr(obj, related_ds._primary_key)
|
|
130
|
+
if obj_pk not in seen:
|
|
131
|
+
seen.add(obj_pk)
|
|
132
|
+
objs.append(obj)
|
|
133
|
+
for value in staged.get("quick") or []:
|
|
134
|
+
quick_field = ftype.resolve_quick_field()
|
|
135
|
+
# Pre-processed condition list: bypasses QBE parsing so the
|
|
136
|
+
# user-typed value needs no special-character escaping.
|
|
137
|
+
rows, _ = related_ds.filter({quick_field: [{"=": value}]}, limit=(0, 1))
|
|
138
|
+
if rows:
|
|
139
|
+
obj_pk = getattr(rows[0], related_ds._primary_key)
|
|
140
|
+
if obj_pk not in seen:
|
|
141
|
+
seen.add(obj_pk)
|
|
142
|
+
objs.append(rows[0])
|
|
143
|
+
else:
|
|
144
|
+
sub_ftype = related_ds._schema[quick_field]
|
|
145
|
+
objs.append(
|
|
146
|
+
related_ds.model(**{quick_field: sub_ftype.parse_value(value)})
|
|
147
|
+
)
|
|
148
|
+
for item in staged.get("new") or []:
|
|
149
|
+
sub_kwargs = {
|
|
150
|
+
sub_key: related_ds._schema[sub_key].parse_value(sub_value)
|
|
151
|
+
for sub_key, sub_value in item.items()
|
|
152
|
+
if sub_key in related_ds._schema
|
|
153
|
+
}
|
|
154
|
+
objs.append(related_ds.model(**sub_kwargs))
|
|
155
|
+
return objs
|
|
156
|
+
|
|
157
|
+
def create(self, data):
|
|
158
|
+
kwargs = {}
|
|
159
|
+
list_items_data = data.pop("list_items", None)
|
|
160
|
+
many_related_new = data.pop("many_related_new", None) or {}
|
|
161
|
+
model_columns = self.model.__table__.columns.keys()
|
|
162
|
+
many_related_data = {}
|
|
163
|
+
for key, ftype in self._schema.items():
|
|
164
|
+
if key in data and key != "id":
|
|
165
|
+
if isinstance(ftype, ManyRelated):
|
|
166
|
+
many_related_data[key] = data[key]
|
|
167
|
+
elif isinstance(ftype, Related):
|
|
168
|
+
if data[key]:
|
|
169
|
+
related_obj = ftype.related_class().read(data[key])
|
|
170
|
+
kwargs[ftype.related_field.split(".")[0]] = related_obj
|
|
171
|
+
elif key in model_columns and not isinstance(ftype, List):
|
|
172
|
+
kwargs[key] = ftype.parse_value(data[key])
|
|
173
|
+
|
|
174
|
+
rec = self.model(**kwargs)
|
|
175
|
+
|
|
176
|
+
for key, pks in many_related_data.items():
|
|
177
|
+
ftype = self._schema[key]
|
|
178
|
+
related_objs = self._resolve_many_related(
|
|
179
|
+
ftype, pks, many_related_new.get(key)
|
|
180
|
+
)
|
|
181
|
+
setattr(rec, ftype.related_field, related_objs)
|
|
182
|
+
|
|
183
|
+
if list_items_data:
|
|
184
|
+
for field, actions in list_items_data.items():
|
|
185
|
+
ftype = self._schema[field]
|
|
186
|
+
if "new" in actions:
|
|
187
|
+
list_class_ds = ftype.list_class()
|
|
188
|
+
for new_item_data in actions["new"]:
|
|
189
|
+
sub_kwargs = {}
|
|
190
|
+
for sub_key, sub_ftype in list_class_ds._schema.items():
|
|
191
|
+
if sub_key in new_item_data and sub_key != "id":
|
|
192
|
+
sub_kwargs[sub_key] = new_item_data[sub_key]
|
|
193
|
+
|
|
194
|
+
sub_rec = list_class_ds.model(**sub_kwargs)
|
|
195
|
+
getattr(rec, ftype.list_field).append(sub_rec)
|
|
196
|
+
|
|
197
|
+
self.session.add(rec)
|
|
198
|
+
self.session.commit()
|
|
199
|
+
return rec
|
|
200
|
+
|
|
201
|
+
def update(self, id, data):
|
|
202
|
+
rec = self.read(id)
|
|
203
|
+
if rec is None:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
list_items_data = data.pop("list_items", None)
|
|
207
|
+
many_related_new = data.pop("many_related_new", None) or {}
|
|
208
|
+
model_columns = self.model.__table__.columns.keys()
|
|
209
|
+
|
|
210
|
+
for key, value in data.items():
|
|
211
|
+
ftype = self._schema.get(key)
|
|
212
|
+
if ftype:
|
|
213
|
+
if isinstance(ftype, ManyRelated):
|
|
214
|
+
related_objs = self._resolve_many_related(
|
|
215
|
+
ftype, value, many_related_new.get(key)
|
|
216
|
+
)
|
|
217
|
+
setattr(rec, ftype.related_field, related_objs)
|
|
218
|
+
elif isinstance(ftype, Related):
|
|
219
|
+
if value:
|
|
220
|
+
related_obj = ftype.related_class().read(value)
|
|
221
|
+
setattr(rec, ftype.related_field.split(".")[0], related_obj)
|
|
222
|
+
else:
|
|
223
|
+
setattr(rec, ftype.related_field.split(".")[0], None)
|
|
224
|
+
elif key in model_columns and not isinstance(ftype, List):
|
|
225
|
+
setattr(rec, key, ftype.parse_value(value))
|
|
226
|
+
|
|
227
|
+
if list_items_data:
|
|
228
|
+
for field, actions in list_items_data.items():
|
|
229
|
+
ftype = self._schema[field]
|
|
230
|
+
if "new" in actions:
|
|
231
|
+
list_class_ds = ftype.list_class()
|
|
232
|
+
for new_item_data in actions["new"]:
|
|
233
|
+
sub_kwargs = {}
|
|
234
|
+
for sub_key, sub_ftype in list_class_ds._schema.items():
|
|
235
|
+
if sub_key in new_item_data and sub_key != "id":
|
|
236
|
+
sub_kwargs[sub_key] = new_item_data[sub_key]
|
|
237
|
+
|
|
238
|
+
sub_rec = list_class_ds.model(**sub_kwargs)
|
|
239
|
+
getattr(rec, ftype.list_field).append(sub_rec)
|
|
240
|
+
if "delete" in actions:
|
|
241
|
+
list_class_ds = ftype.list_class()
|
|
242
|
+
for item_id_to_delete in actions["delete"]:
|
|
243
|
+
item_to_delete = list_class_ds.read(item_id_to_delete)
|
|
244
|
+
if item_to_delete:
|
|
245
|
+
self.session.delete(item_to_delete)
|
|
246
|
+
|
|
247
|
+
if "update" in actions:
|
|
248
|
+
list_class_ds = ftype.list_class()
|
|
249
|
+
for item_to_update_data in actions["update"]:
|
|
250
|
+
item_id_to_update = item_to_update_data.pop("id")
|
|
251
|
+
item_to_update = list_class_ds.read(item_id_to_update)
|
|
252
|
+
if item_to_update:
|
|
253
|
+
for sub_key, sub_value in item_to_update_data.items():
|
|
254
|
+
setattr(item_to_update, sub_key, sub_value)
|
|
255
|
+
|
|
256
|
+
self.session.commit()
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
def delete(self, id):
|
|
260
|
+
rec = self.read(id)
|
|
261
|
+
if rec is None:
|
|
262
|
+
return False
|
|
263
|
+
self.session.delete(rec)
|
|
264
|
+
self.session.commit()
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
def search(self, q=None, limit=None, fields=None):
|
|
268
|
+
limit = effective_limit(limit)
|
|
269
|
+
if q is None:
|
|
270
|
+
tot = self.session.scalar(
|
|
271
|
+
select(func.count()).select_from(self.get_base_query().subquery())
|
|
272
|
+
)
|
|
273
|
+
return (
|
|
274
|
+
self.session.scalars(
|
|
275
|
+
self.get_base_query().offset(limit[0]).limit(limit[1])
|
|
276
|
+
).all(),
|
|
277
|
+
tot,
|
|
278
|
+
)
|
|
279
|
+
qry = self.get_base_query()
|
|
280
|
+
clauses = []
|
|
281
|
+
if fields is not None:
|
|
282
|
+
for field in fields:
|
|
283
|
+
ftype = self._schema.get(field)
|
|
284
|
+
if isinstance(ftype, (PK, List, ManyRelated)):
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"Field '{field}' of {type(self).__name__} cannot be"
|
|
287
|
+
f" free-text searched."
|
|
288
|
+
)
|
|
289
|
+
# Related is allowed: _get_column resolves its (dotted)
|
|
290
|
+
# display column and get_base_query already joins the table.
|
|
291
|
+
column = self._get_column(field)
|
|
292
|
+
if not isinstance(column, property):
|
|
293
|
+
clauses.append(column.like(f"%{q}%"))
|
|
294
|
+
else:
|
|
295
|
+
for field, ftype in self._schema.items():
|
|
296
|
+
if isinstance(ftype, (PK, List, Related, ManyRelated)):
|
|
297
|
+
continue
|
|
298
|
+
column = self._get_column(field)
|
|
299
|
+
if not isinstance(column, property):
|
|
300
|
+
clauses.append(column.like(f"%{q}%"))
|
|
301
|
+
if clauses:
|
|
302
|
+
qry = qry.filter(or_(*clauses))
|
|
303
|
+
tot = self.session.scalar(select(func.count()).select_from(qry.subquery()))
|
|
304
|
+
return (self.session.scalars(qry.offset(limit[0]).limit(limit[1])).all(), tot)
|