fastapi-basekit 0.3.1__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/PKG-INFO +1 -1
  2. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/controller/base.py +2 -0
  3. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/repository/base.py +138 -58
  4. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/service/base.py +81 -30
  5. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/controller/base.py +111 -41
  6. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/controller/base.py +3 -0
  7. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/controller/base.py +3 -0
  8. fastapi_basekit-0.3.3/fastapi_basekit/exceptions/__init__.py +3 -0
  9. fastapi_basekit-0.3.3/fastapi_basekit/exceptions/domain.py +57 -0
  10. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit.egg-info/PKG-INFO +1 -1
  11. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit.egg-info/SOURCES.txt +5 -0
  12. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/pyproject.toml +1 -1
  13. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_base_service.py +18 -1
  14. fastapi_basekit-0.3.3/tests/test_beanie_aggregation_hooks.py +484 -0
  15. fastapi_basekit-0.3.3/tests/test_beanie_aggregation_integration.py +199 -0
  16. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_controller_auto_permissions.py +56 -71
  17. fastapi_basekit-0.3.3/tests/test_crud_sqlmodel_repository_service.py +242 -0
  18. fastapi_basekit-0.3.3/tests/test_sql_queryset_override.py +332 -0
  19. fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
  20. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/LICENSE +0 -0
  21. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/README.md +0 -0
  22. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/__init__.py +0 -0
  23. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/__init__.py +0 -0
  24. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  25. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  26. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  27. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  28. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/controller/__init__.py +0 -0
  29. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  30. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/permissions/base.py +0 -0
  31. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  32. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  33. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  34. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/repository/base.py +0 -0
  35. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  36. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/service/base.py +0 -0
  37. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
  38. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
  39. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
  40. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
  41. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
  42. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
  43. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
  44. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/cli/__init__.py +0 -0
  45. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/cli/main.py +0 -0
  46. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  47. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/exceptions/handler.py +0 -0
  48. {fastapi_basekit-0.3.1/fastapi_basekit/exceptions → fastapi_basekit-0.3.3/fastapi_basekit/schema}/__init__.py +0 -0
  49. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/schema/base.py +0 -0
  50. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/schema/jwt.py +0 -0
  51. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/schema/schema.py +0 -0
  52. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/servicios/__init__.py +0 -0
  53. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  54. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  55. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
  56. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
  57. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
  58. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
  59. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
  60. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
  61. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
  62. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
  63. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
  64. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
  65. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
  66. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
  67. {fastapi_basekit-0.3.1/fastapi_basekit/schema → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app}/__init__.py +0 -0
  68. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api}/__init__.py +0 -0
  69. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1}/__init__.py +0 -0
  70. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1 → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints}/__init__.py +0 -0
  71. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
  72. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
  73. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
  74. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
  75. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
  76. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
  77. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
  78. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
  79. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
  80. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware}/__init__.py +0 -0
  81. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
  82. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
  83. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
  84. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
  85. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
  86. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
  87. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
  88. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions}/__init__.py +0 -0
  89. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
  90. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories}/__init__.py +0 -0
  91. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user}/__init__.py +0 -0
  92. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
  93. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas}/__init__.py +0 -0
  94. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
  95. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
  96. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
  97. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts}/__init__.py +0 -0
  98. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
  99. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
  100. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services}/__init__.py +0 -0
  101. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
  102. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
  103. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
  104. {fastapi_basekit-0.3.1/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services → fastapi_basekit-0.3.3/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils}/__init__.py +0 -0
  105. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
  106. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
  107. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
  108. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
  109. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
  110. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  111. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit.egg-info/entry_points.txt +0 -0
  112. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit.egg-info/requires.txt +0 -0
  113. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/fastapi_basekit.egg-info/top_level.txt +0 -0
  114. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/setup.cfg +0 -0
  115. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_api_exceptions.py +0 -0
  116. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_base_response.py +0 -0
  117. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_crud_beanie_controller.py +0 -0
  118. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_crud_controller.py +0 -0
  119. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_jwt_service.py +0 -0
  120. {fastapi_basekit-0.3.1 → fastapi_basekit-0.3.3}/tests/test_sqlalchemy_base_service_order.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -18,6 +18,7 @@ class BeanieBaseController(BaseController):
18
18
 
19
19
  async def list(self):
20
20
  """Lista documentos con paginación usando Beanie."""
21
+ await self.prepare_action("list")
21
22
  params = self._params(skip_frames=2)
22
23
  items, total = await self.service.list(**params)
23
24
  count = params.get("count") or 0
@@ -39,6 +40,7 @@ class BeanieBaseController(BaseController):
39
40
  check_fields: Optional[List[str]] = None,
40
41
  ):
41
42
  """Crea un nuevo documento con validación de campos únicos."""
43
+ await self.prepare_action("create")
42
44
  result = await self.service.create(validated_data, check_fields)
43
45
  return self.format_response(result, message="Creado exitosamente")
44
46
 
@@ -207,27 +207,42 @@ class BaseRepository:
207
207
  # Apply ordering if provided and not already applied
208
208
  if order_by:
209
209
  query = query.sort(order_by)
210
-
210
+
211
211
  total = await query.count()
212
212
  items = await query.skip(count * (page - 1)).limit(count).to_list()
213
213
  return items, total
214
-
215
- async def list_with_aggregation(
214
+
215
+ def build_list_queryset(
216
+ self,
217
+ search: Optional[str] = None,
218
+ search_fields: Optional[List[str]] = None,
219
+ filters: Optional[dict] = None,
220
+ order_by: Optional[List[tuple]] = None,
221
+ **kwargs,
222
+ ) -> FindMany[Document]:
223
+ """Hook: returns the FindMany query used by `list` endpoints.
224
+
225
+ Beanie equivalent of SQLAlchemy `build_list_queryset`. Override at
226
+ repository OR service level (`BaseService.build_list_queryset`) to
227
+ customize filters, projections, or query options before pagination.
228
+ Default implementation delegates to `build_filter_query`.
229
+ """
230
+ return self.build_filter_query(
231
+ search=search,
232
+ search_fields=search_fields or [],
233
+ filters=filters or {},
234
+ order_by=order_by,
235
+ **kwargs,
236
+ )
237
+
238
+ def _build_match_stage(
216
239
  self,
217
240
  search: Optional[str],
218
- search_fields: List[str],
219
- filters: dict,
220
- order_by: str,
221
- page: int,
222
- count: int,
223
- **kwargs
224
- ) -> tuple[List[Document], int]:
225
- """List with support for nested ordering using aggregation ($facet optimized)."""
226
- field_path, direction, is_nested = self._parse_order_field(order_by)
227
- pipeline = []
228
-
229
- # 1. Match stage (same as before)
230
- match_conditions = {}
241
+ search_fields: Optional[List[str]],
242
+ filters: Optional[dict],
243
+ ) -> Dict[str, Any]:
244
+ """Build a `$match` stage dict (without the `$match` wrapper)."""
245
+ match_conditions: Dict[str, Any] = {}
231
246
  if filters:
232
247
  for key, value in filters.items():
233
248
  if isinstance(value, ObjectId):
@@ -236,7 +251,7 @@ class BaseRepository:
236
251
  match_conditions[f"{key}.$id"] = value.id
237
252
  else:
238
253
  match_conditions[key] = value
239
-
254
+
240
255
  if search and search_fields:
241
256
  search_conditions = [
242
257
  {field: {"$regex": f".*{search}.*", "$options": "i"}}
@@ -244,22 +259,47 @@ class BaseRepository:
244
259
  ]
245
260
  if search_conditions:
246
261
  if match_conditions:
247
- match_conditions = {"$and": [match_conditions, {"$or": search_conditions}]}
262
+ match_conditions = {
263
+ "$and": [
264
+ match_conditions,
265
+ {"$or": search_conditions},
266
+ ]
267
+ }
248
268
  else:
249
269
  match_conditions = {"$or": search_conditions}
250
-
270
+ return match_conditions
271
+
272
+ def build_list_pipeline(
273
+ self,
274
+ search: Optional[str] = None,
275
+ search_fields: Optional[List[str]] = None,
276
+ filters: Optional[dict] = None,
277
+ order_by: Optional[str] = None,
278
+ **kwargs,
279
+ ) -> List[Dict[str, Any]]:
280
+ """Hook: returns the aggregation pipeline used by `list` endpoints.
281
+
282
+ Beanie equivalent of SQL subqueries / JOINs. Override at repository
283
+ OR service level (`BaseService.build_list_pipeline`) to add `$lookup`,
284
+ `$project`, `$group`, etc. Default builds `$match` + optional `$sort`
285
+ (with auto `$lookup` for nested-link ordering). The `$facet` pagination
286
+ stage is appended later by `paginate_pipeline`.
287
+ """
288
+ pipeline: List[Dict[str, Any]] = []
289
+
290
+ match_conditions = self._build_match_stage(search, search_fields, filters)
251
291
  if match_conditions:
252
292
  pipeline.append({"$match": match_conditions})
253
-
254
- # 2. Lookup & Sort (same as before)
255
- collection_name = None
256
- first_field = None
257
-
293
+
294
+ if not order_by:
295
+ return pipeline
296
+
297
+ field_path, direction, is_nested = self._parse_order_field(order_by)
298
+
258
299
  if is_nested:
259
300
  parts = field_path.split(".")
260
301
  first_field = parts[0]
261
302
  collection_name = self._get_collection_name_from_field(first_field)
262
-
263
303
  if collection_name:
264
304
  pipeline.extend([
265
305
  {
@@ -267,65 +307,105 @@ class BaseRepository:
267
307
  "from": collection_name,
268
308
  "localField": f"{first_field}.$id",
269
309
  "foreignField": "_id",
270
- "as": f"{first_field}_data"
310
+ "as": f"{first_field}_data",
271
311
  }
272
312
  },
273
313
  {
274
314
  "$unwind": {
275
315
  "path": f"${first_field}_data",
276
- "preserveNullAndEmptyArrays": True
316
+ "preserveNullAndEmptyArrays": True,
277
317
  }
278
- }
318
+ },
279
319
  ])
280
320
  remaining_path = ".".join(parts[1:]) if len(parts) > 1 else ""
281
- sort_field = f"{first_field}_data.{remaining_path}" if remaining_path else f"{first_field}_data"
321
+ sort_field = (
322
+ f"{first_field}_data.{remaining_path}"
323
+ if remaining_path
324
+ else f"{first_field}_data"
325
+ )
282
326
  else:
283
327
  sort_field = field_path
284
328
  else:
285
329
  sort_field = field_path
286
-
330
+
287
331
  pipeline.append({"$sort": {sort_field: direction}})
288
-
289
- # 3. Facet for Single Query Pagination
290
- project_exclusion = {f"{first_field}_data": 0} if (is_nested and collection_name) else None
291
-
292
- facet_stage = {
293
- "$facet": {
294
- "metadata": [{"$count": "total"}],
295
- "data": [
296
- {"$skip": count * (page - 1)},
297
- {"$limit": count}
298
- ]
332
+ return pipeline
333
+
334
+ async def paginate_pipeline(
335
+ self,
336
+ pipeline: List[Dict[str, Any]],
337
+ page: int,
338
+ count: int,
339
+ validate: bool = True,
340
+ ) -> tuple[List[Any], int]:
341
+ """Run an aggregation pipeline with `$facet` pagination.
342
+
343
+ Args:
344
+ pipeline: Pipeline stages (without the final `$facet`).
345
+ page, count: Pagination params.
346
+ validate: If True, validates each row against `self.model`.
347
+ Set False when the pipeline projects a non-model shape (e.g.
348
+ joined columns) — the raw dicts are returned untouched.
349
+ """
350
+ full_pipeline = list(pipeline) + [
351
+ {
352
+ "$facet": {
353
+ "metadata": [{"$count": "total"}],
354
+ "data": [
355
+ {"$skip": count * (page - 1)},
356
+ {"$limit": count},
357
+ ],
358
+ }
299
359
  }
300
- }
301
-
302
- # Add projection to remove join artifacts if needed
303
- if project_exclusion:
304
- facet_stage["$facet"]["data"].append({"$project": project_exclusion})
305
-
306
- pipeline.append(facet_stage)
307
-
308
- # Execute Pipeline
309
- results = await self.model.aggregate(pipeline).to_list()
310
-
311
- # Process Results
360
+ ]
361
+
362
+ results = await self.model.aggregate(full_pipeline).to_list()
312
363
  if not results or not results[0].get("metadata"):
313
364
  return [], 0
314
-
365
+
315
366
  data = results[0]
316
367
  total = data["metadata"][0]["total"] if data["metadata"] else 0
317
368
  items_raw = data["data"]
318
-
319
- # Efficient Validation
320
- items = []
369
+
370
+ if not validate:
371
+ return items_raw, total
372
+
373
+ items: List[Any] = []
321
374
  for raw_item in items_raw:
322
375
  try:
323
376
  items.append(self.model.model_validate(raw_item))
324
377
  except Exception:
325
378
  continue
326
-
327
379
  return items, total
328
380
 
381
+ async def list_with_aggregation(
382
+ self,
383
+ search: Optional[str],
384
+ search_fields: List[str],
385
+ filters: dict,
386
+ order_by: str,
387
+ page: int,
388
+ count: int,
389
+ **kwargs,
390
+ ) -> tuple[List[Document], int]:
391
+ """Backward-compat wrapper: delegates to `build_list_pipeline` +
392
+ `paginate_pipeline`. Kept for callers that hit it directly.
393
+ """
394
+ pipeline = self.build_list_pipeline(
395
+ search=search,
396
+ search_fields=search_fields,
397
+ filters=filters,
398
+ order_by=order_by,
399
+ **kwargs,
400
+ )
401
+ # Strip join artifacts when default sort-lookup added them
402
+ field_path, _, is_nested = self._parse_order_field(order_by) if order_by else ("", 0, False)
403
+ if is_nested:
404
+ first_field = field_path.split(".")[0]
405
+ if self._get_collection_name_from_field(first_field):
406
+ pipeline.append({"$project": {f"{first_field}_data": 0}})
407
+ return await self.paginate_pipeline(pipeline, page, count, validate=True)
408
+
329
409
  async def get_by_id(
330
410
  self,
331
411
  obj_id: Union[str, ObjectId],
@@ -1,5 +1,7 @@
1
1
  from typing import Any, Dict, List, Optional, Union
2
2
 
3
+ from beanie import Document
4
+ from beanie.odm.queries.find import FindMany
3
5
  from fastapi import Request
4
6
  from pydantic import BaseModel
5
7
 
@@ -20,6 +22,8 @@ class BaseService:
20
22
  kwargs_query: Dict[str, Union[str, int]] = {}
21
23
  action: str = ""
22
24
  order_by: Optional[List[tuple]] = None
25
+ use_aggregation: bool = False
26
+ aggregation_validate: bool = True
23
27
 
24
28
  def __init__(
25
29
  self, repository: BaseRepository, request: Optional[Request] = None
@@ -69,61 +73,108 @@ class BaseService:
69
73
  raise NotFoundException(f"id={id} no encontrado")
70
74
  return obj
71
75
 
76
+ def build_list_queryset(
77
+ self,
78
+ search: Optional[str] = None,
79
+ search_fields: Optional[List[str]] = None,
80
+ filters: Optional[Dict[str, Any]] = None,
81
+ order_by: Optional[List[tuple]] = None,
82
+ **kwargs,
83
+ ) -> FindMany[Document]:
84
+ """Service-level hook over `repository.build_list_queryset`.
85
+
86
+ Override here to compose query options across repositories or to
87
+ decorate the FindMany before pagination.
88
+ """
89
+ return self.repository.build_list_queryset(
90
+ search=search,
91
+ search_fields=search_fields or self.search_fields,
92
+ filters=filters,
93
+ order_by=order_by,
94
+ **kwargs,
95
+ )
96
+
97
+ def build_list_pipeline(
98
+ self,
99
+ search: Optional[str] = None,
100
+ search_fields: Optional[List[str]] = None,
101
+ filters: Optional[Dict[str, Any]] = None,
102
+ order_by: Optional[str] = None,
103
+ **kwargs,
104
+ ) -> List[Dict[str, Any]]:
105
+ """Service-level hook over `repository.build_list_pipeline`.
106
+
107
+ Override here to add `$lookup`, `$project`, `$group`, etc. for
108
+ cross-collection joins (subquery-like). Set `use_aggregation = True`
109
+ on the service to force the pipeline path even without nested order.
110
+ Set `aggregation_validate = False` if the projection produces a
111
+ non-model shape (joined columns / flattened rows).
112
+ """
113
+ return self.repository.build_list_pipeline(
114
+ search=search,
115
+ search_fields=search_fields or self.search_fields,
116
+ filters=filters,
117
+ order_by=order_by,
118
+ **kwargs,
119
+ )
120
+
72
121
  async def list(
73
122
  self,
74
123
  search: Optional[str] = None,
75
124
  page: int = 1,
76
125
  count: int = 25,
77
126
  filters: Optional[Dict[str, Any]] = None,
78
- order_by: Optional[str] = None, # NEW: Dynamic ordering (e.g., "-created_at" or "tool__name")
127
+ order_by: Optional[str] = None, # Dynamic ordering (e.g., "-created_at" or "tool__name")
79
128
  ):
80
129
  kwargs = self.get_kwargs_query()
81
130
  applied_filters = self.get_filters(filters)
82
-
83
- # Determine which ordering to use
131
+
132
+ # Resolve ordering
84
133
  if order_by:
85
- # Dynamic ordering from parameter (takes precedence)
86
134
  order_str = order_by
87
135
  else:
88
- # Fall back to service-level ordering
89
136
  default_order = self.get_order()
90
137
  if default_order:
91
- # Convert list of tuples to string format
92
- # e.g., [("created_at", -1)] -> "-created_at"
93
138
  field, direction = default_order[0]
94
139
  order_str = f"{'-' if direction == -1 else ''}{field}"
95
140
  else:
96
141
  order_str = None
97
-
98
- # Check if we need aggregation (nested field with __ or .)
99
- if order_str and ("__" in order_str or "." in order_str):
100
- # Use aggregation pipeline for nested ordering
101
- return await self.repository.list_with_aggregation(
142
+
143
+ nested_order = bool(order_str and ("__" in order_str or "." in order_str))
144
+ use_pipeline = self.use_aggregation or nested_order
145
+
146
+ if use_pipeline:
147
+ pipeline = self.build_list_pipeline(
102
148
  search=search,
103
149
  search_fields=self.search_fields,
104
150
  filters=applied_filters,
105
151
  order_by=order_str,
106
- page=page,
107
- count=count,
108
152
  **kwargs,
109
153
  )
110
- else:
111
- # Use standard query for simple ordering
112
- order_list = None
113
- if order_str:
114
- # Parse the order string
115
- direction = -1 if order_str.startswith("-") else 1
116
- field = order_str.lstrip("-")
117
- order_list = [(field, direction)]
118
-
119
- query = self.repository.build_filter_query(
120
- search=search,
121
- search_fields=self.search_fields,
122
- filters=applied_filters,
123
- order_by=order_list,
124
- **kwargs,
154
+ return await self.repository.paginate_pipeline(
155
+ pipeline,
156
+ page=page,
157
+ count=count,
158
+ validate=self.aggregation_validate,
125
159
  )
126
- return await self.repository.paginate(query, page, count, order_by=order_list)
160
+
161
+ # FindMany path
162
+ order_list = None
163
+ if order_str:
164
+ direction = -1 if order_str.startswith("-") else 1
165
+ field = order_str.lstrip("-")
166
+ order_list = [(field, direction)]
167
+
168
+ query = self.build_list_queryset(
169
+ search=search,
170
+ search_fields=self.search_fields,
171
+ filters=applied_filters,
172
+ order_by=order_list,
173
+ **kwargs,
174
+ )
175
+ return await self.repository.paginate(
176
+ query, page, count, order_by=order_list
177
+ )
127
178
 
128
179
  async def create(
129
180
  self, payload: BaseModel, check_fields: Optional[List[str]] = None
@@ -14,7 +14,11 @@ class BaseController:
14
14
 
15
15
  service = Depends()
16
16
  schema_class: ClassVar[Type[BaseModel]]
17
- action: ClassVar[Optional[str]] = None
17
+
18
+ # DRF Style: Permisos globales por defecto
19
+ permission_classes: ClassVar[List[Type[BasePermission]]] = []
20
+
21
+ action: Optional[str] = None
18
22
  request: Request
19
23
  _params_excluded_fields: ClassVar[Set[str]] = {
20
24
  "self",
@@ -32,34 +36,73 @@ class BaseController:
32
36
  }
33
37
 
34
38
  def __init__(self) -> None:
35
- endpoint_func = (
36
- self.request.scope.get("endpoint")
37
- if hasattr(self, "request") and self.request
38
- else None
39
- )
40
- self.action = endpoint_func.__name__ if endpoint_func else None
39
+ """Inicializa el controller."""
40
+ pass
41
+
42
+ def get_permissions(self) -> List[Type[BasePermission]]:
43
+ """
44
+ Instancia y retorna la lista de permisos que esta vista requiere.
45
+
46
+ Sobrescribir esto permite lógica tipo DRF:
47
+
48
+ if self.action == 'list':
49
+ return [AllowAny]
50
+ return [IsAuthenticated]
51
+ """
52
+ return self.permission_classes
53
+
54
+ async def prepare_action(self, action_name: str) -> None:
55
+ """Set the current action and run permission checks.
56
+
57
+ Auto-called by ``ControllerMeta`` for every public async method on
58
+ the controller. Idempotent within one invocation: if the same
59
+ ``action_name`` has already been prepared on this instance,
60
+ subsequent calls are no-ops. This lets custom methods opt into
61
+ calling ``await self.prepare_action(...)`` explicitly without
62
+ double-firing permission checks (when the metaclass already ran
63
+ before entering the method body).
64
+ """
65
+ if getattr(self, "_basekit_prepared_action", None) == action_name:
66
+ return
67
+ self.action = action_name
68
+ self._basekit_prepared_action = action_name
69
+ await self.check_permissions()
70
+
71
+ async def check_permissions(self):
72
+ """Run each declared permission. Raises ``PermissionException``
73
+ on the first denial.
74
+ """
75
+ for permission_class in self.get_permissions():
76
+ permission = permission_class()
77
+ has_perm = await permission.has_permission(self.request)
78
+ if not has_perm:
79
+ message = getattr(
80
+ permission,
81
+ "message_exception",
82
+ "No tienes permiso para realizar esta acción.",
83
+ )
84
+ raise PermissionException(message)
85
+
86
+ async def check_permissions_class(self):
87
+ """Backward-compat alias for ``check_permissions``.
88
+
89
+ Pre-0.3.2 controllers called this manually inside endpoint methods.
90
+ Keep working for users who haven't migrated to ``permission_classes``
91
+ + auto-wrapping yet. New code should declare ``permission_classes``
92
+ on the controller and let the metaclass run permissions.
93
+ """
94
+ await self.check_permissions()
41
95
 
42
96
  def get_schema_class(self) -> Type[BaseModel]:
43
97
  assert self.schema_class is not None, (
44
98
  "'%s' should either include a `schema_class` attribute, "
45
- "or override the `get_serializer_class()` method."
99
+ "or override the `get_schema_class()` method."
46
100
  % self.__class__.__name__
47
101
  )
48
102
  return self.schema_class
49
103
 
50
- async def check_permissions_class(self):
51
- permissions = self.check_permissions()
52
- if permissions:
53
- for permission in permissions:
54
- obj = permission()
55
- check = await obj.has_permission(self.request)
56
- if not check:
57
- raise PermissionException(obj.message_exception)
58
-
59
- def check_permissions(self) -> List[Type[BasePermission]]:
60
- pass
61
-
62
104
  async def list(self):
105
+ await self.prepare_action("list")
63
106
  params = self._params()
64
107
  items, total = await self.service.list(**params)
65
108
  count = params.get("count") or 0
@@ -75,18 +118,22 @@ class BaseController:
75
118
  return self.format_response(data=items, pagination=pagination)
76
119
 
77
120
  async def retrieve(self, id: str):
121
+ await self.prepare_action("retrieve")
78
122
  item = await self.service.retrieve(id)
79
123
  return self.format_response(data=item)
80
124
 
81
125
  async def create(self, validated_data: Any):
126
+ await self.prepare_action("create")
82
127
  result = await self.service.create(validated_data)
83
128
  return self.format_response(result, message="Creado exitosamente")
84
129
 
85
130
  async def update(self, id: str, validated_data: Any):
131
+ await self.prepare_action("update")
86
132
  result = await self.service.update(id, validated_data)
87
133
  return self.format_response(result, message="Actualizado exitosamente")
88
134
 
89
135
  async def delete(self, id: str):
136
+ await self.prepare_action("delete")
90
137
  await self.service.delete(id)
91
138
  return self.format_response(None, message="Eliminado exitosamente")
92
139
 
@@ -99,33 +146,48 @@ class BaseController:
99
146
  ) -> BaseModel:
100
147
  schema = self.get_schema_class()
101
148
 
149
+ # Robust Pydantic v2 validation. Each branch falls back to the
150
+ # raw value when the schema doesn't fit (custom-action endpoints
151
+ # often return ad-hoc dicts that don't match the controller's
152
+ # default schema_class — those should pass through untouched).
102
153
  if isinstance(data, list):
103
154
  data_dicts = [self.to_dict(item) for item in data]
104
- adapter = TypeAdapter(List[schema])
105
- data_parsed = adapter.validate_python(data_dicts)
106
- elif self.service.repository and isinstance(
107
- data, self.service.repository.model
108
- ):
109
- data_parsed = self.to_dict(data)
110
- data_parsed = schema.model_validate(data_parsed)
155
+ try:
156
+ adapter = TypeAdapter(List[schema])
157
+ data_parsed = adapter.validate_python(data_dicts)
158
+ except Exception:
159
+ data_parsed = data_dicts
160
+
111
161
  elif isinstance(data, dict):
112
- data_parsed = schema.model_validate(data)
162
+ try:
163
+ data_parsed = schema.model_validate(data)
164
+ except Exception:
165
+ data_parsed = data
166
+
167
+ elif hasattr(data, "__dict__"):
168
+ data_dict = self.to_dict(data)
169
+ try:
170
+ data_parsed = schema.model_validate(data_dict)
171
+ except Exception:
172
+ data_parsed = data_dict
173
+
174
+ elif data is None:
175
+ data_parsed = None
113
176
  else:
114
177
  data_parsed = data
115
178
 
179
+ response_cls = BasePaginationResponse if pagination else BaseResponse
180
+
181
+ # Construcción dinámica de argumentos
182
+ kwargs = {
183
+ "data": data_parsed,
184
+ "message": message or "Operación exitosa",
185
+ "status": response_status,
186
+ }
116
187
  if pagination:
117
- return BasePaginationResponse(
118
- data=data_parsed,
119
- pagination=pagination,
120
- message=message or "Operación exitosa",
121
- status=response_status,
122
- )
123
- else:
124
- return BaseResponse(
125
- data=data_parsed,
126
- message=message or "Operación exitosa",
127
- status=response_status,
128
- )
188
+ kwargs["pagination"] = pagination
189
+
190
+ return response_cls(**kwargs)
129
191
 
130
192
  def _params(self, skip_frames: int = 1) -> Dict[str, Any]:
131
193
  """
@@ -208,6 +270,14 @@ class BaseController:
208
270
  }
209
271
 
210
272
  def to_dict(self, obj: Any):
211
- if hasattr(obj, "model_dump"):
273
+ """Helper para convertir modelos ORM/Pydantic a dict."""
274
+ if hasattr(obj, "model_dump"): # Pydantic v2
212
275
  return obj.model_dump()
276
+ if hasattr(obj, "dict"): # Pydantic v1
277
+ return obj.dict()
278
+ if hasattr(obj, "__dict__"): # SQLAlchemy models (basic)
279
+ # Filtramos atributos privados de SQLAlchemy
280
+ return {
281
+ k: v for k, v in obj.__dict__.items() if not k.startswith("_")
282
+ }
213
283
  return obj
@@ -48,6 +48,7 @@ class SQLAlchemyBaseController(BaseController):
48
48
  joins: Lista de relaciones a hacer JOIN eager loading
49
49
  order_by: Expresión de ordenamiento (ej: User.created_at.desc())
50
50
  """
51
+ await self.prepare_action("list")
51
52
  params = self._params(skip_frames=2)
52
53
  service_params = {
53
54
  **params,
@@ -73,6 +74,7 @@ class SQLAlchemyBaseController(BaseController):
73
74
  id: ID del registro
74
75
  joins: Lista de relaciones a hacer JOIN eager loading
75
76
  """
77
+ await self.prepare_action("retrieve")
76
78
  item = await self.service.retrieve(id, joins=joins)
77
79
  return self.format_response(data=item)
78
80
 
@@ -89,6 +91,7 @@ class SQLAlchemyBaseController(BaseController):
89
91
  validated_data: Datos validados para crear
90
92
  check_fields: Campos a verificar por duplicados antes de crear
91
93
  """
94
+ await self.prepare_action("create")
92
95
  result = await self.service.create(validated_data, check_fields)
93
96
  return self.format_response(result, message="Creado exitosamente")
94
97