Spaces:
Running
Running
Commit Β·
0cb811c
1
Parent(s): 5c3e065
db update: auth middleware, modelos, routers y config
Browse files- .env.example +8 -1
- app/auth_middleware.py +2 -1
- app/config.py +5 -0
- app/main.py +57 -1
- app/models.py +22 -1
- app/routers/archivos.py +96 -0
- app/routers/auth.py +9 -2
- app/routers/contactos.py +9 -0
- app/routers/ingresos.py +16 -0
- app/schemas.py +21 -0
- requirements.txt +2 -0
.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="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|