sqladmin 0.16.1__py3-none-any.whl → 0.18.0__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.
- sqladmin/__init__.py +1 -1
- sqladmin/_menu.py +11 -9
- sqladmin/_queries.py +10 -8
- sqladmin/ajax.py +5 -3
- sqladmin/application.py +54 -43
- sqladmin/authentication.py +4 -2
- sqladmin/fields.py +35 -33
- sqladmin/forms.py +56 -59
- sqladmin/helpers.py +13 -12
- sqladmin/models.py +98 -20
- sqladmin/pagination.py +5 -3
- sqladmin/templates/{_macros.html → sqladmin/_macros.html} +33 -0
- sqladmin/templates/{create.html → sqladmin/create.html} +3 -16
- sqladmin/templates/{details.html → sqladmin/details.html} +3 -3
- sqladmin/templates/{edit.html → sqladmin/edit.html} +5 -16
- sqladmin/templates/{error.html → sqladmin/error.html} +1 -1
- sqladmin/templates/sqladmin/index.html +3 -0
- sqladmin/templates/{layout.html → sqladmin/layout.html} +2 -2
- sqladmin/templates/{list.html → sqladmin/list.html} +6 -6
- sqladmin/templates/{login.html → sqladmin/login.html} +1 -1
- sqladmin/templating.py +9 -7
- sqladmin/widgets.py +20 -12
- {sqladmin-0.16.1.dist-info → sqladmin-0.18.0.dist-info}/METADATA +2 -2
- sqladmin-0.18.0.dist-info/RECORD +50 -0
- {sqladmin-0.16.1.dist-info → sqladmin-0.18.0.dist-info}/WHEEL +1 -1
- sqladmin/templates/index.html +0 -3
- sqladmin-0.16.1.dist-info/RECORD +0 -50
- /sqladmin/templates/{base.html → sqladmin/base.html} +0 -0
- /sqladmin/templates/{modals → sqladmin/modals}/delete.html +0 -0
- /sqladmin/templates/{modals → sqladmin/modals}/details_action_confirmation.html +0 -0
- /sqladmin/templates/{modals → sqladmin/modals}/list_action_confirmation.html +0 -0
- {sqladmin-0.16.1.dist-info → sqladmin-0.18.0.dist-info}/licenses/LICENSE.md +0 -0
sqladmin/forms.py
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
"""
|
|
2
2
|
The converters are from Flask-Admin project.
|
|
3
3
|
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
4
6
|
import enum
|
|
5
7
|
import inspect
|
|
6
8
|
import sys
|
|
7
9
|
from typing import (
|
|
8
10
|
Any,
|
|
9
11
|
Callable,
|
|
10
|
-
Dict,
|
|
11
|
-
List,
|
|
12
|
-
Optional,
|
|
13
12
|
Sequence,
|
|
14
|
-
Tuple,
|
|
15
|
-
Type,
|
|
16
13
|
TypeVar,
|
|
17
14
|
Union,
|
|
18
15
|
no_type_check,
|
|
@@ -85,7 +82,7 @@ class ConverterCallable(Protocol):
|
|
|
85
82
|
self,
|
|
86
83
|
model: type,
|
|
87
84
|
prop: MODEL_PROPERTY,
|
|
88
|
-
kwargs:
|
|
85
|
+
kwargs: dict[str, Any],
|
|
89
86
|
) -> UnboundField:
|
|
90
87
|
... # pragma: no cover
|
|
91
88
|
|
|
@@ -107,7 +104,7 @@ def converts(*args: str) -> Callable[[T_CC], T_CC]:
|
|
|
107
104
|
|
|
108
105
|
|
|
109
106
|
class ModelConverterBase:
|
|
110
|
-
_converters:
|
|
107
|
+
_converters: dict[str, ConverterCallable] = {}
|
|
111
108
|
|
|
112
109
|
def __init__(self) -> None:
|
|
113
110
|
super().__init__()
|
|
@@ -128,12 +125,12 @@ class ModelConverterBase:
|
|
|
128
125
|
self,
|
|
129
126
|
prop: MODEL_PROPERTY,
|
|
130
127
|
session_maker: sessionmaker,
|
|
131
|
-
field_args:
|
|
132
|
-
field_widget_args:
|
|
128
|
+
field_args: dict[str, Any],
|
|
129
|
+
field_widget_args: dict[str, Any],
|
|
133
130
|
form_include_pk: bool,
|
|
134
|
-
label:
|
|
135
|
-
loader:
|
|
136
|
-
) ->
|
|
131
|
+
label: str | None = None,
|
|
132
|
+
loader: QueryAjaxModelLoader | None = None,
|
|
133
|
+
) -> dict[str, Any] | None:
|
|
137
134
|
if not isinstance(prop, (RelationshipProperty, ColumnProperty)):
|
|
138
135
|
return None
|
|
139
136
|
|
|
@@ -169,7 +166,7 @@ class ModelConverterBase:
|
|
|
169
166
|
if (column.primary_key or column.foreign_keys) and not form_include_pk:
|
|
170
167
|
return None
|
|
171
168
|
|
|
172
|
-
default = getattr(column, "default", None)
|
|
169
|
+
default = getattr(column, "default", None) or kwargs.get("default")
|
|
173
170
|
|
|
174
171
|
if default is not None:
|
|
175
172
|
# Only actually change default if it has an attribute named
|
|
@@ -205,7 +202,7 @@ class ModelConverterBase:
|
|
|
205
202
|
prop: RelationshipProperty,
|
|
206
203
|
kwargs: dict,
|
|
207
204
|
session_maker: sessionmaker,
|
|
208
|
-
loader:
|
|
205
|
+
loader: QueryAjaxModelLoader | None = None,
|
|
209
206
|
) -> dict:
|
|
210
207
|
nullable = True
|
|
211
208
|
for pair in prop.local_remote_pairs:
|
|
@@ -225,7 +222,7 @@ class ModelConverterBase:
|
|
|
225
222
|
self,
|
|
226
223
|
prop: RelationshipProperty,
|
|
227
224
|
session_maker: sessionmaker,
|
|
228
|
-
) ->
|
|
225
|
+
) -> list[tuple[str, Any]]:
|
|
229
226
|
target_model = prop.mapper.class_
|
|
230
227
|
stmt = select(target_model)
|
|
231
228
|
|
|
@@ -283,13 +280,13 @@ class ModelConverterBase:
|
|
|
283
280
|
model: type,
|
|
284
281
|
prop: MODEL_PROPERTY,
|
|
285
282
|
session_maker: sessionmaker,
|
|
286
|
-
field_args:
|
|
287
|
-
field_widget_args:
|
|
283
|
+
field_args: dict[str, Any],
|
|
284
|
+
field_widget_args: dict[str, Any],
|
|
288
285
|
form_include_pk: bool,
|
|
289
|
-
label:
|
|
290
|
-
override:
|
|
291
|
-
form_ajax_refs:
|
|
292
|
-
) ->
|
|
286
|
+
label: str | None = None,
|
|
287
|
+
override: type[Field] | None = None,
|
|
288
|
+
form_ajax_refs: dict[str, QueryAjaxModelLoader] = {},
|
|
289
|
+
) -> UnboundField:
|
|
293
290
|
loader = form_ajax_refs.get(prop.key)
|
|
294
291
|
kwargs = await self._prepare_kwargs(
|
|
295
292
|
prop=prop,
|
|
@@ -329,7 +326,7 @@ class ModelConverterBase:
|
|
|
329
326
|
|
|
330
327
|
class ModelConverter(ModelConverterBase):
|
|
331
328
|
@staticmethod
|
|
332
|
-
def _string_common(prop: ColumnProperty) ->
|
|
329
|
+
def _string_common(prop: ColumnProperty) -> list[Validator]:
|
|
333
330
|
li = []
|
|
334
331
|
column: Column = prop.columns[0]
|
|
335
332
|
if isinstance(column.type.length, int) and column.type.length:
|
|
@@ -338,7 +335,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
338
335
|
|
|
339
336
|
@converts("String", "CHAR") # includes Unicode
|
|
340
337
|
def conv_string(
|
|
341
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
338
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
342
339
|
) -> UnboundField:
|
|
343
340
|
extra_validators = self._string_common(prop)
|
|
344
341
|
kwargs.setdefault("validators", [])
|
|
@@ -347,7 +344,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
347
344
|
|
|
348
345
|
@converts("Text", "LargeBinary", "Binary") # includes UnicodeText
|
|
349
346
|
def conv_text(
|
|
350
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
347
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
351
348
|
) -> UnboundField:
|
|
352
349
|
kwargs.setdefault("validators", [])
|
|
353
350
|
extra_validators = self._string_common(prop)
|
|
@@ -356,7 +353,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
356
353
|
|
|
357
354
|
@converts("Boolean", "dialects.mssql.base.BIT")
|
|
358
355
|
def conv_boolean(
|
|
359
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
356
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
360
357
|
) -> UnboundField:
|
|
361
358
|
if not prop.columns[0].nullable:
|
|
362
359
|
kwargs.setdefault("render_kw", {})
|
|
@@ -370,25 +367,25 @@ class ModelConverter(ModelConverterBase):
|
|
|
370
367
|
|
|
371
368
|
@converts("Date")
|
|
372
369
|
def conv_date(
|
|
373
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
370
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
374
371
|
) -> UnboundField:
|
|
375
372
|
return DateField(**kwargs)
|
|
376
373
|
|
|
377
374
|
@converts("Time")
|
|
378
375
|
def conv_time(
|
|
379
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
376
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
380
377
|
) -> UnboundField:
|
|
381
378
|
return TimeField(**kwargs)
|
|
382
379
|
|
|
383
380
|
@converts("DateTime")
|
|
384
381
|
def conv_datetime(
|
|
385
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
382
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
386
383
|
) -> UnboundField:
|
|
387
384
|
return DateTimeField(**kwargs)
|
|
388
385
|
|
|
389
386
|
@converts("Enum")
|
|
390
387
|
def conv_enum(
|
|
391
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
388
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
392
389
|
) -> UnboundField:
|
|
393
390
|
available_choices = [(e, e) for e in prop.columns[0].type.enums]
|
|
394
391
|
accepted_values = [choice[0] for choice in available_choices]
|
|
@@ -408,13 +405,13 @@ class ModelConverter(ModelConverterBase):
|
|
|
408
405
|
|
|
409
406
|
@converts("Integer") # includes BigInteger and SmallInteger
|
|
410
407
|
def conv_integer(
|
|
411
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
408
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
412
409
|
) -> UnboundField:
|
|
413
410
|
return IntegerField(**kwargs)
|
|
414
411
|
|
|
415
412
|
@converts("Numeric") # includes DECIMAL, Float/FLOAT, REAL, and DOUBLE
|
|
416
413
|
def conv_decimal(
|
|
417
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
414
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
418
415
|
) -> UnboundField:
|
|
419
416
|
# override default decimal places limit, use database defaults instead
|
|
420
417
|
kwargs.setdefault("places", None)
|
|
@@ -422,13 +419,13 @@ class ModelConverter(ModelConverterBase):
|
|
|
422
419
|
|
|
423
420
|
@converts("JSON", "JSONB")
|
|
424
421
|
def conv_json(
|
|
425
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
422
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
426
423
|
) -> UnboundField:
|
|
427
424
|
return JSONField(**kwargs)
|
|
428
425
|
|
|
429
426
|
@converts("Interval")
|
|
430
427
|
def conv_interval(
|
|
431
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
428
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
432
429
|
) -> UnboundField:
|
|
433
430
|
kwargs["render_kw"]["placeholder"] = "Like: 1 day 1:25:33.652"
|
|
434
431
|
return IntervalField(**kwargs)
|
|
@@ -439,7 +436,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
439
436
|
"sqlalchemy_utils.types.ip_address.IPAddressType",
|
|
440
437
|
)
|
|
441
438
|
def conv_ip_address(
|
|
442
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
439
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
443
440
|
) -> UnboundField:
|
|
444
441
|
kwargs.setdefault("validators", [])
|
|
445
442
|
kwargs["validators"].append(validators.IPAddress(ipv4=True, ipv6=True))
|
|
@@ -450,7 +447,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
450
447
|
"sqlalchemy.dialects.postgresql.types.MACADDR",
|
|
451
448
|
)
|
|
452
449
|
def conv_mac_address(
|
|
453
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
450
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
454
451
|
) -> UnboundField:
|
|
455
452
|
kwargs.setdefault("validators", [])
|
|
456
453
|
kwargs["validators"].append(validators.MacAddress())
|
|
@@ -463,7 +460,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
463
460
|
"sqlalchemy_utils.types.uuid.UUIDType",
|
|
464
461
|
)
|
|
465
462
|
def conv_uuid(
|
|
466
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
463
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
467
464
|
) -> UnboundField:
|
|
468
465
|
kwargs.setdefault("validators", [])
|
|
469
466
|
kwargs["validators"].append(validators.UUID())
|
|
@@ -473,13 +470,13 @@ class ModelConverter(ModelConverterBase):
|
|
|
473
470
|
"sqlalchemy.dialects.postgresql.base.ARRAY", "sqlalchemy.sql.sqltypes.ARRAY"
|
|
474
471
|
)
|
|
475
472
|
def conv_ARRAY(
|
|
476
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
473
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
477
474
|
) -> UnboundField:
|
|
478
475
|
return Select2TagsField(**kwargs)
|
|
479
476
|
|
|
480
477
|
@converts("sqlalchemy_utils.types.email.EmailType")
|
|
481
478
|
def conv_email(
|
|
482
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
479
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
483
480
|
) -> UnboundField:
|
|
484
481
|
kwargs.setdefault("validators", [])
|
|
485
482
|
kwargs["validators"].append(validators.Email())
|
|
@@ -487,7 +484,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
487
484
|
|
|
488
485
|
@converts("sqlalchemy_utils.types.url.URLType")
|
|
489
486
|
def conv_url(
|
|
490
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
487
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
491
488
|
) -> UnboundField:
|
|
492
489
|
kwargs.setdefault("validators", [])
|
|
493
490
|
kwargs["validators"].append(validators.URL())
|
|
@@ -495,7 +492,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
495
492
|
|
|
496
493
|
@converts("sqlalchemy_utils.types.currency.CurrencyType")
|
|
497
494
|
def conv_currency(
|
|
498
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
495
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
499
496
|
) -> UnboundField:
|
|
500
497
|
kwargs.setdefault("validators", [])
|
|
501
498
|
kwargs["validators"].append(CurrencyValidator())
|
|
@@ -503,7 +500,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
503
500
|
|
|
504
501
|
@converts("sqlalchemy_utils.types.timezone.TimezoneType")
|
|
505
502
|
def conv_timezone(
|
|
506
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
503
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
507
504
|
) -> UnboundField:
|
|
508
505
|
kwargs.setdefault("validators", [])
|
|
509
506
|
kwargs["validators"].append(
|
|
@@ -513,7 +510,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
513
510
|
|
|
514
511
|
@converts("sqlalchemy_utils.types.phone_number.PhoneNumberType")
|
|
515
512
|
def conv_phone_number(
|
|
516
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
513
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
517
514
|
) -> UnboundField:
|
|
518
515
|
kwargs.setdefault("validators", [])
|
|
519
516
|
kwargs["validators"].append(PhoneNumberValidator())
|
|
@@ -521,7 +518,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
521
518
|
|
|
522
519
|
@converts("sqlalchemy_utils.types.color.ColorType")
|
|
523
520
|
def conv_color(
|
|
524
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
521
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
525
522
|
) -> UnboundField:
|
|
526
523
|
kwargs.setdefault("validators", [])
|
|
527
524
|
kwargs["validators"].append(ColorValidator())
|
|
@@ -530,7 +527,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
530
527
|
@converts("sqlalchemy_utils.types.choice.ChoiceType")
|
|
531
528
|
@no_type_check
|
|
532
529
|
def convert_choice_type(
|
|
533
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
530
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
534
531
|
) -> UnboundField:
|
|
535
532
|
available_choices = []
|
|
536
533
|
column = prop.columns[0]
|
|
@@ -559,32 +556,32 @@ class ModelConverter(ModelConverterBase):
|
|
|
559
556
|
|
|
560
557
|
@converts("fastapi_storages.integrations.sqlalchemy.FileType")
|
|
561
558
|
def conv_file(
|
|
562
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
559
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
563
560
|
) -> UnboundField:
|
|
564
561
|
return FileField(**kwargs)
|
|
565
562
|
|
|
566
563
|
@converts("fastapi_storages.integrations.sqlalchemy.ImageType")
|
|
567
564
|
def conv_image(
|
|
568
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
565
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
569
566
|
) -> UnboundField:
|
|
570
567
|
return FileField(**kwargs)
|
|
571
568
|
|
|
572
569
|
@converts("ONETOONE")
|
|
573
570
|
def conv_one_to_one(
|
|
574
|
-
self, model: type, prop: RelationshipProperty, kwargs:
|
|
571
|
+
self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any]
|
|
575
572
|
) -> UnboundField:
|
|
576
573
|
kwargs["allow_blank"] = True
|
|
577
574
|
return QuerySelectField(**kwargs)
|
|
578
575
|
|
|
579
576
|
@converts("MANYTOONE")
|
|
580
577
|
def conv_many_to_one(
|
|
581
|
-
self, model: type, prop: RelationshipProperty, kwargs:
|
|
578
|
+
self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any]
|
|
582
579
|
) -> UnboundField:
|
|
583
580
|
return QuerySelectField(**kwargs)
|
|
584
581
|
|
|
585
582
|
@converts("MANYTOMANY", "ONETOMANY")
|
|
586
583
|
def conv_many_to_many(
|
|
587
|
-
self, model: type, prop: RelationshipProperty, kwargs:
|
|
584
|
+
self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any]
|
|
588
585
|
) -> UnboundField:
|
|
589
586
|
return QuerySelectMultipleField(**kwargs)
|
|
590
587
|
|
|
@@ -592,17 +589,17 @@ class ModelConverter(ModelConverterBase):
|
|
|
592
589
|
async def get_model_form(
|
|
593
590
|
model: type,
|
|
594
591
|
session_maker: sessionmaker,
|
|
595
|
-
only:
|
|
596
|
-
exclude:
|
|
597
|
-
column_labels:
|
|
598
|
-
form_args:
|
|
599
|
-
form_widget_args:
|
|
600
|
-
form_class:
|
|
601
|
-
form_overrides:
|
|
602
|
-
form_ajax_refs:
|
|
592
|
+
only: Sequence[str] | None = None,
|
|
593
|
+
exclude: Sequence[str] | None = None,
|
|
594
|
+
column_labels: dict[str, str] | None = None,
|
|
595
|
+
form_args: dict[str, dict[str, Any]] | None = None,
|
|
596
|
+
form_widget_args: dict[str, dict[str, Any]] | None = None,
|
|
597
|
+
form_class: type[Form] = Form,
|
|
598
|
+
form_overrides: dict[str, type[Field]] | None = None,
|
|
599
|
+
form_ajax_refs: dict[str, QueryAjaxModelLoader] | None = None,
|
|
603
600
|
form_include_pk: bool = False,
|
|
604
|
-
form_converter:
|
|
605
|
-
) ->
|
|
601
|
+
form_converter: type[ModelConverterBase] = ModelConverter,
|
|
602
|
+
) -> type[Form]:
|
|
606
603
|
type_name = model.__name__ + "Form"
|
|
607
604
|
converter = form_converter()
|
|
608
605
|
mapper = sqlalchemy_inspect(model)
|
sqladmin/helpers.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import csv
|
|
2
4
|
import enum
|
|
3
5
|
import os
|
|
@@ -9,11 +11,7 @@ from typing import (
|
|
|
9
11
|
Any,
|
|
10
12
|
AsyncGenerator,
|
|
11
13
|
Callable,
|
|
12
|
-
Dict,
|
|
13
14
|
Generator,
|
|
14
|
-
List,
|
|
15
|
-
Optional,
|
|
16
|
-
Tuple,
|
|
17
15
|
TypeVar,
|
|
18
16
|
)
|
|
19
17
|
|
|
@@ -136,11 +134,11 @@ class Writer(ABC):
|
|
|
136
134
|
"""https://docs.python.org/3/library/csv.html#writer-objects"""
|
|
137
135
|
|
|
138
136
|
@abstractmethod
|
|
139
|
-
def writerow(self, row:
|
|
137
|
+
def writerow(self, row: list[str]) -> None:
|
|
140
138
|
pass # pragma: no cover
|
|
141
139
|
|
|
142
140
|
@abstractmethod
|
|
143
|
-
def writerows(self, rows:
|
|
141
|
+
def writerows(self, rows: list[list[str]]) -> None:
|
|
144
142
|
pass # pragma: no cover
|
|
145
143
|
|
|
146
144
|
@property
|
|
@@ -174,7 +172,7 @@ def stream_to_csv(
|
|
|
174
172
|
return callback(writer) # type: ignore
|
|
175
173
|
|
|
176
174
|
|
|
177
|
-
def get_primary_keys(model: Any) ->
|
|
175
|
+
def get_primary_keys(model: Any) -> tuple[Column, ...]:
|
|
178
176
|
return tuple(inspect(model).mapper.primary_key)
|
|
179
177
|
|
|
180
178
|
|
|
@@ -191,7 +189,7 @@ def get_object_identifier(obj: Any) -> Any:
|
|
|
191
189
|
return ";".join(str(v).replace("\\", "\\\\").replace(";", r"\;") for v in values)
|
|
192
190
|
|
|
193
191
|
|
|
194
|
-
def _object_identifier_parts(id_string: str, model: type) ->
|
|
192
|
+
def _object_identifier_parts(id_string: str, model: type) -> tuple[str, ...]:
|
|
195
193
|
pks = get_primary_keys(model)
|
|
196
194
|
if len(pks) == 1:
|
|
197
195
|
# Only one primary key so no special processing
|
|
@@ -241,10 +239,13 @@ def get_direction(prop: MODEL_PROPERTY) -> str:
|
|
|
241
239
|
|
|
242
240
|
def get_column_python_type(column: Column) -> type:
|
|
243
241
|
try:
|
|
244
|
-
if hasattr(column.type, "impl"):
|
|
245
|
-
return column.type.impl.python_type
|
|
246
242
|
return column.type.python_type
|
|
247
243
|
except NotImplementedError:
|
|
244
|
+
if hasattr(column.type, "impl"):
|
|
245
|
+
try:
|
|
246
|
+
return column.type.impl.python_type
|
|
247
|
+
except NotImplementedError:
|
|
248
|
+
...
|
|
248
249
|
return str
|
|
249
250
|
|
|
250
251
|
|
|
@@ -252,7 +253,7 @@ def is_relationship(prop: MODEL_PROPERTY) -> bool:
|
|
|
252
253
|
return isinstance(prop, RelationshipProperty)
|
|
253
254
|
|
|
254
255
|
|
|
255
|
-
def parse_interval(value: str) ->
|
|
256
|
+
def parse_interval(value: str) -> timedelta | None:
|
|
256
257
|
match = (
|
|
257
258
|
standard_duration_re.match(value)
|
|
258
259
|
or iso8601_duration_re.match(value)
|
|
@@ -262,7 +263,7 @@ def parse_interval(value: str) -> Optional[timedelta]:
|
|
|
262
263
|
if not match:
|
|
263
264
|
return None
|
|
264
265
|
|
|
265
|
-
kw:
|
|
266
|
+
kw: dict[str, Any] = match.groupdict()
|
|
266
267
|
sign = -1 if kw.pop("sign", "+") == "-" else 1
|
|
267
268
|
if kw.get("microseconds"):
|
|
268
269
|
kw["microseconds"] = kw["microseconds"].ljust(6, "0")
|
sqladmin/models.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import time
|
|
4
|
+
import warnings
|
|
2
5
|
from enum import Enum
|
|
3
6
|
from typing import (
|
|
4
7
|
TYPE_CHECKING,
|
|
@@ -20,14 +23,16 @@ from urllib.parse import urlencode
|
|
|
20
23
|
import anyio
|
|
21
24
|
from sqlalchemy import Column, String, asc, cast, desc, func, inspect, or_
|
|
22
25
|
from sqlalchemy.exc import NoInspectionAvailable
|
|
23
|
-
from sqlalchemy.orm import
|
|
26
|
+
from sqlalchemy.orm import selectinload, sessionmaker
|
|
24
27
|
from sqlalchemy.orm.exc import DetachedInstanceError
|
|
25
28
|
from sqlalchemy.sql.elements import ClauseElement
|
|
26
29
|
from sqlalchemy.sql.expression import Select, select
|
|
27
30
|
from starlette.datastructures import URL
|
|
31
|
+
from starlette.exceptions import HTTPException
|
|
28
32
|
from starlette.requests import Request
|
|
29
33
|
from starlette.responses import StreamingResponse
|
|
30
34
|
from wtforms import Field, Form
|
|
35
|
+
from wtforms.fields.core import UnboundField
|
|
31
36
|
|
|
32
37
|
from sqladmin._queries import Query
|
|
33
38
|
from sqladmin._types import MODEL_ATTR
|
|
@@ -414,17 +419,17 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
414
419
|
"""
|
|
415
420
|
|
|
416
421
|
# Templates
|
|
417
|
-
list_template: ClassVar[str] = "list.html"
|
|
418
|
-
"""List view template. Default is `list.html`."""
|
|
422
|
+
list_template: ClassVar[str] = "sqladmin/list.html"
|
|
423
|
+
"""List view template. Default is `sqladmin/list.html`."""
|
|
419
424
|
|
|
420
|
-
create_template: ClassVar[str] = "create.html"
|
|
421
|
-
"""Create view template. Default is `create.html`."""
|
|
425
|
+
create_template: ClassVar[str] = "sqladmin/create.html"
|
|
426
|
+
"""Create view template. Default is `sqladmin/create.html`."""
|
|
422
427
|
|
|
423
|
-
details_template: ClassVar[str] = "details.html"
|
|
424
|
-
"""Details view template. Default is `details.html`."""
|
|
428
|
+
details_template: ClassVar[str] = "sqladmin/details.html"
|
|
429
|
+
"""Details view template. Default is `sqladmin/details.html`."""
|
|
425
430
|
|
|
426
|
-
edit_template: ClassVar[str] = "edit.html"
|
|
427
|
-
"""Edit view template. Default is `edit.html`."""
|
|
431
|
+
edit_template: ClassVar[str] = "sqladmin/edit.html"
|
|
432
|
+
"""Edit view template. Default is `sqladmin/edit.html`."""
|
|
428
433
|
|
|
429
434
|
# Export
|
|
430
435
|
column_export_list: ClassVar[List[MODEL_ATTR]] = []
|
|
@@ -597,6 +602,28 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
597
602
|
```
|
|
598
603
|
"""
|
|
599
604
|
|
|
605
|
+
form_rules: ClassVar[list[str]] = []
|
|
606
|
+
"""List of rendering rules for model creation and edit form.
|
|
607
|
+
This property changes default form rendering behavior and to rearrange
|
|
608
|
+
order of rendered fields, add some text between fields, group them, etc.
|
|
609
|
+
If not set, will use default Flask-Admin form rendering logic.
|
|
610
|
+
|
|
611
|
+
???+ example
|
|
612
|
+
```python
|
|
613
|
+
class UserAdmin(ModelAdmin, model=User):
|
|
614
|
+
form_rules = [
|
|
615
|
+
"first_name",
|
|
616
|
+
"last_name",
|
|
617
|
+
]
|
|
618
|
+
```
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
form_create_rules: ClassVar[list[str]] = []
|
|
622
|
+
"""Customized rules for the create form. Cannot be specified with `form_rules`."""
|
|
623
|
+
|
|
624
|
+
form_edit_rules: ClassVar[list[str]] = []
|
|
625
|
+
"""Customized rules for the edit form. Cannot be specified with `form_rules`."""
|
|
626
|
+
|
|
600
627
|
# General options
|
|
601
628
|
column_labels: ClassVar[Dict[MODEL_ATTR, str]] = {}
|
|
602
629
|
"""A mapping of column labels, used to map column names to new names.
|
|
@@ -684,6 +711,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
684
711
|
model_admin=self, name=name, options=options
|
|
685
712
|
)
|
|
686
713
|
|
|
714
|
+
self._refresh_form_rules_cache()
|
|
715
|
+
|
|
687
716
|
self._custom_actions_in_list: Dict[str, str] = {}
|
|
688
717
|
self._custom_actions_in_detail: Dict[str, str] = {}
|
|
689
718
|
self._custom_actions_confirmation: Dict[str, str] = {}
|
|
@@ -746,6 +775,17 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
746
775
|
|
|
747
776
|
return value
|
|
748
777
|
|
|
778
|
+
def validate_page_number(self, number: Union[str, None], default: int) -> int:
|
|
779
|
+
if not number:
|
|
780
|
+
return default
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
return int(number)
|
|
784
|
+
except ValueError:
|
|
785
|
+
raise HTTPException(
|
|
786
|
+
status_code=400, detail="Invalid page or pageSize parameter"
|
|
787
|
+
)
|
|
788
|
+
|
|
749
789
|
async def count(self, request: Request, stmt: Optional[Select] = None) -> int:
|
|
750
790
|
if stmt is None:
|
|
751
791
|
stmt = self.count_query(request)
|
|
@@ -753,14 +793,14 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
753
793
|
return rows[0]
|
|
754
794
|
|
|
755
795
|
async def list(self, request: Request) -> Pagination:
|
|
756
|
-
page =
|
|
757
|
-
page_size =
|
|
796
|
+
page = self.validate_page_number(request.query_params.get("page"), 1)
|
|
797
|
+
page_size = self.validate_page_number(request.query_params.get("pageSize"), 0)
|
|
758
798
|
page_size = min(page_size or self.page_size, max(self.page_size_options))
|
|
759
799
|
search = request.query_params.get("search", None)
|
|
760
800
|
|
|
761
801
|
stmt = self.list_query(request)
|
|
762
802
|
for relation in self._list_relations:
|
|
763
|
-
stmt = stmt.options(
|
|
803
|
+
stmt = stmt.options(selectinload(relation))
|
|
764
804
|
|
|
765
805
|
stmt = self.sort_query(stmt, request)
|
|
766
806
|
|
|
@@ -790,7 +830,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
790
830
|
stmt = self.list_query(request).limit(limit)
|
|
791
831
|
|
|
792
832
|
for relation in self._list_relations:
|
|
793
|
-
stmt = stmt.options(
|
|
833
|
+
stmt = stmt.options(selectinload(relation))
|
|
794
834
|
|
|
795
835
|
rows = await self._run_query(stmt)
|
|
796
836
|
return rows
|
|
@@ -803,16 +843,12 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
803
843
|
stmt = self._stmt_by_identifier(value)
|
|
804
844
|
|
|
805
845
|
for relation in self._details_relations:
|
|
806
|
-
stmt = stmt.options(
|
|
846
|
+
stmt = stmt.options(selectinload(relation))
|
|
807
847
|
|
|
808
848
|
return await self._get_object_by_pk(stmt)
|
|
809
849
|
|
|
810
|
-
async def get_object_for_edit(self,
|
|
811
|
-
stmt = self.
|
|
812
|
-
|
|
813
|
-
for relation in self._form_relations:
|
|
814
|
-
stmt = stmt.options(joinedload(relation))
|
|
815
|
-
|
|
850
|
+
async def get_object_for_edit(self, request: Request) -> Any:
|
|
851
|
+
stmt = self.form_edit_query(request)
|
|
816
852
|
return await self._get_object_by_pk(stmt)
|
|
817
853
|
|
|
818
854
|
async def get_object_for_delete(self, value: Any) -> Any:
|
|
@@ -1045,6 +1081,25 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1045
1081
|
|
|
1046
1082
|
return select(self.model)
|
|
1047
1083
|
|
|
1084
|
+
def edit_form_query(self, request: Request) -> Select:
|
|
1085
|
+
msg = (
|
|
1086
|
+
"Overriding 'edit_form_query' is deprecated. Use 'form_edit_query' instead."
|
|
1087
|
+
)
|
|
1088
|
+
warnings.warn(msg, DeprecationWarning, stacklevel=2)
|
|
1089
|
+
return self.form_edit_query(request)
|
|
1090
|
+
|
|
1091
|
+
def form_edit_query(self, request: Request) -> Select:
|
|
1092
|
+
"""
|
|
1093
|
+
The SQLAlchemy select expression used for the edit form page which can be
|
|
1094
|
+
customized. By default it will select the object by primary key(s) without any
|
|
1095
|
+
additional filters.
|
|
1096
|
+
"""
|
|
1097
|
+
|
|
1098
|
+
stmt = self._stmt_by_identifier(request.path_params["pk"])
|
|
1099
|
+
for relation in self._form_relations:
|
|
1100
|
+
stmt = stmt.options(selectinload(relation))
|
|
1101
|
+
return stmt
|
|
1102
|
+
|
|
1048
1103
|
def count_query(self, request: Request) -> Select:
|
|
1049
1104
|
"""
|
|
1050
1105
|
The SQLAlchemy select expression used for the count query
|
|
@@ -1123,3 +1178,26 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1123
1178
|
media_type="text/csv",
|
|
1124
1179
|
headers={"Content-Disposition": f"attachment;filename={filename}"},
|
|
1125
1180
|
)
|
|
1181
|
+
|
|
1182
|
+
def _refresh_form_rules_cache(self) -> None:
|
|
1183
|
+
if self.form_rules:
|
|
1184
|
+
self._form_create_rules = self.form_rules
|
|
1185
|
+
self._form_edit_rules = self.form_rules
|
|
1186
|
+
else:
|
|
1187
|
+
self._form_create_rules = self.form_create_rules
|
|
1188
|
+
self._form_edit_rules = self.form_edit_rules
|
|
1189
|
+
|
|
1190
|
+
def _validate_form_class(self, ruleset: List[Any], form_class: Type[Form]) -> None:
|
|
1191
|
+
form_fields = []
|
|
1192
|
+
for name, obj in form_class.__dict__.items():
|
|
1193
|
+
if isinstance(obj, UnboundField):
|
|
1194
|
+
form_fields.append(name)
|
|
1195
|
+
|
|
1196
|
+
missing_fields = []
|
|
1197
|
+
if ruleset:
|
|
1198
|
+
for field_name in form_fields:
|
|
1199
|
+
if field_name not in ruleset:
|
|
1200
|
+
missing_fields.append(field_name)
|
|
1201
|
+
|
|
1202
|
+
for field_name in missing_fields:
|
|
1203
|
+
delattr(form_class, field_name)
|
sqladmin/pagination.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from dataclasses import dataclass, field
|
|
2
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
3
5
|
|
|
4
6
|
from starlette.datastructures import URL
|
|
5
7
|
|
|
@@ -12,11 +14,11 @@ class PageControl:
|
|
|
12
14
|
|
|
13
15
|
@dataclass
|
|
14
16
|
class Pagination:
|
|
15
|
-
rows:
|
|
17
|
+
rows: list[Any]
|
|
16
18
|
page: int
|
|
17
19
|
page_size: int
|
|
18
20
|
count: int
|
|
19
|
-
page_controls:
|
|
21
|
+
page_controls: list[PageControl] = field(default_factory=list)
|
|
20
22
|
max_page_controls: int = 7
|
|
21
23
|
|
|
22
24
|
@property
|