From 2ac550b28a0d5081951eb6f8f3262b7bc0932b4f Mon Sep 17 00:00:00 2001 From: Kemuel-sepulvida Date: Sat, 22 Aug 2026 12:16:18 -0300 Subject: [PATCH] Fix(Assinatura): materializacao recusa rodar sem base URL em vez de falhar 861 vezes AB#1473 (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achado num acervo real em 22/08/2026: 0 de 861 materias DOCX tinham PDF-alvo, e o motivo nao aparecia em lugar nenhum. So os 7 PDF passavam — PDF nao converte, copia bytes — e davam a impressao de que a rotina estava viva. A conversao DOCX->PDF e o OnlyOffice quem faz, e para isso ele BAIXA o documento de origem por URL absoluta. `build_onlyoffice_url` prefere SAPL_INTERNAL_URL e so cai no request sem ela; no cron o "request" e o `_RequisicaoDeSistema`, que monta a partir de SITE_URL. Com as duas vazias a URL sai SEM HOST, o OnlyOffice responde erro e cada materia vira um logger.error individual. O resumo da passada (`0 gerados, 873 falhas`) nao se distingue de um dia normal no meio do log. Duas mudancas, as duas sobre visibilidade: 1. Erro de CONFIGURACAO morre no comeco, alto: sem SAPL_INTERNAL_URL nem SITE_URL o comando levanta CommandError antes de varrer o acervo, dizendo qual variavel falta e por que. Vale para a passada unica e para o laco. 2. Passada que falha sem NENHUM gerado grita no stderr: falha em todas nao e documento podre avulso, e ambiente parado (OnlyOffice fora, URL que ele nao alcanca, MEDIA sem os binarios). A fixture `base_url_configurada` entra em autouse no test_materializacao: base URL e pressuposto de toda materializacao, nao caso de borda — a ausencia dela tem os seus proprios testes. Testes: 64 passed em sapl/integracao_hub/tests/ (eram 61). --- .../materializar_pdfs_para_assinatura.py | 44 +++++++++++- .../tests/test_materializacao.py | 70 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) 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 672e55ca2..77d78e829 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -5,7 +5,7 @@ import time from django.conf import settings from django.core.files.base import ContentFile -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.db import transaction from sapl.integracao_hub.models import DocumentoParaAssinatura @@ -34,6 +34,21 @@ class _RequisicaoDeSistema: return base.rstrip('/') + caminho +def _base_url_de_sistema(): + """URL que o OnlyOffice usa para BAIXAR o documento de origem, no caminho cron. + + `build_onlyoffice_url` prefere `SAPL_INTERNAL_URL` e so cai no request sem + ela — e no cron o "request" e o `_RequisicaoDeSistema`, que monta a partir de + `SITE_URL`. Sem nenhuma das duas, a URL sai SEM HOST, o OnlyOffice nao + consegue baixar e TODA materia DOCX falha na conversao. Descoberto em + 22/08/2026 num acervo real: 0 de 861 DOCX materializaram, cada uma virando um + logger.error que ninguem le, enquanto os 7 PDF passavam (PDF nao converte, so + copia bytes) e davam a impressao de que a rotina estava viva. + """ + return (getattr(settings, 'SAPL_INTERNAL_URL', '') + or getattr(settings, 'SITE_URL', '') or '') + + class Command(BaseCommand): help = ('Materializa o PDF-alvo da assinatura (refinamento §5/§5.1): varre ' 'matérias protocoladas com texto_original, gera o PDF uma única ' @@ -54,6 +69,18 @@ class Command(BaseCommand): 'vira pendencia no app, em silencio.')) def handle(self, *args, **options): + # Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por + # materia num log que ninguem le. Vale para a passada unica e para o laco: + # o container que sobe sem isso nunca materializa nada. + if not _base_url_de_sistema(): + raise CommandError( + 'materializar_pdfs: nem SAPL_INTERNAL_URL nem SITE_URL estao ' + 'configuradas. A conversao DOCX->PDF passa pelo OnlyOffice, que ' + 'BAIXA o documento de origem por URL absoluta — sem host ele ' + 'responde erro e NENHUMA materia DOCX vira pendencia de ' + 'assinatura. Configure SAPL_INTERNAL_URL com uma URL deste SAPL ' + 'que o servidor do OnlyOffice alcance.') + intervalo = options['intervalo'] if intervalo <= 0: self._passada() @@ -99,6 +126,21 @@ class Command(BaseCommand): 'materializar_pdfs: %s gerados, %s retificados, %s em dia, ' '%s falhas' % (gerados, retificados, pulados, falhas)) + # Falha sem NENHUM avanco nao e materia podre avulsa: e o ambiente + # inteiro parado (OnlyOffice fora do ar, URL que ele nao alcanca, MEDIA + # sem os binarios). Some do log comum porque cada materia falha + # individualmente e o resumo parece so mais uma linha de rotina. + if falhas and not gerados and not retificados: + aviso = ( + 'materializar_pdfs: %s falhas e NENHUM PDF-alvo gerado — isso e ' + 'ambiente, nao documento. Confira o OnlyOffice em %s e se ele ' + 'alcanca %s; enquanto isso nenhuma materia DOCX vira pendencia ' + 'de assinatura no app.' % ( + falhas, getattr(settings, 'ONLYOFFICE_URL', ''), + _base_url_de_sistema())) + self.stderr.write(aviso) + logger.error(aviso) + def _materializar(self, materia): materia.texto_original.open('rb') try: diff --git a/sapl/integracao_hub/tests/test_materializacao.py b/sapl/integracao_hub/tests/test_materializacao.py index 28e868df1..64d9a08be 100644 --- a/sapl/integracao_hub/tests/test_materializacao.py +++ b/sapl/integracao_hub/tests/test_materializacao.py @@ -3,6 +3,7 @@ import hashlib import pytest from django.core.files.base import ContentFile from django.core.management import call_command +from django.core.management.base import CommandError from django.utils import timezone from model_bakery import baker @@ -13,6 +14,17 @@ from sapl.materia.models import MateriaLegislativa PDF = b'%PDF-1.4 conteudo-original' +@pytest.fixture(autouse=True) +def base_url_configurada(settings): + """Toda materializacao pressupoe uma base URL — o comando recusa rodar sem. + + Fica em autouse porque a ausencia dela nao e um caso de borda a exercitar em + cada teste: e erro de ambiente, e tem os seus proprios dois testes abaixo. + """ + settings.SAPL_INTERNAL_URL = 'http://sapl-interno:8000' + settings.SITE_URL = '' + + def criar_materia(protocolo=100, conteudo=PDF, nome='texto.pdf'): materia = baker.make(MateriaLegislativa, numero_protocolo=protocolo) if conteudo is not None: @@ -187,3 +199,61 @@ def test_laco_sobrevive_a_passada_que_estoura(db, monkeypatch): call_command('materializar_pdfs_para_assinatura', intervalo=5) assert len(passadas) == 2, 'o laço deve seguir apos a passada que estourou' + + +# --------------------------------------------------------------------------- +# Guarda de configuração: sem base URL o OnlyOffice não baixa nada +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_recusa_rodar_sem_nenhuma_base_url(db, settings): + """O modo de falha que custou o acervo de Franco (22/08/2026). + + Sem SAPL_INTERNAL_URL nem SITE_URL a URL de download sai sem host, o + OnlyOffice responde erro e TODA matéria DOCX falha na conversão — 861 delas, + cada uma como um `logger.error` que ninguém lê, enquanto os poucos PDF + passavam e davam a impressão de rotina viva. Erro de ambiente morre no + começo, não em 861 falhas por documento. + """ + settings.SAPL_INTERNAL_URL = '' + settings.SITE_URL = '' + materia = criar_materia() + + with pytest.raises(CommandError) as erro: + call_command('materializar_pdfs_para_assinatura') + + assert 'SAPL_INTERNAL_URL' in str(erro.value) + assert not DocumentoParaAssinatura.objects.filter(materia=materia).exists() + + +@pytest.mark.django_db(transaction=False) +def test_site_url_sozinha_basta(db, settings): + """SITE_URL é o fallback legítimo — a guarda pega ausência das DUAS.""" + settings.SAPL_INTERNAL_URL = '' + settings.SITE_URL = 'https://sapl.exemplo.gov.br' + materia = criar_materia() + + call_command('materializar_pdfs_para_assinatura') + + assert DocumentoParaAssinatura.objects.filter(materia=materia).exists() + + +@pytest.mark.django_db(transaction=False) +def test_passada_sem_nenhum_avanco_grita_no_stderr(db, monkeypatch, capsys): + """Falha sem nenhum gerado é ambiente parado, não documento podre avulso. + + É o resumo que passava por linha de rotina: `0 gerados, 873 falhas` não se + distingue de um dia normal no meio do log. + """ + criar_materia(protocolo=201, nome='a.docx', conteudo=b'docx') + criar_materia(protocolo=202, nome='b.docx', conteudo=b'docx') + + monkeypatch.setattr( + 'sapl.materia.views_assinatura._gerar_pdf_da_materia', + lambda materia, request: (None, 'OnlyOffice: codigo -8')) + + call_command('materializar_pdfs_para_assinatura') + + erro = capsys.readouterr().err + assert 'NENHUM PDF-alvo gerado' in erro + assert 'e ambiente, nao documento' in erro