files-server-fastapi 0.1.4__tar.gz → 0.1.6__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_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/PKG-INFO +1 -1
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/pyproject.toml +1 -1
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/__init__.py +12 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/constants.py +24 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/dependencies.py +90 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/download_router.py +42 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/folder_router.py +41 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/list_router.py +44 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/open_url_router.py +51 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/tree_router.py +31 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/files/upload_router.py +64 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/routers/area_router.py +131 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/routers/files_router.py +18 -0
- files_server_fastapi-0.1.6/src/files_server_fastapi/routers/users_extend_router.py +151 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/uv.lock +1 -1
- files_server_fastapi-0.1.4/src/files_server_fastapi/routers/area_router.py +0 -26
- files_server_fastapi-0.1.4/src/files_server_fastapi/routers/files_router.py +0 -479
- files_server_fastapi-0.1.4/src/files_server_fastapi/routers/users_extend_router.py +0 -67
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/.gitignore +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/.python-version +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/LICENSE +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/MANIFEST.in +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/README.md +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/__init__.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/models/__init__.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/models/area_model.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/models/permisos_model.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/models/rol_model.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/models/rutas_model.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/models/users_extend_model.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/routers/__init__.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/routers/permisos_router.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/src/files_server_fastapi/routers/rol_router.py +0 -0
- {files_server_fastapi-0.1.4 → files_server_fastapi-0.1.6}/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.
|
|
3
|
+
Version: 0.1.6
|
|
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.
|
|
3
|
+
version = "0.1.6"
|
|
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,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Paquete de sub-módulos para el router de archivos.
|
|
3
|
+
|
|
4
|
+
constants.py — BASE_DIR, OFFICE_PROTOCOLS, INLINE_MIME_TYPES
|
|
5
|
+
dependencies.py — check_folder_access (validación ACL)
|
|
6
|
+
list_router.py — GET /files/list
|
|
7
|
+
folder_router.py — POST /files/folder
|
|
8
|
+
upload_router.py — POST /files/upload
|
|
9
|
+
open_url_router.py — GET /files/open-url
|
|
10
|
+
download_router.py — GET /files/download
|
|
11
|
+
tree_router.py — GET /files/tree
|
|
12
|
+
"""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Directorio maestro (ruta de red Samba vista desde Windows)
|
|
2
|
+
BASE_DIR = r"\\192.168.1.122\Compartido"
|
|
3
|
+
|
|
4
|
+
# Mapeo extensión → protocolo de Office
|
|
5
|
+
OFFICE_PROTOCOLS = {
|
|
6
|
+
"doc": "ms-word",
|
|
7
|
+
"docx": "ms-word",
|
|
8
|
+
"dot": "ms-word",
|
|
9
|
+
"dotx": "ms-word",
|
|
10
|
+
"xls": "ms-excel",
|
|
11
|
+
"xlsx": "ms-excel",
|
|
12
|
+
"xlsm": "ms-excel",
|
|
13
|
+
"ppt": "ms-powerpoint",
|
|
14
|
+
"pptx": "ms-powerpoint",
|
|
15
|
+
"pps": "ms-powerpoint",
|
|
16
|
+
"ppsx": "ms-powerpoint",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# MIME types que el navegador puede mostrar inline (sin descarga forzada)
|
|
20
|
+
INLINE_MIME_TYPES = {
|
|
21
|
+
"application/pdf",
|
|
22
|
+
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
|
|
23
|
+
"text/plain", "text/csv", "text/html",
|
|
24
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from fastapi import HTTPException, Depends
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlmodel import select
|
|
4
|
+
from pgsqlasync2fast_fastapi.dependencies import get_db_session
|
|
5
|
+
from oauth2fast_fastapi import get_current_user, User
|
|
6
|
+
from files_server_fastapi.models.permisos_model import User_Ruta_Access
|
|
7
|
+
from files_server_fastapi.models.rutas_model import Rutas
|
|
8
|
+
from files_server_fastapi.models.users_extend_model import Users_extend
|
|
9
|
+
from files_server_fastapi.models.area_model import Area
|
|
10
|
+
from files_server_fastapi.models.rol_model import Rol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def check_folder_access(
|
|
14
|
+
area: str,
|
|
15
|
+
subpath: str = "/",
|
|
16
|
+
required_access: str = "allow_read",
|
|
17
|
+
current_user: User = Depends(get_current_user),
|
|
18
|
+
db: AsyncSession = Depends(get_db_session),
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Verifica si el usuario actual tiene acceso al área y subpath indicados.
|
|
22
|
+
required_access: 'allow_read' (por defecto) o 'allow_write'
|
|
23
|
+
"""
|
|
24
|
+
# 1. Obtener extensión de usuario y verificar pertenencia al área
|
|
25
|
+
result_ext = await db.execute(
|
|
26
|
+
select(Users_extend).where(Users_extend.user_id == current_user.id)
|
|
27
|
+
)
|
|
28
|
+
user_exts = result_ext.scalars().all()
|
|
29
|
+
|
|
30
|
+
area_obj = None
|
|
31
|
+
user_ext_match = None
|
|
32
|
+
for ext in user_exts:
|
|
33
|
+
res_area = await db.execute(select(Area).where(Area.id == ext.area_id))
|
|
34
|
+
a = res_area.scalars().first()
|
|
35
|
+
if a and a.area_name.upper() == area.upper():
|
|
36
|
+
area_obj = a
|
|
37
|
+
user_ext_match = ext
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
if not area_obj or not user_ext_match:
|
|
41
|
+
raise HTTPException(status_code=403, detail="No perteneces a esta área")
|
|
42
|
+
|
|
43
|
+
# Obtener el Rol Base del usuario en esta área
|
|
44
|
+
res_rol = await db.execute(select(Rol).where(Rol.id == user_ext_match.rol_id))
|
|
45
|
+
rol_obj = res_rol.scalars().first()
|
|
46
|
+
rol_name = rol_obj.role_name.lower() if rol_obj else ""
|
|
47
|
+
|
|
48
|
+
# 2. Verificar ACL específico por ruta y herencia de padres
|
|
49
|
+
logical_path = f"/{area.upper()}/{subpath.strip('/')}".replace("//", "/")
|
|
50
|
+
parts = logical_path.strip("/").split("/")
|
|
51
|
+
paths_to_check = []
|
|
52
|
+
current_path = ""
|
|
53
|
+
for part in parts:
|
|
54
|
+
if part:
|
|
55
|
+
current_path += f"/{part}"
|
|
56
|
+
paths_to_check.append(current_path)
|
|
57
|
+
|
|
58
|
+
paths_to_check.reverse() # Más específico primero
|
|
59
|
+
|
|
60
|
+
res_acl = await db.execute(
|
|
61
|
+
select(Rutas.ruta, User_Ruta_Access)
|
|
62
|
+
.join(User_Ruta_Access, User_Ruta_Access.ruta_id == Rutas.id)
|
|
63
|
+
.where(Rutas.ruta.in_(paths_to_check))
|
|
64
|
+
.where(User_Ruta_Access.user_id == current_user.id)
|
|
65
|
+
)
|
|
66
|
+
acls_encontrados = {row[0]: row[1] for row in res_acl.all()}
|
|
67
|
+
|
|
68
|
+
for path_in_tree in paths_to_check:
|
|
69
|
+
if path_in_tree in acls_encontrados:
|
|
70
|
+
acl_obj = acls_encontrados[path_in_tree]
|
|
71
|
+
|
|
72
|
+
if acl_obj.access_type == "deny_all":
|
|
73
|
+
raise HTTPException(status_code=403, detail="Acceso denegado a esta carpeta o heredado de una superior")
|
|
74
|
+
|
|
75
|
+
if required_access == "allow_write":
|
|
76
|
+
if acl_obj.access_type == "allow_write":
|
|
77
|
+
return True
|
|
78
|
+
elif acl_obj.access_type == "allow_read":
|
|
79
|
+
raise HTTPException(status_code=403, detail="Solo tienes permiso de lectura (regla heredada)")
|
|
80
|
+
|
|
81
|
+
if required_access == "allow_read":
|
|
82
|
+
if acl_obj.access_type in ["allow_read", "allow_write"]:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
# 3. Sin ACL explícito → aplicar reglas del Rol Base
|
|
86
|
+
if required_access == "allow_write":
|
|
87
|
+
if "editor" not in rol_name and "admin" not in rol_name:
|
|
88
|
+
raise HTTPException(status_code=403, detail="Tu rol no permite modificar esta carpeta")
|
|
89
|
+
|
|
90
|
+
return True
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import mimetypes
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
4
|
+
from fastapi.responses import FileResponse
|
|
5
|
+
from files_server_fastapi.files.constants import BASE_DIR, INLINE_MIME_TYPES
|
|
6
|
+
from files_server_fastapi.files.dependencies import check_folder_access
|
|
7
|
+
|
|
8
|
+
router = APIRouter()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/download", summary="Descargar o visualizar un archivo inline en el navegador")
|
|
12
|
+
async def download_file(
|
|
13
|
+
area: str,
|
|
14
|
+
filename: str,
|
|
15
|
+
subpath: str = "/",
|
|
16
|
+
has_access: bool = Depends(check_folder_access)
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Sirve un archivo desde el share Samba.
|
|
20
|
+
- PDFs e imágenes → inline en el navegador.
|
|
21
|
+
- Otros tipos → descarga forzada.
|
|
22
|
+
"""
|
|
23
|
+
if ".." in subpath or ".." in filename:
|
|
24
|
+
raise HTTPException(status_code=400, detail="Ruta o nombre de archivo inválido")
|
|
25
|
+
|
|
26
|
+
safe_filename = os.path.basename(filename)
|
|
27
|
+
safe_subpath = subpath.strip("/")
|
|
28
|
+
ruta_real = os.path.join(BASE_DIR, area.upper(), safe_subpath, safe_filename) if safe_subpath else os.path.join(BASE_DIR, area.upper(), safe_filename)
|
|
29
|
+
|
|
30
|
+
if not os.path.isfile(ruta_real):
|
|
31
|
+
raise HTTPException(status_code=404, detail=f"Archivo no encontrado: {safe_filename}")
|
|
32
|
+
|
|
33
|
+
mime_type, _ = mimetypes.guess_type(safe_filename)
|
|
34
|
+
mime_type = mime_type or "application/octet-stream"
|
|
35
|
+
disposition = "inline" if mime_type in INLINE_MIME_TYPES else "attachment"
|
|
36
|
+
|
|
37
|
+
return FileResponse(
|
|
38
|
+
path=ruta_real,
|
|
39
|
+
media_type=mime_type,
|
|
40
|
+
filename=safe_filename,
|
|
41
|
+
headers={"Content-Disposition": f'{disposition}; filename="{safe_filename}"', "Cache-Control": "no-cache"},
|
|
42
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
from pgsqlasync2fast_fastapi.dependencies import get_db_session
|
|
6
|
+
from oauth2fast_fastapi import get_current_user, User
|
|
7
|
+
from files_server_fastapi.files.constants import BASE_DIR
|
|
8
|
+
from files_server_fastapi.files.dependencies import check_folder_access
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FolderCreate(BaseModel):
|
|
14
|
+
area: str
|
|
15
|
+
subpath: str
|
|
16
|
+
folder_name: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("/folder", summary="Crear una nueva carpeta en el servidor")
|
|
20
|
+
async def create_folder(
|
|
21
|
+
req: FolderCreate,
|
|
22
|
+
current_user: User = Depends(get_current_user),
|
|
23
|
+
db: AsyncSession = Depends(get_db_session)
|
|
24
|
+
):
|
|
25
|
+
await check_folder_access(area=req.area, subpath=req.subpath, required_access="allow_write", current_user=current_user, db=db)
|
|
26
|
+
|
|
27
|
+
if ".." in req.subpath or ".." in req.folder_name or "/" in req.folder_name:
|
|
28
|
+
raise HTTPException(status_code=400, detail="Nombre de carpeta o ruta inválida")
|
|
29
|
+
|
|
30
|
+
safe_subpath = req.subpath.strip("/")
|
|
31
|
+
ruta_final = os.path.join(BASE_DIR, req.area.upper(), safe_subpath, req.folder_name) if safe_subpath else os.path.join(BASE_DIR, req.area.upper(), req.folder_name)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
os.makedirs(ruta_final, exist_ok=False)
|
|
35
|
+
return {"message": "Carpeta creada exitosamente", "path": ruta_final}
|
|
36
|
+
except FileExistsError:
|
|
37
|
+
raise HTTPException(status_code=400, detail="Ya existe una carpeta con ese nombre aquí")
|
|
38
|
+
except PermissionError:
|
|
39
|
+
raise HTTPException(status_code=403, detail="El servidor rechazó el permiso de escritura")
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
4
|
+
from files_server_fastapi.files.constants import BASE_DIR
|
|
5
|
+
from files_server_fastapi.files.dependencies import check_folder_access
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/list", summary="Listar archivos de una carpeta")
|
|
11
|
+
async def list_directory(
|
|
12
|
+
area: str,
|
|
13
|
+
subpath: str = "/",
|
|
14
|
+
has_access: bool = Depends(check_folder_access)
|
|
15
|
+
):
|
|
16
|
+
"""Devuelve el contenido de una carpeta dentro del área indicada."""
|
|
17
|
+
if ".." in subpath:
|
|
18
|
+
raise HTTPException(status_code=400, detail="Ruta inválida")
|
|
19
|
+
|
|
20
|
+
safe_subpath = subpath.strip("/")
|
|
21
|
+
ruta_real = os.path.join(BASE_DIR, area.upper(), safe_subpath) if safe_subpath else os.path.join(BASE_DIR, area.upper())
|
|
22
|
+
|
|
23
|
+
if not os.path.exists(ruta_real):
|
|
24
|
+
return []
|
|
25
|
+
if not os.path.isdir(ruta_real):
|
|
26
|
+
raise HTTPException(status_code=400, detail="La ruta no es un directorio")
|
|
27
|
+
|
|
28
|
+
items = []
|
|
29
|
+
try:
|
|
30
|
+
with os.scandir(ruta_real) as ficheros:
|
|
31
|
+
for fichero in ficheros:
|
|
32
|
+
info = fichero.stat()
|
|
33
|
+
fecha_mod = datetime.fromtimestamp(info.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
34
|
+
if fichero.is_dir():
|
|
35
|
+
items.append({"name": fichero.name, "type": "folder", "updated": fecha_mod, "size": "", "locked": False})
|
|
36
|
+
else:
|
|
37
|
+
size_kb = info.st_size / 1024
|
|
38
|
+
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
|
39
|
+
items.append({"name": fichero.name, "type": "file", "updated": fecha_mod, "size": size_str, "locked": False})
|
|
40
|
+
|
|
41
|
+
items.sort(key=lambda x: (x["type"] == "file", x["name"].lower()))
|
|
42
|
+
return items
|
|
43
|
+
except PermissionError:
|
|
44
|
+
raise HTTPException(status_code=403, detail="Acceso denegado por el sistema operativo")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Depends
|
|
3
|
+
from files_server_fastapi.files.constants import BASE_DIR, OFFICE_PROTOCOLS
|
|
4
|
+
from files_server_fastapi.files.dependencies import check_folder_access
|
|
5
|
+
|
|
6
|
+
router = APIRouter()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/open-url", summary="Obtener URL para abrir un archivo en la app local (Office, etc.)")
|
|
10
|
+
async def get_open_url(
|
|
11
|
+
area: str,
|
|
12
|
+
filename: str,
|
|
13
|
+
subpath: str = "/",
|
|
14
|
+
has_access: bool = Depends(check_folder_access)
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Devuelve la URL de protocolo adecuada para abrir el archivo directamente
|
|
18
|
+
en la aplicación instalada en la PC del usuario.
|
|
19
|
+
|
|
20
|
+
- Archivos **Office** (.docx, .xlsx, .pptx…): URL `ms-word:ofe|u|...` para abrir y guardar directo.
|
|
21
|
+
- **Otros** (PDF, imágenes, txt): URL del endpoint `/files/download` para vista inline.
|
|
22
|
+
"""
|
|
23
|
+
if ".." in subpath or ".." in filename:
|
|
24
|
+
raise HTTPException(status_code=400, detail="Ruta o nombre de archivo inválido")
|
|
25
|
+
|
|
26
|
+
safe_filename = os.path.basename(filename)
|
|
27
|
+
safe_subpath = subpath.strip("/")
|
|
28
|
+
ruta_real = os.path.join(BASE_DIR, area.upper(), safe_subpath, safe_filename) if safe_subpath else os.path.join(BASE_DIR, area.upper(), safe_filename)
|
|
29
|
+
|
|
30
|
+
if not os.path.isfile(ruta_real):
|
|
31
|
+
raise HTTPException(status_code=404, detail=f"Archivo no encontrado: {safe_filename}")
|
|
32
|
+
|
|
33
|
+
ext = safe_filename.rsplit(".", 1)[-1].lower() if "." in safe_filename else ""
|
|
34
|
+
office_protocol = OFFICE_PROTOCOLS.get(ext)
|
|
35
|
+
|
|
36
|
+
if office_protocol:
|
|
37
|
+
subpath_win = safe_subpath.replace("/", "\\")
|
|
38
|
+
unc_path = f"{BASE_DIR}\\{area.upper()}\\{subpath_win}\\{safe_filename}" if safe_subpath else f"{BASE_DIR}\\{area.upper()}\\{safe_filename}"
|
|
39
|
+
return {
|
|
40
|
+
"type": "office",
|
|
41
|
+
"protocol": office_protocol,
|
|
42
|
+
"url": f"{office_protocol}:ofe|u|{unc_path}",
|
|
43
|
+
"unc_path": unc_path,
|
|
44
|
+
"filename": safe_filename,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"type": "download",
|
|
49
|
+
"url": f"/files/download?area={area}&subpath={subpath}&filename={safe_filename}",
|
|
50
|
+
"filename": safe_filename,
|
|
51
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
from files_server_fastapi.files.constants import BASE_DIR
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_directory_tree(path_to_scan: str, base_name: str = "") -> list:
|
|
9
|
+
"""Función recursiva para leer el árbol de subcarpetas."""
|
|
10
|
+
tree = []
|
|
11
|
+
try:
|
|
12
|
+
with os.scandir(path_to_scan) as entries:
|
|
13
|
+
for entry in entries:
|
|
14
|
+
if entry.is_dir():
|
|
15
|
+
relative_path = os.path.join(base_name, entry.name).replace("\\", "/")
|
|
16
|
+
tree.append({
|
|
17
|
+
"name": entry.name,
|
|
18
|
+
"path": f"/{relative_path}",
|
|
19
|
+
"children": get_directory_tree(entry.path, relative_path)
|
|
20
|
+
})
|
|
21
|
+
except PermissionError:
|
|
22
|
+
pass
|
|
23
|
+
return sorted(tree, key=lambda x: x["name"].lower())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("/tree", summary="Obtener el árbol de carpetas de un área")
|
|
27
|
+
async def get_area_tree(area: str):
|
|
28
|
+
area_path = os.path.join(BASE_DIR, area.upper())
|
|
29
|
+
if not os.path.exists(area_path):
|
|
30
|
+
return []
|
|
31
|
+
return get_directory_tree(area_path)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from pgsqlasync2fast_fastapi.dependencies import get_db_session
|
|
5
|
+
from oauth2fast_fastapi import get_current_user, User
|
|
6
|
+
from files_server_fastapi.files.constants import BASE_DIR
|
|
7
|
+
from files_server_fastapi.files.dependencies import check_folder_access
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("/upload", summary="Subir un archivo a una carpeta del servidor")
|
|
13
|
+
async def upload_file(
|
|
14
|
+
area: str = Form(...),
|
|
15
|
+
subpath: str = Form(default="/"),
|
|
16
|
+
file: UploadFile = File(...),
|
|
17
|
+
current_user: User = Depends(get_current_user),
|
|
18
|
+
db: AsyncSession = Depends(get_db_session)
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Sube un archivo a la carpeta indicada dentro del share Samba.
|
|
22
|
+
|
|
23
|
+
- **area**: Área destino (ej: 'CONTABILIDAD')
|
|
24
|
+
- **subpath**: Subcarpeta relativa (ej: '/' raíz, '/2024/Enero' subcarpeta)
|
|
25
|
+
- **file**: El archivo a subir (multipart/form-data)
|
|
26
|
+
"""
|
|
27
|
+
await check_folder_access(area=area, subpath=subpath, required_access="allow_write", current_user=current_user, db=db)
|
|
28
|
+
|
|
29
|
+
if ".." in subpath or ".." in (file.filename or ""):
|
|
30
|
+
raise HTTPException(status_code=400, detail="Ruta o nombre de archivo inválido")
|
|
31
|
+
|
|
32
|
+
safe_filename = os.path.basename(file.filename or "archivo_sin_nombre")
|
|
33
|
+
if not safe_filename:
|
|
34
|
+
raise HTTPException(status_code=400, detail="Nombre de archivo inválido")
|
|
35
|
+
|
|
36
|
+
safe_subpath = subpath.strip("/")
|
|
37
|
+
ruta_destino = os.path.join(BASE_DIR, area.upper(), safe_subpath) if safe_subpath else os.path.join(BASE_DIR, area.upper())
|
|
38
|
+
|
|
39
|
+
if not os.path.isdir(ruta_destino):
|
|
40
|
+
raise HTTPException(status_code=404, detail=f"La carpeta de destino no existe: /{area.upper()}/{safe_subpath}")
|
|
41
|
+
|
|
42
|
+
ruta_archivo = os.path.join(ruta_destino, safe_filename)
|
|
43
|
+
if os.path.exists(ruta_archivo):
|
|
44
|
+
raise HTTPException(status_code=409, detail=f"Ya existe un archivo con ese nombre: {safe_filename}")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
CHUNK_SIZE = 1024 * 1024 # 1 MB por chunk
|
|
48
|
+
with open(ruta_archivo, "wb") as f:
|
|
49
|
+
while chunk := await file.read(CHUNK_SIZE):
|
|
50
|
+
f.write(chunk)
|
|
51
|
+
|
|
52
|
+
size_bytes = os.path.getsize(ruta_archivo)
|
|
53
|
+
size_kb = size_bytes / 1024
|
|
54
|
+
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
|
|
55
|
+
ruta_logica = f"/{area.upper()}/{safe_subpath}/{safe_filename}".replace("//", "/")
|
|
56
|
+
|
|
57
|
+
return {"message": "Archivo subido exitosamente", "filename": safe_filename, "size": size_str, "path": ruta_logica}
|
|
58
|
+
|
|
59
|
+
except PermissionError:
|
|
60
|
+
raise HTTPException(status_code=403, detail="El servidor rechazó el permiso de escritura en el share Samba")
|
|
61
|
+
except OSError as e:
|
|
62
|
+
raise HTTPException(status_code=500, detail=f"Error del sistema al guardar el archivo: {e}")
|
|
63
|
+
finally:
|
|
64
|
+
await file.close()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from pgsqlasync2fast_fastapi.dependencies import get_db_session
|
|
8
|
+
from files_server_fastapi.models.area_model import Area
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/areas", tags=["Gestión de Áreas"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AreaCreate(BaseModel):
|
|
14
|
+
area_name: str
|
|
15
|
+
description: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AreaUpdate(BaseModel):
|
|
19
|
+
area_name: Optional[str] = None
|
|
20
|
+
description: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ==========================================
|
|
24
|
+
# POST /areas/ — Crear nueva área
|
|
25
|
+
# ==========================================
|
|
26
|
+
@router.post("/", response_model=Area, status_code=status.HTTP_201_CREATED, summary="Crear una nueva Área")
|
|
27
|
+
async def create_area(area_data: AreaCreate, db: AsyncSession = Depends(get_db_session)):
|
|
28
|
+
"""
|
|
29
|
+
Crea y guarda una nueva área en la base de datos.
|
|
30
|
+
Solo accesible por superusuarios desde el panel de administración.
|
|
31
|
+
"""
|
|
32
|
+
# Verificar si ya existe un área con el mismo nombre
|
|
33
|
+
result = await db.execute(select(Area).where(Area.area_name == area_data.area_name))
|
|
34
|
+
existing = result.scalars().first()
|
|
35
|
+
if existing:
|
|
36
|
+
raise HTTPException(
|
|
37
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
38
|
+
detail=f"Ya existe un área con el nombre '{area_data.area_name}'."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
new_area = Area(**area_data.model_dump())
|
|
42
|
+
db.add(new_area)
|
|
43
|
+
await db.commit()
|
|
44
|
+
await db.refresh(new_area)
|
|
45
|
+
return new_area
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ==========================================
|
|
49
|
+
# GET /areas/ — Listar todas las áreas
|
|
50
|
+
# ==========================================
|
|
51
|
+
@router.get("/", response_model=list[Area], summary="Obtener todas las Áreas")
|
|
52
|
+
async def get_areas(db: AsyncSession = Depends(get_db_session)):
|
|
53
|
+
"""
|
|
54
|
+
Devuelve la lista de todas las áreas registradas.
|
|
55
|
+
"""
|
|
56
|
+
result = await db.execute(select(Area))
|
|
57
|
+
return result.scalars().all()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ==========================================
|
|
61
|
+
# GET /areas/{area_id} — Obtener área por ID
|
|
62
|
+
# ==========================================
|
|
63
|
+
@router.get("/{area_id}", response_model=Area, summary="Obtener un Área por ID")
|
|
64
|
+
async def get_area(area_id: int, db: AsyncSession = Depends(get_db_session)):
|
|
65
|
+
"""
|
|
66
|
+
Devuelve los datos de un área específica por su ID.
|
|
67
|
+
"""
|
|
68
|
+
result = await db.execute(select(Area).where(Area.id == area_id))
|
|
69
|
+
area = result.scalars().first()
|
|
70
|
+
if not area:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
73
|
+
detail=f"No se encontró el área con ID {area_id}."
|
|
74
|
+
)
|
|
75
|
+
return area
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ==========================================
|
|
79
|
+
# PATCH /areas/{area_id} — Actualizar área
|
|
80
|
+
# ==========================================
|
|
81
|
+
@router.patch("/{area_id}", response_model=Area, summary="Actualizar un Área")
|
|
82
|
+
async def update_area(area_id: int, area_update: AreaUpdate, db: AsyncSession = Depends(get_db_session)):
|
|
83
|
+
"""
|
|
84
|
+
Actualiza parcialmente los datos de un área existente.
|
|
85
|
+
"""
|
|
86
|
+
result = await db.execute(select(Area).where(Area.id == area_id))
|
|
87
|
+
area = result.scalars().first()
|
|
88
|
+
if not area:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
91
|
+
detail=f"No se encontró el área con ID {area_id}."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Si se quiere cambiar el nombre, verificar que no exista otro con ese nombre
|
|
95
|
+
if area_update.area_name and area_update.area_name != area.area_name:
|
|
96
|
+
dup_result = await db.execute(select(Area).where(Area.area_name == area_update.area_name))
|
|
97
|
+
if dup_result.scalars().first():
|
|
98
|
+
raise HTTPException(
|
|
99
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
100
|
+
detail=f"Ya existe un área con el nombre '{area_update.area_name}'."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
update_data = area_update.model_dump(exclude_unset=True)
|
|
104
|
+
for key, value in update_data.items():
|
|
105
|
+
setattr(area, key, value)
|
|
106
|
+
|
|
107
|
+
db.add(area)
|
|
108
|
+
await db.commit()
|
|
109
|
+
await db.refresh(area)
|
|
110
|
+
return area
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ==========================================
|
|
114
|
+
# DELETE /areas/{area_id} — Eliminar área
|
|
115
|
+
# ==========================================
|
|
116
|
+
@router.delete("/{area_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Eliminar un Área")
|
|
117
|
+
async def delete_area(area_id: int, db: AsyncSession = Depends(get_db_session)):
|
|
118
|
+
"""
|
|
119
|
+
Elimina un área por su ID.
|
|
120
|
+
ADVERTENCIA: Solo eliminar si no hay usuarios asociados a esta área.
|
|
121
|
+
"""
|
|
122
|
+
result = await db.execute(select(Area).where(Area.id == area_id))
|
|
123
|
+
area = result.scalars().first()
|
|
124
|
+
if not area:
|
|
125
|
+
raise HTTPException(
|
|
126
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
127
|
+
detail=f"No se encontró el área con ID {area_id}."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
await db.delete(area)
|
|
131
|
+
await db.commit()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from files_server_fastapi.files import (
|
|
3
|
+
list_router,
|
|
4
|
+
folder_router,
|
|
5
|
+
upload_router,
|
|
6
|
+
open_url_router,
|
|
7
|
+
download_router,
|
|
8
|
+
tree_router,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/files", tags=["Archivos del Sistema"])
|
|
12
|
+
|
|
13
|
+
router.include_router(list_router.router)
|
|
14
|
+
router.include_router(folder_router.router)
|
|
15
|
+
router.include_router(upload_router.router)
|
|
16
|
+
router.include_router(open_url_router.router)
|
|
17
|
+
router.include_router(download_router.router)
|
|
18
|
+
router.include_router(tree_router.router)
|