fastapi-basekit 0.1.25__tar.gz → 0.2.1__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 (59) hide show
  1. fastapi_basekit-0.2.1/PKG-INFO +775 -0
  2. fastapi_basekit-0.2.1/README.md +739 -0
  3. fastapi_basekit-0.2.1/fastapi_basekit/aio/__init__.py +1 -0
  4. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/repository/base.py +10 -2
  5. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/service/base.py +14 -12
  6. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/__init__.py +9 -0
  7. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/controller/__init__.py +3 -0
  8. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/controller/base.py +107 -0
  9. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/repository/__init__.py +3 -0
  10. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/repository/base.py +587 -0
  11. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/service/__init__.py +3 -0
  12. fastapi_basekit-0.2.1/fastapi_basekit/aio/sqlmodel/service/base.py +173 -0
  13. fastapi_basekit-0.2.1/fastapi_basekit.egg-info/PKG-INFO +775 -0
  14. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit.egg-info/SOURCES.txt +9 -1
  15. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit.egg-info/requires.txt +5 -0
  16. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/pyproject.toml +7 -2
  17. fastapi_basekit-0.2.1/tests/test_sqlalchemy_base_service_order.py +62 -0
  18. fastapi_basekit-0.1.25/PKG-INFO +0 -791
  19. fastapi_basekit-0.1.25/README.md +0 -759
  20. fastapi_basekit-0.1.25/fastapi_basekit/aio/__init__.py +0 -1
  21. fastapi_basekit-0.1.25/fastapi_basekit.egg-info/PKG-INFO +0 -791
  22. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/LICENSE +0 -0
  23. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/__init__.py +0 -0
  24. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  25. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  26. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
  27. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  28. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  29. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/beanie/service/base.py +0 -0
  30. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/controller/__init__.py +0 -0
  31. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/controller/base.py +0 -0
  32. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  33. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/permissions/base.py +0 -0
  34. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  35. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  36. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -0
  37. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  38. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/repository/base.py +0 -0
  39. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  40. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/exceptions/__init__.py +0 -0
  41. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  42. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/exceptions/handler.py +0 -0
  43. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/schema/__init__.py +0 -0
  44. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/schema/base.py +0 -0
  45. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/schema/jwt.py +0 -0
  46. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/schema/schema.py +0 -0
  47. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/servicios/__init__.py +0 -0
  48. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  49. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  50. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  51. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/fastapi_basekit.egg-info/top_level.txt +0 -0
  52. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/setup.cfg +0 -0
  53. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_api_exceptions.py +0 -0
  54. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_base_response.py +0 -0
  55. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_base_service.py +0 -0
  56. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_controller_auto_permissions.py +0 -0
  57. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_crud_beanie_controller.py +0 -0
  58. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_crud_controller.py +0 -0
  59. {fastapi_basekit-0.1.25 → fastapi_basekit-0.2.1}/tests/test_jwt_service.py +0 -0
@@ -0,0 +1,775 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastapi-basekit
3
+ Version: 0.2.1
4
+ Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
+ Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mundobien2025/fastapi-basekit
8
+ Project-URL: Repository, https://github.com/mundobien2025/fastapi-basekit
9
+ Project-URL: Issues, https://github.com/mundobien2025/fastapi-basekit/issues
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: fastapi>=0.116.1
19
+ Requires-Dist: pydantic<3,>=2.11.7
20
+ Requires-Dist: fastapi-restful[all]>=0.6.0
21
+ Requires-Dist: pyjwt>=2.10.1
22
+ Provides-Extra: beanie
23
+ Requires-Dist: beanie>=1.24.0; extra == "beanie"
24
+ Requires-Dist: motor>=3.3.0; extra == "beanie"
25
+ Provides-Extra: sqlalchemy
26
+ Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "sqlalchemy"
27
+ Requires-Dist: psycopg2>=2.9.0; extra == "sqlalchemy"
28
+ Provides-Extra: sqlmodel
29
+ Requires-Dist: sqlmodel>=0.0.37; extra == "sqlmodel"
30
+ Provides-Extra: all
31
+ Requires-Dist: beanie>=1.24.0; extra == "all"
32
+ Requires-Dist: motor>=3.3.0; extra == "all"
33
+ Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "all"
34
+ Requires-Dist: psycopg2>=2.9.0; extra == "all"
35
+ Requires-Dist: sqlmodel>=0.0.37; extra == "all"
36
+
37
+ # FastAPI BaseKit
38
+
39
+ ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=flat-square&logo=fastapi)
40
+ ![Python](https://img.shields.io/badge/python-3.11+-blue?style=flat-square&logo=python)
41
+ ![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-red?style=flat-square)
42
+ ![SQLModel](https://img.shields.io/badge/SQLModel-0.0.21+-orange?style=flat-square)
43
+ ![MongoDB](https://img.shields.io/badge/MongoDB-Beanie-47A248?style=flat-square&logo=mongodb)
44
+ ![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)
45
+
46
+ Clases base para construir APIs REST con FastAPI de forma rápida: repositorios, servicios y controllers con CRUD, paginación, búsqueda, filtros y ordenamiento ya resueltos.
47
+
48
+ Soporta **SQLAlchemy**, **SQLModel** y **Beanie (MongoDB)**.
49
+
50
+ ---
51
+
52
+ ## Instalación
53
+
54
+ ```bash
55
+ # Solo lo base (sin ORM)
56
+ pip install fastapi-basekit
57
+
58
+ # SQLAlchemy (PostgreSQL / MySQL / SQLite)
59
+ pip install fastapi-basekit[sqlalchemy]
60
+
61
+ # SQLModel
62
+ pip install fastapi-basekit[sqlmodel]
63
+
64
+ # Beanie (MongoDB)
65
+ pip install fastapi-basekit[beanie]
66
+
67
+ # Todo
68
+ pip install fastapi-basekit[all]
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Inicio rápido — SQLAlchemy
74
+
75
+ El patrón real usado en producción: class-based views con `@cbv` de `fastapi-restful`.
76
+
77
+ ### 1. Modelo
78
+
79
+ ```python
80
+ # app/models/auth.py
81
+ from sqlalchemy import String, Enum as SAEnum
82
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
83
+ from .base import BaseModel # tu Base con id, created_at, etc.
84
+ from .enums import UserStatusEnum
85
+
86
+ class Users(BaseModel):
87
+ __tablename__ = "users"
88
+
89
+ email: Mapped[str] = mapped_column(String(320), unique=True)
90
+ full_name: Mapped[str | None] = mapped_column(String(255))
91
+ document: Mapped[str | None] = mapped_column(String(64))
92
+ phone: Mapped[str | None] = mapped_column(String(50))
93
+ status: Mapped[UserStatusEnum] = mapped_column(SAEnum(UserStatusEnum))
94
+
95
+ # Relación many-to-many
96
+ user_roles: Mapped[list["UserRoles"]] = relationship(back_populates="user")
97
+ ```
98
+
99
+ ### 2. Schemas
100
+
101
+ Puedes tener múltiples schemas por recurso — el controller decide cuál usar por acción.
102
+
103
+ ```python
104
+ # app/schemas/user.py
105
+ from pydantic import BaseModel, ConfigDict
106
+ from uuid import UUID
107
+
108
+ class UserListResponseSchema(BaseModel):
109
+ model_config = ConfigDict(from_attributes=True)
110
+ id: UUID
111
+ email: str
112
+ full_name: str | None
113
+ role_name: str | None # columna extra del queryset enriquecido
114
+
115
+ class UserResponseSchema(BaseModel):
116
+ model_config = ConfigDict(from_attributes=True)
117
+ id: UUID
118
+ email: str
119
+ full_name: str | None
120
+ document: str | None
121
+ phone: str | None
122
+ status: str
123
+ ```
124
+
125
+ ### 3. Repository
126
+
127
+ En el 90% de los casos el repositorio solo necesita declarar el modelo. Todo el CRUD ya está implementado en `BaseRepository`.
128
+
129
+ ```python
130
+ # app/repositories/user.py
131
+ from fastapi_basekit.aio.sqlalchemy.repository.base import BaseRepository
132
+ from app.models.auth import Users
133
+
134
+ class UserRepository(BaseRepository):
135
+ model = Users
136
+ ```
137
+
138
+ Sobreescribe `build_list_queryset` solo si necesitas un query base distinto al `select(model)` por defecto — por ejemplo para agregar columnas calculadas que el schema pueda consumir directamente:
139
+
140
+ ```python
141
+ from sqlalchemy import select, func
142
+ from fastapi_basekit.aio.sqlalchemy.repository.base import BaseRepository
143
+ from app.models.auth import Roles, UserRoles
144
+
145
+ class RoleRepository(BaseRepository):
146
+ model = Roles
147
+
148
+ def build_list_queryset(self, **kwargs):
149
+ """Agrega el conteo de usuarios por rol como columna extra."""
150
+ member_count = (
151
+ select(func.count(UserRoles.user_id))
152
+ .where(UserRoles.role_id == Roles.id)
153
+ .scalar_subquery()
154
+ .label("member_count")
155
+ )
156
+ return select(Roles, member_count)
157
+ ```
158
+
159
+ El schema recibe `member_count` directamente (no hace falta nada más):
160
+
161
+ ```python
162
+ class RoleResponseSchema(BaseModel):
163
+ model_config = ConfigDict(from_attributes=True)
164
+ id: UUID
165
+ name: str
166
+ code: str
167
+ member_count: int
168
+ ```
169
+
170
+ ### Métodos disponibles en BaseRepository
171
+
172
+ ```python
173
+ # Por ID
174
+ user = await repo.get(user_id)
175
+ user = await repo.get_with_joins(user_id, joins=["user_roles", "company"])
176
+
177
+ # Por campo
178
+ user = await repo.get_by_field("email", "john@example.com")
179
+ user = await repo.get_by_field_with_joins("email", "john@example.com", joins=["user_roles"])
180
+
181
+ # Por múltiples filtros
182
+ users = await repo.get_by_filters({"status": "active", "company_id": company_id})
183
+ users = await repo.get_by_filters({"status": ["active", "pending"]}) # IN
184
+ users = await repo.get_by_filters({"status": "active"}, use_or=False)
185
+
186
+ # Con joins + filtros
187
+ user = await repo.get_by_filters_with_joins(
188
+ {"email": "john@example.com"}, joins=["user_roles"], one=True
189
+ )
190
+
191
+ # Filtros en relaciones con sintaxis __
192
+ admins = await repo.get_by_filters({"user_roles__role__code": "admin"})
193
+
194
+ # CRUD
195
+ created = await repo.create({"email": "new@example.com", "full_name": "Jane"})
196
+ updated = await repo.update(user_id, {"full_name": "Jane Doe"})
197
+ deleted = await repo.delete(user_id)
198
+ ```
199
+
200
+ ### 4. Service
201
+
202
+ El servicio usa los métodos del repositorio en sus métodos de negocio. No necesita escribir SQL.
203
+
204
+ ```python
205
+ # app/services/user.py
206
+ from fastapi import Request, Depends
207
+ from fastapi_basekit.aio.sqlalchemy.service.base import BaseService
208
+ from fastapi_basekit.exceptions.api_exceptions import NotFoundException, DatabaseIntegrityException
209
+ from sqlalchemy.ext.asyncio import AsyncSession
210
+ from app.config.database import get_db
211
+ from app.repositories.user import UserRepository
212
+
213
+ class UserService(BaseService):
214
+ repository: UserRepository
215
+
216
+ # Búsqueda textual sobre estos campos (ILIKE %term%)
217
+ search_fields = ["full_name", "email", "document", "phone"]
218
+
219
+ # Verifica duplicados en estos campos al crear
220
+ duplicate_check_fields = ["email"]
221
+
222
+ def get_kwargs_query(self) -> dict:
223
+ """Joins cargados automáticamente según la acción."""
224
+ if self.action in ["list_users", "retrieve"]:
225
+ return {"joins": ["user_roles"]}
226
+ return {}
227
+
228
+ def get_filters(self, filters: dict | None = None) -> dict:
229
+ """Inyecta filtros de negocio antes de consultar."""
230
+ filters = {k: v for k, v in (filters or {}).items() if v is not None}
231
+ user = getattr(self.request.state, "user", None) if self.request else None
232
+ if user and "company_id" not in filters:
233
+ filters["company_id"] = user.company_id
234
+ return filters
235
+
236
+ # Métodos de negocio usando las herramientas del repositorio
237
+ async def get_by_email(self, email: str):
238
+ user = await self.repository.get_by_field("email", email)
239
+ if not user:
240
+ raise NotFoundException(message="Usuario no encontrado")
241
+ return user
242
+
243
+ async def get_with_roles(self, user_id: str):
244
+ return await self.repository.get_with_joins(user_id, joins=["user_roles"])
245
+
246
+ async def get_active_by_company(self, company_id):
247
+ return await self.repository.get_by_filters(
248
+ {"company_id": company_id, "status": "active"}
249
+ )
250
+
251
+ async def find_admins(self):
252
+ # Filtro sobre relación: user_roles → role → code
253
+ return await self.repository.get_by_filters(
254
+ {"user_roles__role__code": "admin"}
255
+ )
256
+
257
+
258
+ def get_user_service(
259
+ request: Request,
260
+ session: AsyncSession = Depends(get_db),
261
+ ) -> UserService:
262
+ return UserService(
263
+ repository=UserRepository(session),
264
+ request=request,
265
+ )
266
+ ```
267
+
268
+ ### 5. Controller
269
+
270
+ ```python
271
+ # app/api/v1/endpoints/user/user.py
272
+ from typing import List, Optional, Type
273
+ from uuid import UUID
274
+
275
+ from fastapi import APIRouter, Depends, Query, status
276
+ from fastapi_restful.cbv import cbv
277
+ from pydantic import BaseModel
278
+
279
+ from fastapi_basekit.aio.sqlalchemy.controller.base import SQLAlchemyBaseController
280
+ from fastapi_basekit.aio.permissions.base import BasePermission
281
+ from fastapi_basekit.schema.base import BaseResponse, BasePaginationResponse
282
+
283
+ from app.schemas.user import UserResponseSchema, UserListResponseSchema
284
+ from app.services.user import UserService, get_user_service
285
+ from app.permissions.user import IsAdminPermission
286
+
287
+ router = APIRouter(prefix="/users", tags=["users"])
288
+
289
+
290
+ @cbv(router)
291
+ class UserController(SQLAlchemyBaseController):
292
+ service: UserService = Depends(get_user_service)
293
+ schema_class = UserResponseSchema
294
+
295
+ def get_schema_class(self) -> Type[BaseModel]:
296
+ """Schema diferente según la acción."""
297
+ if self.action == "list_users":
298
+ return UserListResponseSchema # incluye role_name
299
+ return UserResponseSchema
300
+
301
+ def check_permissions(self) -> List[Type[BasePermission]]:
302
+ """Permisos por acción."""
303
+ if self.action in ["delete_user"]:
304
+ return [IsAdminPermission]
305
+ return []
306
+
307
+ @router.get(
308
+ "/",
309
+ response_model=BasePaginationResponse[UserListResponseSchema],
310
+ status_code=status.HTTP_200_OK,
311
+ )
312
+ async def list_users(
313
+ self,
314
+ page: int = Query(1, ge=1),
315
+ count: int = Query(10, ge=1, le=100),
316
+ search: Optional[str] = Query(None, description="Busca en nombre, email, documento, teléfono"),
317
+ order_by: Optional[str] = Query(None, description="Ej: created_at, -created_at"),
318
+ status: Optional[str] = Query(None),
319
+ ):
320
+ """Lista usuarios con paginación, búsqueda y filtros."""
321
+ return await self.list()
322
+
323
+ @router.get("/{user_id}/", response_model=BaseResponse[UserResponseSchema])
324
+ async def get_user(self, user_id: UUID):
325
+ return await self.retrieve(str(user_id))
326
+
327
+ @router.delete("/{user_id}/", response_model=BaseResponse[None])
328
+ async def delete_user(self, user_id: UUID):
329
+ await self.check_permissions_class()
330
+ await self.service.delete(str(user_id))
331
+ return BaseResponse(data=None, message="Usuario eliminado")
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Características destacadas
337
+
338
+ ### Búsqueda multi-campo
339
+
340
+ Define `search_fields` en el servicio. El parámetro `search` del query se convierte automáticamente en `ILIKE %term%` sobre todos esos campos con `OR`.
341
+
342
+ ```python
343
+ class UserService(BaseService):
344
+ search_fields = ["full_name", "email", "document", "phone"]
345
+ ```
346
+
347
+ ```
348
+ GET /users?search=juan → busca "juan" en full_name, email, document, phone
349
+ GET /users?search=@gmail.com → encuentra todos los emails de gmail
350
+ ```
351
+
352
+ Soporta rutas anidadas con `__`:
353
+
354
+ ```python
355
+ search_fields = ["name", "user_roles__role__name"] # busca también en rol relacionado
356
+ ```
357
+
358
+ ---
359
+
360
+ ### Filtros automáticos desde query params
361
+
362
+ Todo lo que el endpoint declara como `Query(...)` (que no sea `page`, `count`, `search`, `order_by`) se pasa automáticamente como filtro. No necesitas extraerlos manualmente.
363
+
364
+ ```python
365
+ @router.get("/")
366
+ async def list_tools(
367
+ self,
368
+ page: int = Query(1, ge=1),
369
+ count: int = Query(10),
370
+ search: Optional[str] = Query(None),
371
+ active: bool | None = Query(None), # → filters["active"]
372
+ tool_type: str | None = Query(None), # → filters["tool_type"]
373
+ platform: str | None = Query(None), # → filters["platform"]
374
+ ):
375
+ return await self.list() # _params() extrae todos los filtros del frame
376
+ ```
377
+
378
+ Soporta filtros con relaciones usando `__`:
379
+
380
+ ```
381
+ GET /users?user_roles__role__code=admin → JOIN automático + WHERE
382
+ ```
383
+
384
+ ---
385
+
386
+ ### Filtros inyectados desde el servicio
387
+
388
+ Usa `get_filters()` para agregar, transformar o validar filtros antes de consultar. Muy útil para filtrar por el usuario autenticado.
389
+
390
+ ```python
391
+ def get_filters(self, filters: dict | None = None) -> dict:
392
+ filters = super().get_filters(filters)
393
+ filters = {k: v for k, v in filters.items() if v is not None}
394
+
395
+ # Inyectar company_id del usuario autenticado
396
+ user = getattr(self.request.state, "user", None) if self.request else None
397
+ if user and "company_id" not in filters:
398
+ filters["company_id"] = user.company_id
399
+
400
+ # Mapear parámetros de query a columnas internas
401
+ if "folder_id" in filters:
402
+ filters["parent_id"] = filters.pop("folder_id")
403
+
404
+ return filters
405
+ ```
406
+
407
+ ---
408
+
409
+ ### Ordenamiento
410
+
411
+ El parámetro `order_by` acepta:
412
+
413
+ | Valor | Resultado |
414
+ |---|---|
415
+ | `created_at` | ORDER BY created_at ASC |
416
+ | `-created_at` | ORDER BY created_at DESC |
417
+ | `user__full_name` | JOIN users + ORDER BY users.full_name ASC |
418
+ | `-user__email` | JOIN users + ORDER BY users.email DESC |
419
+
420
+ ```
421
+ GET /users?order_by=-created_at → más recientes primero
422
+ GET /tools?order_by=tool_type__name → ordenado por nombre del tipo
423
+ ```
424
+
425
+ Para Beanie, soporta ordenamiento anidado usando pipeline de agregación automáticamente:
426
+
427
+ ```
428
+ GET /tools?order_by=-created_at → sort simple
429
+ GET /tools?order_by=tool_type__name → $lookup + $sort automático
430
+ ```
431
+
432
+ ---
433
+
434
+ ### Joins / eager loading (SQLAlchemy)
435
+
436
+ Define qué relaciones cargar según la acción para evitar queries N+1:
437
+
438
+ ```python
439
+ def get_kwargs_query(self) -> dict:
440
+ if self.action in ["list_users", "retrieve"]:
441
+ return {"joins": ["user_roles"]} # selectinload para listas
442
+ return {}
443
+ ```
444
+
445
+ O pásalos directamente desde el controller:
446
+
447
+ ```python
448
+ async def retrieve(self, id: UUID):
449
+ return await self.service.retrieve(str(id), joins=["user_roles", "company"])
450
+ ```
451
+
452
+ ---
453
+
454
+ ### Queryset personalizado con subconsultas
455
+
456
+ Sobreescribe `build_list_queryset()` en el repositorio para cambiar el query base del listado. Los filtros, búsqueda, ordenamiento y paginación se aplican encima automáticamente — no necesitas tocar `list()`.
457
+
458
+ Los parámetros que recibe son los mismos kwargs estándar de `list_paginated` (`filters`, `search`, `order_by`, etc.) — úsalos si quieres tomar decisiones en el query base.
459
+
460
+ ```python
461
+ from sqlalchemy import select
462
+ from fastapi_basekit.aio.sqlalchemy.repository.base import BaseRepository
463
+ from app.models.auth import Users, UserRoles, Roles
464
+
465
+ class UserRepository(BaseRepository):
466
+ model = Users
467
+
468
+ def build_list_queryset(self, **kwargs):
469
+ # Subconsulta correlacionada: nombre del primer rol del usuario
470
+ role_name_subq = (
471
+ select(Roles.name)
472
+ .join(UserRoles, UserRoles.role_id == Roles.id)
473
+ .where(UserRoles.user_id == Users.id)
474
+ .limit(1)
475
+ .scalar_subquery()
476
+ .label("role_name")
477
+ )
478
+ return (
479
+ select(Users, role_name_subq)
480
+ .where(Users.deleted_at.is_(None))
481
+ )
482
+ ```
483
+
484
+ El schema recibe la columna extra directamente gracias a `from_attributes=True`:
485
+
486
+ ```python
487
+ class UserListResponseSchema(BaseModel):
488
+ model_config = ConfigDict(from_attributes=True)
489
+ id: UUID
490
+ email: str
491
+ full_name: str | None
492
+ role_name: str | None # inyectada desde la subconsulta
493
+ ```
494
+
495
+ ---
496
+
497
+ ### Múltiples schemas por controller
498
+
499
+ ```python
500
+ def get_schema_class(self) -> Type[BaseModel]:
501
+ if self.action in ["retrieve", "create", "update"]:
502
+ return ToolDResponseSchema # detallado con relaciones
503
+ return ToolResponseSchema # resumido para el listado
504
+ ```
505
+
506
+ ---
507
+
508
+ ### Múltiples repositorios en un servicio
509
+
510
+ ```python
511
+ class UserService(BaseService):
512
+ def __init__(
513
+ self,
514
+ repository: UserRepository,
515
+ user_role_repository: UserRoleRepository,
516
+ role_repository: RoleRepository,
517
+ permission_repository: PermissionRepository,
518
+ request: Request | None = None,
519
+ ):
520
+ super().__init__(repository, request=request)
521
+ self.user_role_repository = user_role_repository
522
+ self.role_repository = role_repository
523
+ self.permission_repository = permission_repository
524
+
525
+
526
+ def get_user_service(
527
+ request: Request,
528
+ session: AsyncSession = Depends(get_db),
529
+ ) -> UserService:
530
+ return UserService(
531
+ repository=UserRepository(session),
532
+ user_role_repository=UserRoleRepository(session),
533
+ role_repository=RoleRepository(session),
534
+ permission_repository=PermissionRepository(session),
535
+ request=request,
536
+ )
537
+ ```
538
+
539
+ ---
540
+
541
+ ### Permisos
542
+
543
+ ```python
544
+ # app/permissions/user.py
545
+ from fastapi import Request
546
+ from fastapi_basekit.aio.permissions.base import BasePermission
547
+
548
+ class IsAdminPermission(BasePermission):
549
+ message_exception = "Solo administradores pueden realizar esta acción"
550
+
551
+ async def has_permission(self, request: Request) -> bool:
552
+ user = getattr(request.state, "user", None)
553
+ role_codes = getattr(request.state, "user_role_codes", [])
554
+ return "admin" in role_codes if user else False
555
+ ```
556
+
557
+ Aplicar en el controller:
558
+
559
+ ```python
560
+ def check_permissions(self) -> List[Type[BasePermission]]:
561
+ if self.action in ["delete_user", "update_profile"]:
562
+ return [IsAdminPermission]
563
+ return []
564
+
565
+ # O manualmente en un método:
566
+ async def delete_user(self, user_id: UUID):
567
+ await self.check_permissions_class()
568
+ await self.service.delete(str(user_id))
569
+ ```
570
+
571
+ ---
572
+
573
+ ## Beanie (MongoDB)
574
+
575
+ El mismo patrón, usando `BeanieBaseController` y `BeanieBaseService`.
576
+
577
+ ```python
578
+ # app/api/v1/endpoints/tool/tool.py
579
+ from fastapi_basekit.aio.beanie.controller.base import BeanieBaseController
580
+ from fastapi_basekit.aio.beanie.service.base import BaseService
581
+
582
+ @cbv(router)
583
+ class ToolController(BeanieBaseController):
584
+ service: ToolService = Depends(get_tool_service)
585
+ schema_class = ToolResponseSchema
586
+
587
+ def get_schema_class(self):
588
+ if self.action in ["retrieve", "create", "update"]:
589
+ return ToolDResponseSchema
590
+ return ToolResponseSchema
591
+
592
+ @router.get("/", response_model=ToolPResponseSchema)
593
+ async def list(
594
+ self,
595
+ page: int = Query(1, ge=1),
596
+ count: int = Query(10, ge=1),
597
+ search: str | None = Query(None),
598
+ tool_type: PydanticObjectId | None = Query(None),
599
+ active: bool | None = Query(None),
600
+ order_by: str | None = Query(None),
601
+ ):
602
+ return await super().list()
603
+ ```
604
+
605
+ ### fetch_links (relaciones en Beanie)
606
+
607
+ ```python
608
+ class ToolService(BaseService):
609
+ search_fields = ["name", "description"]
610
+
611
+ def get_kwargs_query(self) -> dict:
612
+ """Carga relaciones según la acción."""
613
+ if self.action in ["list", "retrieve", "create", "update"]:
614
+ return {
615
+ "fetch_links": True,
616
+ "nesting_depths_per_field": {"tool_type": 2, "platform": 1},
617
+ }
618
+ return {}
619
+ ```
620
+
621
+ ### get_filters en Beanie
622
+
623
+ ```python
624
+ def get_filters(self, filters: dict | None = None) -> dict:
625
+ filters = {k: v for k, v in (filters or {}).items() if v is not None}
626
+
627
+ user = getattr(self.request.state, "user", None) if self.request else None
628
+ if user:
629
+ category = filters.pop("category", "user")
630
+ if category == "user":
631
+ filters["user"] = user.id
632
+ elif category == "global":
633
+ filters["category"] = ToolCategoryEnum.GLOBAL
634
+ # category="all" → sin filtro adicional
635
+
636
+ return filters
637
+ ```
638
+
639
+ ---
640
+
641
+ ## SQLModel
642
+
643
+ Mismo contrato que SQLAlchemy, solo cambia la sesión y la forma de definir modelos.
644
+
645
+ ```python
646
+ pip install fastapi-basekit[sqlmodel]
647
+ ```
648
+
649
+ ```python
650
+ from sqlmodel import SQLModel, Field, Relationship
651
+
652
+ class Hero(SQLModel, table=True):
653
+ id: int | None = Field(default=None, primary_key=True)
654
+ name: str
655
+ team_id: int | None = Field(default=None, foreign_key="team.id")
656
+ team: "Team | None" = Relationship(back_populates="heroes")
657
+ ```
658
+
659
+ ```python
660
+ from fastapi_basekit.aio.sqlmodel import (
661
+ SQLModelBaseController,
662
+ BaseRepository,
663
+ BaseService,
664
+ )
665
+
666
+ class HeroRepository(BaseRepository):
667
+ model = Hero
668
+
669
+ class HeroService(BaseService):
670
+ search_fields = ["name"]
671
+ duplicate_check_fields = ["name"]
672
+
673
+ @cbv(router)
674
+ class HeroController(SQLModelBaseController):
675
+ service: HeroService = Depends(get_hero_service)
676
+ schema_class = HeroSchema
677
+ ```
678
+
679
+ La sesión usa `sqlmodel.ext.asyncio.session.AsyncSession` internamente. Los queries usan `session.exec()` para tipos seguros.
680
+
681
+ ---
682
+
683
+ ## Formato de respuesta
684
+
685
+ Todas las respuestas siguen el mismo envelope:
686
+
687
+ ```python
688
+ # Detalle / create / update
689
+ BaseResponse[Schema]
690
+ {
691
+ "data": { ... },
692
+ "message": "Operación exitosa",
693
+ "status": "success"
694
+ }
695
+
696
+ # Listado paginado
697
+ BasePaginationResponse[Schema]
698
+ {
699
+ "data": [ ... ],
700
+ "pagination": {
701
+ "page": 1,
702
+ "count": 10,
703
+ "total": 87,
704
+ "total_pages": 9
705
+ },
706
+ "message": "Operación exitosa",
707
+ "status": "status"
708
+ }
709
+ ```
710
+
711
+ Declara el `response_model` en el decorador para que FastAPI genere el OpenAPI correcto:
712
+
713
+ ```python
714
+ @router.get("/", response_model=BasePaginationResponse[UserListResponseSchema])
715
+ async def list_users(self, ...):
716
+ return await self.list()
717
+
718
+ @router.get("/{id}/", response_model=BaseResponse[UserResponseSchema])
719
+ async def get_user(self, id: UUID):
720
+ return await self.retrieve(str(id))
721
+ ```
722
+
723
+ ---
724
+
725
+ ## Excepciones
726
+
727
+ ```python
728
+ from fastapi_basekit.exceptions.api_exceptions import (
729
+ NotFoundException, # 404
730
+ DatabaseIntegrityException, # 400 — registro duplicado
731
+ ValidationException, # 422
732
+ PermissionException, # 403
733
+ JWTAuthenticationException, # 401
734
+ GlobalException, # 500
735
+ )
736
+
737
+ # Uso en servicio o repositorio
738
+ raise NotFoundException(message="Usuario no encontrado")
739
+ raise DatabaseIntegrityException(message="El email ya está en uso", data={"email": email})
740
+ ```
741
+
742
+ Registra el handler global en `main.py`:
743
+
744
+ ```python
745
+ from fastapi_basekit.exceptions.handler import register_exception_handlers
746
+
747
+ app = FastAPI()
748
+ register_exception_handlers(app)
749
+ ```
750
+
751
+ ---
752
+
753
+ ## Arquitectura
754
+
755
+ ```
756
+ Controller ← valida parámetros, permisos, schema de respuesta
757
+
758
+ Service ← lógica de negocio, get_filters(), build_queryset()
759
+
760
+ Repository ← acceso a datos, queries SQL/Mongo, build_list_queryset()
761
+
762
+ DB ← SQLAlchemy / SQLModel / Beanie
763
+ ```
764
+
765
+ ---
766
+
767
+ ## Changelog
768
+
769
+ Ver [CHANGELOG.md](./CHANGELOG.md)
770
+
771
+ ---
772
+
773
+ ## Licencia
774
+
775
+ MIT — ver [LICENSE](./LICENSE)