706 lines
27 KiB
Python
706 lines
27 KiB
Python
"""
|
|
csj_client.py — Cliente HTTP para el sistema judicial del PJ Paraguay.
|
|
|
|
Responsabilidades:
|
|
- Login (POST /autenticador/login)
|
|
- Renovación de sesión cuando el token expira (relogin transparente)
|
|
- GET de notificaciones pendientes
|
|
- Manejo de errores y reintentos
|
|
|
|
Lo que NO hace:
|
|
- No acusa notificaciones (PUT .../recibir) — fuera del scope de la v0
|
|
- No hace nada que tenga efecto procesal
|
|
|
|
Refs:
|
|
- docs/reconocimiento/SISTEMA_NUEVO_RECON.md
|
|
- docs/reconocimiento/MANUAL_SANITIZADO.md (estructura de responses)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import structlog
|
|
|
|
import audit
|
|
from config import Settings
|
|
from utils.sanitize import scrub
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# Headers que el sistema PJ espera ver (simulan Chrome)
|
|
BASE_HEADERS = {
|
|
"Accept": "application/json, text/plain, */*",
|
|
"Accept-Language": "es-ES,es;q=0.9",
|
|
"Connection": "keep-alive",
|
|
"Origin": "https://apps.csj.gov.py",
|
|
"Referer": "https://apps.csj.gov.py/gestion-partes",
|
|
"Sec-Fetch-Dest": "empty",
|
|
"Sec-Fetch-Mode": "cors",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/124.0.0.0 Safari/537.36"
|
|
),
|
|
}
|
|
|
|
|
|
class CSJAuthError(Exception):
|
|
"""Credenciales incorrectas u otro error de autenticación no recuperable."""
|
|
|
|
|
|
class CSJPasswordTemporalError(CSJAuthError):
|
|
"""El password del PJ es temporal — el titular debe cambiarlo manualmente."""
|
|
|
|
|
|
class CSJConnectionError(Exception):
|
|
"""Error de red transitorio, puede reintentarse."""
|
|
|
|
|
|
class CSJDocumentoNoDisponibleError(Exception):
|
|
"""El documento principal de la actuación no existe (404)."""
|
|
|
|
|
|
class NotificacionPendiente:
|
|
"""Notificación pendiente del sistema judicial."""
|
|
|
|
__slots__ = (
|
|
"cod_notificacion_origen",
|
|
"cod_actuacion_caso",
|
|
"nro_expediente_numero",
|
|
"nro_expediente_anio",
|
|
"caratula",
|
|
"descripcion_actuacion",
|
|
"descripcion_tipo_actuacion",
|
|
"fecha",
|
|
"juzgado",
|
|
"circunscripcion",
|
|
"cod_caso_judicial",
|
|
"origen",
|
|
)
|
|
|
|
def __init__(self, data: dict[str, Any]) -> None:
|
|
self.cod_notificacion_origen: int = data["codNotificacionOrigen"]
|
|
# El endpoint de notificaciones usa codActuacionCaso — necesario para descargar PDF
|
|
self.cod_actuacion_caso: int | None = data.get("codActuacionCaso")
|
|
# Campos documentados como nroExpedienteNumero/Anio no existen en el endpoint real
|
|
self.nro_expediente_numero: int | None = data.get("nroExpedienteNumero")
|
|
self.nro_expediente_anio: int | None = data.get("nroExpedienteAnio")
|
|
self.caratula: str | None = data.get("caratula")
|
|
self.descripcion_actuacion: str | None = data.get("descripcionActuacion")
|
|
self.descripcion_tipo_actuacion: str | None = data.get("descripcionTipoActuacion")
|
|
# fecha: el campo real es fechaNotificacion, no fecha
|
|
self.fecha: str | None = data.get("fechaNotificacion") or data.get("fecha")
|
|
# juzgado: el campo real es descripcionDespacho
|
|
self.juzgado: str | None = data.get("descripcionDespacho") or data.get("juzgado")
|
|
# circunscripcion: el campo real es descripcionCircunscripcion
|
|
self.circunscripcion: str | None = data.get("descripcionCircunscripcion") or data.get("circunscripcion")
|
|
self.cod_caso_judicial: int | None = data.get("codCasoJudicial")
|
|
self.origen: int | None = data.get("origen")
|
|
|
|
@property
|
|
def expediente_str(self) -> str:
|
|
if self.nro_expediente_numero and self.nro_expediente_anio:
|
|
return f"{self.nro_expediente_numero}/{self.nro_expediente_anio}"
|
|
return "sin número"
|
|
|
|
@property
|
|
def fecha_formateada(self) -> str:
|
|
if not self.fecha:
|
|
return "sin fecha"
|
|
try:
|
|
dt = datetime.fromisoformat(self.fecha.replace("Z", "+00:00"))
|
|
return dt.strftime("%d/%m/%Y")
|
|
except ValueError:
|
|
return self.fecha
|
|
|
|
|
|
class CasoJudicial:
|
|
"""Expediente judicial devuelto por búsqueda."""
|
|
|
|
__slots__ = (
|
|
"cod_caso_judicial",
|
|
"caratula",
|
|
"origen",
|
|
"nro_expediente_numero",
|
|
"nro_expediente_anio",
|
|
"circunscripcion",
|
|
"nombre_circunscripcion",
|
|
"descripcion_despacho_judicial",
|
|
"descripcion_estado_despacho_caso",
|
|
"cod_estado_despacho_caso",
|
|
"es_corte",
|
|
"cantidad_total_elementos",
|
|
)
|
|
|
|
def __init__(self, data: dict[str, Any]) -> None:
|
|
self.cod_caso_judicial: int = data["codCasoJudicial"]
|
|
self.caratula: str = data.get("caratula") or "sin carátula"
|
|
self.origen: int = data.get("origen", 0)
|
|
self.nro_expediente_numero: int | None = data.get("nroExpedienteNumero")
|
|
self.nro_expediente_anio: int | None = data.get("nroExpedienteAnio")
|
|
self.circunscripcion: str | None = data.get("nombreCircunscripcion") or data.get("circunscripcion")
|
|
self.descripcion_despacho_judicial: str | None = data.get("descripcionDespachoJudicial")
|
|
self.descripcion_estado_despacho_caso: str | None = data.get("descripcionEstadoDespachoCaso")
|
|
self.cod_estado_despacho_caso: int = data.get("codEstadoDespachoCaso", 0)
|
|
self.es_corte: int = data.get("esCorte", 0)
|
|
self.cantidad_total_elementos: int = data.get("cantidadTotalElementos", 0)
|
|
|
|
@property
|
|
def expediente_str(self) -> str:
|
|
if self.nro_expediente_numero and self.nro_expediente_anio:
|
|
return f"{self.nro_expediente_numero}/{self.nro_expediente_anio}"
|
|
return "sin número"
|
|
|
|
@property
|
|
def estado(self) -> str:
|
|
return self.descripcion_estado_despacho_caso or "sin estado"
|
|
|
|
|
|
class ActuacionCaso:
|
|
"""Actuación/movimiento dentro de un expediente."""
|
|
|
|
__slots__ = (
|
|
"cod_actuacion_caso",
|
|
"cod_caso_judicial",
|
|
"cod_tipo_actuacion_global",
|
|
"descripcion_actuacion",
|
|
"descripcion_tipo_actuacion",
|
|
"descripcion_despacho",
|
|
"descripcion_proceso_caso",
|
|
"fecha_actuacion",
|
|
"fecha_finalizacion",
|
|
"estado",
|
|
"grupo",
|
|
"origen",
|
|
)
|
|
|
|
def __init__(self, data: dict[str, Any]) -> None:
|
|
self.cod_actuacion_caso: int = data["codActuacionCaso"]
|
|
self.cod_caso_judicial: int = data["codCasoJudicial"]
|
|
self.cod_tipo_actuacion_global: int | None = data.get("codTipoActuacionGlobal")
|
|
self.descripcion_actuacion: str | None = data.get("descripcionActuacion")
|
|
self.descripcion_tipo_actuacion: str | None = data.get("descripcionTipoActuacion")
|
|
self.descripcion_despacho: str | None = data.get("descripcionDespacho")
|
|
self.descripcion_proceso_caso: str | None = data.get("descripcionProcesoCaso")
|
|
self.fecha_actuacion: str | None = data.get("fechaActuacion")
|
|
self.fecha_finalizacion: str | None = data.get("fechaFinalizacion")
|
|
self.estado: str | None = data.get("estado")
|
|
self.grupo: str | None = data.get("grupo")
|
|
self.origen: int | None = data.get("origen")
|
|
|
|
@property
|
|
def fecha_formateada(self) -> str:
|
|
if not self.fecha_actuacion:
|
|
return "sin fecha"
|
|
try:
|
|
dt = datetime.fromisoformat(self.fecha_actuacion)
|
|
return dt.strftime("%d/%m/%Y")
|
|
except ValueError:
|
|
return self.fecha_actuacion
|
|
|
|
@property
|
|
def tipo(self) -> str:
|
|
return self.descripcion_tipo_actuacion or f"Tipo {self.cod_tipo_actuacion_global}"
|
|
|
|
|
|
class _Session:
|
|
"""Sesión autenticada con el PJ."""
|
|
|
|
def __init__(
|
|
self,
|
|
bearer_token: str,
|
|
cookie_session: str,
|
|
rol_id: int,
|
|
expires_at: datetime,
|
|
) -> None:
|
|
self._bearer_token = bearer_token
|
|
self.cookie_session = cookie_session
|
|
self.rol_id = rol_id
|
|
self.expires_at = expires_at
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
return datetime.now(timezone.utc) >= self.expires_at
|
|
|
|
@property
|
|
def needs_refresh(self) -> bool:
|
|
"""True si queda menos de 10 minutos para expirar."""
|
|
from datetime import timedelta
|
|
return datetime.now(timezone.utc) >= self.expires_at - timedelta(minutes=10)
|
|
|
|
def auth_headers(self) -> dict[str, str]:
|
|
return {
|
|
"Cookie": f"cookiesession1={self.cookie_session}",
|
|
"Authorization": f"Bearer {self._bearer_token}",
|
|
"usuario-rol": str(self.rol_id),
|
|
}
|
|
|
|
|
|
class CSJClient:
|
|
"""
|
|
Cliente HTTP del Sistema de Gestión de Partes del PJ Paraguay.
|
|
Solo lectura. No realiza ninguna acción con efectos procesales.
|
|
|
|
Para multi-tenant: pasar usuario y password explícitos al constructor.
|
|
Si no se pasan, se leen de settings (credenciales del tenant principal).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
*,
|
|
usuario: str | None = None,
|
|
password: str | None = None,
|
|
chat_id: int | None = None,
|
|
) -> None:
|
|
self._settings = settings
|
|
self._usuario = usuario
|
|
self._password = password
|
|
self._chat_id = chat_id
|
|
self._session: _Session | None = None
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
async def __aenter__(self) -> CSJClient:
|
|
self._http = httpx.AsyncClient(
|
|
base_url=self._settings.pj_base_url,
|
|
headers=BASE_HEADERS,
|
|
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
follow_redirects=True,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any) -> None:
|
|
if self._http:
|
|
await self._http.aclose()
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Autenticación
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
async def _get_cookie_session(self) -> str:
|
|
"""Paso 1: GET /login para obtener cookiesession1."""
|
|
assert self._http is not None
|
|
try:
|
|
r = await self._http.get("/login")
|
|
r.raise_for_status()
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"No se pudo obtener cookie de sesión: {e}") from e
|
|
|
|
cookie = r.cookies.get("cookiesession1")
|
|
if not cookie:
|
|
# Algunos proxies devuelven el cookie en headers directamente
|
|
set_cookie = r.headers.get("set-cookie", "")
|
|
if "cookiesession1=" in set_cookie:
|
|
cookie = set_cookie.split("cookiesession1=")[1].split(";")[0]
|
|
|
|
if not cookie:
|
|
raise CSJConnectionError("El sistema PJ no devolvió cookiesession1")
|
|
|
|
return cookie
|
|
|
|
async def login_with_credentials(self, usuario: str, password: str) -> None:
|
|
"""
|
|
Login con credenciales explícitas (para multi-tenant).
|
|
Levanta CSJAuthError, CSJPasswordTemporalError, CSJConnectionError.
|
|
No loggea ni guarda usuario ni password.
|
|
"""
|
|
assert self._http is not None
|
|
|
|
t0 = time.monotonic()
|
|
try:
|
|
cookie_session = await self._get_cookie_session()
|
|
r = await self._http.post(
|
|
"/autenticador/login",
|
|
json={"usuario": usuario, "clave": password, "temporal": False},
|
|
headers={"Cookie": f"cookiesession1={cookie_session}"},
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"Error de red en login: {e}") from e
|
|
|
|
if r.status_code == 401:
|
|
raise CSJAuthError("Credenciales incorrectas (401)")
|
|
|
|
if r.status_code not in (200, 201):
|
|
raise CSJConnectionError(f"Login devolvió status inesperado: {r.status_code}")
|
|
|
|
data = r.json()
|
|
|
|
atributos = data.get("atributos") or {}
|
|
if isinstance(atributos, dict) and atributos.get("clave") == "temporal":
|
|
raise CSJPasswordTemporalError(
|
|
"La contraseña del PJ es temporal. "
|
|
"Cambiala en https://apps.csj.gov.py antes de continuar."
|
|
)
|
|
|
|
par_tokens = data.get("parTokens", {})
|
|
bearer_token = par_tokens.get("bearerToken")
|
|
if not bearer_token:
|
|
raise CSJAuthError(f"Login no devolvió bearerToken. Response: {data}")
|
|
|
|
expires_str = par_tokens.get("timestampExpiracionUtc", "")
|
|
try:
|
|
expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
|
|
except (ValueError, AttributeError):
|
|
from datetime import timedelta
|
|
expires_at = datetime.now(timezone.utc) + timedelta(minutes=55)
|
|
logger.warning("timestampExpiracionUtc_no_parseable_usando_55min")
|
|
|
|
self._session = _Session(
|
|
bearer_token=bearer_token,
|
|
cookie_session=cookie_session,
|
|
rol_id=self._settings.pj_rol_id,
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
duracion_ms = int((time.monotonic() - t0) * 1000)
|
|
logger.info(
|
|
"login_exitoso",
|
|
expires_at=expires_at.isoformat(),
|
|
rol_id=self._settings.pj_rol_id,
|
|
)
|
|
await audit.log("pj_login", chat_id=self._chat_id, detalle={"duracion_ms": duracion_ms}, duracion_ms=duracion_ms)
|
|
|
|
async def login(self) -> None:
|
|
"""
|
|
Login usando credenciales del constructor (explícitas o del Settings).
|
|
Levanta CSJAuthError, CSJPasswordTemporalError, CSJConnectionError.
|
|
"""
|
|
if self._usuario is not None and self._password is not None:
|
|
await self.login_with_credentials(self._usuario, self._password)
|
|
else:
|
|
# Credenciales del tenant principal (desde .env)
|
|
usuario = self._settings.descifrar_usuario()
|
|
password = self._settings.descifrar_password()
|
|
try:
|
|
await self.login_with_credentials(usuario, password)
|
|
finally:
|
|
del usuario, password
|
|
|
|
async def _asegurar_sesion(self) -> _Session:
|
|
"""Garantiza que hay una sesión válida. Hace relogin si es necesario."""
|
|
if self._session is None or self._session.is_expired:
|
|
if self._session is not None: # era válida pero expiró
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "expirado"})
|
|
logger.info("sesion_expirada_o_inexistente_relogin")
|
|
await self.login()
|
|
elif self._session.needs_refresh:
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "proactivo"})
|
|
logger.info("sesion_proxima_a_expirar_relogin_proactivo")
|
|
await self.login()
|
|
|
|
assert self._session is not None
|
|
return self._session
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Notificaciones (read-only)
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
async def contar_notificaciones(self) -> int:
|
|
"""
|
|
Retorna la cantidad de notificaciones pendientes.
|
|
Request barato (~50 bytes de response). Usar como check antes del listado.
|
|
"""
|
|
session = await self._asegurar_sesion()
|
|
assert self._http is not None
|
|
|
|
try:
|
|
r = await self._http.get(
|
|
"/api/gestion-partes/Notificaciones/CantidadNotificacionesPorRecibir",
|
|
headers=session.auth_headers(),
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"Error al contar notificaciones: {e}") from e
|
|
|
|
if r.status_code == 401:
|
|
# Token inválido — forzar relogin y reintentar una vez
|
|
logger.warning("401_en_contar_notificaciones_reintentando")
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
|
|
self._session = None
|
|
session = await self._asegurar_sesion()
|
|
r = await self._http.get(
|
|
"/api/gestion-partes/Notificaciones/CantidadNotificacionesPorRecibir",
|
|
headers=session.auth_headers(),
|
|
)
|
|
|
|
r.raise_for_status()
|
|
|
|
# El endpoint devuelve un entero directamente o un objeto con un campo
|
|
data = r.json()
|
|
cantidad: int
|
|
if isinstance(data, int):
|
|
cantidad = data
|
|
elif isinstance(data, dict):
|
|
# Intentar campos comunes
|
|
for key in ("cantidad", "total", "count", "value"):
|
|
if key in data:
|
|
cantidad = int(data[key])
|
|
break
|
|
else:
|
|
logger.warning("respuesta_inesperada_en_contar", data=str(data)[:200])
|
|
cantidad = -1
|
|
else:
|
|
logger.warning("respuesta_inesperada_en_contar", data=str(data)[:200])
|
|
cantidad = -1
|
|
|
|
await audit.log("pj_notificaciones_contadas", chat_id=self._chat_id, detalle={"cantidad": cantidad})
|
|
return cantidad
|
|
|
|
async def listar_notificaciones(self, cantidad: int = 50) -> list[NotificacionPendiente]:
|
|
"""
|
|
Retorna la lista de notificaciones pendientes (sin acusarlas).
|
|
cantidad: máximo de notificaciones a traer (default 50, suficiente para uso diario)
|
|
"""
|
|
session = await self._asegurar_sesion()
|
|
assert self._http is not None
|
|
|
|
try:
|
|
r = await self._http.get(
|
|
"/api/gestion-partes/Notificaciones/PorRecibir",
|
|
params={"offset": 0, "cantidad": cantidad},
|
|
headers=session.auth_headers(),
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"Error al listar notificaciones: {e}") from e
|
|
|
|
if r.status_code == 401:
|
|
logger.warning("401_en_listar_notificaciones_reintentando")
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
|
|
self._session = None
|
|
session = await self._asegurar_sesion()
|
|
r = await self._http.get(
|
|
"/api/gestion-partes/Notificaciones/PorRecibir",
|
|
params={"offset": 0, "cantidad": cantidad},
|
|
headers=session.auth_headers(),
|
|
)
|
|
|
|
if r.status_code == 403:
|
|
raise CSJAuthError(
|
|
"Acceso denegado al listado de notificaciones (403). "
|
|
"La cuenta puede no tener el rol de abogado habilitado en el PJ."
|
|
)
|
|
|
|
r.raise_for_status()
|
|
|
|
data = r.json()
|
|
|
|
# El endpoint puede devolver una lista directa o un objeto con items
|
|
if isinstance(data, list):
|
|
items = data
|
|
elif isinstance(data, dict):
|
|
items = (
|
|
data.get("resultado")
|
|
or data.get("items")
|
|
or data.get("data")
|
|
or data.get("notificaciones")
|
|
or []
|
|
)
|
|
if not items and data:
|
|
logger.warning("respuesta_inesperada_en_listar", claves=list(data.keys()), data=str(data)[:300])
|
|
else:
|
|
logger.warning("respuesta_inesperada_en_listar", data=str(data)[:200])
|
|
return []
|
|
|
|
notifs = []
|
|
for item in items:
|
|
try:
|
|
notifs.append(NotificacionPendiente(item))
|
|
except (KeyError, TypeError) as e:
|
|
logger.warning("error_parseando_notificacion", error=str(e), item=str(item)[:200])
|
|
|
|
logger.info("notificaciones_listadas", cantidad=len(notifs))
|
|
await audit.log("pj_notificaciones_listadas", chat_id=self._chat_id, detalle={"cantidad": len(notifs)})
|
|
return notifs
|
|
|
|
async def buscar_expedientes(
|
|
self,
|
|
*,
|
|
caratula: str | None = None,
|
|
numero: int | None = None,
|
|
anio: int | None = None,
|
|
cantidad: int = 10,
|
|
offset: int = 0,
|
|
) -> tuple[list[CasoJudicial], int]:
|
|
"""
|
|
Busca expedientes por carátula y/o número+año.
|
|
Retorna (lista de casos, total de registros).
|
|
|
|
Al menos uno de caratula o numero debe proveerse.
|
|
Parámetros confirmados por reconocimiento 22/05/2026:
|
|
- NroExpedienteAnho: año con 'h', no 'ñ'
|
|
"""
|
|
if not caratula and not numero:
|
|
raise ValueError("Se requiere al menos carátula o número de expediente")
|
|
|
|
session = await self._asegurar_sesion()
|
|
assert self._http is not None
|
|
|
|
params: dict[str, Any] = {
|
|
"offset": offset,
|
|
"cantidad": cantidad,
|
|
"caratula": caratula or "",
|
|
}
|
|
if numero is not None:
|
|
params["NroExpedienteNumero"] = numero
|
|
if anio is not None:
|
|
params["NroExpedienteAnho"] = anio
|
|
|
|
try:
|
|
r = await self._http.get(
|
|
"/api/gestion-partes/CasoJudicial",
|
|
params=params,
|
|
headers=session.auth_headers(),
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"Error al buscar expedientes: {e}") from e
|
|
|
|
if r.status_code == 401:
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
|
|
self._session = None
|
|
session = await self._asegurar_sesion()
|
|
r = await self._http.get(
|
|
"/api/gestion-partes/CasoJudicial",
|
|
params=params,
|
|
headers=session.auth_headers(),
|
|
)
|
|
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
|
|
items = data.get("resultado") or []
|
|
total = data.get("cantidadTotalRegistros") or 0
|
|
|
|
casos: list[CasoJudicial] = []
|
|
for item in items:
|
|
try:
|
|
casos.append(CasoJudicial(item))
|
|
except (KeyError, TypeError) as e:
|
|
logger.warning("error_parseando_caso", error=str(e), item=str(item)[:200])
|
|
|
|
logger.info("expedientes_encontrados", total=total, devueltos=len(casos))
|
|
query_tipo = "numero" if numero is not None else "caratula"
|
|
await audit.log("pj_expediente_buscado", chat_id=self._chat_id, detalle={"query_tipo": query_tipo, "resultados": len(casos)})
|
|
return casos, total
|
|
|
|
async def listar_actuaciones(
|
|
self,
|
|
cod_caso_judicial: int,
|
|
origen: int,
|
|
cantidad: int = 10,
|
|
offset: int = 0,
|
|
incluir_notificaciones: bool = False,
|
|
) -> tuple[list[ActuacionCaso], int]:
|
|
"""
|
|
Retorna las actuaciones de un expediente.
|
|
|
|
URL confirmada en logs de producción 19/06/2026:
|
|
GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}:0/actuaciones
|
|
|
|
El PJ usa ":" como separador entre cod_caso_judicial y origen
|
|
(igual que en descargar_documento_principal()), no "/".
|
|
"""
|
|
session = await self._asegurar_sesion()
|
|
assert self._http is not None
|
|
|
|
url = f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}/{origen}:0/actuaciones"
|
|
params: dict[str, Any] = {
|
|
"offset": offset,
|
|
"cantidad": cantidad,
|
|
"incluirNotificaciones": str(incluir_notificaciones).lower(),
|
|
}
|
|
|
|
try:
|
|
r = await self._http.get(url, params=params, headers=session.auth_headers())
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"Error al listar actuaciones: {e}") from e
|
|
|
|
if r.status_code == 401:
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
|
|
self._session = None
|
|
session = await self._asegurar_sesion()
|
|
r = await self._http.get(url, params=params, headers=session.auth_headers())
|
|
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
|
|
items = data.get("resultado") or []
|
|
total = data.get("cantidadTotalRegistros") or 0
|
|
|
|
actuaciones: list[ActuacionCaso] = []
|
|
for item in items:
|
|
try:
|
|
actuaciones.append(ActuacionCaso(item))
|
|
except (KeyError, TypeError) as e:
|
|
logger.warning("error_parseando_actuacion", error=str(e), item=str(item)[:200])
|
|
|
|
logger.info(
|
|
"actuaciones_listadas",
|
|
cod_caso=cod_caso_judicial,
|
|
total=total,
|
|
devueltas=len(actuaciones),
|
|
)
|
|
await audit.log("pj_actuaciones_listadas", chat_id=self._chat_id, detalle={"cod_caso": cod_caso_judicial, "total": total})
|
|
return actuaciones, total
|
|
|
|
async def descargar_documento_principal(
|
|
self,
|
|
cod_caso_judicial: int,
|
|
origen_caso: int,
|
|
cod_actuacion_caso: int,
|
|
origen_actuacion: int | None = None,
|
|
) -> bytes:
|
|
"""
|
|
Descarga el documento principal (PDF) de una actuación.
|
|
Retorna los bytes del PDF listos para enviar a Telegram.
|
|
|
|
URL confirmada 24/05/2026:
|
|
GET /api/gestion-partes/CasoJudicial/{cod_caso}/{origen_caso}:0
|
|
/actuaciones/{cod_actuacion}:{origen_actuacion}:0/documentos/principal
|
|
|
|
⚠️ El separador en este endpoint es ':' (dos puntos), NO '/' (barra).
|
|
Son convenciones distintas del mismo backend. No "corregir" el patrón.
|
|
|
|
Levanta CSJDocumentoNoDisponibleError si el documento no existe (404).
|
|
"""
|
|
if origen_actuacion is None:
|
|
origen_actuacion = origen_caso
|
|
|
|
session = await self._asegurar_sesion()
|
|
assert self._http is not None
|
|
|
|
url = (
|
|
f"/api/gestion-partes/CasoJudicial/{cod_caso_judicial}"
|
|
f"/{origen_caso}:0/actuaciones/{cod_actuacion_caso}:{origen_actuacion}:0"
|
|
f"/documentos/principal"
|
|
)
|
|
logger.info("descargando_documento_pdf", url=scrub(url))
|
|
|
|
try:
|
|
r = await self._http.get(url, headers=session.auth_headers())
|
|
except httpx.HTTPError as e:
|
|
raise CSJConnectionError(f"Error al descargar documento: {e}") from e
|
|
|
|
if r.status_code == 401:
|
|
await audit.log("pj_relogin", chat_id=self._chat_id, detalle={"motivo": "401"})
|
|
self._session = None
|
|
session = await self._asegurar_sesion()
|
|
r = await self._http.get(url, headers=session.auth_headers())
|
|
|
|
if r.status_code == 404:
|
|
raise CSJDocumentoNoDisponibleError(
|
|
f"Documento no disponible para actuación {cod_actuacion_caso}"
|
|
)
|
|
|
|
r.raise_for_status()
|
|
logger.info("documento_descargado", bytes=len(r.content))
|
|
await audit.log(
|
|
"pj_pdf_descargado",
|
|
chat_id=self._chat_id,
|
|
detalle={"cod_caso": cod_caso_judicial, "cod_actuacion": cod_actuacion_caso, "size_kb": len(r.content) // 1024},
|
|
)
|
|
return r.content
|