sqladmin 0.20.1__py3-none-any.whl → 0.22.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
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
@@ -183,6 +189,9 @@ class BaseView(BaseModelView):
183
189
  category: ClassVar[str] = ""
184
190
  """Category name to group views together."""
185
191
 
192
+ category_icon: ClassVar[str] = ""
193
+ """Display icon for category in the sidebar."""
194
+
186
195
 
187
196
  class ModelView(BaseView, metaclass=ModelViewMeta):
188
197
  """Base class for defining admnistrative behaviour for the model.
@@ -312,6 +321,17 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
312
321
  ```
313
322
  """
314
323
 
324
+ column_filters: ClassVar[Sequence[ColumnFilter]] = []
325
+ """Collection of the filterable columns for the list view.
326
+ Columns can either be string names or SQLAlchemy columns.
327
+
328
+ ???+ example
329
+ ```python
330
+ class UserAdmin(ModelView, model=User):
331
+ column_filters = [User.is_admin]
332
+ ```
333
+ """
334
+
315
335
  column_sortable_list: ClassVar[Sequence[MODEL_ATTR]] = []
316
336
  """Collection of the sortable columns for the list view.
317
337
 
@@ -398,7 +418,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
398
418
  """
399
419
 
400
420
  save_as: ClassVar[bool] = False
401
- """Set `save_as` to enable a save as new feature on admin change forms.
421
+ """Set `save_as` to enable a "save as new" feature on admin change forms.
402
422
 
403
423
  Normally, objects have three save options:
404
424
  ``Save`, `Save and continue editing` and `Save and add another`.
@@ -432,6 +452,12 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
432
452
  edit_template: ClassVar[str] = "sqladmin/edit.html"
433
453
  """Edit view template. Default is `sqladmin/edit.html`."""
434
454
 
455
+ # Template configuration
456
+ show_compact_lists: ClassVar[bool] = True
457
+ """Show compact lists. Default is `True`.
458
+ If False, when showing lists of objects, each object will be \
459
+ displayed in a separate line."""
460
+
435
461
  # Export
436
462
  column_export_list: ClassVar[List[MODEL_ATTR]] = []
437
463
  """List of columns to include when exporting.
@@ -645,7 +671,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
645
671
  - None will be displayed as an empty string
646
672
  - bool will be displayed as a checkmark if it is True otherwise as an X.
647
673
 
648
- If you dont like the default behavior and dont want any type formatters applied,
674
+ If you don't like the default behavior and don't want any type formatters applied,
649
675
  just override this property with an empty dictionary:
650
676
 
651
677
  ???+ example
@@ -718,6 +744,19 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
718
744
  self._custom_actions_in_detail: Dict[str, str] = {}
719
745
  self._custom_actions_confirmation: Dict[str, str] = {}
720
746
 
747
+ def _run_arbitrary_query_sync(self, stmt: ClauseElement) -> Any:
748
+ with self.session_maker(expire_on_commit=False) as session:
749
+ result = session.execute(stmt)
750
+ return result.all()
751
+
752
+ async def _run_arbitrary_query(self, stmt: ClauseElement) -> Any:
753
+ if self.is_async:
754
+ async with self.session_maker(expire_on_commit=False) as session:
755
+ result = await session.execute(stmt)
756
+ return result.all()
757
+ else:
758
+ return self._run_arbitrary_query_sync(stmt)
759
+
721
760
  def _run_query_sync(self, stmt: ClauseElement) -> Any:
722
761
  with self.session_maker(expire_on_commit=False) as session:
723
762
  result = session.execute(stmt)
@@ -803,13 +842,34 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
803
842
  for relation in self._list_relations:
804
843
  stmt = stmt.options(selectinload(relation))
805
844
 
845
+ for filter in self.get_filters():
846
+ filter_param_name = filter.parameter_name
847
+ filter_value = request.query_params.get(filter_param_name)
848
+
849
+ if filter_value:
850
+ if hasattr(filter, "has_operator") and filter.has_operator:
851
+ # Use operation-based filtering
852
+ operation_filter = typing_cast(OperationColumnFilter, filter)
853
+ operation_param = request.query_params.get(
854
+ f"{filter_param_name}_op"
855
+ )
856
+ if operation_param:
857
+ stmt = await operation_filter.get_filtered_query(
858
+ stmt, operation_param, filter_value, self.model
859
+ )
860
+ else:
861
+ # Use simple filtering for filters without operators
862
+ simple_filter = typing_cast(SimpleColumnFilter, filter)
863
+ stmt = await simple_filter.get_filtered_query(
864
+ stmt, filter_value, self.model
865
+ )
866
+
806
867
  stmt = self.sort_query(stmt, request)
807
868
 
808
869
  if search:
809
870
  stmt = self.search_query(stmt=stmt, term=search)
810
- count = await self.count(request, select(func.count()).select_from(stmt))
811
- else:
812
- count = await self.count(request)
871
+
872
+ count = await self.count(request, select(func.count()).select_from(stmt))
813
873
 
814
874
  stmt = stmt.limit(page_size).offset((page - 1) * page_size)
815
875
  rows = await self._run_query(stmt)
@@ -840,12 +900,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
840
900
  rows = await self._run_query(stmt)
841
901
  return rows[0] if rows else None
842
902
 
843
- async def get_object_for_details(self, value: Any) -> Any:
844
- stmt = self._stmt_by_identifier(value)
845
-
846
- for relation in self._details_relations:
847
- stmt = stmt.options(selectinload(relation))
848
-
903
+ async def get_object_for_details(self, request: Request) -> Any:
904
+ stmt = self.details_query(request)
849
905
  return await self._get_object_by_pk(stmt)
850
906
 
851
907
  async def get_object_for_edit(self, request: Request) -> Any:
@@ -973,6 +1029,15 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
973
1029
  defaults=self._list_prop_names,
974
1030
  )
975
1031
 
1032
+ def get_filters(self) -> List[ColumnFilter]:
1033
+ """Get list of filters."""
1034
+
1035
+ filters = getattr(self, "column_filters", None)
1036
+ if not filters:
1037
+ return []
1038
+
1039
+ return filters
1040
+
976
1041
  async def on_model_change(
977
1042
  self, data: dict, model: Any, is_created: bool, request: Request
978
1043
  ) -> None:
@@ -1088,6 +1153,14 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
1088
1153
 
1089
1154
  return select(self.model)
1090
1155
 
1156
+ def details_query(self, request: Request) -> Select:
1157
+ """
1158
+ The SQLAlchemy select expression used for the details page which can be
1159
+ customized. By default it will select all objects without any filters.
1160
+ """
1161
+
1162
+ return self.form_edit_query(request)
1163
+
1091
1164
  def edit_form_query(self, request: Request) -> Select:
1092
1165
  msg = (
1093
1166
  "Overriding 'edit_form_query' is deprecated. Use 'form_edit_query' instead."
@@ -1184,7 +1257,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
1184
1257
 
1185
1258
  return StreamingResponse(
1186
1259
  content=stream_to_csv(generate),
1187
- media_type="text/csv",
1260
+ media_type="text/csv; charset=utf-8",
1188
1261
  headers={"Content-Disposition": f"attachment;filename={filename}"},
1189
1262
  )
1190
1263
 
@@ -1203,7 +1276,9 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
1203
1276
  name: str(await self.get_prop_value(row, name))
1204
1277
  for name in self._export_prop_names
1205
1278
  }
1206
- yield json.dumps(row_dict) + (separator if idx < last_idx else "")
1279
+ yield json.dumps(row_dict, ensure_ascii=False) + (
1280
+ separator if idx < last_idx else ""
1281
+ )
1207
1282
 
1208
1283
  yield "]"
1209
1284
 
@@ -1,3 +1,10 @@
1
1
  .table thead th {
2
2
  text-transform: none;
3
- }
3
+ }
4
+
5
+ .required-label::before {
6
+ content: "*";
7
+ color: red;
8
+ margin-right: 5px;
9
+ }
10
+
@@ -1,5 +1,5 @@
1
1
  {% macro menu_category(menu, request) %}
2
- {% if menu.is_active(request) %}
2
+ {% if menu.is_visible(request) and menu.is_accessible(request) %}
3
3
  <li class="nav-item dropdown">
4
4
  <a class="nav-link dropdown-toggle {% if menu.is_active(request) %}active{% endif %}" data-bs-toggle="dropdown"
5
5
  href="#">
@@ -56,7 +56,10 @@
56
56
 
57
57
  {% macro render_field(field, kwargs={}) %}
58
58
  <div class="mb-3 form-group row">
59
- {{ field.label(class_="form-label col-sm-2 col-form-label") }}
59
+ {{ field.label(
60
+ class_="form-label col-sm-2 col-form-label" + (' required-label' if field.flags.required else ''),
61
+ **({'title': "This is a required field"} if field.flags.required else {})
62
+ ) }}
60
63
  <div class="col-sm-10">
61
64
  {% if field.errors %}
62
65
  {{ field(class_="form-control is-invalid") }}
@@ -28,7 +28,11 @@
28
28
  {% if is_list( value ) %}
29
29
  <td>
30
30
  {% for elem, formatted_elem in zip(value, formatted_value) %}
31
- <a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
31
+ {% if model_view.show_compact_lists %}
32
+ <a href="{{ model_view._build_url_for('admin:details', request, elem) }}">({{ formatted_elem }})</a>
33
+ {% else %}
34
+ <a href="{{ model_view._build_url_for('admin:details', request, elem) }}">{{ formatted_elem }}</a><br/>
35
+ {% endif %}
32
36
  {% endfor %}
33
37
  </td>
34
38
  {% else %}