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:
@@ -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"),
|
||||
]
|
||||
Reference in New Issue
Block a user