From bc7160790b7793faaa92698c232fb5f5f8ca7e76 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 5 May 2026 15:05:11 -0600 Subject: [PATCH] feature/mercancias-permisos --- .../modules/a76/layouts_csv/classes/routes.py | 61 +++++++++++++++++- .../common/track_commit_dispatch.py | 30 ++++++--- .../modules/a76/layouts_csv/parts/routes.py | 64 +++++++++++++++++-- backend/api/v1/modules/a76/parts/routes.py | 1 + .../dashboard/goods/parts/columns.ts | 22 +++++-- .../goods/parts/data-table-actions.svelte | 21 ++++-- .../src/lib/components/sidebar/modules.ts | 2 + .../permissions/goods-classes-permissions.ts | 23 +++++++ .../permissions/goods-parts-permissions.ts | 23 +++++++ .../goods/fixed-asset-classes/+page.svelte | 37 +++++++++-- .../routes/dashboard/goods/parts/+page.svelte | 49 ++++++++++---- .../goods/parts/edit/[[id]]/+page.svelte | 19 ++++++ 12 files changed, 307 insertions(+), 45 deletions(-) create mode 100644 frontend/src/lib/permissions/goods-classes-permissions.ts create mode 100644 frontend/src/lib/permissions/goods-parts-permissions.ts diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py index 4b20f57f..8d747921 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/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 @@ -39,6 +39,50 @@ def _get_redis(): return redis.Redis.from_url(url, decode_responses=False) +def _assert_classes_csv_job_access( + db: Session, + job_id: str, + current_user: Dict[str, Any], +) -> None: + 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"{CLS_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"{CLS_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("Classes import: job access validation failed: %s", e) + raise HTTPException(status_code=403, detail="Sin acceso a este job") from None + + @router.post("/upload", response_model=ImportJobResponse) async def upload_import_file( file: UploadFile = File(...), @@ -110,7 +154,12 @@ 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), +): + _assert_classes_csv_job_access(db, job_id, current_user) task_result = celery_app.AsyncResult(job_id) if task_result.state == "PENDING": @@ -192,6 +241,7 @@ async def commit_import_job( task_name="classes_insert_valid_rows", task_origin="a76/layouts_csv/classes/commit", args=[job_id], + required_permissions=["csv_upload.process"], ) return { "status": "committing", @@ -201,5 +251,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_classes_csv_job_access(db, job_id, current_user) return download_scan_errors_csv_stream("cls", 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 a0f69978..ec304d5a 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 @@ -29,6 +29,9 @@ def dispatch_tracked_layouts_csv_commit( """ Lee tenant_id / company_id del meta en Redis, valida acceso y despacha la tarea Celery con un task_id nuevo (commit) para que el polling use commit_job_id. + + Si ``required_permissions`` es None, se normaliza a [] para validar acceso a compañía + (``validate_access_to_resource(..., None)`` omitiría esa validación por el atajo tipo /me). """ raw = redis_client.get(meta_redis_key) if not raw: @@ -36,22 +39,29 @@ def dispatch_tracked_layouts_csv_commit( meta = json.loads(raw.decode("utf-8")) tenant_id = int(meta["tenant_id"]) company_id = meta.get("company_id") - if company_id is not None: - try: - 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 + if company_id is None: + raise HTTPException( + status_code=403, + detail="Job sin compañía asociada o meta incompleto", + ) + effective_permissions: list[str] = ( + [] if required_permissions is None else required_permissions + ) + try: + validate_access_to_resource( + db, int(company_id), current_user, effective_permissions + ) + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=403, detail="Sin acceso a este job") from None commit_id = str(uuid4()) track_and_dispatch( db=db, task=celery_task, tenant_id=tenant_id, - company_id=int(company_id) if company_id is not None else None, + company_id=int(company_id), requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"), diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py index f69f614c..5add9e21 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -9,9 +9,9 @@ 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 from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -39,6 +39,50 @@ def _get_redis(): return redis.Redis.from_url(url, decode_responses=False) +def _assert_parts_csv_job_access( + db: Session, + job_id: str, + current_user: Dict[str, Any], +) -> None: + 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"{PART_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"{PART_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("Parts import: job access validation failed: %s", e) + raise HTTPException(status_code=403, detail="Sin acceso a este job") from None + + @router.post("/upload", response_model=ImportJobResponse) async def upload_import_file( file: UploadFile = File(...), @@ -108,8 +152,12 @@ async def upload_import_file( @router.get("/{job_id}/status") -async def get_import_status(job_id: str): - from core.celery_app import celery_app +async def get_import_status( + job_id: str, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + _assert_parts_csv_job_access(db, job_id, current_user) task_result = celery_app.AsyncResult(job_id) if task_result.state == "PENDING": @@ -191,6 +239,7 @@ async def commit_import_job( task_name="parts_insert_valid_rows", task_origin="a76/layouts_csv/parts/commit", args=[job_id], + required_permissions=["csv_upload.process"], ) return { "status": "committing", @@ -200,5 +249,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_parts_csv_job_access(db, job_id, current_user) return download_scan_errors_csv_stream("part", job_id) diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py index ba0b71a0..28da1648 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -30,6 +30,7 @@ router.include_router( enable_filters=True, max_page_size=1000, list_permissions=["goods_parts.view"], + get_permissions=["goods_parts.view"], create_permissions=["goods_parts.create"], update_permissions=["goods_parts.edit"], delete_permissions=["goods_parts.delete"], diff --git a/frontend/src/lib/components/dashboard/goods/parts/columns.ts b/frontend/src/lib/components/dashboard/goods/parts/columns.ts index 84bb8abe..005703e6 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/columns.ts +++ b/frontend/src/lib/components/dashboard/goods/parts/columns.ts @@ -18,7 +18,16 @@ function formatCurrency(amount: number | null, currency: string | null): string }).format(amount); } -export function createColumns(onSuccess?: () => void): ColumnDef[] { +export type GoodsPartsColumnOptions = { + canEdit?: boolean; + canDelete?: boolean; +}; + +export function createColumns( + onSuccess?: () => void, + options: GoodsPartsColumnOptions = {} +): ColumnDef[] { + const { canEdit = false, canDelete = false } = options; return [ { id: "select", @@ -301,10 +310,13 @@ export function createColumns(onSuccess?: () => void): ColumnDef[] { id: "actions", header: "", cell: ({ row }) => { - return renderComponent(DataTableActions, { item: row.original, onSuccess }); + return renderComponent(DataTableActions, { + item: row.original, + onSuccess, + canEdit, + canDelete + }); } } ]; -} - -export const columns = createColumns(); \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte b/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte index 0feda248..62ab0ab8 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/data-table-actions.svelte @@ -9,16 +9,21 @@ let { item, - onSuccess + onSuccess, + canEdit = false, + canDelete = false }: { item: Part; // Cambiado a Part onSuccess?: () => void; + canEdit?: boolean; + canDelete?: boolean; } = $props(); let loading = $state(false); let error = $state(null); async function handleDelete() { + if (!canDelete) return; // 2. Ajustamos el mensaje de confirmación para que muestre el No. Parte if (!confirm(`¿Estás seguro de eliminar la parte "${item.part_number}"?`)) { return; @@ -64,11 +69,13 @@ } function handleEdit() { + if (!canEdit) return; // Redirigimos a la página de edición usando el ID goto(`/dashboard/goods/parts/edit/${item.id}`); } +{#if canEdit || canDelete} {#snippet child({ props })} @@ -81,14 +88,14 @@ Acciones - + {#if canEdit} Editar - - - + {/if} + {#if canDelete} + {#if canEdit}{/if} {#if loading} @@ -97,5 +104,7 @@ {/if} Eliminar + {/if} - \ No newline at end of file + +{/if} \ No newline at end of file diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 265bcab8..ed4f7791 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -367,10 +367,12 @@ export function getSidebarData(): SidebarData { { title: m["sidebar.goods.classes"](), url: "/dashboard/goods/fixed-asset-classes", + permission: 'goods_classes.view', }, { title: m["sidebar.goods.parts"](), url: "/dashboard/goods/parts", + permission: 'goods_parts.view', }, { title: m["sidebar.goods.fda_codes"](), diff --git a/frontend/src/lib/permissions/goods-classes-permissions.ts b/frontend/src/lib/permissions/goods-classes-permissions.ts new file mode 100644 index 00000000..85c0e232 --- /dev/null +++ b/frontend/src/lib/permissions/goods-classes-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 canViewGoodsClasses(user: User | null): boolean { + return userHasPermission(user, 'goods_classes.view'); +} + +export function canCreateGoodsClasses(user: User | null): boolean { + return hasAssignedCode(user, 'goods_classes.create'); +} + +export function canEditGoodsClasses(user: User | null): boolean { + return hasAssignedCode(user, 'goods_classes.edit'); +} + +export function canDeleteGoodsClasses(user: User | null): boolean { + return hasAssignedCode(user, 'goods_classes.delete'); +} diff --git a/frontend/src/lib/permissions/goods-parts-permissions.ts b/frontend/src/lib/permissions/goods-parts-permissions.ts new file mode 100644 index 00000000..c6515018 --- /dev/null +++ b/frontend/src/lib/permissions/goods-parts-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 canViewGoodsParts(user: User | null): boolean { + return userHasPermission(user, 'goods_parts.view'); +} + +export function canCreateGoodsParts(user: User | null): boolean { + return hasAssignedCode(user, 'goods_parts.create'); +} + +export function canEditGoodsParts(user: User | null): boolean { + return hasAssignedCode(user, 'goods_parts.edit'); +} + +export function canDeleteGoodsParts(user: User | null): boolean { + return hasAssignedCode(user, 'goods_parts.delete'); +} diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index 9462269f..67d560d6 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -11,8 +11,14 @@ import { companyStore } from '$lib/stores/company.svelte'; import DataTable from '$lib/components/dashboard/goods/classes/data-table.svelte'; import { columns } from '$lib/components/dashboard/goods/classes/columns'; - import { currentUser, userHasPermission } from '$lib/auth'; + import { currentUser } from '$lib/auth'; import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; + import { + canCreateGoodsClasses, + canDeleteGoodsClasses, + canEditGoodsClasses, + canViewGoodsClasses + } from '$lib/permissions/goods-classes-permissions'; import { formatMexTariffDigitsForDisplay, normalizeMexTariffDigitsStored @@ -62,10 +68,10 @@ let status = $state(200); // Permisos - const canView = $derived(userHasPermission($currentUser, 'goods_classes.view')); - const canCreate = $derived(userHasPermission($currentUser, 'goods_classes.create')); - const canEdit = $derived(userHasPermission($currentUser, 'goods_classes.edit')); - const canDelete = $derived(userHasPermission($currentUser, 'goods_classes.delete')); + const canView = $derived(canViewGoodsClasses($currentUser)); + const canCreate = $derived(canCreateGoodsClasses($currentUser)); + const canEdit = $derived(canEditGoodsClasses($currentUser)); + const canDelete = $derived(canDeleteGoodsClasses($currentUser)); const isError = $derived(!canView || status >= 400); @@ -259,6 +265,10 @@ } function handleRowDoubleClick(cls: A76Class) { + if (!canEdit) { + toast.error('No tienes permiso para editar clases de activo fijo'); + return; + } const fixedClass = cls as FixedAssetClassExtended; selectedClassIds = [fixedClass.id, ...selectedClassIds.filter((id) => id !== fixedClass.id)]; setActiveClass(fixedClass); @@ -347,6 +357,15 @@ description: 'Guardar clase', action: () => { if (!showInsertDialog || isSaving) return; + const isEditMode = !!selectedClass?.id; + if (isEditMode && !canEdit) { + toast.error('No tienes permiso para editar clases'); + return; + } + if (!isEditMode && !canCreate) { + toast.error('No tienes permiso para crear clases'); + return; + } document.dispatchEvent(new CustomEvent('save-form')); } } @@ -658,6 +677,14 @@ if (isSaving) { return; } + if (selectedClass?.id && !canEdit) { + toast.error('No tienes permiso para editar clases'); + return; + } + if (!selectedClass?.id && !canCreate) { + toast.error('No tienes permiso para crear clases'); + return; + } isSaving = true; validationError = ''; diff --git a/frontend/src/routes/dashboard/goods/parts/+page.svelte b/frontend/src/routes/dashboard/goods/parts/+page.svelte index 01066b15..bb80c625 100644 --- a/frontend/src/routes/dashboard/goods/parts/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/+page.svelte @@ -11,8 +11,14 @@ import DataTable from '$lib/components/dashboard/goods/parts/data-table.svelte'; import { createColumns } from '$lib/components/dashboard/goods/parts/columns'; import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list'; - import { currentUser, userHasPermission } from '$lib/auth'; + import { currentUser } from '$lib/auth'; import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; + import { + canCreateGoodsParts, + canDeleteGoodsParts, + canEditGoodsParts, + canViewGoodsParts + } from '$lib/permissions/goods-parts-permissions'; import { formatMexTariffDigitsForDisplay } from '$lib/utils/mexican-tariff-fraction'; // Estado de la lista de partes @@ -26,16 +32,21 @@ { id: 'updated_at', desc: true } ]); - // Pasamos los permisos a createColumns para bloquear las acciones de fila internamente - const columns = createColumns(() => loadParts()); let error = $state(null); let status = $state(200); // Permisos - const canView = $derived(userHasPermission($currentUser, 'goods_parts.view')); - const canCreate = $derived(userHasPermission($currentUser, 'goods_parts.create')); - const canEdit = $derived(userHasPermission($currentUser, 'goods_parts.edit')); - const canDelete = $derived(userHasPermission($currentUser, 'goods_parts.delete')); + const canView = $derived(canViewGoodsParts($currentUser)); + const canCreate = $derived(canCreateGoodsParts($currentUser)); + const canEdit = $derived(canEditGoodsParts($currentUser)); + const canDelete = $derived(canDeleteGoodsParts($currentUser)); + + const columns = $derived( + createColumns(() => loadParts(), { + canEdit, + canDelete + }) + ); const isError = $derived(!canView || status >= 400 || error); @@ -67,16 +78,26 @@ 'Goods List', obtenerAtajosListaMercancias({ crear: () => { - if (canCreate) goto('/dashboard/goods/parts/edit/new'); + if (!canCreate) { + toast.error('No tienes permiso para crear partes'); + return; + } + goto('/dashboard/goods/parts/edit/new'); }, recargar: loadData, editar: () => { - if (!canEdit) return; + if (!canEdit) { + toast.error('No tienes permiso para editar partes'); + return; + } if (selectedPart?.id) goto(`/dashboard/goods/parts/edit/${selectedPart.id}`); else toast.info('Seleccione una parte para editar'); }, eliminar: () => { - if (!canDelete) return; + if (!canDelete) { + toast.error('No tienes permiso para eliminar partes'); + return; + } if (selectedPart?.id) handleDelete(); else toast.info('Seleccione una parte para eliminar'); } @@ -253,7 +274,13 @@ {sorting} onSortingChange={(newSorting) => (sorting = newSorting)} onRowClick={selectPart} - onRowDoubleClick={(part) => canEdit && goto(`/dashboard/goods/parts/edit/${part.id}`)} + onRowDoubleClick={(part) => { + if (!canEdit) { + toast.error('No tienes permiso para editar partes'); + return; + } + goto(`/dashboard/goods/parts/edit/${part.id}`); + }} /> diff --git a/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte index ee342456..f9d3bb90 100644 --- a/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/edit/[[id]]/+page.svelte @@ -3,8 +3,19 @@ import { goto } from '$app/navigation'; import PartForm from '$lib/components/dashboard/goods/parts/partForm.svelte'; import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte'; + import { currentUser } from '$lib/auth'; + import { + canCreateGoodsParts, + canEditGoodsParts + } from '$lib/permissions/goods-parts-permissions'; + import ErrorState from '$lib/components/dashboard/common/error-state.svelte'; + let routeId = $derived($page.params.id); let id = $derived($page.params.id === 'new' ? null : Number($page.params.id)); + let isEditingPart = $derived(!!routeId && routeId !== 'new'); + const canAccessPartEditor = $derived( + isEditingPart ? canEditGoodsParts($currentUser) : canCreateGoodsParts($currentUser) + ); let type = $state<'inv' | 'fa' | 'both'>('fa'); // When editing, the PartForm auto-detects the type from the loaded data. @@ -27,6 +38,13 @@ }); +{#if !canAccessPartEditor} +
+ +
+{:else} {#if data?.isCreate} +{/if}