feature/file-manager-user-only-read

This commit is contained in:
2026-04-06 07:43:43 -06:00
parent 9439a6a775
commit 1355bf34c4
10 changed files with 614 additions and 12 deletions

View File

@@ -1,19 +1,147 @@
"""
Audit Log Router
"""
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, Query, HTTPException
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_, desc, distinct
from core.database import get_core_db
from core.security import get_current_user # Assuming this exists
from core.security import get_current_user, get_tenant_from_token
from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket
from .models import AuditLog
from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse
from .schemas import (
AuditFileBreadcrumb,
AuditFileBrowserResponse,
AuditFileFolderItem,
AuditFileObjectItem,
AuditLogDetailResponse,
AuditLogListResponse,
)
from api.v1.modules.a76.general_catalogs.company.models import Company
router = APIRouter()
_SEGMENT_LABELS = {
"tenants": "Espacio",
"companies": "Companias",
"users": "Usuarios",
"imports": "Importaciones",
"csv": "Archivos CSV",
"branding": "Logotipos",
"certificates": "Certificados",
"customs_brokers": "Agentes aduanales",
"keys": "Llaves",
"cove": "COVE",
"doda": "DODA",
"system": "Sistema",
"help": "Ayuda",
}
def _tenant_id_from_user(current_user: Dict[str, Any]) -> int:
tenant_id = get_tenant_from_token(current_user) or current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=401, detail="User context is invalid")
return int(tenant_id)
def _normalize_relative_path(raw: Optional[str]) -> str:
if not raw:
return ""
val = raw.strip().strip("/")
if not val:
return ""
if ".." in val or "\\" in val:
raise HTTPException(status_code=400, detail="Invalid path")
parts = [p for p in val.split("/") if p]
for part in parts:
if part in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid path segment")
return "/".join(parts)
def _tenant_prefix(tenant_id: int) -> str:
return f"tenants/{tenant_id}/"
def _relative_from_tenant_prefix(key: str, tenant_prefix: str) -> str:
if not key.startswith(tenant_prefix):
raise HTTPException(status_code=403, detail="Access denied to object key")
return key[len(tenant_prefix) :].strip("/")
def _companies_map(db: Session, tenant_id: int) -> Dict[str, str]:
rows = (
db.query(Company.id, Company.name)
.filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
.all()
)
out: Dict[str, str] = {}
for company_id, company_name in rows:
if company_id is None:
continue
safe_name = (company_name or "").strip()
out[str(company_id)] = safe_name or "Compania"
return out
def _display_segment(part: str, prev_part: Optional[str], company_names: Dict[str, str]) -> str:
if prev_part == "companies":
return company_names.get(part, "Compania")
if part in _SEGMENT_LABELS:
return _SEGMENT_LABELS[part]
# Evita exponer IDs puros en UI.
if part.isdigit():
return "Elemento"
return part.replace("_", " ").strip().title() or "Elemento"
def _display_path(rel_path: str, company_names: Dict[str, str]) -> str:
if not rel_path:
return "Raiz de archivos"
parts = [p for p in rel_path.split("/") if p]
labels: List[str] = []
prev: Optional[str] = None
for part in parts:
labels.append(_display_segment(part, prev, company_names))
prev = part
return " / ".join(labels)
def _display_file_name(filename: str) -> str:
stem, dot, ext = filename.rpartition(".")
if not dot:
stem = filename
ext = ""
if stem.isdigit():
return f"Archivo{f'.{ext}' if ext else ''}"
return filename
def _build_breadcrumbs(rel_path: str, company_names: Dict[str, str]) -> List[AuditFileBreadcrumb]:
breadcrumbs: List[AuditFileBreadcrumb] = [
AuditFileBreadcrumb(path="", display_name="Raiz de archivos")
]
if not rel_path:
return breadcrumbs
parts = [p for p in rel_path.split("/") if p]
prev: Optional[str] = None
acc: List[str] = []
for part in parts:
acc.append(part)
breadcrumbs.append(
AuditFileBreadcrumb(
path="/".join(acc),
display_name=_display_segment(part, prev, company_names),
)
)
prev = part
return breadcrumbs
@router.get("/bitacora", response_model=AuditLogListResponse)
async def get_bitacora(
page: int = Query(1, ge=1),
@@ -91,3 +219,101 @@ async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)):
if not log:
raise HTTPException(status_code=404, detail="Log entry not found")
return log
@router.get("/files", response_model=AuditFileBrowserResponse)
async def list_tenant_files(
path: Optional[str] = Query(default="", description="Ruta relativa de navegación."),
continuation_token: Optional[str] = Query(default=None),
max_keys: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Explorador de archivos de solo lectura para Auditoría.
"""
if not should_ensure_s3_bucket():
raise HTTPException(status_code=400, detail="S3 storage is disabled")
tenant_id = _tenant_id_from_user(current_user)
tenant_prefix = _tenant_prefix(tenant_id)
rel_path = _normalize_relative_path(path)
list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix
data = list_objects_tree(
prefix=list_prefix,
delimiter="/",
max_keys=max_keys,
continuation_token=continuation_token,
)
company_names = _companies_map(db, tenant_id)
folders: List[AuditFileFolderItem] = []
for prefix in data.get("prefixes", []):
rel = _relative_from_tenant_prefix(prefix, tenant_prefix)
folders.append(
AuditFileFolderItem(
path=rel,
display_name=_display_path(rel, company_names).split(" / ")[-1],
)
)
files: List[AuditFileObjectItem] = []
for obj in data.get("objects", []):
key = obj.get("key")
if not key:
continue
rel = _relative_from_tenant_prefix(key, tenant_prefix)
name = rel.rsplit("/", 1)[-1]
files.append(
AuditFileObjectItem(
path=rel,
display_name=_display_file_name(name),
size=int(obj.get("size", 0) or 0),
last_modified=obj.get("last_modified"),
)
)
return AuditFileBrowserResponse(
current_path=rel_path,
display_path=_display_path(rel_path, company_names),
breadcrumbs=_build_breadcrumbs(rel_path, company_names),
folders=sorted(folders, key=lambda x: x.display_name.lower()),
files=sorted(files, key=lambda x: x.display_name.lower()),
next_token=data.get("next_continuation_token"),
)
@router.get("/files/download")
async def download_tenant_file(
path: str = Query(..., description="Ruta relativa del archivo a descargar."),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Descarga segura (backend streaming) de archivos autorizados.
"""
if not should_ensure_s3_bucket():
raise HTTPException(status_code=400, detail="S3 storage is disabled")
tenant_id = _tenant_id_from_user(current_user)
tenant_prefix = _tenant_prefix(tenant_id)
rel_path = _normalize_relative_path(path)
if not rel_path or rel_path.endswith("/"):
raise HTTPException(status_code=400, detail="A file path is required")
object_key = f"{tenant_prefix}{rel_path}"
if not object_key.startswith(tenant_prefix):
raise HTTPException(status_code=403, detail="Access denied to object key")
try:
body = get_object_bytes(object_key)
except Exception as e:
raise HTTPException(status_code=404, detail=f"File not found: {e}") from e
filename = rel_path.rsplit("/", 1)[-1]
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return StreamingResponse(
iter([body]),
media_type="application/octet-stream",
headers=headers,
)