feature/file-manager-user-only-read
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user