sqladmin 0.9.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.9.0 → sqladmin-0.10.0}/.gitignore +1 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/PKG-INFO +1 -1
- {sqladmin-0.9.0 → sqladmin-0.10.0}/pyproject.toml +4 -3
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/__init__.py +1 -1
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/_queries.py +12 -2
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/application.py +42 -6
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/authentication.py +10 -6
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/fields.py +26 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/forms.py +14 -8
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/helpers.py +90 -1
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/models.py +16 -32
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/create.html +1 -1
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/edit.html +1 -1
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/widgets.py +20 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/LICENSE.md +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/README.md +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/_types.py +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/_validators.py +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/ajax.py +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/exceptions.py +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/formatters.py +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/pagination.py +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/py.typed +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/css/main.css +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/css/select2.min.css +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/css/solid.min.css +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/css/tabler.min.css +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/jquery.min.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/main.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/popper.min.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/select2.full.min.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/js/tabler.min.js +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/webfonts/fa-solid-900.ttf +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/base.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/details.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/error.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/index.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/layout.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/list.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/login.html +0 -0
- {sqladmin-0.9.0 → sqladmin-0.10.0}/sqladmin/templates/modals/delete.html +0 -0
|
@@ -70,7 +70,8 @@ dependencies = [
|
|
|
70
70
|
"psycopg2-binary==2.9.5",
|
|
71
71
|
"pytest==7.2.0",
|
|
72
72
|
"python-dateutil==2.8.2",
|
|
73
|
-
"sqlalchemy_utils==0.38.3",
|
|
73
|
+
"sqlalchemy_utils==0.38.3",
|
|
74
|
+
"sqlalchemy-fields",
|
|
74
75
|
]
|
|
75
76
|
|
|
76
77
|
[tool.hatch.envs.lint]
|
|
@@ -119,9 +120,9 @@ sqlalchemy = ["1.4", "2.0"]
|
|
|
119
120
|
|
|
120
121
|
[tool.hatch.envs.test.overrides]
|
|
121
122
|
matrix.sqlalchemy.dependencies = [
|
|
122
|
-
{ value = "sqlalchemy
|
|
123
|
+
{ value = "sqlalchemy==1.4.41", if = ["1.4"] },
|
|
123
124
|
{ value = "sqlmodel==0.0.8", if = ["1.4"] },
|
|
124
|
-
{ value = "sqlalchemy
|
|
125
|
+
{ value = "sqlalchemy==2.0", if = ["2.0"] },
|
|
125
126
|
]
|
|
126
127
|
|
|
127
128
|
[tool.mypy]
|
|
@@ -45,7 +45,12 @@ class Query:
|
|
|
45
45
|
|
|
46
46
|
if not value:
|
|
47
47
|
# Set falsy values to None, if column is Nullable
|
|
48
|
-
if
|
|
48
|
+
if (
|
|
49
|
+
not relation
|
|
50
|
+
and column.nullable
|
|
51
|
+
and isinstance(value, bool)
|
|
52
|
+
and value is not False
|
|
53
|
+
):
|
|
49
54
|
value = None
|
|
50
55
|
|
|
51
56
|
setattr(obj, key, value)
|
|
@@ -78,7 +83,12 @@ class Query:
|
|
|
78
83
|
|
|
79
84
|
if not value:
|
|
80
85
|
# Set falsy values to None, if column is Nullable
|
|
81
|
-
if
|
|
86
|
+
if (
|
|
87
|
+
not relation
|
|
88
|
+
and column.nullable
|
|
89
|
+
and isinstance(value, bool)
|
|
90
|
+
and value is not False
|
|
91
|
+
):
|
|
82
92
|
value = None
|
|
83
93
|
|
|
84
94
|
setattr(obj, key, value)
|
|
@@ -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,7 +569,7 @@ 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:
|
|
572
|
+
) -> Union[str, URL]:
|
|
563
573
|
"""
|
|
564
574
|
Get the redirect URL after a save action
|
|
565
575
|
which is triggered from create/edit page.
|
|
@@ -576,6 +586,32 @@ class Admin(BaseAdminView):
|
|
|
576
586
|
return request.url_for("admin:edit", identity=identity, pk=pk)
|
|
577
587
|
return request.url_for("admin:create", identity=identity)
|
|
578
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
|
+
|
|
579
615
|
|
|
580
616
|
def expose(
|
|
581
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
|
|
|
@@ -7,12 +7,14 @@ 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,
|
|
@@ -370,3 +388,11 @@ class Select2TagsField(fields.SelectField):
|
|
|
370
388
|
|
|
371
389
|
def process_data(self, value: Optional[list]) -> None:
|
|
372
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()
|
|
@@ -47,6 +47,8 @@ from sqladmin.fields import (
|
|
|
47
47
|
AjaxSelectMultipleField,
|
|
48
48
|
DateField,
|
|
49
49
|
DateTimeField,
|
|
50
|
+
FileField,
|
|
51
|
+
IntervalField,
|
|
50
52
|
JSONField,
|
|
51
53
|
QuerySelectField,
|
|
52
54
|
QuerySelectMultipleField,
|
|
@@ -414,10 +416,12 @@ class ModelConverter(ModelConverterBase):
|
|
|
414
416
|
) -> UnboundField:
|
|
415
417
|
return JSONField(**kwargs)
|
|
416
418
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
419
|
+
@converts("Interval")
|
|
420
|
+
def conv_interval(
|
|
421
|
+
self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any]
|
|
422
|
+
) -> UnboundField:
|
|
423
|
+
kwargs["render_kw"]["placeholder"] = "Like: 1 day 1:25:33.652"
|
|
424
|
+
return IntervalField(**kwargs)
|
|
421
425
|
|
|
422
426
|
@converts(
|
|
423
427
|
"sqlalchemy.dialects.postgresql.base.INET",
|
|
@@ -427,7 +431,6 @@ class ModelConverter(ModelConverterBase):
|
|
|
427
431
|
def conv_ip_address(
|
|
428
432
|
self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any]
|
|
429
433
|
) -> UnboundField:
|
|
430
|
-
kwargs.setdefault("label", "IP Address")
|
|
431
434
|
kwargs.setdefault("validators", [])
|
|
432
435
|
kwargs["validators"].append(validators.IPAddress(ipv4=True, ipv6=True))
|
|
433
436
|
return StringField(**kwargs)
|
|
@@ -439,7 +442,6 @@ class ModelConverter(ModelConverterBase):
|
|
|
439
442
|
def conv_mac_address(
|
|
440
443
|
self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any]
|
|
441
444
|
) -> UnboundField:
|
|
442
|
-
kwargs.setdefault("label", "MAC Address")
|
|
443
445
|
kwargs.setdefault("validators", [])
|
|
444
446
|
kwargs["validators"].append(validators.MacAddress())
|
|
445
447
|
return StringField(**kwargs)
|
|
@@ -452,7 +454,6 @@ class ModelConverter(ModelConverterBase):
|
|
|
452
454
|
def conv_uuid(
|
|
453
455
|
self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any]
|
|
454
456
|
) -> UnboundField:
|
|
455
|
-
kwargs.setdefault("label", "UUID")
|
|
456
457
|
kwargs.setdefault("validators", [])
|
|
457
458
|
kwargs["validators"].append(validators.UUID())
|
|
458
459
|
return StringField(**kwargs)
|
|
@@ -469,7 +470,6 @@ class ModelConverter(ModelConverterBase):
|
|
|
469
470
|
def conv_email(
|
|
470
471
|
self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any]
|
|
471
472
|
) -> UnboundField:
|
|
472
|
-
kwargs.setdefault("label", "Email")
|
|
473
473
|
kwargs.setdefault("validators", [])
|
|
474
474
|
kwargs["validators"].append(validators.Email())
|
|
475
475
|
return StringField(**kwargs)
|
|
@@ -516,6 +516,12 @@ class ModelConverter(ModelConverterBase):
|
|
|
516
516
|
kwargs["validators"].append(ColorValidator())
|
|
517
517
|
return StringField(**kwargs)
|
|
518
518
|
|
|
519
|
+
@converts("sqlalchemy_fields.types.file.FileType")
|
|
520
|
+
def conv_file(
|
|
521
|
+
self, model: type, prop: ColumnProperty, kwargs: Dict[str, Any]
|
|
522
|
+
) -> UnboundField:
|
|
523
|
+
return FileField(**kwargs)
|
|
524
|
+
|
|
519
525
|
@converts("ONETOONE")
|
|
520
526
|
def conv_one_to_one(
|
|
521
527
|
self, model: type, prop: RelationshipProperty, kwargs: Dict[str, Any]
|
|
@@ -3,12 +3,28 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
import unicodedata
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
Callable,
|
|
11
|
+
Dict,
|
|
12
|
+
Generator,
|
|
13
|
+
List,
|
|
14
|
+
Optional,
|
|
15
|
+
TypeVar,
|
|
16
|
+
Union,
|
|
17
|
+
)
|
|
7
18
|
|
|
8
19
|
from sqlalchemy import Column, inspect
|
|
9
20
|
from sqlalchemy.orm import RelationshipProperty
|
|
21
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
10
22
|
|
|
11
23
|
from sqladmin._types import MODEL_PROPERTY
|
|
24
|
+
from sqladmin.exceptions import InvalidColumnError
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from sqladmin.models import ModelView
|
|
12
28
|
|
|
13
29
|
T = TypeVar("T")
|
|
14
30
|
|
|
@@ -28,6 +44,44 @@ _windows_device_files = (
|
|
|
28
44
|
"NUL",
|
|
29
45
|
)
|
|
30
46
|
|
|
47
|
+
standard_duration_re = re.compile(
|
|
48
|
+
r"^"
|
|
49
|
+
r"(?:(?P<days>-?\d+) (days?, )?)?"
|
|
50
|
+
r"(?P<sign>-?)"
|
|
51
|
+
r"((?:(?P<hours>\d+):)(?=\d+:\d+))?"
|
|
52
|
+
r"(?:(?P<minutes>\d+):)?"
|
|
53
|
+
r"(?P<seconds>\d+)"
|
|
54
|
+
r"(?:[\.,](?P<microseconds>\d{1,6})\d{0,6})?"
|
|
55
|
+
r"$"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Support the sections of ISO 8601 date representation that are accepted by timedelta
|
|
59
|
+
iso8601_duration_re = re.compile(
|
|
60
|
+
r"^(?P<sign>[-+]?)"
|
|
61
|
+
r"P"
|
|
62
|
+
r"(?:(?P<days>\d+([\.,]\d+)?)D)?"
|
|
63
|
+
r"(?:T"
|
|
64
|
+
r"(?:(?P<hours>\d+([\.,]\d+)?)H)?"
|
|
65
|
+
r"(?:(?P<minutes>\d+([\.,]\d+)?)M)?"
|
|
66
|
+
r"(?:(?P<seconds>\d+([\.,]\d+)?)S)?"
|
|
67
|
+
r")?"
|
|
68
|
+
r"$"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Support PostgreSQL's day-time interval format, e.g. "3 days 04:05:06". The
|
|
72
|
+
# year-month and mixed intervals cannot be converted to a timedelta and thus
|
|
73
|
+
# aren't accepted.
|
|
74
|
+
postgres_interval_re = re.compile(
|
|
75
|
+
r"^"
|
|
76
|
+
r"(?:(?P<days>-?\d+) (days? ?))?"
|
|
77
|
+
r"(?:(?P<sign>[-+])?"
|
|
78
|
+
r"(?P<hours>\d+):"
|
|
79
|
+
r"(?P<minutes>\d\d):"
|
|
80
|
+
r"(?P<seconds>\d\d)"
|
|
81
|
+
r"(?:\.(?P<microseconds>\d{1,6}))?"
|
|
82
|
+
r")?$"
|
|
83
|
+
)
|
|
84
|
+
|
|
31
85
|
|
|
32
86
|
def prettify_class_name(name: str) -> str:
|
|
33
87
|
return re.sub(r"(?<=.)([A-Z])", r" \1", name)
|
|
@@ -138,3 +192,38 @@ def get_column_python_type(column: Column) -> type:
|
|
|
138
192
|
|
|
139
193
|
def is_relationship(prop: MODEL_PROPERTY) -> bool:
|
|
140
194
|
return isinstance(prop, RelationshipProperty)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_interval(value: str) -> Optional[timedelta]:
|
|
198
|
+
match = (
|
|
199
|
+
standard_duration_re.match(value)
|
|
200
|
+
or iso8601_duration_re.match(value)
|
|
201
|
+
or postgres_interval_re.match(value)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if not match:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
kw: Dict[str, Any] = match.groupdict()
|
|
208
|
+
sign = -1 if kw.pop("sign", "+") == "-" else 1
|
|
209
|
+
if kw.get("microseconds"):
|
|
210
|
+
kw["microseconds"] = kw["microseconds"].ljust(6, "0")
|
|
211
|
+
kw = {k: float(v.replace(",", ".")) for k, v in kw.items() if v is not None}
|
|
212
|
+
days = timedelta(kw.pop("days", 0.0) or 0.0)
|
|
213
|
+
if match.re == iso8601_duration_re:
|
|
214
|
+
days *= sign
|
|
215
|
+
return days + sign * timedelta(**kw)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def map_attr_to_prop(
|
|
219
|
+
attr: Union[str, InstrumentedAttribute], model_admin: "ModelView"
|
|
220
|
+
) -> MODEL_PROPERTY:
|
|
221
|
+
if isinstance(attr, InstrumentedAttribute):
|
|
222
|
+
attr = attr.prop.key
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
return model_admin._props[attr]
|
|
226
|
+
except KeyError:
|
|
227
|
+
raise InvalidColumnError(
|
|
228
|
+
f"Model '{model_admin.model.__name__}' has no attribute '{attr}'."
|
|
229
|
+
)
|
|
@@ -28,6 +28,7 @@ from sqlalchemy.orm import (
|
|
|
28
28
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
29
29
|
from sqlalchemy.sql.elements import ClauseElement
|
|
30
30
|
from sqlalchemy.sql.expression import Select, select
|
|
31
|
+
from starlette.datastructures import URL
|
|
31
32
|
from starlette.requests import Request
|
|
32
33
|
from starlette.responses import StreamingResponse
|
|
33
34
|
from starlette.templating import Jinja2Templates
|
|
@@ -36,13 +37,14 @@ from wtforms import Field, Form
|
|
|
36
37
|
from sqladmin._queries import Query
|
|
37
38
|
from sqladmin._types import ENGINE_TYPE, MODEL_PROPERTY
|
|
38
39
|
from sqladmin.ajax import create_ajax_loader
|
|
39
|
-
from sqladmin.exceptions import
|
|
40
|
+
from sqladmin.exceptions import InvalidModelError
|
|
40
41
|
from sqladmin.formatters import BASE_FORMATTERS
|
|
41
42
|
from sqladmin.forms import ModelConverter, ModelConverterBase, get_model_form
|
|
42
43
|
from sqladmin.helpers import (
|
|
43
44
|
Writer,
|
|
44
45
|
get_column_python_type,
|
|
45
46
|
get_primary_key,
|
|
47
|
+
map_attr_to_prop,
|
|
46
48
|
prettify_class_name,
|
|
47
49
|
secure_filename,
|
|
48
50
|
slugify_class_name,
|
|
@@ -691,13 +693,13 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
691
693
|
|
|
692
694
|
column_formatters = getattr(self, "column_formatters", {})
|
|
693
695
|
self._list_formatters = {
|
|
694
|
-
|
|
696
|
+
map_attr_to_prop(attr, self): formatter
|
|
695
697
|
for (attr, formatter) in column_formatters.items()
|
|
696
698
|
}
|
|
697
699
|
|
|
698
700
|
column_formatters_detail = getattr(self, "column_formatters_detail", {})
|
|
699
701
|
self._detail_formatters = {
|
|
700
|
-
|
|
702
|
+
map_attr_to_prop(attr, self): formatter
|
|
701
703
|
for (attr, formatter) in column_formatters_detail.items()
|
|
702
704
|
}
|
|
703
705
|
|
|
@@ -711,7 +713,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
711
713
|
]
|
|
712
714
|
|
|
713
715
|
self._sort_fields = [
|
|
714
|
-
|
|
716
|
+
map_attr_to_prop(attr, self).key for attr in self.column_sortable_list
|
|
715
717
|
]
|
|
716
718
|
|
|
717
719
|
self._form_ajax_refs = {}
|
|
@@ -733,7 +735,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
733
735
|
else:
|
|
734
736
|
return await anyio.to_thread.run_sync(self._run_query_sync, stmt)
|
|
735
737
|
|
|
736
|
-
def _url_for_details(self, request: Request, obj: Any) -> str:
|
|
738
|
+
def _url_for_details(self, request: Request, obj: Any) -> Union[str, URL]:
|
|
737
739
|
pk = getattr(obj, self.pk_column.name)
|
|
738
740
|
return request.url_for(
|
|
739
741
|
"admin:details",
|
|
@@ -741,7 +743,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
741
743
|
pk=pk,
|
|
742
744
|
)
|
|
743
745
|
|
|
744
|
-
def _url_for_edit(self, request: Request, obj: Any) -> str:
|
|
746
|
+
def _url_for_edit(self, request: Request, obj: Any) -> Union[str, URL]:
|
|
745
747
|
pk = getattr(obj, self.pk_column.name)
|
|
746
748
|
return request.url_for(
|
|
747
749
|
"admin:edit",
|
|
@@ -755,11 +757,11 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
755
757
|
url = request.url_for(
|
|
756
758
|
"admin:delete", identity=slugify_class_name(obj.__class__.__name__)
|
|
757
759
|
)
|
|
758
|
-
return url + "?" + query_params
|
|
760
|
+
return str(url) + "?" + query_params
|
|
759
761
|
|
|
760
762
|
def _url_for_details_with_prop(
|
|
761
763
|
self, request: Request, obj: Any, prop: RelationshipProperty
|
|
762
|
-
) -> str:
|
|
764
|
+
) -> Union[str, URL]:
|
|
763
765
|
target = getattr(obj, prop.key)
|
|
764
766
|
if target is None:
|
|
765
767
|
return ""
|
|
@@ -889,19 +891,6 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
889
891
|
formatted_value = formatter(obj, prop)
|
|
890
892
|
return value, formatted_value
|
|
891
893
|
|
|
892
|
-
def _attr_to_prop(self, attr: Union[str, InstrumentedAttribute]) -> MODEL_PROPERTY:
|
|
893
|
-
if isinstance(attr, str):
|
|
894
|
-
key = attr
|
|
895
|
-
else:
|
|
896
|
-
key = attr.prop.key
|
|
897
|
-
|
|
898
|
-
if key in self._props:
|
|
899
|
-
return self._props[key]
|
|
900
|
-
|
|
901
|
-
raise InvalidColumnError(
|
|
902
|
-
f"Model '{self.model.__name__}' has no attribute '{key}'."
|
|
903
|
-
)
|
|
904
|
-
|
|
905
894
|
def _build_column_list(
|
|
906
895
|
self,
|
|
907
896
|
defaults: List[MODEL_PROPERTY],
|
|
@@ -912,9 +901,9 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
912
901
|
for any sequence of inclusions or exclusions.
|
|
913
902
|
"""
|
|
914
903
|
if include:
|
|
915
|
-
props = [
|
|
904
|
+
props = [map_attr_to_prop(prop, self) for prop in include]
|
|
916
905
|
elif exclude:
|
|
917
|
-
exclude_props = {
|
|
906
|
+
exclude_props = {map_attr_to_prop(prop, self) for prop in exclude}
|
|
918
907
|
props = [prop for prop in self._props if prop not in exclude_props]
|
|
919
908
|
else:
|
|
920
909
|
props = defaults
|
|
@@ -951,11 +940,6 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
951
940
|
form_columns = getattr(self, "form_columns", None)
|
|
952
941
|
form_excluded_columns = getattr(self, "form_excluded_columns", None)
|
|
953
942
|
|
|
954
|
-
default = []
|
|
955
|
-
for prop in self._props:
|
|
956
|
-
if prop in self._relation_props or prop in self._column_props:
|
|
957
|
-
default.append(prop)
|
|
958
|
-
|
|
959
943
|
return self._build_column_list(
|
|
960
944
|
include=form_columns,
|
|
961
945
|
exclude=form_excluded_columns,
|
|
@@ -991,7 +975,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
991
975
|
self,
|
|
992
976
|
) -> Dict[MODEL_PROPERTY, str]:
|
|
993
977
|
return {
|
|
994
|
-
|
|
978
|
+
map_attr_to_prop(column_label, self): value
|
|
995
979
|
for column_label, value in self.column_labels.items()
|
|
996
980
|
}
|
|
997
981
|
|
|
@@ -1005,12 +989,12 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1005
989
|
return await Query(self).update(pk, data)
|
|
1006
990
|
|
|
1007
991
|
async def on_model_delete(self, model: Any) -> None:
|
|
1008
|
-
"""Perform some actions before a model is
|
|
992
|
+
"""Perform some actions before a model is deleted.
|
|
1009
993
|
By default does nothing.
|
|
1010
994
|
"""
|
|
1011
995
|
|
|
1012
996
|
async def after_model_delete(self, model: Any) -> None:
|
|
1013
|
-
"""Perform some actions
|
|
997
|
+
"""Perform some actions after a model is deleted.
|
|
1014
998
|
By default do nothing.
|
|
1015
999
|
"""
|
|
1016
1000
|
|
|
@@ -1045,7 +1029,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1045
1029
|
"""
|
|
1046
1030
|
|
|
1047
1031
|
search_fields = [
|
|
1048
|
-
|
|
1032
|
+
map_attr_to_prop(attr, self) for attr in self.column_searchable_list
|
|
1049
1033
|
]
|
|
1050
1034
|
field_names = [
|
|
1051
1035
|
self._column_labels.get(field, field.key) for field in search_fields
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<h3 class="card-title">New {{ model_view.name }}</h3>
|
|
7
7
|
</div>
|
|
8
8
|
<div class="card-body border-bottom py-3">
|
|
9
|
-
<form action="{{ request.url }}" method="POST">
|
|
9
|
+
<form action="{{ request.url }}" method="POST" enctype="multipart/form-data">
|
|
10
10
|
<fieldset class="form-fieldset">
|
|
11
11
|
{% for field in form %}
|
|
12
12
|
<div class="mb-3 form-group row">
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<h3 class="card-title">Edit {{ model_view.name }}</h3>
|
|
7
7
|
</div>
|
|
8
8
|
<div class="card-body border-bottom py-3">
|
|
9
|
-
<form action="{{ request.url }}" method="POST">
|
|
9
|
+
<form action="{{ request.url }}" method="POST" enctype="multipart/form-data">
|
|
10
10
|
<fieldset class="form-fieldset">
|
|
11
11
|
{% for field in form %}
|
|
12
12
|
<div class="mb-3 form-group row">
|
|
@@ -77,3 +77,23 @@ class Select2TagsWidget(widgets.Select):
|
|
|
77
77
|
kwargs.setdefault("data-role", "select2-tags")
|
|
78
78
|
kwargs.setdefault("data-json", json.dumps(field.data))
|
|
79
79
|
return super().__call__(field, **kwargs)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FileInputWidget(widgets.FileInput):
|
|
83
|
+
"""
|
|
84
|
+
File input widget with clear checkbox.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __call__(self, field: Field, **kwargs: Any) -> str:
|
|
88
|
+
file_input = super().__call__(field, **kwargs)
|
|
89
|
+
checkbox_id = f"{field.id}_checkbox"
|
|
90
|
+
checkbox_label = Markup(
|
|
91
|
+
f'<label class="form-check-label" for="{checkbox_id}">Clear</label>'
|
|
92
|
+
)
|
|
93
|
+
checkbox_input = Markup(
|
|
94
|
+
f'<input class="form-check-input" type="checkbox" id="{checkbox_id}" name="{checkbox_id}">' # noqa: E501
|
|
95
|
+
)
|
|
96
|
+
checkbox = Markup(
|
|
97
|
+
f'<div class="form-check">{checkbox_input}{checkbox_label}</div>'
|
|
98
|
+
)
|
|
99
|
+
return file_input + checkbox
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|