From 91c0fe7ffb2b314f72298e461cae3fdfdbe0770c Mon Sep 17 00:00:00 2001 From: Kemuel-sepulvida Date: Sat, 22 Aug 2026 11:25:05 -0300 Subject: [PATCH] fix(assinatura): materializacao do PDF-alvo sobe com o servico (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --- docker/docker-compose-dev.yml | 7 +- docker/startup_scripts/start.sh | 12 ++ .../materializar_pdfs_para_assinatura.py | 29 +++++ sapl/integracao_hub/serializacao.py | 61 +++++++--- sapl/integracao_hub/tests/test_assinatura.py | 115 ++++++++++++++++++ .../tests/test_materializacao.py | 67 ++++++++++ 6 files changed, 274 insertions(+), 17 deletions(-) diff --git a/docker/docker-compose-dev.yml b/docker/docker-compose-dev.yml index 8f8eeb1bf..40717a11e 100644 --- a/docker/docker-compose-dev.yml +++ b/docker/docker-compose-dev.yml @@ -15,7 +15,12 @@ services: build: context: ../ 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: - ..:/sapl-dev ports: diff --git a/docker/startup_scripts/start.sh b/docker/startup_scripts/start.sh index 686d0db5c..a6fb153ee 100755 --- a/docker/startup_scripts/start.sh +++ b/docker/startup_scripts/start.sh @@ -260,6 +260,17 @@ setup_cache_dir() { 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() { log "Starting gunicorn..." gunicorn -c gunicorn.conf.py & @@ -278,6 +289,7 @@ main() { create_admin setup_cache_dir fix_logging_and_socket_perms + start_materializacao_assinatura cat <<'BANNER' ------------------------------------- diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py index 88e7fb583..672e55ca2 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -1,6 +1,7 @@ import hashlib import logging import os +import time from django.conf import settings from django.core.files.base import ContentFile @@ -40,7 +41,35 @@ class Command(BaseCommand): 'de assinatura (decisão do arquiteto, 19/08/2026). Idempotente — ' '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): + 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 .filter(numero_protocolo__isnull=False, texto_original__isnull=False) diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index 6ac7c0304..1a1686692 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/sapl/integracao_hub/serializacao.py @@ -1,4 +1,5 @@ import hashlib +import logging import os 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.parlamentares.models import Parlamentar, Votante +logger = logging.getLogger(__name__) + def _iso(valor): if valor is None: @@ -95,6 +98,33 @@ def _sha256_do_arquivo(campo): 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): # Mesma normalização da sprint (views_assinatura): dict legado vira lista. 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.""" materia = alvo.materia 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': { 'id': materia.pk, 'numero': materia.numero, @@ -181,14 +217,9 @@ def serializar_pendencia(alvo, request): 'ementa': materia.ementa, }, 'autores_pendentes': _autores_pendentes(materia), - 'documento': { - 'nome': os.path.basename(alvo.arquivo.name), - 'mime': 'application/pdf', - 'tamanho_bytes': alvo.arquivo.size, - 'url': _url_absoluta( - request, 'integracao_hub_documento_alvo', materia.pk), - 'hash_sha256': alvo.hash_sha256, - }, + 'documento': _bloco_documento( + alvo.arquivo, request, 'integracao_hub_documento_alvo', + materia.pk, hash_sha256=alvo.hash_sha256), } @@ -249,19 +280,17 @@ def serializar_materia_assinada(materia, request): }) return { + # Keyset da fonte: o hub le `id` no topo para o desempate do cursor + # composto (assinado_em, id). + 'id': materia.pk, 'materia': { 'id': materia.pk, 'numero': materia.numero, 'ano': materia.ano, }, - 'documento_assinado': { - 'nome': os.path.basename(materia.pdf_assinado.name), - 'mime': 'application/pdf', - 'tamanho_bytes': materia.pdf_assinado.size, - 'url': _url_absoluta( - request, 'integracao_hub_documento_assinado', materia.pk), - 'hash_sha256': _sha256_do_arquivo(materia.pdf_assinado), - }, + 'documento_assinado': _bloco_documento( + materia.pdf_assinado, request, + 'integracao_hub_documento_assinado', materia.pk), 'codigo_autenticacao': materia.codigo_autenticacao, 'assinado_em': _iso(materia.assinado_em), 'assinaturas': assinaturas, diff --git a/sapl/integracao_hub/tests/test_assinatura.py b/sapl/integracao_hub/tests/test_assinatura.py index f8e84c774..488d01864 100644 --- a/sapl/integracao_hub/tests/test_assinatura.py +++ b/sapl/integracao_hub/tests/test_assinatura.py @@ -1,4 +1,5 @@ import hashlib +import os import uuid 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'] if i['materia']['id'] == materia.pk) 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 diff --git a/sapl/integracao_hub/tests/test_materializacao.py b/sapl/integracao_hub/tests/test_materializacao.py index f5e283411..28e868df1 100644 --- a/sapl/integracao_hub/tests/test_materializacao.py +++ b/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( materia=quebrada).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'