From 1355bf34c4470ebbd673d6dc86fb771a0ceecc2b Mon Sep 17 00:00:00 2001 From: hreyes Date: Mon, 6 Apr 2026 07:43:43 -0600 Subject: [PATCH] feature/file-manager-user-only-read --- .../api/v1/modules/a76/audit_log/router.py | 234 +++++++++++++++++- .../api/v1/modules/a76/audit_log/schemas.py | 26 ++ backend/core/storage_s3.py | 55 +++- frontend/messages/en.json | 13 + frontend/messages/es.json | 13 + frontend/src/lib/api.ts | 1 + .../src/lib/api/dashboard/a76/audit_files.ts | 53 ++++ .../routes/dashboard/audit_logs/+page.svelte | 24 +- .../src/routes/dashboard/audit_logs/+page.ts | 2 +- .../dashboard/audit_logs/files-tab.svelte | 205 +++++++++++++++ 10 files changed, 614 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/api/dashboard/a76/audit_files.ts create mode 100644 frontend/src/routes/dashboard/audit_logs/files-tab.svelte diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py index 6bd03187..46d9bb11 100644 --- a/backend/api/v1/modules/a76/audit_log/router.py +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -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, + ) diff --git a/backend/api/v1/modules/a76/audit_log/schemas.py b/backend/api/v1/modules/a76/audit_log/schemas.py index bd803974..ae1ad0e0 100644 --- a/backend/api/v1/modules/a76/audit_log/schemas.py +++ b/backend/api/v1/modules/a76/audit_log/schemas.py @@ -50,3 +50,29 @@ class AuditLogListResponse(BaseModel): total: int page: int page_size: int + + +class AuditFileBreadcrumb(BaseModel): + path: str = Field(default="") + display_name: str + + +class AuditFileFolderItem(BaseModel): + path: str = Field(description="Ruta relativa interna del archivo, para navegación.") + display_name: str + + +class AuditFileObjectItem(BaseModel): + path: str = Field(description="Ruta relativa interna del archivo, para descarga.") + display_name: str + size: int + last_modified: Optional[datetime] = None + + +class AuditFileBrowserResponse(BaseModel): + current_path: str = Field(default="") + display_path: str = Field(default="") + breadcrumbs: List[AuditFileBreadcrumb] + folders: List[AuditFileFolderItem] + files: List[AuditFileObjectItem] + next_token: Optional[str] = None diff --git a/backend/core/storage_s3.py b/backend/core/storage_s3.py index 555d6ab3..5febded2 100644 --- a/backend/core/storage_s3.py +++ b/backend/core/storage_s3.py @@ -5,7 +5,7 @@ Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_k ``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí. """ import logging -from typing import Optional +from typing import Any, Dict, List, Optional import boto3 from botocore.config import Config @@ -111,6 +111,59 @@ def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str: ) +def list_objects_tree( + prefix: str, + delimiter: str = "/", + max_keys: int = 100, + continuation_token: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lista objetos/prefijos como árbol virtual. + + Retorna: + - ``prefixes``: subcarpetas (CommonPrefixes) + - ``objects``: objetos directos bajo ``prefix`` + - ``next_continuation_token`` y ``is_truncated`` para paginación + """ + params: Dict[str, Any] = { + "Bucket": settings.S3_BUCKET, + "Prefix": prefix, + "Delimiter": delimiter, + "MaxKeys": max(1, min(int(max_keys), 500)), + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + resp = _client().list_objects_v2(**params) + common_prefixes: List[str] = [ + p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix") + ] + objects: List[Dict[str, Any]] = [] + for obj in resp.get("Contents") or []: + key = obj.get("Key") + if not key: + continue + if key == prefix: + # Marcador de carpeta (objeto vacío con mismo nombre del prefijo). + continue + objects.append( + { + "key": key, + "size": int(obj.get("Size", 0) or 0), + "last_modified": obj.get("LastModified"), + "etag": obj.get("ETag"), + "storage_class": obj.get("StorageClass"), + } + ) + + return { + "prefixes": common_prefixes, + "objects": objects, + "next_continuation_token": resp.get("NextContinuationToken"), + "is_truncated": bool(resp.get("IsTruncated")), + } + + def s3_key_for_csv_import( tenant_id, company_id: int, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a4985a02..ed08c90a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -119,6 +119,19 @@ "audit_logs_description": "Audit trail of operations and background task (Celery) status.", "audit_logs_tab_bitacora": "Audit trail", "audit_logs_tab_tasks": "Background tasks", + "audit_logs_tab_files": "File manager", + "audit_logs_files_title": "File manager", + "audit_logs_files_root": "Files root", + "audit_logs_files_refresh": "Refresh", + "audit_logs_files_list_title": "Contents", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Name", + "audit_logs_files_col_size": "Size", + "audit_logs_files_col_modified": "Modified", + "audit_logs_files_col_actions": "Actions", + "audit_logs_files_loading": "Loading files...", + "audit_logs_files_empty": "No files or folders found in this location.", + "audit_logs_files_download": "Download", "client_provider_type": { "client_indicator": "C", "provider_indicator": "P", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 53e27d56..b0ab3e96 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -119,6 +119,19 @@ "audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).", "audit_logs_tab_bitacora": "Bitácora", "audit_logs_tab_tasks": "Tareas en segundo plano", + "audit_logs_tab_files": "Gestor de archivos", + "audit_logs_files_title": "Gestor de archivos", + "audit_logs_files_root": "Raíz de archivos", + "audit_logs_files_refresh": "Actualizar", + "audit_logs_files_list_title": "Contenido", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Nombre", + "audit_logs_files_col_size": "Tamaño", + "audit_logs_files_col_modified": "Modificado", + "audit_logs_files_col_actions": "Acciones", + "audit_logs_files_loading": "Cargando archivos...", + "audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.", + "audit_logs_files_download": "Descargar", "client_provider_type": { "client_indicator": "C", "provider_indicator": "P", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 42b5290a..ce180b4f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -456,6 +456,7 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise(endpoint: string) => fetchApi(endpoint, { method: 'GET' }), + getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }), post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { diff --git a/frontend/src/lib/api/dashboard/a76/audit_files.ts b/frontend/src/lib/api/dashboard/a76/audit_files.ts new file mode 100644 index 00000000..def3ba73 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/audit_files.ts @@ -0,0 +1,53 @@ +import { api } from '$lib/api'; + +const BASE_PATH = '/v1/a76/audit-log/files'; + +export interface AuditFileBreadcrumb { + path: string; + display_name: string; +} + +export interface AuditFolderItem { + path: string; + display_name: string; +} + +export interface AuditFileItem { + path: string; + display_name: string; + size: number; + last_modified?: string | null; +} + +export interface AuditFileListResponse { + current_path: string; + display_path: string; + breadcrumbs: AuditFileBreadcrumb[]; + folders: AuditFolderItem[]; + files: AuditFileItem[]; + next_token?: string | null; +} + +export const AuditFilesAPI = { + list: async (params?: { + path?: string; + continuation_token?: string; + max_keys?: number; + }): Promise => { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.continuation_token) query.set('continuation_token', params.continuation_token); + if (params?.max_keys) query.set('max_keys', String(params.max_keys)); + + const qs = query.toString(); + const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH; + const response = await api.get(endpoint); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to list tenant files'); + } + return response.data; + }, + + downloadBlob: (path: string) => + api.getBlob(`${BASE_PATH}/download?path=${encodeURIComponent(path)}`) +}; diff --git a/frontend/src/routes/dashboard/audit_logs/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte index 7de90e27..6d41408e 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.svelte +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -2,10 +2,11 @@ import { afterNavigate, goto } from '$app/navigation'; import { page } from '$app/state'; import * as Tabs from '$lib/components/ui/tabs'; - import { ScrollText, ListTodo } from 'lucide-svelte'; + import { ScrollText, ListTodo, FolderTree } from 'lucide-svelte'; import * as m from '$lib/paraglide/messages.js'; import BitacoraTab from './bitacora-tab.svelte'; import TasksTab from './tasks-tab.svelte'; + import FilesTab from './files-tab.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -17,17 +18,19 @@ }); afterNavigate(() => { - const t = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora'; + const raw = page.url.searchParams.get('tab'); + const t = raw === 'tasks' || raw === 'files' ? raw : 'bitacora'; tabValue = t; }); function onTabValueChange(v: string) { - const fromUrl = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora'; + const raw = page.url.searchParams.get('tab'); + const fromUrl = raw === 'tasks' || raw === 'files' ? raw : 'bitacora'; if (v === fromUrl) return; const u = new URL(page.url.href); - if (v === 'tasks') { - u.searchParams.set('tab', 'tasks'); + if (v === 'tasks' || v === 'files') { + u.searchParams.set('tab', v); } else { u.searchParams.delete('tab'); } @@ -53,7 +56,7 @@ onValueChange={onTabValueChange} class="flex min-h-0 w-full flex-1 flex-col" > - + {m['sidebar.audit_logs_tab_bitacora']()} @@ -62,6 +65,10 @@ {m['sidebar.audit_logs_tab_tasks']()} + + + {m['sidebar.audit_logs_tab_files']()} + @@ -74,5 +81,10 @@ {/if} + + {#if tabValue === 'files'} + + {/if} + diff --git a/frontend/src/routes/dashboard/audit_logs/+page.ts b/frontend/src/routes/dashboard/audit_logs/+page.ts index 1bf476b2..a653eef6 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.ts +++ b/frontend/src/routes/dashboard/audit_logs/+page.ts @@ -2,6 +2,6 @@ import type { PageLoad } from './$types'; export const load: PageLoad = ({ url }) => { const tab = url.searchParams.get('tab'); - const initialTab = tab === 'tasks' ? 'tasks' : 'bitacora'; + const initialTab = tab === 'tasks' || tab === 'files' ? tab : 'bitacora'; return { initialTab }; }; diff --git a/frontend/src/routes/dashboard/audit_logs/files-tab.svelte b/frontend/src/routes/dashboard/audit_logs/files-tab.svelte new file mode 100644 index 00000000..051df209 --- /dev/null +++ b/frontend/src/routes/dashboard/audit_logs/files-tab.svelte @@ -0,0 +1,205 @@ + + +
+
+
+

{m['sidebar.audit_logs_files_title']()}

+

{displayPath || m['sidebar.audit_logs_files_root']()}

+
+ +
+ + + +
+ {#each breadcrumbs as crumb, idx} + {#if idx > 0} + / + {/if} + {#if idx === breadcrumbs.length - 1} + + {crumb.display_name} + + {:else} + + {/if} + {/each} +
+
+
+ + + + {m['sidebar.audit_logs_files_list_title']()} + + + {#if error} +
+ {m['sidebar.audit_logs_files_error_prefix']()} {error} +
+ {/if} +
+ + + + + {m['sidebar.audit_logs_files_col_name']()} + + + {m['sidebar.audit_logs_files_col_size']()} + + + {m['sidebar.audit_logs_files_col_modified']()} + + + {m['sidebar.audit_logs_files_col_actions']()} + + + + + {#if loading} + + + {m['sidebar.audit_logs_files_loading']()} + + + {:else if folders.length === 0 && files.length === 0} + + + {m['sidebar.audit_logs_files_empty']()} + + + {:else} + {#each folders as folder} + void loadPath(folder.path)} + > + + + + {folder.display_name} + + + + + + + {/each} + {#each files as file} + + + {file.display_name} + + {formatSize(file.size)} + {formatDate(file.last_modified)} + + + + + {/each} + {/if} + + +
+
+
+