""" Audit Log Router """ from datetime import date 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, 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 ( 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), page_size: int = Query(50, ge=1, le=100), search: Optional[str] = None, username: Optional[str] = None, procedure: Optional[str] = None, reference: Optional[str] = None, date_from: Optional[date] = None, date_to: Optional[date] = None, db: Session = Depends(get_core_db) ): """ Get legacy audit log (Bitácora) """ query = db.query(AuditLog) # Filters if date_from: query = query.filter(AuditLog.date >= date_from) if date_to: query = query.filter(AuditLog.date <= date_to) if username: query = query.filter(AuditLog.username.ilike(f"%{username}%")) if procedure: # Exact match for dropdown filter usually better, but let's allow partial if manual # Legacy UI sends exact strings usually query = query.filter(AuditLog.procedure == procedure) if reference: query = query.filter(AuditLog.reference.ilike(f"%{reference}%")) if search: # General search across main columns search_filter = or_( AuditLog.reference.ilike(f"%{search}%"), AuditLog.procedure.ilike(f"%{search}%"), AuditLog.movement.ilike(f"%{search}%"), AuditLog.username.ilike(f"%{search}%") ) query = query.filter(search_filter) total = query.count() # Sort by ID desc (newest first) -> Legacy usually shows newest first or spec_id desc logs = query.order_by(desc(AuditLog.spec_id))\ .offset((page - 1) * page_size)\ .limit(page_size)\ .all() return { "data": logs, "total": total, "page": page, "page_size": page_size } @router.get("/bitacora/procedimientos", response_model=List[str]) async def get_procedures(db: Session = Depends(get_core_db)): """ Get distinct list of procedures for filters """ results = db.query(distinct(AuditLog.procedure))\ .order_by(AuditLog.procedure)\ .all() # verify if result is tuple return [r[0] for r in results if r[0]] @router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse) async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)): """ Get full detail of a log entry """ log = db.query(AuditLog).filter(AuditLog.spec_id == spec_id).first() 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, )