feature/app-selector
This commit is contained in:
@@ -3,7 +3,7 @@ import logging
|
||||
import inspect
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource
|
||||
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource, get_active_system
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
|
||||
|
||||
from api.v1.common.catalog_validation_errors import CatalogValidationError
|
||||
@@ -175,11 +175,16 @@ class TenantCRUDRoutes(
|
||||
# Excluimos los parámetros estándar de paginación y control
|
||||
standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"}
|
||||
filters = {
|
||||
k: v
|
||||
for k, v in request.query_params.items()
|
||||
k: v
|
||||
for k, v in request.query_params.items()
|
||||
if k not in standard_params and v is not None and v != ""
|
||||
}
|
||||
|
||||
# Inyectar active_system (header/cookie) si no viene por query param
|
||||
active_system = get_active_system(request)
|
||||
if active_system and "system" not in filters:
|
||||
filters["system"] = active_system
|
||||
|
||||
# Determine what parameters the service method accepts
|
||||
sig = inspect.signature(self.service.get_all)
|
||||
kwargs = {}
|
||||
@@ -383,6 +388,7 @@ class TenantCRUDRoutes(
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_child_resource(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
@@ -396,6 +402,12 @@ class TenantCRUDRoutes(
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||
if self.enable_filters:
|
||||
active_system = get_active_system(request)
|
||||
if active_system and hasattr(data, "system"):
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
# For child resources, parent_id validation would go here
|
||||
try:
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
@@ -429,6 +441,7 @@ class TenantCRUDRoutes(
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_parent_resource(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
@@ -441,6 +454,13 @@ class TenantCRUDRoutes(
|
||||
self.create_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||
if self.enable_filters:
|
||||
active_system = get_active_system(request)
|
||||
if active_system and hasattr(data, "system"):
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
try:
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
return resource
|
||||
|
||||
@@ -46,6 +46,10 @@ class ClassCreateDTO(BaseModel):
|
||||
is_active: Optional[bool] = Field(
|
||||
True, description="Indicates if the class is active (default: true)"
|
||||
)
|
||||
system: str = Field(
|
||||
default="fixed_asset", max_length=12,
|
||||
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -125,6 +129,10 @@ class ClassUpdateDTO(BaseModel):
|
||||
is_active: Optional[bool] = Field(
|
||||
True, description="Indicates if the class is active (default: true)"
|
||||
)
|
||||
system: Optional[str] = Field(
|
||||
None, max_length=12,
|
||||
description="Sistema: 'fixed_asset' (SCAF) o 'inventory' (SCAII)"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, extra='forbid') # Explicitly forbid extra fields
|
||||
|
||||
@@ -146,6 +154,7 @@ class ClassResponseDTO(BaseModel):
|
||||
physical_review: Optional[int] = None
|
||||
iva_exempt_fraction: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
system: str = "fixed_asset"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -226,9 +235,10 @@ class ClassWithFADataResponse(BaseModel):
|
||||
sub_key: Optional[str] = None
|
||||
physical_review: Optional[int] = None
|
||||
iva_exempt_fraction: Optional[str] = None
|
||||
system: str = "fixed_asset"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# FA-specific fields (embedded from a24.fa_classes)
|
||||
fa_class_id: Optional[int] = None
|
||||
import_tariff_code: Optional[str] = None
|
||||
|
||||
@@ -88,6 +88,9 @@ class Class(Base, TenantScopedMixin, TimestampMixin):
|
||||
default=True, server_default="true", nullable=False
|
||||
) # Campo para habilitar/deshabilitar clases sin eliminarlas
|
||||
|
||||
# Sistema al que pertenece la clase: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
|
||||
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
|
||||
|
||||
# Relationships
|
||||
material_type: Mapped[Optional["MaterialType"]] = relationship(
|
||||
foreign_keys=[material_key]
|
||||
|
||||
@@ -3,11 +3,11 @@ Endpoints API para gestión de clases SCAII y SCAF
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from core.security import get_current_user, get_active_system
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
|
||||
|
||||
from .dto import (
|
||||
@@ -37,6 +37,7 @@ router.include_router(imports_router, prefix="/imports", tags=["a76 / classes /
|
||||
tags=["a76 / classes"],
|
||||
)
|
||||
async def get_classes_with_fa_data(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=1000, description="Page size"),
|
||||
@@ -54,28 +55,31 @@ async def get_classes_with_fa_data(
|
||||
This endpoint is optimized for the fixed-asset-classes view.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.view"])
|
||||
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
if any([class_code, description, material_key, fraction]):
|
||||
filters = {}
|
||||
if class_code:
|
||||
filters["class_code"] = class_code
|
||||
if description:
|
||||
filters["description"] = description
|
||||
if material_key:
|
||||
filters["material_key"] = material_key
|
||||
if fraction:
|
||||
filters["fraction"] = fraction
|
||||
|
||||
filters: Dict[str, Any] = {}
|
||||
if class_code:
|
||||
filters["class_code"] = class_code
|
||||
if description:
|
||||
filters["description"] = description
|
||||
if material_key:
|
||||
filters["material_key"] = material_key
|
||||
if fraction:
|
||||
filters["fraction"] = fraction
|
||||
|
||||
# Sistema activo desde header/cookie (TenantMiddleware no setea request.state en /api/)
|
||||
active_system = get_active_system(request)
|
||||
if active_system:
|
||||
filters["system"] = active_system
|
||||
|
||||
classes_with_fa, total = ClassService.get_all_with_fa_data(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters=filters,
|
||||
filters=filters if filters else None,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -96,17 +100,30 @@ async def get_classes_with_fa_data(
|
||||
tags=["a76 / classes"],
|
||||
)
|
||||
async def create_fa_class(
|
||||
request: Request,
|
||||
class_data: ClassCreateDTOFA,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create a fixed asset class (both base class and FA extension)"""
|
||||
|
||||
active_system = get_active_system(request)
|
||||
if active_system == "inventory":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Solo se pueden crear clases de activo fijo con el módulo de Activo Fijo (SCAF) activo. "
|
||||
"Cambie de aplicación e intente de nuevo."
|
||||
),
|
||||
)
|
||||
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, ["goods_classes.create"])
|
||||
|
||||
|
||||
# Siempre persistir como activo fijo; no depender del default del DTO ni del body del cliente
|
||||
class_data = class_data.model_copy(update={"system": "fixed_asset"})
|
||||
|
||||
result = ClassService.create_fa_class(db, class_data, tenant_id, company_id)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
# Now include generic CRUD routes
|
||||
|
||||
@@ -82,7 +82,9 @@ class ClassService:
|
||||
query = query.filter(
|
||||
Class.physical_review == filters["physical_review"]
|
||||
)
|
||||
|
||||
if filters.get("system"):
|
||||
query = query.filter(Class.system == filters["system"])
|
||||
|
||||
# Apply sorting
|
||||
if sort_by:
|
||||
column = getattr(Class, sort_by, None)
|
||||
@@ -161,7 +163,9 @@ class ClassService:
|
||||
)
|
||||
if filters.get("fraction"):
|
||||
query = query.filter(Class.fraction.ilike(f"%{filters['fraction']}%"))
|
||||
|
||||
if filters.get("system"):
|
||||
query = query.filter(Class.system == filters["system"])
|
||||
|
||||
# Apply sorting
|
||||
if sort_by:
|
||||
# Check if sort_by belongs to Class or QClasses
|
||||
@@ -203,6 +207,7 @@ class ClassService:
|
||||
"sub_key": base_class.sub_key,
|
||||
"physical_review": base_class.physical_review,
|
||||
"iva_exempt_fraction": base_class.iva_exempt_fraction,
|
||||
"system": base_class.system,
|
||||
"created_at": base_class.created_at,
|
||||
"updated_at": base_class.updated_at,
|
||||
# FA extension fields (None if no FA record exists)
|
||||
@@ -399,7 +404,7 @@ class ClassService:
|
||||
base_fields = {
|
||||
"class_code", "description_es", "description_en",
|
||||
"material_key", "unit_of_measure", "fraction", "us_fraction",
|
||||
"sub_key", "physical_review", "iva_exempt_fraction"
|
||||
"sub_key", "physical_review", "iva_exempt_fraction", "system"
|
||||
}
|
||||
base_data = {k: v for k, v in class_data.model_dump().items() if k in base_fields}
|
||||
|
||||
@@ -442,6 +447,7 @@ class ClassService:
|
||||
"sub_key": base_class.sub_key,
|
||||
"physical_review": base_class.physical_review,
|
||||
"iva_exempt_fraction": base_class.iva_exempt_fraction,
|
||||
"system": base_class.system,
|
||||
"created_at": base_class.created_at,
|
||||
"updated_at": base_class.updated_at,
|
||||
# FA extension fields
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Dict, Any, Literal, Optional
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.exceptions import BaseAPIException
|
||||
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource, get_active_system
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Request
|
||||
from sqlalchemy import func, or_, and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -181,6 +181,7 @@ def get_remesa_suggestion(
|
||||
|
||||
@router.post("/invoices/", response_model=schemas.InvoiceHeaderResponse)
|
||||
def create_invoice(
|
||||
request: Request,
|
||||
data: schemas.InvoiceHeaderCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
@@ -188,11 +189,16 @@ def create_invoice(
|
||||
):
|
||||
try:
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
|
||||
# Sistema autoritativo desde el contexto activo (header/cookie)
|
||||
active_system = get_active_system(request)
|
||||
if active_system:
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
# Validamos usando los datos que vienen en el body (payload)
|
||||
perm_base = get_invoice_permission_base(data.operation_type, data.invoice_type)
|
||||
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"])
|
||||
|
||||
|
||||
return services.InvoiceService.create(db, data, tenant_id, company_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -506,6 +512,7 @@ def interface_cp_genesis(
|
||||
|
||||
@router.get("/invoices/", response_model=schemas.InvoiceHeaderListResponse)
|
||||
def list_invoices(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
|
||||
@@ -575,9 +582,10 @@ def list_invoices(
|
||||
"pedimento": pedimento,
|
||||
"project_number": project_number,
|
||||
"year": year,
|
||||
"allowed_types": allowed_filters
|
||||
"allowed_types": allowed_filters,
|
||||
"system": get_active_system(request),
|
||||
}
|
||||
|
||||
|
||||
filters = {k: v for k, v in filters.items() if v is not None}
|
||||
|
||||
items, total = services.InvoiceService.get_all(
|
||||
|
||||
@@ -369,6 +369,10 @@ class InvoiceService:
|
||||
if not filters.get("invoice_type") and ot_exp_val == "exp":
|
||||
query = query.filter(models.InvoiceHeader.invoice_type != "REPAR")
|
||||
|
||||
# Filtro por sistema activo (SCAF / SCAII)
|
||||
if filters.get("system"):
|
||||
query = query.filter(models.InvoiceHeader.system == filters["system"])
|
||||
|
||||
# Filtro por permisos granulares (allowed_types)
|
||||
if "allowed_types" in filters:
|
||||
allowed = filters["allowed_types"]
|
||||
|
||||
@@ -174,6 +174,9 @@ class PartBase(BaseModel):
|
||||
is_active: bool = True
|
||||
part_photo: Optional[str] = None
|
||||
|
||||
# Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
|
||||
system: str = Field(default="fixed_asset", max_length=12)
|
||||
|
||||
# Anidados
|
||||
fa_data: Optional[FaDataDTO] = None
|
||||
inv_data: Optional[InvDataDTO] = None
|
||||
|
||||
@@ -97,6 +97,9 @@ class Part(Base, TenantScopedMixin, TimestampMixin):
|
||||
)
|
||||
part_photo: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
# Sistema al que pertenece la parte: 'fixed_asset' (SCAF) o 'inventory' (SCAII)
|
||||
system: Mapped[str] = mapped_column(String(12), nullable=False, server_default="fixed_asset")
|
||||
|
||||
creation_date: Mapped[Optional[int]] = mapped_column()
|
||||
modification_date: Mapped[Optional[int]] = mapped_column()
|
||||
modification_date_iso: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
|
||||
@@ -96,6 +96,8 @@ class PartService:
|
||||
Part.commercial_part_number.ilike(search)
|
||||
)
|
||||
)
|
||||
if filters.get("system"):
|
||||
query = query.filter(Part.system == filters["system"])
|
||||
# Otros filtros...
|
||||
|
||||
# Apply sorting
|
||||
|
||||
@@ -104,18 +104,45 @@ async def get_my_permissions(
|
||||
# Nota: bootstrap_super_admin ya hace commit e intenta no duplicar si el rol ya existe
|
||||
permission_service.bootstrap_super_admin(user_id, company_id)
|
||||
|
||||
# 4. Obtener permisos finales
|
||||
# 4. Sincronizar permisos de sistema desde claim de Keycloak (si viene en el token)
|
||||
# El claim 'allowed_systems' puede contener ["fixed_asset"], ["inventory"], o ambos.
|
||||
# Solo sincroniza si el claim está presente; si no, los permisos de sistema se asignan
|
||||
# manualmente desde el panel de administración.
|
||||
_VALID_SYSTEMS = frozenset(("fixed_asset", "inventory"))
|
||||
keycloak_systems = current_user.get("allowed_systems") or []
|
||||
if keycloak_systems:
|
||||
for sys_code in keycloak_systems:
|
||||
if sys_code in _VALID_SYSTEMS:
|
||||
try:
|
||||
permission_service.grant_direct_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_code=f"system.{sys_code}.access",
|
||||
assigned_by="keycloak_sync",
|
||||
)
|
||||
except ValueError:
|
||||
# El permiso de sistema aún no existe en BD (sync pendiente); se ignora.
|
||||
pass
|
||||
|
||||
# 5. Obtener permisos finales
|
||||
permissions = permission_service.get_user_permissions(user_id, company_id)
|
||||
|
||||
# 5. Obtener roles
|
||||
roles = permission_service.get_user_roles(user_id, company_id)
|
||||
role_names = [role.name for role in roles]
|
||||
|
||||
# 6. Derivar sistemas permitidos desde los permisos de sistema
|
||||
allowed_systems = [
|
||||
sys for sys in ("inventory", "fixed_asset")
|
||||
if f"system.{sys}.access" in permissions
|
||||
]
|
||||
|
||||
return UserPermissionsResponse(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permissions=list(permissions),
|
||||
roles=role_names,
|
||||
allowed_systems=allowed_systems,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ class UserPermissionsResponse(BaseModel):
|
||||
roles: List[str] = Field(
|
||||
default_factory=list, description="Lista de nombres de roles del usuario"
|
||||
)
|
||||
allowed_systems: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory",
|
||||
)
|
||||
|
||||
|
||||
class UserCompanyRoleResponse(BaseModel):
|
||||
|
||||
@@ -200,6 +200,14 @@ permissions_csv = [
|
||||
("csv_upload.process", "Procesar Cargas Masivas CSV", "csv_upload", "process"),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# ACCESO A SISTEMAS (SCAII / SCAF)
|
||||
# ============================================================================
|
||||
permissions_system_access = [
|
||||
("system.inventory.access", "Acceso al sistema Inventario (SCAII)", "system", "access"),
|
||||
("system.fixed_asset.access", "Acceso al sistema Activo Fijo (SCAF)", "system", "access"),
|
||||
]
|
||||
|
||||
def register_core_permissions():
|
||||
"""Registra los permisos granulados de la aplicación según Sidebar."""
|
||||
registry.register_many(permissions_audit)
|
||||
@@ -222,6 +230,7 @@ def register_core_permissions():
|
||||
registry.register_many(permissions_settings)
|
||||
registry.register_many(permissions_help)
|
||||
registry.register_many(permissions_csv)
|
||||
registry.register_many(permissions_system_access)
|
||||
|
||||
# Ejecutar registro al importar este módulo
|
||||
register_core_permissions()
|
||||
|
||||
Reference in New Issue
Block a user