sqladmin 0.21.0__py3-none-any.whl → 0.23.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/models.py CHANGED
@@ -19,6 +19,7 @@ from typing import (
19
19
  Union,
20
20
  no_type_check,
21
21
  )
22
+ from typing import cast as typing_cast
22
23
  from urllib.parse import urlencode
23
24
 
24
25
  import anyio
@@ -36,7 +37,12 @@ from wtforms import Field, Form
36
37
  from wtforms.fields.core import UnboundField
37
38
 
38
39
  from sqladmin._queries import Query
39
- from sqladmin._types import MODEL_ATTR, ColumnFilter
40
+ from sqladmin._types import (
41
+ MODEL_ATTR,
42
+ ColumnFilter,
43
+ OperationColumnFilter,
44
+ SimpleColumnFilter,
45
+ )
40
46
  from sqladmin.ajax import create_ajax_loader
41
47
  from sqladmin.exceptions import InvalidModelError
42
48
  from sqladmin.formatters import BASE_FORMATTERS
@@ -54,10 +60,11 @@ from sqladmin.helpers import (
54
60
 
55
61
  # stream_to_csv,
56
62
  from sqladmin.pagination import Pagination
63
+ from sqladmin.pretty_export import PrettyExport
57
64
  from sqladmin.templating import Jinja2Templates
58
65
 
59
66
  if TYPE_CHECKING:
60
- from sqlalchemy.ext.asyncio import async_sessionmaker
67
+ from sqlalchemy.ext.asyncio import async_sessionmaker # type: ignore[attr-defined]
61
68
 
62
69
  from sqladmin.application import BaseAdmin
63
70
 
@@ -76,8 +83,8 @@ class ModelViewMeta(type):
76
83
  """
77
84
 
78
85
  @no_type_check
79
- def __new__(mcls, name, bases, attrs: dict, **kwargs: Any):
80
- cls: Type["ModelView"] = super().__new__(mcls, name, bases, attrs)
86
+ def __new__(mcs, name, bases, attrs: dict, **kwargs: Any):
87
+ cls: Type["ModelView"] = super().__new__(mcs, name, bases, attrs)
81
88
 
82
89
  model = kwargs.get("model")
83
90
 
@@ -86,10 +93,10 @@ class ModelViewMeta(type):
86
93
 
87
94
  try:
88
95
  inspect(model)
89
- except NoInspectionAvailable:
96
+ except NoInspectionAvailable as exc:
90
97
  raise InvalidModelError(
91
98
  f"Class {model.__name__} is not a SQLAlchemy model."
92
- )
99
+ ) from exc
93
100
 
94
101
  cls.pk_columns = get_primary_keys(model)
95
102
  cls.identity = slugify_class_name(model.__name__)
@@ -98,21 +105,19 @@ class ModelViewMeta(type):
98
105
  cls.name = attrs.get("name", prettify_class_name(cls.model.__name__))
99
106
  cls.name_plural = attrs.get("name_plural", f"{cls.name}s")
100
107
 
101
- mcls._check_conflicting_options(["column_list", "column_exclude_list"], attrs)
102
- mcls._check_conflicting_options(
103
- ["form_columns", "form_excluded_columns"], attrs
104
- )
105
- mcls._check_conflicting_options(
108
+ mcs._check_conflicting_options(["column_list", "column_exclude_list"], attrs)
109
+ mcs._check_conflicting_options(["form_columns", "form_excluded_columns"], attrs)
110
+ mcs._check_conflicting_options(
106
111
  ["column_details_list", "column_details_exclude_list"], attrs
107
112
  )
108
- mcls._check_conflicting_options(
113
+ mcs._check_conflicting_options(
109
114
  ["column_export_list", "column_export_exclude_list"], attrs
110
115
  )
111
116
 
112
117
  return cls
113
118
 
114
119
  @classmethod
115
- def _check_conflicting_options(mcls, keys: List[str], attrs: dict) -> None:
120
+ def _check_conflicting_options(mcs, keys: List[str], attrs: dict) -> None:
116
121
  if all(k in attrs for k in keys):
117
122
  raise AssertionError(f"Cannot use {' and '.join(keys)} together.")
118
123
 
@@ -205,7 +210,12 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
205
210
 
206
211
  # Internals
207
212
  pk_columns: ClassVar[Tuple[Column]]
208
- session_maker: ClassVar[Union[sessionmaker, "async_sessionmaker"]]
213
+ session_maker: ClassVar[ # type: ignore[no-any-unimported]
214
+ Union[
215
+ sessionmaker,
216
+ "async_sessionmaker",
217
+ ]
218
+ ]
209
219
  is_async: ClassVar[bool] = False
210
220
  is_model: ClassVar[bool] = True
211
221
  ajax_lookup_url: ClassVar[str] = ""
@@ -485,6 +495,17 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
485
495
  Unlimited by default.
486
496
  """
487
497
 
498
+ use_pretty_export: ClassVar[bool] = False
499
+ """
500
+ Enable export of CSV files using column labels and column formatters.
501
+
502
+ If set to True, the export will apply column labels and formatting logic
503
+ used in the list template.
504
+ Otherwise, raw database values and field names will be used.
505
+
506
+ You can override cell formatting per column by implementing `custom_export_cell`.
507
+ """
508
+
488
509
  # Form
489
510
  form: ClassVar[Optional[Type[Form]]] = None
490
511
  """Form class.
@@ -797,8 +818,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
797
818
  return self.column_default_sort
798
819
  if isinstance(self.column_default_sort, tuple):
799
820
  return [self.column_default_sort]
800
- else:
801
- return [(self.column_default_sort, False)]
821
+
822
+ return [(self.column_default_sort, False)]
802
823
 
803
824
  return [(pk.name, False) for pk in self.pk_columns]
804
825
 
@@ -815,10 +836,10 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
815
836
 
816
837
  try:
817
838
  return int(number)
818
- except ValueError:
839
+ except ValueError as exc:
819
840
  raise HTTPException(
820
841
  status_code=400, detail="Invalid page or pageSize parameter"
821
- )
842
+ ) from exc
822
843
 
823
844
  async def count(self, request: Request, stmt: Optional[Select] = None) -> int:
824
845
  if stmt is None:
@@ -836,19 +857,36 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
836
857
  for relation in self._list_relations:
837
858
  stmt = stmt.options(selectinload(relation))
838
859
 
839
- for filter in self.get_filters():
840
- if request.query_params.get(filter.parameter_name):
841
- stmt = await filter.get_filtered_query(
842
- stmt, request.query_params.get(filter.parameter_name), self.model
843
- )
860
+ for filter_ in self.get_filters():
861
+ filter_param_name = filter_.parameter_name
862
+ filter_value = request.query_params.get(filter_param_name)
863
+
864
+ if filter_value:
865
+ if hasattr(filter_, "has_operator") and filter_.has_operator:
866
+ # Use operation-based filtering
867
+ operation_filter = typing_cast(OperationColumnFilter, filter_)
868
+ operation_param = request.query_params.get(
869
+ f"{filter_param_name}_op"
870
+ )
871
+ if operation_param:
872
+ stmt = await operation_filter.get_filtered_query(
873
+ stmt, operation_param, filter_value, self.model
874
+ )
875
+ else:
876
+ # Use simple filtering for filters without operators
877
+ simple_filter = typing_cast(SimpleColumnFilter, filter_)
878
+ stmt = await simple_filter.get_filtered_query(
879
+ stmt, filter_value, self.model
880
+ )
844
881
 
845
882
  stmt = self.sort_query(stmt, request)
846
883
 
847
884
  if search:
848
885
  stmt = self.search_query(stmt=stmt, term=search)
849
- count = await self.count(request, select(func.count()).select_from(stmt))
850
- else:
851
- count = await self.count(request)
886
+
887
+ count = await self.count(
888
+ request, select(func.count()).select_from(stmt.subquery())
889
+ )
852
890
 
853
891
  stmt = stmt.limit(page_size).offset((page - 1) * page_size)
854
892
  rows = await self._run_query(stmt)
@@ -950,14 +988,16 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
950
988
  """This function generalizes constructing a list of columns
951
989
  for any sequence of inclusions or exclusions.
952
990
  """
953
-
954
991
  if include == "__all__":
955
992
  return self._prop_names
956
- elif include:
993
+
994
+ if include:
957
995
  return [self._get_prop_name(item) for item in include]
958
- elif exclude:
996
+
997
+ if exclude:
959
998
  exclude = [self._get_prop_name(item) for item in exclude]
960
999
  return [prop for prop in self._prop_names if prop not in exclude]
1000
+
961
1001
  return defaults
962
1002
 
963
1003
  def get_list_columns(self) -> List[str]:
@@ -1066,7 +1106,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
1066
1106
 
1067
1107
  form = await get_model_form(
1068
1108
  model=self.model,
1069
- session_maker=self.session_maker,
1109
+ session_maker=self.session_maker, # type: ignore[arg-type]
1070
1110
  only=self._form_prop_names,
1071
1111
  column_labels=self._column_labels,
1072
1112
  form_args=self.form_args,
@@ -1210,9 +1250,16 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
1210
1250
  export_type: str = "csv",
1211
1251
  ) -> StreamingResponse:
1212
1252
  if export_type == "csv":
1213
- return await self._export_csv(data)
1214
- elif export_type == "json":
1253
+ export_method = (
1254
+ PrettyExport.pretty_export_csv(self, data)
1255
+ if self.use_pretty_export
1256
+ else self._export_csv(data)
1257
+ )
1258
+ return await export_method
1259
+
1260
+ if export_type == "json":
1215
1261
  return await self._export_json(data)
1262
+
1216
1263
  raise NotImplementedError("Only export_type='csv' or 'json' is implemented.")
1217
1264
 
1218
1265
  async def _export_csv(
@@ -1268,6 +1315,22 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
1268
1315
  headers={"Content-Disposition": f"attachment;filename={filename}"},
1269
1316
  )
1270
1317
 
1318
+ async def custom_export_cell(
1319
+ self,
1320
+ row: Any,
1321
+ name: str,
1322
+ value: Any,
1323
+ ) -> Optional[str]:
1324
+ """
1325
+ Override to provide custom formatting for a specific cell in pretty export.
1326
+
1327
+ Return a string to override the default formatting for the given field,
1328
+ or return None to fall back to `base_export_cell`.
1329
+
1330
+ Only used when `use_pretty_export = True`.
1331
+ """
1332
+ return None
1333
+
1271
1334
  def _refresh_form_rules_cache(self) -> None:
1272
1335
  if self.form_rules:
1273
1336
  self._form_create_rules = self.form_rules
@@ -0,0 +1,75 @@
1
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, List
2
+
3
+ from starlette.responses import StreamingResponse
4
+
5
+ from sqladmin.helpers import Writer, secure_filename, stream_to_csv
6
+
7
+ if TYPE_CHECKING:
8
+ from .models import ModelView
9
+
10
+
11
+ class PrettyExport:
12
+ @staticmethod
13
+ async def _base_export_cell(
14
+ model_view: "ModelView", name: str, value: Any, formatted_value: Any
15
+ ) -> str:
16
+ """
17
+ Default formatting logic for a cell in pretty export.
18
+
19
+ Used when `custom_export_cell` returns None.
20
+ Applies standard rules for related fields, booleans, etc.
21
+
22
+ Only used when `use_pretty_export = True`.
23
+ """
24
+ if name in model_view._relation_names:
25
+ if isinstance(value, list):
26
+ cell_value = ",".join(formatted_value)
27
+ else:
28
+ cell_value = formatted_value
29
+ else:
30
+ if isinstance(value, bool):
31
+ cell_value = "TRUE" if value else "FALSE"
32
+ else:
33
+ cell_value = formatted_value
34
+ return cell_value
35
+
36
+ @classmethod
37
+ async def _get_export_row_values(
38
+ cls, model_view: "ModelView", row: Any, column_names: List[str]
39
+ ) -> List[Any]:
40
+ row_values = []
41
+ for name in column_names:
42
+ value, formatted_value = await model_view.get_list_value(row, name)
43
+ custom_value = await model_view.custom_export_cell(row, name, value)
44
+ if custom_value is None:
45
+ cell_value = await cls._base_export_cell(
46
+ model_view, name, value, formatted_value
47
+ )
48
+ else:
49
+ cell_value = custom_value
50
+ row_values.append(cell_value)
51
+ return row_values
52
+
53
+ @classmethod
54
+ async def pretty_export_csv(
55
+ cls, model_view: "ModelView", rows: List[Any]
56
+ ) -> StreamingResponse:
57
+ async def generate(writer: Writer) -> AsyncGenerator[Any, None]:
58
+ column_names = model_view.get_export_columns()
59
+ headers = [
60
+ model_view._column_labels.get(name, name) for name in column_names
61
+ ]
62
+
63
+ yield writer.writerow(headers)
64
+
65
+ for row in rows:
66
+ vals = await cls._get_export_row_values(model_view, row, column_names)
67
+ yield writer.writerow(vals)
68
+
69
+ filename = secure_filename(model_view.get_export_name(export_type="csv"))
70
+
71
+ return StreamingResponse(
72
+ content=stream_to_csv(generate),
73
+ media_type="text/csv",
74
+ headers={"Content-Disposition": f"attachment;filename={filename}"},
75
+ )
@@ -5,7 +5,7 @@
5
5
  <div class="card-header">
6
6
  <h3 class="card-title">
7
7
  {% for pk in model_view.pk_columns -%}
8
- {{ pk.name }}
8
+ {{ pk.name | title }}
9
9
  {%- if not loop.last %};{% endif -%}
10
10
  {% endfor %}: {{ get_object_identifier(model) }}</h3>
11
11
  </div>
@@ -48,14 +48,14 @@
48
48
  </table>
49
49
  </div>
50
50
  <div class="card-footer container">
51
- <div class="row">
52
- <div class="col-md-1">
51
+ <div class="row row-gap-2">
52
+ <div class="col-auto">
53
53
  <a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
54
54
  Go Back
55
55
  </a>
56
56
  </div>
57
57
  {% if model_view.can_delete %}
58
- <div class="col-md-1">
58
+ <div class="col-auto">
59
59
  <a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
60
60
  data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
61
61
  data-bs-target="#modal-delete" class="btn btn-danger">
@@ -64,14 +64,14 @@
64
64
  </div>
65
65
  {% endif %}
66
66
  {% if model_view.can_edit %}
67
- <div class="col-md-1">
67
+ <div class="col-auto">
68
68
  <a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
69
69
  Edit
70
70
  </a>
71
71
  </div>
72
72
  {% endif %}
73
73
  {% for custom_action,label in model_view._custom_actions_in_detail.items() %}
74
- <div class="col-md-1">
74
+ <div class="col-auto">
75
75
  {% if custom_action in model_view._custom_actions_confirmation %}
76
76
  <a href="#" class="btn btn-secondary" data-bs-toggle="modal"
77
77
  data-bs-target="#modal-confirmation-{{ custom_action }}">
@@ -103,4 +103,4 @@ url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_i
103
103
  {% endif %}
104
104
  {% endfor %}
105
105
 
106
- {% endblock %}
106
+ {% endblock %}
@@ -221,23 +221,72 @@
221
221
  <div class="card-header">
222
222
  <h3 class="card-title">Filters</h3>
223
223
  </div>
224
- <div class="card-body p-0">
225
- <div class="list-group list-group-flush">
226
- {% for filter in model_view.get_filters() %}
227
- <div class="list-group-item">
228
- <div class="p-2">
229
- <div class="fw-bold text-truncate">{{ filter.title }}</div>
230
- <div>
231
- {% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
232
- <a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
233
- {{ lookup[1] }}
234
- </a>
224
+ <div class="card-body">
225
+ {% for filter in model_view.get_filters() %}
226
+ {% if filter.has_operator %}
227
+ <div class="mb-3">
228
+ <div class="fw-bold text-truncate">{{ filter.title }}</div>
229
+ <div>
230
+ <!-- Show current filter if active -->
231
+ {% set current_filter = request.query_params.get(filter.parameter_name, '') %}
232
+ {% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
233
+ {% if current_filter %}
234
+ <div class="mb-2 text-muted small">
235
+ Current: {{ current_op }} {{ current_filter }}
236
+ <a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
237
+ </div>
238
+ {% endif %}
239
+ <!-- Single form with dropdown for operations -->
240
+ <form method="get" class="d-flex flex-column" style="gap: 8px;">
241
+ <!-- Preserve existing query parameters -->
242
+ {% for key, value in request.query_params.items() %}
243
+ {% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
244
+ <input type="hidden" name="{{ key }}" value="{{ value }}">
245
+ {% endif %}
246
+ {% endfor %}
247
+ <!-- Operation dropdown -->
248
+ <select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
249
+ <option value="">Select operation...</option>
250
+ {% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
251
+ <option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
235
252
  {% endfor %}
236
- </div>
253
+ </select>
254
+ <!-- Value input -->
255
+ <input type="text"
256
+ name="{{ filter.parameter_name }}"
257
+ placeholder="Enter value"
258
+ class="form-control form-control-sm"
259
+ value="{{ current_filter }}"
260
+ required>
261
+ <button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
262
+ </form>
263
+ </div>
264
+ </div>
265
+ {% else %}
266
+ <!-- Fallback for other filter types -->
267
+ <div class="mb-3">
268
+ <div class="fw-bold text-truncate fs-3 mb-2">{{ filter.title }}</div>
269
+ <div>
270
+ {% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
271
+ {% if request.query_params.get(filter.parameter_name) == lookup[0] %}
272
+ <div class="d-flex align-items-center justify-content-between bg-secondary-lt px-2 py-1 rounded">
273
+ <span class="text-truncate fw-bold text-dark">
274
+ {{ lookup[1] }}
275
+ </span>
276
+ <a href="{{ request.url.remove_query_params(filter.parameter_name) }}" class="text-decoration-none ms-2" title="Clear filter">
277
+ <i class="fa-solid fa-times"></i>
278
+ </a>
237
279
  </div>
280
+ {% else %}
281
+ <a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate px-2 py-1">
282
+ {{ lookup[1] }}
283
+ </a>
284
+ {% endif %}
285
+ {% endfor %}
238
286
  </div>
239
- {% endfor %}
240
287
  </div>
288
+ {% endif %}
289
+ {% endfor %}
241
290
  </div>
242
291
  </div>
243
292
  </div>
sqladmin/templating.py CHANGED
@@ -45,7 +45,7 @@ class Jinja2Templates:
45
45
  def __init__(self, directory: str) -> None:
46
46
  @jinja2.pass_context
47
47
  def url_for(context: dict, __name: str, **path_params: Any) -> URL:
48
- request = context["request"]
48
+ request: Request = context["request"]
49
49
  return request.url_for(__name, **path_params)
50
50
 
51
51
  loader = jinja2.FileSystemLoader(directory)
sqladmin/widgets.py CHANGED
@@ -1,10 +1,15 @@
1
+ # mypy: disable-error-code="override"
2
+
1
3
  import json
2
- from typing import Any
4
+ from typing import TYPE_CHECKING, Any
3
5
 
4
6
  from markupsafe import Markup
5
- from wtforms import Field, widgets
7
+ from wtforms import Field, SelectFieldBase, widgets
6
8
  from wtforms.widgets import html_params
7
9
 
10
+ if TYPE_CHECKING:
11
+ from sqladmin.fields import AjaxSelectField
12
+
8
13
  __all__ = [
9
14
  "AjaxSelect2Widget",
10
15
  "DatePickerWidget",
@@ -38,7 +43,7 @@ class AjaxSelect2Widget(widgets.Select):
38
43
  self.multiple = multiple
39
44
  self.lookup_url = ""
40
45
 
41
- def __call__(self, field: Field, **kwargs: Any) -> Markup:
46
+ def __call__(self, field: "AjaxSelectField", **kwargs: Any) -> Markup:
42
47
  kwargs.setdefault("data-role", "select2-ajax")
43
48
  kwargs.setdefault("data-url", field.loader.model_admin.ajax_lookup_url)
44
49
 
@@ -61,11 +66,11 @@ class AjaxSelect2Widget(widgets.Select):
61
66
  if data:
62
67
  kwargs["data-json"] = json.dumps([data])
63
68
 
64
- return Markup(f"<select {html_params(name=field.name, **kwargs)}></select>")
69
+ return Markup(f"<select {html_params(name=field.name, **kwargs)}></select>") # nosec: markupsafe_markup_xss
65
70
 
66
71
 
67
72
  class Select2TagsWidget(widgets.Select):
68
- def __call__(self, field: Field, **kwargs: Any) -> str:
73
+ def __call__(self, field: SelectFieldBase, **kwargs: Any) -> str:
69
74
  kwargs.setdefault("data-role", "select2-tags")
70
75
  kwargs.setdefault("data-json", json.dumps(field.data))
71
76
  kwargs.setdefault("multiple", "multiple")
@@ -81,20 +86,21 @@ class FileInputWidget(widgets.FileInput):
81
86
  if not field.flags.required:
82
87
  checkbox_id = f"{field.id}_checkbox"
83
88
  checkbox_label = Markup(
84
- f'<label class="form-check-label" for="{checkbox_id}">Clear</label>'
85
- )
89
+ '<label class="form-check-label" for="{}">Clear</label>'
90
+ ).format(checkbox_id)
91
+
86
92
  checkbox_input = Markup(
87
- f'<input class="form-check-input" type="checkbox" id="{checkbox_id}" name="{checkbox_id}">' # noqa: E501
88
- )
89
- checkbox = Markup(
90
- f'<div class="form-check">{checkbox_input}{checkbox_label}</div>'
93
+ '<input class="form-check-input" type="checkbox" id="{}" name="{}">' # noqa: E501
94
+ ).format(checkbox_id, checkbox_id)
95
+ checkbox = Markup('<div class="form-check">{}{}</div>').format(
96
+ checkbox_input, checkbox_label
91
97
  )
92
98
  else:
93
99
  checkbox = Markup()
94
100
 
95
101
  if field.data:
96
- current_value = Markup(f"<p>Currently: {field.data}</p>")
102
+ current_value = Markup("<p>Currently: {}</p>").format(field.data)
97
103
  field.flags.required = False
98
104
  return current_value + checkbox + super().__call__(field, **kwargs)
99
- else:
100
- return super().__call__(field, **kwargs)
105
+
106
+ return super().__call__(field, **kwargs)
@@ -1,34 +1,35 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqladmin
3
- Version: 0.21.0
3
+ Version: 0.23.0
4
4
  Summary: SQLAlchemy admin for FastAPI and Starlette
5
- Project-URL: Documentation, https://aminalaee.github.io/sqladmin/
6
- Project-URL: Issues, https://github.com/aminalaee/sqladmin/issues
7
- Project-URL: Source, https://github.com/aminalaee/sqladmin
5
+ Keywords: sqlalchemy,fastapi,starlette,admin
6
+ Author: Amin Alaee
8
7
  Author-email: Amin Alaee <me@aminalaee.dev>
9
8
  License-Expression: BSD-3-Clause
10
- License-File: LICENSE.md
11
- Keywords: admin,fastapi,sqlalchemy,starlette
12
9
  Classifier: Development Status :: 4 - Beta
13
- Classifier: Environment :: Web Environment
14
- Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: BSD License
16
- Classifier: Operating System :: OS Independent
17
10
  Classifier: Programming Language :: Python
18
- Classifier: Programming Language :: Python :: 3.8
19
11
  Classifier: Programming Language :: Python :: 3.9
20
12
  Classifier: Programming Language :: Python :: 3.10
21
13
  Classifier: Programming Language :: Python :: 3.11
22
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Environment :: Web Environment
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: License :: OSI Approved :: BSD License
23
20
  Classifier: Topic :: Internet :: WWW/HTTP
24
- Requires-Python: >=3.8
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Dist: starlette
23
+ Requires-Dist: wtforms>=3.1,<3.2
25
24
  Requires-Dist: jinja2
26
25
  Requires-Dist: python-multipart
27
- Requires-Dist: sqlalchemy>=1.4
28
- Requires-Dist: starlette
29
- Requires-Dist: wtforms<3.2,>=3.1
26
+ Requires-Dist: sqlalchemy>=2.0
27
+ Requires-Dist: itsdangerous ; extra == 'full'
28
+ Requires-Python: >=3.9
29
+ Project-URL: Documentation, https://aminalaee.github.io/sqladmin/
30
+ Project-URL: Issues, https://github.com/aminalaee/sqladmin/issues
31
+ Project-URL: Source, https://github.com/aminalaee/sqladmin
30
32
  Provides-Extra: full
31
- Requires-Dist: itsdangerous; extra == 'full'
32
33
  Description-Content-Type: text/markdown
33
34
 
34
35
  <p align="center">