From 3683aa05c59ceebbe8f9cbcae94f621773f1da78 Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:26:04 -0300 Subject: [PATCH] Feat(Assinatura): materializacao idempotente do PDF-alvo, retificacao zera AB#1473 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Management command para cron na VPS (refinamento §5.1): a conversao DOCX->PDF acontece UMA vez, no SAPL (dono do OnlyOffice), fora do caminho quente das requisicoes. Reusa _gerar_pdf_da_materia da sprint em vez de duplicar — PDF copia os bytes, DOCX converte. - varre materias protocoladas com texto_original; sem alvo gera, com alvo em dia pula (idempotente), hash_origem divergente e RETIFICACAO - retificacao ZERA o processo de assinatura (decisao do arquiteto, 19/08/2026): regenera o alvo e limpa pdf_assinado, assinatura_info, assinado_em, assinado_por e codigo_autenticacao — mesmo efeito da rotina remover-assinatura. Assinar texto retificado e impossivel por construcao. - falha de conversao de uma materia loga e segue: OnlyOffice fora do ar nao trava o ciclo, a pendencia aparece no ciclo seguinte Co-Authored-By: Claude Fable 5 --- sapl/integracao_hub/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../materializar_pdfs_para_assinatura.py | 134 ++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 sapl/integracao_hub/management/__init__.py create mode 100644 sapl/integracao_hub/management/commands/__init__.py create mode 100644 sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py diff --git a/sapl/integracao_hub/management/__init__.py b/sapl/integracao_hub/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/management/commands/__init__.py b/sapl/integracao_hub/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py new file mode 100644 index 000000000..88e7fb583 --- /dev/null +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -0,0 +1,134 @@ +import hashlib +import logging +import os + +from django.conf import settings +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand +from django.db import transaction + +from sapl.integracao_hub.models import DocumentoParaAssinatura +from sapl.materia.models import MateriaLegislativa + +logger = logging.getLogger(__name__) + + +class _RequisicaoDeSistema: + """Substituto mínimo de request para reusar `_gerar_pdf_da_materia` fora do HTTP. + + A rotina da sprint só usa o request em dois pontos do caminho DOCX: + `build_onlyoffice_url` (que prefere `SAPL_INTERNAL_URL` e só cai no request + sem ela — aqui caímos em `SITE_URL`) e `generate_file_key` (pk do usuário, + que num cron não existe — 0 identifica o sistema). O caminho PDF não toca + o request. Reuso em vez de cópia: a conversão OnlyOffice tem UM dono (§5.1). + """ + + class _UsuarioDeSistema: + pk = 0 + + user = _UsuarioDeSistema() + + def build_absolute_uri(self, caminho): + base = getattr(settings, 'SITE_URL', '') or '' + return base.rstrip('/') + caminho + + +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 ' + 'vez e, em retificação do texto, regenera o alvo e ZERA o processo ' + 'de assinatura (decisão do arquiteto, 19/08/2026). Idempotente — ' + 'feito para cron.') + + def handle(self, *args, **options): + materias = (MateriaLegislativa.objects + .filter(numero_protocolo__isnull=False, + texto_original__isnull=False) + .exclude(texto_original='') + .order_by('id')) + + gerados = retificados = pulados = falhas = 0 + for materia in materias.iterator(): + try: + resultado = self._materializar(materia) + 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', + materia.pk, exc) + falhas += 1 + continue + if resultado == 'gerado': + gerados += 1 + elif resultado == 'retificado': + retificados += 1 + elif resultado == 'falha': + falhas += 1 + else: + pulados += 1 + + self.stdout.write( + 'materializar_pdfs: %s gerados, %s retificados, %s em dia, ' + '%s falhas' % (gerados, retificados, pulados, falhas)) + + def _materializar(self, materia): + materia.texto_original.open('rb') + try: + conteudo_origem = materia.texto_original.read() + finally: + materia.texto_original.close() + hash_origem = hashlib.sha256(conteudo_origem).hexdigest() + + alvo = DocumentoParaAssinatura.objects.filter(materia=materia).first() + if alvo is not None and alvo.hash_origem == hash_origem: + return 'em dia' # idempotência: nada mudou desde a geração + + # 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). + from sapl.materia.views_assinatura import _gerar_pdf_da_materia + pdf_bytes, erro = _gerar_pdf_da_materia( + materia, _RequisicaoDeSistema()) + if erro: + logger.error( + 'materializar_pdfs: matéria %s não convertida (%s) — segue ' + 'visível só na tela do SAPL até o próximo ciclo', materia.pk, + erro) + return 'falha' + + nome = 'materia_%s_alvo.pdf' % materia.pk + hash_alvo = hashlib.sha256(pdf_bytes).hexdigest() + + with transaction.atomic(): + if alvo is None: + alvo = DocumentoParaAssinatura( + materia=materia, hash_sha256=hash_alvo, + hash_origem=hash_origem) + alvo.arquivo.save(nome, ContentFile(pdf_bytes), save=True) + logger.info( + 'materializar_pdfs: PDF-alvo da matéria %s gerado (%s)', + materia.pk, hash_alvo) + return 'gerado' + + # RETIFICAÇÃO (§5.1, decisão do arquiteto 19/08): texto_original + # mudou depois da conversão → o alvo está defasado. Regenera E zera + # o processo de assinatura — mesmo efeito da rotina + # `materia_remover_assinatura` que o SAPL já tem. Assinatura sobre + # texto retificado é impossível por construção. + alvo.arquivo.delete(save=False) + alvo.hash_sha256 = hash_alvo + alvo.hash_origem = hash_origem + alvo.arquivo.save(nome, ContentFile(pdf_bytes), save=True) + + if materia.pdf_assinado: + materia.pdf_assinado.delete(save=False) + materia.pdf_assinado = None + materia.assinatura_info = None + materia.assinado_em = None + materia.assinado_por = None + materia.codigo_autenticacao = None + materia.save() + + logger.info( + 'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) ' + 'e assinaturas zeradas', materia.pk, hash_alvo) + return 'retificado'