sqladmin 0.9.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqladmin/__init__.py CHANGED
@@ -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.9.0"
4
+ __version__ = "0.10.0"
5
5
 
6
6
  __all__ = [
7
7
  "Admin",
sqladmin/_queries.py CHANGED
@@ -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 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
+ ):
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 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
+ ):
82
92
  value = None
83
93
 
84
94
  setattr(obj, key, value)
sqladmin/application.py CHANGED
@@ -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,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 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
 
sqladmin/fields.py CHANGED
@@ -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()
sqladmin/forms.py CHANGED
@@ -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
- # @converts("dialects.mysql.types.YEAR", "dialects.mysql.base.YEAR")
418
- # def conv_MSYear(self, field_args: Dict, **kwargs: Any) -> Field:
419
- # field_args["validators"].append(validators.NumberRange(min=1901, max=2155))
420
- # return StringField(**field_args)
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]
sqladmin/helpers.py CHANGED
@@ -3,12 +3,28 @@ import os
3
3
  import re
4
4
  import unicodedata
5
5
  from abc import ABC, abstractmethod
6
- from typing import Callable, Generator, List, TypeVar
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
+ )
sqladmin/models.py CHANGED
@@ -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 InvalidColumnError, InvalidModelError
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
- self._attr_to_prop(attr): formatter
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
- self._attr_to_prop(attr): formatter
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
- self._attr_to_prop(attr).key for attr in self.column_sortable_list
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 = [self._attr_to_prop(prop) for prop in include]
904
+ props = [map_attr_to_prop(prop, self) for prop in include]
916
905
  elif exclude:
917
- exclude_props = {self._attr_to_prop(prop) for prop in exclude}
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
- self._attr_to_prop(column_label): value
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 created or updated.
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 before a model is deleted.
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
- self._attr_to_prop(attr) for attr in self.column_searchable_list
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">
sqladmin/widgets.py CHANGED
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sqladmin
3
- Version: 0.9.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
@@ -1,19 +1,19 @@
1
- sqladmin/__init__.py,sha256=BBIwJWIx60nsqU_pn9hiPbUlMed3T_KJ5Z5iKeRyAAg,223
2
- sqladmin/_queries.py,sha256=z7arlYmPE3DcaU1wvD0sSDitvCbAVzl_b-wVcYFZGmQ,7971
1
+ sqladmin/__init__.py,sha256=BcFoQ3KjLGs4Sh3evnyrQABVOiysdvUnIfc_4Xo5Dbg,224
2
+ sqladmin/_queries.py,sha256=maNxo8ZWHJys3XvqB5QLLQfMibUvmFEZ4Ufjd_rsN4I,8227
3
3
  sqladmin/_types.py,sha256=REixpHnnD4Qytug58b5hbY9B8MsNbZqWI2bJ8jUhCeI,277
4
4
  sqladmin/_validators.py,sha256=w0siGhZQq4MD__lu9Edua9DgMOoKET_kk-alpARFHIM,1604
5
5
  sqladmin/ajax.py,sha256=WcukKZXTwGM8TUSyQbYYMiHiMeBlwpVA6Cp862rs4ic,2473
6
- sqladmin/application.py,sha256=yfa8lZ3a5KVQwB2UBpXENisnCRmAhNOFObeIbwtZaxQ,19994
7
- sqladmin/authentication.py,sha256=bPUxMgJJUglNAM7s_PZkKNmWVp0J3Vf12TfwwRQd6vs,2027
6
+ sqladmin/application.py,sha256=xdDsT2pcRAPIKC11d9JncSSOhp1W1kQXa84O2kooS_w,21094
7
+ sqladmin/authentication.py,sha256=p6xqVhYlCd0jM6kHdCFGHnOfdIBwcpXqYqpeoj5STIg,2202
8
8
  sqladmin/exceptions.py,sha256=804ZRBRXh05zobafOHIdwmOeEK-cXZ3sQBu0OHSjtxI,210
9
- sqladmin/fields.py,sha256=ybvka5VpLjB4k1skVbsl7wa-3Tf7oGX0t9JMr2f4JwU,11271
9
+ sqladmin/fields.py,sha256=rWqrXW722gOYlTFSG9y_Kjs3HlfOUWHynDJBd-lB0i8,11864
10
10
  sqladmin/formatters.py,sha256=K06la0mm9-Bs5UA9L6KGJC_X_lV3UHdJ3ENI6j9j2Zg,480
11
- sqladmin/forms.py,sha256=DcptCurDu9tuh4kCWvB3HgM-9n5hhTKRRr2p66jFutM,19569
12
- sqladmin/helpers.py,sha256=R-Q23h2HZFS-Kv7oU7fciI9iPLjP7Ccl9DMOFnjzYI0,3838
13
- sqladmin/models.py,sha256=PibLsrpArLnUJG5utU4PPRb5jIZ0Cqc6OxjA_0L5cyM,35612
11
+ sqladmin/forms.py,sha256=kPHx-_VSxENicKM1_Pdmyg02frwOI17ixbytyA6mNlQ,19603
12
+ sqladmin/helpers.py,sha256=GRSxbi_anqZOyjQaJomYJi96dODPAF_D1FnTHufPX-c,6193
13
+ sqladmin/models.py,sha256=OjrNzMvxNnbsE2yI8hWzrLuJx8vfWg4IuI6UJRBeb6s,35169
14
14
  sqladmin/pagination.py,sha256=AmaZ5xNgPdBqTURYdOltz5IcSz8pY4qGQsr8evpUtHk,2256
15
15
  sqladmin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- sqladmin/widgets.py,sha256=aDelIACGnoAlZ0co_EvKai41dCCnv-pJVny6GAjk7Jk,2318
16
+ sqladmin/widgets.py,sha256=NQZH7byJbUaY8O_U-1iv0W0Btiu3LuUuBRmhe58Ur2M,3019
17
17
  sqladmin/statics/css/flatpickr.min.css,sha256=GzSkJVLJbxDk36qko2cnawOGiqz_Y8GsQv_jMTUrx1Q,16166
18
18
  sqladmin/statics/css/fontawesome.min.css,sha256=z9NKRrVjN9Zj8OCp7yFV4PlxGOqT4AaJCOy-rnQ7Rig,68945
19
19
  sqladmin/statics/css/main.css,sha256=G1fZ4-mnRIXSXEXNHIAMGyegV-46bSYxjxwmwud30IE,46
@@ -30,16 +30,16 @@ sqladmin/statics/js/tabler.min.js,sha256=l-Tz5LXKidfgYBYmcKJIjSYCoa0iVcSW03rLL2o
30
30
  sqladmin/statics/webfonts/fa-solid-900.ttf,sha256=F4IVa_ogVNtCkne0LxQymKHOA0N8BeJ3nKeHURKxnTw,303480
31
31
  sqladmin/statics/webfonts/fa-solid-900.woff2,sha256=GwmfiMBu0IaYclYcFX8OycvhM6CTnZ7OTuHh9UvUaD0,126828
32
32
  sqladmin/templates/base.html,sha256=mY3cL4oxSUpp4map0KqfcSiXFpYfiUY8dCMys_sASCo,1739
33
- sqladmin/templates/create.html,sha256=tLHupg0Wycvo6ahMCH0TvQ-B-wZHTE-KWdWaeSWjD5Q,1702
33
+ sqladmin/templates/create.html,sha256=gcnlfR6zuZpO2WuvOuHrLwyYniLSexA7An6egviTu34,1732
34
34
  sqladmin/templates/details.html,sha256=7MIUvbVtB51BIwAqg08jSItNTIfXSlcbkID5Tai_SM4,2574
35
- sqladmin/templates/edit.html,sha256=7O-EHP_Na7jpgmf-NffRqK4cYs9AiaSsZ7i8F6jrpuQ,1876
35
+ sqladmin/templates/edit.html,sha256=BZ365fBBo62qkR_aGotDFx5yrdoCOTdkQagD4oL87MA,1906
36
36
  sqladmin/templates/error.html,sha256=DGmfAYQB5eYpo1nfK_rDa1-YQ93OR5CKRkWl7x--ajE,282
37
37
  sqladmin/templates/index.html,sha256=4JMiiNqyHkriMjaXFgaJ1OGqPMEQKFZVr3bzZUzU_zU,63
38
38
  sqladmin/templates/layout.html,sha256=rFMc-m77oVjL9anf6i8PmZlzUHmU_E6Dphu-WP0Tawo,2821
39
39
  sqladmin/templates/list.html,sha256=QlnpuvnY4BbTbLPt0PymrDG0bcAo4-8RIYaaX3X0sNY,8467
40
40
  sqladmin/templates/login.html,sha256=owaAUlVpHS1bmPXDVPRvKvGWMbWW8K8xsIaagLYp3wQ,1475
41
41
  sqladmin/templates/modals/delete.html,sha256=8K4iNkszaG3Qopt22OLTXxsLCsDidzjhtPQchyBwVLI,1093
42
- sqladmin-0.9.0.dist-info/METADATA,sha256=waJZ3YOtNbw0kZPu3Q1f409mC4IvTkPEoAzx5NnnZME,5038
43
- sqladmin-0.9.0.dist-info/WHEEL,sha256=hKi7AIIx6qfnsRbr087vpeJnrVUuDokDHZacPPMW7-Y,87
44
- sqladmin-0.9.0.dist-info/licenses/LICENSE.md,sha256=4zzpHQMPtND4hzIgJA5qnb4R_wRBWJlYGqNrZolBeP8,1488
45
- sqladmin-0.9.0.dist-info/RECORD,,
42
+ sqladmin-0.10.0.dist-info/METADATA,sha256=48ehxl49nFEuOn8YJi8qyb1iMPfcequilFDwSbrMy5o,5039
43
+ sqladmin-0.10.0.dist-info/WHEEL,sha256=Fd6mP6ydyRguakwUJ05oBE7fh2IPxgtDN9IwHJ9OqJQ,87
44
+ sqladmin-0.10.0.dist-info/licenses/LICENSE.md,sha256=4zzpHQMPtND4hzIgJA5qnb4R_wRBWJlYGqNrZolBeP8,1488
45
+ sqladmin-0.10.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.12.2
2
+ Generator: hatchling 1.13.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any