fastapi-rtk 0.2.27__py3-none-any.whl → 1.0.13__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.
Files changed (98) hide show
  1. fastapi_rtk/__init__.py +39 -35
  2. fastapi_rtk/_version.py +1 -0
  3. fastapi_rtk/api/model_rest_api.py +476 -221
  4. fastapi_rtk/auth/auth.py +0 -9
  5. fastapi_rtk/backends/generic/__init__.py +6 -0
  6. fastapi_rtk/backends/generic/column.py +21 -12
  7. fastapi_rtk/backends/generic/db.py +42 -7
  8. fastapi_rtk/backends/generic/filters.py +21 -16
  9. fastapi_rtk/backends/generic/interface.py +14 -8
  10. fastapi_rtk/backends/generic/model.py +19 -11
  11. fastapi_rtk/backends/sqla/__init__.py +1 -0
  12. fastapi_rtk/backends/sqla/db.py +77 -17
  13. fastapi_rtk/backends/sqla/extensions/audit/audit.py +401 -189
  14. fastapi_rtk/backends/sqla/extensions/geoalchemy2/filters.py +15 -12
  15. fastapi_rtk/backends/sqla/filters.py +50 -21
  16. fastapi_rtk/backends/sqla/interface.py +96 -34
  17. fastapi_rtk/backends/sqla/model.py +56 -39
  18. fastapi_rtk/bases/__init__.py +20 -0
  19. fastapi_rtk/bases/db.py +94 -7
  20. fastapi_rtk/bases/file_manager.py +47 -3
  21. fastapi_rtk/bases/filter.py +22 -0
  22. fastapi_rtk/bases/interface.py +49 -5
  23. fastapi_rtk/bases/model.py +3 -0
  24. fastapi_rtk/bases/session.py +2 -0
  25. fastapi_rtk/cli/cli.py +62 -9
  26. fastapi_rtk/cli/commands/__init__.py +23 -0
  27. fastapi_rtk/cli/{db.py → commands/db/__init__.py} +107 -50
  28. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/env.py +2 -3
  29. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/env.py +10 -9
  30. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/script.py.mako +3 -1
  31. fastapi_rtk/cli/{export.py → commands/export.py} +12 -10
  32. fastapi_rtk/cli/{security.py → commands/security.py} +73 -7
  33. fastapi_rtk/cli/commands/translate.py +299 -0
  34. fastapi_rtk/cli/decorators.py +9 -4
  35. fastapi_rtk/cli/utils.py +46 -0
  36. fastapi_rtk/config.py +41 -1
  37. fastapi_rtk/const.py +29 -1
  38. fastapi_rtk/db.py +76 -40
  39. fastapi_rtk/decorators.py +1 -1
  40. fastapi_rtk/dependencies.py +134 -62
  41. fastapi_rtk/exceptions.py +51 -1
  42. fastapi_rtk/fastapi_react_toolkit.py +186 -171
  43. fastapi_rtk/file_managers/file_manager.py +8 -6
  44. fastapi_rtk/file_managers/s3_file_manager.py +69 -33
  45. fastapi_rtk/globals.py +22 -12
  46. fastapi_rtk/lang/__init__.py +3 -0
  47. fastapi_rtk/lang/babel/__init__.py +4 -0
  48. fastapi_rtk/lang/babel/cli.py +40 -0
  49. fastapi_rtk/lang/babel/config.py +17 -0
  50. fastapi_rtk/lang/babel.cfg +1 -0
  51. fastapi_rtk/lang/lazy_text.py +120 -0
  52. fastapi_rtk/lang/messages.pot +238 -0
  53. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
  54. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +248 -0
  55. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
  56. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +244 -0
  57. fastapi_rtk/manager.py +355 -37
  58. fastapi_rtk/mixins.py +12 -0
  59. fastapi_rtk/routers.py +208 -72
  60. fastapi_rtk/schemas.py +142 -39
  61. fastapi_rtk/security/sqla/apis.py +39 -13
  62. fastapi_rtk/security/sqla/models.py +8 -23
  63. fastapi_rtk/security/sqla/security_manager.py +369 -11
  64. fastapi_rtk/setting.py +446 -88
  65. fastapi_rtk/types.py +94 -27
  66. fastapi_rtk/utils/__init__.py +8 -0
  67. fastapi_rtk/utils/async_task_runner.py +286 -61
  68. fastapi_rtk/utils/csv_json_converter.py +243 -40
  69. fastapi_rtk/utils/hooks.py +34 -0
  70. fastapi_rtk/utils/merge_schema.py +3 -3
  71. fastapi_rtk/utils/multiple_async_contexts.py +21 -0
  72. fastapi_rtk/utils/pydantic.py +46 -1
  73. fastapi_rtk/utils/run_utils.py +31 -1
  74. fastapi_rtk/utils/self_dependencies.py +1 -1
  75. fastapi_rtk/utils/use_default_when_none.py +1 -1
  76. fastapi_rtk/version.py +6 -1
  77. fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
  78. fastapi_rtk-1.0.13.dist-info/RECORD +133 -0
  79. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
  80. fastapi_rtk/backends/gremlinpython/__init__.py +0 -108
  81. fastapi_rtk/backends/gremlinpython/column.py +0 -208
  82. fastapi_rtk/backends/gremlinpython/db.py +0 -228
  83. fastapi_rtk/backends/gremlinpython/exceptions.py +0 -34
  84. fastapi_rtk/backends/gremlinpython/filters.py +0 -461
  85. fastapi_rtk/backends/gremlinpython/interface.py +0 -734
  86. fastapi_rtk/backends/gremlinpython/model.py +0 -364
  87. fastapi_rtk/backends/gremlinpython/session.py +0 -23
  88. fastapi_rtk/cli/commands.py +0 -295
  89. fastapi_rtk-0.2.27.dist-info/METADATA +0 -23
  90. fastapi_rtk-0.2.27.dist-info/RECORD +0 -126
  91. fastapi_rtk-0.2.27.dist-info/top_level.txt +0 -1
  92. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/README +0 -0
  93. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/alembic.ini.mako +0 -0
  94. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/script.py.mako +0 -0
  95. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/README +0 -0
  96. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/alembic.ini.mako +0 -0
  97. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
  98. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -15,16 +15,16 @@ from pydantic import BaseModel, ConfigDict, Field, create_model
15
15
  from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session
16
16
  from sqlalchemy.orm import Session, scoped_session
17
17
 
18
- from ..backends.sqla.filters import BaseFilter
18
+ from ..backends.sqla.filters import BaseFilter, BaseOprFilter
19
19
  from ..backends.sqla.interface import SQLAInterface
20
20
  from ..backends.sqla.model import Model
21
- from ..bases.db import DBQueryParams
22
- from ..bases.session import AbstractSession
23
- from ..const import AVAILABLE_ROUTES, PERMISSION_PREFIX
21
+ from ..bases import AbstractFileManager, AbstractSession, DBQueryParams
22
+ from ..const import AVAILABLE_ROUTES, PERMISSION_PREFIX, ErrorCode
24
23
  from ..decorators import expose, permission_name, priority
25
24
  from ..dependencies import current_permissions, has_access_dependency
26
- from ..exceptions import raise_exception
25
+ from ..exceptions import HTTPWithValidationException, raise_exception
27
26
  from ..globals import g
27
+ from ..lang.lazy_text import translate
28
28
  from ..schemas import (
29
29
  PRIMARY_KEY,
30
30
  BaseResponseMany,
@@ -39,7 +39,6 @@ from ..schemas import (
39
39
  RelInfo,
40
40
  )
41
41
  from ..setting import Setting
42
- from ..types import ExportMode
43
42
  from ..utils import (
44
43
  AsyncTaskRunner,
45
44
  CSVJSONConverter,
@@ -171,19 +170,6 @@ class ModelRestApi(BaseApi):
171
170
  """
172
171
  The maximum page size for the related fields in add_columns, edit_columns, and search_columns properties.
173
172
  """
174
- file_manager = lazy(
175
- lambda self: g.file_manager.get_instance_with_subfolder(self.resource_name)
176
- )
177
- """
178
- The file manager instance for the API. Defaults to `g.file_manager` with the subfolder set to the resource name.
179
- """
180
- image_manager = lazy(
181
- lambda self: g.image_manager.get_instance_with_subfolder(self.resource_name)
182
- )
183
- """
184
- The image manager instance for the API. Defaults to `g.image_manager` with the subfolder set to the resource name.
185
- """
186
-
187
173
  routes: list[AVAILABLE_ROUTES] = lazy(
188
174
  lambda self: [
189
175
  x
@@ -219,6 +205,19 @@ class ModelRestApi(BaseApi):
219
205
  """
220
206
  The list of routes to exclude from protection. Defaults to `[]`.
221
207
  """
208
+ expose_params: dict[AVAILABLE_ROUTES, dict[str, Any]] = lazy(lambda: dict())
209
+ """
210
+ Additional parameters to pass to the `expose` decorator for each route. See `expose` decorator for more information.
211
+
212
+ Example:
213
+ ```python
214
+ expose_params = {
215
+ 'image': {
216
+ 'description': 'Custom description for image endpoint',
217
+ },
218
+ }
219
+ ```
220
+ """
222
221
  base_order: Tuple[str, Literal["asc", "desc"]] | None = None
223
222
  """
224
223
  The default order for the list endpoint. Set this to set the default order for the list endpoint.
@@ -239,6 +238,26 @@ class ModelRestApi(BaseApi):
239
238
  ]
240
239
  ```
241
240
  """
241
+ base_opr_filters: List[Tuple[Type[BaseOprFilter], Any]] | None = None
242
+ """
243
+ The default opr filters to apply for the following endpoints: `download`, `list`, `show`, `add`, and `edit`. Defaults to None.
244
+
245
+ Example:
246
+ ```python
247
+ base_opr_filters = [
248
+ [FilterEqualOnNameAndAge, "active"],
249
+ ]
250
+ ```
251
+ """
252
+ opr_filters: List[Type[BaseOprFilter]] | None = None
253
+ """
254
+ The list of opr filters to be exposed to the user. Defaults to None, which means no opr filters will be exposed.
255
+
256
+ Example:
257
+ ```python
258
+ opr_filters = [FilterEqualOnNameAndAge]
259
+ ```
260
+ """
242
261
  label_columns = lazy(lambda: dict[str, str]())
243
262
  """
244
263
  The label for each column in the list columns and show columns properties.
@@ -257,6 +276,8 @@ class ModelRestApi(BaseApi):
257
276
  {col: col for col in self.order_columns},
258
277
  type=str,
259
278
  )
279
+ if self.order_columns
280
+ else typing.Annotated[str, pydantic.AfterValidator(lambda _: None)]
260
281
  )
261
282
  """
262
283
  The enum for the order columns in the list endpoint.
@@ -412,34 +433,24 @@ class ModelRestApi(BaseApi):
412
433
  lambda self: merge_schema(
413
434
  InfoResponse,
414
435
  {
415
- "add_title": (str, Field(default=self.add_title)),
416
- "edit_title": (str, Field(default=self.edit_title)),
436
+ "add_title": (str, Field(default_factory=lambda: self.add_title)),
437
+ "edit_title": (str, Field(default_factory=lambda: self.edit_title)),
417
438
  "filter_options": (dict, Field(default={})),
439
+ "add_translations": (
440
+ dict,
441
+ Field(default_factory=lambda: self.add_jsonforms_translations),
442
+ ),
443
+ "edit_translations": (
444
+ dict,
445
+ Field(default_factory=lambda: self.edit_jsonforms_translations),
446
+ ),
418
447
  "add_type": (
419
448
  typing.Literal["json"] | typing.Literal["form"],
420
- Field(
421
- default="form"
422
- if any(
423
- x
424
- in self.datamodel.get_file_column_list()
425
- + self.datamodel.get_image_column_list()
426
- for x in self.add_columns
427
- )
428
- else "json"
429
- ),
449
+ Field(default="form" if self._is_form_data_add else "json"),
430
450
  ),
431
451
  "edit_type": (
432
452
  typing.Literal["json"] | typing.Literal["form"],
433
- Field(
434
- default="form"
435
- if any(
436
- x
437
- in self.datamodel.get_file_column_list()
438
- + self.datamodel.get_image_column_list()
439
- for x in self.edit_columns
440
- )
441
- else "json"
442
- ),
453
+ Field(default="form" if self._is_form_data_edit else "json"),
443
454
  ),
444
455
  },
445
456
  name=f"{self.__class__.__name__}-InfoResponse",
@@ -451,6 +462,17 @@ class ModelRestApi(BaseApi):
451
462
  DO NOT MODIFY UNLESS YOU KNOW WHAT YOU ARE DOING.
452
463
  """
453
464
 
465
+ """
466
+ -------------
467
+ LIST
468
+ -------------
469
+ """
470
+
471
+ list_query_count_enabled = True
472
+ """
473
+ Whether to enable counting the total number of items in the list endpoint. Defaults to `True`.
474
+ """
475
+
454
476
  """
455
477
  -------------
456
478
  TITLES
@@ -458,25 +480,37 @@ class ModelRestApi(BaseApi):
458
480
  """
459
481
 
460
482
  list_title = lazy(
461
- lambda self: "List " + self._prettify_name(self.datamodel.obj.__name__)
483
+ lambda self: translate(
484
+ "List {ModelName}", ModelName=self.datamodel.obj.__name__
485
+ ),
486
+ cache=False,
462
487
  )
463
488
  """
464
489
  The title for the list endpoint. If not provided, Defaults to "List {ModelName}".
465
490
  """
466
491
  show_title = lazy(
467
- lambda self: "Show " + self._prettify_name(self.datamodel.obj.__name__)
492
+ lambda self: translate(
493
+ "Show {ModelName}", ModelName=self.datamodel.obj.__name__
494
+ ),
495
+ cache=False,
468
496
  )
469
497
  """
470
498
  The title for the show endpoint. If not provided, Defaults to "Show {ModelName}".
471
499
  """
472
500
  add_title = lazy(
473
- lambda self: "Add " + self._prettify_name(self.datamodel.obj.__name__)
501
+ lambda self: translate(
502
+ "Add {ModelName}", ModelName=self.datamodel.obj.__name__
503
+ ),
504
+ cache=False,
474
505
  )
475
506
  """
476
507
  The title for the add endpoint. If not provided, Defaults to "Add {ModelName}".
477
508
  """
478
509
  edit_title = lazy(
479
- lambda self: "Edit " + self._prettify_name(self.datamodel.obj.__name__)
510
+ lambda self: translate(
511
+ "Edit {ModelName}", ModelName=self.datamodel.obj.__name__
512
+ ),
513
+ cache=False,
480
514
  )
481
515
  """
482
516
  The title for the edit endpoint. If not provided, Defaults to "Edit {ModelName}".
@@ -531,17 +565,25 @@ class ModelRestApi(BaseApi):
531
565
  The list of columns to display in the edit endpoint. If not provided, all columns will be displayed.
532
566
  """
533
567
  search_columns = lazy(
534
- lambda self: self.datamodel.get_search_column_list(
535
- list_columns=self.list_columns
536
- )
568
+ lambda self: [
569
+ x
570
+ for x in self.datamodel.get_search_column_list(
571
+ list_columns=self.list_columns
572
+ )
573
+ if x not in self.search_exclude_columns
574
+ ]
537
575
  )
538
576
  """
539
577
  The list of columns that are allowed to be filtered in the list endpoint. If not provided, all columns will be allowed.
540
578
  """
541
579
  order_columns = lazy(
542
- lambda self: self.datamodel.get_order_column_list(
543
- list_columns=self.list_columns
544
- )
580
+ lambda self: [
581
+ x
582
+ for x in self.datamodel.get_order_column_list(
583
+ list_columns=self.list_columns
584
+ )
585
+ if x not in self.order_exclude_columns
586
+ ]
545
587
  )
546
588
  """
547
589
  The list of columns that can be ordered in the list endpoint. If not provided, all columns will be allowed.
@@ -573,6 +615,10 @@ class ModelRestApi(BaseApi):
573
615
  """
574
616
  The list of columns to exclude from the search columns.
575
617
  """
618
+ order_exclude_columns = lazy(lambda: list[str]())
619
+ """
620
+ The list of columns to exclude from the order columns.
621
+ """
576
622
 
577
623
  """
578
624
  -------------
@@ -619,6 +665,10 @@ class ModelRestApi(BaseApi):
619
665
  """
620
666
  The `JSONForms` uischema for the add endpoint. If not provided, it will let `JSONForms` generate the uischema.
621
667
  """
668
+ add_jsonforms_translations: dict[str, dict[str, str]] | None = None
669
+ """
670
+ The `JSONForms` translations for the add endpoint.
671
+ """
622
672
  edit_jsonforms_schema: Dict[str, Any] | None = None
623
673
  """
624
674
  The `JSONForms` schema for the edit endpoint. If not provided, Defaults to the schema generated from the `edit_columns` property.
@@ -627,6 +677,10 @@ class ModelRestApi(BaseApi):
627
677
  """
628
678
  The `JSONForms` uischema for the edit endpoint. If not provided, it will let `JSONForms` generate the uischema.
629
679
  """
680
+ edit_jsonforms_translations: dict[str, dict[str, str]] | None = None
681
+ """
682
+ The `JSONForms` translations for the edit endpoint.
683
+ """
630
684
 
631
685
  """
632
686
  -------------
@@ -667,6 +721,7 @@ class ModelRestApi(BaseApi):
667
721
  with_name=False,
668
722
  name=f"{self.__class__.__name__}-AddObjSchema",
669
723
  hide_sensitive_columns=False,
724
+ is_form_data=self._is_form_data_add,
670
725
  )
671
726
  )
672
727
  """
@@ -682,6 +737,7 @@ class ModelRestApi(BaseApi):
682
737
  optional=True,
683
738
  name=f"{self.__class__.__name__}-EditObjSchema",
684
739
  hide_sensitive_columns=False,
740
+ is_form_data=self._is_form_data_edit,
685
741
  )
686
742
  )
687
743
  """
@@ -707,12 +763,7 @@ class ModelRestApi(BaseApi):
707
763
  name=f"{self.__class__.__name__}-AddSchema",
708
764
  ),
709
765
  fastapi.Form(media_type="multipart/form-data")
710
- if any(
711
- x
712
- in self.datamodel.get_file_column_list()
713
- + self.datamodel.get_image_column_list()
714
- for x in self.add_columns
715
- )
766
+ if self._is_form_data_add
716
767
  else None,
717
768
  ]
718
769
  )
@@ -733,12 +784,7 @@ class ModelRestApi(BaseApi):
733
784
  name=f"{self.__class__.__name__}-EditSchema",
734
785
  ),
735
786
  fastapi.Form(media_type="multipart/form-data")
736
- if any(
737
- x
738
- in self.datamodel.get_file_column_list()
739
- + self.datamodel.get_image_column_list()
740
- for x in self.edit_columns
741
- )
787
+ if self._is_form_data_edit
742
788
  else None,
743
789
  ]
744
790
  )
@@ -852,6 +898,32 @@ class ModelRestApi(BaseApi):
852
898
  """
853
899
  A flag to indicate if the default info schema is used.
854
900
 
901
+ DO NOT MODIFY.
902
+ """
903
+ _is_form_data_add = lazy(
904
+ lambda self: any(
905
+ x
906
+ in self.datamodel.get_file_column_list()
907
+ + self.datamodel.get_image_column_list()
908
+ for x in self.add_columns
909
+ )
910
+ )
911
+ """
912
+ A flag to indicate if the add endpoint is using form data.
913
+
914
+ DO NOT MODIFY.
915
+ """
916
+ _is_form_data_edit = lazy(
917
+ lambda self: any(
918
+ x
919
+ in self.datamodel.get_file_column_list()
920
+ + self.datamodel.get_image_column_list()
921
+ for x in self.edit_columns
922
+ )
923
+ )
924
+ """
925
+ A flag to indicate if the edit endpoint is using form data.
926
+
855
927
  DO NOT MODIFY.
856
928
  """
857
929
 
@@ -916,6 +988,14 @@ class ModelRestApi(BaseApi):
916
988
  # Instantiate all the filters
917
989
  if self.base_filters:
918
990
  self.base_filters = self._init_filters(self.datamodel, self.base_filters)
991
+ if self.base_opr_filters:
992
+ self.base_opr_filters = self._init_filters(
993
+ self.datamodel, [["", *x] for x in self.base_opr_filters]
994
+ )
995
+ self.base_opr_filters = [x[1:] for x in self.base_opr_filters]
996
+ if self.opr_filters:
997
+ self.opr_filters = [x(self.datamodel) for x in self.opr_filters]
998
+ self.datamodel.opr_filters = self.opr_filters
919
999
 
920
1000
  field_keys = [
921
1001
  "add_query_rel_fields",
@@ -961,6 +1041,28 @@ class ModelRestApi(BaseApi):
961
1041
  """
962
1042
  return current_permissions(self)
963
1043
 
1044
+ def get_q_and_session_count_factory(self):
1045
+ """
1046
+ Returns a dependency that yields a tuple of (QuerySchema, session or None).
1047
+
1048
+ If `list_query_count_enabled` is False or `q.with_count` is False, the session will be None.
1049
+
1050
+ Returns:
1051
+ Callable[..., Coroutine[Any, Any, Tuple[QuerySchema, AsyncSession | Session | None]]]: The dependency that yields the tuple.
1052
+ """
1053
+ session_factory = self.datamodel.get_session_factory()
1054
+
1055
+ async def dependency(q: self.query_schema = Depends()): # type: ignore
1056
+ q: QuerySchema = q
1057
+ if not self.list_query_count_enabled or not q.with_count:
1058
+ yield q, None
1059
+ return
1060
+
1061
+ async for session in session_factory():
1062
+ yield q, session
1063
+
1064
+ return dependency
1065
+
964
1066
  """
965
1067
  -------------
966
1068
  ROUTES
@@ -981,12 +1083,15 @@ class ModelRestApi(BaseApi):
981
1083
  expose(
982
1084
  "/_image/{filename}",
983
1085
  methods=["GET"],
984
- name="Get Image",
985
- description="Get an image from the application.",
986
- response_class=FileResponse,
987
- dependencies=[Depends(has_access_dependency(self, "image"))]
988
- if "image" in self.protected_routes
989
- else None,
1086
+ **{
1087
+ "name": "Get Image",
1088
+ "description": f"Get an image associated with an existing {self.datamodel.obj.__name__} item.",
1089
+ "response_class": FileResponse,
1090
+ "dependencies": [Depends(has_access_dependency(self, "image"))]
1091
+ if "image" in self.protected_routes
1092
+ else None,
1093
+ **self.expose_params.get("image", {}),
1094
+ },
990
1095
  )(self.image_headless)
991
1096
 
992
1097
  def file(self):
@@ -1003,12 +1108,15 @@ class ModelRestApi(BaseApi):
1003
1108
  expose(
1004
1109
  "/_file/{filename}",
1005
1110
  methods=["GET"],
1006
- name="Get File",
1007
- description="Get a file from the application.",
1008
- response_class=FileResponse,
1009
- dependencies=[Depends(has_access_dependency(self, "file"))]
1010
- if "file" in self.protected_routes
1011
- else None,
1111
+ **{
1112
+ "name": "Get File",
1113
+ "description": f"Get a file associated with an existing {self.datamodel.obj.__name__} item.",
1114
+ "response_class": FileResponse,
1115
+ "dependencies": [Depends(has_access_dependency(self, "file"))]
1116
+ if "file" in self.protected_routes
1117
+ else None,
1118
+ **self.expose_params.get("file", {}),
1119
+ },
1012
1120
  )(self.file_headless)
1013
1121
 
1014
1122
  def info(self):
@@ -1020,12 +1128,15 @@ class ModelRestApi(BaseApi):
1020
1128
  expose(
1021
1129
  "/_info",
1022
1130
  methods=["GET"],
1023
- name="Get Info",
1024
- description="Get the info for this API's Model.",
1025
- response_model=self.info_return_schema | typing.Any,
1026
- dependencies=[Depends(has_access_dependency(self, "info"))]
1027
- if "info" in self.protected_routes
1028
- else None,
1131
+ **{
1132
+ "name": "Get Info",
1133
+ "description": f"Get metadata information about the model {self.datamodel.obj.__name__}. such as add/edit columns, filter options, titles, and JSONForms schemas.",
1134
+ "response_model": self.info_return_schema | typing.Any,
1135
+ "dependencies": [Depends(has_access_dependency(self, "info"))]
1136
+ if "info" in self.protected_routes
1137
+ else None,
1138
+ **self.expose_params.get("info", {}),
1139
+ },
1029
1140
  )(self.info_headless)
1030
1141
 
1031
1142
  def download(self):
@@ -1037,11 +1148,14 @@ class ModelRestApi(BaseApi):
1037
1148
  expose(
1038
1149
  "/download",
1039
1150
  methods=["GET"],
1040
- name="Download",
1041
- description="Download list of items in CSV format.",
1042
- dependencies=[Depends(has_access_dependency(self, "download"))]
1043
- if "download" in self.protected_routes
1044
- else None,
1151
+ **{
1152
+ "name": "Download",
1153
+ "description": f"Download list of {self.datamodel.obj.__name__} items as a CSV file with support for filtering, ordering, and global search.",
1154
+ "dependencies": [Depends(has_access_dependency(self, "download"))]
1155
+ if "download" in self.protected_routes
1156
+ else None,
1157
+ **self.expose_params.get("download", {}),
1158
+ },
1045
1159
  )(self.download_headless)
1046
1160
 
1047
1161
  #! Disabled until further notice
@@ -1068,11 +1182,14 @@ class ModelRestApi(BaseApi):
1068
1182
  expose(
1069
1183
  "/bulk/{handler}",
1070
1184
  methods=["POST"],
1071
- name="Bulk",
1072
- description="Handle bulk operations.",
1073
- dependencies=[Depends(has_access_dependency(self, "bulk"))]
1074
- if "bulk" in self.protected_routes
1075
- else None,
1185
+ **{
1186
+ "name": "Bulk",
1187
+ "description": f"Handle bulk operations for the model {self.datamodel.obj.__name__} that are set in the API.",
1188
+ "dependencies": [Depends(has_access_dependency(self, "bulk"))]
1189
+ if "bulk" in self.protected_routes
1190
+ else None,
1191
+ **self.expose_params.get("bulk", {}),
1192
+ },
1076
1193
  )(self.bulk_headless)
1077
1194
 
1078
1195
  def get_list(self):
@@ -1084,12 +1201,15 @@ class ModelRestApi(BaseApi):
1084
1201
  expose(
1085
1202
  "/",
1086
1203
  methods=["GET"],
1087
- name="Get items",
1088
- description="Get a list of items.",
1089
- response_model=self.list_return_schema | typing.Any,
1090
- dependencies=[Depends(has_access_dependency(self, "get"))]
1091
- if "get_list" in self.protected_routes
1092
- else None,
1204
+ **{
1205
+ "name": "Get items",
1206
+ "description": f"Get a list of {self.datamodel.obj.__name__} items with support for column selection, filtering, ordering, pagination, and global search.",
1207
+ "response_model": self.list_return_schema | typing.Any,
1208
+ "dependencies": [Depends(has_access_dependency(self, "get"))]
1209
+ if "get_list" in self.protected_routes
1210
+ else None,
1211
+ **self.expose_params.get("get_list", {}),
1212
+ },
1093
1213
  )(self.get_list_headless)
1094
1214
 
1095
1215
  def get(self):
@@ -1101,12 +1221,15 @@ class ModelRestApi(BaseApi):
1101
1221
  expose(
1102
1222
  "/{id}",
1103
1223
  methods=["GET"],
1104
- name="Get item",
1105
- description="Get a single item.",
1106
- response_model=self.show_return_schema | typing.Any,
1107
- dependencies=[Depends(has_access_dependency(self, "get"))]
1108
- if "get" in self.protected_routes
1109
- else None,
1224
+ **{
1225
+ "name": "Get item",
1226
+ "description": f"Get a single {self.datamodel.obj.__name__} item.",
1227
+ "response_model": self.show_return_schema | typing.Any,
1228
+ "dependencies": [Depends(has_access_dependency(self, "get"))]
1229
+ if "get" in self.protected_routes
1230
+ else None,
1231
+ **self.expose_params.get("get", {}),
1232
+ },
1110
1233
  )(self.get_headless)
1111
1234
 
1112
1235
  def post(self):
@@ -1118,12 +1241,15 @@ class ModelRestApi(BaseApi):
1118
1241
  expose(
1119
1242
  "/",
1120
1243
  methods=["POST"],
1121
- name="Add item",
1122
- description="Add a new item.",
1123
- response_model=self.add_return_schema | typing.Any,
1124
- dependencies=[Depends(has_access_dependency(self, "post"))]
1125
- if "post" in self.protected_routes
1126
- else None,
1244
+ **{
1245
+ "name": "Add item",
1246
+ "description": f"Add a new {self.datamodel.obj.__name__} item.",
1247
+ "response_model": self.add_return_schema | typing.Any,
1248
+ "dependencies": [Depends(has_access_dependency(self, "post"))]
1249
+ if "post" in self.protected_routes
1250
+ else None,
1251
+ **self.expose_params.get("post", {}),
1252
+ },
1127
1253
  )(self.post_headless)
1128
1254
 
1129
1255
  def put(self):
@@ -1135,12 +1261,15 @@ class ModelRestApi(BaseApi):
1135
1261
  expose(
1136
1262
  "/{id}",
1137
1263
  methods=["PUT"],
1138
- name="Update item",
1139
- description="Update an item.",
1140
- response_model=self.edit_return_schema | typing.Any,
1141
- dependencies=[Depends(has_access_dependency(self, "put"))]
1142
- if "put" in self.protected_routes
1143
- else None,
1264
+ **{
1265
+ "name": "Update item",
1266
+ "description": f"Update an existing {self.datamodel.obj.__name__} item.",
1267
+ "response_model": self.edit_return_schema | typing.Any,
1268
+ "dependencies": [Depends(has_access_dependency(self, "put"))]
1269
+ if "put" in self.protected_routes
1270
+ else None,
1271
+ **self.expose_params.get("put", {}),
1272
+ },
1144
1273
  )(self.put_headless)
1145
1274
 
1146
1275
  def delete(self):
@@ -1152,12 +1281,15 @@ class ModelRestApi(BaseApi):
1152
1281
  expose(
1153
1282
  "/{id}",
1154
1283
  methods=["DELETE"],
1155
- response_model=GeneralResponse | typing.Any,
1156
- name="Delete item",
1157
- description="Delete an item.",
1158
- dependencies=[Depends(has_access_dependency(self, "delete"))]
1159
- if "delete" in self.protected_routes
1160
- else None,
1284
+ **{
1285
+ "name": "Delete item",
1286
+ "description": f"Delete an existing {self.datamodel.obj.__name__} item.",
1287
+ "response_model": GeneralResponse | typing.Any,
1288
+ "dependencies": [Depends(has_access_dependency(self, "delete"))]
1289
+ if "delete" in self.protected_routes
1290
+ else None,
1291
+ **self.expose_params.get("delete", {}),
1292
+ },
1161
1293
  )(self.delete_headless)
1162
1294
 
1163
1295
  """
@@ -1177,12 +1309,12 @@ class ModelRestApi(BaseApi):
1177
1309
  filename = pre_image
1178
1310
  else:
1179
1311
  return pre_image
1180
- is_image_exist = self.image_manager.file_exists(filename)
1312
+ is_image_exist = self.datamodel.image_manager.file_exists(filename)
1181
1313
  if not is_image_exist:
1182
1314
  raise HTTPException(
1183
- status_code=404, detail=f"Image '{filename}' not found."
1315
+ fastapi.status.HTTP_404_NOT_FOUND, ErrorCode.IMAGE_NOT_FOUND
1184
1316
  )
1185
- return StreamingResponse(self.image_manager.stream_file(filename))
1317
+ return StreamingResponse(self.datamodel.image_manager.stream_file(filename))
1186
1318
 
1187
1319
  async def file_headless(self, filename: str):
1188
1320
  """
@@ -1195,12 +1327,12 @@ class ModelRestApi(BaseApi):
1195
1327
  filename = pre_file
1196
1328
  else:
1197
1329
  return pre_file
1198
- is_file_exist = self.file_manager.file_exists(filename)
1330
+ is_file_exist = self.datamodel.file_manager.file_exists(filename)
1199
1331
  if not is_file_exist:
1200
1332
  raise HTTPException(
1201
- status_code=404, detail=f"File '{filename}' not found."
1333
+ fastapi.status.HTTP_404_NOT_FOUND, ErrorCode.FILE_NOT_FOUND
1202
1334
  )
1203
- return StreamingResponse(self.file_manager.stream_file(filename))
1335
+ return StreamingResponse(self.datamodel.file_manager.stream_file(filename))
1204
1336
 
1205
1337
  async def info_headless(
1206
1338
  self,
@@ -1241,7 +1373,7 @@ class ModelRestApi(BaseApi):
1241
1373
  q: QuerySchema = SelfType.with_depends().query_schema,
1242
1374
  session: AsyncSession
1243
1375
  | Session = SelfDepends().datamodel.get_download_session_factory,
1244
- export_mode: ExportMode = "simplified",
1376
+ export_mode: CSVJSONConverter.ExportMode = "simplified",
1245
1377
  delimiter: str = ",",
1246
1378
  quotechar: str = '"',
1247
1379
  label: str = "",
@@ -1253,7 +1385,7 @@ class ModelRestApi(BaseApi):
1253
1385
  Args:
1254
1386
  body (QueryBody): The query body.
1255
1387
  session (AsyncSession | Session): A database scoped session.
1256
- export_mode (str): The export mode. Can be "simplified" or "detailed". Defaults to "simplified".
1388
+ export_mode (ExportMode): The export mode. Can be "simplified" or "detailed". Defaults to "simplified".
1257
1389
  delimiter (str): The delimiter for the CSV file. Defaults to ",".
1258
1390
  quotechar (str): Quote character for the CSV file. Defaults to '"'.
1259
1391
  label (str): The label for the CSV file. Defaults to the resource name.
@@ -1290,7 +1422,7 @@ class ModelRestApi(BaseApi):
1290
1422
 
1291
1423
  if check_validity:
1292
1424
  await smart_run(self.datamodel.close, session)
1293
- return GeneralResponse(detail="OK")
1425
+ return GeneralResponse(detail=translate("OK"))
1294
1426
 
1295
1427
  params = self._handle_query_params(q)
1296
1428
  params["page_size"] = 100 # Set a default page size for export
@@ -1337,7 +1469,7 @@ class ModelRestApi(BaseApi):
1337
1469
  # """
1338
1470
  # if not file.content_type.endswith("csv"):
1339
1471
  # raise HTTPException(
1340
- # status_code=400,
1472
+ # fastapi.status.HTTP_400_BAD_REQUEST,
1341
1473
  # detail="Invalid file type. Only CSV files are allowed.",
1342
1474
  # )
1343
1475
 
@@ -1368,11 +1500,13 @@ class ModelRestApi(BaseApi):
1368
1500
  async with AsyncTaskRunner():
1369
1501
  bulk_handler: Callable | None = getattr(self, f"bulk_{handler}", None)
1370
1502
  if not bulk_handler:
1371
- raise HTTPException(status_code=404, detail="Handler not found")
1503
+ raise HTTPException(
1504
+ fastapi.status.HTTP_404_NOT_FOUND, ErrorCode.HANDLER_NOT_FOUND
1505
+ )
1372
1506
  try:
1373
1507
  return await smart_run(bulk_handler, body, self.datamodel, session)
1374
1508
  except NotImplementedError as e:
1375
- raise HTTPException(status_code=404, detail=str(e))
1509
+ raise HTTPException(fastapi.status.HTTP_404_NOT_FOUND, str(e))
1376
1510
 
1377
1511
  async def bulk_handler(
1378
1512
  self,
@@ -1407,19 +1541,17 @@ class ModelRestApi(BaseApi):
1407
1541
 
1408
1542
  async def get_list_headless(
1409
1543
  self,
1410
- q: QuerySchema = SelfType.with_depends().query_schema,
1411
1544
  session: AsyncSession | Session = SelfDepends().datamodel.get_session_factory,
1412
- session_count: AsyncSession
1413
- | Session = SelfDepends().datamodel.get_session_factory,
1545
+ q_session_count: tuple[
1546
+ QuerySchema, AsyncSession | Session | None
1547
+ ] = SelfDepends().get_q_and_session_count_factory,
1414
1548
  ):
1415
1549
  """
1416
1550
  Retrieves all items in a headless mode.
1417
1551
 
1418
1552
  Args:
1419
- q (QuerySchema): The query schema.
1420
- query (QueryManager): The query manager object.
1421
1553
  session (AsyncSession | Session): A database scoped session.
1422
- session_count (AsyncSession | Session): A database scoped session for counting.
1554
+ q_session_count (tuple[QuerySchema, AsyncSession | Session | None]): A tuple of query schema and a database scoped session for counting.
1423
1555
 
1424
1556
  Returns:
1425
1557
  list_return_schema: The list return schema.
@@ -1428,6 +1560,7 @@ class ModelRestApi(BaseApi):
1428
1560
  If you are overriding this method, make sure to copy all the decorators too.
1429
1561
  """
1430
1562
  async with AsyncTaskRunner():
1563
+ q, session_count = q_session_count
1431
1564
  self._validate_query_parameters(q)
1432
1565
  params = self._handle_query_params(q)
1433
1566
  try:
@@ -1435,9 +1568,14 @@ class ModelRestApi(BaseApi):
1435
1568
  task_items = tg.create_task(
1436
1569
  smart_run(self.datamodel.get_many, session, params)
1437
1570
  )
1438
- task_count = tg.create_task(
1439
- smart_run(self.datamodel.count, session_count, params)
1440
- )
1571
+ if session_count:
1572
+ task_count = tg.create_task(
1573
+ smart_run(self.datamodel.count, session_count, params)
1574
+ )
1575
+ else:
1576
+ task_count = tg.create_task(
1577
+ asyncio.sleep(0, result=None)
1578
+ ) # Dummy task
1441
1579
  items, count = await task_items, await task_count
1442
1580
  pks, data = self.datamodel.convert_to_result(items)
1443
1581
  schema = self.list_return_schema
@@ -1500,10 +1638,13 @@ class ModelRestApi(BaseApi):
1500
1638
  "list_columns": self.show_select_columns,
1501
1639
  "where_id": id,
1502
1640
  "filter_classes": self.base_filters,
1641
+ "opr_filter_classes": self.base_opr_filters,
1503
1642
  },
1504
1643
  )
1505
1644
  if not item:
1506
- raise HTTPException(status_code=404, detail="Item not found")
1645
+ raise HTTPException(
1646
+ fastapi.status.HTTP_404_NOT_FOUND, ErrorCode.ITEM_NOT_FOUND
1647
+ )
1507
1648
  pk, data = self.datamodel.convert_to_result(item)
1508
1649
  body = await smart_run(
1509
1650
  self.show_return_schema,
@@ -1606,10 +1747,13 @@ class ModelRestApi(BaseApi):
1606
1747
  "list_columns": self.show_select_columns,
1607
1748
  "where_id": id,
1608
1749
  "filter_classes": self.base_filters,
1750
+ "opr_filter_classes": self.base_opr_filters,
1609
1751
  },
1610
1752
  )
1611
1753
  if not item:
1612
- raise HTTPException(status_code=404, detail="Item not found")
1754
+ raise HTTPException(
1755
+ fastapi.status.HTTP_404_NOT_FOUND, ErrorCode.ITEM_NOT_FOUND
1756
+ )
1613
1757
  body_json = await smart_run(
1614
1758
  self._process_body,
1615
1759
  session,
@@ -1681,10 +1825,13 @@ class ModelRestApi(BaseApi):
1681
1825
  "list_columns": self.show_select_columns,
1682
1826
  "where_id": id,
1683
1827
  "filter_classes": self.base_filters,
1828
+ "opr_filter_classes": self.base_opr_filters,
1684
1829
  },
1685
1830
  )
1686
1831
  if not item:
1687
- raise HTTPException(status_code=404, detail="Item not found")
1832
+ raise HTTPException(
1833
+ fastapi.status.HTTP_404_NOT_FOUND, ErrorCode.ITEM_NOT_FOUND
1834
+ )
1688
1835
  pre_delete = await smart_run(
1689
1836
  self.pre_delete,
1690
1837
  item,
@@ -1700,19 +1847,29 @@ class ModelRestApi(BaseApi):
1700
1847
  x
1701
1848
  for x in self.datamodel.get_file_column_list()
1702
1849
  + self.datamodel.get_image_column_list()
1703
- if getattr(item, x, None)
1704
1850
  ]
1851
+ schema = self.datamodel.generate_schema(
1852
+ file_and_image_columns,
1853
+ with_id=False,
1854
+ with_name=False,
1855
+ with_property=False,
1856
+ )
1857
+ schema_data = schema.model_validate(item, from_attributes=True)
1705
1858
  for column in file_and_image_columns:
1706
1859
  fm = (
1707
- self.file_manager
1860
+ self.datamodel.file_manager
1708
1861
  if self.datamodel.is_file(column)
1709
- else self.image_manager
1862
+ else self.datamodel.image_manager
1710
1863
  )
1711
- AsyncTaskRunner.add_task(
1712
- lambda fm=fm, filename=getattr(item, column): smart_run(
1713
- fm.delete_file, filename
1864
+ filenames = getattr(schema_data, column, None) or []
1865
+ if not isinstance(filenames, list):
1866
+ filenames = [filenames]
1867
+ for filename in filenames:
1868
+ AsyncTaskRunner.add_task(
1869
+ lambda fm=fm, filename=filename: smart_run(
1870
+ fm.delete_file, filename
1871
+ )
1714
1872
  )
1715
- )
1716
1873
  await smart_run(self.datamodel.delete, session, item)
1717
1874
  post_delete = await smart_run(
1718
1875
  self.post_delete,
@@ -1724,8 +1881,7 @@ class ModelRestApi(BaseApi):
1724
1881
  item = post_delete
1725
1882
  else:
1726
1883
  return post_delete
1727
- body = GeneralResponse(detail="Item deleted")
1728
- return body
1884
+ return fastapi.Response(status_code=fastapi.status.HTTP_204_NO_CONTENT)
1729
1885
 
1730
1886
  """
1731
1887
  -------------
@@ -1942,12 +2098,12 @@ class ModelRestApi(BaseApi):
1942
2098
  type = related_interface.id_schema | RelInfo
1943
2099
  if optional or self.datamodel.is_nullable(col):
1944
2100
  type = (
1945
- type
1946
- | None
1947
- | typing.Annotated[
2101
+ typing.Annotated[
1948
2102
  typing.Literal["null"],
1949
2103
  pydantic.AfterValidator(lambda _: None),
1950
2104
  ]
2105
+ | type
2106
+ | None
1951
2107
  )
1952
2108
  rel_schema[col] = (
1953
2109
  type,
@@ -1962,9 +2118,7 @@ class ModelRestApi(BaseApi):
1962
2118
  )
1963
2119
  if optional or self.datamodel.is_nullable(col):
1964
2120
  type = (
1965
- type
1966
- | None
1967
- | typing.Annotated[
2121
+ typing.Annotated[
1968
2122
  list[str],
1969
2123
  pydantic.AfterValidator(
1970
2124
  lambda value: []
@@ -1972,6 +2126,8 @@ class ModelRestApi(BaseApi):
1972
2126
  else value
1973
2127
  ),
1974
2128
  ]
2129
+ | type
2130
+ | None
1975
2131
  )
1976
2132
  related_interface = self.datamodel.get_related_interface(col)
1977
2133
  rel_schema[col] = (
@@ -2203,32 +2359,52 @@ class ModelRestApi(BaseApi):
2203
2359
  continue
2204
2360
 
2205
2361
  fm = (
2206
- self.file_manager
2362
+ self.datamodel.file_manager
2207
2363
  if self.datamodel.is_file(key)
2208
- else self.image_manager
2364
+ else self.datamodel.image_manager
2209
2365
  )
2210
2366
 
2211
- # Delete existing file or image if it is being updated
2212
- if item and hasattr(item, key) and getattr(item, key):
2213
- old_filename = getattr(item, key)
2214
- AsyncTaskRunner.add_task(
2215
- lambda fm=fm, old_filename=old_filename: smart_run(
2216
- fm.delete_file, old_filename
2217
- ),
2218
- tags=["file"],
2367
+ if self.datamodel.is_files(key) or self.datamodel.is_images(key):
2368
+ value = [x for x in value if x] # Remove None values
2369
+ old_filenames = (
2370
+ [x for x in value if isinstance(x, str)] if value else []
2219
2371
  )
2372
+ if item and hasattr(item, key) and getattr(item, key):
2373
+ actual_old_filenames = getattr(item, key)
2374
+ # Delete only the files or images that are not in the new old_filenames
2375
+ for filename in actual_old_filenames:
2376
+ if filename not in old_filenames:
2377
+ AsyncTaskRunner.add_task(
2378
+ lambda fm=fm, old_filename=filename: smart_run(
2379
+ fm.delete_file, old_filename
2380
+ ),
2381
+ tags=["file"],
2382
+ )
2383
+
2384
+ new_filenames = []
2385
+ # Loop through value instead of only file values so the order is maintained
2386
+ for file in value:
2387
+ if file in old_filenames:
2388
+ new_filenames.append(file)
2389
+ continue
2390
+ new_filenames.append(
2391
+ await self._process_body_file(fm, file, key)
2392
+ )
2393
+ value = new_filenames
2394
+ else:
2395
+ # Delete existing file or image if it is being updated
2396
+ if item and hasattr(item, key) and getattr(item, key):
2397
+ filename = getattr(item, key)
2398
+ AsyncTaskRunner.add_task(
2399
+ lambda fm=fm, old_filename=filename: smart_run(
2400
+ fm.delete_file, old_filename
2401
+ ),
2402
+ tags=["file"],
2403
+ )
2220
2404
 
2221
- # Only process if the value exists and is not None
2222
- if value:
2223
- new_name = fm.generate_name(value.filename)
2224
- content = await value.read()
2225
- AsyncTaskRunner.add_task(
2226
- lambda fm=fm, content=content, new_name=new_name: smart_run(
2227
- fm.save_content_to_file, content, new_name
2228
- ),
2229
- tags=["file"],
2230
- )
2231
- value = new_name
2405
+ # Only process if the value exists and is not None
2406
+ if value:
2407
+ value = await self._process_body_file(fm, value, key)
2232
2408
 
2233
2409
  new_body[key] = value
2234
2410
  continue
@@ -2259,8 +2435,17 @@ class ModelRestApi(BaseApi):
2259
2435
  )
2260
2436
  # If the length is not equal, then some items were not found
2261
2437
  if len(related_items) != len(value):
2262
- raise HTTPException(
2263
- status_code=400, detail=f"Some items in {key} not found"
2438
+ raise HTTPWithValidationException(
2439
+ fastapi.status.HTTP_400_BAD_REQUEST,
2440
+ "greater_than"
2441
+ if len(value) < len(related_items)
2442
+ else "less_than",
2443
+ "body",
2444
+ key,
2445
+ translate(
2446
+ "Number of items in '{column}' does not match the number of items found.",
2447
+ column=key,
2448
+ ),
2264
2449
  )
2265
2450
 
2266
2451
  new_body[key] = related_items
@@ -2273,11 +2458,57 @@ class ModelRestApi(BaseApi):
2273
2458
  params={"where_id": value, "filter_classes": filter_dict.get(key)},
2274
2459
  )
2275
2460
  if not related_item:
2276
- raise HTTPException(400, detail=f"{key} not found")
2461
+ raise HTTPWithValidationException(
2462
+ fastapi.status.HTTP_400_BAD_REQUEST,
2463
+ "missing",
2464
+ "body",
2465
+ key,
2466
+ translate(
2467
+ "Could not find related item for column '{column}'", column=key
2468
+ ),
2469
+ )
2277
2470
  new_body[key] = related_item
2278
2471
 
2279
2472
  return new_body
2280
2473
 
2474
+ async def _process_body_file(
2475
+ self, fm: AbstractFileManager, file: fastapi.UploadFile, key: str
2476
+ ):
2477
+ """
2478
+ Process the file upload by saving it using the provided file manager and returning the new filename.
2479
+
2480
+ Args:
2481
+ fm (AbstractFileManager): The file manager to handle file operations.
2482
+ file (fastapi.UploadFile): The uploaded file.
2483
+ key (str): The key of the file in the request body.
2484
+
2485
+ Raises:
2486
+ HTTPWithValidationException: If the file type is not allowed.
2487
+
2488
+ Returns:
2489
+ str: The new filename after saving the file.
2490
+ """
2491
+ new_name = fm.generate_name(file.filename)
2492
+ if not fm.is_filename_allowed(new_name):
2493
+ raise HTTPWithValidationException(
2494
+ fastapi.status.HTTP_400_BAD_REQUEST,
2495
+ "value_error",
2496
+ "body",
2497
+ key,
2498
+ translate(
2499
+ "File type from '{filename}' is not allowed.",
2500
+ filename=file.filename,
2501
+ ),
2502
+ )
2503
+ content = await file.read()
2504
+ AsyncTaskRunner.add_task(
2505
+ lambda fm=fm, content=content, new_name=new_name: smart_run(
2506
+ fm.save_content_to_file, content, new_name
2507
+ ),
2508
+ tags=["file"],
2509
+ )
2510
+ return new_name
2511
+
2281
2512
  """
2282
2513
  -----------------------------------------
2283
2514
  HELPER FUNCTIONS
@@ -2395,12 +2626,18 @@ class ModelRestApi(BaseApi):
2395
2626
  Returns:
2396
2627
  dict: The generated JSONForms schema.
2397
2628
  """
2398
- jsonforms_schema = schema.model_json_schema()
2629
+ cache_key = f"jsonforms_schema_{schema.__name__}"
2630
+ jsonforms_schema = self.cache.get(cache_key)
2631
+ if not jsonforms_schema:
2632
+ self.cache[cache_key] = jsonforms_schema = schema.model_json_schema()
2399
2633
 
2400
2634
  # Remove unused vars
2401
2635
  jsonforms_schema.pop("$defs", None)
2402
2636
 
2403
- for key, value in jsonforms_schema["properties"].items():
2637
+ result = jsonforms_schema.copy()
2638
+ result["properties"] = jsonforms_schema["properties"].copy()
2639
+ for key, value in result["properties"].items():
2640
+ value = value.copy()
2404
2641
  label = self.label_columns.get(key)
2405
2642
  if label:
2406
2643
  value["title"] = label
@@ -2411,25 +2648,23 @@ class ModelRestApi(BaseApi):
2411
2648
 
2412
2649
  if self.datamodel.is_file(key) or self.datamodel.is_image(key):
2413
2650
  value["type"] = "string"
2414
- value["contentMediaType"] = (
2415
- ", ".join(
2416
- [
2417
- f"application/{ext}"
2418
- for ext in self.file_manager.allowed_extensions
2419
- ]
2420
- )
2421
- if self.file_manager.allowed_extensions
2422
- else ""
2423
- if self.datamodel.is_file(key)
2424
- else ", ".join(
2425
- [
2426
- f"image/{ext}"
2427
- for ext in self.image_manager.allowed_extensions
2428
- ]
2651
+ allowed_extensions = self.datamodel.list_columns[
2652
+ key
2653
+ ].type.allowed_extensions
2654
+ if allowed_extensions is None:
2655
+ allowed_extensions = (
2656
+ "" if self.datamodel.is_file(key) else "image/*"
2429
2657
  )
2430
- if self.image_manager.allowed_extensions
2431
- else "image/*"
2432
- )
2658
+ else:
2659
+ prefix = "application" if self.datamodel.is_file(key) else "image"
2660
+ allowed_extensions = [
2661
+ f"{prefix}/{ext}" for ext in allowed_extensions
2662
+ ]
2663
+ value["contentMediaType"] = ", ".join(allowed_extensions)
2664
+ if self.datamodel.is_files(key) or self.datamodel.is_images(key):
2665
+ current_value = value.copy()
2666
+ value["type"] = "array"
2667
+ value["items"] = current_value
2433
2668
  elif self.datamodel.is_boolean(key):
2434
2669
  value["type"] = "boolean"
2435
2670
  elif self.datamodel.is_integer(key):
@@ -2488,14 +2723,15 @@ class ModelRestApi(BaseApi):
2488
2723
  value.pop("anyOf", None)
2489
2724
  value.pop("default", None)
2490
2725
  value.pop("$ref", None)
2726
+ value.pop("const", None)
2491
2727
 
2492
2728
  # Check whether the value should be a `secret`
2493
2729
  if key in g.sensitive_data.get(self.datamodel.obj.__name__, []):
2494
2730
  value["format"] = "password"
2495
2731
 
2496
- jsonforms_schema["properties"][key] = value
2732
+ result["properties"][key] = value
2497
2733
 
2498
- return jsonforms_schema
2734
+ return result
2499
2735
 
2500
2736
  async def _export_data(
2501
2737
  self,
@@ -2504,7 +2740,7 @@ class ModelRestApi(BaseApi):
2504
2740
  label_columns: dict[str, str],
2505
2741
  schema: type[BaseModel],
2506
2742
  *,
2507
- export_mode: ExportMode = "simplified",
2743
+ export_mode: CSVJSONConverter.ExportMode = "simplified",
2508
2744
  delimiter: str = ",",
2509
2745
  quotechar: str = '"',
2510
2746
  ):
@@ -2540,11 +2776,8 @@ class ModelRestApi(BaseApi):
2540
2776
 
2541
2777
  async for chunk in data:
2542
2778
  for item in chunk:
2543
- async with AsyncTaskRunner():
2544
- item_model = schema.model_validate(item, from_attributes=True)
2545
- item_dict = item_model.model_dump(mode="json")
2546
- row = CSVJSONConverter._json_to_csv(
2547
- item_dict,
2779
+ row = await CSVJSONConverter.ajson_to_csv_single(
2780
+ item,
2548
2781
  list_columns=list_columns,
2549
2782
  delimiter=delimiter,
2550
2783
  export_mode=export_mode,
@@ -2604,9 +2837,17 @@ class ModelRestApi(BaseApi):
2604
2837
  },
2605
2838
  )
2606
2839
  if len(items) != len(item):
2607
- raise HTTPException(
2608
- status_code=400,
2609
- detail=f"Length of {col} does not match the number of items found.",
2840
+ raise HTTPWithValidationException(
2841
+ fastapi.status.HTTP_400_BAD_REQUEST,
2842
+ "greater_than"
2843
+ if len(item) < len(items)
2844
+ else "less_than",
2845
+ "body",
2846
+ col,
2847
+ translate(
2848
+ "Number of items in '{column}' does not match the number of items found.",
2849
+ column=col,
2850
+ ),
2610
2851
  )
2611
2852
  item = items
2612
2853
  continue
@@ -2659,14 +2900,26 @@ class ModelRestApi(BaseApi):
2659
2900
  Raises:
2660
2901
  HTTPException: If any of the filters or columns are invalid.
2661
2902
  """
2662
- for filter in q.filters:
2903
+ for index, col in enumerate(q.columns):
2904
+ if col not in self.list_select_columns:
2905
+ raise HTTPWithValidationException(
2906
+ fastapi.status.HTTP_400_BAD_REQUEST,
2907
+ "invalid_key",
2908
+ "query",
2909
+ f"columns[{index}]",
2910
+ translate("Invalid column: {column}", column=col),
2911
+ input=q.columns,
2912
+ )
2913
+ for index, filter in enumerate(q.filters):
2663
2914
  if filter.col not in self.search_columns:
2664
- raise HTTPException(
2665
- status_code=400, detail=f"Invalid filter: {filter.col}"
2915
+ raise HTTPWithValidationException(
2916
+ fastapi.status.HTTP_400_BAD_REQUEST,
2917
+ "invalid_key",
2918
+ "query",
2919
+ f"filters[{index}]",
2920
+ translate("Invalid filter: {column}", column=filter.col),
2921
+ input=filter,
2666
2922
  )
2667
- for col in q.columns:
2668
- if col not in self.list_select_columns:
2669
- raise HTTPException(status_code=400, detail=f"Invalid column: {col}")
2670
2923
 
2671
2924
  def _handle_query_params(self, q: QuerySchema):
2672
2925
  """
@@ -2690,6 +2943,8 @@ class ModelRestApi(BaseApi):
2690
2943
  order_direction=q.order_direction or base_order_direction,
2691
2944
  filters=q.filters,
2692
2945
  filter_classes=self.base_filters,
2946
+ opr_filters=q.opr_filters,
2947
+ opr_filter_classes=self.base_opr_filters,
2693
2948
  global_filter=(self.list_columns, q.global_filter)
2694
2949
  if q.global_filter
2695
2950
  else None,