fastapi-basekit 0.4.0__tar.gz → 0.4.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 (126) hide show
  1. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/PKG-INFO +1 -1
  2. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/repository/base.py +63 -2
  3. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/service/base.py +8 -0
  4. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/controller/base.py +0 -1
  5. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -1
  6. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/repository/base.py +12 -1
  7. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/service/base.py +58 -1
  8. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit.egg-info/PKG-INFO +1 -1
  9. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit.egg-info/SOURCES.txt +2 -0
  10. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/pyproject.toml +1 -1
  11. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_action_param_leak.py +16 -0
  12. fastapi_basekit-0.4.3/tests/test_beanie_keyset_pagination.py +75 -0
  13. fastapi_basekit-0.4.3/tests/test_beanie_service_mutable_defaults.py +29 -0
  14. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/LICENSE +0 -0
  15. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/README.md +0 -0
  16. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/__init__.py +0 -0
  17. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/__init__.py +0 -0
  18. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  19. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  20. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
  21. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  22. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  23. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/controller/__init__.py +0 -0
  24. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  25. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/permissions/base.py +0 -0
  26. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  27. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  28. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  29. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  30. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
  31. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
  32. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
  33. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/controller/base.py +0 -0
  34. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
  35. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
  36. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
  37. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
  38. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/cli/__init__.py +0 -0
  39. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/cli/main.py +0 -0
  40. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/exceptions/__init__.py +0 -0
  41. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  42. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/exceptions/domain.py +0 -0
  43. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/exceptions/handler.py +0 -0
  44. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/openapi.py +0 -0
  45. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/schema/__init__.py +0 -0
  46. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/schema/base.py +0 -0
  47. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/schema/jwt.py +0 -0
  48. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/schema/schema.py +0 -0
  49. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/servicios/__init__.py +0 -0
  50. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  51. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  52. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
  53. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
  54. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
  55. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
  56. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
  57. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
  58. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
  59. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
  60. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
  61. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
  62. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
  63. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
  64. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
  65. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
  66. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/__init__.py +0 -0
  67. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/__init__.py +0 -0
  68. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
  69. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
  70. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
  71. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
  72. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
  73. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
  74. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
  75. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
  76. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
  77. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
  78. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
  79. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
  80. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
  81. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
  82. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
  83. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
  84. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
  85. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/__init__.py +0 -0
  86. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
  87. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
  88. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/__init__.py +0 -0
  89. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
  90. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/__init__.py +0 -0
  91. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
  92. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
  93. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
  94. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/__init__.py +0 -0
  95. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
  96. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
  97. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
  98. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
  99. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
  100. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
  101. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
  102. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
  103. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
  104. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
  105. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
  106. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
  107. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  108. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit.egg-info/entry_points.txt +0 -0
  109. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit.egg-info/requires.txt +0 -0
  110. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/fastapi_basekit.egg-info/top_level.txt +0 -0
  111. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/setup.cfg +0 -0
  112. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_api_exceptions.py +0 -0
  113. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_base_response.py +0 -0
  114. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_base_service.py +0 -0
  115. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_beanie_aggregation_hooks.py +0 -0
  116. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_beanie_aggregation_integration.py +0 -0
  117. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_controller_auto_permissions.py +0 -0
  118. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_crud_beanie_controller.py +0 -0
  119. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_crud_controller.py +0 -0
  120. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_crud_sqlmodel_repository_service.py +0 -0
  121. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_exception_handlers.py +0 -0
  122. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_filter_operators.py +0 -0
  123. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_jwt_service.py +0 -0
  124. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_openapi_simplify.py +0 -0
  125. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.3}/tests/test_sql_queryset_override.py +0 -0
  126. {fastapi_basekit-0.4.0 → fastapi_basekit-0.4.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.4.0
3
+ Version: 0.4.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
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from typing import Any, Dict, List, Optional, Type, Union
2
3
  from typing import get_args, get_origin
3
4
  import re
@@ -8,6 +9,8 @@ from beanie import Document, Link
8
9
  from beanie.odm.queries.find import FindMany
9
10
  from beanie.operators import Or, RegEx
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
 
12
15
  class BaseRepository:
13
16
  model: Type[Document]
@@ -212,6 +215,44 @@ class BaseRepository:
212
215
  items = await query.skip(count * (page - 1)).limit(count).to_list()
213
216
  return items, total
214
217
 
218
+ async def paginate_keyset(
219
+ self,
220
+ query: FindMany[Document],
221
+ limit: int,
222
+ cursor_field: str,
223
+ cursor_value: Optional[Any] = None,
224
+ ascending: bool = False,
225
+ ) -> tuple[List[Document], bool]:
226
+ """Keyset (cursor) pagination — O(1) por página, sin `skip` ni `count`.
227
+
228
+ Alternativa a `paginate` para listados que escalan: en vez de contar
229
+ toda la colección y saltar `count*(page-1)` documentos (coste que crece
230
+ con la profundidad), filtra por un cursor sobre `cursor_field` y trae
231
+ `limit + 1` para saber si quedan más — sin un `count()`.
232
+
233
+ Args:
234
+ query: FindMany ya filtrado (los filtros base del listado).
235
+ limit: tamaño de página.
236
+ cursor_field: campo Mongo indexado del cursor (ej. "_id",
237
+ "created_at", "timestamp"). Debe existir un índice que lo cubra.
238
+ cursor_value: valor del último item de la página previa. None =
239
+ primera página.
240
+ ascending: True = orden ascendente (`> cursor`); False = descendente
241
+ (`< cursor`), útil para "más recientes primero".
242
+
243
+ Returns:
244
+ (items, has_more). El caller deriva el próximo cursor del último
245
+ item devuelto (`getattr(items[-1], cursor_field)`).
246
+ """
247
+ limit = max(1, int(limit))
248
+ op = "$gt" if ascending else "$lt"
249
+ if cursor_value is not None:
250
+ query = query.find({cursor_field: {op: cursor_value}})
251
+ query = query.sort((cursor_field, 1 if ascending else -1))
252
+ docs = await query.limit(limit + 1).to_list()
253
+ has_more = len(docs) > limit
254
+ return docs[:limit], has_more
255
+
215
256
  def build_list_queryset(
216
257
  self,
217
258
  search: Optional[str] = None,
@@ -371,11 +412,31 @@ class BaseRepository:
371
412
  return items_raw, total
372
413
 
373
414
  items: List[Any] = []
415
+ dropped = 0
374
416
  for raw_item in items_raw:
375
417
  try:
376
418
  items.append(self.model.model_validate(raw_item))
377
- except Exception:
378
- continue
419
+ except Exception as exc:
420
+ # No tragar en silencio: una fila que no valida se pierde de la
421
+ # respuesta sin aviso (menos items que el limit, total inflado).
422
+ # Se loguea para que sea diagnosticable; la fila igual se omite
423
+ # para no romper el listado completo por un doc corrupto.
424
+ dropped += 1
425
+ logger.warning(
426
+ "paginate_pipeline: fila descartada por fallo de "
427
+ "validación contra %s: %s",
428
+ getattr(self.model, "__name__", self.model),
429
+ exc,
430
+ )
431
+ if dropped:
432
+ logger.warning(
433
+ "paginate_pipeline: %d/%d filas descartadas en %s "
434
+ "(total reportado=%d)",
435
+ dropped,
436
+ len(items_raw),
437
+ getattr(self.model, "__name__", self.model),
438
+ total,
439
+ )
379
440
  return items, total
380
441
 
381
442
  async def list_with_aggregation(
@@ -30,6 +30,14 @@ class BaseService:
30
30
  ):
31
31
  self.repository = repository
32
32
  self.request = request
33
+ # Copia por instancia de los defaults mutables (heredados como
34
+ # atributos de CLASE, compartidos por todo el proceso). Sin esto, una
35
+ # mutación en runtime (`self.search_fields.append(...)`) se filtraría a
36
+ # la clase y contaminaría otras requests. Respeta el override del
37
+ # subclass: `list(self.search_fields)` lee su atributo de clase.
38
+ self.search_fields = list(self.search_fields)
39
+ self.duplicate_check_fields = list(self.duplicate_check_fields)
40
+ self.kwargs_query = dict(self.kwargs_query)
33
41
  endpoint_func = (
34
42
  self.request.scope.get("endpoint") if self.request else None
35
43
  )
@@ -30,7 +30,6 @@ class BaseController:
30
30
  "count",
31
31
  "search",
32
32
  "order_by",
33
- "action",
34
33
  "__class__",
35
34
  "args",
36
35
  "kwargs",
@@ -24,7 +24,6 @@ class SQLAlchemyBaseController(BaseController):
24
24
  "use_or",
25
25
  "joins",
26
26
  "order_by",
27
- "action",
28
27
  "__class__",
29
28
  "args",
30
29
  "kwargs",
@@ -783,4 +783,15 @@ class BaseRepository:
783
783
  raise NotFoundException(message=f"{model.__name__} no encontrado")
784
784
  await db.delete(record)
785
785
  await db.flush()
786
- return True
786
+ return True
787
+
788
+ async def save(self, obj: Any) -> Any:
789
+ """Persiste una entidad nueva o mutada (add + flush)."""
790
+ self.session.add(obj)
791
+ await self.session.flush()
792
+ return obj
793
+
794
+ async def hard_delete(self, obj: Any) -> None:
795
+ """Elimina físicamente la fila (sin soft delete)."""
796
+ await self.session.delete(obj)
797
+ await self.session.flush()
@@ -2,8 +2,10 @@ from typing import Any, Dict, List, Optional
2
2
 
3
3
  from fastapi import Request
4
4
  from pydantic import BaseModel
5
+ from sqlalchemy import select
5
6
  from ..repository.base import BaseRepository
6
7
  from ....exceptions.api_exceptions import (
8
+ APIException,
7
9
  NotFoundException,
8
10
  DatabaseIntegrityException,
9
11
  )
@@ -26,6 +28,17 @@ class BaseService:
26
28
  action: str | None = None
27
29
  kwargs_query: Dict[str, Any] = {}
28
30
 
31
+ # --- Política de borrado (ver `delete`) ---
32
+ # "hard" -> elimina físicamente (default, comportamiento histórico)
33
+ # "soft" -> marca deleted_at (requiere model con soft_delete())
34
+ # "soft_mangle" -> soft + renombra `mangle_fields` (`<valor>__del_<id>`)
35
+ # para liberar un valor único y poder recrear el registro
36
+ # "hard_if_unused" -> elimina físicamente si no está referenciado, si no -> 409
37
+ delete_mode: str = "hard"
38
+ mangle_fields: List[str] = []
39
+ # [(Model, "fk_attr"), ...] revisados en "hard_if_unused".
40
+ delete_references: List[Any] = []
41
+
29
42
  def __init__(
30
43
  self,
31
44
  repository: BaseRepository,
@@ -184,5 +197,49 @@ class BaseService:
184
197
  updated = await self.repository.update(id, update_data)
185
198
  return updated
186
199
 
200
+ async def _referenced_label(self, obj: Any) -> Optional[str]:
201
+ """Nombre de un modelo que aún referencia a `obj` (bloquea hard delete), o None."""
202
+ session = self.repository.session
203
+ for model, fk in self.delete_references:
204
+ query = select(model.id).where(getattr(model, fk) == obj.id)
205
+ if hasattr(model, "deleted_at"):
206
+ query = query.where(model.deleted_at.is_(None))
207
+ if (await session.execute(query.limit(1))).first():
208
+ return model.__name__
209
+ return None
210
+
211
+ async def apply_delete(self, obj: Any) -> bool:
212
+ """Aplica la política de borrado (`delete_mode`) sobre una entidad ya cargada.
213
+
214
+ Útil cuando una subclase necesita resolver/scopear el objeto antes de borrar
215
+ (p. ej. tenant + region scope): hace `obj = self._get_scoped(id)` y luego
216
+ `await self.apply_delete(obj)`.
217
+ """
218
+ mode = self.delete_mode
219
+ if mode in ("soft", "soft_mangle"):
220
+ if not hasattr(obj, "soft_delete"): # modelo sin soft delete -> físico
221
+ await self.repository.hard_delete(obj)
222
+ return True
223
+ obj.soft_delete()
224
+ if mode == "soft_mangle":
225
+ for field in self.mangle_fields:
226
+ current = getattr(obj, field, None)
227
+ if current is not None:
228
+ setattr(obj, field, f"{current}__del_{obj.id}")
229
+ await self.repository.save(obj)
230
+ elif mode == "hard_if_unused":
231
+ if await self._referenced_label(obj) is not None:
232
+ raise APIException(
233
+ message="No se puede eliminar: el registro está en uso.",
234
+ status_code="IN_USE", status=409,
235
+ )
236
+ await self.repository.hard_delete(obj)
237
+ else: # "hard"
238
+ await self.repository.hard_delete(obj)
239
+ return True
240
+
187
241
  async def delete(self, id: str) -> bool:
188
- return await self.repository.delete(id)
242
+ obj = await self.repository.get(id)
243
+ if not obj:
244
+ raise NotFoundException(message=f"id={id} no encontrado")
245
+ return await self.apply_delete(obj)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.4.0
3
+ Version: 0.4.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
@@ -110,6 +110,8 @@ tests/test_base_response.py
110
110
  tests/test_base_service.py
111
111
  tests/test_beanie_aggregation_hooks.py
112
112
  tests/test_beanie_aggregation_integration.py
113
+ tests/test_beanie_keyset_pagination.py
114
+ tests/test_beanie_service_mutable_defaults.py
113
115
  tests/test_controller_auto_permissions.py
114
116
  tests/test_crud_beanie_controller.py
115
117
  tests/test_crud_controller.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-basekit"
7
- version = "0.4.0"
7
+ version = "0.4.3"
8
8
  description = "Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -33,3 +33,19 @@ def test_declared_query_filters_still_present():
33
33
  get_list = spec["paths"]["/users/"]["get"]
34
34
  names = {p["name"] for p in get_list.get("parameters", [])}
35
35
  assert {"page", "count", "search", "is_active", "age_min"} <= names
36
+
37
+
38
+ def test_action_is_filterable_not_excluded():
39
+ """Regresión inversa: `action` NO debe estar en `_params_excluded_fields`.
40
+
41
+ El ClassVar ya evita el `?action=` espurio (test de arriba); excluir
42
+ `action` del set de filtros rompía filtrar por una columna `action`
43
+ declarada como query param (ej. audit-logs `?action=...`). v0.4.1.
44
+ """
45
+ from fastapi_basekit.aio.sqlalchemy.controller.base import (
46
+ SQLAlchemyBaseController,
47
+ )
48
+ from fastapi_basekit.aio.controller.base import BaseController
49
+
50
+ assert "action" not in SQLAlchemyBaseController._params_excluded_fields
51
+ assert "action" not in BaseController._params_excluded_fields
@@ -0,0 +1,75 @@
1
+ """Tests de `BaseRepository.paginate_keyset` (paginación por cursor, sin skip/count)."""
2
+
3
+ import pytest
4
+ import mongomock_motor
5
+ from beanie import init_beanie
6
+
7
+ from example_crud_beanie.models import UserDocument
8
+ from example_crud_beanie.repository import UserBeanieRepository
9
+
10
+
11
+ @pytest.fixture
12
+ async def init_db():
13
+ client = mongomock_motor.AsyncMongoMockClient()
14
+ await init_beanie(database=client.test_db, document_models=[UserDocument])
15
+ yield
16
+ client.close()
17
+
18
+
19
+ async def _seed(n: int):
20
+ for i in range(n):
21
+ await UserDocument(
22
+ name=f"u{i:02d}", email=f"u{i}@x.com", age=i
23
+ ).insert()
24
+
25
+
26
+ class TestKeysetPagination:
27
+ async def test_desc_latest_first_and_cursor_walk(self, init_db):
28
+ repo = UserBeanieRepository()
29
+ await _seed(25)
30
+
31
+ # Página 1: los más "nuevos" por age desc.
32
+ items, has_more = await repo.paginate_keyset(
33
+ repo.model.find(), limit=10, cursor_field="age", ascending=False
34
+ )
35
+ assert [d.age for d in items] == list(range(24, 14, -1))
36
+ assert has_more is True
37
+
38
+ # Página 2 vía cursor (age del último item).
39
+ items2, has_more2 = await repo.paginate_keyset(
40
+ repo.model.find(),
41
+ limit=10,
42
+ cursor_field="age",
43
+ cursor_value=items[-1].age,
44
+ ascending=False,
45
+ )
46
+ assert [d.age for d in items2] == list(range(14, 4, -1))
47
+ assert has_more2 is True
48
+
49
+ # Página 3 (final): quedan 5, sin has_more.
50
+ items3, has_more3 = await repo.paginate_keyset(
51
+ repo.model.find(),
52
+ limit=10,
53
+ cursor_field="age",
54
+ cursor_value=items2[-1].age,
55
+ ascending=False,
56
+ )
57
+ assert [d.age for d in items3] == list(range(4, -1, -1))
58
+ assert has_more3 is False
59
+
60
+ async def test_ascending_order(self, init_db):
61
+ repo = UserBeanieRepository()
62
+ await _seed(5)
63
+ items, has_more = await repo.paginate_keyset(
64
+ repo.model.find(), limit=3, cursor_field="age", ascending=True
65
+ )
66
+ assert [d.age for d in items] == [0, 1, 2]
67
+ assert has_more is True
68
+
69
+ async def test_no_skip_or_count_on_empty(self, init_db):
70
+ repo = UserBeanieRepository()
71
+ items, has_more = await repo.paginate_keyset(
72
+ repo.model.find(), limit=10, cursor_field="age", ascending=False
73
+ )
74
+ assert items == []
75
+ assert has_more is False
@@ -0,0 +1,29 @@
1
+ """Los defaults mutables del BaseService (search_fields, kwargs_query,
2
+ duplicate_check_fields) deben ser por instancia — mutar uno no puede filtrarse
3
+ a otra instancia ni al atributo de clase (contaminación cross-request)."""
4
+
5
+ from example_crud_beanie.repository import UserBeanieRepository
6
+ from example_crud_beanie.service import UserBeanieService
7
+
8
+
9
+ class TestMutableDefaultsIsolation:
10
+ def test_subclass_override_preserved(self):
11
+ s = UserBeanieService(repository=UserBeanieRepository())
12
+ # El override del subclass sigue vigente (copiado, no perdido).
13
+ assert s.search_fields == ["name", "email"]
14
+ assert s.duplicate_check_fields == ["email"]
15
+
16
+ def test_mutation_does_not_leak_across_instances(self):
17
+ s1 = UserBeanieService(repository=UserBeanieRepository())
18
+ s2 = UserBeanieService(repository=UserBeanieRepository())
19
+ s1.search_fields.append("phone")
20
+ assert "phone" not in s2.search_fields
21
+ # El atributo de CLASE queda intacto.
22
+ assert "phone" not in UserBeanieService.search_fields
23
+
24
+ def test_kwargs_query_dict_isolated(self):
25
+ s1 = UserBeanieService(repository=UserBeanieRepository())
26
+ s2 = UserBeanieService(repository=UserBeanieRepository())
27
+ s1.kwargs_query["fetch_links"] = True
28
+ assert s2.kwargs_query == {}
29
+ assert UserBeanieService.kwargs_query == {}
File without changes