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