""" 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 from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource 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() def _audit_scope_tenant_id(db: Session, company_id: int) -> int: """Tenant_id de la fila ``Company`` para filtrar ``audit_logs`` (alineado con lo persistido).""" row = ( db.query(Company.tenant_id) .filter(Company.id == company_id, Company.deleted_at.is_(None)) .first() ) if not row: raise HTTPException(status_code=404, detail="Company not found") return int(row[0]) _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 _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], current_user_id: Optional[str] = None, current_user_label: Optional[str] = None, ) -> str: if prev_part == "companies": return company_names.get(part, "Compania") if prev_part == "users": # Para carpetas de usuarios, mostrar un nombre amigable: # - Si es el propio usuario actual, usar preferred_username/email/nombre. # - Para otros IDs (UUIDs) mostrar un label genérico. if current_user_id and part == str(current_user_id): return (current_user_label or "").strip() or "Usuario" return "Usuario" 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], current_user_id: Optional[str] = None, current_user_label: Optional[str] = None, ) -> 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, current_user_id=current_user_id, current_user_label=current_user_label, ) ) 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], current_user_id: Optional[str] = None, current_user_label: Optional[str] = None, ) -> 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, current_user_id=current_user_id, current_user_label=current_user_label, ), ) ) prev = part return breadcrumbs @router.get("/bitacora", response_model=AuditLogListResponse) async def get_bitacora( company_id: int = Query(..., description="Company ID"), 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), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Bitácora por compañía. Requiere permiso ``audit_logs.view``. """ validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) query = db.query(AuditLog).filter( AuditLog.company_id == company_id, AuditLog.tenant_id == scope_tenant_id, ) # 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( company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Lista de procedimientos para filtros (alcance compañía). Requiere ``audit_logs.view``. """ validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) results = ( db.query(AuditLog.procedure) .filter( AuditLog.company_id == company_id, AuditLog.tenant_id == scope_tenant_id, ) .distinct() .order_by(AuditLog.procedure) .all() ) 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, company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Detalle de un registro de bitácora. Requiere ``audit_logs.view``. """ validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) log = ( db.query(AuditLog) .filter( AuditLog.spec_id == spec_id, AuditLog.company_id == company_id, AuditLog.tenant_id == scope_tenant_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( company_id: int = Query(..., description="Company ID"), 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. Requiere permiso ``audit_logs.view``; el prefijo S3 sigue al tenant de la compañía. """ if not should_ensure_s3_bucket(): raise HTTPException(status_code=400, detail="S3 storage is disabled") validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) tenant_prefix = _tenant_prefix(scope_tenant_id) rel_path = _normalize_relative_path(path) list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix # Datos del usuario actual para etiquetas amigables bajo /users/{id}/... current_user_id = str(current_user.get("sub") or "") current_user_label = ( (current_user.get("preferred_username") or "").strip() or (current_user.get("name") or "").strip() or (current_user.get("email") or "").strip() or "Usuario" ) data = list_objects_tree( prefix=list_prefix, delimiter="/", max_keys=max_keys, continuation_token=continuation_token, ) company_names = _companies_map(db, scope_tenant_id) folders: List[AuditFileFolderItem] = [] for prefix in data.get("prefixes", []): rel = _relative_from_tenant_prefix(prefix, tenant_prefix) folders.append( AuditFileFolderItem( path=rel, # Para el gestor de archivos mostramos el nombre amigable del último segmento # (empresa, usuario actual, etc.), no el ID bruto. display_name=_display_path( rel, company_names, current_user_id=current_user_id, current_user_label=current_user_label, ).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, current_user_id=current_user_id, current_user_label=current_user_label, ), breadcrumbs=_build_breadcrumbs( rel_path, company_names, current_user_id=current_user_id, current_user_label=current_user_label, ), 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( company_id: int = Query(..., description="Company ID"), path: str = Query(..., description="Ruta relativa del archivo a descargar."), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Descarga segura (backend streaming) de archivos autorizados. Requiere ``audit_logs.view``. """ if not should_ensure_s3_bucket(): raise HTTPException(status_code=400, detail="S3 storage is disabled") validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) tenant_prefix = _tenant_prefix(scope_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, )