Laevateinn707 commited on
Commit
0cb811c
Β·
1 Parent(s): 5c3e065

db update: auth middleware, modelos, routers y config

Browse files
.env.example CHANGED
@@ -14,4 +14,11 @@ ALGORITHM=HS256
14
  ACCESS_TOKEN_EXPIRE_MINUTES=60
15
 
16
  # Entorno: development / production
17
- ENVIRONMENT=development
 
 
 
 
 
 
 
 
14
  ACCESS_TOKEN_EXPIRE_MINUTES=60
15
 
16
  # Entorno: development / production
17
+ ENVIRONMENT=development
18
+
19
+ # Almacenamiento de archivos adjuntos (Supabase Storage)
20
+ # Crea un bucket PUBLICO en Supabase y pega aqui la URL del proyecto y la service_role key.
21
+ # Si se dejan vacias, POST /archivos responde 503 (no configurado).
22
+ SUPABASE_URL=https://TU_PROYECTO.supabase.co
23
+ SUPABASE_SERVICE_KEY=tu_service_role_key
24
+ SUPABASE_BUCKET=archivos
app/auth_middleware.py CHANGED
@@ -33,11 +33,12 @@ async def get_current_user(
33
  return user
34
 
35
 
36
- def create_access_token(user_id: str, rol_id: int) -> str:
37
  expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
38
  payload = {
39
  "sub": user_id,
40
  "rol": rol_id,
 
41
  "exp": expire,
42
  }
43
  return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
 
33
  return user
34
 
35
 
36
+ def create_access_token(user_id: str, rol_id: int, pais_id: int | None = None) -> str:
37
  expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
38
  payload = {
39
  "sub": user_id,
40
  "rol": rol_id,
41
+ "pais_id": pais_id,
42
  "exp": expire,
43
  }
44
  return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
app/config.py CHANGED
@@ -8,6 +8,11 @@ class Settings(BaseSettings):
8
  ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
9
  ENVIRONMENT: str = "production"
10
 
 
 
 
 
 
11
  class Config:
12
  env_file = ".env"
13
 
 
8
  ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
9
  ENVIRONMENT: str = "production"
10
 
11
+ # Almacenamiento de archivos (Supabase Storage)
12
+ SUPABASE_URL: str = ""
13
+ SUPABASE_SERVICE_KEY: str = ""
14
+ SUPABASE_BUCKET: str = "archivos"
15
+
16
  class Config:
17
  env_file = ".env"
18
 
app/main.py CHANGED
@@ -8,7 +8,7 @@ from app.routers import (
8
  estadisticas_paises, roles, configuracion, usuarios,
9
  ciudades_mision, ingresos, miembros_info_adicional,
10
  saldos_caja_banco, traslados, auth, continentes,
11
- estudios_diarios, estadisticas,
12
  )
13
 
14
  app = FastAPI(
@@ -134,6 +134,61 @@ async def startup():
134
  await conn.execute(text("""
135
  ALTER TABLE cotizaciones ADD COLUMN IF NOT EXISTS ciudad_id INTEGER
136
  """))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  # ── Claves foraneas (idempotentes) ─────────────────────────────
139
  # Postgres no soporta ADD CONSTRAINT IF NOT EXISTS para FK, por eso
@@ -205,6 +260,7 @@ app.include_router(auth.router)
205
  app.include_router(continentes.router)
206
  app.include_router(estudios_diarios.router)
207
  app.include_router(estadisticas.router)
 
208
 
209
 
210
  @app.get("/", tags=["Estado"])
 
8
  estadisticas_paises, roles, configuracion, usuarios,
9
  ciudades_mision, ingresos, miembros_info_adicional,
10
  saldos_caja_banco, traslados, auth, continentes,
11
+ estudios_diarios, estadisticas, archivos,
12
  )
13
 
14
  app = FastAPI(
 
134
  await conn.execute(text("""
135
  ALTER TABLE cotizaciones ADD COLUMN IF NOT EXISTS ciudad_id INTEGER
136
  """))
137
+ # Correlativo de recibos por pais+anio+mes (RC-001)
138
+ await conn.execute(text("""
139
+ ALTER TABLE ingresos ADD COLUMN IF NOT EXISTS numero VARCHAR(20)
140
+ """))
141
+ # Backfill idempotente: solo rellena los que aun no tienen numero
142
+ await conn.execute(text("""
143
+ WITH numbered AS (
144
+ SELECT id, 'RC-' || LPAD((ROW_NUMBER() OVER (
145
+ PARTITION BY pais_id, anio, mes ORDER BY fecha, id))::text, 3, '0') AS num
146
+ FROM ingresos
147
+ )
148
+ UPDATE ingresos i SET numero = n.num
149
+ FROM numbered n WHERE i.id = n.id AND i.numero IS NULL
150
+ """))
151
+ # Codigos contables de caja/banco
152
+ await conn.execute(text("""
153
+ ALTER TABLE saldos_caja_banco ADD COLUMN IF NOT EXISTS codigo_contable_caja VARCHAR(20)
154
+ """))
155
+ await conn.execute(text("""
156
+ ALTER TABLE saldos_caja_banco ADD COLUMN IF NOT EXISTS codigo_contable_banco VARCHAR(20)
157
+ """))
158
+ # Tabla de archivos adjuntos (ingresos/gastos)
159
+ await conn.execute(text("""
160
+ CREATE TABLE IF NOT EXISTS public.archivos (
161
+ id SERIAL PRIMARY KEY,
162
+ tipo VARCHAR(20) NOT NULL,
163
+ referencia_id INTEGER NOT NULL,
164
+ nombre_original TEXT,
165
+ content_type VARCHAR(100),
166
+ tamano_bytes BIGINT,
167
+ storage_path TEXT,
168
+ url TEXT NOT NULL,
169
+ fecha_creacion TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
170
+ CONSTRAINT chk_archivos_tipo CHECK (tipo IN ('ingreso', 'gasto'))
171
+ )
172
+ """))
173
+ await conn.execute(text("""
174
+ CREATE INDEX IF NOT EXISTS idx_archivos_ref ON public.archivos (tipo, referencia_id)
175
+ """))
176
+ # Proteger contactos con estudios: recrear FK contacto_id como RESTRICT
177
+ await conn.execute(text("""
178
+ DO $$ DECLARE existing text; BEGIN
179
+ SELECT conname INTO existing FROM pg_constraint c
180
+ WHERE c.conrelid='estudios_diarios'::regclass AND c.contype='f'
181
+ AND (SELECT attname FROM pg_attribute
182
+ WHERE attrelid=c.conrelid AND attnum=c.conkey[1])='contacto_id';
183
+ IF existing IS NOT NULL THEN
184
+ IF (SELECT confdeltype FROM pg_constraint WHERE conname=existing) <> 'r' THEN
185
+ EXECUTE format('ALTER TABLE estudios_diarios DROP CONSTRAINT %I', existing);
186
+ ALTER TABLE estudios_diarios ADD CONSTRAINT estudios_diarios_contacto_id_fkey
187
+ FOREIGN KEY (contacto_id) REFERENCES contactos(id) ON DELETE RESTRICT;
188
+ END IF;
189
+ END IF;
190
+ END $$
191
+ """))
192
 
193
  # ── Claves foraneas (idempotentes) ─────────────────────────────
194
  # Postgres no soporta ADD CONSTRAINT IF NOT EXISTS para FK, por eso
 
260
  app.include_router(continentes.router)
261
  app.include_router(estudios_diarios.router)
262
  app.include_router(estadisticas.router)
263
+ app.include_router(archivos.router)
264
 
265
 
266
  @app.get("/", tags=["Estado"])
app/models.py CHANGED
@@ -339,6 +339,7 @@ class Ingreso(Base):
339
  __tablename__ = "ingresos"
340
 
341
  id = Column(Integer, primary_key=True)
 
342
  pais_id = Column(Integer, ForeignKey("paises.id", ondelete="SET NULL"))
343
  mes = Column(Integer, nullable=False)
344
  anio = Column(Integer, nullable=False)
@@ -376,6 +377,8 @@ class SaldoCajaBanco(Base):
376
  pais_id = Column(Integer, ForeignKey("paises.id", ondelete="SET NULL"))
377
  saldo_caja = Column(Numeric(15, 2), nullable=False, default=0)
378
  saldo_banco = Column(Numeric(15, 2), nullable=False, default=0)
 
 
379
  updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
380
 
381
  pais_rel = relationship("Pais", back_populates="saldos")
@@ -411,7 +414,7 @@ class EstudioDiario(Base):
411
  id = Column(Integer, primary_key=True)
412
  miembro_id = Column(String(30), ForeignKey("miembros.id", ondelete="CASCADE"), nullable=False)
413
  pais_id = Column(Integer, ForeignKey("paises.id", ondelete="SET NULL"))
414
- contacto_id = Column(Integer, ForeignKey("contactos.id", ondelete="SET NULL"))
415
  mes = Column(Integer, nullable=False)
416
  anio = Column(Integer, nullable=False)
417
  dia = Column(Integer, nullable=False)
@@ -426,3 +429,21 @@ class EstudioDiario(Base):
426
  miembro_rel = relationship("Miembro", back_populates="estudios_diarios")
427
  pais_rel = relationship("Pais")
428
  contacto_rel = relationship("Contacto")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  __tablename__ = "ingresos"
340
 
341
  id = Column(Integer, primary_key=True)
342
+ numero = Column(String(20))
343
  pais_id = Column(Integer, ForeignKey("paises.id", ondelete="SET NULL"))
344
  mes = Column(Integer, nullable=False)
345
  anio = Column(Integer, nullable=False)
 
377
  pais_id = Column(Integer, ForeignKey("paises.id", ondelete="SET NULL"))
378
  saldo_caja = Column(Numeric(15, 2), nullable=False, default=0)
379
  saldo_banco = Column(Numeric(15, 2), nullable=False, default=0)
380
+ codigo_contable_caja = Column(String(20))
381
+ codigo_contable_banco = Column(String(20))
382
  updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
383
 
384
  pais_rel = relationship("Pais", back_populates="saldos")
 
414
  id = Column(Integer, primary_key=True)
415
  miembro_id = Column(String(30), ForeignKey("miembros.id", ondelete="CASCADE"), nullable=False)
416
  pais_id = Column(Integer, ForeignKey("paises.id", ondelete="SET NULL"))
417
+ contacto_id = Column(Integer, ForeignKey("contactos.id", ondelete="RESTRICT"))
418
  mes = Column(Integer, nullable=False)
419
  anio = Column(Integer, nullable=False)
420
  dia = Column(Integer, nullable=False)
 
429
  miembro_rel = relationship("Miembro", back_populates="estudios_diarios")
430
  pais_rel = relationship("Pais")
431
  contacto_rel = relationship("Contacto")
432
+
433
+
434
+ class Archivo(Base):
435
+ __tablename__ = "archivos"
436
+
437
+ id = Column(Integer, primary_key=True)
438
+ tipo = Column(String(20), nullable=False) # 'ingreso' | 'gasto'
439
+ referencia_id = Column(Integer, nullable=False) # id del ingreso o gasto asociado
440
+ nombre_original = Column(Text)
441
+ content_type = Column(String(100))
442
+ tamano_bytes = Column(BigInteger)
443
+ storage_path = Column(Text)
444
+ url = Column(Text, nullable=False)
445
+ fecha_creacion = Column(DateTime, default=datetime.utcnow, nullable=False)
446
+
447
+ __table_args__ = (
448
+ CheckConstraint("tipo IN ('ingreso', 'gasto')", name="chk_archivos_tipo"),
449
+ )
app/routers/archivos.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+ import httpx
4
+ from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlalchemy import select
7
+ from app.database import get_db
8
+ from app.config import settings
9
+ from app.models import Archivo, Ingreso, GastoReal
10
+ from app.schemas import ArchivoOut
11
+
12
+ router = APIRouter(prefix="/archivos", tags=["Archivos"])
13
+
14
+ TIPOS_VALIDOS = {"ingreso", "gasto"}
15
+
16
+
17
+ async def _validar_referencia(db: AsyncSession, tipo: str, referencia_id: int):
18
+ modelo = Ingreso if tipo == "ingreso" else GastoReal
19
+ obj = await db.get(modelo, referencia_id)
20
+ if not obj:
21
+ raise HTTPException(404, f"No existe un {tipo} con id {referencia_id}")
22
+
23
+
24
+ async def _subir_a_supabase(path: str, contenido: bytes, content_type: str) -> str:
25
+ if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_KEY:
26
+ raise HTTPException(
27
+ 503,
28
+ "Almacenamiento de archivos no configurado (faltan SUPABASE_URL / SUPABASE_SERVICE_KEY).",
29
+ )
30
+ base = settings.SUPABASE_URL.rstrip("/")
31
+ bucket = settings.SUPABASE_BUCKET
32
+ upload_url = f"{base}/storage/v1/object/{bucket}/{path}"
33
+ headers = {
34
+ "Authorization": f"Bearer {settings.SUPABASE_SERVICE_KEY}",
35
+ "Content-Type": content_type or "application/octet-stream",
36
+ "x-upsert": "true",
37
+ }
38
+ async with httpx.AsyncClient(timeout=30) as client:
39
+ resp = await client.post(upload_url, content=contenido, headers=headers)
40
+ if resp.status_code not in (200, 201):
41
+ raise HTTPException(502, f"Error al subir el archivo al almacenamiento: {resp.text}")
42
+ return f"{base}/storage/v1/object/public/{bucket}/{path}"
43
+
44
+
45
+ @router.get("", response_model=list[ArchivoOut])
46
+ async def listar(
47
+ tipo: str | None = Query(None),
48
+ referencia_id: int | None = Query(None),
49
+ db: AsyncSession = Depends(get_db),
50
+ ):
51
+ q = select(Archivo).order_by(Archivo.fecha_creacion.desc())
52
+ if tipo:
53
+ q = q.where(Archivo.tipo == tipo)
54
+ if referencia_id:
55
+ q = q.where(Archivo.referencia_id == referencia_id)
56
+ result = await db.execute(q)
57
+ return result.scalars().all()
58
+
59
+
60
+ @router.post("", response_model=ArchivoOut, status_code=201)
61
+ async def subir(
62
+ tipo: str = Form(...),
63
+ referencia_id: int = Form(...),
64
+ file: UploadFile = File(...),
65
+ db: AsyncSession = Depends(get_db),
66
+ ):
67
+ if tipo not in TIPOS_VALIDOS:
68
+ raise HTTPException(422, "tipo debe ser 'ingreso' o 'gasto'")
69
+ await _validar_referencia(db, tipo, referencia_id)
70
+
71
+ contenido = await file.read()
72
+ nombre = file.filename or "archivo"
73
+ path = f"{tipo}/{referencia_id}/{uuid.uuid4().hex}_{nombre}"
74
+ url = await _subir_a_supabase(path, contenido, file.content_type)
75
+
76
+ obj = Archivo(
77
+ tipo=tipo,
78
+ referencia_id=referencia_id,
79
+ nombre_original=nombre,
80
+ content_type=file.content_type,
81
+ tamano_bytes=len(contenido),
82
+ storage_path=path,
83
+ url=url,
84
+ )
85
+ db.add(obj)
86
+ await db.flush()
87
+ await db.refresh(obj)
88
+ return obj
89
+
90
+
91
+ @router.delete("/{id}", status_code=204)
92
+ async def eliminar(id: int, db: AsyncSession = Depends(get_db)):
93
+ obj = await db.get(Archivo, id)
94
+ if not obj:
95
+ raise HTTPException(404, "Archivo no encontrado")
96
+ await db.delete(obj)
app/routers/auth.py CHANGED
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
  from sqlalchemy import select
5
  from app.database import get_db
6
- from app.models import Usuario, Rol
7
  from app.schemas import LoginRequest, TokenResponse, UsuarioLoginOut
8
  from app.auth_middleware import create_access_token
9
 
@@ -39,7 +39,12 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
39
  rol_obj = rol_result.scalar_one_or_none()
40
  rol_nombre = rol_obj.nombre if rol_obj else ""
41
 
42
- token = create_access_token(user.id, user.rol)
 
 
 
 
 
43
  usuario_out = UsuarioLoginOut(
44
  id=user.id,
45
  nombre=user.nombre,
@@ -47,6 +52,8 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
47
  rol_id=user.rol,
48
  rol_nombre=rol_nombre,
49
  activo=user.activo,
 
 
50
  )
51
  return TokenResponse(token=token, usuario=usuario_out)
52
 
 
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
  from sqlalchemy import select
5
  from app.database import get_db
6
+ from app.models import Usuario, Rol, Pais
7
  from app.schemas import LoginRequest, TokenResponse, UsuarioLoginOut
8
  from app.auth_middleware import create_access_token
9
 
 
39
  rol_obj = rol_result.scalar_one_or_none()
40
  rol_nombre = rol_obj.nombre if rol_obj else ""
41
 
42
+ pais_nombre = None
43
+ if user.pais_id is not None:
44
+ pais_obj = await db.get(Pais, user.pais_id)
45
+ pais_nombre = pais_obj.nombre if pais_obj else None
46
+
47
+ token = create_access_token(user.id, user.rol, user.pais_id)
48
  usuario_out = UsuarioLoginOut(
49
  id=user.id,
50
  nombre=user.nombre,
 
52
  rol_id=user.rol,
53
  rol_nombre=rol_nombre,
54
  activo=user.activo,
55
+ pais_id=user.pais_id,
56
+ pais_nombre=pais_nombre,
57
  )
58
  return TokenResponse(token=token, usuario=usuario_out)
59
 
app/routers/contactos.py CHANGED
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
2
  from sqlalchemy.ext.asyncio import AsyncSession
3
  from sqlalchemy import select
4
  from sqlalchemy.orm import selectinload
 
5
  from app.database import get_db
6
  from app.models import Contacto, Pais
7
  from app.schemas import ContactoCreate, ContactoUpdate, ContactoOut
@@ -88,3 +89,11 @@ async def eliminar(id: int, db: AsyncSession = Depends(get_db)):
88
  if not obj:
89
  raise HTTPException(404, "Contacto no encontrado")
90
  await db.delete(obj)
 
 
 
 
 
 
 
 
 
2
  from sqlalchemy.ext.asyncio import AsyncSession
3
  from sqlalchemy import select
4
  from sqlalchemy.orm import selectinload
5
+ from sqlalchemy.exc import IntegrityError
6
  from app.database import get_db
7
  from app.models import Contacto, Pais
8
  from app.schemas import ContactoCreate, ContactoUpdate, ContactoOut
 
89
  if not obj:
90
  raise HTTPException(404, "Contacto no encontrado")
91
  await db.delete(obj)
92
+ try:
93
+ await db.flush()
94
+ except IntegrityError:
95
+ await db.rollback()
96
+ raise HTTPException(
97
+ 409,
98
+ "No se puede eliminar el contacto porque tiene estudios biblicos registrados.",
99
+ )
app/routers/ingresos.py CHANGED
@@ -8,6 +8,21 @@ from app.schemas import IngresoCreate, IngresoUpdate, IngresoOut
8
  router = APIRouter(prefix="/ingresos", tags=["Ingresos"])
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  @router.get("", response_model=list[IngresoOut])
12
  async def listar(
13
  pais_id: int | None = Query(None),
@@ -43,6 +58,7 @@ async def obtener(id: int, db: AsyncSession = Depends(get_db)):
43
  @router.post("", response_model=IngresoOut, status_code=201)
44
  async def crear(data: IngresoCreate, db: AsyncSession = Depends(get_db)):
45
  obj = Ingreso(**data.model_dump())
 
46
  db.add(obj)
47
  await db.flush()
48
  await db.refresh(obj)
 
8
  router = APIRouter(prefix="/ingresos", tags=["Ingresos"])
9
 
10
 
11
+ async def _siguiente_numero(db: AsyncSession, pais_id: int | None, anio: int, mes: int) -> str:
12
+ """Correlativo por pais + anio + mes, formato RC-001. Robusto ante huecos/borrados."""
13
+ q = select(Ingreso.numero).where(Ingreso.anio == anio, Ingreso.mes == mes)
14
+ q = q.where(Ingreso.pais_id.is_(None) if pais_id is None else Ingreso.pais_id == pais_id)
15
+ result = await db.execute(q)
16
+ maximo = 0
17
+ for (numero,) in result.all():
18
+ if numero and numero.startswith("RC-"):
19
+ try:
20
+ maximo = max(maximo, int(numero[3:]))
21
+ except ValueError:
22
+ pass
23
+ return f"RC-{maximo + 1:03d}"
24
+
25
+
26
  @router.get("", response_model=list[IngresoOut])
27
  async def listar(
28
  pais_id: int | None = Query(None),
 
58
  @router.post("", response_model=IngresoOut, status_code=201)
59
  async def crear(data: IngresoCreate, db: AsyncSession = Depends(get_db)):
60
  obj = Ingreso(**data.model_dump())
61
+ obj.numero = await _siguiente_numero(db, data.pais_id, data.anio, data.mes)
62
  db.add(obj)
63
  await db.flush()
64
  await db.refresh(obj)
app/schemas.py CHANGED
@@ -574,6 +574,7 @@ class IngresoUpdate(BaseModel):
574
 
575
  class IngresoOut(IngresoBase):
576
  id: int
 
577
  fecha_creacion: datetime
578
  model_config = {"from_attributes": True}
579
 
@@ -612,6 +613,8 @@ class SaldoCajaBancoBase(BaseModel):
612
  pais_id: Optional[int] = None
613
  saldo_caja: Decimal = Decimal("0")
614
  saldo_banco: Decimal = Decimal("0")
 
 
615
 
616
  class SaldoCajaBancoCreate(SaldoCajaBancoBase):
617
  pass
@@ -620,6 +623,8 @@ class SaldoCajaBancoUpdate(BaseModel):
620
  pais_id: Optional[int] = None
621
  saldo_caja: Optional[Decimal] = None
622
  saldo_banco: Optional[Decimal] = None
 
 
623
 
624
  class SaldoCajaBancoOut(SaldoCajaBancoBase):
625
  id: int
@@ -736,6 +741,8 @@ class UsuarioLoginOut(BaseModel):
736
  rol_id: int
737
  rol_nombre: str
738
  activo: bool
 
 
739
  model_config = {"from_attributes": True}
740
 
741
  class TokenResponse(BaseModel):
@@ -743,6 +750,20 @@ class TokenResponse(BaseModel):
743
  usuario: UsuarioLoginOut
744
 
745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  # ── Estadisticas Agregadas ────────────────────────────────────────────────
747
 
748
  class SerieData(BaseModel):
 
574
 
575
  class IngresoOut(IngresoBase):
576
  id: int
577
+ numero: Optional[str] = None
578
  fecha_creacion: datetime
579
  model_config = {"from_attributes": True}
580
 
 
613
  pais_id: Optional[int] = None
614
  saldo_caja: Decimal = Decimal("0")
615
  saldo_banco: Decimal = Decimal("0")
616
+ codigo_contable_caja: Optional[str] = None
617
+ codigo_contable_banco: Optional[str] = None
618
 
619
  class SaldoCajaBancoCreate(SaldoCajaBancoBase):
620
  pass
 
623
  pais_id: Optional[int] = None
624
  saldo_caja: Optional[Decimal] = None
625
  saldo_banco: Optional[Decimal] = None
626
+ codigo_contable_caja: Optional[str] = None
627
+ codigo_contable_banco: Optional[str] = None
628
 
629
  class SaldoCajaBancoOut(SaldoCajaBancoBase):
630
  id: int
 
741
  rol_id: int
742
  rol_nombre: str
743
  activo: bool
744
+ pais_id: Optional[int] = None
745
+ pais_nombre: Optional[str] = None
746
  model_config = {"from_attributes": True}
747
 
748
  class TokenResponse(BaseModel):
 
750
  usuario: UsuarioLoginOut
751
 
752
 
753
+ # ── Archivos ───────────────────────────────────────────────────────────────
754
+
755
+ class ArchivoOut(BaseModel):
756
+ id: int
757
+ tipo: str
758
+ referencia_id: int
759
+ nombre_original: Optional[str] = None
760
+ content_type: Optional[str] = None
761
+ tamano_bytes: Optional[int] = None
762
+ url: str
763
+ fecha_creacion: datetime
764
+ model_config = {"from_attributes": True}
765
+
766
+
767
  # ── Estadisticas Agregadas ────────────────────────────────────────────────
768
 
769
  class SerieData(BaseModel):
requirements.txt CHANGED
@@ -7,3 +7,5 @@ pydantic[email]==2.10.3
7
  pydantic-settings==2.6.1
8
  python-jose[cryptography]==3.3.0
9
  passlib[bcrypt]==1.7.4
 
 
 
7
  pydantic-settings==2.6.1
8
  python-jose[cryptography]==3.3.0
9
  passlib[bcrypt]==1.7.4
10
+ python-multipart==0.0.12
11
+ httpx==0.27.2