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.
- {sqladmin-0.8.0 → sqladmin-0.10.0}/.gitignore +1 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/PKG-INFO +9 -3
- {sqladmin-0.8.0 → sqladmin-0.10.0}/README.md +5 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/pyproject.toml +59 -20
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/__init__.py +1 -1
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/_queries.py +18 -9
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/_types.py +1 -1
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/_validators.py +24 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/ajax.py +1 -1
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/application.py +46 -7
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/authentication.py +10 -6
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/fields.py +52 -5
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/forms.py +61 -16
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/helpers.py +99 -16
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/models.py +119 -118
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/main.js +14 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/create.html +1 -1
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/details.html +6 -6
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/edit.html +1 -1
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/list.html +10 -10
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/widgets.py +30 -1
- {sqladmin-0.8.0 → sqladmin-0.10.0}/LICENSE.md +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/exceptions.py +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/formatters.py +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/pagination.py +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/py.typed +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/main.css +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/select2.min.css +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/solid.min.css +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/css/tabler.min.css +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/jquery.min.js +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/popper.min.js +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/select2.full.min.js +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/js/tabler.min.js +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/webfonts/fa-solid-900.ttf +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/base.html +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/error.html +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/index.html +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/layout.html +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/login.html +0 -0
- {sqladmin-0.8.0 → sqladmin-0.10.0}/sqladmin/templates/modals/delete.html +0 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: sqladmin
|
|
3
|
-
Version: 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 ::
|
|
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
|
|
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 ::
|
|
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
|
|
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.
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
123
|
-
stmt = stmt.options(joinedload(relation
|
|
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
|
-
|
|
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,12 +1,22 @@
|
|
|
1
1
|
import inspect
|
|
2
|
-
from typing import
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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) ->
|
|
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
|
-
|
|
53
|
-
if
|
|
54
|
-
return
|
|
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,
|
|
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 =
|
|
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 =
|
|
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()
|