feature/mercancias-permisos

This commit is contained in:
2026-05-05 15:05:11 -06:00
parent 63939e3aa7
commit bc7160790b
12 changed files with 307 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,16 @@ function formatCurrency(amount: number | null, currency: string | null): string
}).format(amount);
}
export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
export type GoodsPartsColumnOptions = {
canEdit?: boolean;
canDelete?: boolean;
};
export function createColumns(
onSuccess?: () => void,
options: GoodsPartsColumnOptions = {}
): ColumnDef<Part>[] {
const { canEdit = false, canDelete = false } = options;
return [
{
id: "select",
@@ -301,10 +310,13 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
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();
}

View File

@@ -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<string | null>(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}`);
}
</script>
{#if canEdit || canDelete}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
@@ -81,14 +88,14 @@
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
{#if canEdit}
<DropdownMenu.Item onSelect={handleEdit}>
<Pencil size={16} class="mr-2" />
<span>Editar</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
{#if canDelete}
{#if canEdit}<DropdownMenu.Separator />{/if}
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
@@ -97,5 +104,7 @@
{/if}
Eliminar
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DropdownMenu.Root>
{/if}

View File

@@ -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"](),

View File

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

View File

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

View File

@@ -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<number>(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 = '';

View File

@@ -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<string | null>(null);
let status = $state<number>(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}`);
}}
/>
</div>
</div>

View File

@@ -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 @@
});
</script>
{#if !canAccessPartEditor}
<div
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>
<ErrorState status={403} />
</div>
{:else}
{#if data?.isCreate}
<PrerequisitesModal
bind:open={showPrerequisitesModal}
@@ -40,3 +58,4 @@
<div class="space-y-4 p-4">
<PartForm partId={id} bind:formType={type} />
</div>
{/if}