sqladmin 0.8.0__tar.gz → 0.10.0__tar.gz

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 (46) hide show
  1. {sqladmin-0.8.0 → sqladmin-0.10.0}/.gitignore +1 -0
  2. {sqladmin-0.8.0 → sqladmin-0.10.0}/PKG-INFO +9 -3
  3. {sqladmin-0.8.0 → sqladmin-0.10.0}/README.md +5 -0
  4. {sqladmin-0.8.0 → sqladmin-0.10.0}/pyproject.toml +59 -20
  5. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/__init__.py +1 -1
  6. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/_queries.py +18 -9
  7. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/_types.py +1 -1
  8. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/_validators.py +24 -0
  9. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/ajax.py +1 -1
  10. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/application.py +46 -7
  11. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/authentication.py +10 -6
  12. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/fields.py +52 -5
  13. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/forms.py +61 -16
  14. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/helpers.py +99 -16
  15. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/models.py +119 -118
  16. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/main.js +14 -0
  17. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/create.html +1 -1
  18. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/details.html +6 -6
  19. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/edit.html +1 -1
  20. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/list.html +10 -10
  21. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/widgets.py +30 -1
  22. {sqladmin-0.8.0 → sqladmin-0.10.0}/LICENSE.md +0 -0
  23. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/exceptions.py +0 -0
  24. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/formatters.py +0 -0
  25. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/pagination.py +0 -0
  26. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/py.typed +0 -0
  27. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
  28. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
  29. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/main.css +0 -0
  30. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/select2.min.css +0 -0
  31. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/solid.min.css +0 -0
  32. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/tabler.min.css +0 -0
  33. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
  34. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
  35. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/jquery.min.js +0 -0
  36. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/popper.min.js +0 -0
  37. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/select2.full.min.js +0 -0
  38. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/tabler.min.js +0 -0
  39. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/webfonts/fa-solid-900.ttf +0 -0
  40. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
  41. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/base.html +0 -0
  42. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/error.html +0 -0
  43. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/index.html +0 -0
  44. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/layout.html +0 -0
  45. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/login.html +0 -0
  46. {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/modals/delete.html +0 -0
@@ -11,3 +11,4 @@ htmlcov/
11
11
  coverage.xml
12
12
  examples/
13
13
  .vscode/
14
+ .uploads
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sqladmin
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: SQLAlchemy admin for FastAPI and Starlette
5
5
  Project-URL: Documentation, https://aminalaee.dev/sqladmin
6
6
  Project-URL: Issues, https://github.com/aminalaee/sqladmin/issues
7
7
  Project-URL: Source, https://github.com/aminalaee/sqladmin
8
8
  Author-email: Amin Alaee <mohammadamin.alaee@gmail.com>
9
+ License-Expression: BSD-3-Clause
9
10
  License-File: LICENSE.md
10
11
  Keywords: admin,fastapi,sqlalchemy,starlette
11
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 4 - Beta
12
13
  Classifier: Environment :: Web Environment
13
14
  Classifier: Intended Audience :: Developers
14
15
  Classifier: License :: OSI Approved :: BSD License
@@ -21,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.10
21
22
  Classifier: Programming Language :: Python :: 3.11
22
23
  Classifier: Topic :: Internet :: WWW/HTTP
23
24
  Requires-Python: >=3.7
24
- Requires-Dist: sqlalchemy<1.5,>=1.4
25
+ Requires-Dist: sqlalchemy>=1.4
25
26
  Requires-Dist: starlette[full]
26
27
  Requires-Dist: typing-extensions>=4.0; python_version < '3.8'
27
28
  Requires-Dist: wtforms<4,>=3
@@ -83,6 +84,11 @@ $ pip install sqladmin
83
84
 
84
85
  ---
85
86
 
87
+ ## Screenshots
88
+
89
+ <img width="1492" alt="sqladmin-1" src="https://user-images.githubusercontent.com/19784933/208232730-0114a155-2740-4e89-9d73-64a4e51a5cf5.png">
90
+ <img width="1492" alt="sqladmin-2" src="https://user-images.githubusercontent.com/19784933/208232731-6d783dde-b93e-41c0-911b-3d1c3c73f1d5.png">
91
+
86
92
  ## Quickstart
87
93
 
88
94
  Let's define an example SQLAlchemy model:
@@ -54,6 +54,11 @@ $ pip install sqladmin
54
54
 
55
55
  ---
56
56
 
57
+ ## Screenshots
58
+
59
+ <img width="1492" alt="sqladmin-1" src="https://user-images.githubusercontent.com/19784933/208232730-0114a155-2740-4e89-9d73-64a4e51a5cf5.png">
60
+ <img width="1492" alt="sqladmin-2" src="https://user-images.githubusercontent.com/19784933/208232731-6d783dde-b93e-41c0-911b-3d1c3c73f1d5.png">
61
+
57
62
  ## Quickstart
58
63
 
59
64
  Let's define an example SQLAlchemy model:
@@ -13,7 +13,7 @@ authors = [
13
13
  { name = "Amin Alaee", email = "mohammadamin.alaee@gmail.com" },
14
14
  ]
15
15
  classifiers = [
16
- "Development Status :: 3 - Alpha",
16
+ "Development Status :: 4 - Beta",
17
17
  "Programming Language :: Python",
18
18
  "Programming Language :: Python :: 3.7",
19
19
  "Programming Language :: Python :: 3.8",
@@ -29,7 +29,7 @@ classifiers = [
29
29
  dependencies = [
30
30
  "starlette[full]",
31
31
  "typing_extensions>=4.0;python_version < '3.8'",
32
- "sqlalchemy >=1.4, <1.5",
32
+ "sqlalchemy >=1.4",
33
33
  "wtforms >=3, <4",
34
34
  ]
35
35
  dynamic = ["version"]
@@ -53,38 +53,77 @@ exclude = [
53
53
  "tests/*",
54
54
  ]
55
55
 
56
- [tool.hatch.envs.default]
56
+ [tool.hatch.envs.test]
57
57
  dependencies = [
58
58
  "aiosqlite==0.17.0",
59
+ "arrow==1.2.3",
59
60
  "asyncpg==0.27.0",
60
- "autoflake==1.7.7",
61
61
  "babel==2.10.3",
62
- "black==22.10.0",
63
62
  "build==0.9.0",
63
+ "colour==0.1.5",
64
64
  "coverage==6.5.0",
65
65
  "email-validator==1.3.0",
66
66
  "greenlet==2.0.0",
67
67
  "httpx==0.23.0",
68
- "isort==5.10.1",
69
68
  "itsdangerous==2.1.2",
69
+ "phonenumbers==8.13.5",
70
+ "psycopg2-binary==2.9.5",
71
+ "pytest==7.2.0",
72
+ "python-dateutil==2.8.2",
73
+ "sqlalchemy_utils==0.38.3",
74
+ "sqlalchemy-fields",
75
+ ]
76
+
77
+ [tool.hatch.envs.lint]
78
+ dependencies = [
79
+ "black==22.10.0",
80
+ "isort==5.10.1",
81
+ "mypy==0.982",
82
+ "ruff==0.0.237",
83
+ "sqlalchemy~=1.4", # MyPy issues with SQLAlchemy V2
84
+ ]
85
+
86
+ [tool.hatch.envs.docs]
87
+ dependencies = [
70
88
  "mkdocs-material==8.5.7",
71
89
  "mkdocs==1.4.1",
72
90
  "mkdocstrings==0.18.1",
73
- "mypy==0.982",
74
- "psycopg2==2.9.5",
75
- "pytest==7.2.0",
76
- "sqlalchemy_utils==0.38.3",
77
- "sqlmodel==0.0.8",
78
91
  ]
79
- [tool.hatch.envs.default.scripts]
80
- check = "isort --check --project=sqladmin . && black --check . && mypy sqladmin"
81
- clean = "rm -r dist site"
82
- cov = "coverage report --show-missing --skip-covered --fail-under=99 && coverage xml"
83
- docs = "mkdocs serve"
84
- docs_build = "mkdocs build"
85
- docs_deploy = "mkdocs gh-deploy --force"
86
- lint = "autoflake --in-place --recursive .s && isort --project=sqladmin . && black ."
87
- test = "python -m coverage run --concurrency=thread,greenlet -m pytest"
92
+
93
+ [tool.hatch.envs.test.scripts]
94
+ cov = [
95
+ "coverage report --show-missing --skip-covered --fail-under=99",
96
+ "coverage xml",
97
+ ]
98
+ test = "coverage run -a --concurrency=thread,greenlet -m pytest"
99
+
100
+ [tool.hatch.envs.lint.scripts]
101
+ check = [
102
+ "ruff .",
103
+ "isort --check --project=sqladmin .",
104
+ "black --check .",
105
+ "mypy sqladmin",
106
+ ]
107
+ format = [
108
+ "isort --project=sqladmin .",
109
+ "black .",
110
+ "ruff --fix .",
111
+ ]
112
+
113
+ [tool.hatch.envs.docs.scripts]
114
+ build = "mkdocs build"
115
+ serve = "mkdocs serve --dev-addr localhost:8080"
116
+ deploy = "mkdocs gh-deploy --force"
117
+
118
+ [[tool.hatch.envs.test.matrix]]
119
+ sqlalchemy = ["1.4", "2.0"]
120
+
121
+ [tool.hatch.envs.test.overrides]
122
+ matrix.sqlalchemy.dependencies = [
123
+ { value = "sqlalchemy==1.4.41", if = ["1.4"] },
124
+ { value = "sqlmodel==0.0.8", if = ["1.4"] },
125
+ { value = "sqlalchemy==2.0", if = ["2.0"] },
126
+ ]
88
127
 
89
128
  [tool.mypy]
90
129
  disallow_untyped_defs = true
@@ -1,7 +1,7 @@
1
1
  from sqladmin.application import Admin, expose
2
2
  from sqladmin.models import BaseView, ModelAdmin, ModelView
3
3
 
4
- __version__ = "0.8.0"
4
+ __version__ = "0.10.0"
5
5
 
6
6
  __all__ = [
7
7
  "Admin",
@@ -1,4 +1,3 @@
1
- from re import L
2
1
  from typing import TYPE_CHECKING, Any, Dict, List
3
2
 
4
3
  import anyio
@@ -7,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
7
6
  from sqlalchemy.orm import Session, joinedload
8
7
  from sqlalchemy.sql.expression import Select
9
8
 
10
- from sqladmin._types import MODEL_ATTR_TYPE
9
+ from sqladmin._types import MODEL_PROPERTY
11
10
  from sqladmin.helpers import get_column_python_type, get_direction, get_primary_key
12
11
 
13
12
  if TYPE_CHECKING:
@@ -18,7 +17,7 @@ class Query:
18
17
  def __init__(self, model_view: "ModelView") -> None:
19
18
  self.model_view = model_view
20
19
 
21
- def _get_to_many_stmt(self, relation: MODEL_ATTR_TYPE, values: List[Any]) -> Select:
20
+ def _get_to_many_stmt(self, relation: MODEL_PROPERTY, values: List[Any]) -> Select:
22
21
  target = relation.mapper.class_
23
22
  target_pk = get_primary_key(target)
24
23
  target_pk_type = get_column_python_type(target_pk)
@@ -26,14 +25,14 @@ class Query:
26
25
  related_stmt = select(target).where(target_pk.in_(pk_values))
27
26
  return related_stmt
28
27
 
29
- def _get_to_one_stmt(self, relation: MODEL_ATTR_TYPE, value: Any) -> Select:
28
+ def _get_to_one_stmt(self, relation: MODEL_PROPERTY, value: Any) -> Select:
30
29
  target = relation.mapper.class_
31
30
  target_pk = get_primary_key(target)
32
31
  target_pk_type = get_column_python_type(target_pk)
33
32
  related_stmt = select(target).where(target_pk == target_pk_type(value))
34
33
  return related_stmt
35
34
 
36
- def _set_many_to_one(self, obj: Any, relation: MODEL_ATTR_TYPE, value: Any) -> Any:
35
+ def _set_many_to_one(self, obj: Any, relation: MODEL_PROPERTY, value: Any) -> Any:
37
36
  fk = relation.local_remote_pairs[0][0]
38
37
  fk_type = get_column_python_type(fk)
39
38
  setattr(obj, fk.name, fk_type(value))
@@ -46,7 +45,12 @@ class Query:
46
45
 
47
46
  if not value:
48
47
  # Set falsy values to None, if column is Nullable
49
- if not relation and column.nullable and value is not False:
48
+ if (
49
+ not relation
50
+ and column.nullable
51
+ and isinstance(value, bool)
52
+ and value is not False
53
+ ):
50
54
  value = None
51
55
 
52
56
  setattr(obj, key, value)
@@ -79,7 +83,12 @@ class Query:
79
83
 
80
84
  if not value:
81
85
  # Set falsy values to None, if column is Nullable
82
- if not relation and column.nullable and value is not False:
86
+ if (
87
+ not relation
88
+ and column.nullable
89
+ and isinstance(value, bool)
90
+ and value is not False
91
+ ):
83
92
  value = None
84
93
 
85
94
  setattr(obj, key, value)
@@ -119,8 +128,8 @@ class Query:
119
128
  pk = get_column_python_type(self.model_view.pk_column)(pk)
120
129
  stmt = select(self.model_view.model).where(self.model_view.pk_column == pk)
121
130
 
122
- for relation in self.model_view._relations:
123
- stmt = stmt.options(joinedload(relation.key))
131
+ for relation in self.model_view._relation_attrs:
132
+ stmt = stmt.options(joinedload(relation))
124
133
 
125
134
  async with self.model_view.sessionmaker(expire_on_commit=False) as session:
126
135
  result = await session.execute(stmt)
@@ -4,5 +4,5 @@ from sqlalchemy.engine import Engine
4
4
  from sqlalchemy.ext.asyncio import AsyncEngine
5
5
  from sqlalchemy.orm import ColumnProperty, RelationshipProperty
6
6
 
7
- MODEL_ATTR_TYPE = Union[ColumnProperty, RelationshipProperty]
7
+ MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty]
8
8
  ENGINE_TYPE = Union[Engine, AsyncEngine]
@@ -15,6 +15,30 @@ class CurrencyValidator:
15
15
  raise ValidationError("Not a valid ISO currency code (e.g. USD, EUR, CNY).")
16
16
 
17
17
 
18
+ class PhoneNumberValidator:
19
+ """Form validator for sqlalchemy_utils PhoneNumberType."""
20
+
21
+ def __call__(self, form: Form, field: Field) -> None:
22
+ from sqlalchemy_utils import PhoneNumber, PhoneNumberParseException
23
+
24
+ try:
25
+ PhoneNumber(field.data)
26
+ except PhoneNumberParseException:
27
+ raise ValidationError("Not a valid phone number.")
28
+
29
+
30
+ class ColorValidator:
31
+ """General Color validator using `colour` package."""
32
+
33
+ def __call__(self, form: Form, field: Field) -> None:
34
+ from colour import Color
35
+
36
+ try:
37
+ Color(field.data)
38
+ except ValueError:
39
+ raise ValidationError('Not a valid color (e.g. "red", "#f00", "#ff0000").')
40
+
41
+
18
42
  class TimezoneValidator:
19
43
  """Form validator for sqlalchemy_utils TimezoneType."""
20
44
 
@@ -1,6 +1,6 @@
1
1
  from typing import TYPE_CHECKING, Any, Dict, List
2
2
 
3
- from sqlalchemy import String, cast, inspect, or_, select, text
3
+ from sqlalchemy import String, cast, inspect, or_, select
4
4
 
5
5
  from sqladmin.helpers import get_primary_key
6
6
 
@@ -1,12 +1,22 @@
1
1
  import inspect
2
- from typing import Any, Callable, List, Optional, Sequence, Type, Union, no_type_check
2
+ from typing import (
3
+ Any,
4
+ Callable,
5
+ Dict,
6
+ List,
7
+ Optional,
8
+ Sequence,
9
+ Type,
10
+ Union,
11
+ no_type_check,
12
+ )
3
13
 
4
14
  from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader
5
15
  from sqlalchemy.engine import Engine
6
16
  from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
7
17
  from sqlalchemy.orm import Session, sessionmaker
8
18
  from starlette.applications import Starlette
9
- from starlette.datastructures import FormData
19
+ from starlette.datastructures import URL, FormData, UploadFile
10
20
  from starlette.exceptions import HTTPException
11
21
  from starlette.middleware import Middleware
12
22
  from starlette.requests import Request
@@ -404,7 +414,7 @@ class Admin(BaseAdminView):
404
414
 
405
415
  await model_view.delete_model(model)
406
416
 
407
- return Response(content=request.url_for("admin:list", identity=identity))
417
+ return Response(content=str(request.url_for("admin:list", identity=identity)))
408
418
 
409
419
  @login_required
410
420
  async def create(self, request: Request) -> Response:
@@ -416,7 +426,7 @@ class Admin(BaseAdminView):
416
426
  model_view = self._find_model_view(identity)
417
427
 
418
428
  Form = await model_view.scaffold_form()
419
- form_data = await request.form()
429
+ form_data = await self._handle_form_data(request)
420
430
  form = Form(form_data)
421
431
 
422
432
  context = {
@@ -472,7 +482,7 @@ class Admin(BaseAdminView):
472
482
  if request.method == "GET":
473
483
  return self.templates.TemplateResponse(model_view.edit_template, context)
474
484
 
475
- form_data = await request.form()
485
+ form_data = await self._handle_form_data(request, model)
476
486
  form = Form(form_data)
477
487
  if not form.validate():
478
488
  context["form"] = form
@@ -559,8 +569,11 @@ class Admin(BaseAdminView):
559
569
 
560
570
  def get_save_redirect_url(
561
571
  self, request: Request, form: FormData, model_view: ModelView, obj: Any
562
- ) -> str:
563
- """Get the redirect URL after a save action is triggered from create/edit page."""
572
+ ) -> Union[str, URL]:
573
+ """
574
+ Get the redirect URL after a save action
575
+ which is triggered from create/edit page.
576
+ """
564
577
 
565
578
  identity = request.path_params["identity"]
566
579
  pk = getattr(obj, model_view.pk_column.name)
@@ -573,6 +586,32 @@ class Admin(BaseAdminView):
573
586
  return request.url_for("admin:edit", identity=identity, pk=pk)
574
587
  return request.url_for("admin:create", identity=identity)
575
588
 
589
+ async def _handle_form_data(self, request: Request, obj: Any = None) -> FormData:
590
+ """
591
+ Handle form data and modify in case of UplaodFile.
592
+ This is needed since in edit page
593
+ there's no way to show current file of object.
594
+ """
595
+
596
+ form = await request.form()
597
+ form_data: Dict[str, Any] = {}
598
+
599
+ for key, value in form.items():
600
+ if not isinstance(value, UploadFile):
601
+ form_data[key] = value
602
+ continue
603
+
604
+ should_clear = form.get(key + "_checkbox")
605
+ empty_upload = len(await value.read(1)) != 1
606
+ if should_clear:
607
+ form_data[key] = None
608
+ elif empty_upload and getattr(obj, key):
609
+ f = getattr(obj, key) # In case of update, imitate UploadFile
610
+ form_data[key] = UploadFile(filename=f.name, file=f.open())
611
+ else:
612
+ form_data[key] = value
613
+ return FormData(form_data)
614
+
576
615
 
577
616
  def expose(
578
617
  path: str,
@@ -1,10 +1,10 @@
1
1
  import functools
2
- from typing import Any, Callable
2
+ from typing import Any, Callable, Optional
3
3
 
4
4
  from starlette.middleware import Middleware
5
5
  from starlette.middleware.sessions import SessionMiddleware
6
6
  from starlette.requests import Request
7
- from starlette.responses import RedirectResponse
7
+ from starlette.responses import Response
8
8
 
9
9
 
10
10
  class AuthenticationBackend:
@@ -31,10 +31,14 @@ class AuthenticationBackend:
31
31
  """
32
32
  raise NotImplementedError()
33
33
 
34
- async def authenticate(self, request: Request) -> bool:
34
+ async def authenticate(self, request: Request) -> Optional[Response]:
35
35
  """Implement authenticate logic here.
36
36
  This method will be called for each incoming request
37
37
  to validate the authentication.
38
+
39
+ If the request is authenticated, this method should return `None` or do nothing.
40
+ Otherwise it should return a `Response` object,
41
+ like a redirect to the login page or SSO page.
38
42
  """
39
43
  raise NotImplementedError()
40
44
 
@@ -49,9 +53,9 @@ def login_required(func: Callable[..., Any]) -> Callable[..., Any]:
49
53
  admin, request = args[0], args[1]
50
54
  auth_backend = admin.authentication_backend
51
55
  if auth_backend is not None:
52
- is_authenticated = await auth_backend.authenticate(request)
53
- if not is_authenticated:
54
- return RedirectResponse(request.url_for("admin:login"), status_code=302)
56
+ response = await auth_backend.authenticate(request)
57
+ if response and isinstance(response, Response):
58
+ return response
55
59
 
56
60
  return await func(*args, **kwargs)
57
61
 
@@ -3,16 +3,18 @@ import operator
3
3
  from typing import Any, Callable, Generator, List, Optional, Set, Tuple, Union
4
4
 
5
5
  from sqlalchemy import inspect
6
- from wtforms import Form, SelectFieldBase, ValidationError, fields, widgets
6
+ from wtforms import Form, ValidationError, fields, widgets
7
7
 
8
8
  from sqladmin import widgets as sqladmin_widgets
9
9
  from sqladmin.ajax import QueryAjaxModelLoader
10
+ from sqladmin.helpers import parse_interval
10
11
 
11
12
  __all__ = [
12
13
  "AjaxSelectField",
13
14
  "AjaxSelectMultipleField",
14
15
  "DateField",
15
16
  "DateTimeField",
17
+ "IntervalField",
16
18
  "JSONField",
17
19
  "QuerySelectField",
18
20
  "QuerySelectMultipleField",
@@ -46,6 +48,22 @@ class TimeField(fields.TimeField):
46
48
  widget = sqladmin_widgets.TimePickerWidget()
47
49
 
48
50
 
51
+ class IntervalField(fields.StringField):
52
+ """
53
+ A text field which stores a `datetime.timedelta` object.
54
+ """
55
+
56
+ def process_formdata(self, valuelist: List[str]) -> None:
57
+ if not valuelist:
58
+ return
59
+
60
+ interval = parse_interval(valuelist[0])
61
+ if not interval:
62
+ raise ValueError("Invalide timedelta format.")
63
+
64
+ self.data = interval
65
+
66
+
49
67
  class SelectField(fields.SelectField):
50
68
  def __init__(
51
69
  self,
@@ -166,7 +184,11 @@ class QuerySelectField(fields.SelectFieldBase):
166
184
  yield ("__None", self.blank_text, self.data is None)
167
185
 
168
186
  if self.data:
169
- primary_key = str(inspect(self.data).identity[0])
187
+ primary_key = (
188
+ self.data
189
+ if isinstance(self.data, str)
190
+ else str(inspect(self.data).identity[0])
191
+ )
170
192
  else:
171
193
  primary_key = None
172
194
 
@@ -251,7 +273,11 @@ class QuerySelectMultipleField(QuerySelectField):
251
273
 
252
274
  def iter_choices(self) -> Generator[Tuple[str, Any, bool], None, None]:
253
275
  if self.data is not None:
254
- primary_keys = [str(inspect(m).identity[0]) for m in self.data]
276
+ primary_keys = (
277
+ self.data
278
+ if all(isinstance(d, str) for d in self.data)
279
+ else [str(inspect(m).identity[0]) for m in self.data]
280
+ )
255
281
  for pk, label in self._select_data:
256
282
  yield (pk, self.get_label(label), pk in primary_keys)
257
283
 
@@ -268,7 +294,7 @@ class QuerySelectMultipleField(QuerySelectField):
268
294
  raise ValidationError(self.gettext("Not a valid choice"))
269
295
 
270
296
 
271
- class AjaxSelectField(SelectFieldBase):
297
+ class AjaxSelectField(fields.SelectFieldBase):
272
298
  widget = sqladmin_widgets.AjaxSelect2Widget()
273
299
  separator = ","
274
300
 
@@ -310,7 +336,7 @@ class AjaxSelectField(SelectFieldBase):
310
336
  raise ValidationError("Not a valid choice")
311
337
 
312
338
 
313
- class AjaxSelectMultipleField(SelectFieldBase):
339
+ class AjaxSelectMultipleField(fields.SelectFieldBase):
314
340
  widget = sqladmin_widgets.AjaxSelect2Widget(multiple=True)
315
341
  separator = ","
316
342
 
@@ -349,3 +375,24 @@ class AjaxSelectMultipleField(SelectFieldBase):
349
375
  for field in valuelist:
350
376
  for n in field.split(self.separator):
351
377
  self._formdata.add(n)
378
+
379
+
380
+ class Select2TagsField(fields.SelectField):
381
+ widget = sqladmin_widgets.Select2TagsWidget()
382
+
383
+ def pre_validate(self, form: Form) -> None:
384
+ ...
385
+
386
+ def process_formdata(self, valuelist: list) -> None:
387
+ self.data = valuelist
388
+
389
+ def process_data(self, value: Optional[list]) -> None:
390
+ self.data = value or []
391
+
392
+
393
+ class FileField(fields.FileField):
394
+ """
395
+ File field which is clearable.
396
+ """
397
+
398
+ widget = sqladmin_widgets.FileInputWidget()