Initial commit — Pedrito Hechakuaa v0
Bot de Telegram para monitoreo de notificaciones judiciales del PJ Paraguay. Multi-tenant con onboarding conversacional, audit log, y polling automático. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
691
csj_client.py
Normal file
691
csj_client.py
Normal file
@@ -0,0 +1,691 @@
|
||||
"""
|
||||
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",
|
||||
"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"]
|
||||
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")
|
||||
self.fecha: str | None = data.get("fecha")
|
||||
self.juzgado: str | None = data.get("juzgado")
|
||||
self.circunscripcion: str | None = 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(),
|
||||
)
|
||||
|
||||
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 por reconocimiento 22/05/2026:
|
||||
GET /api/gestion-partes/CasoJudicial/{codCasoJudicial}/{origen}/0/actuaciones
|
||||
|
||||
El segundo posicional (/0/) es fijo en todos los casos observados.
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user