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