Browse Source

fix(assinatura): materializacao do PDF-alvo sobe com o servico (#15)

* fix(assinatura): materializacao do PDF-alvo sobe com o servico

O comando `materializar_pdfs_para_assinatura` era idempotente e "feito para
cron" — mas nada o agendava: nem crontab no repo, nem celery, nem timer, nem
entrada em /etc/cron.d na imagem. Na pratica virava passo manual de
implantacao, e a falha e MUDA: materia protocolada nunca vira pendencia no
app, sem erro nenhum, sem log. So aparece quando um vereador reclama que a
materia nao chegou para assinar.

- `--intervalo SEGUNDOS` no proprio comando. 0 (padrao) preserva a passada
  unica da invocacao manual; maior que zero fica em laco. A logica ficou em
  Python, e nao num `while true` de shell, para ser testavel, logar pelo
  Django e sobreviver a passada que estoura — um laco que morre em silencio
  seria o mesmo problema, so que mais dificil de achar.
- `start.sh` sobe o laco junto do gunicorn, ao lado de migrate_db e
  create_admin. Onde a imagem subir, a rotina sobe. Intervalo por
  MATERIALIZACAO_INTERVALO_SEGUNDOS, padrao 300s.
- compose de dev faz o mesmo, para o ambiente local nao divergir.

Testes: passada unica preservada, laco dorme entre passadas e nao retorna, e
laco sobrevive a passada que levanta excecao.

Refs: refinamento da assinatura §5/§5.1 (o doc ja previa "em cron na VPS" —
isto tira a dependencia de alguem lembrar de instalar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix(Assinatura): pendencia carrega o id do keyset e PDF sumido nao derruba o lote AB#1473

Dois defeitos mudos no mesmo caminho, os dois cortando a pendencia antes de
ela chegar ao app:

1. `serializar_pendencia` nao emitia `id` no topo. O hub le exatamente esse
   campo para avancar o cursor (PollerSapl.pollAssinaturasPendentes) e devolve
   como `id_gt`, que a view filtra em `materia_id__gt`. Sem ele o cursor virava
   string vazia e a fonte relia a primeira pagina para sempre. Vai o id da
   MATERIA, nao o do registro de pendencia: o alvo e OneToOne e a view pagina
   por materia_id. Mesmo tratamento na fonte de concluidas, onde o id e o
   desempate do cursor composto (assinado_em, id).

2. Referencia no banco sem binario no MEDIA (969 materias em Franco, dump
   restaurado sem a media) levantava OSError em `.size`/`.read()` e derrubava
   a resposta INTEIRA do poll com 500 — uma materia podre travava a fonte para
   todas as outras. Agora o item continua na lista com `documento` nulo, o
   cursor anda e o buraco grita no log.

Testes: 61 passed em sapl/integracao_hub/tests/.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
91c0fe7ffb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      docker/docker-compose-dev.yml
  2. 12
      docker/startup_scripts/start.sh
  3. 29
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  4. 61
      sapl/integracao_hub/serializacao.py
  5. 115
      sapl/integracao_hub/tests/test_assinatura.py
  6. 67
      sapl/integracao_hub/tests/test_materializacao.py

7
docker/docker-compose-dev.yml

@ -15,7 +15,12 @@ services:
build: build:
context: ../ context: ../
dockerfile: ./docker/Dockerfile.dev dockerfile: ./docker/Dockerfile.dev
command: python3 manage.py runserver 0:8000 # A materializacao sobe junto do runserver (mesmo racional do start.sh de
# producao): sem ela, materia protocolada nunca vira pendencia no app.
command: >
sh -c "python3 manage.py materializar_pdfs_para_assinatura
--intervalo ${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300} &
exec python3 manage.py runserver 0:8000"
volumes: volumes:
- ..:/sapl-dev - ..:/sapl-dev
ports: ports:

12
docker/startup_scripts/start.sh

@ -260,6 +260,17 @@ setup_cache_dir() {
umask 0007 umask 0007
} }
# A materializacao do PDF-alvo da assinatura (refinamento §5) roda AQUI, junto do
# servico — nao e passo opcional de implantacao. Sem ela, materia protocolada nunca
# vira pendencia no app e a falha e MUDA: nenhum erro, a materia so nao aparece para
# assinar. O comando e idempotente (§5.1) e engole falha por materia, entao a passada
# seguinte recupera sozinha o DOCX que o OnlyOffice ainda nao converteu.
start_materializacao_assinatura() {
local intervalo="${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300}"
log "Starting materializacao do PDF-alvo (a cada ${intervalo}s)..."
python manage.py materializar_pdfs_para_assinatura --intervalo "$intervalo" &
}
start_services() { start_services() {
log "Starting gunicorn..." log "Starting gunicorn..."
gunicorn -c gunicorn.conf.py & gunicorn -c gunicorn.conf.py &
@ -278,6 +289,7 @@ main() {
create_admin create_admin
setup_cache_dir setup_cache_dir
fix_logging_and_socket_perms fix_logging_and_socket_perms
start_materializacao_assinatura
cat <<'BANNER' cat <<'BANNER'
------------------------------------- -------------------------------------

29
sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py

@ -1,6 +1,7 @@
import hashlib import hashlib
import logging import logging
import os import os
import time
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@ -40,7 +41,35 @@ class Command(BaseCommand):
'de assinatura (decisão do arquiteto, 19/08/2026). Idempotente — ' 'de assinatura (decisão do arquiteto, 19/08/2026). Idempotente — '
'feito para cron.') 'feito para cron.')
def add_arguments(self, parser):
parser.add_argument(
'--intervalo',
type=int,
default=0,
metavar='SEGUNDOS',
help=('Segundos entre passadas. 0 (padrao) roda uma vez e sai — '
'o modo para invocacao manual. Maior que zero fica em laco, '
'que e como o container sobe a rotina (start.sh): sem isso a '
'materializacao vira passo manual e materia protocolada NUNCA '
'vira pendencia no app, em silencio.'))
def handle(self, *args, **options): def handle(self, *args, **options):
intervalo = options['intervalo']
if intervalo <= 0:
self._passada()
return
self.stdout.write(
'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo)
while True:
try:
self._passada()
except Exception as exc: # noqa — o laco NUNCA morre: se morrer,
# a materializacao para de vez e ninguem percebe ate a materia
# nao aparecer para assinar.
logger.exception('materializar_pdfs: passada falhou: %s', exc)
time.sleep(intervalo)
def _passada(self):
materias = (MateriaLegislativa.objects materias = (MateriaLegislativa.objects
.filter(numero_protocolo__isnull=False, .filter(numero_protocolo__isnull=False,
texto_original__isnull=False) texto_original__isnull=False)

61
sapl/integracao_hub/serializacao.py

@ -1,4 +1,5 @@
import hashlib import hashlib
import logging
import os import os
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -9,6 +10,8 @@ from sapl.base.models import Autor, OperadorAutor
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import MateriaLegislativa
from sapl.parlamentares.models import Parlamentar, Votante from sapl.parlamentares.models import Parlamentar, Votante
logger = logging.getLogger(__name__)
def _iso(valor): def _iso(valor):
if valor is None: if valor is None:
@ -95,6 +98,33 @@ def _sha256_do_arquivo(campo):
return hashlib.sha256(conteudo).hexdigest() return hashlib.sha256(conteudo).hexdigest()
def _bloco_documento(campo, request, nome_rota, materia_pk, hash_sha256=None):
"""Bloco do documento, ou `None` quando o binario nao esta no MEDIA.
Referencia no banco sem arquivo em disco (dump restaurado sem a media,
volume trocado) fazia `.size`/`.read()` levantar OSError e derrubar a
resposta INTEIRA do poll com 500. O cursor ficava parado no mesmo item e a
fonte travava para sempre uma materia podre bloqueando todas as outras.
Aqui o item continua na lista (o leitor avanca o cursor) e so o documento
vem nulo, com o buraco gritando no log.
"""
try:
tamanho = campo.size
digest = hash_sha256 if hash_sha256 is not None else _sha256_do_arquivo(campo)
except OSError:
logger.warning(
'materia %s: pdf ausente no MEDIA (%s) — item entregue sem '
'documento para o cursor nao travar', materia_pk, campo.name)
return None
return {
'nome': os.path.basename(campo.name),
'mime': 'application/pdf',
'tamanho_bytes': tamanho,
'url': _url_absoluta(request, nome_rota, materia_pk),
'hash_sha256': digest,
}
def _normalizar_assinatura_info(info): def _normalizar_assinatura_info(info):
# Mesma normalização da sprint (views_assinatura): dict legado vira lista. # Mesma normalização da sprint (views_assinatura): dict legado vira lista.
if info is None: if info is None:
@ -174,6 +204,12 @@ def serializar_pendencia(alvo, request):
"""Item de `assinaturas-pendentes` (§3): só existe com o PDF-alvo materializado.""" """Item de `assinaturas-pendentes` (§3): só existe com o PDF-alvo materializado."""
materia = alvo.materia materia = alvo.materia
return { return {
# Keyset da fonte: o hub le `id` no topo e devolve como `id_gt`, e a
# view filtra `materia_id__gt` (o alvo e OneToOne com a materia, ver
# AssinaturasPendentesPollView). Aqui vai o id da MATERIA, nao o do
# registro de pendencia — emitir alvo.pk faria o hub pedir uma pagina
# que a view nunca entende, relendo a mesma primeira pagina para sempre.
'id': materia.pk,
'materia': { 'materia': {
'id': materia.pk, 'id': materia.pk,
'numero': materia.numero, 'numero': materia.numero,
@ -181,14 +217,9 @@ def serializar_pendencia(alvo, request):
'ementa': materia.ementa, 'ementa': materia.ementa,
}, },
'autores_pendentes': _autores_pendentes(materia), 'autores_pendentes': _autores_pendentes(materia),
'documento': { 'documento': _bloco_documento(
'nome': os.path.basename(alvo.arquivo.name), alvo.arquivo, request, 'integracao_hub_documento_alvo',
'mime': 'application/pdf', materia.pk, hash_sha256=alvo.hash_sha256),
'tamanho_bytes': alvo.arquivo.size,
'url': _url_absoluta(
request, 'integracao_hub_documento_alvo', materia.pk),
'hash_sha256': alvo.hash_sha256,
},
} }
@ -249,19 +280,17 @@ def serializar_materia_assinada(materia, request):
}) })
return { return {
# Keyset da fonte: o hub le `id` no topo para o desempate do cursor
# composto (assinado_em, id).
'id': materia.pk,
'materia': { 'materia': {
'id': materia.pk, 'id': materia.pk,
'numero': materia.numero, 'numero': materia.numero,
'ano': materia.ano, 'ano': materia.ano,
}, },
'documento_assinado': { 'documento_assinado': _bloco_documento(
'nome': os.path.basename(materia.pdf_assinado.name), materia.pdf_assinado, request,
'mime': 'application/pdf', 'integracao_hub_documento_assinado', materia.pk),
'tamanho_bytes': materia.pdf_assinado.size,
'url': _url_absoluta(
request, 'integracao_hub_documento_assinado', materia.pk),
'hash_sha256': _sha256_do_arquivo(materia.pdf_assinado),
},
'codigo_autenticacao': materia.codigo_autenticacao, 'codigo_autenticacao': materia.codigo_autenticacao,
'assinado_em': _iso(materia.assinado_em), 'assinado_em': _iso(materia.assinado_em),
'assinaturas': assinaturas, 'assinaturas': assinaturas,

115
sapl/integracao_hub/tests/test_assinatura.py

@ -1,4 +1,5 @@
import hashlib import hashlib
import os
import uuid import uuid
from datetime import timedelta from datetime import timedelta
@ -496,3 +497,117 @@ def test_pendencia_do_titular_some_apos_assinatura_do_titular(cliente_hub):
item = next(i for i in resposta.data['resultados'] item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk) if i['materia']['id'] == materia.pk)
assert autor.pk not in item['autores_pendentes'] assert autor.pk not in item['autores_pendentes']
# ---------------------------------------------------------------------------
# Arquivo referenciado no banco mas ausente do MEDIA (969 matérias em Franco)
# ---------------------------------------------------------------------------
def apagar_do_disco(campo):
"""Deixa a referência no banco e some com o binário — o estado real do dump
restaurado sem a media. `.size`/`.read()` passam a levantar FileNotFoundError
(subclasse de OSError)."""
os.remove(campo.path)
@pytest.mark.django_db(transaction=False)
def test_concluidas_com_pdf_sumido_do_media_nao_derruba_o_lote(cliente_hub):
"""O bug que travava a fonte inteira: uma matéria podre respondia 500.
Com 500 o hub segurava o cursor e NENHUMA matéria passava uma referência
órfã bloqueava todas as outras, para sempre. O contrato agora é: 200, o item
aparece com `documento_assinado` nulo (o leitor avança o cursor) e as sadias
do mesmo lote continuam completas.
"""
podre, _ = criar_materia_com_alvo()
sadia, _ = criar_materia_com_alvo()
autor = criar_autor_com_operador('ver-a')
baker.make(Autoria, materia=podre, autor=autor)
baker.make(Autoria, materia=sadia, autor=autor)
assinar_localmente(podre)
assinar_localmente(sadia)
apagar_do_disco(podre.pdf_assinado)
resposta = cliente_hub.get(
BASE + 'assinaturas-concluidas/',
{'desde': (timezone.now() - timedelta(days=1)).isoformat(),
'id_gt': 0})
assert resposta.status_code == 200
por_materia = {i['materia']['id']: i for i in resposta.data['resultados']}
assert podre.pk in por_materia and sadia.pk in por_materia
item_podre = por_materia[podre.pk]
assert item_podre['documento_assinado'] is None
# O resto do item continua íntegro: só o binário faltou, não a assinatura.
assert item_podre['codigo_autenticacao'] == 'ABCD1234ABCD1234'
assert item_podre['assinaturas'][0]['signed_by'] == 'ver-a'
documento_sadio = por_materia[sadia.pk]['documento_assinado']
assert documento_sadio['hash_sha256'] == \
hashlib.sha256(PDF_ASSINADO).hexdigest()
assert documento_sadio['tamanho_bytes'] == len(PDF_ASSINADO)
@pytest.mark.django_db(transaction=False)
def test_pendentes_com_alvo_sumido_do_media_da_documento_nulo(cliente_hub):
"""Mesma regra na outra fonte: sem o PDF-alvo em disco, `documento` é nulo —
a pendência não vira evento no hub, mas o cursor não trava."""
materia, _ = criar_materia_com_alvo()
alvo = DocumentoParaAssinatura.objects.get(materia=materia)
apagar_do_disco(alvo.arquivo)
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', {'id_gt': 0})
assert resposta.status_code == 200
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assert item['documento'] is None
# Hash gravado no banco existe, mas sem o binário o bloco inteiro cai fora:
# entregar hash sem tamanho/URL utilizável seria mentir para o consumidor.
assert item['materia']['numero'] == materia.numero
# ---------------------------------------------------------------------------
# `id` no topo do item — o keyset que o hub usa para avançar o cursor
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_concluidas_traz_id_da_materia_no_topo(cliente_hub):
"""`PollerSapl.pollAssinaturasConcluidas` lê `item.path("id")` para o
desempate do cursor composto (assinado_em|id). Sem o campo o cursor gravava
id 0 e o instante relia a mesma página."""
materia, _ = criar_materia_com_alvo()
assinar_localmente(materia)
resposta = cliente_hub.get(
BASE + 'assinaturas-concluidas/',
{'desde': (timezone.now() - timedelta(days=1)).isoformat(),
'id_gt': 0})
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assert item['id'] == materia.pk
@pytest.mark.django_db(transaction=False)
def test_pendentes_traz_id_da_materia_no_topo(cliente_hub):
"""`PollerSapl.pollAssinaturasPendentes` lê `item.path("id")`. Sem o campo o
cursor virava string vazia e o ciclo seguinte estourava em
`Long.parseLong("")`.
E o valor tem que ser o id da MATÉRIA: o hub devolve esse número como
`id_gt` e a view filtra `materia_id__gt`. Mandar o pk do alvo faria o hub
pedir uma página que a view não entende releitura eterna da primeira.
A matéria descartada antes desalinha as sequências de propósito: com
`alvo.pk == materia.pk` o teste passaria por coincidência.
"""
baker.make(MateriaLegislativa, numero_protocolo=300) # desalinha os ids
materia, alvo = criar_materia_com_alvo()
assert alvo.pk != materia.pk
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', {'id_gt': 0})
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assert item['id'] == materia.pk

67
sapl/integracao_hub/tests/test_materializacao.py

@ -120,3 +120,70 @@ def test_falha_de_conversao_de_uma_materia_nao_trava_as_demais(
assert not DocumentoParaAssinatura.objects.filter( assert not DocumentoParaAssinatura.objects.filter(
materia=quebrada).exists() materia=quebrada).exists()
assert DocumentoParaAssinatura.objects.filter(materia=boa).exists() assert DocumentoParaAssinatura.objects.filter(materia=boa).exists()
# ---------------------------------------------------------------------------
# Modo laço (--intervalo): a rotina sobe junto do serviço, não é passo manual
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_sem_intervalo_roda_uma_passada_e_sai(db):
"""O padrão continua sendo a invocação manual de uma passada só."""
materia = criar_materia(protocolo=700)
call_command('materializar_pdfs_para_assinatura')
assert DocumentoParaAssinatura.objects.filter(materia=materia).exists()
@pytest.mark.django_db(transaction=False)
def test_com_intervalo_fica_em_laco_e_dorme_entre_passadas(db, monkeypatch):
"""Com --intervalo o comando NÃO retorna: é o modo que o start.sh usa.
Sem esse laço a materialização vira passo manual de implantação, e matéria
protocolada nunca vira pendência no app falha muda, sem erro nenhum.
Aqui o sleep corta o laço na terceira chamada para o teste terminar.
"""
criar_materia(protocolo=701)
dormidas = []
def sleep_que_interrompe(segundos):
dormidas.append(segundos)
if len(dormidas) == 3:
raise KeyboardInterrupt
monkeypatch.setattr(
'sapl.integracao_hub.management.commands'
'.materializar_pdfs_para_assinatura.time.sleep',
sleep_que_interrompe)
with pytest.raises(KeyboardInterrupt):
call_command('materializar_pdfs_para_assinatura', intervalo=30)
assert dormidas == [30, 30, 30]
@pytest.mark.django_db(transaction=False)
def test_laco_sobrevive_a_passada_que_estoura(db, monkeypatch):
"""Se o laço morrer, a materialização para de vez e ninguém percebe."""
from sapl.integracao_hub.management.commands import (
materializar_pdfs_para_assinatura as cmd)
passadas = []
def passada_que_explode(self):
passadas.append(1)
raise RuntimeError('banco caiu no meio da varredura')
monkeypatch.setattr(cmd.Command, '_passada', passada_que_explode)
def sleep_que_interrompe(segundos):
if len(passadas) == 2:
raise KeyboardInterrupt
monkeypatch.setattr(cmd.time, 'sleep', sleep_que_interrompe)
with pytest.raises(KeyboardInterrupt):
call_command('materializar_pdfs_para_assinatura', intervalo=5)
assert len(passadas) == 2, 'o laço deve seguir apos a passada que estourou'

Loading…
Cancel
Save