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,
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -456,6 +456,7 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
// Métodos HTTP
export const api = {
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {

View File

@@ -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<AuditFileListResponse> => {
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<AuditFileListResponse>(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)}`)
};

View File

@@ -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"
>
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="bitacora">
<ScrollText class="mr-2 h-4 w-4" />
{m['sidebar.audit_logs_tab_bitacora']()}
@@ -62,6 +65,10 @@
<ListTodo class="mr-2 h-4 w-4" />
{m['sidebar.audit_logs_tab_tasks']()}
</Tabs.Trigger>
<Tabs.Trigger value="files">
<FolderTree class="mr-2 h-4 w-4" />
{m['sidebar.audit_logs_tab_files']()}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="bitacora" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -74,5 +81,10 @@
<TasksTab />
{/if}
</Tabs.Content>
<Tabs.Content value="files" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
{#if tabValue === 'files'}
<FilesTab />
{/if}
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -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 };
};

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { Folder, FileDown, RefreshCw } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages.js';
import {
AuditFilesAPI,
type AuditFileBreadcrumb,
type AuditFileItem,
type AuditFolderItem
} from '$lib/api/dashboard/a76/audit_files';
let loading = $state(false);
let error = $state<string | null>(null);
let currentPath = $state('');
let displayPath = $state('');
let breadcrumbs = $state<AuditFileBreadcrumb[]>([]);
let folders = $state<AuditFolderItem[]>([]);
let files = $state<AuditFileItem[]>([]);
async function loadPath(path = '') {
loading = true;
error = null;
try {
const res = await AuditFilesAPI.list({ path, max_keys: 200 });
currentPath = res.current_path ?? '';
displayPath = res.display_path ?? '';
breadcrumbs = res.breadcrumbs ?? [];
folders = res.folders ?? [];
files = res.files ?? [];
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function formatSize(bytes: number) {
if (!bytes || bytes < 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx++;
}
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function formatDate(value?: string | null) {
if (!value) return '—';
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
async function downloadFile(path: string, filename: string) {
try {
const blob = await AuditFilesAPI.downloadBlob(path);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(() => {
void loadPath('');
});
</script>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">{m['sidebar.audit_logs_files_title']()}</h2>
<p class="text-sm text-muted-foreground">{displayPath || m['sidebar.audit_logs_files_root']()}</p>
</div>
<Button variant="outline" onclick={() => void loadPath(currentPath)} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{m['sidebar.audit_logs_files_refresh']()}
</Button>
</div>
<Card.Root>
<Card.Content class="py-2">
<div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
{#each breadcrumbs as crumb, idx}
{#if idx > 0}
<span class="mx-1 text-[10px]">/</span>
{/if}
{#if idx === breadcrumbs.length - 1}
<span
class="max-w-[240px] truncate font-medium text-foreground sm:max-w-[320px]"
>
{crumb.display_name}
</span>
{:else}
<button
type="button"
class="max-w-[180px] truncate hover:text-foreground hover:underline underline-offset-2"
onclick={() => void loadPath(crumb.path)}
disabled={loading}
>
{crumb.display_name}
</button>
{/if}
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden">
<Card.Header class="pb-2">
<Card.Title class="text-base">{m['sidebar.audit_logs_files_list_title']()}</Card.Title>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-auto p-0">
{#if error}
<div class="m-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{m['sidebar.audit_logs_files_error_prefix']()} {error}
</div>
{/if}
<div class="w-full overflow-x-auto">
<Table.Root class="min-w-full text-sm">
<Table.Header>
<Table.Row>
<Table.Head class="min-w-[200px]">
{m['sidebar.audit_logs_files_col_name']()}
</Table.Head>
<Table.Head class="w-[110px]">
{m['sidebar.audit_logs_files_col_size']()}
</Table.Head>
<Table.Head class="w-[160px]">
{m['sidebar.audit_logs_files_col_modified']()}
</Table.Head>
<Table.Head class="w-[120px] text-left">
{m['sidebar.audit_logs_files_col_actions']()}
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
{m['sidebar.audit_logs_files_loading']()}
</Table.Cell>
</Table.Row>
{:else if folders.length === 0 && files.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
{m['sidebar.audit_logs_files_empty']()}
</Table.Cell>
</Table.Row>
{:else}
{#each folders as folder}
<Table.Row
class="cursor-pointer hover:bg-muted/30"
onclick={() => void loadPath(folder.path)}
>
<Table.Cell class="max-w-[260px] font-medium">
<span class="inline-flex items-center gap-2 truncate text-left">
<Folder class="h-4 w-4 flex-shrink-0 text-amber-500" />
<span class="truncate">{folder.display_name}</span>
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground"></Table.Cell>
<Table.Cell class="text-muted-foreground"></Table.Cell>
<Table.Cell class="text-left text-muted-foreground"></Table.Cell>
</Table.Row>
{/each}
{#each files as file}
<Table.Row>
<Table.Cell class="max-w-[260px] font-medium">
<span class="truncate">{file.display_name}</span>
</Table.Cell>
<Table.Cell>{formatSize(file.size)}</Table.Cell>
<Table.Cell>{formatDate(file.last_modified)}</Table.Cell>
<Table.Cell class="text-left">
<Button
size="icon"
variant="outline"
class="h-8 w-8"
onclick={() => void downloadFile(file.path, file.display_name)}
>
<FileDown class="h-4 w-4" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</Card.Content>
</Card.Root>
</div>