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.
Files changed (32) hide show
  1. sqladmin/__init__.py +1 -1
  2. sqladmin/_menu.py +11 -9
  3. sqladmin/_queries.py +10 -8
  4. sqladmin/ajax.py +5 -3
  5. sqladmin/application.py +54 -43
  6. sqladmin/authentication.py +4 -2
  7. sqladmin/fields.py +35 -33
  8. sqladmin/forms.py +56 -59
  9. sqladmin/helpers.py +13 -12
  10. sqladmin/models.py +98 -20
  11. sqladmin/pagination.py +5 -3
  12. sqladmin/templates/{_macros.html → sqladmin/_macros.html} +33 -0
  13. sqladmin/templates/{create.html → sqladmin/create.html} +3 -16
  14. sqladmin/templates/{details.html → sqladmin/details.html} +3 -3
  15. sqladmin/templates/{edit.html → sqladmin/edit.html} +5 -16
  16. sqladmin/templates/{error.html → sqladmin/error.html} +1 -1
  17. sqladmin/templates/sqladmin/index.html +3 -0
  18. sqladmin/templates/{layout.html → sqladmin/layout.html} +2 -2
  19. sqladmin/templates/{list.html → sqladmin/list.html} +6 -6
  20. sqladmin/templates/{login.html → sqladmin/login.html} +1 -1
  21. sqladmin/templating.py +9 -7
  22. sqladmin/widgets.py +20 -12
  23. {sqladmin-0.16.1.dist-info → sqladmin-0.18.0.dist-info}/METADATA +2 -2
  24. sqladmin-0.18.0.dist-info/RECORD +50 -0
  25. {sqladmin-0.16.1.dist-info → sqladmin-0.18.0.dist-info}/WHEEL +1 -1
  26. sqladmin/templates/index.html +0 -3
  27. sqladmin-0.16.1.dist-info/RECORD +0 -50
  28. /sqladmin/templates/{base.html → sqladmin/base.html} +0 -0
  29. /sqladmin/templates/{modals → sqladmin/modals}/delete.html +0 -0
  30. /sqladmin/templates/{modals → sqladmin/modals}/details_action_confirmation.html +0 -0
  31. /sqladmin/templates/{modals → sqladmin/modals}/list_action_confirmation.html +0 -0
  32. {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: Dict[str, Any],
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: Dict[str, ConverterCallable] = {}
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: Dict[str, Any],
132
- field_widget_args: Dict[str, Any],
128
+ field_args: dict[str, Any],
129
+ field_widget_args: dict[str, Any],
133
130
  form_include_pk: bool,
134
- label: Optional[str] = None,
135
- loader: Optional[QueryAjaxModelLoader] = None,
136
- ) -> Optional[Dict[str, Any]]:
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: Optional[QueryAjaxModelLoader] = None,
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
- ) -> List[Tuple[str, Any]]:
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: Dict[str, Any],
287
- field_widget_args: Dict[str, Any],
283
+ field_args: dict[str, Any],
284
+ field_widget_args: dict[str, Any],
288
285
  form_include_pk: bool,
289
- label: Optional[str] = None,
290
- override: Optional[Type[Field]] = None,
291
- form_ajax_refs: Dict[str, QueryAjaxModelLoader] = {},
292
- ) -> Optional[UnboundField]:
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) -> List[Validator]:
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Dict[str, Any]
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: Optional[Sequence[str]] = None,
596
- exclude: Optional[Sequence[str]] = None,
597
- column_labels: Optional[Dict[str, str]] = None,
598
- form_args: Optional[Dict[str, Dict[str, Any]]] = None,
599
- form_widget_args: Optional[Dict[str, Dict[str, Any]]] = None,
600
- form_class: Type[Form] = Form,
601
- form_overrides: Optional[Dict[str, Type[Field]]] = None,
602
- form_ajax_refs: Optional[Dict[str, QueryAjaxModelLoader]] = None,
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: Type[ModelConverterBase] = ModelConverter,
605
- ) -> Type[Form]:
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: List[str]) -> None:
137
+ def writerow(self, row: list[str]) -> None:
140
138
  pass # pragma: no cover
141
139
 
142
140
  @abstractmethod
143
- def writerows(self, rows: List[List[str]]) -> None:
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) -> Tuple[Column, ...]:
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) -> Tuple[str, ...]:
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) -> Optional[timedelta]:
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: Dict[str, Any] = match.groupdict()
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 joinedload, sessionmaker
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 = int(request.query_params.get("page", 1))
757
- page_size = int(request.query_params.get("pageSize", 0))
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(joinedload(relation))
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(joinedload(relation))
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(joinedload(relation))
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, value: Any) -> Any:
811
- stmt = self._stmt_by_identifier(value)
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, List
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: List[Any]
17
+ rows: list[Any]
16
18
  page: int
17
19
  page_size: int
18
20
  count: int
19
- page_controls: List[PageControl] = field(default_factory=list)
21
+ page_controls: list[PageControl] = field(default_factory=list)
20
22
  max_page_controls: int = 7
21
23
 
22
24
  @property