fastapi-basekit 0.1.24__tar.gz → 0.2.0__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.
- fastapi_basekit-0.2.0/PKG-INFO +774 -0
- fastapi_basekit-0.2.0/README.md +739 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/__init__.py +1 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -1
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/repository/base.py +107 -104
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/service/base.py +65 -42
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/__init__.py +9 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/controller/__init__.py +3 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/controller/base.py +107 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/repository/__init__.py +3 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/repository/base.py +587 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/service/__init__.py +3 -0
- fastapi_basekit-0.2.0/fastapi_basekit/aio/sqlmodel/service/base.py +173 -0
- fastapi_basekit-0.2.0/fastapi_basekit.egg-info/PKG-INFO +774 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/SOURCES.txt +9 -1
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/requires.txt +4 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/pyproject.toml +6 -2
- fastapi_basekit-0.2.0/tests/test_sqlalchemy_base_service_order.py +62 -0
- fastapi_basekit-0.1.24/PKG-INFO +0 -791
- fastapi_basekit-0.1.24/README.md +0 -759
- fastapi_basekit-0.1.24/fastapi_basekit/aio/__init__.py +0 -1
- fastapi_basekit-0.1.24/fastapi_basekit.egg-info/PKG-INFO +0 -791
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/LICENSE +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/repository/base.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/beanie/service/base.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/controller/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/controller/base.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/permissions/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/permissions/base.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/exceptions/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/exceptions/handler.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/base.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/jwt.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/schema/schema.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/servicios/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/fastapi_basekit.egg-info/top_level.txt +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/setup.cfg +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_api_exceptions.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_base_response.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_base_service.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_controller_auto_permissions.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_crud_beanie_controller.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_crud_controller.py +0 -0
- {fastapi_basekit-0.1.24 → fastapi_basekit-0.2.0}/tests/test_jwt_service.py +0 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: fastapi-basekit
|
|
3
|
+
Version: 0.2.0
|
|
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
|
+
Provides-Extra: beanie
|
|
22
|
+
Requires-Dist: beanie>=1.24.0; extra == "beanie"
|
|
23
|
+
Requires-Dist: motor>=3.3.0; extra == "beanie"
|
|
24
|
+
Provides-Extra: sqlalchemy
|
|
25
|
+
Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "sqlalchemy"
|
|
26
|
+
Requires-Dist: psycopg2>=2.9.0; extra == "sqlalchemy"
|
|
27
|
+
Provides-Extra: sqlmodel
|
|
28
|
+
Requires-Dist: sqlmodel>=0.0.37; extra == "sqlmodel"
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: beanie>=1.24.0; extra == "all"
|
|
31
|
+
Requires-Dist: motor>=3.3.0; extra == "all"
|
|
32
|
+
Requires-Dist: SQLAlchemy[asyncio]>=2.0.0; extra == "all"
|
|
33
|
+
Requires-Dist: psycopg2>=2.9.0; extra == "all"
|
|
34
|
+
Requires-Dist: sqlmodel>=0.0.37; extra == "all"
|
|
35
|
+
|
|
36
|
+
# FastAPI BaseKit
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+

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