From 63939e3aa7b5d820354915d5b38a301963aa5c03 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 5 May 2026 12:32:04 -0600 Subject: [PATCH] feature/cliente-proveedor-permisos --- .../clients_and_providers/routes.py | 66 ++++++++++++++++-- .../common/track_commit_dispatch.py | 7 +- .../a76/layouts_csv/customs_brokers/routes.py | 2 +- .../clients_and_providers/columns.ts | 16 ++++- .../data-table-actions.svelte | 18 +++-- .../src/lib/components/sidebar/modules.ts | 1 + .../clients-providers-permissions.ts | 23 +++++++ .../clients_and_providers/+page.svelte | 68 ++++++++++++++----- .../edit/[[id]]/+page.svelte | 28 +++++++- 9 files changed, 197 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/permissions/clients-providers-permissions.ts diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py index c0744dba..8cbf361a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py @@ -9,7 +9,7 @@ from uuid import uuid4 from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends from sqlalchemy.orm import Session -from typing import Dict, Any +from typing import Dict, Any, Optional from core.celery_app import celery_app from core.database import get_core_db @@ -33,6 +33,54 @@ router = APIRouter() logger = logging.getLogger(__name__) +def _assert_cp_csv_job_access( + db: Session, + job_id: str, + current_user: Dict[str, Any], +) -> None: + """ + Exige autenticación + csv_upload.process para la compañía asociada al job + (task_runs, meta Redis del job, o meta del layout_import_job_id en commits). + """ + from api.v1.modules.core.tasks_tracking.models import TaskRun + + company_id: Optional[int] = None + row = db.query(TaskRun).filter(TaskRun.task_id == job_id).first() + if row is not None and row.company_id is not None: + company_id = int(row.company_id) + + r = _get_redis() + if company_id is None: + raw = r.get(f"{CP_IMPORT_META_PREFIX}{job_id}") + if raw: + meta = json.loads(raw.decode("utf-8")) + cid = meta.get("company_id") + if cid is not None: + company_id = int(cid) + + if company_id is None and row is not None and row.meta_payload: + layout_jid = row.meta_payload.get("layout_import_job_id") + if layout_jid: + raw2 = r.get(f"{CP_IMPORT_META_PREFIX}{layout_jid}") + if raw2: + meta2 = json.loads(raw2.decode("utf-8")) + cid2 = meta2.get("company_id") + if cid2 is not None: + company_id = int(cid2) + + if company_id is None: + raise HTTPException(status_code=403, detail="Sin acceso a este job") + try: + validate_access_to_resource( + db, company_id, current_user, ["csv_upload.process"] + ) + except HTTPException: + raise + except Exception as e: + logger.error("CP import: job access validation failed: %s", e) + raise HTTPException(status_code=403, detail="Sin acceso a este job") from None + + def _get_redis(): import redis url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0")) @@ -109,10 +157,15 @@ async def upload_import_file( @router.get("/{job_id}/status") -async def get_import_status(job_id: str): +async def get_import_status( + job_id: str, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): """ Polling: estado del escaneo o del commit. """ + _assert_cp_csv_job_access(db, job_id, current_user) task_result = celery_app.AsyncResult(job_id) if task_result.state == "PENDING": @@ -166,7 +219,6 @@ async def commit_import_job( """ Fase 2: Usuario confirma; se encola la inserción de filas válidas. """ - validate_access_to_resource(db, company_id, current_user, ["csv_upload.process"]) # company_id will be extracted from Redis meta r = _get_redis() commit_id = dispatch_tracked_layouts_csv_commit( db=db, @@ -178,6 +230,7 @@ async def commit_import_job( task_name="clients_and_providers_insert_valid_rows", task_origin="a76/layouts_csv/clients_and_providers/commit", args=[job_id], + required_permissions=["csv_upload.process"], ) return { "status": "committing", @@ -187,5 +240,10 @@ async def commit_import_job( @router.get("/{job_id}/errors/scan-csv") -async def download_scan_errors_csv(job_id: str): +async def download_scan_errors_csv( + job_id: str, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + _assert_cp_csv_job_access(db, job_id, current_user) return download_scan_errors_csv_stream("cp", job_id) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py b/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py index 296906c4..a0f69978 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/track_commit_dispatch.py @@ -24,6 +24,7 @@ def dispatch_tracked_layouts_csv_commit( task_name: str, task_origin: str, args: list[Any], + required_permissions: list[str] | None = None, ) -> str: """ Lee tenant_id / company_id del meta en Redis, valida acceso y despacha la tarea Celery @@ -37,7 +38,11 @@ def dispatch_tracked_layouts_csv_commit( company_id = meta.get("company_id") if company_id is not None: try: - validate_access_to_resource(db, int(company_id), current_user) + validate_access_to_resource( + db, int(company_id), current_user, required_permissions + ) + except HTTPException: + raise except Exception: raise HTTPException(status_code=403, detail="Sin acceso a este job") from None diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py index d973d888..5c5ce0b7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py @@ -166,7 +166,6 @@ async def commit_import_job( """ Fase 2: Usuario confirma; se encola la inserción de filas válidas. """ - validate_access_to_resource(db, company_id, current_user, ["csv_upload.process"]) # company_id will be extracted from Redis meta r = _get_redis() commit_id = dispatch_tracked_layouts_csv_commit( db=db, @@ -178,6 +177,7 @@ async def commit_import_job( task_name="customs_brokers_insert_valid_rows", task_origin="a76/layouts_csv/customs_brokers/commit", args=[job_id], + required_permissions=["csv_upload.process"], ) return { "status": "committing", diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts b/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts index a779f66a..8ebed1a0 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts +++ b/frontend/src/lib/components/dashboard/clients_and_providers/columns.ts @@ -2,7 +2,13 @@ import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/in import { createRawSnippet } from "svelte"; import DataTableActions from "./data-table-actions.svelte"; -export function createColumns(onSuccess) { +export type ClientsProvidersColumnOptions = { + canEdit?: boolean; + canDelete?: boolean; +}; + +export function createColumns(onSuccess: () => void, options: ClientsProvidersColumnOptions = {}) { + const { canEdit = false, canDelete = false } = options; return [ { accessorKey: "id", @@ -150,7 +156,13 @@ export function createColumns(onSuccess) { { id: "actions", header: "Acciones", - cell: ({ row }) => renderComponent(DataTableActions, { item: row.original, onSuccess }) + cell: ({ row }) => + renderComponent(DataTableActions, { + item: row.original, + onSuccess, + canEdit, + canDelete + }) } ]; } \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte index 291753a7..de0ee204 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/data-table-actions.svelte @@ -11,10 +11,14 @@ let { item, - onSuccess + onSuccess, + canEdit = false, + canDelete = false }: { item: ClientProvider; onSuccess?: () => void; + canEdit?: boolean; + canDelete?: boolean; } = $props(); let showDetailsDialog = $state(false); @@ -41,11 +45,14 @@ } async function handleToggleStatus() { - if (isToggling || !companyStore.activeCompany) return; + if (!canEdit || isToggling || !companyStore.activeCompany) return; isToggling = true; try { - const response = await clientsProvidersApi.toggleStatus(item.id, companyStore.activeCompany.id); + const nextActive = item.is_active !== true; + const response = await clientsProvidersApi.update(item.id, companyStore.activeCompany.id, { + is_active: nextActive + }); if (response.error) { console.error('Error toggling status:', response.error); @@ -85,15 +92,18 @@ Ver detalles - + {#if canEdit} goto(`/dashboard/clients_and_providers/edit/${item.id}`)}> Editar {isToggling ? 'Cambiando...' : item.is_active === true ? 'Desactivar' : 'Activar'} + {/if} + {#if canDelete} Eliminar + {/if} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index ce6a4360..265bcab8 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -507,6 +507,7 @@ export function getSidebarData(): SidebarData { title: m["sidebar.clients_and_providers"](), url: "/dashboard/clients_and_providers", icon: Users, + permission: "partners_mgmt.view", items: [], }, { diff --git a/frontend/src/lib/permissions/clients-providers-permissions.ts b/frontend/src/lib/permissions/clients-providers-permissions.ts new file mode 100644 index 00000000..52eab8dd --- /dev/null +++ b/frontend/src/lib/permissions/clients-providers-permissions.ts @@ -0,0 +1,23 @@ +import type { User } from '$lib/auth'; +import { userHasPermission } from '$lib/auth'; + +function hasAssignedCode(user: User | null, code: string): boolean { + if (!user) return false; + return user.permissions.includes(code); +} + +export function canViewClientsAndProviders(user: User | null): boolean { + return userHasPermission(user, 'partners_mgmt.view'); +} + +export function canCreateClientsAndProviders(user: User | null): boolean { + return hasAssignedCode(user, 'partners_mgmt.create'); +} + +export function canEditClientsAndProviders(user: User | null): boolean { + return hasAssignedCode(user, 'partners_mgmt.edit'); +} + +export function canDeleteClientsAndProviders(user: User | null): boolean { + return hasAssignedCode(user, 'partners_mgmt.delete'); +} diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte index f5a85d5b..9e756f3b 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -17,6 +17,16 @@ import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; import type { ApiError } from '$lib/utils/error-handler'; + import { currentUser } from '$lib/auth'; + import { + canCreateClientsAndProviders, + canDeleteClientsAndProviders, + canEditClientsAndProviders + } from '$lib/permissions/clients-providers-permissions'; + + const canCreatePartner = $derived(canCreateClientsAndProviders($currentUser)); + const canEditPartner = $derived(canEditClientsAndProviders($currentUser)); + const canDeletePartner = $derived(canDeleteClientsAndProviders($currentUser)); // Los datos iniciales vienen del servidor let { data }: { data: any } = $props(); @@ -158,6 +168,10 @@ } function handleRowDoubleClick(item: ClientProvider) { + if (!canEditPartner) { + toast.error('No tienes permiso para editar clientes o proveedores'); + return; + } selectedItem = item; goto(`/dashboard/clients_and_providers/edit/${item.id}`); } @@ -169,6 +183,10 @@ } function handleEdit() { + if (!canEditPartner) { + toast.error('No tienes permiso para editar clientes o proveedores'); + return; + } if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`); } @@ -176,6 +194,10 @@ import { obtenerAtajosListaSocios } from '$lib/config/shortcuts/dashboard/clients_and_providers/list'; async function handleDelete() { + if (!canDeletePartner) { + toast.error('No tienes permiso para eliminar clientes o proveedores'); + return; + } if (!selectedItem || !companyStore.activeCompany?.id) return; if (!confirm('¿Estás seguro de eliminar este registro?')) return; @@ -195,7 +217,13 @@ filtrarTodos: () => handleTypeChange('both'), filtrarClientes: () => handleTypeChange('client'), filtrarProveedores: () => handleTypeChange('provider'), - nuevo: () => goto('/dashboard/clients_and_providers/edit'), + nuevo: () => { + if (!canCreatePartner) { + toast.error('No tienes permiso para crear clientes o proveedores'); + return; + } + goto('/dashboard/clients_and_providers/edit/new'); + }, recargar: () => loadItems(1) }) ); @@ -503,22 +531,28 @@ >
- - - + {#if canCreatePartner} + + {/if} + {#if canEditPartner} + + {/if} + {#if canDeletePartner} + + {/if}
diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index 94d020b3..f5d26ae9 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -48,12 +48,24 @@ // API & Stores import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; + import { currentUser } from '$lib/auth'; + import { + canCreateClientsAndProviders, + canEditClientsAndProviders + } from '$lib/permissions/clients-providers-permissions'; + import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; + import { useShortcuts } from '$lib/hooks/use-shortcuts'; + import { obtenerAtajosEdicionSocio } from '$lib/config/shortcuts/dashboard/clients_and_providers/edit'; // --- CONFIGURACIÓN --- let id = $derived($page.params.id); /** Alta: sin id o ruta legacy `/edit/new` (misma intención que el botón "Nuevo"). */ let isEditing = $derived(!!id && id !== 'new'); + const canAccessPartnerEditor = $derived( + isEditing ? canEditClientsAndProviders($currentUser) : canCreateClientsAndProviders($currentUser) + ); + // State for Tabs let activeTab = $state('general'); @@ -125,6 +137,7 @@ // --- CARGA DE DATOS --- $effect(() => { + if (!canAccessPartnerEditor) return; if (id && id !== 'new' && companyStore.activeCompany?.id) loadData(Number(id)); else if (!id || id === 'new') formData = getEmptyForm(); }); @@ -189,6 +202,10 @@ // --- ENVÍO DE DATOS --- async function handleSubmit() { + if (!canAccessPartnerEditor) { + toast.error('No tienes permiso para guardar este registro'); + return; + } if (!companyStore.activeCompany) { error = 'Selecciona una compañía'; toast.error(error); @@ -278,9 +295,6 @@ } } - import { useShortcuts } from '$lib/hooks/use-shortcuts'; - import { obtenerAtajosEdicionSocio } from '$lib/config/shortcuts/dashboard/clients_and_providers/edit'; - function handleCancel() { goto('/dashboard/clients_and_providers'); } @@ -298,6 +312,13 @@ ); +{#if !canAccessPartnerEditor} +
+ +
+{:else}
@@ -825,3 +846,4 @@ bind:open={sectorModalOpen} onSelect={(sector) => (formData.prosec = sector.key)} /> +{/if}