Merge pull request 'feature/pedimentos' (#11) from feature/pedimentos into development

Reviewed-on: ADUANASOFT/anexo76#11
This commit is contained in:
2025-11-07 21:27:40 +00:00
9 changed files with 604 additions and 28 deletions

View File

@@ -49,7 +49,10 @@ class AuthService:
try:
# Verificar que el tenant existe
from api.v1.modules.a76.tenants.service import TenantService
from api.v1.modules.a76.user_tenant.service import UserTenantService
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
if not tenant:
@@ -73,7 +76,59 @@ class AuthService:
grant_type=["password"]
)
logger.info(f"User logged in: {login_data.username} (tenant: {tenant.slug})")
# Obtener información del usuario y verificar acceso al tenant
user_info = keycloak_client.userinfo(token_response["access_token"])
user_id = user_info.get("sub")
if user_id:
# Verificar si el usuario tiene acceso a este tenant
has_access = user_tenant_service.user_has_access_to_tenant(user_id, tenant.id)
if not has_access:
logger.warning(f"User {user_id} tried to access tenant {tenant.id} without permission")
raise HTTPException(
status_code=403,
detail="You don't have access to this tenant"
)
# Actualizar el tenant_id del usuario en Keycloak basado en el slug usado
try:
# Crear instancia de KeycloakAdmin para actualizar atributos
keycloak_admin = KeycloakAdmin(
server_url=settings.KEYCLOAK_SERVER_URL,
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=tenant.keycloak_realm,
user_realm_name="master",
verify=True
)
# Obtener los datos actuales del usuario para no sobrescribirlos
current_user = keycloak_admin.get_user(user_id)
# Obtener los atributos actuales o crear un dict vacío
current_attributes = current_user.get("attributes", {})
# Actualizar solo los atributos de tenant
current_attributes["tenant_id"] = [str(tenant.id)]
current_attributes["tenant_slug"] = [tenant.slug]
# Actualizar el usuario enviando TODOS los campos para evitar que se borren
update_payload = {
"email": current_user.get("email"),
"firstName": current_user.get("firstName"),
"lastName": current_user.get("lastName"),
"enabled": current_user.get("enabled", True),
"emailVerified": current_user.get("emailVerified", False),
"attributes": current_attributes
}
keycloak_admin.update_user(user_id=user_id, payload=update_payload)
logger.info(f"Updated tenant_id={tenant.id} for user {login_data.username}")
except Exception as e:
# No queremos que falle el login si no se puede actualizar el atributo
logger.warning(f"Could not update tenant_id attribute: {str(e)}")
return TokenResponseDTO(
access_token=token_response["access_token"],
@@ -249,6 +304,29 @@ class AuthService:
# El rol 'user' no existe, no es un error crítico
logger.warning(f"Could not assign 'user' role: {str(e)}")
# Agregar el usuario al tenant en la base de datos
try:
from api.v1.modules.a76.user_tenant.service import UserTenantService
user_tenant_service = UserTenantService(self.db)
user_tenant_service.add_user_to_tenant(
keycloak_user_id=user_id,
tenant_id=tenant.id,
role="user" # Rol por defecto
)
logger.info(f"Added user {user_id} to tenant {tenant.id} in database")
except Exception as e:
# Si falla, hacer rollback del usuario en Keycloak
logger.error(f"Failed to add user to tenant in database: {str(e)}")
try:
keycloak_admin.delete_user(user_id)
logger.info(f"Rolled back user creation in Keycloak")
except:
pass
raise HTTPException(
status_code=500,
detail="Failed to register user in database"
)
logger.info(f"User registered: {register_data.username} (tenant: {tenant.slug}, user_id: {user_id})")
return RegisterResponseDTO(

View File

@@ -1,8 +1,12 @@
from enum import IntEnum
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
from decimal import Decimal
from datetime import datetime
class OperationType(IntEnum):
EXPORTACION = 1
IMPORTACION = 2
class PedimentosBase(BaseModel):
"""Base schema for Pedimentos"""
@@ -29,16 +33,16 @@ class PedimentosCreate(PedimentosBase):
class PedimentosUpdate(BaseModel):
"""Schema for updating a Pedimento"""
year: Optional[str] = Field(None, max_length=2)
customs_office: Optional[str] = Field(None, max_length=2)
license: Optional[str] = Field(None, max_length=4)
pedimento_number: Optional[str] = Field(None, max_length=7)
client_id: Optional[int] = None
operation_type: Optional[int] = None
pedimento_type: Optional[int] = None
pedimento_code: Optional[str] = Field(None, max_length=2)
regime: Optional[str] = Field(None, max_length=3)
status: Optional[str] = Field(None, max_length=30)
year: Optional[str] = Field(..., max_length=2)
customs_office: Optional[str] = Field(..., max_length=2)
license: Optional[str] = Field(..., max_length=4)
pedimento_number: Optional[str] = Field(..., max_length=7)
client_id: Optional[int]
operation_type: Optional[OperationType]
pedimento_type: Optional[int]
pedimento_code: Optional[str] = Field(..., max_length=2)
regime: Optional[str] = Field(..., max_length=3)
status: Optional[str] = Field(..., max_length=30)
usd_value: Optional[Decimal] = None
paid_price: Optional[Decimal] = None
gross_weight: Optional[Decimal] = None

View File

@@ -1,8 +1,10 @@
from typing import TYPE_CHECKING
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKeyConstraint, Index, Integer, Numeric, PrimaryKeyConstraint, String, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.base import Mapped
from datetime import datetime
from enum import IntEnum
from core.database import Base
if TYPE_CHECKING:
@@ -37,22 +39,22 @@ class Pedimentos(Base):
{'schema': 'a76'}
)
id = mapped_column(Integer)
tenant_id = mapped_column(Integer, nullable=False, index=True)
year = mapped_column(String(2))
customs_office = mapped_column(String(2))
license = mapped_column(String(4))
pedimento_number = mapped_column(String(7))
client_id = mapped_column(Integer)
operation_type = mapped_column(Integer)
pedimento_type = mapped_column(Integer)
id: Mapped[int] = mapped_column(Integer)
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
year: Mapped[str] = mapped_column(String(2))
customs_office: Mapped[str] = mapped_column(String(2))
license: Mapped[str] = mapped_column(String(4))
pedimento_number: Mapped[str] = mapped_column(String(7))
client_id: Mapped[int] = mapped_column(Integer)
operation_type: Mapped[int] = mapped_column(Integer)
pedimento_type: Mapped[int] = mapped_column(Integer)
pedimento_code = mapped_column(String(2))
regime = mapped_column(String(3))
status = mapped_column(String(30))
usd_value = mapped_column(Numeric(17, 6))
paid_price = mapped_column(Numeric(17, 6))
gross_weight = mapped_column(Numeric(19, 3))
exchange_rate = mapped_column(Numeric(9, 5))
regime: Mapped[str] = mapped_column(String(3))
status: Mapped[str] = mapped_column(String(30))
usd_value: Mapped[Optional[Decimal]] = mapped_column(Numeric(17, 6))
paid_price: Mapped[Optional[Decimal]] = mapped_column(Numeric(17, 6))
gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 3))
exchange_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(9, 5))
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=func.now())

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter
# Importar routers de módulos
from .auth import router as auth_router
from .tenants import router as tenants_router
from .user_tenant.routes import router as user_tenant_router
from .licenses import router as licenses_router
from .pedmientos.router import router as pedimentos_router
from .client_and_provider import router as client_and_provider_router
@@ -26,6 +27,7 @@ router = APIRouter()
# Registrar módulos
router.include_router(auth_router)
router.include_router(tenants_router, prefix="/a76", tags=["a76 / tenants"])
router.include_router(user_tenant_router, prefix="/a76", tags=["a76 / user-tenants"])
router.include_router(licenses_router, prefix="/a76", tags=["a76 / licenses"])
router.include_router(pedimentos_router, prefix="/a76")
router.include_router(client_and_provider_router, prefix="/a76", tags=["a76 / clients and providers"])

View File

@@ -3,11 +3,15 @@ Modelos ORM para gestión de tenants
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Enum as SQLEnum
from sqlalchemy.sql import func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import List, TYPE_CHECKING
from core.database import Base
import enum
if TYPE_CHECKING:
from api.v1.modules.a76.user_tenant.models import UserTenant
class TenantType(enum.Enum):
"""Tipo de tenant según tamaño y necesidades"""
@@ -49,5 +53,8 @@ class Tenant(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=func.now(), onupdate=func.now())
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relación con UserTenant
user_relations: Mapped[List["UserTenant"]] = relationship("UserTenant", back_populates="tenant")
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name}, type={self.type.value})>"

View File

@@ -0,0 +1,59 @@
"""
DTOs para gestión de relaciones usuario-tenant
"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class AddUserToTenantRequestDTO(BaseModel):
"""Request para agregar un usuario a un tenant"""
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
tenant_id: int = Field(..., description="ID del tenant")
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
class RemoveUserFromTenantRequestDTO(BaseModel):
"""Request para eliminar un usuario de un tenant"""
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
tenant_id: int = Field(..., description="ID del tenant")
soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina")
class UpdateUserRoleRequestDTO(BaseModel):
"""Request para actualizar el rol de un usuario en un tenant"""
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
tenant_id: int = Field(..., description="ID del tenant")
role: str = Field(..., description="Nuevo rol del usuario")
class UserTenantResponseDTO(BaseModel):
"""Response con información de relación usuario-tenant"""
id: int
keycloak_user_id: str
tenant_id: int
is_active: bool
role: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TenantBasicInfoDTO(BaseModel):
"""Información básica de un tenant"""
id: int
name: str
slug: str
is_active: bool
keycloak_realm: str
class Config:
from_attributes = True
class UserTenantsResponseDTO(BaseModel):
"""Response con los tenants de un usuario"""
keycloak_user_id: str
tenants: list[TenantBasicInfoDTO]

View File

@@ -0,0 +1,49 @@
"""
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from sqlalchemy import Integer, String, DateTime, Boolean, UniqueConstraint, ForeignKeyConstraint
from sqlalchemy.sql import func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.tenants.models import Tenant
class UserTenant(Base):
"""
Relación muchos-a-muchos entre usuarios de Keycloak y tenants
Un usuario puede pertenecer a múltiples tenants
Un tenant puede tener múltiples usuarios
"""
__tablename__ = "user_tenants"
__table_args__ = (
ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id']),
UniqueConstraint('keycloak_user_id', 'tenant_id', name='uq_user_tenant'),
{"schema": "a76"}
)
# Primary Key
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# ID del usuario en Keycloak (UUID string)
keycloak_user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
# ID del tenant
tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
# Estado de la relación
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Información adicional - Rol del usuario en este tenant (opcional)
role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relación con Tenant
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations")

View File

@@ -0,0 +1,147 @@
"""
Rutas para gestión de relaciones usuario-tenant
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from core.database import get_core_db
from core.security import get_current_user
from .service import UserTenantService
from .dto import (
AddUserToTenantRequestDTO,
RemoveUserFromTenantRequestDTO,
UpdateUserRoleRequestDTO,
UserTenantResponseDTO,
UserTenantsResponseDTO,
TenantBasicInfoDTO
)
router = APIRouter(
prefix="/user-tenants",
tags=["User-Tenant Relations"]
)
@router.post("/add", response_model=UserTenantResponseDTO)
def add_user_to_tenant(
data: AddUserToTenantRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Agrega un usuario a un tenant
Requiere permisos de administrador
"""
service = UserTenantService(db)
result = service.add_user_to_tenant(
keycloak_user_id=data.keycloak_user_id,
tenant_id=data.tenant_id,
role=data.role
)
return result
@router.post("/remove")
def remove_user_from_tenant(
data: RemoveUserFromTenantRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Elimina un usuario de un tenant
Requiere permisos de administrador
"""
service = UserTenantService(db)
service.remove_user_from_tenant(
keycloak_user_id=data.keycloak_user_id,
tenant_id=data.tenant_id,
soft_delete=data.soft_delete
)
return {"message": "User removed from tenant successfully"}
@router.put("/update-role", response_model=UserTenantResponseDTO)
def update_user_role(
data: UpdateUserRoleRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Actualiza el rol de un usuario en un tenant
Requiere permisos de administrador
"""
service = UserTenantService(db)
result = service.update_user_role_in_tenant(
keycloak_user_id=data.keycloak_user_id,
tenant_id=data.tenant_id,
role=data.role
)
return result
@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO)
def get_user_tenants(
keycloak_user_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene todos los tenants a los que tiene acceso un usuario
Los usuarios solo pueden ver sus propios tenants, a menos que sean admin
"""
# Verificar que el usuario solo pueda ver sus propios tenants (excepto admin)
if current_user.get("sub") != keycloak_user_id:
# TODO: Verificar si es admin
raise HTTPException(
status_code=403,
detail="You can only view your own tenants"
)
service = UserTenantService(db)
tenants = service.get_user_tenants(keycloak_user_id)
return UserTenantsResponseDTO(
keycloak_user_id=keycloak_user_id,
tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants]
)
@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO])
def get_tenant_users(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene todos los usuarios que tienen acceso a un tenant
Requiere permisos de administrador del tenant
"""
service = UserTenantService(db)
user_tenants = service.get_tenant_users(tenant_id)
return user_tenants
@router.get("/check-access/{keycloak_user_id}/{tenant_id}")
def check_user_access(
keycloak_user_id: str,
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Verifica si un usuario tiene acceso a un tenant
"""
service = UserTenantService(db)
has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id)
return {
"keycloak_user_id": keycloak_user_id,
"tenant_id": tenant_id,
"has_access": has_access
}

View File

@@ -0,0 +1,228 @@
"""
Servicio para gestionar relaciones entre usuarios y tenants
"""
from sqlalchemy.orm import Session
from sqlalchemy import and_
from typing import List, Optional
from fastapi import HTTPException
import logging
from .models import UserTenant
from ..tenants.models import Tenant
logger = logging.getLogger(__name__)
class UserTenantService:
"""Servicio para gestionar acceso de usuarios a tenants"""
def __init__(self, db: Session):
self.db = db
def add_user_to_tenant(
self,
keycloak_user_id: str,
tenant_id: int,
role: Optional[str] = None
) -> UserTenant:
"""
Agrega un usuario a un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
role: Rol opcional del usuario en este tenant
Returns:
UserTenant creado
"""
# Verificar que el tenant existe
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
# Verificar si la relación ya existe
existing = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id
)
).first()
if existing:
# Si existe pero está inactiva, reactivarla
if not existing.is_active:
existing.is_active = True
existing.role = role
self.db.commit()
self.db.refresh(existing)
logger.info(f"Reactivated user {keycloak_user_id} in tenant {tenant_id}")
return existing
else:
raise HTTPException(
status_code=409,
detail="User already has access to this tenant"
)
# Crear nueva relación
user_tenant = UserTenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
role=role,
is_active=True
)
self.db.add(user_tenant)
self.db.commit()
self.db.refresh(user_tenant)
logger.info(f"Added user {keycloak_user_id} to tenant {tenant_id}")
return user_tenant
def remove_user_from_tenant(
self,
keycloak_user_id: str,
tenant_id: int,
soft_delete: bool = True
) -> bool:
"""
Elimina un usuario de un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente
Returns:
True si se eliminó correctamente
"""
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id
)
).first()
if not user_tenant:
raise HTTPException(
status_code=404,
detail="User-tenant relationship not found"
)
if soft_delete:
user_tenant.is_active = False
self.db.commit()
logger.info(f"Deactivated user {keycloak_user_id} from tenant {tenant_id}")
else:
self.db.delete(user_tenant)
self.db.commit()
logger.info(f"Deleted user {keycloak_user_id} from tenant {tenant_id}")
return True
def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]:
"""
Obtiene todos los tenants a los que tiene acceso un usuario
Args:
keycloak_user_id: ID del usuario en Keycloak
Returns:
Lista de tenants
"""
user_tenants = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True
)
).all()
tenant_ids = [ut.tenant_id for ut in user_tenants]
tenants = self.db.query(Tenant).filter(
and_(
Tenant.id.in_(tenant_ids),
Tenant.is_active == True
)
).all()
return tenants
def get_tenant_users(self, tenant_id: int) -> List[UserTenant]:
"""
Obtiene todos los usuarios que tienen acceso a un tenant
Args:
tenant_id: ID del tenant
Returns:
Lista de relaciones UserTenant
"""
return self.db.query(UserTenant).filter(
and_(
UserTenant.tenant_id == tenant_id,
UserTenant.is_active == True
)
).all()
def user_has_access_to_tenant(
self,
keycloak_user_id: str,
tenant_id: int
) -> bool:
"""
Verifica si un usuario tiene acceso a un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
Returns:
True si tiene acceso, False en caso contrario
"""
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
UserTenant.is_active == True
)
).first()
return user_tenant is not None
def update_user_role_in_tenant(
self,
keycloak_user_id: str,
tenant_id: int,
role: str
) -> UserTenant:
"""
Actualiza el rol de un usuario en un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
role: Nuevo rol
Returns:
UserTenant actualizado
"""
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id
)
).first()
if not user_tenant:
raise HTTPException(
status_code=404,
detail="User-tenant relationship not found"
)
user_tenant.role = role
self.db.commit()
self.db.refresh(user_tenant)
logger.info(f"Updated role for user {keycloak_user_id} in tenant {tenant_id} to {role}")
return user_tenant