diff --git a/backend/alembic/versions/531bf8cdae06_create_material_types_table.py b/backend/alembic/versions/531bf8cdae06_create_material_types_table.py deleted file mode 100644 index 7e7b4630..00000000 --- a/backend/alembic/versions/531bf8cdae06_create_material_types_table.py +++ /dev/null @@ -1,333 +0,0 @@ -"""create material_types table - -Revision ID: 531bf8cdae06 -Revises: -Create Date: 2025-10-19 18:23:39.613953 - -""" - -# pylint: disable=no-member - - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "531bf8cdae06" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "containers", - sa.Column("key", sa.String(length=3), nullable=False), - sa.Column("description", sa.String(length=500), nullable=False), - sa.PrimaryKeyConstraint("key", name="containers_pkey"), - schema="public", - ) - op.create_table( - "countries", - sa.Column("m3_key", sa.String(length=3), nullable=False), - sa.Column("mex_key", sa.String(length=2), nullable=False), - sa.Column("ame_key", sa.String(length=2), nullable=False), - sa.Column("description_es", sa.String(length=50), nullable=False), - sa.Column("description_en", sa.String(length=50), nullable=False), - sa.PrimaryKeyConstraint("m3_key", name="countries_pkey"), - schema="public", - ) - op.create_index( - "ak_country_ame", "countries", ["ame_key"], unique=True, schema="public" - ) - op.create_table( - "currency_types", - sa.Column("code", sa.String(length=3), nullable=False), - sa.Column("currency_name", sa.String(length=15), nullable=False), - sa.Column("country_description", sa.String(length=50), nullable=False), - sa.PrimaryKeyConstraint("code", name="currency_types_pkey"), - schema="public", - ) - op.create_table( - "customs_sections", - sa.Column("customs_code", sa.String(length=3), nullable=False), - sa.Column("section_name", sa.String(length=255), nullable=False), - sa.PrimaryKeyConstraint("customs_code", name="customs_code_pkey"), - schema="public", - ) - op.create_table( - "customs_warehouses", - sa.Column("key", sa.String(length=3), nullable=False), - sa.Column("customs", sa.String(length=100), nullable=False), - sa.Column("fiscalized_warehouse", sa.String(length=1000), nullable=False), - sa.PrimaryKeyConstraint("key", "customs", name="pk_customs_warehouse"), - schema="public", - ) - op.create_table( - "incoterms", - sa.Column("code", sa.String(length=5), nullable=False), - sa.Column("description_es", sa.String(length=256), nullable=False), - sa.Column("description_en", sa.String(length=256), nullable=False), - sa.PrimaryKeyConstraint("code", name="incoterms_pkey"), - schema="public", - ) - op.create_table( - "invoice_types", - sa.Column("key", sa.String(length=5), nullable=False), - sa.Column("description", sa.String(length=50), nullable=False), - sa.Column("note", sa.String(length=500), nullable=False), - sa.Column("type", sa.String(length=15), nullable=False), - sa.Column("operation", sa.String(length=5), nullable=False), - sa.PrimaryKeyConstraint("key", name="invoice_types_pkey"), - schema="public", - ) - op.create_table( - "material_types", - sa.Column("key", sa.String(length=10), nullable=False), - sa.Column("type", sa.String(length=15), nullable=False), - sa.Column("description", sa.String(length=256), nullable=False), - sa.PrimaryKeyConstraint("key", name="material_types_pkey"), - schema="public", - ) - op.create_table( - "payment_methods", - sa.Column("key", sa.String(length=2), nullable=False), - sa.Column("description", sa.String(length=100), nullable=False), - sa.PrimaryKeyConstraint("key", name="payment_methods_pkey"), - schema="public", - ) - op.create_table( - "pedimento_codes", - sa.Column("code", sa.String(length=3), nullable=False), - sa.Column("description", sa.String(length=250), nullable=False), - sa.PrimaryKeyConstraint("code", name="pedimento_codes_pkey"), - schema="public", - ) - op.create_table( - "pedimento_regimens", - sa.Column("code", sa.String(length=3), nullable=False), - sa.Column("description", sa.String(length=100), nullable=False), - sa.PrimaryKeyConstraint("code", name="pedimento_regimens_pkey"), - schema="public", - ) - op.create_table( - "sectors", - sa.Column("key", sa.String(length=8), nullable=False), - sa.Column("description", sa.String(length=150), nullable=False), - sa.Column("authorized", sa.SmallInteger(), nullable=False), - sa.PrimaryKeyConstraint("key", name="sectors_pkey"), - schema="public", - ) - op.create_table( - "states", - sa.Column("m3_key", sa.String(length=3), nullable=False), - sa.Column("description", sa.String(length=50), nullable=False), - sa.Column("mex_key", sa.String(length=3), nullable=True), - sa.Column("ame_key", sa.String(length=2), nullable=True), - sa.PrimaryKeyConstraint("m3_key", "description", name="states_pkey"), - schema="public", - ) - op.create_table( - "transport_modes", - sa.Column("key", sa.String(length=3), nullable=False), - sa.Column("name", sa.String(length=30), nullable=False), - sa.PrimaryKeyConstraint("key", name="transport_modes_pkey"), - schema="public", - ) - op.create_table( - "transport_types", - sa.Column("transport_code", sa.String(length=2), nullable=False), - sa.Column("description", sa.String(length=100), nullable=False), - sa.PrimaryKeyConstraint("transport_code", name="transport_types_pkey"), - schema="public", - ) - op.create_table( - "valuation_methods", - sa.Column("key", sa.String(length=2), nullable=False), - sa.Column("description", sa.String(length=200), nullable=False), - sa.PrimaryKeyConstraint("key", name="valuation_methods_pkey"), - schema="public", - ) - op.create_table( - "code_pedimento_regimens", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("pedimento_code", sa.String(length=3), nullable=False), - sa.Column("regimen_code", sa.String(length=3), nullable=False), - sa.Column("type_code", sa.String(length=1), nullable=True), - sa.ForeignKeyConstraint( - ["pedimento_code"], ["public.pedimento_codes.code"], name="fk_codeped" - ), - sa.ForeignKeyConstraint( - ["regimen_code"], ["public.pedimento_regimens.code"], name="fk_regimenped" - ), - sa.PrimaryKeyConstraint("id", name="clave_pedimento_regimens_pkey"), - schema="public", - ) - - # Tablas de Unidades de Medida (A76) - - # 1. Tabla: unit_of_measure_ace (ACE Units) - op.create_table( - 'unit_of_measure_ace', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('code', sa.String(length=4), nullable=False), - sa.Column('description', sa.String(length=49), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code', name='uq_uom_ace_code'), - schema='a76' - ) - - # 2. Tabla: unit_of_measure_oma (OMA Units) - op.create_table( - 'unit_of_measure_oma', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('code', sa.String(length=10), nullable=False), - sa.Column('description', sa.String(length=200), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code', name='uq_uom_oma_code'), - schema='a76' - ) - - # 3. Tabla: unit_of_measure_american (American Units) - op.create_table( - 'unit_of_measure_american', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('code', sa.String(length=3), nullable=False), - sa.Column('description', sa.String(length=40), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code', name='uq_uom_american_code'), - schema='a76' - ) - - # 4. Tabla: unit_of_measure_customs (Customs Units) - op.create_table( - 'unit_of_measure_customs', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('code', sa.String(length=10), nullable=False), - sa.Column('description', sa.String(length=50), nullable=True), - sa.Column('scaii_unit_code', sa.String(length=5), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code', name='uq_uom_customs_code'), - schema='a76' - ) - - # 5. Tabla: units_of_measure (Main Unit of Measure) - op.create_table( - 'units_of_measure', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('code', sa.String(length=10), nullable=False), - sa.Column('description', sa.String(length=100), nullable=True), - sa.Column('description_en', sa.String(length=100), nullable=True), - sa.Column('customs_code', sa.String(length=10), nullable=True), - sa.Column('american_code', sa.String(length=3), nullable=True), - sa.Column('ace_code', sa.String(length=4), nullable=True), - sa.Column('oma_code', sa.String(length=10), nullable=True), - sa.Column('tenant_id', sa.Integer(), nullable=False), - sa.Column('company_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ['customs_code'], - ['a76.unit_of_measure_customs.code'], - name='fk_uom_customs' - ), - sa.ForeignKeyConstraint( - ['american_code'], - ['a76.unit_of_measure_american.code'], - name='fk_uom_american' - ), - sa.ForeignKeyConstraint( - ['ace_code'], - ['a76.unit_of_measure_ace.code'], - name='fk_uom_ace' - ), - sa.ForeignKeyConstraint( - ['oma_code'], - ['a76.unit_of_measure_oma.code'], - name='fk_uom_oma' - ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='uq_uom_code'), - schema='a76' - ) - - # 6. Tabla: units_of_measure_general (General/Conversion Units) - op.create_table( - 'units_of_measure_general', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('code', sa.String(length=10), nullable=False), - sa.Column('description', sa.String(length=100), nullable=True), - sa.Column('conversion_factor', sa.Numeric(precision=13, scale=6), nullable=True), - sa.Column('mexico_unit', sa.String(length=10), nullable=True), - sa.Column('american_unit_code', sa.String(length=5), nullable=True), - sa.Column('customs_code', sa.String(length=10), nullable=True), - sa.Column('ace_code', sa.String(length=4), nullable=True), - sa.Column('tenant_id', sa.Integer(), nullable=False), - sa.Column('company_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), - sa.ForeignKeyConstraint( - ['customs_code'], - ['a76.unit_of_measure_customs.code'], - name='fk_uom_general_customs' - ), - sa.ForeignKeyConstraint( - ['ace_code'], - ['a76.unit_of_measure_ace.code'], - name='fk_uom_general_ace' - ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code', 'tenant_id', 'company_id', name='uq_uom_general_code'), - schema='a76' - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - # Eliminar tablas de unidades de medida - op.drop_table('units_of_measure_general', schema='a76') - op.drop_table('units_of_measure', schema='a76') - op.drop_table('unit_of_measure_customs', schema='a76') - op.drop_table('unit_of_measure_american', schema='a76') - op.drop_table('unit_of_measure_oma', schema='a76') - op.drop_table('unit_of_measure_ace', schema='a76') - - # Eliminar tablas públicas - op.drop_table("code_pedimento_regimens", schema="public") - op.drop_table("valuation_methods", schema="public") - op.drop_table("transport_types", schema="public") - op.drop_table("transport_modes", schema="public") - op.drop_table("states", schema="public") - op.drop_table("sectors", schema="public") - op.drop_table("pedimento_regimens", schema="public") - op.drop_table("pedimento_codes", schema="public") - op.drop_table("payment_methods", schema="public") - op.drop_table("material_types", schema="public") - op.drop_table("invoice_types", schema="public") - op.drop_table("incoterms", schema="public") - op.drop_table("customs_warehouses", schema="public") - op.drop_table("customs_sections", schema="public") - op.drop_table("currency_types", schema="public") - op.drop_index("ak_country_ame", table_name="countries", schema="public") - op.drop_table("countries", schema="public") - op.drop_table("containers", schema="public") - # ### end Alembic commands ### diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index a3ba52df..fba03be6 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -53,36 +53,27 @@ from api.v1.modules.public.reference_data.transport_types.seed import ( from api.v1.modules.public.reference_data.valuation_methods.seed import ( seed as valuation_methods_seed, ) -from api.v1.modules.a76.general_catalogs.units_of_measure.seed_med import seed as units_of_measure_seed +from api.v1.modules.a76.general_catalogs.units_of_measure.seed import seed as units_of_measure_seed from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ace import seed as ace_seed from api.v1.modules.a76.general_catalogs.units_of_measure.seed_oma import seed as oma_seed from api.v1.modules.a76.general_catalogs.units_of_measure.seed_ame import seed as ame_seed from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import seed as adua_seed +from api.v1.modules.core.permissions.seed import ( + seed_invoices, + seed_user, + seed_report, + seed_roles, +) # revision identifiers, used by Alembic. revision: str = "7937209f9718" -down_revision: Union[str, Sequence[str], None] = "531bf8cdae06" +down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" - - # --- AMPLIAR COLUMNAS ANTES DE INSERTAR DATOS --- - op.execute("ALTER TABLE a76.unit_of_measure_ace ALTER COLUMN code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.unit_of_measure_oma ALTER COLUMN code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.unit_of_measure_american ALTER COLUMN code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.unit_of_measure_customs ALTER COLUMN code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.units_of_measure ALTER COLUMN code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.units_of_measure ALTER COLUMN customs_code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.units_of_measure ALTER COLUMN american_code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.units_of_measure ALTER COLUMN ace_code TYPE VARCHAR(20);") - op.execute("ALTER TABLE a76.units_of_measure ALTER COLUMN oma_code TYPE VARCHAR(20);") - - # --- AGREGAR COLUMNAS deleted_at --- - op.execute("ALTER TABLE a76.units_of_measure ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;") - op.execute("ALTER TABLE a76.units_of_measure_general ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;") + """Upgrade schema.""" # --- UTILIDAD DE FORMATEO --- def format_value(val): @@ -358,7 +349,7 @@ def upgrade() -> None: # Insertar códigos adicionales if additional_customs: val_add_customs = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in additional_customs]) - op.execute(f"INSERT INTO a76.unit_of_measure_customs (code, description) VALUES {val_add_customs} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;") + op.execute(f"INSERT INTO a76.unit_of_measure_customs (code, description, a76_unit_code) VALUES {val_add_customs} ON CONFLICT ON CONSTRAINT uq_uom_customs_code DO NOTHING;") if additional_american: val_add_american = ", ".join([f"({format_value(c)}, {format_value(d)})" for c, d in additional_american]) @@ -373,40 +364,67 @@ def upgrade() -> None: op.execute(f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;") # TABLA MAESTRA UOM + # TODO: Generar tenant_id y company_id correctos val_uom = ", ".join([ f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, 1, 1)" for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed ]) + op.execute("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;") op.execute(f""" INSERT INTO a76.units_of_measure (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) VALUES {val_uom} ON CONFLICT (code, tenant_id, company_id) DO NOTHING; """) + op.execute("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;") + + # --- SEEDS CORE (Permissions) --- + + # Combinar todas las seeds de permisos + all_permissions = seed_invoices + seed_user + seed_report + seed_roles + + values_permissions = ", ".join([ + f"({format_value(code)}, {format_value(desc)}, {format_value(module)}, {format_value(action)})" + for code, desc, module, action in all_permissions + ]) + + if values_permissions: + op.execute(f""" + INSERT INTO core.permissions (code, description, module, action) + VALUES {values_permissions} + ON CONFLICT (code) DO NOTHING; + """) + + def downgrade() -> None: """Downgrade schema.""" - op.execute("DELETE FROM a76.units_of_measure;") - op.execute("DELETE FROM a76.unit_of_measure_customs;") - op.execute("DELETE FROM a76.unit_of_measure_american;") - op.execute("DELETE FROM a76.unit_of_measure_oma;") - op.execute("DELETE FROM a76.unit_of_measure_ace;") - op.execute("DELETE FROM public.valuation_methods;") - op.execute("DELETE FROM public.transport_types;") - op.execute("DELETE FROM public.transport_modes;") - op.execute("DELETE FROM public.sectors;") - op.execute("DELETE FROM public.payment_methods;") - op.execute("DELETE FROM public.material_types;") - op.execute("DELETE FROM public.invoice_types;") - op.execute("DELETE FROM public.incoterms;") - op.execute("DELETE FROM public.customs_warehouses;") - op.execute("DELETE FROM public.customs_sections;") - op.execute("DELETE FROM public.currency_types;") - op.execute("DELETE FROM public.countries;") - op.execute("DELETE FROM public.containers;") - op.execute("DELETE FROM public.code_pedimento_regimens;") - op.execute("DELETE FROM public.pedimento_regimens;") - op.execute("DELETE FROM public.pedimento_codes;") + op.drop_table("valuation_methods", schema="public") + op.drop_table("transport_types", schema="public") + op.drop_table("transport_modes", schema="public") + op.drop_table("sectors", schema="public") + op.drop_table("payment_methods", schema="public") + op.drop_table("material_types", schema="public") + op.drop_table("invoice_types", schema="public") + op.drop_table("incoterms", schema="public") + op.drop_table("customs_warehouses", schema="public") + op.drop_table("customs_sections", schema="public") + op.drop_table("currency_types", schema="public") + op.drop_table("countries", schema="public") + op.drop_table("containers", schema="public") + op.drop_table("code_pedimento_regimens", schema="public") + op.drop_table("pedimento_regimens", schema="public") + op.drop_table("pedimento_codes", schema="public") + op.drop_table("user_client_permissions", schema="core") + op.drop_table("user_client_roles", schema="core") + op.drop_table("role_permissions", schema="core") + op.drop_table("client_roles", schema="core") + op.drop_table("permissions", schema="core") + op.drop_table("units_of_measure", schema="a76") + op.drop_table("unit_of_measure_customs", schema="a76") + op.drop_table("unit_of_measure_american", schema="a76") + op.drop_table("unit_of_measure_oma", schema="a76") + op.drop_table("unit_of_measure_ace", schema="a76") \ No newline at end of file diff --git a/backend/api/v1/common/base_models.py b/backend/api/v1/common/base_models.py index ddfbafd6..78b26204 100644 --- a/backend/api/v1/common/base_models.py +++ b/backend/api/v1/common/base_models.py @@ -9,10 +9,10 @@ class TimestampMixin: """Mixin for common timestamp fields""" created_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=func.now() + DateTime, nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, default=func.now(), onupdate=func.now() + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 26dfa830..78e213d8 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -15,8 +15,7 @@ ServiceType = TypeVar("ServiceType") class TenantCRUDRoutes( - Generic[CreateSchemaType, UpdateSchemaType, - ResponseSchemaType, ServiceType] + Generic[CreateSchemaType, UpdateSchemaType, ResponseSchemaType, ServiceType] ): """ Generic CRUD routes factory for tenant-scoped resources @@ -88,6 +87,13 @@ class TenantCRUDRoutes( enable_filters: bool = False, # Enable custom filters in list endpoint default_page_size: int = 50, max_page_size: int = 100, + # Permissions for each operation + list_permissions: Optional[list[str]] = None, + get_permissions: Optional[list[str]] = None, + create_permissions: Optional[list[str]] = None, + update_permissions: Optional[list[str]] = None, + delete_permissions: Optional[list[str]] = None, + require_all: bool = True, # If True, requires ALL permissions; if False, requires ANY ): self.service = service self.create_schema = create_schema @@ -104,6 +110,12 @@ class TenantCRUDRoutes( self.enable_filters = enable_filters self.default_page_size = default_page_size self.max_page_size = max_page_size + self.list_permissions = list_permissions + self.get_permissions = get_permissions + self.create_permissions = create_permissions + self.update_permissions = update_permissions + self.delete_permissions = delete_permissions + self.require_all = require_all self.router = APIRouter(prefix=prefix, tags=tags) self._register_routes() @@ -130,18 +142,22 @@ class TenantCRUDRoutes( le=self.max_page_size, description="Page size", ), - status: Optional[str] = Query( - None, description="Filter by status"), + status: Optional[str] = Query(None, description="Filter by status"), operation_type: Optional[str] = Query( - None, description="Filter by operation type"), + None, description="Filter by operation type" + ), invoice_type: Optional[str] = Query( - None, description="Filter by invoice type"), + None, description="Filter by invoice type" + ), db: Session = Depends(self.db_dependency), - current_user: Dict[str, Any] = Depends( - self.auth_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user + db, + company_id, + current_user, + self.list_permissions, + self.require_all, ) skip = (page - 1) * page_size @@ -184,11 +200,14 @@ class TenantCRUDRoutes( description="Page size", ), db: Session = Depends(self.db_dependency), - current_user: Dict[str, Any] = Depends( - self.auth_dependency), + current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user + db, + company_id, + current_user, + self.list_permissions, + self.require_all, ) skip = (page - 1) * page_size @@ -225,7 +244,8 @@ class TenantCRUDRoutes( ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, company_id, current_user, self.get_permissions, self.require_all + ) parent_id = path_params.get(self.parent_id_name) # Try method with 4 params (pedimento_id, tenant_id, company_id) @@ -239,8 +259,7 @@ class TenantCRUDRoutes( db, parent_id, tenant_id, company_id ) else: - resource = self.service.get( - db, parent_id, tenant_id, company_id) + resource = self.service.get(db, parent_id, tenant_id, company_id) if not resource: raise HTTPException( @@ -265,7 +284,8 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, company_id, current_user, self.get_permissions, self.require_all + ) resource = self.service.get_by_id( db, resource_id, tenant_id, company_id @@ -298,7 +318,12 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.create_permissions, + self.require_all, + ) # For child resources, parent_id validation would go here try: @@ -310,6 +335,7 @@ class TenantCRUDRoutes( except Exception as e: # Re-lanzar otros errores raise + else: # Parent resource - no parent_id needed @@ -330,7 +356,12 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.create_permissions, + self.require_all, + ) try: resource = self.service.create(db, data, tenant_id, company_id) return resource @@ -364,7 +395,12 @@ class TenantCRUDRoutes( **path_params, ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.update_permissions, + self.require_all, + ) parent_id = path_params.get(self.parent_id_name) try: @@ -404,7 +440,12 @@ class TenantCRUDRoutes( ): f"""Update {self.resource_name}""" tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.update_permissions, + self.require_all, + ) try: resource = self.service.update( @@ -438,11 +479,15 @@ class TenantCRUDRoutes( **path_params, ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.delete_permissions, + self.require_all, + ) parent_id = path_params.get(self.parent_id_name) - success = self.service.delete( - db, parent_id, tenant_id, company_id) + success = self.service.delete(db, parent_id, tenant_id, company_id) if not success: raise HTTPException( @@ -467,10 +512,14 @@ class TenantCRUDRoutes( current_user: Dict[str, Any] = Depends(self.auth_dependency), ): tenant_id = validate_access_to_resource( - db, company_id, current_user) + db, + company_id, + current_user, + self.delete_permissions, + self.require_all, + ) - success = self.service.delete( - db, resource_id, tenant_id, company_id) + success = self.service.delete(db, resource_id, tenant_id, company_id) if not success: raise HTTPException( diff --git a/backend/api/v1/modules/a24/fa/fa_classes/models.py b/backend/api/v1/modules/a24/fa/fa_classes/models.py index 90c8cb1f..776baf54 100644 --- a/backend/api/v1/modules/a24/fa/fa_classes/models.py +++ b/backend/api/v1/modules/a24/fa/fa_classes/models.py @@ -18,18 +18,32 @@ class QClasses(Base, TenantScopedMixin, TimestampMixin): __tablename__ = "fa_classes" # QClases __table_args__ = ( PrimaryKeyConstraint("id", name="qclases_pk"), - ForeignKeyConstraint(["class_id"], ["a76.classes.id"], name="fk_qclasses_classes"), + ForeignKeyConstraint( + ["class_id"], ["a76.classes.id"], name="fk_qclasses_classes" + ), {"schema": "a24"}, ) id: Mapped[int] = mapped_column(Integer, primary_key=True) class_id: Mapped[int] = mapped_column(Integer, nullable=False) - import_tariff_code: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # FRACCIONIMPO - import_tariff_type: Mapped[Optional[str]] = mapped_column(String(6), nullable=True) # TIPOFRACIMPO - export_tariff_code: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) # FRACCIONEXPO - export_tariff_type: Mapped[Optional[str]] = mapped_column(String(6), nullable=True) # TIPOFRACEXPO - depreciation_rate: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 2), nullable=True) # TASADEPRECIA + import_tariff_code: Mapped[Optional[str]] = mapped_column( + String(10), nullable=True + ) # FRACCIONIMPO + import_tariff_type: Mapped[Optional[str]] = mapped_column( + String(6), nullable=True + ) # TIPOFRACIMPO + export_tariff_code: Mapped[Optional[str]] = mapped_column( + String(10), nullable=True + ) # FRACCIONEXPO + export_tariff_type: Mapped[Optional[str]] = mapped_column( + String(6), nullable=True + ) # TIPOFRACEXPO + depreciation_rate: Mapped[Optional[Decimal]] = mapped_column( + Numeric(5, 2), nullable=True + ) # TASADEPRECIA fda_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # FDA eccn_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # ECCN - class_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) # HABILITADESHABILITACLASE + class_enabled: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) # HABILITADESHABILITACLASE diff --git a/backend/api/v1/modules/a76/doc_types_dig/models.py b/backend/api/v1/modules/a76/doc_types_dig/models.py index db7636ce..61b2efd0 100644 --- a/backend/api/v1/modules/a76/doc_types_dig/models.py +++ b/backend/api/v1/modules/a76/doc_types_dig/models.py @@ -1,6 +1,13 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base -from sqlalchemy import Boolean, Integer, PrimaryKeyConstraint, String, Text, UniqueConstraint +from sqlalchemy import ( + Boolean, + Integer, + PrimaryKeyConstraint, + String, + Text, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column @@ -22,4 +29,4 @@ class DocumentTypeDigitization(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer) code: Mapped[str] = mapped_column(String(10), nullable=False, index=True) description: Mapped[str] = mapped_column(Text, nullable=False) - active: Mapped[bool] = mapped_column(Boolean, default=True) + active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/dto.py b/backend/api/v1/modules/a76/general_catalogs/ports/dto.py index ebc91255..d173d9ea 100644 --- a/backend/api/v1/modules/a76/general_catalogs/ports/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/ports/dto.py @@ -11,7 +11,7 @@ class PortBase(BaseModel): location_description: Optional[str] = Field( None, max_length=20, description="Location Description") port_type: PortType = Field( - default=PortType.ENTRY, description="Port Type (ENTRY, EXIT, BOTH)") + server_default=PortType.ENTRY, description="Port Type (ENTRY, EXIT, BOTH)") class PortCreate(PortBase): diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/models.py b/backend/api/v1/modules/a76/general_catalogs/ports/models.py index 01aa45df..61bdf8a4 100644 --- a/backend/api/v1/modules/a76/general_catalogs/ports/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/ports/models.py @@ -32,4 +32,4 @@ class Port(Base, TenantScopedMixin, TimestampMixin): # New column requested port_type: Mapped[PortType] = mapped_column( - String(15), nullable=False, default=PortType.ENTRY) + String(15), nullable=False, server_default=PortType.ENTRY) diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py index d33b82b6..4564c8d6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/models.py @@ -13,8 +13,6 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base # 1. GUniMedACE - - class UnitOfMeasureACE(Base, TimestampMixin): __tablename__ = "unit_of_measure_ace" __table_args__ = ( @@ -28,8 +26,6 @@ class UnitOfMeasureACE(Base, TimestampMixin): # 2. GUMOMA - - class UnitOfMeasureOMA(Base, TimestampMixin): __tablename__ = "unit_of_measure_oma" __table_args__ = ( @@ -43,8 +39,6 @@ class UnitOfMeasureOMA(Base, TimestampMixin): # 3. GUMAme - - class UnitOfMeasureAmerican(Base, TimestampMixin): __tablename__ = "unit_of_measure_american" __table_args__ = ( @@ -58,8 +52,6 @@ class UnitOfMeasureAmerican(Base, TimestampMixin): # 4. GUMAduana - - class UnitOfMeasureCustoms(Base, TimestampMixin): __tablename__ = "unit_of_measure_customs" __table_args__ = ( @@ -68,52 +60,15 @@ class UnitOfMeasureCustoms(Base, TimestampMixin): ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(2), nullable=False) # CLAVE - description: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) - scaii_unit_code: Mapped[Optional[str]] = mapped_column( - String(5), nullable=True - ) # UNIDADSCAII + code: Mapped[str] = mapped_column(Integer, nullable=False) # CLAVE + description: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # 5. GUniMedida (Main) - - class UnitOfMeasure(Base, TenantScopedMixin, TimestampMixin): __tablename__ = "units_of_measure" __table_args__ = ( - UniqueConstraint("code", "tenant_id", "company_id", name="uq_uom_code"), - ForeignKeyConstraint( - ["customs_code"], - [ - "a76.unit_of_measure_customs.code" - ], - use_alter=True, - name="fk_uom_customs", - ), - ForeignKeyConstraint( - ["american_code"], - [ - "a76.unit_of_measure_american.code" - ], - use_alter=True, - name="fk_uom_american", - ), - ForeignKeyConstraint( - ["ace_code"], - [ - "a76.unit_of_measure_ace.code" - ], - use_alter=True, - name="fk_uom_ace", - ), - ForeignKeyConstraint( - ["oma_code"], - [ - "a76.unit_of_measure_oma.code" - ], - use_alter=True, - name="fk_uom_oma", - ), + UniqueConstraint("code", "tenant_id", "company_id", name="uq_uom_code"), {"schema": "a76", "extend_existing": True}, ) @@ -122,35 +77,19 @@ class UnitOfMeasure(Base, TenantScopedMixin, TimestampMixin): description: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) description_en: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) - customs_code: Mapped[Optional[str]] = mapped_column( - String(2), nullable=True - ) # CLAVE_AMEX - american_code: Mapped[Optional[str]] = mapped_column( - String(3), nullable=True - ) # CLAVE_AAMER - ace_code: Mapped[Optional[str]] = mapped_column( - String(4), nullable=True - ) # CLAVEACE - oma_code: Mapped[Optional[str]] = mapped_column( - String(10), nullable=True - ) # CLAVEOMA + customs_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_customs.code"), nullable=True) # CLAVE_AMEX + american_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_american.code"), nullable=True) # CLAVE_AAMER + ace_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_ace.code"), nullable=True) # CLAVEACE + oma_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_oma.code"), nullable=True) # CLAVEOMA # Relationships omitted for simplicity or need explicit primaryjoin customs_unit: Mapped[Optional["UnitOfMeasureCustoms"]] = relationship() - american_unit: Mapped[Optional["UnitOfMeasureAmerican"]] = relationship( - overlaps="customs_unit" - ) - ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship( - overlaps="american_unit,customs_unit" - ) - oma_unit: Mapped[Optional["UnitOfMeasureOMA"]] = relationship( - overlaps="ace_unit,american_unit,customs_unit" - ) + american_unit: Mapped[Optional["UnitOfMeasureAmerican"]] = relationship(overlaps="customs_unit") + ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship(overlaps="american_unit,customs_unit") + oma_unit: Mapped[Optional["UnitOfMeasureOMA"]] = relationship(overlaps="ace_unit,american_unit,customs_unit") # 6. GUniMed (General/Conversion) - - class UnitOfMeasureGeneral(Base, TenantScopedMixin, TimestampMixin): __tablename__ = "units_of_measure_general" __table_args__ = ( @@ -178,14 +117,10 @@ class UnitOfMeasureGeneral(Base, TenantScopedMixin, TimestampMixin): ) mexico_unit: Mapped[Optional[str]] = mapped_column(String(5), nullable=True) # UNIDAD_AME (Note: GUniMed has UNIDAD_AME varchar(5), but GUMAme has CLAVE varchar(3). Keeping as string for now) - american_unit_code: Mapped[Optional[str]] = mapped_column(String(5), nullable=True) + american_unit_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_american.code"), nullable=True) - customs_code: Mapped[Optional[str]] = mapped_column( - String(2), nullable=True - ) # CLAVE_ADUANA - ace_code: Mapped[Optional[str]] = mapped_column( - String(4), nullable=True - ) # CLAVEACE + customs_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_customs.code"), nullable=True) # CLAVE_ADUANA + ace_code: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.unit_of_measure_ace.code"), nullable=True) # CLAVEACE customs_unit: Mapped[Optional["UnitOfMeasureCustoms"]] = relationship() ace_unit: Mapped[Optional["UnitOfMeasureACE"]] = relationship( diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py index 080b71e4..d0b91e84 100644 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py +++ b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_adua.py @@ -1,24 +1,24 @@ seed = [ - ('KGS', 'Kilo'), - ('KW', 'Kilowatt'), - ('MILLR', 'Millar'), - ('JGO', 'Juego'), - ('KWH', 'Kilowatt/Hora'), - ('TON', 'Tonelada'), - ('BARR', 'Barril'), - ('GRN', 'Gramo Neto'), - ('DEC', 'Decenas'), - ('CIEN', 'Cientos'), - ('DOCE', 'Decenas (Docenas)'), - ('GR', 'Gramo'), - ('CAJA', 'Caja'), - ('PZA', 'Botella'), - ('CARAT', 'Carat'), - ('MT', 'Metro Lineal'), - ('M2', 'Metro Cuadrado'), - ('M3', 'Metro Cubico'), - ('PZA', 'Pieza'), - ('PZA', 'Cabeza'), - ('LT', 'Litro'), - ('PAR', 'Par'), + ("1", "Kilo"), + ("2", "Gramo"), + ("3", "Metro Lineal"), + ("4", "Metro Cuadrado"), + ("5", "Metro Cubico"), + ("6", "Pieza"), + ("7", "Cabeza"), + ("8", "Litro"), + ("9", "Par"), + ("10", "Kilowatt"), + ("11", "Millar"), + ("12", "Juego"), + ("13", "Kilowatt/Hora"), + ("14", "Tonelada"), + ("15", "Barril"), + ("16", "Gramo Neto"), + ("17", "Decenas"), + ("18", "Cientos"), + ("19", "Decenas"), + ("20", "Caja"), + ("21", "Botella"), + ("22", "Carat"), ] \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_med.py b/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_med.py deleted file mode 100644 index 75131b93..00000000 --- a/backend/api/v1/modules/a76/general_catalogs/units_of_measure/seed_med.py +++ /dev/null @@ -1,44 +0,0 @@ -seed = [ - # (code, desc_es, desc_en, customs, american, ace, oma) - ('BARR', 'BARRIL', 'BARIEL', '', 'BBL', 'BLL', ''), - ('BD FT', 'PIE TABLA', 'BD FEET', '', 'FT', 'BFT', ''), - ('BOLS', 'BOLSA', 'BAG', '', 'PCS', 'BG', ''), - ('BTL', 'BOTELLA', 'BOTTLE', '', 'PCS', 'BO', ''), - ('BULT', 'BULTO', 'BULK', '', 'PCS', 'VQ', ''), - ('CAJA', 'CAJA', 'BOX', '', '', 'BX', ''), - ('CARAT', 'CARAT', 'CARAT', '', '', 'HE', ''), - ('CBZA', 'CABEZA', 'HEAD', '', 'PCS', 'Z4', ''), - ('CIEN', 'CIENTO', 'CIEN', '', '', 'CEN', ''), - ('CM', 'CENTIMETRO', 'CM', 'CM', 'CM', 'CMT', ''), - ('CM2', 'CENTIMETRO CUADRADO', 'CM2', 'CM2', 'CM2', 'CMK', ''), - ('DEC', 'DECENA', '', '', '', 'DC', ''), - ('DM', 'DECIMETRO', 'DM', '', '', 'DMT', ''), - ('DM2', 'DECIMETRO CUADRADO', 'SQ DM', '', '', 'DMK', ''), - ('DOCE', 'DOCENA', 'DOZ', '', 'DOZ', 'DZN', 'DZ'), - ('FOZ', 'ONZA LIQUIDA', 'FOZ', 'FOZ', 'FOZ', 'OZA', ''), - ('FT', 'PIES', 'FT', 'FT', 'FT', 'LF', ''), - ('FT2', 'PIE CUADRADO', 'FT2', '', 'SFT', 'FTK', ''), - ('GAL', 'GALON', 'GAL', 'GAL', 'GAL', 'GLL', ''), - ('GR', 'GRAMO', 'GRAM', '', '', 'GRM', ''), - ('IN', 'PULGADA', 'IN', '', '', 'LI', ''), - ('IN2', 'PULGADA CUADRADA', 'IN2', '', '', 'INK', ''), - ('JGO', 'JUEGO', 'SET', '', '', 'SET', ''), - ('KGS', 'KILOGRAMOS', 'KGS', '', 'KG2', 'KGM', ''), - ('LB', 'LIBRAS', 'LB', '', '', 'LBR', ''), - ('LT', 'LITRO', 'LT', 'LT', 'L', 'LTR', ''), - ('M2', 'METRO CUADRADO', 'M2', 'M2', 'M2', 'MTK', ''), - ('M3', 'METRO CUBICO', 'M3', 'M3', 'M3', 'MTQ', ''), - ('MI', 'MILLA', 'MILE', '', 'KM', 'SMI', ''), - ('MILLR', 'MILLAR', 'MILLR', '', '', 'MIL', ''), - ('MT', 'METROS', 'MT', 'MT', 'M', 'MTR', ''), - ('OZ', 'ONZA', 'OZ', 'FOZ', 'FOZ', 'OZ', ''), - ('PAR', 'PAR', 'PAIR', '', '', 'PB', ''), - ('PQ', 'PAQUETE', 'PACKAGE', '', 'PCS', 'PK_1', ''), - ('PZA', 'PIEZA', 'PCS', 'PCS', 'PCS', 'C62_1', ''), - ('QGL', 'CUARTO DE GALON', 'QGL', '', '', 'QT', ''), - ('ROLL', 'ROLLO', 'ROLL', '', '', 'RO', ''), - ('TON', 'TONELADA', 'TON', 'TON', 'TON', 'TNE_1', ''), - ('TOZ', 'ONZA TROY', 'TOZ', '', 'TOZ', 'APZ', ''), - ('YD', 'YARDA', 'YD', 'YD', 'YD', 'YRD', ''), - ('YD2', 'YARDA CUADRADA', 'YD2', '', 'SYD', 'YDK', ''), -] \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py index 67da3db4..8e6f35d5 100644 --- a/backend/api/v1/modules/a76/invoices/models.py +++ b/backend/api/v1/modules/a76/invoices/models.py @@ -10,6 +10,7 @@ from sqlalchemy import ( String, Text, TIMESTAMP, + func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from core.database import Base @@ -101,7 +102,7 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin): # Dates invoice_date: Mapped[datetime] = mapped_column(Date) # FECHAFACTURA capture_date: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=False), default=datetime.now + TIMESTAMP(timezone=False), server_default=func.now() ) # FECHACAPTURA + HORAACTUAL emission_date: Mapped[Optional[datetime]] = mapped_column(Date) # FECHAEMISION @@ -153,13 +154,13 @@ class InvoiceHeader(Base, TenantScopedMixin, TimestampMixin): # Generation flags generate_id: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # GENERAID generate_desc_parties: Mapped[Optional[str]] = mapped_column( String(12) ) # GENDESCPARTIDAS / Generar descripción de partidas apply_manual_discount: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # APLICADESCMANUAL # Bulk & Downloads @@ -287,7 +288,7 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin): Integer ) # APENDICE17 / Apéndice 17 is_regime_change: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # ESCAMBIOREGIMEN / Es cambio de régimen which_exchange_rate: Mapped[Optional[str]] = mapped_column( String(5) @@ -299,15 +300,15 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin): String(5) ) # ACTVALOR / Actualizar valor is_pedimento_pending: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # PED_PENDIENTE_ASIGNAR (Mapear 1 -> True, 0 -> False) # Ownership & Balances is_owner_of_goods: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # ESDUENOMCIA / Es dueño de mercancía generate_balances: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # GENERARSALDOS / Generar saldos was_reviewed_by_company: Mapped[Optional[bool]] = mapped_column( Boolean @@ -413,102 +414,102 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin): # Merchandise Values (MN = National Currency, ME = Foreign Currency, MC = Third Currency) value_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORIMPOMN/VALOREXPOMN/VALORENTMN/VALORSALMN value_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORIMPOME/VALOREXPOME/VALORENTME/VALORSALME value_mc: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORIMPOMC/VALOREXPOMC # Customs Value customs_value_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORADUANASMN / Valor en aduanas MN customs_value_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORADUANASME / Valor en aduanas ME # Raw Materials raw_material_value_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORMPMN / Valor materia prima MN raw_material_value_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORMPME / Valor materia prima ME # Aggregate Value aggregate_value_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORAGREMN / Valor agregado MN aggregate_value_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORAGREME / Valor agregado ME aggregate_value_mc: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORAGREMC / Valor agregado MC # Mexican Merchandise Value mexican_value_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORVMEXMN / Valor mercancía mexicana MN mexican_value_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORVMEXME / Valor mercancía mexicana ME mexican_value_mc: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORVMEXMC / Valor mercancía mexicana MC # National Packaging national_packaging_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALEMPAQUENACMN / Valor empaque nacional MN national_packaging_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALEMPAQUENACME / Valor empaque nacional ME national_packaging_mc: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALEMPAQUENACMC / Valor empaque nacional MC # Costs & Increments freight: Mapped[Optional[float]] = mapped_column( - Numeric(19, 8), default=0 + Numeric(19, 8), default=0, server_default="0" ) # FLETE / Flete insurance: Mapped[Optional[float]] = mapped_column( - Numeric(19, 8), default=0 + Numeric(19, 8), default=0, server_default="0" ) # SEGUROS / Seguros insurance_value: Mapped[Optional[float]] = mapped_column( - Numeric(19, 8), default=0 + Numeric(19, 8), default=0, server_default="0" ) # VALSEGUROS / Valor seguros packaging: Mapped[Optional[float]] = mapped_column( - Numeric(19, 8), default=0 + Numeric(19, 8), default=0, server_default="0" ) # EMBALAJES / Embalajes other_increments: Mapped[Optional[float]] = mapped_column( - Numeric(19, 8), default=0 + Numeric(19, 8), default=0, server_default="0" ) # OTROSINCREMENTA / Otros incrementables total_increments_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # TOTALINCREMMN / Total incrementables MN total_increments_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # TOTALINCREMME / Total incrementables ME # Taxes iva_mn: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # IVAEXPOMN/VALORIVAMN / IVA en MN iva_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # IVAEXPOME/VALORIVAME / IVA en ME iva_mc: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # IVAEXPOMC / IVA en MC iva_factor: Mapped[Optional[str]] = mapped_column( String(10) ) # FACTORIVA / Factor IVA (puede ser varchar en imports) tax_value_me: Mapped[Optional[float]] = mapped_column( - Numeric(23, 8), default=0 + Numeric(23, 8), default=0, server_default="0" ) # VALORIMPUESTOME / Valor impuesto ME seal_value_2500: Mapped[Optional[bool]] = mapped_column( Boolean @@ -554,7 +555,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): String(10) ) # TRANSPORTISTAAME / Transportista americano transport_type: Mapped[TransportType] = mapped_column( - String(15), default="none" + String(15), server_default="none" ) # TRANSPORTE / Tipo de transporte transport_num: Mapped[Optional[str]] = mapped_column( String(20) @@ -566,7 +567,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): String(80) ) # CONDUCTOR / Nombre del conductor is_rail: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # ESFERROCARRIL / Es ferrocarril rail_id: Mapped[Optional[str]] = mapped_column( String(31) @@ -655,7 +656,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): # Delivery Control delivered_status: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # ENTREGADO / Estado de entrega received_by: Mapped[Optional[str]] = mapped_column( String(50) @@ -671,7 +672,7 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): # CTM Process is_ctm_process: Mapped[Optional[bool]] = mapped_column( - Boolean, default=False + Boolean, default=False, server_default="false" ) # SETRATAPROCESOCTM / Se trata de proceso CTM # Continuation Tab Fields diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index e81706a1..ceb430d9 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -20,9 +20,14 @@ invoice_crud = TenantCRUDRoutes( tags=[], resource_name="Invoice", id_name="invoice_id", - id_type=int, + id_type=int, enable_list=True, # Enable list endpoint with pagination enable_filters=True, # Enable filters for status, operation_type, etc. + list_permissions=[], + get_permissions=[], + create_permissions=[], + update_permissions=[], + delete_permissions=[], default_page_size=50, max_page_size=200, ) diff --git a/backend/api/v1/modules/a76/parts/models.py b/backend/api/v1/modules/a76/parts/models.py index db56d294..b920065a 100644 --- a/backend/api/v1/modules/a76/parts/models.py +++ b/backend/api/v1/modules/a76/parts/models.py @@ -89,7 +89,9 @@ class Part(Base, TenantScopedMixin, TimestampMixin): export_code: Mapped[Optional[str]] = mapped_column(String(2)) exclusion_symbol: Mapped[Optional[str]] = mapped_column(String(19)) - is_active: Mapped[Optional[bool]] = mapped_column(Boolean, default=True) + is_active: Mapped[Optional[bool]] = mapped_column( + Boolean, default=True, server_default="true" + ) part_photo: Mapped[Optional[str]] = mapped_column(String(255)) creation_date: Mapped[Optional[int]] = mapped_column() diff --git a/backend/api/v1/modules/a76/transportation/trailers/models.py b/backend/api/v1/modules/a76/transportation/trailers/models.py index 038c8987..d3b19e8b 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/models.py +++ b/backend/api/v1/modules/a76/transportation/trailers/models.py @@ -12,7 +12,7 @@ class Trailer(Base, TenantScopedMixin, TimestampMixin): trailer_number = Column(String(20), primary_key=True, nullable=False) ace_trailer_number = Column(String(10), nullable=True) trailer_type_key = Column( - String(2), ForeignKey("a76.trailer_type.trailer_type_key"), nullable=True + String(2), ForeignKey("public.trailer_type.trailer_type_key"), nullable=True ) seal = Column(String(15), nullable=True) entity_code = Column(String(1), nullable=True) diff --git a/backend/api/v1/modules/core/licenses/models.py b/backend/api/v1/modules/core/licenses/models.py index 2bf4bcb9..e08a4145 100644 --- a/backend/api/v1/modules/core/licenses/models.py +++ b/backend/api/v1/modules/core/licenses/models.py @@ -44,21 +44,29 @@ class License(Base, TimestampMixin): ) # Plan y características - plan = Column(SQLEnum(LicensePlan), default=LicensePlan.FREE, nullable=False) + plan = Column( + SQLEnum(LicensePlan), + default=LicensePlan.FREE, + server_default="FREE", + nullable=False, + ) status = Column( - SQLEnum(LicenseStatus), default=LicenseStatus.PENDING, nullable=False + SQLEnum(LicenseStatus), + default=LicenseStatus.PENDING, + server_default="PENDING", + nullable=False, ) # Límites del plan - max_users = Column(Integer, default=5, nullable=False) - max_storage_gb = Column(Integer, default=10, nullable=False) - max_monthly_operations = Column(Integer, default=1000, nullable=False) + max_users = Column(Integer, server_default="5", nullable=False) + max_storage_gb = Column(Integer, server_default="10", nullable=False) + max_monthly_operations = Column(Integer, server_default="1000", nullable=False) # Features habilitadas (booleans) - feature_api_access = Column(Boolean, default=True) - feature_advanced_reports = Column(Boolean, default=False) - feature_integrations = Column(Boolean, default=False) - feature_dedicated_support = Column(Boolean, default=False) + feature_api_access = Column(Boolean, default=True, server_default="true") + feature_advanced_reports = Column(Boolean, default=False, server_default="false") + feature_integrations = Column(Boolean, default=False, server_default="false") + feature_dedicated_support = Column(Boolean, default=False, server_default="false") # Vigencia starts_at = Column(DateTime(timezone=True), nullable=False) @@ -85,10 +93,10 @@ class LicenseUsage(Base, TimestampMixin): period_start = Column(DateTime(timezone=True), nullable=False) period_end = Column(DateTime(timezone=True), nullable=False) - active_users = Column(Integer, default=0) - storage_used_gb = Column(Integer, default=0) - operations_count = Column(Integer, default=0) - api_calls_count = Column(Integer, default=0) + active_users = Column(Integer, default=0, server_default="0") + storage_used_gb = Column(Integer, default=0, server_default="0") + operations_count = Column(Integer, default=0, server_default="0") + api_calls_count = Column(Integer, default=0, server_default="0") def __repr__(self): return f"" diff --git a/backend/api/v1/modules/core/permissions/README.md b/backend/api/v1/modules/core/permissions/README.md new file mode 100644 index 00000000..d259bca4 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/README.md @@ -0,0 +1,324 @@ +# Módulo de Permisos Multi-Tenant + +Sistema completo de permisos granulares para aplicaciones multi-tenant con FastAPI y SQLAlchemy. + +## 📁 Estructura del Módulo + +``` +backend/api/v1/modules/core/permissions/ +├── __init__.py # Exports del módulo +├── models.py # Modelos SQLAlchemy +├── service.py # Lógica de negocio +├── dependencies.py # Dependencias FastAPI +├── schemas.py # Modelos Pydantic (request/response) +└── routes.py # Endpoints de la API +``` + +## 🎯 Componentes + +### **models.py** + +Define los modelos de base de datos: + +- `Permission` - Permisos del sistema (ej: "invoice.view", "invoice.edit") +- `ClientRole` - Roles personalizados por cliente +- `RolePermission` - Relación roles-permisos +- `UserClientRole` - Asignación usuario-rol-cliente +- `UserClientPermission` - Permisos directos por usuario + +### **service.py** + +Contiene la clase `PermissionService` con métodos: + +- `get_user_permissions()` - Obtiene todos los permisos de un usuario +- `has_permission()` - Verifica un permiso específico +- `has_all_permissions()` - Verifica múltiples permisos (AND) +- `has_any_permission()` - Verifica múltiples permisos (OR) +- `assign_role_to_user()` - Asigna roles a usuarios +- `grant_direct_permission()` - Concede permisos directos + +### **dependencies.py** + +Dependencias para proteger rutas: + +- `PermissionChecker` - Clase para verificar múltiples permisos +- `RequirePermission` - Clase para verificar un solo permiso +- `get_client_id()` - Extrae el ID del cliente del header +- `get_permission_service()` - Proporciona instancia del servicio +- `get_current_user_permissions()` - Devuelve permisos del usuario + +### **schemas.py** + +Modelos Pydantic para request/response: + +- Responses: `PermissionResponse`, `ClientRoleResponse`, `UserPermissionsResponse`, etc. +- Requests: `AssignRoleRequest`, `GrantPermissionRequest`, `CreateRoleRequest`, etc. + +### **routes.py** + +Endpoints de la API: + +- `GET /permissions/me` - Permisos del usuario actual +- `GET /permissions/available` - Lista todos los permisos +- `GET /permissions/roles` - Lista roles del cliente +- `POST /permissions/roles` - Crea un rol +- `POST /permissions/assign-role` - Asigna rol a usuario +- `POST /permissions/grant-permission` - Concede permiso directo +- Ejemplos de rutas protegidas + +## 🚀 Uso Rápido + +### Importar el módulo + +```python +from api.v1.modules.core.permissions import ( + Permission, + ClientRole, + PermissionService, + PermissionChecker, + RequirePermission, + router +) +``` + +### Registrar las rutas + +```python +# En backend/api/v1/router.py +from api.v1.modules.core.permissions import router as permissions_router + +api_router = APIRouter() +api_router.include_router(permissions_router) +``` + +### Proteger una ruta con permiso único + +```python +from fastapi import APIRouter, Depends +from api.v1.modules.core.permissions import RequirePermission + +router = APIRouter() + +@router.get("/invoices") +async def list_invoices( + _: None = Depends(RequirePermission("invoice.view")) +): + return {"invoices": [...]} +``` + +### Proteger con múltiples permisos + +```python +from api.v1.modules.core.permissions import PermissionChecker + +@router.post("/invoices") +async def create_invoice( + _: None = Depends(PermissionChecker( + ["invoice.view", "invoice.create"], + require_all=True # Requiere TODOS + )) +): + return {"created": True} +``` + +### Usar permisos en la lógica + +```python +from api.v1.modules.core.permissions import get_current_user_permissions + +@router.get("/dashboard") +async def dashboard( + permissions: set = Depends(get_current_user_permissions) +): + widgets = [] + + if "invoice.view" in permissions: + widgets.append({"type": "invoices", "data": [...]}) + + return {"widgets": widgets} +``` + +## 📊 Base de Datos + +### Ejecutar migración + +```bash +cd backend +alembic upgrade head +``` + +Esto crea las tablas y permisos iniciales: + +- **invoice.*** - view, create, edit, delete, approve +- **user.*** - view, create, edit, delete +- **report.*** - financial.view, admin.view, export +- **roles.*** - view, create, edit, delete, assign +- **permissions.*** - view, grant + +## 🔐 Flujo de Autenticación + +1. Usuario hace request con token JWT de Keycloak +2. Header `X-Client-ID` indica el cliente/tenant +3. Sistema extrae `user_id` del token +4. Consulta permisos del usuario en ese cliente +5. Valida si tiene el permiso requerido +6. Devuelve 200 OK o 403 Forbidden + +## 💡 Ejemplos Prácticos + +### Crear un rol personalizado + +```python +from api.v1.modules.core.permissions import PermissionService +from core.database import get_db + +db = next(get_db()) +service = PermissionService(db) + +# Crear rol +role = ClientRole( + client_id=1, + name="Contador", + code="accountant", + description="Acceso a módulo contable" +) +db.add(role) +db.commit() +``` + +### Asignar permisos a un rol + +```python +from api.v1.modules.core.permissions.models import RolePermission + +# Obtener permisos de facturación +invoice_perms = db.query(Permission).filter( + Permission.module == "invoice" +).all() + +# Asignar al rol +for perm in invoice_perms: + role_perm = RolePermission( + client_role_id=role.id, + permission_id=perm.id + ) + db.add(role_perm) + +db.commit() +``` + +### Asignar rol a usuario + +```python +service.assign_role_to_user( + user_id="user-uuid-from-keycloak", + client_id=1, + role_id=role.id, + assigned_by="admin-uuid" +) +``` + +### Conceder permiso temporal + +```python +from datetime import datetime, timedelta + +service.grant_direct_permission( + user_id="user-uuid", + client_id=1, + permission_code="invoice.delete", + assigned_by="admin-uuid", + expires_at=datetime.utcnow() + timedelta(days=7) +) +``` + +## ⚡ Optimización de Rendimiento + +### 1. Caché con Redis + +```python +import redis +from functools import lru_cache + +redis_client = redis.Redis(host='localhost', port=6379) + +def get_cached_permissions(user_id: str, client_id: int) -> set: + cache_key = f"perms:{user_id}:{client_id}" + + cached = redis_client.get(cache_key) + if cached: + return set(cached.decode().split(',')) + + # Consultar DB + service = PermissionService(db) + permissions = service.get_user_permissions(user_id, client_id) + + # Cachear por 5 minutos + redis_client.setex(cache_key, 300, ','.join(permissions)) + + return permissions +``` + +### 2. Índices de Base de Datos + +Ya están definidos en los modelos: + +- Índices compuestos para consultas eficientes +- Índices únicos para prevenir duplicados +- Índices en foreign keys + +### 3. Query Optimization + +El servicio usa JOINs eficientes en lugar de N+1 queries. + +## 🧪 Testing + +```python +import pytest +from api.v1.modules.core.permissions import PermissionService +from api.v1.modules.core.permissions.models import Permission, ClientRole + +def test_user_has_permission_from_role(db_session): + # Setup + perm = Permission(code="invoice.view", module="invoice", action="view") + db_session.add(perm) + + role = ClientRole(client_id=1, code="viewer", name="Viewer") + db_session.add(role) + db_session.commit() + + # Test + service = PermissionService(db_session) + assert service.has_permission("user-123", 1, "invoice.view") +``` + +## 📝 Notas Importantes + +- **Client ID**: Por defecto se obtiene del header `X-Client-ID`, pero puede adaptarse a subdominios o JWT +- **User ID**: Se extrae del campo `sub` del token JWT de Keycloak +- **Permisos Directos**: Pueden revocar permisos heredados de roles (`is_granted=False`) +- **Soft Delete**: Los roles y permisos se desactivan (`is_active=False`) en lugar de eliminarse + +## 🔗 Integración con Keycloak + +Los roles globales de Keycloak pueden coexistir con los roles locales: + +```python +@router.get("/protected") +async def protected_route( + current_user: dict = Depends(get_current_user), + permissions: set = Depends(get_current_user_permissions) +): + # Verificar rol global de Keycloak + keycloak_roles = current_user.get("realm_access", {}).get("roles", []) + + if "super_admin" in keycloak_roles: + # Super admin tiene acceso total + return {"access": "granted", "level": "global"} + + # Verificar permisos a nivel de cliente + if "invoice.view" in permissions: + return {"access": "granted", "level": "client"} + + raise HTTPException(403, "No access") +``` diff --git a/backend/api/v1/modules/core/permissions/__init__.py b/backend/api/v1/modules/core/permissions/__init__.py new file mode 100644 index 00000000..06a6a509 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/__init__.py @@ -0,0 +1,38 @@ +""" +Módulo de permisos multi-tenant. +Proporciona modelos, servicios y rutas para gestión de permisos granulares por companye. +""" + +from .models import ( + Permission, + CompanyRole, + RolePermission, + UserCompanyRole, + UserCompanyPermission, +) +from .service import PermissionService +from .dependencies import ( + PermissionChecker, + RequirePermission, + get_permission_service, + get_current_user_permissions, +) +from .routes import router + +__all__ = [ + # Models + "Permission", + "CompanyRole", + "RolePermission", + "UserCompanyRole", + "UserCompanyPermission", + # Service + "PermissionService", + # Dependencies + "PermissionChecker", + "RequirePermission", + "get_permission_service", + "get_current_user_permissions", + # Router + "router", +] diff --git a/backend/api/v1/modules/core/permissions/dependencies.py b/backend/api/v1/modules/core/permissions/dependencies.py new file mode 100644 index 00000000..83eea516 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/dependencies.py @@ -0,0 +1,203 @@ +""" +Dependencias de FastAPI para verificación de permisos multi-tenant. +Proporciona decoradores y funciones para proteger rutas con permisos específicos. +""" + +from typing import List, Optional, Callable +from fastapi import Depends, HTTPException, status, Header +from sqlalchemy.orm import Session +from functools import wraps +from core.database import get_core_db +from core.security import get_current_user # Asumiendo que existe esta función +from .service import PermissionService + +# Dependencia para obtener el servicio de permisos +def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService: + """ + Crea una instancia del servicio de permisos con la sesión de base de datos. + """ + return PermissionService(db) + + +# Clase para verificación de permisos (puede usarse como dependencia) +class PermissionChecker: + """ + Verificador de permisos que puede usarse como dependencia de FastAPI. + + Ejemplo de uso: + @app.get("/invoices") + async def list_invoices( + _: None = Depends(PermissionChecker(["invoice.view"])) + ): + return {"invoices": [...]} + """ + + def __init__(self, required_permissions: List[str], require_all: bool = True): + """ + Args: + required_permissions: Lista de permisos requeridos + require_all: Si True, requiere TODOS los permisos. + Si False, requiere AL MENOS UNO. + """ + self.required_permissions = required_permissions + self.require_all = require_all + + async def __call__( + self, + client_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + ): + """ + Verifica que el usuario tenga los permisos requeridos. + + Lanza HTTPException 403 si no tiene permisos. + """ + user_id = current_user.get("sub") or current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + + # Verificar permisos + if self.require_all: + has_access = permission_service.has_all_permissions( + user_id=user_id, + client_id=client_id, + permission_codes=self.required_permissions, + ) + else: + has_access = permission_service.has_any_permission( + user_id=user_id, + client_id=client_id, + permission_codes=self.required_permissions, + ) + + if not has_access: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(self.required_permissions)}", + ) + + return True + + +# Función alternativa para verificar un solo permiso +class RequirePermission: + """ + Verificador simple para un único permiso. + + Ejemplo: + @app.post("/invoices") + async def create_invoice( + _: None = Depends(RequirePermission("invoice.create")) + ): + return {"created": True} + """ + + def __init__(self, permission_code: str): + self.permission_code = permission_code + + async def __call__( + self, + client_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + ): + user_id = current_user.get("sub") or current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + + has_permission = permission_service.has_permission( + user_id=user_id, client_id=client_id, permission_code=self.permission_code + ) + + if not has_permission: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {self.permission_code}", + ) + + return True + + +# Decorador personalizado para aplicar a funciones (opcional) +def require_permissions(*permissions: str, require_all: bool = True): + """ + Decorador para verificar permisos en funciones. + Útil para lógica de negocio fuera de rutas FastAPI. + + Ejemplo: + @require_permissions("invoice.edit", "invoice.view") + def update_invoice_logic(invoice_id: int, user_id: str, client_id: int, db: Session): + # Lógica de actualización + pass + """ + + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + # Extraer user_id, client_id y db de los argumentos + user_id = kwargs.get("user_id") + client_id = kwargs.get("client_id") + db = kwargs.get("db") + + if not all([user_id, client_id, db]): + raise ValueError( + "Function must receive 'user_id', 'client_id', and 'db' as keyword arguments" + ) + + # Verificar permisos + permission_service = PermissionService(db) + + if require_all: + has_access = permission_service.has_all_permissions( + user_id=user_id, + client_id=client_id, + permission_codes=list(permissions), + ) + else: + has_access = permission_service.has_any_permission( + user_id=user_id, + client_id=client_id, + permission_codes=list(permissions), + ) + + if not has_access: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(permissions)}", + ) + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +# Función helper para obtener permisos del usuario actual +async def get_current_user_permissions( + client_id: int, + current_user: dict = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +) -> set: + """ + Devuelve todos los permisos del usuario actual en el cliente. + Útil para endpoints que necesitan conocer los permisos disponibles. + """ + user_id = current_user.get("sub") or current_user.get("id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User ID not found in token", + ) + + return permission_service.get_user_permissions(user_id, client_id) diff --git a/backend/api/v1/modules/core/permissions/integration_example.py b/backend/api/v1/modules/core/permissions/integration_example.py new file mode 100644 index 00000000..e368e884 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/integration_example.py @@ -0,0 +1,153 @@ +""" +Ejemplo de integración del módulo de permisos en el router principal. +Este archivo muestra cómo incluir las rutas de permisos en tu API. +""" + +from fastapi import APIRouter, Depends +from api.v1.modules.core.permissions import router as permissions_router + +# Crear el router principal de la API v1 +api_v1_router = APIRouter(prefix="/api/v1") + +# ============================================================================ +# REGISTRAR EL MÓDULO DE PERMISOS +# ============================================================================ + +# Opción 1: Incluir directamente con el prefix ya definido en el módulo +api_v1_router.include_router(permissions_router) +# Esto creará rutas como: /api/v1/permissions/me, /api/v1/permissions/roles, etc. + + +# Opción 2: Sobrescribir el prefix si lo necesitas +# api_v1_router.include_router( +# permissions_router, +# prefix="/permisos", # Cambiar a español si prefieres +# tags=["Permisos"] +# ) + + +# ============================================================================ +# IMPORTAR DEPENDENCIAS PARA USAR EN OTRAS RUTAS +# ============================================================================ + +from api.v1.modules.core.permissions import ( + RequirePermission, + PermissionChecker, + get_current_user_permissions, + PermissionService +) + +# ============================================================================ +# EJEMPLO: USAR PERMISOS EN OTROS MÓDULOS +# ============================================================================ + +# Supongamos que tienes un módulo de facturas +invoices_router = APIRouter(prefix="/invoices", tags=["Invoices"]) + +@invoices_router.get("/") +async def list_invoices( + _: None = Depends(RequirePermission("invoice.view")) +): + """Lista todas las facturas (requiere permiso invoice.view).""" + return { + "invoices": [ + {"id": 1, "number": "INV-001", "amount": 1000}, + {"id": 2, "number": "INV-002", "amount": 2000} + ] + } + + +@invoices_router.post("/") +async def create_invoice( + _: None = Depends(PermissionChecker(["invoice.view", "invoice.create"], require_all=True)) +): + """Crea una factura (requiere invoice.view + invoice.create).""" + return {"message": "Invoice created", "id": 3} + + +@invoices_router.delete("/{invoice_id}") +async def delete_invoice( + invoice_id: int, + _: None = Depends(RequirePermission("invoice.delete")) +): + """Elimina una factura (requiere invoice.delete).""" + return {"message": f"Invoice {invoice_id} deleted"} + + +# Registrar el router de facturas +api_v1_router.include_router(invoices_router) + + +# ============================================================================ +# EJEMPLO: RUTA CON LÓGICA CONDICIONAL SEGÚN PERMISOS +# ============================================================================ + +reports_router = APIRouter(prefix="/reports", tags=["Reports"]) + +@reports_router.get("/dashboard") +async def get_reports_dashboard( + user_permissions: set = Depends(get_current_user_permissions) +): + """ + Dashboard de reportes que muestra diferentes secciones según los permisos. + No requiere permisos específicos, pero el contenido varía. + """ + sections = [] + + if "report.financial.view" in user_permissions: + sections.append({ + "type": "financial", + "title": "Reportes Financieros", + "reports": ["Balance General", "Estado de Resultados", "Flujo de Efectivo"] + }) + + if "report.admin.view" in user_permissions: + sections.append({ + "type": "administrative", + "title": "Reportes Administrativos", + "reports": ["Actividad de Usuarios", "Auditoría", "Logs del Sistema"] + }) + + if "invoice.view" in user_permissions: + sections.append({ + "type": "invoices", + "title": "Reportes de Facturación", + "reports": ["Facturas por Mes", "Facturas Pendientes", "Análisis de Ventas"] + }) + + return { + "user_permissions": list(user_permissions), + "available_sections": sections + } + + +# Registrar el router de reportes +api_v1_router.include_router(reports_router) + + +# ============================================================================ +# EXPORTAR EL ROUTER PRINCIPAL +# ============================================================================ + +__all__ = ["api_v1_router"] + + +# ============================================================================ +# USO EN main.py +# ============================================================================ +""" +En tu backend/main.py, incluye el router así: + +from fastapi import FastAPI +from api.v1.integration_example import api_v1_router + +app = FastAPI(title="Mi API Multi-Tenant") + +# Registrar todas las rutas de API v1 +app.include_router(api_v1_router) + +# Ahora tendrás: +# - /api/v1/permissions/* (rutas de permisos) +# - /api/v1/invoices/* (rutas de facturas protegidas) +# - /api/v1/reports/* (rutas de reportes) +""" diff --git a/backend/api/v1/modules/core/permissions/models.py b/backend/api/v1/modules/core/permissions/models.py new file mode 100644 index 00000000..03ca0ddb --- /dev/null +++ b/backend/api/v1/modules/core/permissions/models.py @@ -0,0 +1,247 @@ +""" +Modelos de permisos multi-tenant para el sistema. +Este módulo define el sistema de permisos granular por compañia/tenant. +""" + +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import ( + String, + Integer, + ForeignKey, + DateTime, + Boolean, + UniqueConstraint, + Index, +) +from sqlalchemy.orm import relationship, Mapped, mapped_column +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + + +# Modelo para permisos del sistema +# Representa acciones específicas como "invoice.view", "invoice.edit", etc. +class Permission(Base, TimestampMixin): + __tablename__ = "permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + code: Mapped[str] = mapped_column( + String(100), unique=True, nullable=False, index=True + ) + # Código único del permiso (ej: "invoice.view", "user.edit") + + description: Mapped[Optional[str]] = mapped_column(String(255)) + # Descripción legible del permiso + + module: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + # Módulo al que pertenece (ej: "invoice", "user", "report") + + action: Mapped[str] = mapped_column(String(50), nullable=False) + # Acción específica (ej: "view", "edit", "delete", "create") + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # Permite desactivar permisos sin eliminarlos + + __table_args__ = {"schema": "core", "extend_existing": True} + + # Relaciones + role_permissions: Mapped[list["RolePermission"]] = relationship( + "RolePermission", back_populates="permission", cascade="all, delete-orphan" + ) + user_company_permissions: Mapped[list["UserCompanyPermission"]] = relationship( + "UserCompanyPermission", + back_populates="permission", + cascade="all, delete-orphan", + ) + + +# Modelo para roles personalizados por compañia/tenant +# Cada compañia puede definir sus propios roles con nombres personalizados +class CompanyRole(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "company_roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + name: Mapped[str] = mapped_column(String(100), nullable=False) + # Nombre del rol (ej: "Administrador", "Contador", "Vendedor") + + code: Mapped[str] = mapped_column(String(100), nullable=False) + # Código único del rol dentro del compañia (ej: "admin", "accountant") + + description: Mapped[Optional[str]] = mapped_column(String(255)) + # Descripción del rol + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # Permite desactivar roles sin eliminarlos + + # Restricción: el código del rol debe ser único por compañia + __table_args__ = ( + UniqueConstraint( + "company_id", "tenant_id", "code", name="uq_company_role_code" + ), + Index( + "ix_company_roles_company_id_is_active", + "company_id", + "tenant_id", + "is_active", + ), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + role_permissions: Mapped[list["RolePermission"]] = relationship( + "RolePermission", back_populates="company_role", cascade="all, delete-orphan" + ) + user_company_roles: Mapped[list["UserCompanyRole"]] = relationship( + "UserCompanyRole", + back_populates="company_role", + cascade="all, delete-orphan", + ) + + +# Tabla de relación entre roles de compañia y permisos +# Define qué permisos tiene cada rol +class RolePermission(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "role_permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + company_role_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.company_roles.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + permission_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.permissions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # Restricción: un permiso no puede estar duplicado en el mismo rol + __table_args__ = ( + UniqueConstraint("company_role_id", "permission_id", name="uq_role_permission"), + Index("ix_role_permissions_composite", "company_role_id", "permission_id"), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + company_role: Mapped["CompanyRole"] = relationship( + "CompanyRole", back_populates="role_permissions" + ) + permission: Mapped["Permission"] = relationship( + "Permission", back_populates="role_permissions" + ) + + +# Tabla de relación entre usuarios y roles de compañia +# Define qué roles tiene cada usuario en cada compañia +class UserCompanyRole(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "user_company_roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + # ID del usuario (puede ser UUID de Keycloak u otro identificador) + + company_role_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.company_roles.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # Permite desactivar asignaciones sin eliminarlas + + assigned_by: Mapped[Optional[str]] = mapped_column(String(100)) + # ID del usuario que asignó este rol + + # Restricción: un usuario no puede tener el mismo rol duplicado en un compañia + __table_args__ = ( + UniqueConstraint( + "user_id", + "company_id", + "tenant_id", + "company_role_id", + name="uq_user_company_role", + ), + Index( + "ix_user_company_roles_user_company", + "user_id", + "company_id", + "tenant_id", + "is_active", + ), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + company_role: Mapped["CompanyRole"] = relationship( + "CompanyRole", back_populates="user_company_roles" + ) + + +# Tabla para permisos directos de usuario por compañia (opcional) +# Permite asignar permisos específicos a un usuario sin necesidad de un rol +# Útil para casos excepcionales o permisos temporales +class UserCompanyPermission(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "user_company_permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + # ID del usuario + + permission_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("core.permissions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + is_granted: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + # True = permiso concedido, False = permiso revocado explícitamente + # Permite revocar permisos que vienen de roles + + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) + + assigned_by: Mapped[Optional[str]] = mapped_column(String(100)) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime) + # Permite permisos temporales con fecha de expiración + + # Restricción: un usuario no puede tener el mismo permiso duplicado en un compañia + __table_args__ = ( + UniqueConstraint( + "user_id", + "company_id", + "tenant_id", + "permission_id", + name="uq_user_company_permission", + ), + Index( + "ix_user_company_permissions_composite", + "user_id", + "company_id", + "tenant_id", + "is_active", + ), + {"schema": "core", "extend_existing": True}, + ) + + # Relaciones + permission: Mapped["Permission"] = relationship( + "Permission", back_populates="user_company_permissions" + ) diff --git a/backend/api/v1/modules/core/permissions/routes.py b/backend/api/v1/modules/core/permissions/routes.py new file mode 100644 index 00000000..49f989ce --- /dev/null +++ b/backend/api/v1/modules/core/permissions/routes.py @@ -0,0 +1,1239 @@ +""" +Rutas API para el sistema de permisos multi-tenant. +Endpoints para gestión de permisos, roles y asignaciones. +""" + +from typing import List, Optional, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from .dependencies import ( + PermissionChecker, + RequirePermission, + get_permission_service, + get_current_user_permissions, +) +from .service import PermissionService +from .models import Permission, CompanyRole +from .schemas import ( + PermissionResponse, + CompanyRoleResponse, + UserPermissionsResponse, + AssignRoleRequest, + GrantPermissionRequest, + SuccessResponse, + CreateRoleRequest, + UpdateRoleRequest, + AssignPermissionsToRoleRequest, + PermissionListResponse, + RoleListResponse, + UserRoleListResponse, + AssignUserRoleRequest, + UserCompanyRoleResponse, + UserPermissionResponse, + AssignUserPermissionRequest, + UserPermissionsListResponse, + EffectiveUserPermissionsResponse, +) + + +router = APIRouter(prefix="/permissions", tags=["permissions"]) + + +# ============================================================================ +# RUTAS DE CONSULTA DE PERMISOS +# ============================================================================ + + +@router.get("/me", response_model=UserPermissionsResponse) +async def get_my_permissions( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +): + """ + Obtiene los permisos y roles del usuario actual en el company actual. + No requiere permisos especiales ya que es información propia. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + user_id = current_user.get("sub") or current_user.get("id") + + # Obtener permisos + permissions = permission_service.get_user_permissions(user_id, company_id) + + # Obtener roles + roles = permission_service.get_user_roles(user_id, company_id) + role_names = [role.name for role in roles] + + return UserPermissionsResponse( + user_id=user_id, + company_id=company_id, + permissions=list(permissions), + roles=role_names, + ) + + +@router.get("/available", response_model=PermissionListResponse) +async def list_available_permissions( + db: Session = Depends(get_core_db), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=500, description="Tamaño de página"), + module: Optional[str] = Query(None, description="Filtrar por módulo"), + _: None = Depends(RequirePermission("permissions.view")), +): + """ + Lista todos los permisos disponibles en el sistema. + Requiere permiso: permissions.view + """ + query = db.query(Permission).filter(Permission.is_active == True) + + if module: + query = query.filter(Permission.module == module) + + total = query.count() + permissions = query.offset((page - 1) * page_size).limit(page_size).all() + + return PermissionListResponse( + items=permissions, total=total, page=page, page_size=page_size + ) + + +@router.get("/roles", response_model=RoleListResponse) +async def list_company_roles( + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=500, description="Tamaño de página"), +): + """ + Lista todos los roles del companye actual. + TODO: Agregar verificación de permisos + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + query = db.query(CompanyRole).filter( + CompanyRole.company_id == company_id + ) + + total = query.count() + roles = query.offset((page - 1) * page_size).limit(page_size).all() + + return RoleListResponse(items=roles, total=total, page=page, page_size=page_size) + + +@router.get("/users/{user_id}", response_model=UserPermissionsResponse) +async def get_user_permissions( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + _: None = Depends(RequirePermission("user.view")), +): + """ + Obtiene los permisos y roles de un usuario específico. + Requiere permiso: user.view + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + permissions = permission_service.get_user_permissions(user_id, company_id) + roles = permission_service.get_user_roles(user_id, company_id) + role_names = [role.name for role in roles] + + return UserPermissionsResponse( + user_id=user_id, + company_id=company_id, + permissions=list(permissions), + roles=role_names, + ) + + +# ============================================================================ +# RUTAS CRUD DE PERMISOS +# ============================================================================ + + +@router.get("", response_model=PermissionListResponse) +async def list_permissions( + db: Session = Depends(get_core_db), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=500, description="Tamaño de página"), + module: Optional[str] = Query(None, description="Filtrar por módulo"), + action: Optional[str] = Query(None, description="Filtrar por acción"), + search: Optional[str] = Query(None, description="Buscar por código o descripción"), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Lista todos los permisos disponibles en el sistema. + TODO: Agregar verificación de permisos + """ + query = db.query(Permission).filter(Permission.is_active == True) + + if module: + query = query.filter(Permission.module == module) + if action: + query = query.filter(Permission.action == action) + if search: + search_filter = f"%{search}%" + query = query.filter( + (Permission.code.ilike(search_filter)) + | (Permission.description.ilike(search_filter)) + ) + + total = query.count() + permissions = query.offset((page - 1) * page_size).limit(page_size).all() + + return PermissionListResponse( + items=permissions, total=total, page=page, page_size=page_size + ) + + +@router.get("/modules", response_model=List[str]) +async def get_modules( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene la lista de módulos únicos. + TODO: Agregar verificación de permisos + """ + modules = ( + db.query(Permission.module) + .filter(Permission.is_active == True) + .distinct() + .all() + ) + return [m[0] for m in modules] + + +@router.get("/actions", response_model=List[str]) +async def get_actions( + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene la lista de acciones únicas. + TODO: Agregar verificación de permisos + """ + actions = ( + db.query(Permission.action) + .filter(Permission.is_active == True) + .distinct() + .all() + ) + return [a[0] for a in actions] + + +# ============================================================================ +# RUTAS DE GESTIÓN DE ASIGNACIONES DE ROLES A USUARIOS +# ============================================================================ + + +@router.get("/user-roles", response_model=UserRoleListResponse) +async def list_user_roles( + company_id: int = Query(..., description="Company ID"), + user_id: Optional[str] = Query(None, description="Filtrar por user_id"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(100, ge=1, le=500, description="Tamaño de página"), +): + """ + Lista todas las asignaciones de roles a usuarios en la compañía. + """ + from .models import UserCompanyRole + from sqlalchemy.orm import joinedload + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + query = db.query(UserCompanyRole).options( + joinedload(UserCompanyRole.company_role) + ).filter( + UserCompanyRole.company_id == company_id, + UserCompanyRole.tenant_id == tenant_id + ) + + if user_id: + query = query.filter(UserCompanyRole.user_id == user_id) + + total = query.count() + user_roles = query.offset((page - 1) * page_size).limit(page_size).all() + + return UserRoleListResponse( + items=user_roles, + total=total, + page=page, + page_size=page_size + ) + + +@router.post("/user-roles", response_model=UserCompanyRoleResponse, status_code=status.HTTP_201_CREATED) +async def assign_user_role( + request: AssignUserRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Asigna un rol a un usuario. + """ + from .models import UserCompanyRole, CompanyRole + from sqlalchemy.exc import IntegrityError + from sqlalchemy.orm import joinedload + + tenant_id = validate_access_to_resource(db, company_id, current_user) + assigner_id = current_user.get("sub") or current_user.get("id") + + # Verificar que el rol existe + role = db.query(CompanyRole).filter( + CompanyRole.id == request.company_role_id, + CompanyRole.company_id == company_id + ).first() + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Role not found" + ) + + # Crear la asignación + user_role = UserCompanyRole( + user_id=request.user_id, + company_id=company_id, + tenant_id=tenant_id, + company_role_id=request.company_role_id, + assigned_by=assigner_id, + is_active=True + ) + + try: + db.add(user_role) + db.commit() + db.refresh(user_role) + + # Recargar con la relación company_role + user_role = db.query(UserCompanyRole).options( + joinedload(UserCompanyRole.company_role) + ).filter(UserCompanyRole.id == user_role.id).first() + + return user_role + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already has this role assigned" + ) + + +@router.delete("/user-roles/{user_role_id}") +async def remove_user_role( + user_role_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Remueve una asignación de rol a usuario. + """ + from .models import UserCompanyRole + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + user_role = db.query(UserCompanyRole).filter( + UserCompanyRole.id == user_role_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.tenant_id == tenant_id + ).first() + + if not user_role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User role assignment not found" + ) + + db.delete(user_role) + db.commit() + + return {"success": True, "message": "User role assignment removed"} + + +@router.get("/{permission_id}", response_model=PermissionResponse) +async def get_permission( + permission_id: int, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene un permiso por ID. + TODO: Agregar verificación de permisos + """ + permission = db.query(Permission).filter(Permission.id == permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + return permission + + +from .schemas import CreatePermissionRequest + + +@router.post( + "", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED +) +async def create_permission( + request: CreatePermissionRequest, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Crea un nuevo permiso en el sistema. + TODO: Agregar verificación de permisos + """ + # Verificar que el código no esté en uso + existing = db.query(Permission).filter(Permission.code == request.code).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Permission with code '{request.code}' already exists", + ) + + permission = Permission( + code=request.code, + description=request.description, + module=request.module, + action=request.action, + ) + db.add(permission) + db.commit() + db.refresh(permission) + return permission + + +@router.put("/{permission_id}", response_model=PermissionResponse) +async def update_permission( + permission_id: int, + request: CreatePermissionRequest, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Actualiza un permiso existente. + TODO: Agregar verificación de permisos + """ + permission = db.query(Permission).filter(Permission.id == permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + + # Si se cambia el código, verificar que no exista + if request.code != permission.code: + existing = db.query(Permission).filter(Permission.code == request.code).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Permission with code '{request.code}' already exists", + ) + + permission.code = request.code + permission.description = request.description + permission.module = request.module + permission.action = request.action + db.commit() + db.refresh(permission) + return permission + + +@router.delete("/{permission_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_permission( + permission_id: int, + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Elimina (desactiva) un permiso. + TODO: Agregar verificación de permisos + """ + permission = db.query(Permission).filter(Permission.id == permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + + permission.is_active = False + db.commit() + + +# ============================================================================ +# RUTAS DE GESTIÓN DE ROLES +# ============================================================================ + + +@router.post( + "/roles", response_model=CompanyRoleResponse, status_code=status.HTTP_201_CREATED +) +async def create_role( + request: CreateRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Crea un nuevo rol personalizado para el companye. + TODO: Agregar verificación de permisos + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verificar que el código no esté en uso + existing = ( + db.query(CompanyRole) + .filter(CompanyRole.company_id == company_id, CompanyRole.code == request.code) + .first() + ) + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role with code '{request.code}' already exists for this company", + ) + + # Crear el rol + role = CompanyRole( + company_id=company_id, + tenant_id=tenant_id, + name=request.name, + code=request.code, + description=request.description, + ) + + db.add(role) + db.commit() + db.refresh(role) + + # Asignar permisos si se especificaron + if request.permission_ids: + from .models import RolePermission + + for perm_id in request.permission_ids: + role_perm = RolePermission(company_role_id=role.id, permission_id=perm_id) + db.add(role_perm) + db.commit() + + return role + + +@router.patch("/roles/{role_id}", response_model=CompanyRoleResponse) +async def update_role( + role_id: int, + request: UpdateRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Actualiza un rol existente. + TODO: Agregar verificación de permisos + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Actualizar campos + if request.name is not None: + role.name = request.name + if request.description is not None: + role.description = request.description + if request.is_active is not None: + role.is_active = request.is_active + + db.commit() + db.refresh(role) + + return role + + +@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_role( + role_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Elimina permanentemente un rol y sus permisos asociados. + TODO: Agregar verificación de permisos + """ + from .models import RolePermission, UserCompanyRole + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Verificar si hay usuarios con este rol + users_with_role = db.query(UserCompanyRole).filter( + UserCompanyRole.company_role_id == role_id + ).count() + + if users_with_role > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"No se puede eliminar el rol porque tiene {users_with_role} usuario(s) asignado(s). Primero remueve el rol de los usuarios." + ) + + # Eliminar permisos asociados al rol + db.query(RolePermission).filter(RolePermission.company_role_id == role_id).delete() + + # Eliminar el rol + db.delete(role) + db.commit() + + +# ============================================================================ +# RUTAS DE GESTIÓN DE PERMISOS POR ROL +# ============================================================================ + + +@router.get("/roles/{role_id}/permissions") +async def get_role_permissions( + role_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Obtiene todos los permisos asignados a un rol. + """ + from .models import RolePermission + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verificar que el rol existe y pertenece al company + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Obtener permisos del rol con información completa + role_permissions = ( + db.query(RolePermission) + .join(Permission, RolePermission.permission_id == Permission.id) + .filter(RolePermission.company_role_id == role_id) + .all() + ) + + permissions_list = [] + for rp in role_permissions: + permission = db.query(Permission).filter(Permission.id == rp.permission_id).first() + permissions_list.append({ + "id": rp.id, + "company_role_id": rp.company_role_id, + "permission_id": rp.permission_id, + "permission": { + "id": permission.id, + "code": permission.code, + "module": permission.module, + "action": permission.action, + "description": permission.description + } if permission else None + }) + + return { + "role_id": role_id, + "permissions": permissions_list, + "total": len(permissions_list) + } + + +@router.post("/roles/{role_id}/permissions") +async def assign_permission_to_role( + role_id: int, + permission_id: int = Query(..., description="Permission ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Asigna un permiso a un rol. + """ + from .models import RolePermission + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verificar que el rol existe + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + # Verificar que el permiso existe + permission = db.query(Permission).filter(Permission.id == permission_id).first() + + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Permission not found" + ) + + # Verificar si ya existe + existing = ( + db.query(RolePermission) + .filter( + RolePermission.company_role_id == role_id, + RolePermission.permission_id == permission_id + ) + .first() + ) + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Permission already assigned to this role" + ) + + # Crear la asignación + role_permission = RolePermission( + company_role_id=role_id, + permission_id=permission_id, + tenant_id=tenant_id, + company_id=company_id + ) + + db.add(role_permission) + db.commit() + db.refresh(role_permission) + + return {"success": True, "message": "Permission assigned to role"} + + +@router.post("/roles/{role_id}/permissions/batch") +async def assign_multiple_permissions_to_role( + role_id: int, + request: AssignPermissionsToRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Asigna múltiples permisos a un rol. + """ + from .models import RolePermission + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verificar que el rol existe + role = ( + db.query(CompanyRole) + .filter(CompanyRole.id == role_id, CompanyRole.company_id == company_id) + .first() + ) + + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) + + added_count = 0 + + for permission_id in request.permission_ids: + # Verificar que el permiso existe + permission = db.query(Permission).filter(Permission.id == permission_id).first() + + if not permission: + continue # Saltear permisos que no existen + + # Verificar si ya existe + existing = ( + db.query(RolePermission) + .filter( + RolePermission.company_role_id == role_id, + RolePermission.permission_id == permission_id + ) + .first() + ) + + if existing: + continue # Saltear si ya existe + + # Crear la asignación + role_permission = RolePermission( + company_role_id=role_id, + permission_id=permission_id, + tenant_id=tenant_id, + company_id=company_id + ) + + db.add(role_permission) + added_count += 1 + + db.commit() + + return { + "success": True, + "message": f"{added_count} permissions assigned to role", + "added_count": added_count + } + + +@router.delete("/roles/{role_id}/permissions/{permission_id}") +async def remove_permission_from_role( + role_id: int, + permission_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Remueve un permiso de un rol. + """ + from .models import RolePermission + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Buscar la asignación + role_permission = ( + db.query(RolePermission) + .filter( + RolePermission.company_role_id == role_id, + RolePermission.permission_id == permission_id + ) + .first() + ) + + if not role_permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Permission assignment not found" + ) + + db.delete(role_permission) + db.commit() + + return {"success": True, "message": "Permission removed from role"} + + +# ============================================================================ +# RUTAS DE ASIGNACIÓN DE ROLES Y PERMISOS +# ============================================================================ + + +@router.post( + "/assign-role", response_model=SuccessResponse, status_code=status.HTTP_201_CREATED +) +async def assign_role( + request: AssignRoleRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), +): + """ + Asigna un rol a un usuario en el companye actual. + TODO: Agregar verificación de permisos + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + assigner_id = current_user.get("sub") or current_user.get("id") + + try: + user_role = permission_service.assign_role_to_user( + user_id=request.user_id, + company_id=company_id, + role_id=request.role_id, + assigned_by=assigner_id, + ) + + return SuccessResponse( + message=f"Role assigned to user {request.user_id}", + data={"assignment_id": user_role.id}, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@router.post( + "/grant-permission", + response_model=SuccessResponse, + status_code=status.HTTP_201_CREATED, +) +async def grant_permission( + request: GrantPermissionRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), + permission_service: PermissionService = Depends(get_permission_service), + _: None = Depends(RequirePermission("permissions.grant")), +): + """ + Concede un permiso directo a un usuario en el companye actual. + Requiere permiso: permissions.grant + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + assigner_id = current_user.get("sub") or current_user.get("id") + + try: + user_permission = permission_service.grant_direct_permission( + user_id=request.user_id, + company_id=company_id, + permission_code=request.permission_code, + assigned_by=assigner_id, + expires_at=request.expires_at, + ) + + return SuccessResponse( + message=f"Permission {request.permission_code} granted to user {request.user_id}", + data={ + "permission_id": user_permission.id, + "expires_at": ( + user_permission.expires_at.isoformat() + if user_permission.expires_at + else None + ), + }, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +# ============================================================================ +# EJEMPLOS DE RUTAS PROTEGIDAS CON PERMISOS +# ============================================================================ + + +@router.get("/examples/invoices") +async def example_view_invoices(_: None = Depends(RequirePermission("invoice.view"))): + """ + Endpoint de ejemplo que requiere el permiso invoice.view + """ + return { + "message": "You have permission to view invoices", + "invoices": [ + {"id": 1, "number": "INV-001", "amount": 1000}, + {"id": 2, "number": "INV-002", "amount": 2000}, + ], + } + + +@router.post("/examples/invoices") +async def example_edit_invoice( + _: None = Depends( + PermissionChecker(["invoice.view", "invoice.edit"], require_all=True) + ) +): + """ + Endpoint de ejemplo que requiere AMBOS permisos: invoice.view e invoice.edit + """ + return { + "message": "Invoice updated successfully", + "invoice": {"id": 1, "number": "INV-001", "amount": 1500}, + } + + +@router.get("/examples/reports") +async def example_view_reports( + _: None = Depends( + PermissionChecker( + ["report.financial.view", "report.admin.view"], require_all=False + ) + ) +): + """ + Endpoint de ejemplo que requiere AL MENOS UNO de los permisos especificados. + """ + return { + "message": "Financial reports", + "reports": ["Monthly P&L", "Cash Flow", "Balance Sheet"], + } + + +@router.get("/examples/dashboard") +async def example_dashboard( + user_permissions: set = Depends(get_current_user_permissions), +): + """ + Dashboard dinámico que muestra diferentes widgets según los permisos del usuario. + """ + widgets = [] + + if "invoice.view" in user_permissions: + widgets.append( + { + "type": "invoices", + "title": "Recent Invoices", + "data": [{"id": 1, "number": "INV-001"}], + } + ) + + if "report.financial.view" in user_permissions: + widgets.append( + { + "type": "financial", + "title": "Financial Summary", + "data": {"revenue": 50000, "expenses": 30000}, + } + ) + + if "user.view" in user_permissions: + widgets.append( + {"type": "users", "title": "User Activity", "data": {"active_users": 42}} + ) + + return {"permissions": list(user_permissions), "widgets": widgets} + + +# ============================================================================ +# RUTAS DE GESTIÓN DE PERMISOS INDIVIDUALES DE USUARIO +# ============================================================================ + + +@router.get("/users/{user_id}/permissions") +async def get_user_individual_permissions( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> UserPermissionsListResponse: + """ + Obtiene los permisos individuales asignados a un usuario específico + (no incluye los permisos heredados de roles). + """ + from .models import UserCompanyPermission + from sqlalchemy.orm import joinedload + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + user_permissions = ( + db.query(UserCompanyPermission) + .options(joinedload(UserCompanyPermission.permission)) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.is_active == True, + ) + .all() + ) + + return UserPermissionsListResponse( + items=[UserPermissionResponse.model_validate(up) for up in user_permissions], + total=len(user_permissions), + ) + + +@router.get("/users/{user_id}/permissions/effective") +async def get_user_effective_permissions( + user_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> EffectiveUserPermissionsResponse: + """ + Obtiene los permisos efectivos de un usuario: + - Permisos de roles + - Permisos individuales concedidos + - Permisos revocados + - Permisos finales (roles + concedidos - revocados) + """ + from .models import UserCompanyPermission, UserCompanyRole, RolePermission + from sqlalchemy.orm import joinedload + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # 1. Obtener permisos de roles + role_permissions_query = ( + db.query(Permission) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(CompanyRole, CompanyRole.id == RolePermission.company_role_id) + .join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id) + .filter( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.tenant_id == tenant_id, + UserCompanyRole.is_active == True, + CompanyRole.is_active == True, + Permission.is_active == True, + ) + .distinct() + .all() + ) + + # 2. Obtener permisos individuales + individual_permissions = ( + db.query(UserCompanyPermission) + .options(joinedload(UserCompanyPermission.permission)) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.is_active == True, + ) + .all() + ) + + granted_permissions = [ + up.permission for up in individual_permissions + if up.is_granted and up.permission and up.permission.is_active + ] + + revoked_permissions = [ + up.permission for up in individual_permissions + if not up.is_granted and up.permission and up.permission.is_active + ] + + # 3. Calcular permisos efectivos + revoked_ids = {p.id for p in revoked_permissions} + role_perms_filtered = [p for p in role_permissions_query if p.id not in revoked_ids] + + # Combinar y eliminar duplicados + all_perms = {} + for p in role_perms_filtered: + all_perms[p.id] = p + for p in granted_permissions: + all_perms[p.id] = p + + effective_permissions = list(all_perms.values()) + + return EffectiveUserPermissionsResponse( + user_id=user_id, + company_id=company_id, + role_permissions=[PermissionResponse.model_validate(p) for p in role_permissions_query], + granted_permissions=[PermissionResponse.model_validate(p) for p in granted_permissions], + revoked_permissions=[PermissionResponse.model_validate(p) for p in revoked_permissions], + effective_permissions=[PermissionResponse.model_validate(p) for p in effective_permissions], + ) + + +@router.post("/users/{user_id}/permissions", status_code=status.HTTP_201_CREATED) +async def assign_user_permission( + user_id: str, + request: AssignUserPermissionRequest, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> UserPermissionResponse: + """ + Asigna un permiso individual a un usuario. + - is_granted=True: Concede el permiso (útil para permisos extra) + - is_granted=False: Revoca el permiso (útil para quitar permisos del rol) + """ + from .models import UserCompanyPermission + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Verificar que el permiso existe + permission = db.query(Permission).filter(Permission.id == request.permission_id).first() + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Permission not found" + ) + + # Verificar si ya existe + existing = ( + db.query(UserCompanyPermission) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.permission_id == request.permission_id, + ) + .first() + ) + + if existing: + # Actualizar existente + existing.is_granted = request.is_granted + existing.is_active = True + existing.assigned_by = current_user.get("sub") + existing.expires_at = request.expires_at + db.commit() + db.refresh(existing) + return UserPermissionResponse.model_validate(existing) + + # Crear nuevo + user_permission = UserCompanyPermission( + user_id=user_id, + company_id=company_id, + tenant_id=tenant_id, + permission_id=request.permission_id, + is_granted=request.is_granted, + assigned_by=current_user.get("sub"), + expires_at=request.expires_at, + ) + + db.add(user_permission) + db.commit() + db.refresh(user_permission) + + return UserPermissionResponse.model_validate(user_permission) + + +@router.delete("/users/{user_id}/permissions/{permission_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user_permission( + user_id: str, + permission_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Elimina un permiso individual de un usuario. + """ + from .models import UserCompanyPermission + + tenant_id = validate_access_to_resource(db, company_id, current_user) + + user_permission = ( + db.query(UserCompanyPermission) + .filter( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.tenant_id == tenant_id, + UserCompanyPermission.permission_id == permission_id, + ) + .first() + ) + + if not user_permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User permission not found" + ) + + db.delete(user_permission) + db.commit() diff --git a/backend/api/v1/modules/core/permissions/schemas.py b/backend/api/v1/modules/core/permissions/schemas.py new file mode 100644 index 00000000..c3c03b36 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/schemas.py @@ -0,0 +1,320 @@ +""" +Esquemas Pydantic para el módulo de permisos. +Define los modelos de request/response para las APIs de permisos. +""" + +from typing import List, Optional +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict + + +# ============================================================================ +# SCHEMAS DE RESPONSE +# ============================================================================ + + +class PermissionResponse(BaseModel): + """Esquema de respuesta para un permiso individual.""" + + id: int + code: str = Field(..., description="Código único del permiso (ej: 'invoice.view')") + description: Optional[str] = Field(None, description="Descripción del permiso") + module: str = Field(..., description="Módulo al que pertenece (ej: 'invoice')") + action: str = Field(..., description="Acción específica (ej: 'view', 'edit')") + is_active: bool = Field(..., description="Si el permiso está activo") + + model_config = ConfigDict(from_attributes=True) + + +class CompanyRoleResponse(BaseModel): + """Esquema de respuesta para un rol de companye.""" + + id: int + company_id: int = Field(..., description="ID del companye al que pertenece el rol") + name: str = Field(..., description="Nombre del rol (ej: 'Administrador')") + code: str = Field(..., description="Código del rol (ej: 'admin')") + description: Optional[str] = Field(None, description="Descripción del rol") + is_active: bool = Field(..., description="Si el rol está activo") + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CompanyRoleWithPermissionsResponse(CompanyRoleResponse): + """Esquema de respuesta para un rol con sus permisos incluidos.""" + + permissions: List[PermissionResponse] = Field( + default_factory=list, description="Lista de permisos asignados a este rol" + ) + + +class UserPermissionsResponse(BaseModel): + """Esquema de respuesta para los permisos de un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + company_id: int = Field(..., description="ID del companye") + permissions: List[str] = Field( + default_factory=list, description="Lista de códigos de permisos del usuario" + ) + roles: List[str] = Field( + default_factory=list, description="Lista de nombres de roles del usuario" + ) + + +class UserCompanyRoleResponse(BaseModel): + """Esquema de respuesta para la asignación de rol a usuario.""" + + id: int + user_id: str + company_id: int + company_role_id: int + is_active: bool + created_at: datetime + assigned_by: Optional[str] = None + company_role: Optional[CompanyRoleResponse] = Field(None, description="Información del rol asignado") + + model_config = ConfigDict(from_attributes=True) + + +class UserCompanyPermissionResponse(BaseModel): + """Esquema de respuesta para un permiso directo de usuario.""" + + id: int + user_id: str + company_id: int + permission_id: int + permission_code: Optional[str] = None + is_granted: bool = Field( + ..., description="True si está concedido, False si está revocado" + ) + is_active: bool + created_at: datetime + assigned_by: Optional[str] = None + expires_at: Optional[datetime] = Field( + None, description="Fecha de expiración del permiso" + ) + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================ +# SCHEMAS DE REQUEST +# ============================================================================ + + +class AssignRoleRequest(BaseModel): + """Esquema de request para asignar un rol a un usuario.""" + + user_id: str = Field(..., description="ID del usuario al que se asignará el rol") + role_id: int = Field(..., description="ID del rol a asignar") + + +class RemoveRoleRequest(BaseModel): + """Esquema de request para remover un rol de un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + role_id: int = Field(..., description="ID del rol a remover") + + +class GrantPermissionRequest(BaseModel): + """Esquema de request para conceder un permiso directo a un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + permission_code: str = Field( + ..., description="Código del permiso a conceder (ej: 'invoice.delete')" + ) + expires_at: Optional[datetime] = Field( + None, description="Fecha de expiración del permiso (opcional)" + ) + + +class RevokePermissionRequest(BaseModel): + """Esquema de request para revocar un permiso directo.""" + + user_id: str = Field(..., description="ID del usuario") + permission_code: str = Field(..., description="Código del permiso a revocar") + + +class CreateRoleRequest(BaseModel): + """Esquema de request para crear un rol personalizado.""" + + name: str = Field(..., min_length=1, max_length=100, description="Nombre del rol") + code: str = Field( + ..., + min_length=1, + max_length=100, + description="Código único del rol (ej: 'custom_admin')", + ) + description: Optional[str] = Field( + None, max_length=255, description="Descripción del rol" + ) + permission_ids: List[int] = Field( + default_factory=list, description="IDs de permisos a asignar al rol" + ) + + +class UpdateRoleRequest(BaseModel): + """Esquema de request para actualizar un rol existente.""" + + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=255) + is_active: Optional[bool] = None + + +class AssignPermissionsToRoleRequest(BaseModel): + """Esquema de request para asignar permisos a un rol.""" + + permission_ids: List[int] = Field( + ..., description="Lista de IDs de permisos a asignar al rol" + ) + replace_existing: bool = Field( + False, + description="Si True, reemplaza los permisos existentes. Si False, los agrega.", + ) + + +class CreatePermissionRequest(BaseModel): + """Esquema de request para crear un nuevo permiso (uso administrativo).""" + + code: str = Field( + ..., + min_length=1, + max_length=100, + description="Código único del permiso (ej: 'custom_module.action')", + ) + description: Optional[str] = Field(None, max_length=255) + module: str = Field( + ..., min_length=1, max_length=50, description="Módulo del permiso" + ) + action: str = Field( + ..., min_length=1, max_length=50, description="Acción del permiso" + ) + + +# ============================================================================ +# SCHEMAS DE RESPUESTA GENÉRICOS +# ============================================================================ + + +class SuccessResponse(BaseModel): + """Respuesta genérica de éxito.""" + + success: bool = True + message: str = Field(..., description="Mensaje descriptivo de la operación") + data: Optional[dict] = Field(None, description="Datos adicionales opcionales") + + +class ErrorResponse(BaseModel): + """Respuesta genérica de error.""" + + success: bool = False + detail: str = Field(..., description="Descripción del error") + error_code: Optional[str] = Field(None, description="Código de error específico") + + +# ============================================================================ +# SCHEMAS DE PAGINACIÓN +# ============================================================================ + + +class PaginatedResponse(BaseModel): + """Esquema genérico para respuestas paginadas.""" + + items: List[dict] = Field(default_factory=list) + total: int = Field(..., description="Total de items disponibles") + page: int = Field(..., description="Página actual") + page_size: int = Field(..., description="Tamaño de página") + total_pages: int = Field(..., description="Total de páginas disponibles") + + +class PermissionListResponse(BaseModel): + """Lista paginada de permisos.""" + + items: List[PermissionResponse] + total: int + page: int = 1 + page_size: int = 100 + + +class RoleListResponse(BaseModel): + """Lista paginada de roles.""" + + items: List[CompanyRoleResponse] + total: int + page: int = 1 + page_size: int = 100 + + +class UserRoleListResponse(BaseModel): + """Lista paginada de asignaciones de roles a usuarios.""" + + items: List[UserCompanyRoleResponse] + total: int + page: int = 1 + page_size: int = 100 + + +class AssignUserRoleRequest(BaseModel): + """Esquema de request para asignar un rol a un usuario.""" + + user_id: str = Field(..., description="ID del usuario") + company_role_id: int = Field(..., description="ID del rol a asignar") + + +# ============================================================================ +# SCHEMAS PARA PERMISOS INDIVIDUALES DE USUARIO +# ============================================================================ + + +class UserPermissionResponse(BaseModel): + """Esquema de respuesta para un permiso individual de usuario.""" + + id: int + user_id: str + permission_id: int + company_id: int + tenant_id: int + is_granted: bool = Field(..., description="True = permiso concedido, False = permiso revocado") + is_active: bool + assigned_by: Optional[str] = None + expires_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + permission: Optional[PermissionResponse] = None + + model_config = ConfigDict(from_attributes=True) + + +class AssignUserPermissionRequest(BaseModel): + """Esquema de request para asignar un permiso individual a un usuario.""" + + permission_id: int = Field(..., description="ID del permiso") + is_granted: bool = Field(True, description="True para conceder, False para revocar") + expires_at: Optional[datetime] = Field(None, description="Fecha de expiración (opcional)") + + +class UserPermissionsListResponse(BaseModel): + """Lista de permisos individuales de un usuario.""" + + items: List[UserPermissionResponse] + total: int + + +class EffectiveUserPermissionsResponse(BaseModel): + """Permisos efectivos de un usuario (roles + individuales - revocados).""" + + user_id: str + company_id: int + role_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos heredados de roles" + ) + granted_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos individuales concedidos" + ) + revoked_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos revocados explícitamente" + ) + effective_permissions: List[PermissionResponse] = Field( + default_factory=list, description="Permisos finales efectivos" + ) diff --git a/backend/api/v1/modules/core/permissions/seed.py b/backend/api/v1/modules/core/permissions/seed.py new file mode 100644 index 00000000..2f393dbc --- /dev/null +++ b/backend/api/v1/modules/core/permissions/seed.py @@ -0,0 +1,63 @@ +seed_invoices = [ + ("invoice.imp.tem.view", "Ver facturas temporales de importación", "invoice", "view"), + ("invoice.imp.tem.create", "Crear facturas temporales de importación", "invoice", "create"), + ("invoice.imp.tem.edit", "Editar facturas", "invoice", "edit"), + ("invoice.imp.tem.delete", "Eliminar facturas", "invoice", "delete"), + ("invoice.imp.tem.process", "Procesar facturas", "invoice", "process"), + + ("invoice.imp.def.view", "Ver facturas definitivas de importación", "invoice", "view"), + ("invoice.imp.def.create", "Crear facturas definitivas de importación", "invoice", "create"), + ("invoice.imp.def.edit", "Editar facturas", "invoice", "edit"), + ("invoice.imp.def.delete", "Eliminar facturas", "invoice", "delete"), + ("invoice.imp.def.process", "Procesar facturas", "invoice", "process"), + + ("invoice.imp.cm.view", "Ver facturas temporales de importación", "invoice", "view"), + ("invoice.imp.cm.create", "Crear facturas temporales de importación", "invoice", "create"), + ("invoice.imp.cm.edit", "Editar facturas", "invoice", "edit"), + ("invoice.imp.cm.delete", "Eliminar facturas", "invoice", "delete"), + ("invoice.imp.cm.process", "Procesar facturas", "invoice", "process"), + + ("invoice.imp.cr.view", "Ver facturas temporales de importación", "invoice", "view"), + ("invoice.imp.cr.create", "Crear facturas temporales de importación", "invoice", "create"), + ("invoice.imp.cr.edit", "Editar facturas", "invoice", "edit"), + ("invoice.imp.cr.delete", "Eliminar facturas", "invoice", "delete"), + ("invoice.imp.cr.process", "Procesar facturas", "invoice", "process"), + + ("invoice.exp.view", "Ver facturas temporales de importación", "invoice", "view"), + ("invoice.exp.create", "Crear facturas temporales de importación", "invoice", "create"), + ("invoice.exp.edit", "Editar facturas", "invoice", "edit"), + ("invoice.exp.delete", "Eliminar facturas", "invoice", "delete"), + ("invoice.exp.process", "Procesar facturas", "invoice", "process"), + + ("invoice.exp.rep.view", "Ver facturas temporales de importación", "invoice", "view"), + ("invoice.exp.rep.create", "Crear facturas temporales de importación", "invoice", "create"), + ("invoice.exp.rep.edit", "Editar facturas", "invoice", "edit"), + ("invoice.exp.rep.delete", "Eliminar facturas", "invoice", "delete"), + ("invoice.exp.rep.process", "Procesar facturas", "invoice", "process"), + + +] + +seed_user = [ + ("user.view", "Ver facturas temporales de importación", "invoice", "view"), + ("user.create", "Crear facturas temporales de importación", "invoice", "create"), + ("user.edit", "Editar facturas", "invoice", "edit"), + ("user.delete", "Eliminar facturas", "invoice", "delete"), + ("user.process", "Procesar facturas", "invoice", "process"), +] + +seed_report = [ + ("report.view", "Ver facturas temporales de importación", "invoice", "view"), + ("report.create", "Crear facturas temporales de importación", "invoice", "create"), + ("report.edit", "Editar facturas", "invoice", "edit"), + ("report.delete", "Eliminar facturas", "invoice", "delete"), + ("report.process", "Procesar facturas", "invoice", "process"), +] + +seed_roles = [ + ("roles.view", "Ver facturas temporales de importación", "invoice", "view"), + ("roles.create", "Crear facturas temporales de importación", "invoice", "create"), + ("roles.edit", "Editar facturas", "invoice", "edit"), + ("roles.delete", "Eliminar facturas", "invoice", "delete"), + ("roles.process", "Procesar facturas", "invoice", "process"), +] \ No newline at end of file diff --git a/backend/api/v1/modules/core/permissions/service.py b/backend/api/v1/modules/core/permissions/service.py new file mode 100644 index 00000000..f82b906f --- /dev/null +++ b/backend/api/v1/modules/core/permissions/service.py @@ -0,0 +1,298 @@ +""" +Servicio de gestión de permisos multi-tenant. +Proporciona funciones para verificar y obtener permisos de usuarios por companye. +""" + +from datetime import datetime +from typing import Set, Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ +from .models import ( + Permission, + CompanyRole, + RolePermission, + UserCompanyRole, + UserCompanyPermission, +) + + +class PermissionService: + """ + Servicio para gestionar permisos de usuarios en contextos multi-tenant. + Combina permisos de roles y permisos directos del usuario. + """ + + def __init__(self, db: Session): + self.db = db + + def get_user_permissions( + self, user_id: str, company_id: int, use_cache: bool = False + ) -> Set[str]: + """ + Obtiene todos los permisos de un usuario para un companye específico. + Combina permisos de roles asignados y permisos directos. + + Args: + user_id: ID del usuario + company_id: ID del companye/tenant + use_cache: Si True, intenta usar caché (útil en producción) + + Returns: + Set de códigos de permisos (ej: {"invoice.view", "invoice.edit"}) + """ + # Obtener permisos de roles asignados al usuario + role_permissions = self._get_permissions_from_roles(user_id, company_id) + + # Obtener permisos directos del usuario + direct_permissions = self._get_direct_permissions(user_id, company_id) + + # Combinar ambos conjuntos + # Los permisos directos revocados (is_granted=False) anulan los de roles + all_permissions = role_permissions.copy() + + for perm_code, is_granted in direct_permissions.items(): + if is_granted: + all_permissions.add(perm_code) + else: + # Permiso explícitamente revocado + all_permissions.discard(perm_code) + + return all_permissions + + def _get_permissions_from_roles(self, user_id: str, company_id: int) -> Set[str]: + """ + Obtiene permisos derivados de los roles del usuario en el companye. + + Realiza un JOIN eficiente para obtener todos los permisos de los roles activos. + """ + query = ( + self.db.query(Permission.code) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .join(CompanyRole, CompanyRole.id == RolePermission.company_role_id) + .join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id) + .filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, + CompanyRole.is_active == True, + Permission.is_active == True, + ) + ) + ) + + results = query.all() + return {perm_code for (perm_code,) in results} + + def _get_direct_permissions(self, user_id: str, company_id: int) -> dict: + """ + Obtiene permisos directos asignados al usuario. + + Returns: + Dict con código de permiso como key y is_granted como value + { + "invoice.delete": True, # Permiso concedido + "user.delete": False # Permiso revocado explícitamente + } + """ + now = datetime.utcnow() + + query = ( + self.db.query(Permission.code, UserCompanyPermission.is_granted) + .join( + UserCompanyPermission, + UserCompanyPermission.permission_id == Permission.id, + ) + .filter( + and_( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.is_active == True, + Permission.is_active == True, + or_( + UserCompanyPermission.expires_at.is_(None), + UserCompanyPermission.expires_at > now, + ), + ) + ) + ) + + results = query.all() + return {perm_code: is_granted for perm_code, is_granted in results} + + def has_permission( + self, user_id: str, company_id: int, permission_code: str + ) -> bool: + """ + Verifica si un usuario tiene un permiso específico en un companye. + + Args: + user_id: ID del usuario + company_id: ID del companye/tenant + permission_code: Código del permiso (ej: "invoice.edit") + + Returns: + True si el usuario tiene el permiso, False en caso contrario + """ + permissions = self.get_user_permissions(user_id, company_id) + return permission_code in permissions + + def has_any_permission( + self, user_id: str, company_id: int, permission_codes: List[str] + ) -> bool: + """ + Verifica si el usuario tiene al menos uno de los permisos especificados. + """ + permissions = self.get_user_permissions(user_id, company_id) + return any(perm in permissions for perm in permission_codes) + + def has_all_permissions( + self, user_id: str, company_id: int, permission_codes: List[str] + ) -> bool: + """ + Verifica si el usuario tiene todos los permisos especificados. + """ + permissions = self.get_user_permissions(user_id, company_id) + return all(perm in permissions for perm in permission_codes) + + def get_user_roles(self, user_id: str, company_id: int) -> List[CompanyRole]: + """ + Obtiene los roles activos de un usuario en un companye. + """ + query = ( + self.db.query(CompanyRole) + .join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id) + .filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.is_active == True, + CompanyRole.is_active == True, + ) + ) + ) + + return query.all() + + def assign_role_to_user( + self, + user_id: str, + company_id: int, + role_id: int, + assigned_by: Optional[str] = None, + ) -> UserCompanyRole: + """ + Asigna un rol a un usuario en un companye específico. + """ + # Verificar que el rol pertenece al companye + role = ( + self.db.query(CompanyRole) + .filter( + and_( + CompanyRole.id == role_id, + CompanyRole.company_id == company_id, + CompanyRole.is_active == True, + ) + ) + .first() + ) + + if not role: + raise ValueError(f"Role {role_id} not found for company {company_id}") + + # Verificar si ya existe la asignación + existing = ( + self.db.query(UserCompanyRole) + .filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == company_id, + UserCompanyRole.company_role_id == role_id, + ) + ) + .first() + ) + + if existing: + if not existing.is_active: + existing.is_active = True + existing.assigned_at = datetime.utcnow() + existing.assigned_by = assigned_by + self.db.commit() + return existing + return existing + + # Crear nueva asignación + user_role = UserCompanyRole( + user_id=user_id, + company_id=company_id, + company_role_id=role_id, + assigned_by=assigned_by, + ) + + self.db.add(user_role) + self.db.commit() + self.db.refresh(user_role) + + return user_role + + def grant_direct_permission( + self, + user_id: str, + company_id: int, + permission_code: str, + assigned_by: Optional[str] = None, + expires_at: Optional[datetime] = None, + ) -> UserCompanyPermission: + """ + Concede un permiso directo a un usuario en un companye. + """ + # Obtener el permiso por código + permission = ( + self.db.query(Permission) + .filter( + and_(Permission.code == permission_code, Permission.is_active == True) + ) + .first() + ) + + if not permission: + raise ValueError(f"Permission {permission_code} not found") + + # Verificar si ya existe + existing = ( + self.db.query(UserCompanyPermission) + .filter( + and_( + UserCompanyPermission.user_id == user_id, + UserCompanyPermission.company_id == company_id, + UserCompanyPermission.permission_id == permission.id, + ) + ) + .first() + ) + + if existing: + existing.is_granted = True + existing.is_active = True + existing.assigned_at = datetime.utcnow() + existing.assigned_by = assigned_by + existing.expires_at = expires_at + self.db.commit() + return existing + + # Crear nuevo permiso directo + user_permission = UserCompanyPermission( + user_id=user_id, + company_id=company_id, + permission_id=permission.id, + is_granted=True, + assigned_by=assigned_by, + expires_at=expires_at, + ) + + self.db.add(user_permission) + self.db.commit() + self.db.refresh(user_permission) + + return user_permission diff --git a/backend/api/v1/modules/core/permissions/test_permissions.py b/backend/api/v1/modules/core/permissions/test_permissions.py new file mode 100644 index 00000000..183e0dd2 --- /dev/null +++ b/backend/api/v1/modules/core/permissions/test_permissions.py @@ -0,0 +1,424 @@ +""" +Tests unitarios para el módulo de permisos multi-tenant. +Ejecutar con: pytest backend/api/v1/modules/core/permissions/test_permissions.py -v +""" + +import pytest +from datetime import datetime, timedelta +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from core.database import Base +from api.v1.modules.core.permissions.models import ( + Permission, + ClientRole, + RolePermission, + UserClientRole, + UserClientPermission, +) +from api.v1.modules.core.permissions.service import PermissionService + + +# ============================================================================ +# FIXTURES +# ============================================================================ + + +@pytest.fixture +def db_session(): + """Crea una sesión de base de datos en memoria para testing.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + + yield session + + session.close() + + +@pytest.fixture +def sample_permissions(db_session: Session): + """Crea permisos de ejemplo.""" + permissions = [ + Permission( + code="invoice.view", + description="Ver facturas", + module="invoice", + action="view", + ), + Permission( + code="invoice.create", + description="Crear facturas", + module="invoice", + action="create", + ), + Permission( + code="invoice.edit", + description="Editar facturas", + module="invoice", + action="edit", + ), + Permission( + code="invoice.delete", + description="Eliminar facturas", + module="invoice", + action="delete", + ), + Permission( + code="user.view", description="Ver usuarios", module="user", action="view" + ), + ] + + for perm in permissions: + db_session.add(perm) + + db_session.commit() + return permissions + + +@pytest.fixture +def sample_role(db_session: Session, sample_permissions): + """Crea un rol de ejemplo con permisos.""" + role = ClientRole( + client_id=1, name="Administrador", code="admin", description="Acceso completo" + ) + db_session.add(role) + db_session.commit() + + # Asignar permisos al rol + for perm in sample_permissions[:3]: # invoice.view, create, edit + role_perm = RolePermission(client_role_id=role.id, permission_id=perm.id) + db_session.add(role_perm) + + db_session.commit() + return role + + +# ============================================================================ +# TESTS DE PermissionService +# ============================================================================ + + +class TestPermissionService: + """Tests para el servicio de permisos.""" + + def test_get_permissions_from_role(self, db_session, sample_role): + """Test: obtener permisos desde roles asignados.""" + # Asignar rol a usuario + user_role = UserClientRole( + user_id="user-123", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + # Verificar permisos + service = PermissionService(db_session) + permissions = service.get_user_permissions("user-123", 1) + + assert "invoice.view" in permissions + assert "invoice.create" in permissions + assert "invoice.edit" in permissions + assert "invoice.delete" not in permissions # No está en el rol + + def test_has_permission_single(self, db_session, sample_role): + """Test: verificar un permiso específico.""" + user_role = UserClientRole( + user_id="user-123", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + service = PermissionService(db_session) + + assert service.has_permission("user-123", 1, "invoice.view") is True + assert service.has_permission("user-123", 1, "invoice.delete") is False + + def test_has_all_permissions(self, db_session, sample_role): + """Test: verificar múltiples permisos (todos requeridos).""" + user_role = UserClientRole( + user_id="user-123", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + service = PermissionService(db_session) + + # Tiene todos + assert ( + service.has_all_permissions( + "user-123", 1, ["invoice.view", "invoice.create"] + ) + is True + ) + + # Falta uno + assert ( + service.has_all_permissions( + "user-123", 1, ["invoice.view", "invoice.delete"] + ) + is False + ) + + def test_has_any_permission(self, db_session, sample_role): + """Test: verificar múltiples permisos (al menos uno).""" + user_role = UserClientRole( + user_id="user-123", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + service = PermissionService(db_session) + + # Tiene al menos uno + assert ( + service.has_any_permission( + "user-123", 1, ["invoice.view", "invoice.delete"] + ) + is True + ) + + # No tiene ninguno + assert ( + service.has_any_permission("user-123", 1, ["invoice.delete", "user.view"]) + is False + ) + + def test_direct_permission_grant(self, db_session, sample_permissions): + """Test: conceder permiso directo a usuario.""" + service = PermissionService(db_session) + + # Conceder permiso directo + user_perm = service.grant_direct_permission( + user_id="user-123", client_id=1, permission_code="invoice.delete" + ) + + assert user_perm.is_granted is True + assert user_perm.is_active is True + + # Verificar que el usuario tiene el permiso + permissions = service.get_user_permissions("user-123", 1) + assert "invoice.delete" in permissions + + def test_direct_permission_revoke( + self, db_session, sample_role, sample_permissions + ): + """Test: revocar permiso que viene de un rol.""" + # Asignar rol con permisos + user_role = UserClientRole( + user_id="user-123", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + service = PermissionService(db_session) + + # Verificar que tiene el permiso del rol + assert service.has_permission("user-123", 1, "invoice.view") is True + + # Revocar explícitamente + invoice_view = ( + db_session.query(Permission) + .filter(Permission.code == "invoice.view") + .first() + ) + + user_perm = UserClientPermission( + user_id="user-123", + client_id=1, + permission_id=invoice_view.id, + is_granted=False, # Revocado + ) + db_session.add(user_perm) + db_session.commit() + + # Ahora NO debe tener el permiso + assert service.has_permission("user-123", 1, "invoice.view") is False + + def test_permission_expiration(self, db_session, sample_permissions): + """Test: permisos con fecha de expiración.""" + service = PermissionService(db_session) + + # Permiso expirado + expired_perm = service.grant_direct_permission( + user_id="user-123", + client_id=1, + permission_code="invoice.delete", + expires_at=datetime.utcnow() - timedelta(days=1), # Ayer + ) + + # No debe aparecer en los permisos + permissions = service.get_user_permissions("user-123", 1) + assert "invoice.delete" not in permissions + + # Permiso futuro + future_perm = service.grant_direct_permission( + user_id="user-456", + client_id=1, + permission_code="invoice.delete", + expires_at=datetime.utcnow() + timedelta(days=7), # En 7 días + ) + + # Debe aparecer + permissions = service.get_user_permissions("user-456", 1) + assert "invoice.delete" in permissions + + def test_assign_role_to_user(self, db_session, sample_role): + """Test: asignar un rol a un usuario.""" + service = PermissionService(db_session) + + user_role = service.assign_role_to_user( + user_id="user-789", + client_id=1, + role_id=sample_role.id, + assigned_by="admin-001", + ) + + assert user_role.user_id == "user-789" + assert user_role.client_id == 1 + assert user_role.client_role_id == sample_role.id + assert user_role.assigned_by == "admin-001" + assert user_role.is_active is True + + def test_assign_role_invalid_client(self, db_session, sample_role): + """Test: intentar asignar rol de otro cliente falla.""" + service = PermissionService(db_session) + + with pytest.raises(ValueError, match="not found for client"): + service.assign_role_to_user( + user_id="user-789", + client_id=999, # Cliente incorrecto + role_id=sample_role.id, + ) + + def test_multiple_roles_same_user(self, db_session, sample_permissions): + """Test: usuario con múltiples roles combina permisos.""" + # Crear dos roles diferentes + role1 = ClientRole(client_id=1, code="viewer", name="Viewer") + role2 = ClientRole(client_id=1, code="editor", name="Editor") + db_session.add_all([role1, role2]) + db_session.commit() + + # Role1: solo view + rp1 = RolePermission( + client_role_id=role1.id, + permission_id=sample_permissions[0].id, # invoice.view + ) + + # Role2: create y edit + rp2 = RolePermission( + client_role_id=role2.id, + permission_id=sample_permissions[1].id, # invoice.create + ) + rp3 = RolePermission( + client_role_id=role2.id, + permission_id=sample_permissions[2].id, # invoice.edit + ) + db_session.add_all([rp1, rp2, rp3]) + db_session.commit() + + # Asignar ambos roles al usuario + ur1 = UserClientRole(user_id="user-multi", client_id=1, client_role_id=role1.id) + ur2 = UserClientRole(user_id="user-multi", client_id=1, client_role_id=role2.id) + db_session.add_all([ur1, ur2]) + db_session.commit() + + # Verificar que tiene permisos de ambos roles + service = PermissionService(db_session) + permissions = service.get_user_permissions("user-multi", 1) + + assert "invoice.view" in permissions + assert "invoice.create" in permissions + assert "invoice.edit" in permissions + assert "invoice.delete" not in permissions + + def test_inactive_role_no_permissions(self, db_session, sample_role): + """Test: roles inactivos no otorgan permisos.""" + # Asignar rol activo + user_role = UserClientRole( + user_id="user-inactive", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + service = PermissionService(db_session) + + # Verificar que tiene permisos + assert service.has_permission("user-inactive", 1, "invoice.view") is True + + # Desactivar el rol + sample_role.is_active = False + db_session.commit() + + # Ahora no debe tener permisos + assert service.has_permission("user-inactive", 1, "invoice.view") is False + + def test_get_user_roles(self, db_session, sample_role): + """Test: obtener roles de un usuario.""" + user_role = UserClientRole( + user_id="user-roles", client_id=1, client_role_id=sample_role.id + ) + db_session.add(user_role) + db_session.commit() + + service = PermissionService(db_session) + roles = service.get_user_roles("user-roles", 1) + + assert len(roles) == 1 + assert roles[0].code == "admin" + assert roles[0].name == "Administrador" + + +# ============================================================================ +# TESTS DE MODELOS +# ============================================================================ + + +class TestModels: + """Tests para los modelos de base de datos.""" + + def test_permission_creation(self, db_session): + """Test: crear un permiso.""" + perm = Permission( + code="test.action", + description="Permiso de prueba", + module="test", + action="action", + ) + db_session.add(perm) + db_session.commit() + + assert perm.id is not None + assert perm.is_active is True + assert perm.created_at is not None + + def test_client_role_unique_constraint(self, db_session): + """Test: código de rol debe ser único por cliente.""" + role1 = ClientRole(client_id=1, code="admin", name="Admin 1") + db_session.add(role1) + db_session.commit() + + # Intentar crear otro con mismo código en mismo cliente + role2 = ClientRole(client_id=1, code="admin", name="Admin 2") + db_session.add(role2) + + with pytest.raises(Exception): # Violación de constraint + db_session.commit() + + def test_role_permission_relationship( + self, db_session, sample_role, sample_permissions + ): + """Test: relación entre roles y permisos.""" + # El rol ya tiene permisos del fixture + assert len(sample_role.role_permissions) > 0 + + # Verificar que los permisos están relacionados + permission_codes = [rp.permission.code for rp in sample_role.role_permissions] + assert "invoice.view" in permission_codes + + +# ============================================================================ +# EJECUTAR TESTS +# ============================================================================ + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py index 3be9c11f..861eecaa 100644 --- a/backend/api/v1/modules/core/router.py +++ b/backend/api/v1/modules/core/router.py @@ -1,5 +1,6 @@ from .auth.routes import router as auth_router from .licenses.routes import router as licenses_router +from .permissions.routes import router as permissions_router from .tenants.routes import router as tenants_router from .user_tenant.routes import router as user_tenant_router from .users.routes import router as users_router @@ -13,4 +14,5 @@ router.include_router(tenants_router, prefix="/core", tags=["core / tenants"]) router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"]) router.include_router(users_router, prefix="/core", tags=["core / users"]) router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) +router.include_router(permissions_router, prefix="/core", tags=["core / permissions"]) router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"]) diff --git a/backend/api/v1/modules/core/tenants/models.py b/backend/api/v1/modules/core/tenants/models.py index e76fd34e..91eb9aff 100644 --- a/backend/api/v1/modules/core/tenants/models.py +++ b/backend/api/v1/modules/core/tenants/models.py @@ -37,7 +37,12 @@ class Tenant(Base, TimestampMixin): slug = Column(String(100), unique=True, nullable=False, index=True) # Tipo de tenant (compartido o dedicado) - type = Column(SQLEnum(TenantType), default=TenantType.SHARED, nullable=False) + type = Column( + SQLEnum(TenantType), + default=TenantType.SHARED, + server_default="SHARED", + nullable=False, + ) # Keycloak realm asociado keycloak_realm = Column(String(255), nullable=False) @@ -51,7 +56,7 @@ class Tenant(Base, TimestampMixin): contact_phone = Column(String(50)) # Estado - is_active = Column(Boolean, default=True, nullable=False) + is_active = Column(Boolean, default=True, server_default="true", nullable=False) # Relación con UserTenant user_relations: Mapped[List["UserTenant"]] = relationship( diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py index 9fe82c4d..2674b292 100644 --- a/backend/api/v1/modules/core/user_tenant/models.py +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -45,7 +45,9 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin): ) # Estado de la relación - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="true", nullable=False + ) # Información adicional - Rol del usuario en este tenant (opcional) role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 773773f9..594355f5 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -8,7 +8,7 @@ import uuid from pathlib import Path from core.database import get_core_db -from core.security import get_current_user, get_tenant_from_token +from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy.orm import Session @@ -26,37 +26,11 @@ from .service import UserService router = APIRouter(prefix="/users", tags=["Users"]) -def get_user_service( - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -) -> UserService: - """Dependency para obtener servicio de usuarios con el tenant y company del usuario actual""" - # Obtener keycloak_user_id del usuario actual - keycloak_user_id = current_user.get("sub") - if not keycloak_user_id: - raise HTTPException(status_code=400, detail="User ID not found in token") - - # Buscar el user_tenant activo del usuario - user_tenant = ( - db.query(UserTenant) - .filter( - UserTenant.keycloak_user_id == keycloak_user_id, - UserTenant.is_active == True, - ) - .first() - ) - - if not user_tenant: - raise HTTPException( - status_code=400, detail="User does not belong to any tenant" - ) - - return UserService(db, user_tenant.tenant_id, user_tenant.company_id) - - @router.get("/stats", response_model=UserStatsDTO) def get_user_statistics( - service: UserService = Depends(get_user_service), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Obtiene estadísticas de usuarios del tenant actual @@ -68,21 +42,27 @@ def get_user_statistics( - Usuarios disponibles - Porcentaje de uso """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = UserService(db, tenant_id, company_id) return service.get_user_stats() @router.get("/", response_model=UserListResponseDTO) def list_users( + company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Número de página"), page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"), search: Optional[str] = Query(None, description="Término de búsqueda"), - service: UserService = Depends(get_user_service), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Lista todos los usuarios del tenant con paginación Se puede filtrar por término de búsqueda (busca en username, email, nombre) """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = UserService(db, tenant_id, company_id) result = service.get_tenant_users(page=page, page_size=page_size, search=search) return result @@ -90,20 +70,26 @@ def list_users( @router.get("/{user_id}", response_model=UserResponseDTO) def get_user( user_id: str, - service: UserService = Depends(get_user_service), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Obtiene información detallada de un usuario específico El usuario debe pertenecer al tenant actual """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) + service = UserService(db, tenant_id, company_id) return service.get_user(user_id) @router.post("/", response_model=UserResponseDTO, status_code=201) def create_user( data: CreateUserRequestDTO, - service: UserService = Depends(get_user_service), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Crea un nuevo usuario en Keycloak y lo asocia al tenant @@ -115,6 +101,8 @@ def create_user( Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual) """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"]) + service = UserService(db, tenant_id, company_id) user = service.create_user( email=data.email, username=data.username, @@ -132,7 +120,9 @@ def create_user( def update_user( user_id: str, data: UpdateUserRequestDTO, - service: UserService = Depends(get_user_service), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Actualiza información de un usuario @@ -144,6 +134,8 @@ def update_user( - Rol en el tenant - Perfil (avatar, teléfono, bio, preferencias) """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"]) + service = UserService(db, tenant_id, company_id) user = service.update_user( user_id=user_id, first_name=data.first_name, @@ -163,11 +155,13 @@ def update_user( @router.delete("/{user_id}") def delete_user( user_id: str, + company_id: int = Query(..., description="Company ID"), soft_delete: bool = Query( True, description="Si es True, solo desactiva. Si es False, elimina permanentemente", ), - service: UserService = Depends(get_user_service), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Elimina un usuario del tenant @@ -175,6 +169,8 @@ def delete_user( - soft_delete=True: Solo desactiva la relación (recomendado) - soft_delete=False: Elimina permanentemente de Keycloak """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"]) + service = UserService(db, tenant_id, company_id) service.delete_user(user_id, soft_delete=soft_delete) return {"message": "User deleted successfully"} @@ -183,7 +179,9 @@ def delete_user( def change_user_password( user_id: str, data: ChangePasswordRequestDTO, - service: UserService = Depends(get_user_service), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Cambia la contraseña de un usuario @@ -191,6 +189,8 @@ def change_user_password( - temporary=True: Usuario debe cambiar la contraseña en el próximo login - temporary=False: Contraseña permanente """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = UserService(db, tenant_id, company_id) service.change_password(user_id, data.password, data.temporary) return {"message": "Password changed successfully"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 6d2906f0..b7b313ff 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -247,12 +247,15 @@ class UserService: Args: page: Número de página (1-indexed) page_size: Tamaño de página - search: Término de búsqueda (busca en username, email, nombre) + search: Término de búsqueda (busca en username, email, nombre, teléfono y bio) Returns: Dict con usuarios y metadatos de paginación """ try: + from ..permissions.models import UserCompanyRole, CompanyRole + from sqlalchemy.orm import joinedload + # Obtener relaciones usuario-tenant query = self.db.query(UserTenant).filter( and_( @@ -267,12 +270,35 @@ class UserService: offset = (page - 1) * page_size user_tenants = query.offset(offset).limit(page_size).all() + # Obtener roles de usuarios en la compañía actual + user_roles_query = self.db.query(UserCompanyRole).options( + joinedload(UserCompanyRole.company_role) + ).filter( + and_( + UserCompanyRole.company_id == self.company_id, + UserCompanyRole.tenant_id == self.tenant_id, + UserCompanyRole.is_active == True + ) + ) + + # Crear un mapa de user_id -> lista de roles + user_roles_map = {} + for user_role in user_roles_query.all(): + if user_role.user_id not in user_roles_map: + user_roles_map[user_role.user_id] = [] + user_roles_map[user_role.user_id].append(user_role.company_role.name) + # Obtener información de Keycloak para cada usuario users = [] for ut in user_tenants: try: user_info = self.keycloak_admin.get_user(ut.keycloak_user_id) - normalized_user = _normalize_keycloak_user(user_info, ut.role, ut) + + # Obtener roles del usuario + roles = user_roles_map.get(ut.keycloak_user_id, []) + role_str = ", ".join(roles) if roles else None + + normalized_user = _normalize_keycloak_user(user_info, role_str, ut) # Filtrar por búsqueda si se proporciona if search: @@ -284,6 +310,10 @@ class UserService: in normalized_user.get("first_name", "").lower() or search_lower in normalized_user.get("last_name", "").lower() + or search_lower + in normalized_user.get("phone", "").lower() + or search_lower + in normalized_user.get("bio", "").lower() ): users.append(normalized_user) else: @@ -313,6 +343,9 @@ class UserService: def get_user(self, user_id: str) -> Dict[str, Any]: """Obtiene un usuario específico del tenant""" + from ..permissions.models import UserCompanyRole + from sqlalchemy.orm import joinedload + # Verificar que el usuario pertenece al tenant user_tenant = ( self.db.query(UserTenant) @@ -329,9 +362,24 @@ class UserService: if not user_tenant: raise HTTPException(status_code=404, detail="User not found in this tenant") + # Obtener roles del usuario en la compañía actual + user_roles = self.db.query(UserCompanyRole).options( + joinedload(UserCompanyRole.company_role) + ).filter( + and_( + UserCompanyRole.user_id == user_id, + UserCompanyRole.company_id == self.company_id, + UserCompanyRole.tenant_id == self.tenant_id, + UserCompanyRole.is_active == True + ) + ).all() + + roles = [ur.company_role.name for ur in user_roles] + role_str = ", ".join(roles) if roles else None + try: user_info = self.keycloak_admin.get_user(user_id) - return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant) + return _normalize_keycloak_user(user_info, role_str, user_tenant) except KeycloakError as e: logger.error(f"Error getting user from Keycloak: {str(e)}") raise HTTPException(status_code=404, detail="User not found in Keycloak") diff --git a/backend/api/v1/modules/public/reference_data/trailer_types/models.py b/backend/api/v1/modules/public/reference_data/trailer_types/models.py index f4cb7e8a..9a3698c9 100644 --- a/backend/api/v1/modules/public/reference_data/trailer_types/models.py +++ b/backend/api/v1/modules/public/reference_data/trailer_types/models.py @@ -3,10 +3,10 @@ from core.database import Base from sqlalchemy import Column, ForeignKeyConstraint, String -class TrailerType(Base, TenantScopedMixin, TimestampMixin): +class TrailerType(Base, TimestampMixin): __tablename__ = "trailer_type" __table_args__ = ( - {"schema": "a76"}, + {"schema": "public"}, ) trailer_type_key = Column(String(2), primary_key=True, nullable=False) diff --git a/backend/api/v1/modules/public/reference_data/trailer_types/seed.py b/backend/api/v1/modules/public/reference_data/trailer_types/seed.py new file mode 100644 index 00000000..e172cf3e --- /dev/null +++ b/backend/api/v1/modules/public/reference_data/trailer_types/seed.py @@ -0,0 +1,45 @@ +seed = [ + ("20", "20 ft Sea container open top"), + ("2B", "20 ft Sea container - closed top"), + ("40", "40 ft Sea container - open top"), + ("4B", "40 ft Sea container - closed top"), + ("BI", "Beverage rack trailer"), + ("CB", "Gooseneck trailer"), + ("CH", "Chassis"), + ("CL", "Other lenght Sea container - closed top"), + ("CU", "Other length Sea container - open top"), + ("CZ", "Refrigerated Container"), + ("DD", "Double Drop Trailer"), + ("DT", "Drop Back Trailer"), + ("FR", "Flat rack trailer"), + ("FT", "Flatbed/platform trailer"), + ("HC", "Hopper Trailer (covered)"), + ("HE", "Horse trailer"), + ("HO", "Hopper Trailer (open)"), + ("HP", "Hopper Trailer (covered Pneumatic Discharged)"), + ("L1", "Tank trailer (liquids) not heated/not insulated"), + ("L2", "Tank trailer (liquids) heated/not insulated"), + ("L3", "Tank trailer (liquids) not heated/insulated"), + ("L4", "Tank trailer (liquids) heated/insulate"), + ("LP", "Logging, pipe or pole trailer"), + ("LT", "Livestock trailer"), + ("NC", "No Equipment"), + ("OE", "Other"), + ("RD", "Fixed Rack, Double Drop Trailer"), + ("RG", "Gondola closed"), + ("RO", "Gondola open"), + ("RS", "Fixed Rack, Single Drop Trailer"), + ("SD", "Single Drop Trailer"), + ("T1", "Tank trailer (gas) not heated/ not insulated"), + ("T2", "Tank trailer (gas) heated/not insulated"), + ("T3", "Tank trailer (gas) not heated/insulated"), + ("T4", "Tank trailer (gas) heated/insulate"), + ("T5", "Tank trailer (chemicals) not heated/not insulated"), + ("T6", "Tank trailer (chemicals) heated/not insulated"), + ("T7", "Tank trailer (chemicals) not heated/insulated"), + ("T8", "Tank trailer (chemicals) heated/insulate"), + ("TC", "Auto car/trailer"), + ("TK", "Tank trailer (food grade liquids)"), + ("TL", "Semi-truck trailer"), + ("TW", "Controlled temperature trailer"), +] \ No newline at end of file diff --git a/backend/core/security.py b/backend/core/security.py index 9bb89b8b..85111961 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -176,29 +176,64 @@ def validate_company_access( def validate_access_to_resource( - db: Session, company_id: int, current_user: Dict[str, Any] + db: Session, + company_id: int, + current_user: Dict[str, Any], + required_permissions: Optional[list[str]] = None, + require_all: bool = True, ) -> int: """ Valida que el usuario tenga acceso a un recurso específico basado en company_id - y regresa el tenant_id + y regresa el tenant_id. Opcionalmente verifica permisos. Args: + db: Sesión de base de datos company_id: company_id asociado al recurso current_user: Información del usuario actual desde el token + required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos. + require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO. Returns: tenant_id si el usuario tiene acceso Raises: - HTTPException: Si no hay tenant_id o no tiene acceso + HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos """ - tenant_id = get_tenant_from_token(current_user) + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException(status_code=400, detail="Tenant ID not found in token") if not validate_company_access(db, company_id, current_user): raise HTTPException(status_code=403, detail="Access denied to this company") - # Validar que el tenant_id del usuario coincida con el del recurso + # Verificar permisos si se proporcionaron + if required_permissions: + from api.v1.modules.core.permissions.service import PermissionService + + user_id = current_user.get("sub") or current_user.get("id") + if not user_id: + raise HTTPException(status_code=401, detail="User ID not found in token") + + permission_service = PermissionService(db) + + if require_all: + has_access = permission_service.has_all_permissions( + user_id=user_id, + company_id=company_id, + permission_codes=required_permissions, + ) + else: + has_access = permission_service.has_any_permission( + user_id=user_id, + company_id=company_id, + permission_codes=required_permissions, + ) + + if not has_access: + raise HTTPException( + status_code=403, + detail=f"Missing required permissions: {', '.join(required_permissions)}", + ) + return tenant_id diff --git a/backend/main.py b/backend/main.py index adf43bb0..b7ed6b69 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ Backend API con FastAPI + Keycloak + SQLAlchemy """ import logging +import subprocess from api.v1.router import router as api_v1_router from core.config import settings @@ -75,12 +76,19 @@ async def http_exception_handler(request: Request, exc: HTTPException): ) +def run_migrations(): + subprocess.run( + ["alembic", "upgrade", "head"], + check=True + ) + # Inicializar la base de datos @app.on_event("startup") async def on_startup(): """Evento de inicio de la aplicación""" logger.info("Iniciando la aplicación Anexo76...") init_db() + run_migrations() logger.info("Base de datos inicializada correctamente.") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5d863da1..0f0aeaa4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,7 @@ */ import { getToken } from './auth'; import { browser } from '$app/environment'; +import { toast } from 'svelte-sonner'; // Normalize API_BASE_URL to remove trailing slash const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); @@ -172,6 +173,23 @@ async function fetchApi( // Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) { + // Si es 403 (Forbidden), mostrar toast de permisos insuficientes + if (response.status === 403) { + if (browser) { + toast.error('No tienes permisos para realizar esta acción', { + duration: 4000, + description: 'Contacta a tu administrador si crees que esto es un error' + }); + } + // Retornar el error 403 sin intentar refresh + const data = await response.json(); + return { + error: data.detail || 'No tienes permisos para realizar esta acción', + status: 403 + }; + } + + // Si es 401, intentar refrescar el token isRefreshing = true; try { diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index a3faa96e..f91acdcb 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -1,26 +1,7 @@ import { api } from '$lib/api'; import type { ApiResponse } from '$lib/api'; -export interface CustomsBroker { - type?: string | null; - broker_key: string; - name?: string | null; - address?: string | null; - postal_code?: string | null; - city?: string | null; - state?: string | null; - phone?: string | null; - fax?: string | null; - email?: string | null; - country?: string | null; - tax_id?: string | null; - personal_id?: string | null; - position?: string | null; - license: string; - company?: string | null; - contact?: string | null; - tenant_id: string; - company_id: string; +export interface CustomsBroker { id: number; type?: string | null; broker_key: string; @@ -117,13 +98,14 @@ export const customsBrokersApi = { return api.post(`/v1/a76/customs-brokers/?company_id=${companyId}`, data); }, - update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => { - return api.patch(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`, data); - }, + /** + * Actualiza la información de un agente aduanal + */ + update: (brokerKey: string, data: CreateCustomsBrokerData) => { + const companyId = data.company_id; + return api.put(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data); + }, - delete: (brokerKey: string, companyId: string) => { - return api.delete(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`); - }, /** * Elimina un agente aduanal */ @@ -131,13 +113,7 @@ export const customsBrokersApi = { return api.delete(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`); }, - /** - * Actualiza la información de un agente aduanal - */ - update: (brokerKey: string, data: CreateCustomsBrokerData) => { - const companyId = data.company_id; - return api.put(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data); - }, + updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => { return api.put(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data); diff --git a/frontend/src/lib/api/dashboard/admin/index.ts b/frontend/src/lib/api/dashboard/admin/index.ts new file mode 100644 index 00000000..4bc66b7d --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/index.ts @@ -0,0 +1,8 @@ +/** + * Exportaciones centralizadas de APIs de administración + */ + +export * from './permissions'; +export * from './roles'; +export * from './role-permissions'; +export * from './user-roles'; diff --git a/frontend/src/lib/api/dashboard/admin/permissions.ts b/frontend/src/lib/api/dashboard/admin/permissions.ts new file mode 100644 index 00000000..db085c94 --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/permissions.ts @@ -0,0 +1,110 @@ +/** + * API para gestión de permisos del sistema + */ + +import { api } from '$lib/api'; + +export interface Permission { + id: number; + code: string; + description?: string; + module: string; + action: string; + is_active: boolean; + created_at?: string; + updated_at?: string; +} + +export interface CreatePermissionData { + code?: string; + description?: string; + module: string; + action: string; + is_active?: boolean; +} + +export interface UpdatePermissionData { + code?: string; + description?: string; + module?: string; + action?: string; + is_active?: boolean; +} + +export interface PermissionListResponse { + items: Permission[]; + total: number; + page: number; + page_size: number; +} + +export const permissionsAPI = { + /** + * Listar permisos con filtros + */ + async list(params?: { + page?: number; + page_size?: number; + module?: string; + action?: string; + is_active?: boolean; + search?: string; + }): Promise { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.module) queryParams.set('module', params.module); + if (params?.action) queryParams.set('action', params.action); + if (params?.search) queryParams.set('search', params.search); + const query = queryParams.toString(); + const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`); + return response.data; + }, + + /** + * Obtener un permiso por ID + */ + async getById(id: number): Promise { + const response = await api.get(`/v1/core/permissions/${id}`); + return response.data; + }, + + /** + * Crear un nuevo permiso + */ + async create(data: CreatePermissionData): Promise { + const response = await api.post('/v1/core/permissions', data); + return response.data; + }, + + /** + * Actualizar un permiso + */ + async update(id: number, data: UpdatePermissionData): Promise { + const response = await api.put(`/v1/core/permissions/${id}`, data); + return response.data; + }, + + /** + * Eliminar un permiso + */ + async delete(id: number): Promise { + await api.delete(`/v1/core/permissions/${id}`); + }, + + /** + * Obtener módulos únicos + */ + async getModules(): Promise { + const response = await api.get('/v1/core/permissions/modules'); + return response.data; + }, + + /** + * Obtener acciones únicas + */ + async getActions(): Promise { + const response = await api.get('/v1/core/permissions/actions'); + return response.data; + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/role-permissions.ts b/frontend/src/lib/api/dashboard/admin/role-permissions.ts new file mode 100644 index 00000000..208ed61e --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/role-permissions.ts @@ -0,0 +1,76 @@ +/** + * API para gestión de permisos de roles + */ + +import { api } from '$lib/api'; + +export interface RolePermission { + id: number; + company_role_id: number; + permission_id: number; + granted_at?: string; + granted_by?: number; + tenant_id: number; + permission?: { + id: number; + code: string; + module: string; + action: string; + description?: string; + is_active: boolean; + }; +} + +export interface AssignPermissionData { + permission_id: number; +} + +export interface RolePermissionsResponse { + role_id: number; + permissions: RolePermission[]; + total: number; +} + +export const rolePermissionsAPI = { + /** + * Listar todos los permisos asignados a un rol + */ + async listByRole(roleId: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`); + return response.data; + }, + + /** + * Asignar un permiso a un rol + */ + async assign( + roleId: number, + companyId: number, + data: AssignPermissionData + ): Promise { + const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data); + return response.data; + }, + + /** + * Remover un permiso de un rol + */ + async remove(roleId: number, permissionId: number, companyId: number): Promise { + await api.delete(`/v1/core/permissions/roles/${roleId}/permissions/${permissionId}?company_id=${companyId}`); + }, + + /** + * Asignar múltiples permisos a un rol + */ + async assignMultiple( + roleId: number, + companyId: number, + permissionIds: number[] + ): Promise { + const response = await api.post( + `/v1/core/permissions/roles/${roleId}/permissions/batch?company_id=${companyId}`, + { permission_ids: permissionIds } + ); + return response.data; + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/roles.ts b/frontend/src/lib/api/dashboard/admin/roles.ts new file mode 100644 index 00000000..3613ae0d --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/roles.ts @@ -0,0 +1,93 @@ +/** + * API para gestión de roles por compañía + */ + +import { api } from '$lib/api'; + +export interface CompanyRole { + id: number; + name: string; + code: string; + description?: string; + is_active: boolean; + company_id: number; + tenant_id: number; + created_at?: string; + updated_at?: string; +} + +export interface CreateRoleData { + name: string; + code: string; + description?: string; + is_active?: boolean; +} + +export interface UpdateRoleData { + name?: string; + code?: string; + description?: string; + is_active?: boolean; +} + +export interface RoleListResponse { + items: CompanyRole[]; + total: number; + page: number; + page_size: number; +} + +export const rolesAPI = { + /** + * Listar roles con filtros + */ + async list( + companyId: number, + params?: { + page?: number; + page_size?: number; + is_active?: boolean; + search?: string; + } + ): Promise { + const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.is_active !== undefined) queryParams.set('is_active', params.is_active.toString()); + if (params?.search) queryParams.set('search', params.search); + const response = await api.get(`/v1/core/permissions/roles?${queryParams.toString()}`); + return response.data; + }, + + /** + * Obtener un rol por ID + */ + async getById(id: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/roles/${id}?company_id=${companyId}`); + return response.data; + }, + + /** + * Crear un nuevo rol + */ + async create(companyId: number, data: CreateRoleData): Promise { + const response = await api.post(`/v1/core/permissions/roles?company_id=${companyId}`, data); + return response.data; + }, + + /** + * Actualizar un rol + */ + async update(id: number, companyId: number, data: UpdateRoleData): Promise { + const response = await api.patch(`/v1/core/permissions/roles/${id}?company_id=${companyId}`, data); + return response.data; + }, + + /** + * Eliminar un rol + */ + async delete(id: number, companyId: number): Promise { + await api.delete(`/v1/core/permissions/roles/${id}?company_id=${companyId}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/user-permissions.ts b/frontend/src/lib/api/dashboard/admin/user-permissions.ts new file mode 100644 index 00000000..079063b8 --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/user-permissions.ts @@ -0,0 +1,108 @@ +/** + * API para gestión de permisos individuales de usuario + */ + +import { api } from '$lib/api'; +import type { Permission } from './permissions'; + +export interface UserPermission { + id: number; + user_id: string; + permission_id: number; + company_id: number; + tenant_id: number; + is_granted: boolean; + is_active: boolean; + assigned_by?: string; + expires_at?: string; + created_at?: string; + updated_at?: string; + permission?: Permission; +} + +export interface AssignUserPermissionData { + user_id: string; + permission_id: number; + is_granted?: boolean; + expires_at?: string; +} + +export interface UserPermissionsListResponse { + items: UserPermission[]; + total: number; +} + +export interface EffectiveUserPermissions { + user_id: string; + company_id: number; + role_permissions: Permission[]; + granted_permissions: Permission[]; + revoked_permissions: Permission[]; + effective_permissions: Permission[]; +} + +export const userPermissionsAPI = { + /** + * Obtener permisos individuales de un usuario + */ + async getIndividual(userId: string, companyId: number): Promise { + const response = await api.get( + `/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}` + ); + return response.data; + }, + + /** + * Obtener permisos efectivos de un usuario (roles + individuales - revocados) + */ + async getEffective(userId: string, companyId: number): Promise { + const response = await api.get( + `/v1/core/permissions/users/${userId}/permissions/effective?company_id=${companyId}` + ); + return response.data; + }, + + /** + * Asignar un permiso individual a un usuario + */ + async assign( + userId: string, + companyId: number, + data: Omit + ): Promise { + const response = await api.post( + `/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`, + data + ); + return response.data; + }, + + /** + * Conceder un permiso extra a un usuario + */ + async grant(userId: string, companyId: number, permissionId: number): Promise { + return this.assign(userId, companyId, { + permission_id: permissionId, + is_granted: true + }); + }, + + /** + * Revocar un permiso específico (aunque venga del rol) + */ + async revoke(userId: string, companyId: number, permissionId: number): Promise { + return this.assign(userId, companyId, { + permission_id: permissionId, + is_granted: false + }); + }, + + /** + * Eliminar un permiso individual + */ + async remove(userId: string, companyId: number, permissionId: number): Promise { + await api.delete( + `/v1/core/permissions/users/${userId}/permissions/${permissionId}?company_id=${companyId}` + ); + } +}; diff --git a/frontend/src/lib/api/dashboard/admin/user-roles.ts b/frontend/src/lib/api/dashboard/admin/user-roles.ts new file mode 100644 index 00000000..c41742d3 --- /dev/null +++ b/frontend/src/lib/api/dashboard/admin/user-roles.ts @@ -0,0 +1,94 @@ +/** + * API para gestión de roles de usuarios + */ + +import { api } from '$lib/api'; + +export interface UserRole { + id: number; + user_id: string; + company_id: number; + company_role_id: number; + is_active: boolean; + created_at: string; + assigned_by?: string; + company_role?: { + id: number; + name: string; + code: string; + description?: string; + }; + user?: { + id: number; + username: string; + email?: string; + full_name?: string; + }; +} + +export interface AssignUserRoleData { + user_id: string; + company_role_id: number; +} + +export interface UserRolesResponse { + items: UserRole[]; + total: number; + page: number; + page_size: number; +} + +export const userRolesAPI = { + /** + * Listar todos los roles asignados a usuarios + */ + async list( + companyId: number, + params?: { + user_id?: string; + company_role_id?: number; + page?: number; + page_size?: number; + } + ): Promise { + const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); + if (params?.user_id) queryParams.set('user_id', params.user_id); + if (params?.company_role_id) queryParams.set('company_role_id', params.company_role_id.toString()); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + const response = await api.get(`/v1/core/permissions/user-roles?${queryParams.toString()}`); + return response.data; + }, + + /** + * Listar roles de un usuario específico + */ + async listByUser(userId: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/users/${userId}/roles?company_id=${companyId}`); + return response.data; + }, + + /** + * Listar usuarios con un rol específico + */ + async listByRole(roleId: number, companyId: number): Promise { + const response = await api.get(`/v1/core/permissions/roles/${roleId}/users?company_id=${companyId}`); + return response.data; + }, + + /** + * Asignar un rol a un usuario + */ + async assign(companyId: number, data: AssignUserRoleData): Promise { + const response = await api.post(`/v1/core/permissions/user-roles?company_id=${companyId}`, data); + return response.data; + }, + + /** + * Remover un rol de un usuario + */ + async remove(userRoleId: number, companyId: number): Promise { + await api.delete(`/v1/core/permissions/user-roles/${userRoleId}?company_id=${companyId}`); + } +}; diff --git a/frontend/src/lib/api/dashboard/users.ts b/frontend/src/lib/api/dashboard/users.ts index ce8badd9..f8992b0a 100644 --- a/frontend/src/lib/api/dashboard/users.ts +++ b/frontend/src/lib/api/dashboard/users.ts @@ -62,8 +62,8 @@ export const usersAPI = { /** * Obtiene estadísticas de usuarios del tenant */ - async getStats(): Promise { - const response = await api.get('/v1/core/users/stats'); + async getStats(companyId: number): Promise { + const response = await api.get(`/v1/core/users/stats?company_id=${companyId}`); if (response.error) { throw new Error(response.error); } @@ -73,17 +73,18 @@ export const usersAPI = { /** * Lista usuarios del tenant con paginación */ - async list(params?: { + async list(companyId: number, params?: { page?: number; page_size?: number; search?: string; }): Promise { const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); if (params?.page) queryParams.set('page', params.page.toString()); if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); if (params?.search) queryParams.set('search', params.search); - const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`; + const endpoint = `/v1/core/users/?${queryParams}`; const response = await api.get(endpoint); if (response.error) { throw new Error(response.error); @@ -94,8 +95,8 @@ export const usersAPI = { /** * Obtiene un usuario específico */ - async get(userId: string): Promise { - const response = await api.get(`/v1/core/users/${userId}`); + async get(userId: string, companyId: number): Promise { + const response = await api.get(`/v1/core/users/${userId}?company_id=${companyId}`); if (response.error) { throw new Error(response.error); } @@ -105,8 +106,8 @@ export const usersAPI = { /** * Crea un nuevo usuario */ - async create(data: CreateUserRequest): Promise { - const response = await api.post('/v1/core/users/', data); + async create(data: CreateUserRequest, companyId: number): Promise { + const response = await api.post(`/v1/core/users/?company_id=${companyId}`, data); if (response.error) { throw new Error(response.error); } @@ -116,8 +117,8 @@ export const usersAPI = { /** * Actualiza un usuario existente */ - async update(userId: string, data: UpdateUserRequest): Promise { - const response = await api.put(`/v1/core/users/${userId}`, data); + async update(userId: string, data: UpdateUserRequest, companyId: number): Promise { + const response = await api.put(`/v1/core/users/${userId}?company_id=${companyId}`, data); if (response.error) { throw new Error(response.error); } @@ -127,8 +128,9 @@ export const usersAPI = { /** * Elimina un usuario */ - async delete(userId: string, softDelete: boolean = true): Promise { + async delete(userId: string, companyId: number, softDelete: boolean = true): Promise { const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); queryParams.set('soft_delete', softDelete.toString()); const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`); @@ -140,8 +142,8 @@ export const usersAPI = { /** * Cambia la contraseña de un usuario */ - async changePassword(userId: string, data: ChangePasswordRequest): Promise { - const response = await api.post(`/v1/core/users/${userId}/change-password`, data); + async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise { + const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data); if (response.error) { throw new Error(response.error); } diff --git a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte index 23fed8a6..c08d1e4f 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte @@ -27,7 +27,7 @@ // Campo de comentario estatus comments_status: invoice.comments_status || '', // Campos que van en diferentes recursos pero se editan aquí - transport_mode: invoice.logistics?.[0]?.transport_mode || null, + transport_mode: invoice.logistics?.transport_mode || null, is_mixed: invoice.compliance_mx?.is_mixed || null, print_stamp: invoice.financials?.seal_value_2500 || false, rule_3121_parties_ii: false, diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 238e4693..e6dbde48 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -10,7 +10,8 @@ import { GalleryVerticalEnd, LayoutDashboard, Package, - Settings2, + Settings2, + Shield, Users, } from 'lucide-svelte'; import * as m from "$lib/paraglide/messages.js"; @@ -382,14 +383,12 @@ export function getSidebarData(): SidebarData { url: "/dashboard/customs_brokers", icon: BadgeCheck, items: [], - }, + }, { title: m["sidebar.reference_data.configuracion"](), url: "#", icon: Settings2, items: [ - - { title: m["sidebar.reference_data.usuarios"](), url: "", diff --git a/frontend/src/lib/components/sidebar/nav-user.svelte b/frontend/src/lib/components/sidebar/nav-user.svelte index 144f6387..8ec56490 100644 --- a/frontend/src/lib/components/sidebar/nav-user.svelte +++ b/frontend/src/lib/components/sidebar/nav-user.svelte @@ -23,6 +23,9 @@ // URL completa del avatar let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg'); + + // Iniciales del usuario (2 primeras letras) + let initials = $derived(user.name.slice(0, 2).toUpperCase()); // Estado reactivo del idioma actual let currentLocale = $derived(page.data.locale || 'en'); @@ -107,7 +110,7 @@ > - AS + {initials}
{user.name} @@ -127,7 +130,7 @@
- AS + {initials}
{user.name} diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index 341a0142..c61c8a3d 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -16,6 +16,11 @@ ? getBackendAssetUrl(companyStore.activeCompany.logo) : null ); + + // Iniciales de la compañía activa (2 primeras letras) + let activeCompanyInitials = $derived( + companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO' + ); @@ -40,7 +45,7 @@ onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> {:else} - + {activeCompanyInitials} {/if}
@@ -89,7 +94,7 @@ class="size-full rounded object-cover" /> {:else} - + {company.name.slice(0, 2).toUpperCase()} {/if}
diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index ccbfdfdb..3b2c6acf 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -133,7 +133,7 @@ export async function refreshAccessToken( * @param cookies - Objeto de cookies de SvelteKit * @param fetch - Función fetch de SvelteKit * @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional) - * @param timeout - Timeout en milisegundos (default: 10000ms) + * @param timeout - Timeout en milisegundos (default: 30000ms) */ export async function authenticatedFetch( endpoint: string, @@ -141,7 +141,7 @@ export async function authenticatedFetch( cookies: Cookies, fetch: typeof globalThis.fetch, redirectUrl?: string, - timeout: number = 10000 + timeout: number = 30000 ): Promise { try { const baseUrl = getServerApiUrl(); @@ -180,6 +180,12 @@ export async function authenticatedFetch( clearTimeout(timeoutId); + // Si es 403, no intentar refrescar - es un problema de permisos + if (response.status === 403) { + console.warn('🚫 [API] Acceso denegado (403):', endpoint); + return response; // Retornar directamente para que el llamador maneje el error + } + // Si es 401, intentar refrescar el token if (response.status === 401) { const newToken = await refreshAccessToken(cookies, fetch); @@ -346,3 +352,32 @@ export async function getActiveCompanyId( return companyId || null; } + +/** + * Helper para manejar respuestas de API y convertir errores 403 en formato adecuado + * para mostrar toasts en el cliente + */ +export async function handleApiResponse( + response: Response +): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> { + if (response.ok) { + // Para respuestas sin contenido (204) + if (response.status === 204) { + return { data: null as T }; + } + + const data = await response.json(); + return { data }; + } + + // Manejar errores + const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' })); + + const error = { + detail: errorData.detail || errorData.message || 'Error en la petición', + status: response.status, + isForbidden: response.status === 403 + }; + + return { error }; +} diff --git a/frontend/src/lib/utils/error-handler.ts b/frontend/src/lib/utils/error-handler.ts new file mode 100644 index 00000000..9f3db133 --- /dev/null +++ b/frontend/src/lib/utils/error-handler.ts @@ -0,0 +1,84 @@ +/** + * Utilidades para manejar errores de API en el cliente + */ +import { toast } from 'svelte-sonner'; + +export interface ApiError { + detail: string; + status: number; + isForbidden?: boolean; +} + +/** + * Maneja errores de API mostrando el toast apropiado + * @param error - El error a manejar (puede ser un objeto ApiError o un string) + * @returns true si se manejó un error, false si no había error + */ +export function handleApiError(error?: ApiError | string | null): boolean { + if (!error) return false; + + // Si es un string, convertirlo a objeto + if (typeof error === 'string') { + // Detectar si es un error 403 + if (error.includes('403') || error.toLowerCase().includes('forbidden')) { + toast.error(error, { + duration: 5000, + description: 'No tienes permisos para realizar esta acción' + }); + return true; + } + + // Otros errores en formato string + toast.error(error, { + duration: 4000 + }); + return true; + } + + // Es un objeto ApiError + if (error.isForbidden || error.status === 403) { + // Mostrar el mensaje específico del backend si está disponible + const message = error.detail || 'No tienes permisos para realizar esta acción'; + toast.error(message, { + duration: 5000, + description: error.detail ? 'Contacta a tu administrador si crees que esto es un error' : undefined + }); + return true; + } + + if (error.status === 401) { + toast.error('Sesión expirada', { + duration: 3000, + description: 'Por favor, inicia sesión nuevamente' + }); + return true; + } + + // Otros errores + toast.error(error.detail || 'Error en la operación', { + duration: 4000 + }); + return true; +} + +/** + * Hook para usar en componentes Svelte con $effect + * Muestra automáticamente un toast cuando hay un error + * + * Ejemplo de uso en +page.svelte: + * ```svelte + * + * ``` + */ +export function useErrorHandler(error?: ApiError | null) { + if (error) { + handleApiError(error); + } +} diff --git a/frontend/src/lib/utils/permissions.ts b/frontend/src/lib/utils/permissions.ts new file mode 100644 index 00000000..021f5393 --- /dev/null +++ b/frontend/src/lib/utils/permissions.ts @@ -0,0 +1,71 @@ +/** + * Helper para verificar permisos del usuario + * Basado en el sistema de permisos RBAC del backend + */ + +import { get } from 'svelte/store'; +import { page } from '$app/stores'; + +export interface UserPermission { + module: string; + action: string; +} + +/** + * Verifica si el usuario tiene un permiso específico + * @param module - El módulo (ej: 'invoices', 'pedimentos') + * @param action - La acción (ej: 'create', 'update', 'delete', 'read') + * @returns true si el usuario tiene el permiso, false si no + */ +export function hasPermission(module: string, action: string): boolean { + // TODO: Implementar verificación real contra permisos del usuario + // Por ahora retorna true para permitir desarrollo + // En producción esto debe: + // 1. Obtener los permisos del usuario desde el contexto/store + // 2. Verificar si existe un permiso con module y action + // 3. Retornar true/false basado en la verificación + + console.warn('hasPermission() no está implementado - retornando true por defecto'); + return true; +} + +/** + * Verifica si el usuario tiene alguno de varios permisos + * @param permissions - Array de permisos a verificar + * @returns true si el usuario tiene al menos uno de los permisos + */ +export function hasAnyPermission(permissions: UserPermission[]): boolean { + return permissions.some(p => hasPermission(p.module, p.action)); +} + +/** + * Verifica si el usuario tiene todos los permisos especificados + * @param permissions - Array de permisos a verificar + * @returns true si el usuario tiene todos los permisos + */ +export function hasAllPermissions(permissions: UserPermission[]): boolean { + return permissions.every(p => hasPermission(p.module, p.action)); +} + +/** + * Guard para proteger rutas basado en permisos + * Puede ser usado en +page.server.ts o +layout.server.ts + * @param module - El módulo requerido + * @param action - La acción requerida + * @returns objeto con allowed (boolean) y redirect (string opcional) + */ +export function requirePermission(module: string, action: string): { + allowed: boolean; + redirect?: string; +} { + const allowed = hasPermission(module, action); + + if (!allowed) { + return { + allowed: false, + redirect: '/dashboard?error=forbidden' + }; + } + + return { allowed: true }; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 5c8a7830..f69244ba 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,8 +2,18 @@ import '../app.css'; import favicon from '$lib/assets/favicon.svg'; import { Toaster } from 'svelte-sonner'; + import { page } from '$app/stores'; + import { handleApiError } from '$lib/utils/error-handler'; let { children } = $props(); + + // Detectar errores de CUALQUIER página (layout o page) + $effect(() => { + const pageData = $page.data as any; + if (pageData?.error) { + handleApiError(pageData.error); + } + }); diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index 33475637..8eec7051 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -30,7 +30,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { return { authenticated: true, user: userData, - companies // Pasar las compañías al cliente + companies, // Pasar las compañías al cliente + error: undefined // Agregar error opcional para compatibilidad con error-handler }; } catch (error) { // Si es un redirect, re-lanzarlo sin tocar las cookies diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 1c81c69d..1c28eec3 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -12,12 +12,10 @@ FileText, Users, Package, - TruckIcon, Clock, BarChart3, - PieChart, - TrendingUp, - AlertCircle, + Calendar, + CircleAlert, RefreshCw } from 'lucide-svelte'; import type { DashboardStats } from '$lib/api/dashboard/types'; @@ -28,6 +26,13 @@ let loading = $state(true); let error = $state(null); + let greeting = $derived(() => { + const hour = new Date().getHours(); + if (hour < 12) return 'Buenos días'; + if (hour < 18) return 'Buenas tardes'; + return 'Buenas noches'; + }); + async function loadDashboardData() { if (!companyStore.activeCompany) { error = 'No hay compañía activa seleccionada'; @@ -56,29 +61,39 @@
- -
-
-
-

Dashboard

-

+ {#snippet headerSection()} +

+
+

+ {greeting()}, equipo. +

+

{#if stats?.company_name} - {stats.company_name} - Sistema de gestión de comercio exterior + {stats.company_name} + Anexos 22/24/30 {:else} - Sistema de gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT + Sistema de gestión de comercio exterior {/if}

- +
+ + +
-
+ {/snippet} + + {@render headerSection()} {#if error} - + Error {error} diff --git a/frontend/src/routes/dashboard/admin/+layout.svelte b/frontend/src/routes/dashboard/admin/+layout.svelte new file mode 100644 index 00000000..a8772908 --- /dev/null +++ b/frontend/src/routes/dashboard/admin/+layout.svelte @@ -0,0 +1,36 @@ + + +
+
+
+
+ {#each adminRoutes as route} + {@const Icon = route.icon} + + {/each} +
+
+
+ + {@render children()} +
diff --git a/frontend/src/routes/dashboard/admin/roles/+page.svelte b/frontend/src/routes/dashboard/admin/roles/+page.svelte new file mode 100644 index 00000000..c2ea9f15 --- /dev/null +++ b/frontend/src/routes/dashboard/admin/roles/+page.svelte @@ -0,0 +1,529 @@ + + +
+
+

Roles y Permisos

+

Gestiona los roles de la compañía y sus permisos

+
+ +
+ +
+
+
+

+ + Roles +

+ +
+ + {#if loading} +

Cargando...

+ {:else if roles.length === 0} +
+

No hay roles disponibles

+ +
+ {:else} +
+ {#each roles as role (role.id)} +
+ +
+ + +
+
+ {/each} +
+ {/if} +
+
+ + +
+ {#if selectedRole} +
+
+
+

+ + Permisos de "{selectedRole.name}" +

+

+ {rolePermissions.length} permisos asignados +

+
+ +
+ + + + + Módulo + Acción + Descripción + Acciones + + + + {#if loadingPermissions} + + + Cargando permisos... + + + {:else if rolePermissions.length === 0} + + + Este rol no tiene permisos asignados + + + {:else} + {#each rolePermissions as rp (rp.id)} + + + {rp.permission?.module} + + {rp.permission?.action} + + {rp.permission?.description || '-'} + + + + + + {/each} + {/if} + +
+
+ {:else} +
+ +

Selecciona un rol para ver sus permisos

+
+ {/if} +
+
+
+ + + + + + Agregar Permisos a "{selectedRole?.name}" + + +
+
+ updatePermissionsByModule()} + class="w-full" + /> +
+ + {#if availablePermissions.length === 0} +

+ No hay permisos disponibles para asignar +

+ {:else} +
+ {#each [...permissionsByModule] as [module, permissions] (module)} +
+

+ {module} + ({permissions.length} permisos, {new Set(permissions.map(p => p.id)).size} únicos) +

+
+ {#each permissions as permission (permission.id)} +
+ togglePermission(permission.id)} + /> +
+ +
+ {permission.code} +
+
+
+ {/each} +
+
+ {/each} +
+ {/if} +
+ + + + + +
+
+ + + + + + + {editingRole ? 'Editar Rol' : 'Nuevo Rol'} + + + +
+
+ + +
+ +
+ + +
+ +
+ +