files-server-fastapi 0.1.44__tar.gz → 0.1.45__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 (44) hide show
  1. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/PKG-INFO +1 -1
  2. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/pyproject.toml +1 -1
  3. files_server_fastapi-0.1.45/src/files_server_fastapi/dependencies/__init__.py +13 -0
  4. files_server_fastapi-0.1.45/src/files_server_fastapi/dependencies/user_dependencies.py +183 -0
  5. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/acls_router.py +3 -2
  6. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/delete_router.py +11 -6
  7. files_server_fastapi-0.1.45/src/files_server_fastapi/models/rol_model.py +26 -0
  8. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/models/users_extend_model.py +14 -4
  9. files_server_fastapi-0.1.45/src/files_server_fastapi/routers/rol_router.py +90 -0
  10. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/samba_router.py +6 -5
  11. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/users_extend_router.py +190 -10
  12. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/uv.lock +1 -1
  13. files_server_fastapi-0.1.44/src/files_server_fastapi/models/rol_model.py +0 -13
  14. files_server_fastapi-0.1.44/src/files_server_fastapi/routers/rol_router.py +0 -45
  15. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/.gitignore +0 -0
  16. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/.python-version +0 -0
  17. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/LICENSE +0 -0
  18. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/MANIFEST.in +0 -0
  19. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/README.md +0 -0
  20. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/__init__.py +0 -0
  21. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/__init__.py +0 -0
  22. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/constants.py +0 -0
  23. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/create_file_router.py +0 -0
  24. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/dependencies.py +0 -0
  25. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/download_router.py +0 -0
  26. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/folder_router.py +0 -0
  27. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/list_router.py +0 -0
  28. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/onlyoffice_router.py +0 -0
  29. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/open_url_router.py +0 -0
  30. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/path_utils.py +0 -0
  31. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/search_router.py +0 -0
  32. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/stats_router.py +0 -0
  33. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/tree_router.py +0 -0
  34. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/upload_router.py +0 -0
  35. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/files/view_router.py +0 -0
  36. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/models/__init__.py +0 -0
  37. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/models/area_model.py +0 -0
  38. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/models/permisos_model.py +0 -0
  39. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/models/rutas_model.py +0 -0
  40. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/__init__.py +0 -0
  41. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/area_router.py +0 -0
  42. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/files_router.py +0 -0
  43. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/permisos_router.py +0 -0
  44. {files_server_fastapi-0.1.44 → files_server_fastapi-0.1.45}/src/files_server_fastapi/routers/rutas_router.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: files-server-fastapi
3
- Version: 0.1.44
3
+ Version: 0.1.45
4
4
  Summary: Este repositorio es un paquete python desarrollado con FastAPI, gestiona la comunicación con un servidor Samba y utiliza PostgreSQL para administrar rutas de archivos y el control de acceso basado en roles (RBAC) de los usuarios.
5
5
  Project-URL: Homepage, https://github.com/AldoBP/files-server-fastapi
6
6
  Project-URL: Repository, https://github.com/AldoBP/files-server-fastapi
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "files-server-fastapi"
3
- version = "0.1.44"
3
+ version = "0.1.45"
4
4
  description = "Este repositorio es un paquete python desarrollado con FastAPI, gestiona la comunicación con un servidor Samba y utiliza PostgreSQL para administrar rutas de archivos y el control de acceso basado en roles (RBAC) de los usuarios."
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -0,0 +1,13 @@
1
+ from files_server_fastapi.dependencies.user_dependencies import (
2
+ get_active_user,
3
+ get_current_user_ext,
4
+ require_superadmin,
5
+ require_area_admin_or_superadmin,
6
+ )
7
+
8
+ __all__ = [
9
+ "get_active_user",
10
+ "get_current_user_ext",
11
+ "require_superadmin",
12
+ "require_area_admin_or_superadmin",
13
+ ]
@@ -0,0 +1,183 @@
1
+ """
2
+ dependencies/user_dependencies.py
3
+ ==================================
4
+ Dependencies de autenticación y autorización propias del proyecto.
5
+
6
+ Propósito:
7
+ El paquete oauth2fast-fastapi maneja la autenticación base (token JWT),
8
+ pero no conoce el concepto de "usuario dado de baja" ni el sistema de
9
+ niveles de privilegio de este proyecto.
10
+
11
+ Este módulo extiende esa lógica con:
12
+
13
+ - get_active_user: verifica que el usuario NO esté dado de baja.
14
+ - require_superadmin: verifica privilege_level >= 2.
15
+ - require_area_admin_or_superadmin: verifica privilege_level >= 1.
16
+
17
+ Niveles de privilegio (tabla rol.privilege_level):
18
+ 0 → Usuario regular (sin permisos de gestión)
19
+ 1 → Admin de Área (gestión de su propia área)
20
+ 2 → Superadmin/Sistemas (acceso total)
21
+
22
+ ¿Por qué privilege_level en vez de comparar nombres de rol?
23
+ Los nombres de rol pueden cambiar ("admin" → "lider", "jefe", etc.).
24
+ El privilege_level es un contrato estable: el código solo lee el número,
25
+ no el texto. Renombrar un rol en la DB nunca rompe la autorización.
26
+ """
27
+
28
+ from datetime import datetime, timezone
29
+
30
+ from fastapi import Depends, HTTPException, status
31
+ from sqlalchemy import select
32
+ from sqlalchemy.ext.asyncio import AsyncSession
33
+
34
+ from oauth2fast_fastapi import User, get_current_verified_user
35
+ from pgsqlasync2fast_fastapi.dependencies import get_db_session
36
+
37
+ from files_server_fastapi.models.rol_model import Rol
38
+ from files_server_fastapi.models.users_extend_model import Users_extend
39
+
40
+ # ── Constantes de nivel de privilegio ────────────────────────────────────────
41
+ # Cambia estos valores SOLO si redefiniste la escala en tu tabla rol.
42
+ PRIVILEGE_USER = 0 # Usuario regular
43
+ PRIVILEGE_AREA_ADMIN = 1 # Admin de Área
44
+ PRIVILEGE_SUPERADMIN = 2 # Sistemas / Superadmin
45
+
46
+
47
+ # ── Helper interno ────────────────────────────────────────────────────────────
48
+
49
+ async def _get_privilege_level(rol_id: int, db: AsyncSession) -> int:
50
+ """
51
+ Obtiene el privilege_level del rol desde la DB.
52
+ Retorna PRIVILEGE_USER (0) si el rol no existe.
53
+ """
54
+ result = await db.execute(select(Rol).where(Rol.id == rol_id))
55
+ rol = result.scalars().first()
56
+ return rol.privilege_level if rol else PRIVILEGE_USER
57
+
58
+
59
+ # ── Dependency principal ──────────────────────────────────────────────────────
60
+
61
+ async def get_active_user(
62
+ current_user: User = Depends(get_current_verified_user),
63
+ db: AsyncSession = Depends(get_db_session),
64
+ ) -> User:
65
+ """
66
+ Extiende get_current_verified_user con verificación de soft delete.
67
+
68
+ Flujo:
69
+ 1. get_current_verified_user valida el token JWT y que is_verified=True.
70
+ 2. Esta dependency verifica que deleted_at IS NULL en users_extend.
71
+
72
+ Lanza:
73
+ HTTP 403 si el usuario tiene deleted_at con fecha/hora de baja.
74
+ """
75
+ result = await db.execute(
76
+ select(Users_extend).where(Users_extend.user_id == current_user.id)
77
+ )
78
+ ext = result.scalars().first()
79
+
80
+ if ext is not None and ext.deleted_at is not None:
81
+ raise HTTPException(
82
+ status_code=status.HTTP_403_FORBIDDEN,
83
+ detail=(
84
+ "Tu cuenta ha sido dada de baja del sistema. "
85
+ "Contacta a sistemas para más información."
86
+ ),
87
+ )
88
+ return current_user
89
+
90
+
91
+ # ── Helper: extensión del usuario actual ──────────────────────────────────────
92
+
93
+ async def get_current_user_ext(
94
+ current_user: User = Depends(get_active_user),
95
+ db: AsyncSession = Depends(get_db_session),
96
+ ) -> Users_extend:
97
+ """
98
+ Retorna el registro Users_extend del usuario autenticado y activo.
99
+ Lanza 404 si el usuario no tiene extensión registrada.
100
+ """
101
+ result = await db.execute(
102
+ select(Users_extend).where(Users_extend.user_id == current_user.id)
103
+ )
104
+ ext = result.scalars().first()
105
+ if not ext:
106
+ raise HTTPException(
107
+ status_code=status.HTTP_404_NOT_FOUND,
108
+ detail="El usuario no tiene datos de extensión registrados.",
109
+ )
110
+ return ext
111
+
112
+
113
+ # ── Verificadores de nivel de privilegio ──────────────────────────────────────
114
+
115
+ async def require_superadmin(
116
+ current_user: User = Depends(get_active_user),
117
+ db: AsyncSession = Depends(get_db_session),
118
+ ) -> tuple[User, Users_extend]:
119
+ """
120
+ Verifica que el usuario tenga privilege_level >= 2 (Superadmin/Sistemas).
121
+
122
+ Retorna:
123
+ (User, Users_extend) para uso en el endpoint.
124
+
125
+ Lanza:
126
+ HTTP 403 si el nivel de privilegio es insuficiente.
127
+ """
128
+ result = await db.execute(
129
+ select(Users_extend).where(Users_extend.user_id == current_user.id)
130
+ )
131
+ ext = result.scalars().first()
132
+
133
+ if not ext:
134
+ raise HTTPException(
135
+ status_code=status.HTTP_403_FORBIDDEN,
136
+ detail="No tienes permisos suficientes para esta acción.",
137
+ )
138
+
139
+ level = await _get_privilege_level(ext.rol_id, db)
140
+ if level < PRIVILEGE_SUPERADMIN:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_403_FORBIDDEN,
143
+ detail="Solo Sistemas/Superadmin puede ejecutar esta acción.",
144
+ )
145
+
146
+ return current_user, ext
147
+
148
+
149
+ async def require_area_admin_or_superadmin(
150
+ current_user: User = Depends(get_active_user),
151
+ db: AsyncSession = Depends(get_db_session),
152
+ ) -> tuple[User, Users_extend, bool]:
153
+ """
154
+ Verifica que el usuario tenga privilege_level >= 1 (Admin de Área o superior).
155
+
156
+ Retorna:
157
+ (User, Users_extend, is_superadmin: bool)
158
+ El booleano permite al endpoint saber si tiene permisos totales.
159
+
160
+ Lanza:
161
+ HTTP 403 si el nivel de privilegio es insuficiente.
162
+ """
163
+ result = await db.execute(
164
+ select(Users_extend).where(Users_extend.user_id == current_user.id)
165
+ )
166
+ ext = result.scalars().first()
167
+
168
+ if not ext:
169
+ raise HTTPException(
170
+ status_code=status.HTTP_403_FORBIDDEN,
171
+ detail="No tienes permisos suficientes para esta acción.",
172
+ )
173
+
174
+ level = await _get_privilege_level(ext.rol_id, db)
175
+ if level < PRIVILEGE_AREA_ADMIN:
176
+ raise HTTPException(
177
+ status_code=status.HTTP_403_FORBIDDEN,
178
+ detail="Se requiere nivel de Admin de Área o superior.",
179
+ )
180
+
181
+ is_superadmin = level >= PRIVILEGE_SUPERADMIN
182
+ return current_user, ext, is_superadmin
183
+
@@ -267,9 +267,10 @@ async def get_specific_user_acls(
267
267
  Acepta el auth user_id directamente (el mismo que esta en user_ruta_access.user_id).
268
268
  Por compatibilidad tambien acepta users_extend.id si el user_id no se encuentra como auth user_id.
269
269
 
270
- **Requiere rol SUPER_ADMIN o AREA_ADMIN.**
270
+ **Requiere rol SUPER_ADMIN o AREA_ADMIN (excepto si el usuario consulta sus propios permisos).**
271
271
  """
272
- await _require_admin_role(current_user, db)
272
+ if current_user.id != user_id:
273
+ await _require_admin_role(current_user, db)
273
274
  ext_result = await db.execute(
274
275
  select(Users_extend).where(Users_extend.user_id == user_id)
275
276
  )
@@ -3,7 +3,7 @@ import shutil
3
3
  from fastapi import APIRouter, HTTPException, Depends
4
4
  from pydantic import BaseModel
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
- from sqlalchemy import select, delete as sql_delete
6
+ from sqlalchemy import select, delete as sql_delete, or_
7
7
 
8
8
  from pgsqlasync2fast_fastapi.dependencies import get_db_session
9
9
  from oauth2fast_fastapi import get_current_verified_user, User
@@ -17,7 +17,7 @@ router = APIRouter()
17
17
  class DeleteRequest(BaseModel):
18
18
  area: str
19
19
  subpath: str = "/"
20
- name: str # nombre del archivo o carpeta a eliminar
20
+ filename: str # nombre del archivo o carpeta a eliminar
21
21
 
22
22
 
23
23
  @router.delete("/delete", summary="Eliminar un archivo o carpeta del servidor")
@@ -35,8 +35,8 @@ async def delete_item(
35
35
  - La base de datos se limpia **solo si** la eliminación física tuvo éxito, evitando registros huérfanos.
36
36
  """
37
37
  # ── 1. Validar rutas ANTES de cualquier lógica de negocio ─────────────────
38
- # req.name nunca debe contener separadores de directorio ni secuencias de traversal.
39
- if ".." in req.subpath or ".." in req.name or "/" in req.name:
38
+ # req.filename nunca debe contener separadores de directorio ni secuencias de traversal.
39
+ if ".." in req.subpath or ".." in req.filename or "/" in req.filename:
40
40
  raise HTTPException(status_code=400, detail="Ruta o nombre inválido")
41
41
 
42
42
  # ── 2. Verificar permiso de eliminación ───────────────────────────────────
@@ -49,7 +49,7 @@ async def delete_item(
49
49
  )
50
50
 
51
51
  # ── 3. Construir rutas ────────────────────────────────────────────────────
52
- safe_name = os.path.basename(req.name)
52
+ safe_name = os.path.basename(req.filename)
53
53
  safe_subpath = req.subpath.strip("/")
54
54
  ruta_real = (
55
55
  os.path.join(BASE_DIR, req.area.upper(), safe_subpath, safe_name)
@@ -85,7 +85,12 @@ async def delete_item(
85
85
  # Para archivos regulares no suele haber entrada en Rutas, pero el DELETE
86
86
  # es seguro (no borra nada si no hay coincidencia).
87
87
  await db.execute(
88
- sql_delete(Rutas).where(Rutas.ruta.like(f"{logical_prefix}%"))
88
+ sql_delete(Rutas).where(
89
+ or_(
90
+ Rutas.ruta == logical_prefix,
91
+ Rutas.ruta.like(f"{logical_prefix}/%")
92
+ )
93
+ )
89
94
  )
90
95
  await db.commit()
91
96
 
@@ -0,0 +1,26 @@
1
+ from typing import Optional
2
+ from datetime import datetime, timezone
3
+ from sqlmodel import Field
4
+ from oauth2fast_fastapi import AuthModel
5
+
6
+
7
+ def get_utc_now():
8
+ return datetime.now(timezone.utc)
9
+
10
+
11
+ class Rol(AuthModel, table=True):
12
+ __tablename__ = "rol"
13
+
14
+ role_name: str = Field(nullable=False)
15
+ description: Optional[str] = Field(default=None)
16
+
17
+ # Nivel de privilegio del rol. Desacopla la autorización del nombre del rol:
18
+ # el nombre puede cambiar libremente sin afectar la lógica del sistema.
19
+ #
20
+ # 0 → Usuario regular (sin permisos de gestión)
21
+ # 1 → Admin de Área (gestión de su propia área)
22
+ # 2 → Superadmin/Sistemas (acceso total)
23
+ #
24
+ # Para agregar nuevos niveles en el futuro, solo actualiza este campo en la DB.
25
+ privilege_level: int = Field(default=0, nullable=False)
26
+
@@ -1,20 +1,30 @@
1
1
  from typing import Optional
2
+ from datetime import datetime
2
3
  from sqlmodel import Field
3
4
  from oauth2fast_fastapi import AuthModel
4
5
 
6
+
5
7
  class Users_extend(AuthModel, table=True):
6
8
  __tablename__ = "users_extend"
7
-
9
+
8
10
  # Es Foreign Key al usuario (Relación 1 a 1)
9
11
  user_id: int = Field(foreign_key="users.id", unique=True)
10
-
12
+
11
13
  area_id: int = Field(foreign_key="area.id")
12
14
  rol_id: int = Field(foreign_key="rol.id")
13
15
  puesto: Optional[str] = Field(default=None)
14
-
16
+
15
17
  # Contraseña en texto plano para sincronización con el sistema de archivos centralizado de red
16
18
  samba_password: Optional[str] = Field(default=None)
17
-
19
+
18
20
  # Indica si el usuario tiene acceso Samba activado manualmente por el administrador.
19
21
  # Cuando es True, los permisos web se replican automáticamente a Linux ACLs / Samba.
20
22
  samba_enabled: bool = Field(default=False)
23
+
24
+ # ── Soft Delete ───────────────────────────────────────────────────────────
25
+ # NULL → usuario activo
26
+ # NOT NULL → usuario dado de baja (fecha/hora exacta de la baja)
27
+ deleted_at: Optional[datetime] = Field(default=None)
28
+
29
+ # ID del usuario (admin/sistemas) que ejecutó la baja. NULL si está activo.
30
+ deleted_by: Optional[int] = Field(default=None, foreign_key="users.id")
@@ -0,0 +1,90 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select
4
+
5
+ from pgsqlasync2fast_fastapi.dependencies import get_db_session
6
+ from files_server_fastapi.models.rol_model import Rol
7
+ from files_server_fastapi.dependencies.user_dependencies import require_superadmin
8
+
9
+ router = APIRouter(prefix="/roles", tags=["Gestión de Roles"])
10
+
11
+ @router.post("/", response_model=Rol, summary="Crear un nuevo Rol")
12
+ async def create_rol(
13
+ rol: Rol,
14
+ auth: tuple = Depends(require_superadmin),
15
+ db: AsyncSession = Depends(get_db_session)
16
+ ):
17
+ """
18
+ Crea un nuevo rol en el sistema.
19
+ Solo accesible por Superadmin/Sistemas.
20
+
21
+ Puedes enviar 'privilege_level' en el cuerpo de la petición:
22
+ 0 = Usuario regular
23
+ 1 = Admin de Área
24
+ 2 = Superadmin/Sistemas
25
+ """
26
+ db.add(rol)
27
+ await db.commit()
28
+ await db.refresh(rol)
29
+ return rol
30
+
31
+ @router.get("/", response_model=list[Rol], summary="Obtener todos los Roles")
32
+ async def get_roles(
33
+ db: AsyncSession = Depends(get_db_session)
34
+ ):
35
+ """
36
+ Obtiene la lista de todos los roles.
37
+ Público para lectura (necesario para formularios de registro/edición).
38
+ """
39
+ result = await db.execute(select(Rol))
40
+ return result.scalars().all()
41
+
42
+ @router.put("/{rol_id}", response_model=Rol, summary="Editar un Rol")
43
+ async def update_rol(
44
+ rol_id: int,
45
+ rol_data: Rol,
46
+ auth: tuple = Depends(require_superadmin),
47
+ db: AsyncSession = Depends(get_db_session)
48
+ ):
49
+ """
50
+ Actualiza un rol existente, incluyendo su nivel de privilegio.
51
+ Solo accesible por Superadmin/Sistemas.
52
+ """
53
+ result = await db.execute(select(Rol).where(Rol.id == rol_id))
54
+ db_rol = result.scalars().first()
55
+ if not db_rol:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_404_NOT_FOUND,
58
+ detail="Rol no encontrado"
59
+ )
60
+
61
+ db_rol.role_name = rol_data.role_name
62
+ db_rol.description = rol_data.description
63
+ db_rol.privilege_level = rol_data.privilege_level # <-- Ahora sí se actualiza desde el Dashboard
64
+
65
+ await db.commit()
66
+ await db.refresh(db_rol)
67
+ return db_rol
68
+
69
+ @router.delete("/{rol_id}", summary="Eliminar un Rol")
70
+ async def delete_rol(
71
+ rol_id: int,
72
+ auth: tuple = Depends(require_superadmin),
73
+ db: AsyncSession = Depends(get_db_session)
74
+ ):
75
+ """
76
+ Elimina un rol.
77
+ Solo accesible por Superadmin/Sistemas.
78
+ """
79
+ result = await db.execute(select(Rol).where(Rol.id == rol_id))
80
+ db_rol = result.scalars().first()
81
+ if not db_rol:
82
+ raise HTTPException(
83
+ status_code=status.HTTP_404_NOT_FOUND,
84
+ detail="Rol no encontrado"
85
+ )
86
+
87
+ await db.delete(db_rol)
88
+ await db.commit()
89
+ return {"message": "Rol eliminado correctamente"}
90
+
@@ -30,7 +30,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
30
30
  from sqlalchemy import select
31
31
 
32
32
  from pgsqlasync2fast_fastapi.dependencies import get_db_session
33
- from oauth2fast_fastapi import get_current_verified_user, User
33
+ from oauth2fast_fastapi import User
34
+ from files_server_fastapi.dependencies.user_dependencies import get_active_user
34
35
  from files_server_fastapi.models.users_extend_model import Users_extend
35
36
  from files_server_fastapi.models.permisos_model import User_Ruta_Access
36
37
  from files_server_fastapi.models.rutas_model import Rutas
@@ -170,7 +171,7 @@ class SambaActivateRequest(BaseModel):
170
171
  async def activate_samba(
171
172
  user_ext_id: int,
172
173
  req: SambaActivateRequest = SambaActivateRequest(),
173
- current_user: User = Depends(get_current_verified_user),
174
+ current_user: User = Depends(get_active_user),
174
175
  db: AsyncSession = Depends(get_db_session),
175
176
  ):
176
177
  """
@@ -225,7 +226,7 @@ async def activate_samba(
225
226
  )
226
227
  async def deactivate_samba(
227
228
  user_ext_id: int,
228
- current_user: User = Depends(get_current_verified_user),
229
+ current_user: User = Depends(get_active_user),
229
230
  db: AsyncSession = Depends(get_db_session),
230
231
  ):
231
232
  """
@@ -275,7 +276,7 @@ async def deactivate_samba(
275
276
  )
276
277
  async def get_samba_status(
277
278
  user_ext_id: int,
278
- current_user: User = Depends(get_current_verified_user),
279
+ current_user: User = Depends(get_active_user),
279
280
  db: AsyncSession = Depends(get_db_session),
280
281
  ):
281
282
  """
@@ -320,7 +321,7 @@ async def get_samba_status(
320
321
  )
321
322
  async def sync_samba(
322
323
  user_ext_id: int,
323
- current_user: User = Depends(get_current_verified_user),
324
+ current_user: User = Depends(get_active_user),
324
325
  db: AsyncSession = Depends(get_db_session),
325
326
  ):
326
327
  """
@@ -1,16 +1,22 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Optional
3
+
1
4
  from fastapi import APIRouter, Depends, HTTPException, status
2
- from sqlalchemy.ext.asyncio import AsyncSession
5
+ from pydantic import BaseModel, Field, model_validator
3
6
  from sqlalchemy import select
4
- from typing import Optional
5
- from pydantic import BaseModel
7
+ from sqlalchemy.ext.asyncio import AsyncSession
6
8
 
9
+ from oauth2fast_fastapi import User
7
10
  from pgsqlasync2fast_fastapi.dependencies import get_db_session
8
- from files_server_fastapi.models.users_extend_model import Users_extend
11
+
12
+ from files_server_fastapi.dependencies.user_dependencies import (
13
+ get_active_user,
14
+ require_area_admin_or_superadmin,
15
+ require_superadmin,
16
+ )
9
17
  from files_server_fastapi.models.area_model import Area
10
18
  from files_server_fastapi.models.rol_model import Rol
11
-
12
-
13
- from pydantic import BaseModel, Field, model_validator
19
+ from files_server_fastapi.models.users_extend_model import Users_extend
14
20
 
15
21
  class UserExtendCreate(BaseModel):
16
22
  user_id: int
@@ -32,9 +38,11 @@ class UserExtendResponse(BaseModel):
32
38
  user_id: int
33
39
  area_id: int
34
40
  rol_id: int
35
- role_id: int # Añadimos alias virtual para el frontend
41
+ role_id: int # Alias virtual para el frontend
36
42
  puesto: Optional[str] = None
37
-
43
+ deleted_at: Optional[datetime] = None
44
+ deleted_by: Optional[int] = None
45
+
38
46
  class Config:
39
47
  from_attributes = True
40
48
 
@@ -206,12 +214,13 @@ async def update_user_extend(user_id: int, user_ext_update: UserExtendUpdate, db
206
214
 
207
215
 
208
216
  # ==========================================
209
- # DELETE /users-extend/{user_id} — Eliminar
217
+ # DELETE /users-extend/{user_id} — Eliminar (Hard Delete - solo estructura)
210
218
  # ==========================================
211
219
  @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Eliminar extensión de usuario")
212
220
  async def delete_user_extend(user_id: int, db: AsyncSession = Depends(get_db_session)):
213
221
  """
214
222
  Elimina el vínculo de área y rol de un usuario.
223
+ NOTA: Para dar de baja a un usuario sin eliminar sus datos, usa DELETE /{user_id}/deactivate.
215
224
  """
216
225
  result = await db.execute(select(Users_extend).where(Users_extend.user_id == user_id))
217
226
  user_ext = result.scalars().first()
@@ -224,3 +233,174 @@ async def delete_user_extend(user_id: int, db: AsyncSession = Depends(get_db_ses
224
233
 
225
234
  await db.delete(user_ext)
226
235
  await db.commit()
236
+
237
+
238
+ # ==========================================
239
+ # DELETE /users-extend/{user_id}/deactivate — Soft Delete (Baja Lógica)
240
+ # ==========================================
241
+ @router.delete(
242
+ "/{user_id}/deactivate",
243
+ status_code=status.HTTP_200_OK,
244
+ summary="Dar de baja a un usuario (Borrado Lógico)",
245
+ )
246
+ async def deactivate_user(
247
+ user_id: int,
248
+ auth: tuple = Depends(require_area_admin_or_superadmin),
249
+ db: AsyncSession = Depends(get_db_session),
250
+ ):
251
+ """
252
+ Da de baja lógica a un usuario. El usuario no podrá hacer login ni usar
253
+ ningún endpoint del sistema. Sus datos se conservan para auditoría.
254
+
255
+ Reglas de autorización:
256
+ - Superadmin/Sistemas: puede dar de baja a cualquier usuario.
257
+ - Admin de Área: solo puede dar de baja a usuarios de su misma área,
258
+ y no puede dar de baja a otros admins de área.
259
+
260
+ El trigger PostgreSQL automáticamente:
261
+ - Pone is_verified = FALSE en la tabla users (bloquea el login).
262
+ - Pone samba_enabled = FALSE en users_extend.
263
+ """
264
+ current_user, executor_ext, is_superadmin = auth
265
+
266
+ # Buscar al usuario objetivo
267
+ result = await db.execute(
268
+ select(Users_extend).where(Users_extend.user_id == user_id)
269
+ )
270
+ target_ext = result.scalars().first()
271
+
272
+ if not target_ext:
273
+ raise HTTPException(
274
+ status_code=status.HTTP_404_NOT_FOUND,
275
+ detail=f"Usuario con ID {user_id} no encontrado.",
276
+ )
277
+
278
+ # No puede darse de baja a sí mismo
279
+ if user_id == current_user.id:
280
+ raise HTTPException(
281
+ status_code=status.HTTP_400_BAD_REQUEST,
282
+ detail="No puedes darte de baja a ti mismo.",
283
+ )
284
+
285
+ # Verificar si ya está dado de baja
286
+ if target_ext.deleted_at is not None:
287
+ raise HTTPException(
288
+ status_code=status.HTTP_409_CONFLICT,
289
+ detail=f"El usuario con ID {user_id} ya está dado de baja.",
290
+ )
291
+
292
+ if not is_superadmin:
293
+ # Admin de área: solo puede dar de baja a usuarios de su misma área
294
+ if target_ext.area_id != executor_ext.area_id:
295
+ raise HTTPException(
296
+ status_code=status.HTTP_403_FORBIDDEN,
297
+ detail="Solo puedes dar de baja a usuarios de tu propia área.",
298
+ )
299
+
300
+ # Admin de área: no puede dar de baja a otros admins de área ni superadmins
301
+ from files_server_fastapi.dependencies.user_dependencies import (
302
+ _get_privilege_level,
303
+ PRIVILEGE_AREA_ADMIN,
304
+ )
305
+ target_level = await _get_privilege_level(target_ext.rol_id, db)
306
+ if target_level >= PRIVILEGE_AREA_ADMIN:
307
+ raise HTTPException(
308
+ status_code=status.HTTP_403_FORBIDDEN,
309
+ detail="No puedes dar de baja a un Admin de Área o Superadmin. Contacta a Sistemas.",
310
+ )
311
+
312
+ # Ejecutar el soft delete
313
+ target_ext.deleted_at = datetime.now(timezone.utc)
314
+ target_ext.deleted_by = current_user.id
315
+ db.add(target_ext)
316
+ await db.commit()
317
+ await db.refresh(target_ext)
318
+
319
+ return {
320
+ "message": f"Usuario con ID {user_id} dado de baja correctamente.",
321
+ "deleted_at": target_ext.deleted_at.isoformat(),
322
+ "deleted_by": current_user.id,
323
+ }
324
+
325
+
326
+ # ==========================================
327
+ # POST /users-extend/{user_id}/reactivate — Reactivar usuario
328
+ # ==========================================
329
+ @router.post(
330
+ "/{user_id}/reactivate",
331
+ status_code=status.HTTP_200_OK,
332
+ summary="Reactivar un usuario dado de baja (solo Sistemas/Superadmin)",
333
+ )
334
+ async def reactivate_user(
335
+ user_id: int,
336
+ auth: tuple = Depends(require_superadmin),
337
+ db: AsyncSession = Depends(get_db_session),
338
+ ):
339
+ """
340
+ Reactiva a un usuario previamente dado de baja.
341
+ Solo Sistemas/Superadmin puede ejecutar esta acción.
342
+
343
+ El trigger PostgreSQL automáticamente restaura is_verified = TRUE
344
+ en la tabla users, permitiendo que el usuario vuelva a hacer login.
345
+ """
346
+ result = await db.execute(
347
+ select(Users_extend).where(Users_extend.user_id == user_id)
348
+ )
349
+ target_ext = result.scalars().first()
350
+
351
+ if not target_ext:
352
+ raise HTTPException(
353
+ status_code=status.HTTP_404_NOT_FOUND,
354
+ detail=f"Usuario con ID {user_id} no encontrado.",
355
+ )
356
+
357
+ if target_ext.deleted_at is None:
358
+ raise HTTPException(
359
+ status_code=status.HTTP_409_CONFLICT,
360
+ detail=f"El usuario con ID {user_id} no está dado de baja.",
361
+ )
362
+
363
+ target_ext.deleted_at = None
364
+ target_ext.deleted_by = None
365
+ db.add(target_ext)
366
+ await db.commit()
367
+
368
+ return {
369
+ "message": f"Usuario con ID {user_id} reactivado correctamente.",
370
+ "user_id": user_id,
371
+ }
372
+
373
+
374
+ # ==========================================
375
+ # GET /users-extend/inactive — Listar usuarios dados de baja (Auditoría)
376
+ # ==========================================
377
+ @router.get(
378
+ "/inactive",
379
+ status_code=status.HTTP_200_OK,
380
+ summary="Listar usuarios dados de baja (Auditoría — solo Sistemas/Superadmin)",
381
+ )
382
+ async def list_inactive_users(
383
+ auth: tuple = Depends(require_superadmin),
384
+ db: AsyncSession = Depends(get_db_session),
385
+ ):
386
+ """
387
+ Retorna todos los usuarios dados de baja con su fecha y responsable de baja.
388
+ Solo accesible por Sistemas/Superadmin. Útil para auditoría y trazabilidad.
389
+ """
390
+ result = await db.execute(
391
+ select(Users_extend).where(Users_extend.deleted_at.is_not(None))
392
+ )
393
+ inactive_users = result.scalars().all()
394
+
395
+ return [
396
+ {
397
+ "id": u.id,
398
+ "user_id": u.user_id,
399
+ "area_id": u.area_id,
400
+ "rol_id": u.rol_id,
401
+ "puesto": u.puesto,
402
+ "deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
403
+ "deleted_by": u.deleted_by,
404
+ }
405
+ for u in inactive_users
406
+ ]
@@ -637,7 +637,7 @@ wheels = [
637
637
 
638
638
  [[package]]
639
639
  name = "files-server-fastapi"
640
- version = "0.1.43"
640
+ version = "0.1.44"
641
641
  source = { editable = "." }
642
642
  dependencies = [
643
643
  { name = "oauth2fast-fastapi" },
@@ -1,13 +0,0 @@
1
- from typing import Optional
2
- from datetime import datetime, timezone
3
- from sqlmodel import Field
4
- from oauth2fast_fastapi import AuthModel
5
-
6
- def get_utc_now():
7
- return datetime.now(timezone.utc)
8
-
9
- class Rol(AuthModel, table=True):
10
- __tablename__ = "rol"
11
-
12
- role_name: str = Field(nullable=False)
13
- description: Optional[str] = Field(default=None)
@@ -1,45 +0,0 @@
1
- from fastapi import APIRouter, Depends
2
- from sqlalchemy.ext.asyncio import AsyncSession
3
- from sqlalchemy import select
4
-
5
- from pgsqlasync2fast_fastapi.dependencies import get_db_session
6
- from files_server_fastapi.models.rol_model import Rol
7
-
8
- router = APIRouter(prefix="/roles", tags=["Gestión de Roles"])
9
-
10
- @router.post("/", response_model=Rol, summary="Crear un nuevo Rol")
11
- async def create_rol(rol: Rol, db: AsyncSession = Depends(get_db_session)):
12
- db.add(rol)
13
- await db.commit()
14
- await db.refresh(rol)
15
- return rol
16
-
17
- @router.get("/", response_model=list[Rol], summary="Obtener todos los Roles")
18
- async def get_roles(db: AsyncSession = Depends(get_db_session)):
19
- result = await db.execute(select(Rol))
20
- return result.scalars().all()
21
-
22
- @router.put("/{rol_id}", response_model=Rol, summary="Editar un Rol")
23
- async def update_rol(rol_id: int, rol_data: Rol, db: AsyncSession = Depends(get_db_session)):
24
- result = await db.execute(select(Rol).where(Rol.id == rol_id))
25
- db_rol = result.scalars().first()
26
- if not db_rol:
27
- return {"detail": "Rol no encontrado"} # Simplified error handling for now
28
-
29
- db_rol.role_name = rol_data.role_name
30
- db_rol.description = rol_data.description
31
-
32
- await db.commit()
33
- await db.refresh(db_rol)
34
- return db_rol
35
-
36
- @router.delete("/{rol_id}", summary="Eliminar un Rol")
37
- async def delete_rol(rol_id: int, db: AsyncSession = Depends(get_db_session)):
38
- result = await db.execute(select(Rol).where(Rol.id == rol_id))
39
- db_rol = result.scalars().first()
40
- if not db_rol:
41
- return {"detail": "Rol no encontrado"}
42
-
43
- await db.delete(db_rol)
44
- await db.commit()
45
- return {"message": "Rol eliminado correctamente"}