quite-admin 0.4.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.
- quite_admin/__init__.py +45 -0
- quite_admin/adapters/__init__.py +5 -0
- quite_admin/adapters/_gino_relations.py +247 -0
- quite_admin/adapters/_peewee_relations.py +228 -0
- quite_admin/adapters/_queries.py +113 -0
- quite_admin/adapters/_sql_metadata.py +201 -0
- quite_admin/adapters/_sql_relations.py +244 -0
- quite_admin/adapters/_sqlalchemy_relations.py +305 -0
- quite_admin/adapters/_tortoise_relations.py +219 -0
- quite_admin/adapters/base.py +28 -0
- quite_admin/adapters/delegating.py +50 -0
- quite_admin/adapters/gino.py +221 -0
- quite_admin/adapters/peewee.py +200 -0
- quite_admin/adapters/sql.py +258 -0
- quite_admin/adapters/sqlalchemy.py +240 -0
- quite_admin/adapters/tortoise.py +155 -0
- quite_admin/admin.py +1769 -0
- quite_admin/auth.py +95 -0
- quite_admin/concurrency.py +135 -0
- quite_admin/config.py +226 -0
- quite_admin/diagram.py +164 -0
- quite_admin/errors.py +21 -0
- quite_admin/exports.py +126 -0
- quite_admin/filters.py +363 -0
- quite_admin/i18n.py +390 -0
- quite_admin/logging.py +69 -0
- quite_admin/operations.py +44 -0
- quite_admin/py.typed +0 -0
- quite_admin/relations.py +60 -0
- quite_admin/schema.py +358 -0
- quite_admin/static/admin.css +551 -0
- quite_admin/static/filters.js +247 -0
- quite_admin/static/forms.js +249 -0
- quite_admin/static/inline.js +107 -0
- quite_admin/static/listing.js +37 -0
- quite_admin/static/navigation.js +12 -0
- quite_admin/static/relations.js +160 -0
- quite_admin/static/schema.js +364 -0
- quite_admin/templates/quite_admin/_icons.html +3 -0
- quite_admin/templates/quite_admin/_schema_content.html +21 -0
- quite_admin/templates/quite_admin/base.html +51 -0
- quite_admin/templates/quite_admin/dashboard.html +13 -0
- quite_admin/templates/quite_admin/delete.html +8 -0
- quite_admin/templates/quite_admin/error.html +2 -0
- quite_admin/templates/quite_admin/filters.html +27 -0
- quite_admin/templates/quite_admin/form.html +72 -0
- quite_admin/templates/quite_admin/list.html +62 -0
- quite_admin/templates/quite_admin/login.html +15 -0
- quite_admin/templates/quite_admin/schema.html +2 -0
- quite_admin/theme.py +99 -0
- quite_admin-0.4.1.dist-info/METADATA +172 -0
- quite_admin-0.4.1.dist-info/RECORD +54 -0
- quite_admin-0.4.1.dist-info/WHEEL +4 -0
- quite_admin-0.4.1.dist-info/licenses/LICENSE +21 -0
quite_admin/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from .adapters.base import ADAPTER_API_VERSION
|
|
2
|
+
from .adapters.delegating import DelegatingAdapter
|
|
3
|
+
from .admin import TEMPLATE_API_VERSION, Admin, Page
|
|
4
|
+
from .auth import AuthBackend, Identity, PasswordAuth, hash_password, verify_password
|
|
5
|
+
from .concurrency import CheckedAdapter, WriteGuard
|
|
6
|
+
from .config import FieldOptions, ModelOptions, RecordAction
|
|
7
|
+
from .logging import LoggerProtocol
|
|
8
|
+
from .operations import Operation, OperationHooks, current_operation
|
|
9
|
+
from .relations import Junction, Relation, RelationAdapter
|
|
10
|
+
from .schema import Field, Filter, ModelSchema, Query, RecordPage, RelatedFilter
|
|
11
|
+
from .theme import Theme
|
|
12
|
+
|
|
13
|
+
__version__ = "0.4.1"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ADAPTER_API_VERSION",
|
|
17
|
+
"TEMPLATE_API_VERSION",
|
|
18
|
+
"Admin",
|
|
19
|
+
"CheckedAdapter",
|
|
20
|
+
"WriteGuard",
|
|
21
|
+
"AuthBackend",
|
|
22
|
+
"DelegatingAdapter",
|
|
23
|
+
"Field",
|
|
24
|
+
"FieldOptions",
|
|
25
|
+
"Filter",
|
|
26
|
+
"Identity",
|
|
27
|
+
"LoggerProtocol",
|
|
28
|
+
"ModelOptions",
|
|
29
|
+
"ModelSchema",
|
|
30
|
+
"Operation",
|
|
31
|
+
"OperationHooks",
|
|
32
|
+
"Page",
|
|
33
|
+
"PasswordAuth",
|
|
34
|
+
"Query",
|
|
35
|
+
"RecordPage",
|
|
36
|
+
"RecordAction",
|
|
37
|
+
"RelatedFilter",
|
|
38
|
+
"Junction",
|
|
39
|
+
"Relation",
|
|
40
|
+
"RelationAdapter",
|
|
41
|
+
"Theme",
|
|
42
|
+
"hash_password",
|
|
43
|
+
"verify_password",
|
|
44
|
+
"current_operation",
|
|
45
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import and_, exists, false, select
|
|
6
|
+
|
|
7
|
+
from quite_admin.concurrency import check_record, check_relation, guarded
|
|
8
|
+
from quite_admin.errors import Conflict, NotFound
|
|
9
|
+
from quite_admin.schema import RelatedFilter
|
|
10
|
+
|
|
11
|
+
from ._sql_metadata import SQLRelationMetadata, related_predicates
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GinoRelations:
|
|
15
|
+
def _links(self, model):
|
|
16
|
+
models = {self._table(item): item for item in self.discover()}
|
|
17
|
+
links = SQLRelationMetadata()._links(self._table(model))
|
|
18
|
+
result = {}
|
|
19
|
+
for name, link in links.items():
|
|
20
|
+
target = models.get(link.relation.target)
|
|
21
|
+
if target is None:
|
|
22
|
+
continue
|
|
23
|
+
local = tuple(self._attribute(model, column) for column in link.local)
|
|
24
|
+
remote = tuple(self._attribute(target, column) for column in link.remote)
|
|
25
|
+
relation = replace(
|
|
26
|
+
link.relation,
|
|
27
|
+
target=target,
|
|
28
|
+
source_fields=local if link.relation.source_fields else (),
|
|
29
|
+
target_fields=remote if link.relation.target_fields else (),
|
|
30
|
+
source_key_fields=local,
|
|
31
|
+
target_key_fields=remote,
|
|
32
|
+
)
|
|
33
|
+
if relation.junction is not None:
|
|
34
|
+
junction_model = models.get(link.through)
|
|
35
|
+
if junction_model is not None:
|
|
36
|
+
junction = replace(
|
|
37
|
+
relation.junction,
|
|
38
|
+
model=junction_model,
|
|
39
|
+
fields=self.describe(junction_model).fields,
|
|
40
|
+
source_fields=tuple(
|
|
41
|
+
self._attribute(junction_model, name)
|
|
42
|
+
for name in relation.junction.source_fields
|
|
43
|
+
),
|
|
44
|
+
target_fields=tuple(
|
|
45
|
+
self._attribute(junction_model, name)
|
|
46
|
+
for name in relation.junction.target_fields
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
relation = replace(relation, junction=junction)
|
|
50
|
+
result[name] = replace(link, relation=relation, local=local, remote=remote)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
def _filter_conditions(self, model, query):
|
|
54
|
+
if not query.related:
|
|
55
|
+
return ()
|
|
56
|
+
links = self._links(model)
|
|
57
|
+
physical = replace(
|
|
58
|
+
query,
|
|
59
|
+
related=tuple(
|
|
60
|
+
RelatedFilter(
|
|
61
|
+
item.relation,
|
|
62
|
+
{
|
|
63
|
+
getattr(links[item.relation].relation.target, name).name: value
|
|
64
|
+
for name, value in item.key.items()
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
for item in query.related
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
return related_predicates(
|
|
71
|
+
self._table(model), physical, SQLRelationMetadata()._links(self._table(model))
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def relations(self, model):
|
|
75
|
+
return tuple(link.relation for link in self._links(model).values())
|
|
76
|
+
|
|
77
|
+
def _condition(self, link, parent):
|
|
78
|
+
target = link.relation.target
|
|
79
|
+
if not link.reverse and link.through is None:
|
|
80
|
+
if any(parent[name] is None for name in link.local):
|
|
81
|
+
return false()
|
|
82
|
+
if link.through is not None:
|
|
83
|
+
# Correlated EXISTS preserves cardinality, including composite/self junctions.
|
|
84
|
+
return self._composite_through(link, parent, link.through.alias())
|
|
85
|
+
return and_(
|
|
86
|
+
*(
|
|
87
|
+
getattr(target, remote) == parent[local]
|
|
88
|
+
for local, remote in zip(link.local, link.remote, strict=True)
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _composite_through(self, link, parent, through):
|
|
93
|
+
return exists(
|
|
94
|
+
select([1])
|
|
95
|
+
.select_from(through)
|
|
96
|
+
.where(
|
|
97
|
+
and_(
|
|
98
|
+
*(
|
|
99
|
+
through.c[column] == parent[local]
|
|
100
|
+
for column, local in zip(link.through_local, link.local, strict=True)
|
|
101
|
+
),
|
|
102
|
+
*(
|
|
103
|
+
through.c[column] == getattr(link.relation.target, remote)
|
|
104
|
+
for column, remote in zip(link.through_remote, link.remote, strict=True)
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
.correlate(self._table(link.relation.target))
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def related(self, model, name, key, query):
|
|
112
|
+
if query.related:
|
|
113
|
+
raise ValueError("Relationship filters are supported only in list().")
|
|
114
|
+
links = self._links(model)
|
|
115
|
+
if name not in links:
|
|
116
|
+
raise Conflict("Unknown relationship.")
|
|
117
|
+
parent = await self.get(model, key)
|
|
118
|
+
if parent is None:
|
|
119
|
+
raise NotFound("Record not found.")
|
|
120
|
+
link = links[name]
|
|
121
|
+
return await self._page(
|
|
122
|
+
link.relation.target, query, condition=self._condition(link, parent)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def save_relations(self, model, values, selections, *, key=None):
|
|
126
|
+
schema = self.describe(model)
|
|
127
|
+
schema.validate_values(values, creating=key is None)
|
|
128
|
+
if key is not None:
|
|
129
|
+
schema.validate_key(key)
|
|
130
|
+
links = self._links(model)
|
|
131
|
+
if set(selections) - links.keys():
|
|
132
|
+
raise Conflict("Unknown relationship.")
|
|
133
|
+
for name, keys in selections.items():
|
|
134
|
+
link = links[name]
|
|
135
|
+
if link.relation.read_only:
|
|
136
|
+
raise Conflict("This relationship is read only.")
|
|
137
|
+
if link.relation.kind in {"one_to_one", "many_to_one"} and len(keys) > 1:
|
|
138
|
+
raise Conflict("Choose a single related record.")
|
|
139
|
+
for target_key in keys:
|
|
140
|
+
self.describe(link.relation.target).validate_key(target_key)
|
|
141
|
+
|
|
142
|
+
async with self._transaction() as connection:
|
|
143
|
+
if key is not None and guarded():
|
|
144
|
+
original = await connection.first(
|
|
145
|
+
model.query.where(self._where(model, key)).with_for_update()
|
|
146
|
+
)
|
|
147
|
+
if original is None:
|
|
148
|
+
raise NotFound("Record not found.")
|
|
149
|
+
check_record(model, self._record(model, original))
|
|
150
|
+
for relation_name in selections:
|
|
151
|
+
link = links[relation_name]
|
|
152
|
+
target = link.relation.target
|
|
153
|
+
previous = await connection.all(
|
|
154
|
+
target.query.where(
|
|
155
|
+
self._condition(link, self._record(model, original))
|
|
156
|
+
).with_for_update()
|
|
157
|
+
)
|
|
158
|
+
check_relation(
|
|
159
|
+
model,
|
|
160
|
+
relation_name,
|
|
161
|
+
self.describe(target),
|
|
162
|
+
[self._record(target, row) for row in previous],
|
|
163
|
+
)
|
|
164
|
+
changes = dict(values)
|
|
165
|
+
targets = {}
|
|
166
|
+
for name, keys in selections.items():
|
|
167
|
+
link = links[name]
|
|
168
|
+
target = link.relation.target
|
|
169
|
+
# Deduplicate selections without relying on driver-specific keys.
|
|
170
|
+
unique = {tuple(sorted(target_key.items())): target_key for target_key in keys}
|
|
171
|
+
rows = [
|
|
172
|
+
await connection.first(
|
|
173
|
+
target.query.where(self._where(target, target_key)).with_for_update()
|
|
174
|
+
)
|
|
175
|
+
for target_key in unique.values()
|
|
176
|
+
]
|
|
177
|
+
if any(row is None for row in rows):
|
|
178
|
+
raise NotFound("Related record not found.")
|
|
179
|
+
targets[name] = [self._record(target, row) for row in rows]
|
|
180
|
+
if not link.reverse and link.through is None:
|
|
181
|
+
if not rows and not link.relation.can_detach:
|
|
182
|
+
raise Conflict("A related record is required.")
|
|
183
|
+
changes.update(
|
|
184
|
+
{
|
|
185
|
+
local: getattr(rows[0], remote) if rows else None
|
|
186
|
+
for local, remote in zip(link.local, link.remote, strict=True)
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
parent = await self._persist(connection, model, changes, key=key)
|
|
190
|
+
for name, rows in targets.items():
|
|
191
|
+
link = links[name]
|
|
192
|
+
if not link.reverse and link.through is None:
|
|
193
|
+
continue
|
|
194
|
+
target = link.relation.target
|
|
195
|
+
keys = tuple(field.name for field in self.describe(target).primary_keys)
|
|
196
|
+
previous = await connection.all(
|
|
197
|
+
target.query.where(self._condition(link, parent)).with_for_update()
|
|
198
|
+
)
|
|
199
|
+
selected = {tuple(row[k] for k in keys) for row in rows}
|
|
200
|
+
removed = [
|
|
201
|
+
row for row in previous if tuple(getattr(row, k) for k in keys) not in selected
|
|
202
|
+
]
|
|
203
|
+
if removed and not link.relation.can_detach:
|
|
204
|
+
raise Conflict("This relationship cannot be detached.")
|
|
205
|
+
if link.through is not None:
|
|
206
|
+
await connection.status(
|
|
207
|
+
link.through.delete().where(
|
|
208
|
+
and_(
|
|
209
|
+
*(
|
|
210
|
+
link.through.c[column] == parent[local]
|
|
211
|
+
for column, local in zip(
|
|
212
|
+
link.through_local, link.local, strict=True
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
for row in rows:
|
|
219
|
+
data = {
|
|
220
|
+
column: parent[local]
|
|
221
|
+
for column, local in zip(link.through_local, link.local, strict=True)
|
|
222
|
+
}
|
|
223
|
+
data.update(
|
|
224
|
+
{
|
|
225
|
+
column: row[remote]
|
|
226
|
+
for column, remote in zip(
|
|
227
|
+
link.through_remote, link.remote, strict=True
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
await connection.status(link.through.insert().values(**data))
|
|
232
|
+
else:
|
|
233
|
+
# Release a unique 1:1 FK before assigning its replacement.
|
|
234
|
+
for row in removed:
|
|
235
|
+
await row.update(**dict.fromkeys(link.remote)).apply(bind=connection)
|
|
236
|
+
for row in rows:
|
|
237
|
+
await self._persist(
|
|
238
|
+
connection,
|
|
239
|
+
target,
|
|
240
|
+
{
|
|
241
|
+
remote: parent[local]
|
|
242
|
+
for local, remote in zip(link.local, link.remote, strict=True)
|
|
243
|
+
},
|
|
244
|
+
key={field: row[field] for field in keys},
|
|
245
|
+
)
|
|
246
|
+
identity = {field.name: parent[field.name] for field in schema.primary_keys}
|
|
247
|
+
return await self._persist(connection, model, {}, key=identity)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import peewee as pw
|
|
4
|
+
|
|
5
|
+
from quite_admin.concurrency import check_record, check_relation, guarded
|
|
6
|
+
from quite_admin.errors import Conflict, NotFound
|
|
7
|
+
from quite_admin.relations import Junction, Relation
|
|
8
|
+
from quite_admin.schema import RecordPage
|
|
9
|
+
|
|
10
|
+
from ._queries import ordering as order_expressions
|
|
11
|
+
from ._queries import predicates, search_predicate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PeeweeRelations:
|
|
15
|
+
def _relation_fields(self, model):
|
|
16
|
+
result = {field.name: ("forward", field) for field in model._meta.refs}
|
|
17
|
+
result.update(
|
|
18
|
+
{
|
|
19
|
+
field.backref: ("reverse", field)
|
|
20
|
+
for field in model._meta.backrefs
|
|
21
|
+
if field.backref != "!"
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
result.update({name: ("many", field) for name, field in model._meta.manytomany.items()})
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
def relations(self, model):
|
|
28
|
+
result = []
|
|
29
|
+
for name, (direction, field) in self._relation_fields(model).items():
|
|
30
|
+
if direction == "many":
|
|
31
|
+
source_fk = field.through_model._meta.model_refs[model][0]
|
|
32
|
+
target_fk = field.through_model._meta.model_refs[field.rel_model][0]
|
|
33
|
+
result.append(
|
|
34
|
+
Relation(
|
|
35
|
+
name,
|
|
36
|
+
field.rel_model,
|
|
37
|
+
"many_to_many",
|
|
38
|
+
source_key_fields=(source_fk.rel_field.name,),
|
|
39
|
+
target_key_fields=(target_fk.rel_field.name,),
|
|
40
|
+
through=field.through_model._meta.table_name,
|
|
41
|
+
junction=Junction(
|
|
42
|
+
field.through_model._meta.table_name,
|
|
43
|
+
self.describe(field.through_model).fields,
|
|
44
|
+
(source_fk.name,),
|
|
45
|
+
(target_fk.name,),
|
|
46
|
+
field.through_model,
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
reverse = direction == "reverse"
|
|
52
|
+
target = field.model if reverse else field.rel_model
|
|
53
|
+
result.append(
|
|
54
|
+
Relation(
|
|
55
|
+
name,
|
|
56
|
+
target,
|
|
57
|
+
"one_to_one" if field.unique else "one_to_many" if reverse else "many_to_one",
|
|
58
|
+
source_fields=() if reverse else (field.name,),
|
|
59
|
+
source_key_fields=(field.rel_field.name,) if reverse else (field.name,),
|
|
60
|
+
target_key_fields=(field.name,) if reverse else (field.rel_field.name,),
|
|
61
|
+
target_fields=(field.name,) if reverse else (),
|
|
62
|
+
can_detach=field.null,
|
|
63
|
+
mutates_target=reverse,
|
|
64
|
+
read_only=field.primary_key,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return tuple(result)
|
|
68
|
+
|
|
69
|
+
def _filter_conditions(self, model, query):
|
|
70
|
+
fields = self._relation_fields(model) if query.related else {}
|
|
71
|
+
for item in query.related:
|
|
72
|
+
direction, field = fields[item.relation]
|
|
73
|
+
target_model = field.model if direction == "reverse" else field.rel_model
|
|
74
|
+
target = target_model.alias()
|
|
75
|
+
statement = target.select(pw.SQL("1"))
|
|
76
|
+
conditions = [getattr(target, name) == value for name, value in item.key.items()]
|
|
77
|
+
if direction == "many":
|
|
78
|
+
source_fk = field.through_model._meta.model_refs[model][0]
|
|
79
|
+
target_fk = field.through_model._meta.model_refs[target_model][0]
|
|
80
|
+
through = field.through_model.alias()
|
|
81
|
+
statement = statement.join(
|
|
82
|
+
through,
|
|
83
|
+
on=(
|
|
84
|
+
getattr(through, target_fk.name)
|
|
85
|
+
== getattr(target, target_fk.rel_field.name)
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
conditions.append(
|
|
89
|
+
getattr(through, source_fk.name) == getattr(model, source_fk.rel_field.name)
|
|
90
|
+
)
|
|
91
|
+
elif direction == "reverse":
|
|
92
|
+
conditions.append(
|
|
93
|
+
getattr(target, field.name) == getattr(model, field.rel_field.name)
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
conditions.append(
|
|
97
|
+
getattr(target, field.rel_field.name) == getattr(model, field.name)
|
|
98
|
+
)
|
|
99
|
+
yield pw.fn.EXISTS(statement.where(*conditions))
|
|
100
|
+
|
|
101
|
+
def _related_query(self, parent, name):
|
|
102
|
+
direction, field = self._relation_fields(type(parent))[name]
|
|
103
|
+
if direction == "many":
|
|
104
|
+
return getattr(parent, name)
|
|
105
|
+
if direction == "reverse":
|
|
106
|
+
return field.model.select().where(field == parent.__data__[field.rel_field.name])
|
|
107
|
+
value = parent.__data__.get(field.name)
|
|
108
|
+
return field.rel_model.select().where(
|
|
109
|
+
field.rel_field == value if value is not None else False
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def related(self, model, name, key, query):
|
|
113
|
+
relation = next(item for item in self.relations(model) if item.name == name)
|
|
114
|
+
self.describe(model).validate_key(key)
|
|
115
|
+
query.validate(self.describe(relation.target))
|
|
116
|
+
|
|
117
|
+
def operation():
|
|
118
|
+
parent = model.get_or_none(self._where(model, key))
|
|
119
|
+
if parent is None:
|
|
120
|
+
raise NotFound("Record not found.")
|
|
121
|
+
statement = self._related_query(parent, name)
|
|
122
|
+
if query.filters:
|
|
123
|
+
statement = statement.where(
|
|
124
|
+
*predicates(query, lambda field: getattr(relation.target, field), peewee=True)
|
|
125
|
+
)
|
|
126
|
+
if query.search and query.search_fields:
|
|
127
|
+
statement = statement.where(
|
|
128
|
+
search_predicate(
|
|
129
|
+
query, lambda field: getattr(relation.target, field), peewee=True
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
total = statement.count()
|
|
133
|
+
statement = statement.order_by(
|
|
134
|
+
*order_expressions(
|
|
135
|
+
query,
|
|
136
|
+
self.describe(relation.target),
|
|
137
|
+
lambda field: getattr(relation.target, field),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return RecordPage(
|
|
141
|
+
[
|
|
142
|
+
query.project(self._record(relation.target, row))
|
|
143
|
+
for row in statement.offset(query.offset).limit(query.limit)
|
|
144
|
+
],
|
|
145
|
+
total,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return await self._run(model, operation)
|
|
149
|
+
|
|
150
|
+
async def save_relations(self, model, values, selections, *, key=None):
|
|
151
|
+
self.describe(model).validate_values(values, creating=key is None)
|
|
152
|
+
if key is not None:
|
|
153
|
+
self.describe(model).validate_key(key)
|
|
154
|
+
available_targets = {item.name: item.target for item in self.relations(model)}
|
|
155
|
+
for relation_name, selected_keys in selections.items():
|
|
156
|
+
if relation_name in available_targets:
|
|
157
|
+
target_schema = self.describe(available_targets[relation_name])
|
|
158
|
+
for selected_key in selected_keys:
|
|
159
|
+
target_schema.validate_key(selected_key)
|
|
160
|
+
available = {item.name: item for item in self.relations(model)}
|
|
161
|
+
fields = self._relation_fields(model)
|
|
162
|
+
if set(selections) - available.keys():
|
|
163
|
+
raise Conflict("Unknown relationship.")
|
|
164
|
+
|
|
165
|
+
def operation():
|
|
166
|
+
parent = self._locked_get(model, key) if key is not None else model()
|
|
167
|
+
if parent is None:
|
|
168
|
+
raise NotFound("Record not found.")
|
|
169
|
+
if key is not None and guarded():
|
|
170
|
+
check_record(model, self._record(model, parent))
|
|
171
|
+
for relation_name in selections:
|
|
172
|
+
target = available[relation_name].target
|
|
173
|
+
query = self._related_query(parent, relation_name)
|
|
174
|
+
if guarded() and not isinstance(model._meta.database, pw.SqliteDatabase):
|
|
175
|
+
query = query.for_update()
|
|
176
|
+
check_relation(
|
|
177
|
+
model,
|
|
178
|
+
relation_name,
|
|
179
|
+
self.describe(target),
|
|
180
|
+
[self._record(target, row) for row in query],
|
|
181
|
+
)
|
|
182
|
+
for name, value in values.items():
|
|
183
|
+
setattr(parent, name, value)
|
|
184
|
+
selected = {}
|
|
185
|
+
for name, keys in selections.items():
|
|
186
|
+
relation = available[name]
|
|
187
|
+
if relation.read_only:
|
|
188
|
+
raise Conflict("This relationship is read only.")
|
|
189
|
+
if relation.kind in {"one_to_one", "many_to_one"} and len(keys) > 1:
|
|
190
|
+
raise Conflict("Choose a single related record.")
|
|
191
|
+
targets = [
|
|
192
|
+
relation.target.get_or_none(self._where(relation.target, target_key))
|
|
193
|
+
for target_key in keys
|
|
194
|
+
]
|
|
195
|
+
if any(target is None for target in targets):
|
|
196
|
+
raise NotFound("Related record not found.")
|
|
197
|
+
selected[name] = targets
|
|
198
|
+
direction, field = fields[name]
|
|
199
|
+
if direction == "forward":
|
|
200
|
+
if not targets and not relation.can_detach:
|
|
201
|
+
raise Conflict("A related record is required.")
|
|
202
|
+
setattr(parent, name, targets[0] if targets else None)
|
|
203
|
+
parent.save(force_insert=key is None)
|
|
204
|
+
for name, targets in selected.items():
|
|
205
|
+
direction, field = fields[name]
|
|
206
|
+
if direction == "forward":
|
|
207
|
+
continue
|
|
208
|
+
previous = list(self._related_query(parent, name))
|
|
209
|
+
removed = [item for item in previous if item not in targets]
|
|
210
|
+
if removed and not available[name].can_detach:
|
|
211
|
+
raise Conflict("This relationship cannot be detached.")
|
|
212
|
+
if direction == "many":
|
|
213
|
+
manager = getattr(parent, name)
|
|
214
|
+
if removed:
|
|
215
|
+
manager.remove(removed)
|
|
216
|
+
added = [item for item in targets if item not in previous]
|
|
217
|
+
if added:
|
|
218
|
+
manager.add(added)
|
|
219
|
+
else:
|
|
220
|
+
for item in removed:
|
|
221
|
+
setattr(item, field.name, None)
|
|
222
|
+
item.save(only=[field])
|
|
223
|
+
for item in targets:
|
|
224
|
+
setattr(item, field.name, parent)
|
|
225
|
+
item.save(only=[field])
|
|
226
|
+
return self._record(model, parent)
|
|
227
|
+
|
|
228
|
+
return await self._run(model, operation, write=True)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Native query expressions, called only after Query.validate()."""
|
|
2
|
+
|
|
3
|
+
import operator
|
|
4
|
+
|
|
5
|
+
from quite_admin.schema import TEXT_FILTER_OPERATORS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def predicates(query, column, *, peewee=False, text_column=None):
|
|
9
|
+
for item in query.filters:
|
|
10
|
+
field = column(item.field)
|
|
11
|
+
if item.operator == "is_null":
|
|
12
|
+
if peewee:
|
|
13
|
+
yield field.is_null(item.value)
|
|
14
|
+
else:
|
|
15
|
+
yield field.is_(None) if item.value else field.isnot(None)
|
|
16
|
+
elif item.operator in TEXT_FILTER_OPERATORS:
|
|
17
|
+
field = (text_column or column)(item.field)
|
|
18
|
+
method = {
|
|
19
|
+
"icontains": "contains",
|
|
20
|
+
"not_icontains": "contains",
|
|
21
|
+
"istartswith": "startswith",
|
|
22
|
+
"iendswith": "endswith",
|
|
23
|
+
}[item.operator]
|
|
24
|
+
if peewee:
|
|
25
|
+
expression = getattr(field, method)(item.value)
|
|
26
|
+
else:
|
|
27
|
+
literal = item.value.replace("!", "!!").replace("%", "!%").replace("_", "!_")
|
|
28
|
+
pattern = ("%" if method != "startswith" else "") + literal
|
|
29
|
+
pattern += "%" if method != "endswith" else ""
|
|
30
|
+
expression = field.ilike(pattern, escape="!")
|
|
31
|
+
yield ~expression if item.operator == "not_icontains" else expression
|
|
32
|
+
elif item.operator == "in":
|
|
33
|
+
yield field.in_(item.value)
|
|
34
|
+
else:
|
|
35
|
+
operation = {
|
|
36
|
+
"eq": operator.eq,
|
|
37
|
+
"ne": operator.ne,
|
|
38
|
+
"lt": operator.lt,
|
|
39
|
+
"lte": operator.le,
|
|
40
|
+
"gt": operator.gt,
|
|
41
|
+
"gte": operator.ge,
|
|
42
|
+
}[item.operator]
|
|
43
|
+
yield operation(field, item.value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def search_predicate(query, column, *, peewee=False, text_column=None):
|
|
47
|
+
expressions = []
|
|
48
|
+
if query.search:
|
|
49
|
+
for name in query.search_fields:
|
|
50
|
+
field = (text_column or column)(name)
|
|
51
|
+
if peewee:
|
|
52
|
+
expressions.append(field.contains(query.search))
|
|
53
|
+
else:
|
|
54
|
+
literal = query.search.replace("!", "!!").replace("%", "!%").replace("_", "!_")
|
|
55
|
+
expressions.append(field.ilike("%" + literal + "%", escape="!"))
|
|
56
|
+
if query.search_key is not None:
|
|
57
|
+
key_parts = [column(name) == value for name, value in query.search_key.items()]
|
|
58
|
+
key_match = key_parts[0]
|
|
59
|
+
for part in key_parts[1:]:
|
|
60
|
+
key_match = key_match & part
|
|
61
|
+
expressions.append(key_match)
|
|
62
|
+
if not expressions:
|
|
63
|
+
return None
|
|
64
|
+
search = expressions[0]
|
|
65
|
+
for expression in expressions[1:]:
|
|
66
|
+
search = search | expression
|
|
67
|
+
return search
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ordering(query, schema, column):
|
|
71
|
+
order = query.order_by or schema.primary_keys[0].name
|
|
72
|
+
yield column(order).desc() if query.descending else column(order).asc()
|
|
73
|
+
for field in schema.primary_keys:
|
|
74
|
+
if field.name != order:
|
|
75
|
+
yield column(field.name).asc()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tortoise_filters(statement, query):
|
|
79
|
+
for item in query.filters:
|
|
80
|
+
if item.operator == "not_icontains":
|
|
81
|
+
statement = statement.exclude(**{item.field + "__icontains": item.value}).filter(
|
|
82
|
+
**{item.field + "__isnull": False}
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
suffix = {
|
|
86
|
+
"eq": "",
|
|
87
|
+
"ne": "__not",
|
|
88
|
+
"icontains": "__icontains",
|
|
89
|
+
"istartswith": "__istartswith",
|
|
90
|
+
"iendswith": "__iendswith",
|
|
91
|
+
"lt": "__lt",
|
|
92
|
+
"lte": "__lte",
|
|
93
|
+
"gt": "__gt",
|
|
94
|
+
"gte": "__gte",
|
|
95
|
+
"in": "__in",
|
|
96
|
+
"is_null": "__isnull",
|
|
97
|
+
}[item.operator]
|
|
98
|
+
statement = statement.filter(**{item.field + suffix: item.value})
|
|
99
|
+
if item.operator == "ne":
|
|
100
|
+
statement = statement.filter(**{item.field + "__isnull": False})
|
|
101
|
+
return statement
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate_query(adapter, model, query):
|
|
105
|
+
schemas = {}
|
|
106
|
+
if query.related:
|
|
107
|
+
names = {item.relation for item in query.related if hasattr(item, "relation")}
|
|
108
|
+
schemas = {
|
|
109
|
+
relation.name: adapter.describe(relation.target)
|
|
110
|
+
for relation in adapter.relations(model)
|
|
111
|
+
if relation.name in names
|
|
112
|
+
}
|
|
113
|
+
query.validate(adapter.describe(model), related_schemas=schemas)
|