From 1910280e3e9e13b840726f7889cadb37640a54d0 Mon Sep 17 00:00:00 2001 From: Kemuel-sepulvida Date: Sat, 22 Aug 2026 13:16:36 -0300 Subject: [PATCH] Fix(Assinatura): falha em massa diz o motivo, e a varredura nao apaga assinatura AB#1473 (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix(Assinatura): falha em massa vira uma linha legivel e URL de outra instancia nao converte AB#1473 Duas lacunas que o acervo real de Franco expos em 22/08/2026, as duas sobre a materializacao falhar sem que ninguem consiga agir. 1. FALHA EM MASSA AGREGADA POR MOTIVO `2 gerados, 873 falhas` passava por linha de rotina, e as 873 eram todas o MESMO erro — 873 logger.error dispersos que ninguem le. O aviso anterior so disparava com ZERO gerados, entao duas materias PDF passando escondiam o lote inteiro de DOCX parado. Agora o motivo dominante sai numa linha com a contagem. Uma falha isolada continua sem gritar: materia podre avulsa e ruido esperado (§5.1). Junto vai a dica que evita consertar a variavel errada: `codigo -8` do OnlyOffice e erro de TOKEN, nao de URL. Eu mesmo li errado primeiro e mandei configurar SAPL_INTERNAL_URL. JWT desligado AQUI e exatamente o que produz -8 quando o SERVIDOR do OnlyOffice exige assinatura — provado com POST direto ao ConvertService, sem token, com URL publica respondendo 200: devolve -8. Se fosse download quebrado seria -4. A dica so aparece com ONLYOFFICE_JWT_ENABLED=False; com JWT ligado o -8 e outra coisa e a dica viraria pista falsa. 2. CONFERENCIA DA ORIGEM SERVIDA `SAPL_INTERNAL_URL` e configuracao de operador e nao ha de onde deduzi-la: nao ha contrib.sites, ALLOWED_HOSTS e ['*'] e no cron nao existe request. E, mais a fundo, so quem opera sabe qual URL o servidor do OnlyOffice alcanca. O risco nao e ela ser fixa — e ela apontar para OUTRA instancia em silencio: o hash_origem sai do arquivo local e o PDF-alvo do arquivo do outro SAPL. Como e esse hash que dispara a retificacao (§5.1), o alvo defasado nunca mais e regenerado e assina-se um PDF que nao corresponde ao texto da materia. Baixar a propria URL e comparar o sha256 fecha isso sem adivinhacao: seja qual for o valor configurado, so passa se servir ESTE documento. Rodado contra o ambiente local com SAPL_INTERNAL_URL apontando para demo.legisinc.com.br, pegou na hora — materias 1066 e 1072 sao documentos diferentes nas duas instancias. So o caminho DOCX confere; PDF copia bytes e nao toca o OnlyOffice. Testes: 70 passed em sapl/integracao_hub/tests/ (eram 64). * Fix(Assinatura): falha em massa diz o motivo, e a varredura nao apaga assinatura AB#1473 O #17 fez a materializacao recusar rodar sem base URL. Faltava o resto: com base URL configurada e o OnlyOffice recusando a conversao, as 861 falhas de Franco voltavam a ser 861 logger.error dispersos, e o aviso de lote so disparava quando NENHUM PDF-alvo era gerado — bastava um dos 21 PDF passar na mesma passada para o alerta sumir. Tres mudancas: 1. As falhas sao agrupadas por MOTIVO e o motivo dominante grita. `873 falhas` nao diz nada; `873 de 873 falhas pelo MESMO motivo: OnlyOffice codigo -8` diz que o ambiente esta parado. Nao depende mais de zero gerados. 2. O -8 ganha o diagnostico certo. A leitura natural (URL ruim) manda consertar a variavel errada: -8 e erro de TOKEN. Com `ONLYOFFICE_JWT_ENABLED=False` e o servidor do OnlyOffice exigindo JWT, e exatamente esse o codigo. Provado em 22/08/2026 com POST direto ao ConvertService.ashx: URL publica respondendo 200, sem token, devolve -8 (download quebrado seria -4). 3. `--somente-novos`: gera o alvo AUSENTE e nunca retifica. Zerar assinatura em retificacao (§5.1) foi decidido para o ato isolado de retificar um texto — quem retifica sabe o que esta desfazendo. Numa passada sobre o acervo inteiro ninguem pediu isso, e apagar assinatura e irreversivel. A decisao acontece ANTES da conversao: adiar depois seria pagar o OnlyOffice para jogar fora. Junto vai `_origem_servida_confere`: antes de gastar a conversao de um DOCX, baixa a propria URL entregue ao OnlyOffice e compara o sha256 com o texto lido. SAPL_INTERNAL_URL apontando para OUTRA instancia gera um PDF-alvo que nao corresponde ao texto da materia — e como e o `hash_origem` que dispara a retificacao, o alvo defasado nunca mais seria regenerado. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../materializar_pdfs_para_assinatura.py | 47 ++++++-- .../tests/test_materializacao.py | 100 +++++++++++++++++- 2 files changed, 140 insertions(+), 7 deletions(-) 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 ecd7a6ce5..a6606f068 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -106,6 +106,17 @@ class Command(BaseCommand): '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.')) + parser.add_argument( + '--somente-novos', + action='store_true', + help=('Gera apenas o PDF-alvo AUSENTE; nunca entra na retificacao. ' + 'E o modo da passada de recuperacao: quando um acervo inteiro ' + 'materializa de uma vez (o dia em que a conversao DOCX volta ' + 'a funcionar), retificar em lote apagaria assinatura ja feita ' + '— e a decisao de zerar assinatura (§5.1) foi tomada para o ' + 'ato isolado de retificar um texto, nao para uma varredura. ' + 'A retificacao adiada aparece no resumo e roda no ciclo ' + 'normal, uma a uma.')) def handle(self, *args, **options): # Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por @@ -121,32 +132,33 @@ class Command(BaseCommand): 'que o servidor do OnlyOffice alcance.') intervalo = options['intervalo'] + somente_novos = options['somente_novos'] if intervalo <= 0: - self._passada() + self._passada(somente_novos) return self.stdout.write( 'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo) while True: try: - self._passada() + self._passada(somente_novos) 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): + def _passada(self, somente_novos=False): materias = (MateriaLegislativa.objects .filter(numero_protocolo__isnull=False, texto_original__isnull=False) .exclude(texto_original='') .order_by('id')) - gerados = retificados = pulados = falhas = 0 + gerados = retificados = pulados = falhas = adiados = 0 motivos = {} for materia in materias.iterator(): try: - resultado, motivo = self._materializar(materia) + resultado, motivo = self._materializar(materia, somente_novos) except Exception as exc: # noqa — uma matéria não trava as demais (§5.1) logger.exception( 'materializar_pdfs: falha inesperada na matéria %s: %s', @@ -162,6 +174,8 @@ class Command(BaseCommand): elif resultado == 'falha': falhas += 1 motivos[motivo] = motivos.get(motivo, 0) + 1 + elif resultado == 'adiado': + adiados += 1 else: pulados += 1 @@ -169,6 +183,16 @@ class Command(BaseCommand): 'materializar_pdfs: %s gerados, %s retificados, %s em dia, ' '%s falhas' % (gerados, retificados, pulados, falhas)) + # Adiada nao e "em dia": e trabalho pendente que este modo se recusou a + # fazer. Some do resumo comum e vira surpresa quando alguem estranhar + # que o alvo nao acompanhou o texto. + if adiados: + self.stdout.write( + 'materializar_pdfs: %s retificacao(oes) ADIADA(S) por ' + '--somente-novos — o texto mudou e o PDF-alvo segue defasado. ' + 'Rodar sem a flag para regenerar (zera a assinatura da materia, ' + '§5.1).' % adiados) + # Falha em MASSA por um motivo so nao e materia podre avulsa: e o # ambiente parado. O contador sozinho nao dizia isso — `2 gerados, 873 # falhas` passava por linha de rotina, e as 873 eram todas o MESMO erro. @@ -200,7 +224,7 @@ class Command(BaseCommand): self.stderr.write(aviso) logger.error(aviso) - def _materializar(self, materia): + def _materializar(self, materia, somente_novos=False): materia.texto_original.open('rb') try: conteudo_origem = materia.texto_original.read() @@ -212,6 +236,17 @@ class Command(BaseCommand): if alvo is not None and alvo.hash_origem == hash_origem: return 'em dia', None # idempotência: nada mudou desde a geração + # A passada de recuperação não retifica — e decide isso ANTES de gastar + # a conversão, não depois. Alvo defasado é um problema; apagar + # assinatura já feita, em lote e sem ninguém pedir, é um problema pior e + # irreversível. A decisão de zerar (§5.1) foi tomada para o ato isolado + # de retificar um texto, não para uma varredura de acervo inteiro. + if alvo is not None and somente_novos: + logger.info( + 'materializar_pdfs: matéria %s precisa de retificação — ADIADA ' + 'por --somente-novos (sem conversão)', materia.pk) + return 'adiado', None + # Reuso da rotina da sprint: PDF copia os bytes, DOCX converte no # OnlyOffice — a conversão acontece UMA vez, aqui, fora do caminho # quente das requisições (§5.1). diff --git a/sapl/integracao_hub/tests/test_materializacao.py b/sapl/integracao_hub/tests/test_materializacao.py index 43369f9c5..93cd1e14c 100644 --- a/sapl/integracao_hub/tests/test_materializacao.py +++ b/sapl/integracao_hub/tests/test_materializacao.py @@ -183,7 +183,7 @@ def test_laco_sobrevive_a_passada_que_estoura(db, monkeypatch): passadas = [] - def passada_que_explode(self): + def passada_que_explode(self, somente_novos=False): passadas.append(1) raise RuntimeError('banco caiu no meio da varredura') @@ -402,3 +402,101 @@ def test_pdf_nao_passa_pela_conferencia_de_url(db, monkeypatch): call_command('materializar_pdfs_para_assinatura') assert DocumentoParaAssinatura.objects.filter(materia=materia).exists() + + +# --------------------------------------------------------------------------- +# --somente-novos: a passada de recuperação não pode apagar assinatura +# --------------------------------------------------------------------------- + +@pytest.mark.django_db(transaction=False) +def test_somente_novos_gera_o_alvo_ausente(db): + """É para isso que o modo existe: o acervo que nunca materializou. + + O dia em que a conversão DOCX voltar a funcionar, 861 matérias (Franco, + 22/08/2026) materializam de uma vez. Gerar o que falta é exatamente o que + se quer dessa passada. + """ + materia = criar_materia(protocolo=300) + + call_command('materializar_pdfs_para_assinatura', somente_novos=True) + + assert DocumentoParaAssinatura.objects.filter(materia=materia).exists() + + +@pytest.mark.django_db(transaction=False) +def test_somente_novos_adia_retificacao_e_preserva_assinatura(db): + """A varredura não pode zerar assinatura por efeito colateral. + + Zerar em retificação (§5.1) foi decidido para o ato isolado de retificar um + texto — quem retifica sabe o que está desfazendo. Numa passada sobre o + acervo inteiro ninguém pediu isso, e apagar assinatura é irreversível. + """ + materia = criar_materia(protocolo=301) + call_command('materializar_pdfs_para_assinatura') + alvo_antes = DocumentoParaAssinatura.objects.get(materia=materia) + + usuario = baker.make('auth.User', username='ver-b') + materia.refresh_from_db() + materia.pdf_assinado.save( + 'materia_%s_assinado_1.pdf' % materia.pk, + ContentFile(b'%PDF-assinado'), save=False) + materia.assinatura_info = [{'signed_by': 'ver-b', 'nome': 'Ver. B'}] + materia.assinado_em = timezone.now() + materia.assinado_por = usuario + materia.codigo_autenticacao = 'EFGH5678EFGH5678' + materia.save() + + materia.texto_original.save( + 'texto.pdf', ContentFile(b'%PDF-1.4 retificado'), save=True) + + call_command('materializar_pdfs_para_assinatura', somente_novos=True) + + alvo = DocumentoParaAssinatura.objects.get(materia=materia) + assert alvo.hash_origem == alvo_antes.hash_origem # alvo intocado + materia.refresh_from_db() + assert materia.pdf_assinado + assert materia.assinatura_info == [{'signed_by': 'ver-b', 'nome': 'Ver. B'}] + assert materia.codigo_autenticacao == 'EFGH5678EFGH5678' + + +@pytest.mark.django_db(transaction=False) +def test_somente_novos_decide_antes_de_gastar_a_conversao(db, monkeypatch): + """Adiar depois de converter seria pagar o OnlyOffice para jogar fora. + + Numa passada de recuperação sobre um acervo grande isso é a diferença entre + minutos e horas de conversão desperdiçada. + """ + materia = criar_materia(protocolo=302, nome='a.docx', conteudo=b'docx-v1') + monkeypatch.setattr( + MOD + '.http_requests.get', + lambda url, timeout: _RespostaFalsa(b'docx-v1')) + monkeypatch.setattr( + 'sapl.materia.views_assinatura._gerar_pdf_da_materia', + lambda m, r: (PDF, None)) + call_command('materializar_pdfs_para_assinatura') + + materia.texto_original.save('a.docx', ContentFile(b'docx-v2'), save=True) + + def nao_deveria_converter(*args, **kwargs): + raise AssertionError('conversão gasta em matéria que seria adiada') + + monkeypatch.setattr( + 'sapl.materia.views_assinatura._gerar_pdf_da_materia', + nao_deveria_converter) + monkeypatch.setattr(MOD + '.http_requests.get', nao_deveria_converter) + + call_command('materializar_pdfs_para_assinatura', somente_novos=True) + + +@pytest.mark.django_db(transaction=False) +def test_adiada_aparece_no_resumo(db, capsys): + """Adiada não é "em dia" — trabalho pendente calado vira surpresa depois.""" + materia = criar_materia(protocolo=303) + call_command('materializar_pdfs_para_assinatura') + materia.texto_original.save( + 'texto.pdf', ContentFile(b'%PDF-1.4 outro'), save=True) + capsys.readouterr() + + call_command('materializar_pdfs_para_assinatura', somente_novos=True) + + assert 'ADIADA(S) por --somente-novos' in capsys.readouterr().out