Merge pull request 'feat: Implement multi-tenancy support in middleware and security layers' (#15) from feature/security into development
Reviewed-on: ADUANASOFT/anexo76#15
This commit is contained in:
@@ -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 ###
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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', ''),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"<LicenseUsage(tenant_id={self.tenant_id}, operations={self.operations_count})>"
|
||||
|
||||
324
backend/api/v1/modules/core/permissions/README.md
Normal file
324
backend/api/v1/modules/core/permissions/README.md
Normal file
@@ -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")
|
||||
```
|
||||
38
backend/api/v1/modules/core/permissions/__init__.py
Normal file
38
backend/api/v1/modules/core/permissions/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
203
backend/api/v1/modules/core/permissions/dependencies.py
Normal file
203
backend/api/v1/modules/core/permissions/dependencies.py
Normal file
@@ -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)
|
||||
153
backend/api/v1/modules/core/permissions/integration_example.py
Normal file
153
backend/api/v1/modules/core/permissions/integration_example.py
Normal file
@@ -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)
|
||||
"""
|
||||
247
backend/api/v1/modules/core/permissions/models.py
Normal file
247
backend/api/v1/modules/core/permissions/models.py
Normal file
@@ -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"
|
||||
)
|
||||
1239
backend/api/v1/modules/core/permissions/routes.py
Normal file
1239
backend/api/v1/modules/core/permissions/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
320
backend/api/v1/modules/core/permissions/schemas.py
Normal file
320
backend/api/v1/modules/core/permissions/schemas.py
Normal file
@@ -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"
|
||||
)
|
||||
63
backend/api/v1/modules/core/permissions/seed.py
Normal file
63
backend/api/v1/modules/core/permissions/seed.py
Normal file
@@ -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"),
|
||||
]
|
||||
298
backend/api/v1/modules/core/permissions/service.py
Normal file
298
backend/api/v1/modules/core/permissions/service.py
Normal file
@@ -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
|
||||
424
backend/api/v1/modules/core/permissions/test_permissions.py
Normal file
424
backend/api/v1/modules/core/permissions/test_permissions.py
Normal file
@@ -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"])
|
||||
@@ -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"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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<T = any>(
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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<CustomsBroker>(`/v1/a76/customs-brokers/?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => {
|
||||
return api.patch<CustomsBroker>(`/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<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
delete: (brokerKey: string, companyId: string) => {
|
||||
return api.delete<void>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
|
||||
},
|
||||
/**
|
||||
* Elimina un agente aduanal
|
||||
*/
|
||||
@@ -131,13 +113,7 @@ export const customsBrokersApi = {
|
||||
return api.delete<CustomsBroker>(`/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<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
|
||||
updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => {
|
||||
return api.put<CustomsBrokerVU>(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data);
|
||||
|
||||
8
frontend/src/lib/api/dashboard/admin/index.ts
Normal file
8
frontend/src/lib/api/dashboard/admin/index.ts
Normal file
@@ -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';
|
||||
110
frontend/src/lib/api/dashboard/admin/permissions.ts
Normal file
110
frontend/src/lib/api/dashboard/admin/permissions.ts
Normal file
@@ -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<PermissionListResponse> {
|
||||
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<Permission> {
|
||||
const response = await api.get(`/v1/core/permissions/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear un nuevo permiso
|
||||
*/
|
||||
async create(data: CreatePermissionData): Promise<Permission> {
|
||||
const response = await api.post('/v1/core/permissions', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar un permiso
|
||||
*/
|
||||
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
|
||||
const response = await api.put(`/v1/core/permissions/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un permiso
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener módulos únicos
|
||||
*/
|
||||
async getModules(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/modules');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener acciones únicas
|
||||
*/
|
||||
async getActions(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/actions');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
76
frontend/src/lib/api/dashboard/admin/role-permissions.ts
Normal file
76
frontend/src/lib/api/dashboard/admin/role-permissions.ts
Normal file
@@ -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<RolePermissionsResponse> {
|
||||
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<RolePermission> {
|
||||
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<void> {
|
||||
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<RolePermission[]> {
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/roles/${roleId}/permissions/batch?company_id=${companyId}`,
|
||||
{ permission_ids: permissionIds }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
93
frontend/src/lib/api/dashboard/admin/roles.ts
Normal file
93
frontend/src/lib/api/dashboard/admin/roles.ts
Normal file
@@ -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<RoleListResponse> {
|
||||
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<CompanyRole> {
|
||||
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<CompanyRole> {
|
||||
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<CompanyRole> {
|
||||
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<void> {
|
||||
await api.delete(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
108
frontend/src/lib/api/dashboard/admin/user-permissions.ts
Normal file
108
frontend/src/lib/api/dashboard/admin/user-permissions.ts
Normal file
@@ -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<UserPermissionsListResponse> {
|
||||
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<EffectiveUserPermissions> {
|
||||
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<AssignUserPermissionData, 'user_id'>
|
||||
): Promise<UserPermission> {
|
||||
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<UserPermission> {
|
||||
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<UserPermission> {
|
||||
return this.assign(userId, companyId, {
|
||||
permission_id: permissionId,
|
||||
is_granted: false
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un permiso individual
|
||||
*/
|
||||
async remove(userId: string, companyId: number, permissionId: number): Promise<void> {
|
||||
await api.delete(
|
||||
`/v1/core/permissions/users/${userId}/permissions/${permissionId}?company_id=${companyId}`
|
||||
);
|
||||
}
|
||||
};
|
||||
94
frontend/src/lib/api/dashboard/admin/user-roles.ts
Normal file
94
frontend/src/lib/api/dashboard/admin/user-roles.ts
Normal file
@@ -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<UserRolesResponse> {
|
||||
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<UserRolesResponse> {
|
||||
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<UserRolesResponse> {
|
||||
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<UserRole> {
|
||||
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<void> {
|
||||
await api.delete(`/v1/core/permissions/user-roles/${userRoleId}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
@@ -62,8 +62,8 @@ export const usersAPI = {
|
||||
/**
|
||||
* Obtiene estadísticas de usuarios del tenant
|
||||
*/
|
||||
async getStats(): Promise<UserStats> {
|
||||
const response = await api.get<UserStats>('/v1/core/users/stats');
|
||||
async getStats(companyId: number): Promise<UserStats> {
|
||||
const response = await api.get<UserStats>(`/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<UserListResponse> {
|
||||
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<UserListResponse>(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<User> {
|
||||
const response = await api.get<User>(`/v1/core/users/${userId}`);
|
||||
async get(userId: string, companyId: number): Promise<User> {
|
||||
const response = await api.get<User>(`/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<User> {
|
||||
const response = await api.post<User>('/v1/core/users/', data);
|
||||
async create(data: CreateUserRequest, companyId: number): Promise<User> {
|
||||
const response = await api.post<User>(`/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<User> {
|
||||
const response = await api.put<User>(`/v1/core/users/${userId}`, data);
|
||||
async update(userId: string, data: UpdateUserRequest, companyId: number): Promise<User> {
|
||||
const response = await api.put<User>(`/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<void> {
|
||||
async delete(userId: string, companyId: number, softDelete: boolean = true): Promise<void> {
|
||||
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<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password`, data);
|
||||
async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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 @@
|
||||
>
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={avatarUrl} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{user.name}</span>
|
||||
@@ -127,7 +130,7 @@
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={avatarUrl} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{user.name}</span>
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -40,7 +45,7 @@
|
||||
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{:else}
|
||||
<BuildingIcon class="size-4 text-white" />
|
||||
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight min-w-0">
|
||||
@@ -89,7 +94,7 @@
|
||||
class="size-full rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<BuildingIcon class="size-3.5 shrink-0" />
|
||||
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col min-w-0">
|
||||
|
||||
@@ -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<Response> {
|
||||
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<T = any>(
|
||||
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 };
|
||||
}
|
||||
|
||||
84
frontend/src/lib/utils/error-handler.ts
Normal file
84
frontend/src/lib/utils/error-handler.ts
Normal file
@@ -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
|
||||
* <script lang="ts">
|
||||
* import { handleApiError } from '$lib/utils/error-handler';
|
||||
* let { data } = $props();
|
||||
*
|
||||
* $effect(() => {
|
||||
* handleApiError(data.error);
|
||||
* });
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useErrorHandler(error?: ApiError | null) {
|
||||
if (error) {
|
||||
handleApiError(error);
|
||||
}
|
||||
}
|
||||
71
frontend/src/lib/utils/permissions.ts
Normal file
71
frontend/src/lib/utils/permissions.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{#snippet headerSection()}
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-foreground">
|
||||
{greeting()}, equipo.
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 flex items-center gap-2">
|
||||
{#if stats?.company_name}
|
||||
{stats.company_name} - Sistema de gestión de comercio exterior
|
||||
<span class="font-semibold text-primary">{stats.company_name}</span>
|
||||
<span class="text-xs bg-muted px-2 py-0.5 rounded-full">Anexos 22/24/30</span>
|
||||
{: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}
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
|
||||
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="hidden sm:flex">
|
||||
<Calendar class="mr-2 h-4 w-4" />
|
||||
Hoy: {new Date().toLocaleDateString()}
|
||||
</Button>
|
||||
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
|
||||
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{@render headerSection()}
|
||||
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<CircleAlert class="h-4 w-4" />
|
||||
<Alert.Title>Error</Alert.Title>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
36
frontend/src/routes/dashboard/admin/+layout.svelte
Normal file
36
frontend/src/routes/dashboard/admin/+layout.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Shield, Key, Users, UserCog } from 'lucide-svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const adminRoutes = [
|
||||
{ name: 'Roles', path: '/dashboard/admin/roles', icon: Shield }
|
||||
];
|
||||
|
||||
const currentPath = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="border-b">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center gap-2 py-4 overflow-x-auto">
|
||||
{#each adminRoutes as route}
|
||||
{@const Icon = route.icon}
|
||||
<Button
|
||||
variant={currentPath === route.path ? 'default' : 'ghost'}
|
||||
href={route.path}
|
||||
class="flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
{route.name}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
529
frontend/src/routes/dashboard/admin/roles/+page.svelte
Normal file
529
frontend/src/routes/dashboard/admin/roles/+page.svelte
Normal file
@@ -0,0 +1,529 @@
|
||||
<script lang="ts">
|
||||
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
|
||||
import {
|
||||
rolePermissionsAPI,
|
||||
type RolePermission
|
||||
} from '$lib/api/dashboard/admin/role-permissions';
|
||||
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger
|
||||
} from '$lib/components/ui/select';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Plus, Trash2, Shield, Key, Pencil } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let roles = $state<CompanyRole[]>([]);
|
||||
let selectedRole = $state<CompanyRole | null>(null);
|
||||
let rolePermissions = $state<RolePermission[]>([]);
|
||||
let allPermissions = $state<Permission[]>([]);
|
||||
let availablePermissions = $state<Permission[]>([]);
|
||||
let permissionsByModule = $state<Map<string, Permission[]>>(new Map());
|
||||
let loading = $state(true);
|
||||
let loadingPermissions = $state(false);
|
||||
|
||||
// Dialog state
|
||||
let showPermissionsDialog = $state(false);
|
||||
let showRoleDialog = $state(false);
|
||||
let selectedPermissionIds = $state<number[]>([]);
|
||||
let permissionSearchQuery = $state('');
|
||||
let editingRole = $state<CompanyRole | null>(null);
|
||||
let roleFormData = $state({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id);
|
||||
|
||||
// Cargar datos cuando companyId esté disponible
|
||||
$effect(() => {
|
||||
if (companyId) {
|
||||
loadRoles();
|
||||
loadAllPermissions();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadRoles() {
|
||||
if (!companyId) return;
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const response = await rolesAPI.list(companyId);
|
||||
roles = response.items || [];
|
||||
if (roles.length > 0 && !selectedRole) {
|
||||
await selectRole(roles[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error al cargar roles');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllPermissions() {
|
||||
try {
|
||||
const response = await permissionsAPI.list();
|
||||
allPermissions = response.items || [];
|
||||
console.log('Total permissions loaded:', allPermissions.length);
|
||||
console.log('Unique permission IDs:', new Set(allPermissions.map(p => p.id)).size);
|
||||
if (selectedRole) {
|
||||
updateAvailablePermissions();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error al cargar permisos');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRole(role: CompanyRole) {
|
||||
if (!companyId) return;
|
||||
|
||||
selectedRole = role;
|
||||
loadingPermissions = true;
|
||||
|
||||
try {
|
||||
const response = await rolePermissionsAPI.listByRole(role.id, companyId);
|
||||
rolePermissions = response.permissions;
|
||||
updateAvailablePermissions();
|
||||
} catch (error) {
|
||||
toast.error('Error al cargar permisos del rol');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingPermissions = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvailablePermissions() {
|
||||
const assignedIds = rolePermissions.map((rp) => rp.permission_id);
|
||||
availablePermissions = allPermissions.filter((p) => !assignedIds.includes(p.id));
|
||||
|
||||
console.log('Available permissions:', availablePermissions.length);
|
||||
console.log('Unique available IDs:', new Set(availablePermissions.map(p => p.id)).size);
|
||||
|
||||
updatePermissionsByModule();
|
||||
}
|
||||
|
||||
function updatePermissionsByModule() {
|
||||
const grouped = new Map<string, Permission[]>();
|
||||
|
||||
console.log('Grouping permissions:', availablePermissions.length);
|
||||
|
||||
// Filtrar por búsqueda
|
||||
const filtered = availablePermissions.filter((p) => {
|
||||
if (!permissionSearchQuery.trim()) return true;
|
||||
const search = permissionSearchQuery.toLowerCase();
|
||||
return (
|
||||
p.code.toLowerCase().includes(search) ||
|
||||
p.description?.toLowerCase().includes(search) ||
|
||||
p.action.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
filtered.forEach((p) => {
|
||||
if (!grouped.has(p.module)) {
|
||||
grouped.set(p.module, []);
|
||||
}
|
||||
grouped.get(p.module)!.push(p);
|
||||
});
|
||||
|
||||
console.log('Grouped by module:', Array.from(grouped.entries()).map(([k, v]) => `${k}: ${v.length}`));
|
||||
|
||||
permissionsByModule = grouped;
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
const assignedIds = rolePermissions.map((rp) => rp.permission_id);
|
||||
const availablePermissions = allPermissions.filter((p) => !assignedIds.includes(p.id));
|
||||
selectedPermissionIds = [];
|
||||
showPermissionsDialog = true;
|
||||
}
|
||||
|
||||
function openCreateRoleDialog() {
|
||||
editingRole = null;
|
||||
roleFormData = { name: '', code: '', description: '', is_active: true };
|
||||
showRoleDialog = true;
|
||||
}
|
||||
|
||||
function openEditRoleDialog(role: CompanyRole) {
|
||||
editingRole = role;
|
||||
roleFormData = {
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description || '',
|
||||
is_active: role.is_active
|
||||
};
|
||||
showRoleDialog = true;
|
||||
}
|
||||
|
||||
async function handleRoleSubmit() {
|
||||
if (!companyId) return;
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
await rolesAPI.update(editingRole.id, companyId, roleFormData);
|
||||
toast.success('Rol actualizado correctamente');
|
||||
// Si el rol editado es el seleccionado, actualizar la referencia
|
||||
if (selectedRole?.id === editingRole.id) {
|
||||
selectedRole = { ...selectedRole, ...roleFormData };
|
||||
}
|
||||
} else {
|
||||
const newRole = await rolesAPI.create(companyId, roleFormData);
|
||||
toast.success('Rol creado correctamente');
|
||||
// Seleccionar el nuevo rol automáticamente
|
||||
selectedRole = newRole;
|
||||
}
|
||||
showRoleDialog = false;
|
||||
await loadRoles();
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
error?.message ||
|
||||
(editingRole ? 'Error al actualizar rol' : 'Error al crear rol');
|
||||
toast.error(message);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRole(role: CompanyRole) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Estás seguro de eliminar el rol "${role.name}"?`)) return;
|
||||
|
||||
try {
|
||||
await rolesAPI.delete(role.id, companyId);
|
||||
toast.success('Rol eliminado correctamente');
|
||||
// Si el rol eliminado es el seleccionado, deseleccionar
|
||||
if (selectedRole?.id === role.id) {
|
||||
selectedRole = null;
|
||||
}
|
||||
await loadRoles();
|
||||
} catch (error) {
|
||||
toast.error('Error al eliminar rol');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignPermissions() {
|
||||
if (!companyId || !selectedRole || selectedPermissionIds.length === 0) return;
|
||||
|
||||
try {
|
||||
await rolePermissionsAPI.assignMultiple(selectedRole.id, companyId, selectedPermissionIds);
|
||||
toast.success('Permisos asignados correctamente');
|
||||
showPermissionsDialog = false;
|
||||
await selectRole(selectedRole);
|
||||
} catch (error) {
|
||||
toast.error('Error al asignar permisos');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemovePermission(permissionId: number) {
|
||||
if (!companyId || !selectedRole) return;
|
||||
if (!confirm('¿Estás seguro de remover este permiso?')) return;
|
||||
|
||||
try {
|
||||
await rolePermissionsAPI.remove(selectedRole.id, permissionId, companyId);
|
||||
toast.success('Permiso removido correctamente');
|
||||
await selectRole(selectedRole);
|
||||
} catch (error) {
|
||||
toast.error('Error al remover permiso');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(permissionId: number) {
|
||||
if (selectedPermissionIds.includes(permissionId)) {
|
||||
selectedPermissionIds = selectedPermissionIds.filter((id) => id !== permissionId);
|
||||
} else {
|
||||
selectedPermissionIds = [...selectedPermissionIds, permissionId];
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Roles y Permisos</h1>
|
||||
<p class="text-muted-foreground">Gestiona los roles de la compañía y sus permisos</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Roles List -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield class="h-5 w-5" />
|
||||
Roles
|
||||
</h2>
|
||||
<Button size="sm" onclick={openCreateRoleDialog}>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground text-center py-4">Cargando...</p>
|
||||
{:else if roles.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-muted-foreground mb-4">No hay roles disponibles</p>
|
||||
<Button size="sm" onclick={openCreateRoleDialog}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Crear primer rol
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each roles as role (role.id)}
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg border {selectedRole?.id ===
|
||||
role.id
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'hover:bg-accent'}"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex-1 justify-start h-auto p-2"
|
||||
onclick={() => selectRole(role)}
|
||||
>
|
||||
<div class="flex flex-col items-start gap-1 w-full">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Shield class="h-4 w-4 flex-shrink-0" />
|
||||
<span class="font-medium text-sm truncate">{role.name}</span>
|
||||
</div>
|
||||
{#if !role.is_active}
|
||||
<Badge variant="secondary" class="text-xs">Inactivo</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={() => openEditRoleDialog(role)}
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={() => handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions List -->
|
||||
<div class="lg:col-span-2">
|
||||
{#if selectedRole}
|
||||
<div class="border rounded-lg">
|
||||
<div class="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Key class="h-5 w-5" />
|
||||
Permisos de "{selectedRole.name}"
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{rolePermissions.length} permisos asignados
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={openAddDialog} disabled={availablePermissions.length === 0}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Agregar Permisos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Módulo</TableHead>
|
||||
<TableHead>Acción</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
<TableHead class="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if loadingPermissions}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-center py-8">
|
||||
Cargando permisos...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else if rolePermissions.length === 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-center py-8">
|
||||
Este rol no tiene permisos asignados
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
{#each rolePermissions as rp (rp.id)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{rp.permission?.module}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{rp.permission?.action}</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{rp.permission?.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => handleRemovePermission(rp.permission_id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-lg p-12 text-center">
|
||||
<Shield class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p class="text-muted-foreground">Selecciona un rol para ver sus permisos</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Permissions Dialog -->
|
||||
<Dialog.Root bind:open={showPermissionsDialog}>
|
||||
<Dialog.Content class="max-w-2xl max-h-[80vh]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Agregar Permisos a "{selectedRole?.name}"</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="overflow-y-auto max-h-96 py-4">
|
||||
<div class="px-4 pb-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar permisos..."
|
||||
bind:value={permissionSearchQuery}
|
||||
oninput={() => updatePermissionsByModule()}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if availablePermissions.length === 0}
|
||||
<p class="text-center text-muted-foreground py-8">
|
||||
No hay permisos disponibles para asignar
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each [...permissionsByModule] as [module, permissions] (module)}
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<Badge>{module}</Badge>
|
||||
<span class="text-xs text-muted-foreground">({permissions.length} permisos, {new Set(permissions.map(p => p.id)).size} únicos)</span>
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each permissions as permission (permission.id)}
|
||||
<div class="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="perm-{permission.id}"
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
onCheckedChange={() => togglePermission(permission.id)}
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<Label
|
||||
for="perm-{permission.id}"
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{permission.description || permission.code}
|
||||
</Label>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">{permission.code}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showPermissionsDialog = false)}>Cancelar</Button>
|
||||
<Button onclick={handleAssignPermissions} disabled={selectedPermissionIds.length === 0}>
|
||||
Asignar {selectedPermissionIds.length} permisos
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Create/Edit Role Dialog -->
|
||||
<Dialog.Root bind:open={showRoleDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{editingRole ? 'Editar Rol' : 'Nuevo Rol'}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Nombre</Label>
|
||||
<Input id="name" bind:value={roleFormData.name} placeholder="Administrador" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="code">Código</Label>
|
||||
<Input id="code" bind:value={roleFormData.code} placeholder="ADMIN" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={roleFormData.description}
|
||||
placeholder="Rol con acceso completo al sistema"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="is_active" bind:checked={roleFormData.is_active} />
|
||||
<Label for="is_active">Activo</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showRoleDialog = false)}>Cancelar</Button>
|
||||
<Button onclick={handleRoleSubmit}>
|
||||
{editingRole ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
|
||||
import { getAuthTokens, authenticatedFetch, handleApiResponse } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
// Esperar a que el layout padre valide/refresque el token
|
||||
@@ -63,16 +63,13 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
fetch
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('📊 [Clients&Providers] API Error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText
|
||||
});
|
||||
const result = await handleApiResponse(response);
|
||||
|
||||
if (result.error) {
|
||||
console.error('📊 [Clients&Providers] API Error:', result.error);
|
||||
|
||||
return {
|
||||
error: `Error ${response.status}: ${response.statusText}`,
|
||||
error: result.error,
|
||||
items: [],
|
||||
total: 0,
|
||||
page: page,
|
||||
@@ -82,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
items: data.items || [],
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import type { ApiError } from '$lib/utils/error-handler';
|
||||
|
||||
// Los datos iniciales vienen del servidor
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -84,7 +85,7 @@
|
||||
let totalItems = $state(data.total || 0);
|
||||
let loading = $state(false);
|
||||
let hasMore = $derived(allItems.length < totalItems);
|
||||
let error = $state<string | null>(data.error || null);
|
||||
let error = $state<string | ApiError | null>(data.error || null);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
@@ -205,7 +206,9 @@
|
||||
<Card.Root class="border-destructive">
|
||||
<Card.Header>
|
||||
<Card.Title class="text-destructive">Error</Card.Title>
|
||||
<Card.Description>{error}</Card.Description>
|
||||
<Card.Description>
|
||||
{typeof error === 'string' ? error : error.detail}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
|
||||
import { Plus, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
// Los datos iniciales vienen del servidor
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,18 +41,18 @@ wait_for_tcp "${CORE_DB_HOST:-postgres-a76}" "${CORE_DB_PORT:-5432}" "PostgreSQL
|
||||
wait_for_tcp "keycloak" "8080" "Keycloak"
|
||||
|
||||
# Ejecutar migraciones de Alembic
|
||||
if [ -d "/app/alembic" ]; then
|
||||
echo "Ejecutando migraciones de Alembic..."
|
||||
alembic upgrade head || {
|
||||
echo "⚠ WARNING: Error al ejecutar migraciones"
|
||||
echo " Verificando estado de la base de datos..."
|
||||
alembic current || echo " No se pudo determinar la versión actual"
|
||||
}
|
||||
echo "✓ Migraciones completadas"
|
||||
else
|
||||
echo "⚠ WARNING: Directorio /app/alembic no encontrado"
|
||||
echo " Las migraciones de base de datos no se ejecutaron"
|
||||
fi
|
||||
#if [ -d "/app/alembic" ]; then
|
||||
# echo "Ejecutando migraciones de Alembic..."
|
||||
# alembic upgrade head || {
|
||||
# echo "⚠ WARNING: Error al ejecutar migraciones"
|
||||
# echo " Verificando estado de la base de datos..."
|
||||
# alembic current || echo " No se pudo determinar la versión actual"
|
||||
# }
|
||||
# echo "✓ Migraciones completadas"
|
||||
#else
|
||||
# echo "⚠ WARNING: Directorio /app/alembic no encontrado"
|
||||
# echo " Las migraciones de base de datos no se ejecutaron"
|
||||
#fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "Iniciando aplicación FastAPI..."
|
||||
|
||||
@@ -530,7 +530,7 @@ if [ $TENANT_QUERY_STATUS -ne 0 ]; then
|
||||
echo -e "${RED}✗ Error al consultar el tenant:${NC}"
|
||||
echo "$TENANT_ID_RESULT"
|
||||
echo -e "${YELLOW}Verificando si la tabla existe...${NC}"
|
||||
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "\dt core.tenants;"
|
||||
exec_pg_sql "\dt core.tenants;"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -540,7 +540,7 @@ if [ -z "$TENANT_ID" ]; then
|
||||
echo -e "${RED}✗ Error: No se pudo obtener el ID del tenant${NC}"
|
||||
echo "Resultado de la consulta: '$TENANT_ID_RESULT'"
|
||||
echo -e "${YELLOW}Intentando ver todos los tenants...${NC}"
|
||||
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT id, slug FROM core.tenants LIMIT 10;"
|
||||
exec_pg_sql "SELECT id, slug FROM core.tenants LIMIT 10;"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user