sqladmin 0.17.0__py3-none-any.whl → 0.19.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 +8 -6
- sqladmin/ajax.py +12 -5
- sqladmin/application.py +39 -35
- sqladmin/authentication.py +4 -2
- sqladmin/fields.py +35 -33
- sqladmin/forms.py +66 -61
- sqladmin/helpers.py +8 -10
- sqladmin/models.py +60 -2
- sqladmin/pagination.py +10 -3
- sqladmin/statics/css/tabler-icons.min.css +4 -0
- sqladmin/statics/css/tabler-icons.min.css.map +1 -0
- sqladmin/statics/webfonts/tabler-icons.woff2 +0 -0
- sqladmin/templates/sqladmin/_macros.html +33 -0
- sqladmin/templates/sqladmin/base.html +4 -0
- sqladmin/templates/sqladmin/create.html +2 -18
- sqladmin/templates/sqladmin/edit.html +8 -24
- sqladmin/templates/sqladmin/list.html +1 -1
- sqladmin/templating.py +9 -7
- sqladmin/widgets.py +4 -1
- {sqladmin-0.17.0.dist-info → sqladmin-0.19.0.dist-info}/METADATA +1 -1
- {sqladmin-0.17.0.dist-info → sqladmin-0.19.0.dist-info}/RECORD +25 -22
- {sqladmin-0.17.0.dist-info → sqladmin-0.19.0.dist-info}/WHEEL +1 -1
- {sqladmin-0.17.0.dist-info → sqladmin-0.19.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,
|
|
@@ -21,7 +18,12 @@ from typing import (
|
|
|
21
18
|
import anyio
|
|
22
19
|
from sqlalchemy import Boolean, select
|
|
23
20
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
|
24
|
-
from sqlalchemy.orm import
|
|
21
|
+
from sqlalchemy.orm import (
|
|
22
|
+
ColumnProperty,
|
|
23
|
+
RelationshipProperty,
|
|
24
|
+
sessionmaker,
|
|
25
|
+
)
|
|
26
|
+
from sqlalchemy.sql.elements import Label
|
|
25
27
|
from sqlalchemy.sql.schema import Column
|
|
26
28
|
from wtforms import (
|
|
27
29
|
BooleanField,
|
|
@@ -85,7 +87,7 @@ class ConverterCallable(Protocol):
|
|
|
85
87
|
self,
|
|
86
88
|
model: type,
|
|
87
89
|
prop: MODEL_PROPERTY,
|
|
88
|
-
kwargs:
|
|
90
|
+
kwargs: dict[str, Any],
|
|
89
91
|
) -> UnboundField:
|
|
90
92
|
... # pragma: no cover
|
|
91
93
|
|
|
@@ -107,7 +109,7 @@ def converts(*args: str) -> Callable[[T_CC], T_CC]:
|
|
|
107
109
|
|
|
108
110
|
|
|
109
111
|
class ModelConverterBase:
|
|
110
|
-
_converters:
|
|
112
|
+
_converters: dict[str, ConverterCallable] = {}
|
|
111
113
|
|
|
112
114
|
def __init__(self) -> None:
|
|
113
115
|
super().__init__()
|
|
@@ -128,12 +130,12 @@ class ModelConverterBase:
|
|
|
128
130
|
self,
|
|
129
131
|
prop: MODEL_PROPERTY,
|
|
130
132
|
session_maker: sessionmaker,
|
|
131
|
-
field_args:
|
|
132
|
-
field_widget_args:
|
|
133
|
+
field_args: dict[str, Any],
|
|
134
|
+
field_widget_args: dict[str, Any],
|
|
133
135
|
form_include_pk: bool,
|
|
134
|
-
label:
|
|
135
|
-
loader:
|
|
136
|
-
) ->
|
|
136
|
+
label: str | None = None,
|
|
137
|
+
loader: QueryAjaxModelLoader | None = None,
|
|
138
|
+
) -> dict[str, Any] | None:
|
|
137
139
|
if not isinstance(prop, (RelationshipProperty, ColumnProperty)):
|
|
138
140
|
return None
|
|
139
141
|
|
|
@@ -205,7 +207,7 @@ class ModelConverterBase:
|
|
|
205
207
|
prop: RelationshipProperty,
|
|
206
208
|
kwargs: dict,
|
|
207
209
|
session_maker: sessionmaker,
|
|
208
|
-
loader:
|
|
210
|
+
loader: QueryAjaxModelLoader | None = None,
|
|
209
211
|
) -> dict:
|
|
210
212
|
nullable = True
|
|
211
213
|
for pair in prop.local_remote_pairs:
|
|
@@ -225,7 +227,7 @@ class ModelConverterBase:
|
|
|
225
227
|
self,
|
|
226
228
|
prop: RelationshipProperty,
|
|
227
229
|
session_maker: sessionmaker,
|
|
228
|
-
) ->
|
|
230
|
+
) -> list[tuple[str, Any]]:
|
|
229
231
|
target_model = prop.mapper.class_
|
|
230
232
|
stmt = select(target_model)
|
|
231
233
|
|
|
@@ -283,13 +285,13 @@ class ModelConverterBase:
|
|
|
283
285
|
model: type,
|
|
284
286
|
prop: MODEL_PROPERTY,
|
|
285
287
|
session_maker: sessionmaker,
|
|
286
|
-
field_args:
|
|
287
|
-
field_widget_args:
|
|
288
|
+
field_args: dict[str, Any],
|
|
289
|
+
field_widget_args: dict[str, Any],
|
|
288
290
|
form_include_pk: bool,
|
|
289
|
-
label:
|
|
290
|
-
override:
|
|
291
|
-
form_ajax_refs:
|
|
292
|
-
) ->
|
|
291
|
+
label: str | None = None,
|
|
292
|
+
override: type[Field] | None = None,
|
|
293
|
+
form_ajax_refs: dict[str, QueryAjaxModelLoader] = {},
|
|
294
|
+
) -> UnboundField:
|
|
293
295
|
loader = form_ajax_refs.get(prop.key)
|
|
294
296
|
kwargs = await self._prepare_kwargs(
|
|
295
297
|
prop=prop,
|
|
@@ -329,7 +331,7 @@ class ModelConverterBase:
|
|
|
329
331
|
|
|
330
332
|
class ModelConverter(ModelConverterBase):
|
|
331
333
|
@staticmethod
|
|
332
|
-
def _string_common(prop: ColumnProperty) ->
|
|
334
|
+
def _string_common(prop: ColumnProperty) -> list[Validator]:
|
|
333
335
|
li = []
|
|
334
336
|
column: Column = prop.columns[0]
|
|
335
337
|
if isinstance(column.type.length, int) and column.type.length:
|
|
@@ -338,7 +340,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
338
340
|
|
|
339
341
|
@converts("String", "CHAR") # includes Unicode
|
|
340
342
|
def conv_string(
|
|
341
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
343
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
342
344
|
) -> UnboundField:
|
|
343
345
|
extra_validators = self._string_common(prop)
|
|
344
346
|
kwargs.setdefault("validators", [])
|
|
@@ -347,7 +349,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
347
349
|
|
|
348
350
|
@converts("Text", "LargeBinary", "Binary") # includes UnicodeText
|
|
349
351
|
def conv_text(
|
|
350
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
352
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
351
353
|
) -> UnboundField:
|
|
352
354
|
kwargs.setdefault("validators", [])
|
|
353
355
|
extra_validators = self._string_common(prop)
|
|
@@ -356,7 +358,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
356
358
|
|
|
357
359
|
@converts("Boolean", "dialects.mssql.base.BIT")
|
|
358
360
|
def conv_boolean(
|
|
359
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
361
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
360
362
|
) -> UnboundField:
|
|
361
363
|
if not prop.columns[0].nullable:
|
|
362
364
|
kwargs.setdefault("render_kw", {})
|
|
@@ -370,25 +372,25 @@ class ModelConverter(ModelConverterBase):
|
|
|
370
372
|
|
|
371
373
|
@converts("Date")
|
|
372
374
|
def conv_date(
|
|
373
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
375
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
374
376
|
) -> UnboundField:
|
|
375
377
|
return DateField(**kwargs)
|
|
376
378
|
|
|
377
379
|
@converts("Time")
|
|
378
380
|
def conv_time(
|
|
379
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
381
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
380
382
|
) -> UnboundField:
|
|
381
383
|
return TimeField(**kwargs)
|
|
382
384
|
|
|
383
385
|
@converts("DateTime")
|
|
384
386
|
def conv_datetime(
|
|
385
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
387
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
386
388
|
) -> UnboundField:
|
|
387
389
|
return DateTimeField(**kwargs)
|
|
388
390
|
|
|
389
391
|
@converts("Enum")
|
|
390
392
|
def conv_enum(
|
|
391
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
393
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
392
394
|
) -> UnboundField:
|
|
393
395
|
available_choices = [(e, e) for e in prop.columns[0].type.enums]
|
|
394
396
|
accepted_values = [choice[0] for choice in available_choices]
|
|
@@ -408,13 +410,13 @@ class ModelConverter(ModelConverterBase):
|
|
|
408
410
|
|
|
409
411
|
@converts("Integer") # includes BigInteger and SmallInteger
|
|
410
412
|
def conv_integer(
|
|
411
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
413
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
412
414
|
) -> UnboundField:
|
|
413
415
|
return IntegerField(**kwargs)
|
|
414
416
|
|
|
415
417
|
@converts("Numeric") # includes DECIMAL, Float/FLOAT, REAL, and DOUBLE
|
|
416
418
|
def conv_decimal(
|
|
417
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
419
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
418
420
|
) -> UnboundField:
|
|
419
421
|
# override default decimal places limit, use database defaults instead
|
|
420
422
|
kwargs.setdefault("places", None)
|
|
@@ -422,13 +424,13 @@ class ModelConverter(ModelConverterBase):
|
|
|
422
424
|
|
|
423
425
|
@converts("JSON", "JSONB")
|
|
424
426
|
def conv_json(
|
|
425
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
427
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
426
428
|
) -> UnboundField:
|
|
427
429
|
return JSONField(**kwargs)
|
|
428
430
|
|
|
429
431
|
@converts("Interval")
|
|
430
432
|
def conv_interval(
|
|
431
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
433
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
432
434
|
) -> UnboundField:
|
|
433
435
|
kwargs["render_kw"]["placeholder"] = "Like: 1 day 1:25:33.652"
|
|
434
436
|
return IntervalField(**kwargs)
|
|
@@ -439,7 +441,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
439
441
|
"sqlalchemy_utils.types.ip_address.IPAddressType",
|
|
440
442
|
)
|
|
441
443
|
def conv_ip_address(
|
|
442
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
444
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
443
445
|
) -> UnboundField:
|
|
444
446
|
kwargs.setdefault("validators", [])
|
|
445
447
|
kwargs["validators"].append(validators.IPAddress(ipv4=True, ipv6=True))
|
|
@@ -450,7 +452,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
450
452
|
"sqlalchemy.dialects.postgresql.types.MACADDR",
|
|
451
453
|
)
|
|
452
454
|
def conv_mac_address(
|
|
453
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
455
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
454
456
|
) -> UnboundField:
|
|
455
457
|
kwargs.setdefault("validators", [])
|
|
456
458
|
kwargs["validators"].append(validators.MacAddress())
|
|
@@ -463,7 +465,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
463
465
|
"sqlalchemy_utils.types.uuid.UUIDType",
|
|
464
466
|
)
|
|
465
467
|
def conv_uuid(
|
|
466
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
468
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
467
469
|
) -> UnboundField:
|
|
468
470
|
kwargs.setdefault("validators", [])
|
|
469
471
|
kwargs["validators"].append(validators.UUID())
|
|
@@ -473,13 +475,13 @@ class ModelConverter(ModelConverterBase):
|
|
|
473
475
|
"sqlalchemy.dialects.postgresql.base.ARRAY", "sqlalchemy.sql.sqltypes.ARRAY"
|
|
474
476
|
)
|
|
475
477
|
def conv_ARRAY(
|
|
476
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
478
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
477
479
|
) -> UnboundField:
|
|
478
480
|
return Select2TagsField(**kwargs)
|
|
479
481
|
|
|
480
482
|
@converts("sqlalchemy_utils.types.email.EmailType")
|
|
481
483
|
def conv_email(
|
|
482
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
484
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
483
485
|
) -> UnboundField:
|
|
484
486
|
kwargs.setdefault("validators", [])
|
|
485
487
|
kwargs["validators"].append(validators.Email())
|
|
@@ -487,7 +489,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
487
489
|
|
|
488
490
|
@converts("sqlalchemy_utils.types.url.URLType")
|
|
489
491
|
def conv_url(
|
|
490
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
492
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
491
493
|
) -> UnboundField:
|
|
492
494
|
kwargs.setdefault("validators", [])
|
|
493
495
|
kwargs["validators"].append(validators.URL())
|
|
@@ -495,7 +497,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
495
497
|
|
|
496
498
|
@converts("sqlalchemy_utils.types.currency.CurrencyType")
|
|
497
499
|
def conv_currency(
|
|
498
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
500
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
499
501
|
) -> UnboundField:
|
|
500
502
|
kwargs.setdefault("validators", [])
|
|
501
503
|
kwargs["validators"].append(CurrencyValidator())
|
|
@@ -503,7 +505,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
503
505
|
|
|
504
506
|
@converts("sqlalchemy_utils.types.timezone.TimezoneType")
|
|
505
507
|
def conv_timezone(
|
|
506
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
508
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
507
509
|
) -> UnboundField:
|
|
508
510
|
kwargs.setdefault("validators", [])
|
|
509
511
|
kwargs["validators"].append(
|
|
@@ -513,7 +515,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
513
515
|
|
|
514
516
|
@converts("sqlalchemy_utils.types.phone_number.PhoneNumberType")
|
|
515
517
|
def conv_phone_number(
|
|
516
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
518
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
517
519
|
) -> UnboundField:
|
|
518
520
|
kwargs.setdefault("validators", [])
|
|
519
521
|
kwargs["validators"].append(PhoneNumberValidator())
|
|
@@ -521,7 +523,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
521
523
|
|
|
522
524
|
@converts("sqlalchemy_utils.types.color.ColorType")
|
|
523
525
|
def conv_color(
|
|
524
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
526
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
525
527
|
) -> UnboundField:
|
|
526
528
|
kwargs.setdefault("validators", [])
|
|
527
529
|
kwargs["validators"].append(ColorValidator())
|
|
@@ -530,7 +532,7 @@ class ModelConverter(ModelConverterBase):
|
|
|
530
532
|
@converts("sqlalchemy_utils.types.choice.ChoiceType")
|
|
531
533
|
@no_type_check
|
|
532
534
|
def convert_choice_type(
|
|
533
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
535
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
534
536
|
) -> UnboundField:
|
|
535
537
|
available_choices = []
|
|
536
538
|
column = prop.columns[0]
|
|
@@ -559,32 +561,32 @@ class ModelConverter(ModelConverterBase):
|
|
|
559
561
|
|
|
560
562
|
@converts("fastapi_storages.integrations.sqlalchemy.FileType")
|
|
561
563
|
def conv_file(
|
|
562
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
564
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
563
565
|
) -> UnboundField:
|
|
564
566
|
return FileField(**kwargs)
|
|
565
567
|
|
|
566
568
|
@converts("fastapi_storages.integrations.sqlalchemy.ImageType")
|
|
567
569
|
def conv_image(
|
|
568
|
-
self, model: type, prop: ColumnProperty, kwargs:
|
|
570
|
+
self, model: type, prop: ColumnProperty, kwargs: dict[str, Any]
|
|
569
571
|
) -> UnboundField:
|
|
570
572
|
return FileField(**kwargs)
|
|
571
573
|
|
|
572
574
|
@converts("ONETOONE")
|
|
573
575
|
def conv_one_to_one(
|
|
574
|
-
self, model: type, prop: RelationshipProperty, kwargs:
|
|
576
|
+
self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any]
|
|
575
577
|
) -> UnboundField:
|
|
576
578
|
kwargs["allow_blank"] = True
|
|
577
579
|
return QuerySelectField(**kwargs)
|
|
578
580
|
|
|
579
581
|
@converts("MANYTOONE")
|
|
580
582
|
def conv_many_to_one(
|
|
581
|
-
self, model: type, prop: RelationshipProperty, kwargs:
|
|
583
|
+
self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any]
|
|
582
584
|
) -> UnboundField:
|
|
583
585
|
return QuerySelectField(**kwargs)
|
|
584
586
|
|
|
585
587
|
@converts("MANYTOMANY", "ONETOMANY")
|
|
586
588
|
def conv_many_to_many(
|
|
587
|
-
self, model: type, prop: RelationshipProperty, kwargs:
|
|
589
|
+
self, model: type, prop: RelationshipProperty, kwargs: dict[str, Any]
|
|
588
590
|
) -> UnboundField:
|
|
589
591
|
return QuerySelectMultipleField(**kwargs)
|
|
590
592
|
|
|
@@ -592,17 +594,17 @@ class ModelConverter(ModelConverterBase):
|
|
|
592
594
|
async def get_model_form(
|
|
593
595
|
model: type,
|
|
594
596
|
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:
|
|
597
|
+
only: Sequence[str] | None = None,
|
|
598
|
+
exclude: Sequence[str] | None = None,
|
|
599
|
+
column_labels: dict[str, str] | None = None,
|
|
600
|
+
form_args: dict[str, dict[str, Any]] | None = None,
|
|
601
|
+
form_widget_args: dict[str, dict[str, Any]] | None = None,
|
|
602
|
+
form_class: type[Form] = Form,
|
|
603
|
+
form_overrides: dict[str, type[Field]] | None = None,
|
|
604
|
+
form_ajax_refs: dict[str, QueryAjaxModelLoader] | None = None,
|
|
603
605
|
form_include_pk: bool = False,
|
|
604
|
-
form_converter:
|
|
605
|
-
) ->
|
|
606
|
+
form_converter: type[ModelConverterBase] = ModelConverter,
|
|
607
|
+
) -> type[Form]:
|
|
606
608
|
type_name = model.__name__ + "Form"
|
|
607
609
|
converter = form_converter()
|
|
608
610
|
mapper = sqlalchemy_inspect(model)
|
|
@@ -615,9 +617,12 @@ async def get_model_form(
|
|
|
615
617
|
attributes = []
|
|
616
618
|
names = only or mapper.attrs.keys()
|
|
617
619
|
for name in names:
|
|
618
|
-
|
|
620
|
+
attr = mapper.attrs[name]
|
|
621
|
+
if (exclude and name in exclude) or (
|
|
622
|
+
isinstance(attr, ColumnProperty) and isinstance(attr.expression, Label)
|
|
623
|
+
):
|
|
619
624
|
continue
|
|
620
|
-
attributes.append((name,
|
|
625
|
+
attributes.append((name, attr))
|
|
621
626
|
|
|
622
627
|
field_dict = {}
|
|
623
628
|
for name, attr in attributes:
|
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
|
|
@@ -255,7 +253,7 @@ def is_relationship(prop: MODEL_PROPERTY) -> bool:
|
|
|
255
253
|
return isinstance(prop, RelationshipProperty)
|
|
256
254
|
|
|
257
255
|
|
|
258
|
-
def parse_interval(value: str) ->
|
|
256
|
+
def parse_interval(value: str) -> timedelta | None:
|
|
259
257
|
match = (
|
|
260
258
|
standard_duration_re.match(value)
|
|
261
259
|
or iso8601_duration_re.match(value)
|
|
@@ -265,7 +263,7 @@ def parse_interval(value: str) -> Optional[timedelta]:
|
|
|
265
263
|
if not match:
|
|
266
264
|
return None
|
|
267
265
|
|
|
268
|
-
kw:
|
|
266
|
+
kw: dict[str, Any] = match.groupdict()
|
|
269
267
|
sign = -1 if kw.pop("sign", "+") == "-" else 1
|
|
270
268
|
if kw.get("microseconds"):
|
|
271
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,
|
|
@@ -29,6 +32,7 @@ from starlette.exceptions import HTTPException
|
|
|
29
32
|
from starlette.requests import Request
|
|
30
33
|
from starlette.responses import StreamingResponse
|
|
31
34
|
from wtforms import Field, Form
|
|
35
|
+
from wtforms.fields.core import UnboundField
|
|
32
36
|
|
|
33
37
|
from sqladmin._queries import Query
|
|
34
38
|
from sqladmin._types import MODEL_ATTR
|
|
@@ -172,7 +176,7 @@ class BaseView(BaseModelView):
|
|
|
172
176
|
|
|
173
177
|
icon: ClassVar[str] = ""
|
|
174
178
|
"""Display icon for ModelAdmin in the sidebar.
|
|
175
|
-
Currently only supports FontAwesome icons.
|
|
179
|
+
Currently only supports FontAwesome and Tabler icons.
|
|
176
180
|
"""
|
|
177
181
|
|
|
178
182
|
category: ClassVar[str] = ""
|
|
@@ -598,6 +602,28 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
598
602
|
```
|
|
599
603
|
"""
|
|
600
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
|
+
|
|
601
627
|
# General options
|
|
602
628
|
column_labels: ClassVar[Dict[MODEL_ATTR, str]] = {}
|
|
603
629
|
"""A mapping of column labels, used to map column names to new names.
|
|
@@ -685,6 +711,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
685
711
|
model_admin=self, name=name, options=options
|
|
686
712
|
)
|
|
687
713
|
|
|
714
|
+
self._refresh_form_rules_cache()
|
|
715
|
+
|
|
688
716
|
self._custom_actions_in_list: Dict[str, str] = {}
|
|
689
717
|
self._custom_actions_in_detail: Dict[str, str] = {}
|
|
690
718
|
self._custom_actions_confirmation: Dict[str, str] = {}
|
|
@@ -820,7 +848,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
820
848
|
return await self._get_object_by_pk(stmt)
|
|
821
849
|
|
|
822
850
|
async def get_object_for_edit(self, request: Request) -> Any:
|
|
823
|
-
stmt = self.
|
|
851
|
+
stmt = self.form_edit_query(request)
|
|
824
852
|
return await self._get_object_by_pk(stmt)
|
|
825
853
|
|
|
826
854
|
async def get_object_for_delete(self, value: Any) -> Any:
|
|
@@ -1054,6 +1082,13 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1054
1082
|
return select(self.model)
|
|
1055
1083
|
|
|
1056
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:
|
|
1057
1092
|
"""
|
|
1058
1093
|
The SQLAlchemy select expression used for the edit form page which can be
|
|
1059
1094
|
customized. By default it will select the object by primary key(s) without any
|
|
@@ -1143,3 +1178,26 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1143
1178
|
media_type="text/csv",
|
|
1144
1179
|
headers={"Content-Disposition": f"attachment;filename={filename}"},
|
|
1145
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
|
|
@@ -44,6 +46,11 @@ class Pagination:
|
|
|
44
46
|
|
|
45
47
|
raise RuntimeError("Next page not found.")
|
|
46
48
|
|
|
49
|
+
def resize(self, page_size: int) -> Pagination:
|
|
50
|
+
self.page = (self.page - 1) * self.page_size // page_size + 1
|
|
51
|
+
self.page_size = page_size
|
|
52
|
+
return self
|
|
53
|
+
|
|
47
54
|
def add_pagination_urls(self, base_url: URL) -> None:
|
|
48
55
|
# Previous pages
|
|
49
56
|
for p in range(self.page - min(self.max_page_controls, 3), self.page):
|