diff --git a/.gitignore b/.gitignore index 8b4781c39..26422928f 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,6 @@ start.sh *.sql dump_*.sql -DADOS \ No newline at end of file +DADOS +# Backups de .env criados por script de deploy — carregam SECRET_KEY e chaves de API +.env.bak* diff --git a/knowledge b/knowledge index 2ee89cb5c..f8c0df145 160000 --- a/knowledge +++ b/knowledge @@ -1 +1 @@ -Subproject commit 2ee89cb5c88e602f8a61175146d054485747cca0 +Subproject commit f8c0df145abbb482cb80760a298f2bdff5e07578 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 a6606f068..1dfcad0a6 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py @@ -7,13 +7,23 @@ import requests as http_requests from django.conf import settings from django.core.files.base import ContentFile from django.core.management.base import BaseCommand, CommandError -from django.db import transaction +from django.db import IntegrityError, transaction +from django.utils import timezone -from sapl.integracao_hub.models import DocumentoParaAssinatura +from sapl.integracao_hub.models import (DocumentoParaAssinatura, + MateriaComFalhaMaterializacao, + PassadaMaterializacao) from sapl.materia.models import MateriaLegislativa logger = logging.getLogger(__name__) +# Depois de quanto tempo uma passada `em_andamento` é considerada órfã e tem o +# lock liberado. Precisa folgar sobre a passada mais longa plausível: um acervo +# de ~900 matérias a ~4s de conversão cada roda em torno de 1h. Abaixo disso o +# destravamento automático arriscaria derrubar uma passada viva; muito acima, +# um `kill -9` deixaria a rotina travada por um turno inteiro. +HORAS_ATE_CONSIDERAR_ORFA = 6 + class _RequisicaoDeSistema: """Substituto mínimo de request para reusar `_gerar_pdf_da_materia` fora do HTTP. @@ -117,6 +127,13 @@ class Command(BaseCommand): 'ato isolado de retificar um texto, nao para uma varredura. ' 'A retificacao adiada aparece no resumo e roda no ciclo ' 'normal, uma a uma.')) + parser.add_argument( + '--disparada-por', + default='', + metavar='USUARIO', + help=('Quem pediu esta passada. Preenchido pelo botao "Rodar ' + 'agora" do painel; vazio no laco. E rastro operacional, nao ' + 'controle de acesso — a tela ja gateia por pode_integrar.')) def handle(self, *args, **options): # Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por @@ -133,21 +150,80 @@ class Command(BaseCommand): intervalo = options['intervalo'] somente_novos = options['somente_novos'] + disparada_por = options['disparada_por'] if intervalo <= 0: - self._passada(somente_novos) + self._passada_registrada(somente_novos, disparada_por) return self.stdout.write( 'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo) while True: try: - self._passada(somente_novos) + self._passada_registrada(somente_novos, disparada_por) 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, somente_novos=False): + def _fechar_orfas(self): + """Libera o lock de passada cujo processo morreu sem fechar a linha. + + `kill`, reboot ou OOM deixam `em_andamento=True` para sempre, e como + esse campo é o índice único que serializa as passadas, a rotina inteira + ficaria travada — a MESMA falha muda que este painel existe para acabar. + Fecha marcando `abandonada`, para os contadores incompletos não passarem + por resultado real na tela. + """ + limite = timezone.now() - timezone.timedelta( + hours=HORAS_ATE_CONSIDERAR_ORFA) + orfas = PassadaMaterializacao.objects.filter( + em_andamento=True, iniciada_em__lt=limite) + for orfa in orfas: + orfa.em_andamento = None + orfa.terminada_em = timezone.now() + orfa.abandonada = True + orfa.save(update_fields=['em_andamento', 'terminada_em', + 'abandonada']) + logger.warning( + 'materializar_pdfs: passada %s abandonada (aberta desde %s) — ' + 'lock liberado', orfa.pk, orfa.iniciada_em) + + def _passada_registrada(self, somente_novos=False, disparada_por=''): + """Envelope da passada: abre a linha, roda, fecha — sempre fecha. + + A linha existe para a tela responder "quando rodou, o que fez, e por que + falhou" sem ninguém abrir shell. E o `em_andamento` único garante que + laço e botão nunca convertam o mesmo documento ao mesmo tempo. + """ + self._fechar_orfas() + + disparo = (PassadaMaterializacao.DISPARO_MANUAL if disparada_por + else PassadaMaterializacao.DISPARO_LACO) + try: + # `atomic` aqui não é transação de negócio: sem ele a IntegrityError + # do índice único envenena a transação corrente e o rollback leva + # junto o que vier depois. + with transaction.atomic(): + passada = PassadaMaterializacao.objects.create( + disparo=disparo, + disparada_por=disparada_por, + somente_novos=somente_novos) + except IntegrityError: + self.stdout.write( + 'materializar_pdfs: ja ha uma passada em andamento — esta ' + 'foi dispensada') + logger.info('materializar_pdfs: passada dispensada (lock ocupado)') + return None + + try: + self._passada(somente_novos, passada) + finally: + passada.em_andamento = None + passada.terminada_em = timezone.now() + passada.save() + return passada + + def _passada(self, somente_novos=False, passada=None): materias = (MateriaLegislativa.objects .filter(numero_protocolo__isnull=False, texto_original__isnull=False) @@ -166,18 +242,35 @@ class Command(BaseCommand): falhas += 1 motivos[type(exc).__name__] = motivos.get( type(exc).__name__, 0) + 1 + self._registrar_falha(materia, type(exc).__name__) continue if resultado == 'gerado': gerados += 1 + self._limpar_falha(materia) elif resultado == 'retificado': retificados += 1 + self._limpar_falha(materia) elif resultado == 'falha': falhas += 1 motivos[motivo] = motivos.get(motivo, 0) + 1 + self._registrar_falha(materia, motivo) elif resultado == 'adiado': + # Adiada não é falha: o alvo existe e funciona, só está + # defasado. Não mexe na linha de falha — nem cria, nem apaga. adiados += 1 else: pulados += 1 + self._limpar_falha(materia) + + if passada is not None: + passada.gerados = gerados + passada.retificados = retificados + passada.em_dia = pulados + passada.falhas = falhas + passada.adiados = adiados + passada.motivos = motivos + passada.save(update_fields=['gerados', 'retificados', 'em_dia', + 'falhas', 'adiados', 'motivos']) self.stdout.write( 'materializar_pdfs: %s gerados, %s retificados, %s em dia, ' @@ -200,6 +293,20 @@ class Command(BaseCommand): # que se le e se age. Em 22/08/2026 essas 873 eram um unico `codigo -8`. self._relatar_motivos(motivos, falhas) + def _registrar_falha(self, materia, motivo): + """Grava a matéria travada AGORA — a segunda pergunta do operador. + + `update_or_create` de propósito: o painel mostra o estado atual, não um + histórico de tentativas. Com um acervo inteiro falhando a cada ciclo, + histórico cresceria sozinho e afogaria justamente o que importa. + """ + MateriaComFalhaMaterializacao.objects.update_or_create( + materia=materia, defaults={'motivo': motivo or 'motivo não informado'}) + + def _limpar_falha(self, materia): + """Sucesso apaga a marca: a lista da tela é 'travadas agora'.""" + MateriaComFalhaMaterializacao.objects.filter(materia=materia).delete() + def _relatar_motivos(self, motivos, falhas): if not motivos: return @@ -221,6 +328,23 @@ class Command(BaseCommand): 'ONLYOFFICE_JWT_ENABLED=True e ONLYOFFICE_JWT_SECRET com o ' 'mesmo segredo do servidor do OnlyOffice.' % getattr(settings, 'ONLYOFFICE_URL', '')) + # O -4 e o OPOSTO do -8: nao e token, e DOWNLOAD. E a leitura natural + # ("a URL deve estar errada") manda conferir a variavel do jeito errado, + # porque a URL costuma funcionar — de dentro. `_origem_servida_confere` + # baixa a URL do PROPRIO SAPL e por isso passa limpo; ela nunca teve + # como provar que o servidor do OnlyOffice, que e outra maquina, alcanca + # o mesmo endereco. Em 25/08/2026 eram 861 falhas com SAPL_INTERNAL_URL + # apontando para um endereco que so existia dentro da VPS. + elif 'codigo -4' in motivo or 'código -4' in motivo: + aviso += ( + ' | -4 e erro de DOWNLOAD, nao de token: o servidor do ' + 'OnlyOffice em %s nao conseguiu BAIXAR o documento de origem. ' + 'SAPL_INTERNAL_URL (hoje %r) precisa ser uma URL que AQUELE ' + 'servidor alcance — endereco interno (localhost, 127.0.0.1, IP ' + 'privado) funciona daqui e nao de la. Use https: em http o ' + 'nginx responde 301 e redirect nao seguido tambem vira -4.' + % (getattr(settings, 'ONLYOFFICE_URL', ''), + _base_url_de_sistema() or '')) self.stderr.write(aviso) logger.error(aviso) diff --git a/sapl/integracao_hub/migrations/0005_painel_materializacao.py b/sapl/integracao_hub/migrations/0005_painel_materializacao.py new file mode 100644 index 000000000..afd0b6fb4 --- /dev/null +++ b/sapl/integracao_hub/migrations/0005_painel_materializacao.py @@ -0,0 +1,52 @@ +import django.contrib.postgres.fields.jsonb +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('materia', '0094_add_anexoproposicao'), + ('integracao_hub', '0004_assinaturarecebida_operado_por'), + ] + + operations = [ + migrations.CreateModel( + name='PassadaMaterializacao', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('iniciada_em', models.DateTimeField(auto_now_add=True, verbose_name='Iniciada em')), + ('terminada_em', models.DateTimeField(blank=True, null=True, verbose_name='Terminada em')), + ('em_andamento', models.NullBooleanField(default=True, help_text='True enquanto roda, NULL quando termina. Nunca False: o índice único sobre este campo é o que impede duas passadas simultâneas.', unique=True, verbose_name='Em andamento')), + ('disparo', models.CharField(choices=[('laco', 'Laço automático'), ('manual', 'Disparo manual pela tela')], default='laco', max_length=10, verbose_name='Origem do disparo')), + ('disparada_por', models.CharField(blank=True, help_text='Usuário que clicou em "Rodar agora". Vazio no laço.', max_length=150, verbose_name='Disparada por')), + ('somente_novos', models.BooleanField(default=False, help_text='Passada que só gera alvo ausente e nunca retifica.', verbose_name='Somente novos')), + ('gerados', models.IntegerField(default=0, verbose_name='Gerados')), + ('retificados', models.IntegerField(default=0, verbose_name='Retificados')), + ('em_dia', models.IntegerField(default=0, verbose_name='Em dia')), + ('falhas', models.IntegerField(default=0, verbose_name='Falhas')), + ('adiados', models.IntegerField(default=0, verbose_name='Adiados')), + ('motivos', django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=dict, help_text='Dicionário motivo -> quantas. É o que transforma 861 logger.error dispersos numa linha que se lê.', verbose_name='Falhas por motivo')), + ('abandonada', models.BooleanField(default=False, help_text='Passada cujo processo morreu sem fechar a linha (kill, reboot). Fechada pela passada seguinte para destravar o lock — os contadores dela ficam incompletos.', verbose_name='Abandonada')), + ], + options={ + 'verbose_name': 'Passada de Materialização', + 'verbose_name_plural': 'Passadas de Materialização', + 'ordering': ('-iniciada_em',), + }, + ), + migrations.CreateModel( + name='MateriaComFalhaMaterializacao', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('motivo', models.TextField(verbose_name='Motivo')), + ('ocorrido_em', models.DateTimeField(auto_now=True, verbose_name='Última ocorrência')), + ('materia', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='falha_materializacao', to='materia.MateriaLegislativa', verbose_name='Matéria Legislativa')), + ], + options={ + 'verbose_name': 'Matéria com Falha de Materialização', + 'verbose_name_plural': 'Matérias com Falha de Materialização', + 'ordering': ('materia_id',), + }, + ), + ] diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index 079d03b61..66b724ccb 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -1,3 +1,4 @@ +from django.contrib.postgres.fields import JSONField from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -184,3 +185,139 @@ class AnexoProposicao(models.Model): def __str__(self): return '%s (%s)' % (self.nome_original, self.proposicao_id) + + +class PassadaMaterializacao(models.Model): + """Uma linha por passada do `materializar_pdfs_para_assinatura`. + + Existe porque a falha desta rotina é MUDA: matéria protocolada simplesmente + não vira pendência no app, sem erro em lugar nenhum. Até 25/08/2026 o único + jeito de saber o que ela fez era abrir o shell da VPS — o diagnóstico daquele + dia consumiu três idas e voltas com o operador para descobrir o valor de uma + variável de ambiente. + + O comando JÁ apurava todos estes contadores; eles morriam no `stdout` do + processo. Aqui eles ficam, e a tela lê daqui. + + **`em_andamento` é o lock entre processos, não um mero estado.** É + `NullBooleanField(unique=True)` e assume DOIS valores apenas: `True` + enquanto a passada roda, `NULL` quando termina — nunca `False`. Em Postgres + `NULL` não colide em índice único, então o banco garante sozinho que existe + no máximo UMA passada em curso, seja ela do laço ou do botão. É esta + garantia que permite disparar a rotina pela tela sem risco de duas + conversões concorrentes do mesmo documento. + """ + + DISPARO_LACO = 'laco' + DISPARO_MANUAL = 'manual' + DISPARO_CHOICES = ( + (DISPARO_LACO, _('Laço automático')), + (DISPARO_MANUAL, _('Disparo manual pela tela')), + ) + + iniciada_em = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Iniciada em')) + + terminada_em = models.DateTimeField( + null=True, blank=True, + verbose_name=_('Terminada em')) + + em_andamento = models.NullBooleanField( + unique=True, + default=True, + verbose_name=_('Em andamento'), + help_text=_('True enquanto roda, NULL quando termina. Nunca False: o ' + 'índice único sobre este campo é o que impede duas ' + 'passadas simultâneas.')) + + disparo = models.CharField( + max_length=10, + choices=DISPARO_CHOICES, + default=DISPARO_LACO, + verbose_name=_('Origem do disparo')) + + disparada_por = models.CharField( + max_length=150, + blank=True, + verbose_name=_('Disparada por'), + help_text=_('Usuário que clicou em "Rodar agora". Vazio no laço.')) + + somente_novos = models.BooleanField( + default=False, + verbose_name=_('Somente novos'), + help_text=_('Passada que só gera alvo ausente e nunca retifica.')) + + gerados = models.IntegerField(default=0, verbose_name=_('Gerados')) + retificados = models.IntegerField(default=0, verbose_name=_('Retificados')) + em_dia = models.IntegerField(default=0, verbose_name=_('Em dia')) + falhas = models.IntegerField(default=0, verbose_name=_('Falhas')) + adiados = models.IntegerField(default=0, verbose_name=_('Adiados')) + + motivos = JSONField( + default=dict, blank=True, + verbose_name=_('Falhas por motivo'), + help_text=_('Dicionário motivo -> quantas. É o que transforma 861 ' + 'logger.error dispersos numa linha que se lê.')) + + abandonada = models.BooleanField( + default=False, + verbose_name=_('Abandonada'), + help_text=_('Passada cujo processo morreu sem fechar a linha (kill, ' + 'reboot). Fechada pela passada seguinte para destravar o ' + 'lock — os contadores dela ficam incompletos.')) + + class Meta: + verbose_name = _('Passada de Materialização') + verbose_name_plural = _('Passadas de Materialização') + ordering = ('-iniciada_em',) + + def __str__(self): + return 'Passada %s (%s)' % (self.pk, self.iniciada_em) + + @property + def duracao(self): + if not self.terminada_em: + return None + return self.terminada_em - self.iniciada_em + + @property + def motivo_predominante(self): + """O motivo que derruba o lote — o que precisa gritar na tela. + + Falha isolada é ruído esperado (§5.1); o que interessa ao operador é o + motivo único que explica a maioria das falhas. + """ + if not self.motivos: + return None, 0 + return max(self.motivos.items(), key=lambda par: par[1]) + + +class MateriaComFalhaMaterializacao(models.Model): + """As matérias travadas AGORA — uma linha por matéria, não por tentativa. + + Deliberadamente NÃO é histórico: a passada seguinte reescreve a linha e o + sucesso a apaga. A pergunta que o operador faz é "quais estão travadas e + por quê", e um log de tentativas responde isso mal — com 861 matérias + falhando a cada 5 minutos, histórico vira ruído que cresce sozinho. + """ + + materia = models.OneToOneField( + 'materia.MateriaLegislativa', + on_delete=models.CASCADE, + related_name='falha_materializacao', + verbose_name=_('Matéria Legislativa')) + + motivo = models.TextField(verbose_name=_('Motivo')) + + ocorrido_em = models.DateTimeField( + auto_now=True, + verbose_name=_('Última ocorrência')) + + class Meta: + verbose_name = _('Matéria com Falha de Materialização') + verbose_name_plural = _('Matérias com Falha de Materialização') + ordering = ('materia_id',) + + def __str__(self): + return 'Matéria %s: %s' % (self.materia_id, self.motivo) diff --git a/sapl/integracao_hub/painel.py b/sapl/integracao_hub/painel.py new file mode 100644 index 000000000..e226b2b91 --- /dev/null +++ b/sapl/integracao_hub/painel.py @@ -0,0 +1,181 @@ +"""Painel de operação da materialização do PDF-alvo (refinamento §5). + +Por que esta tela existe, em uma frase: até 25/08/2026 a única forma de saber o +que a materialização tinha feito era abrir o shell da VPS, e o operador da casa +legislativa não tem esse acesso — nem nós. + +O episódio que a motivou: as proposições fluíam normalmente, mas nenhuma +pendência de assinatura chegava ao app. O diagnóstico consumiu três idas e +voltas com o administrador do servidor para terminar num valor de variável de +ambiente (`SAPL_INTERNAL_URL` apontando para um endereço que só existia dentro +da VPS, produzindo `código -4` em 861 matérias). Todos os números necessários +para responder aquilo em dez segundos já eram calculados pela rotina — e jogados +fora no `stdout` de um processo que ninguém estava lendo. + +Vive em módulo separado de `views.py` de propósito: aquele arquivo é a superfície +de API consumida pelo hub (DRF, token, `pode_integrar`), este é HTML de operação. +Misturar os dois obrigaria quem mexe na API a ler tela e vice-versa. + +**A rotina é disparada como PROCESSO SEPARADO, não em thread.** Duas razões, e +nenhuma é preferência de estilo: + +1. A passada é longa — um acervo de ~900 matérias a ~4s de conversão cada leva + perto de uma hora. Não cabe num request, e uma thread do gunicorn morre com o + worker que a hospeda, no meio da conversão, sem deixar rastro. +2. O gunicorn roda N workers. Thread por worker significaria N passadas + convertendo a mesma matéria ao mesmo tempo. + +O que serializa tudo isso é o índice único de `PassadaMaterializacao.em_andamento` +(ver o docstring do modelo): o banco recusa a segunda passada, seja ela do laço +ou de um segundo clique. +""" +import logging +import os +import subprocess +import sys + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.views.generic import TemplateView, View + +from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, + PassadaMaterializacao) + +logger = logging.getLogger(__name__) + +PERMISSAO = 'integracao_hub.pode_integrar' + +# Quantas passadas o painel mostra. O laço roda a cada 300s por padrão, então +# 20 linhas cobrem em torno de uma hora e meia — o suficiente para responder +# "está rodando?" e "o que mudou desde que eu mexi na configuração?" sem virar +# um relatório que ninguém lê. +PASSADAS_NO_PAINEL = 20 + +# Quantas matérias travadas listar. Quando o ambiente cai, TODAS falham pelo +# mesmo motivo; listar 900 linhas idênticas esconde justamente o caso +# interessante, que é a matéria que falha sozinha. +FALHAS_NO_PAINEL = 50 + + +def _raiz_do_projeto(): + """Diretório de onde o `manage.py` roda — e onde o `sapl.log` é escrito. + + `settings.BASE_DIR` aponta para o pacote `sapl/`; a raiz é o pai dele. O + `cwd` importa: o handler de log do SAPL usa caminho RELATIVO + (`settings.py`, handler `applogfile`), então rodar de outro diretório + espalharia `sapl.log` por onde o processo tivesse sido disparado. + """ + return os.path.dirname(str(settings.BASE_DIR)) + + +class PainelMaterializacaoView(PermissionRequiredMixin, TemplateView): + """O que a rotina fez, o que está travado, e por quê.""" + + template_name = 'integracao_hub/painel_materializacao.html' + permission_required = (PERMISSAO,) + raise_exception = True + + def get_context_data(self, **kwargs): + contexto = super().get_context_data(**kwargs) + + em_andamento = PassadaMaterializacao.objects.filter( + em_andamento=True).first() + + passadas = list(PassadaMaterializacao.objects + .all()[:PASSADAS_NO_PAINEL]) + + falhas = list(MateriaComFalhaMaterializacao.objects + .select_related('materia')[:FALHAS_NO_PAINEL]) + + total_falhas = MateriaComFalhaMaterializacao.objects.count() + + contexto.update({ + 'em_andamento': em_andamento, + 'passadas': passadas, + # A última CONCLUÍDA é a que responde "o que a rotina fez": a linha + # em andamento ainda tem contadores zerados e passaria a impressão + # falsa de que nada foi feito. + 'ultima_concluida': next( + (p for p in passadas if p.terminada_em), None), + 'nunca_rodou': not passadas, + 'falhas': falhas, + 'total_falhas': total_falhas, + 'falhas_ocultas': max(0, total_falhas - len(falhas)), + 'alvos_materializados': DocumentoParaAssinatura.objects.count(), + # A configuração que decide o sucesso da conversão fica na tela + # porque foi exatamente ela a causa do incidente de 25/08 — e lê-la + # exigia shell. + 'sapl_internal_url': getattr(settings, 'SAPL_INTERNAL_URL', ''), + 'site_url': getattr(settings, 'SITE_URL', ''), + 'onlyoffice_url': getattr(settings, 'ONLYOFFICE_URL', ''), + 'onlyoffice_jwt': getattr( + settings, 'ONLYOFFICE_JWT_ENABLED', False), + }) + return contexto + + +class DispararMaterializacaoView(PermissionRequiredMixin, View): + """Botão "Rodar agora" — sempre em modo `--somente-novos`. + + **A flag não é configurável aqui, e isso é decisão de segurança, não + economia de campo.** Sem ela o comando entra em retificação: onde o texto + mudou depois da conversão, ele regenera o alvo E ZERA a assinatura já feita + (§5.1). Isso foi decidido para o ato isolado de retificar um texto — numa + varredura de acervo inteiro, disparada por um clique, apagaria assinaturas + válidas em lote e sem volta. + + Retificar continua possível pelo fluxo normal, matéria a matéria. + """ + + permission_required = (PERMISSAO,) + raise_exception = True + + def post(self, request, *args, **kwargs): + destino = reverse('integracao_hub_painel_materializacao') + + if PassadaMaterializacao.objects.filter(em_andamento=True).exists(): + messages.warning( + request, + 'Já existe uma passada em andamento. Aguarde ela terminar — ' + 'duas passadas simultâneas converteriam o mesmo documento.') + return HttpResponseRedirect(destino) + + comando = [ + sys.executable, '-m', 'django', + 'materializar_pdfs_para_assinatura', + '--somente-novos', + '--disparada-por', (request.user.get_username() or '')[:150], + ] + ambiente = os.environ.copy() + ambiente.setdefault('DJANGO_SETTINGS_MODULE', 'sapl.settings') + + try: + subprocess.Popen( + comando, + cwd=_raiz_do_projeto(), + env=ambiente, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + # Desliga do processo do gunicorn: sem isso um reload do worker + # levaria a passada junto, no meio da conversão. + start_new_session=True) + except Exception as exc: # noqa — falha de spawn é diagnóstico de tela + logger.exception('painel_materializacao: falha ao disparar: %s', exc) + messages.error( + request, + 'Não foi possível iniciar a materialização: %s' % exc) + return HttpResponseRedirect(destino) + + logger.info( + 'painel_materializacao: passada disparada por %s', + request.user.get_username()) + messages.success( + request, + 'Materialização iniciada em segundo plano. A passada varre o ' + 'acervo inteiro e pode levar cerca de uma hora — atualize esta ' + 'página para acompanhar. Ela leva alguns segundos para aparecer ' + 'como "em andamento".') + return HttpResponseRedirect(destino) diff --git a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html new file mode 100644 index 000000000..ca5225b0e --- /dev/null +++ b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block base_content %} + +

Materialização do PDF-alvo para assinatura

+ +

+ Esta rotina converte o texto original de cada matéria protocolada no PDF que o + vereador assina no aplicativo. Enquanto ela não roda, a matéria não + existe para o app — e a falha é silenciosa: nada quebra, a pendência + simplesmente não aparece. +

+ +{# ---------------------------------------------------------------------- #} +{# Estado atual: a primeira pergunta é sempre "está rodando?" #} +{# ---------------------------------------------------------------------- #} +

Situação

+
+
+ {% if nunca_rodou %} +
+ A rotina nunca rodou neste servidor. + Nenhuma passada foi registrada. Enquanto isso, nenhuma matéria vira + pendência de assinatura no aplicativo. Use o botão abaixo para rodar + agora, e verifique se a rotina automática está ativa no servidor. +
+ {% elif em_andamento %} +
+ Passada em andamento desde {{ em_andamento.iniciada_em }} + ({% if em_andamento.disparo == 'manual' %}disparada por + {{ em_andamento.disparada_por|default:"—" }}{% else %}laço automático{% endif %}). + A varredura do acervo pode levar cerca de uma hora. Atualize a página + para acompanhar. +
+ {% elif ultima_concluida %} +
+ Última passada concluída em {{ ultima_concluida.terminada_em }}. + {{ ultima_concluida.gerados }} gerado(s), + {{ ultima_concluida.retificados }} retificado(s), + {{ ultima_concluida.em_dia }} em dia, + {{ ultima_concluida.falhas }} falha(s). +
+ {% endif %} + +

+ {{ alvos_materializados }} documento(s) já materializado(s) + e disponível(is) para assinatura. + {% if total_falhas %} + + {{ total_falhas }} matéria(s) travada(s) agora. + + {% endif %} +

+
+
+ +{# ---------------------------------------------------------------------- #} +{# Disparo manual. Sempre --somente-novos: ver docstring da view. #} +{# ---------------------------------------------------------------------- #} +
+

Rodar agora

+
+
+
+ {% csrf_token %} + + {% if em_andamento %} + Aguarde a passada em andamento terminar. + {% endif %} +
+

+ Roda em segundo plano, no modo que apenas gera o documento + ausente. Matéria cujo texto mudou depois da conversão não é + regenerada por aqui: regenerar apagaria a assinatura já feita, e essa é + uma decisão que se toma matéria a matéria, nunca em lote. +

+
+
+ +{# ---------------------------------------------------------------------- #} +{# Configuração — foi exatamente ela a causa do incidente de 25/08/2026. #} +{# ---------------------------------------------------------------------- #} +
+

Configuração da conversão

+
+
+ + + + + + + + + + + + + + + +
Endereço deste SAPL para o conversor + {% if sapl_internal_url %} + {{ sapl_internal_url }} + {% elif site_url %} + {{ site_url }} (SITE_URL) + {% else %} + não configurado + {% endif %} +
Servidor de conversão (OnlyOffice){{ onlyoffice_url|default:"não configurado" }}
Token de conversão (JWT){% if onlyoffice_jwt %}habilitado{% else %}desabilitado{% endif %}
+

+ O endereço acima é o que o servidor de conversão usa para + baixar o documento — precisa ser alcançável a partir + dele, não deste servidor. Endereço interno (localhost, + 127.0.0.1, IP privado) funciona aqui e falha lá. +

+
+
+ +{# ---------------------------------------------------------------------- #} +{# O que está travado AGORA — a segunda pergunta do operador. #} +{# ---------------------------------------------------------------------- #} +{% if falhas %} +
+

Matérias travadas

+
+
+ + + + + + + + + + {% for falha in falhas %} + + + + + + {% endfor %} + +
MatériaMotivoÚltima ocorrência
+ + {{ falha.materia }} + + {{ falha.motivo }}{{ falha.ocorrido_em }}
+ {% if falhas_ocultas %} +

+ … e mais {{ falhas_ocultas }} matéria(s) com falha, não listada(s) aqui. + Quando o número é alto, quase sempre é uma causa única de ambiente — + confira o motivo predominante na tabela de passadas abaixo. +

+ {% endif %} +
+
+{% endif %} + +{# ---------------------------------------------------------------------- #} +{# Histórico. Existir uma linha aqui já responde "a rotina está viva?". #} +{# ---------------------------------------------------------------------- #} +
+

Últimas passadas

+
+
+ {% if passadas %} + + + + + + + + + + + + + + + {% for passada in passadas %} + + + + + + + + + + + {% endfor %} + +
InícioDuraçãoOrigemGeradosRetificadosEm diaFalhasMotivo predominante
{{ passada.iniciada_em }} + {% if passada.abandonada %} + interrompida + {% elif passada.terminada_em %} + {{ passada.duracao }} + {% else %} + em andamento + {% endif %} + + {% if passada.disparo == 'manual' %} + {{ passada.disparada_por|default:"manual" }} + {% else %} + laço + {% endif %} + {{ passada.gerados }}{{ passada.retificados }}{{ passada.em_dia }} + {% if passada.falhas %} + {{ passada.falhas }} + {% else %} + 0 + {% endif %} + + {% with predominante=passada.motivo_predominante %} + {% if predominante.0 %} + {{ predominante.0 }} ({{ predominante.1 }}×) + {% else %} + — + {% endif %} + {% endwith %} +
+ {% else %} +

Nenhuma passada registrada.

+ {% endif %} +
+
+ +{% endblock base_content %} diff --git a/sapl/integracao_hub/tests/test_painel.py b/sapl/integracao_hub/tests/test_painel.py new file mode 100644 index 000000000..49730a8a7 --- /dev/null +++ b/sapl/integracao_hub/tests/test_painel.py @@ -0,0 +1,255 @@ +"""Testes do painel de materialização (AB#1480). + +O que estes testes protegem, em ordem de importância: + +1. **O lock.** `em_andamento` é um índice único usado como semáforo entre + processos. Se ele parar de barrar, laço e botão convertem o mesmo documento + ao mesmo tempo — e a falha só apareceria em produção, num acervo grande. +2. **O painel dizer a verdade.** A tela existe para responder sem shell; uma + tela que mostra número errado é pior do que não ter tela. +3. **O `--somente-novos` do botão.** Perder essa flag apaga assinatura já feita, + em lote e sem volta (§5.1). +""" +import hashlib +from unittest import mock + +import pytest +from django.contrib.auth.models import Permission +from django.core.files.base import ContentFile +from django.core.management import call_command +from django.test import Client +from django.urls import reverse +from django.utils import timezone +from model_bakery import baker + +from sapl.integracao_hub.models import (MateriaComFalhaMaterializacao, + PassadaMaterializacao) +from sapl.materia.models import MateriaLegislativa + +PDF = b'%PDF-1.4 conteudo-original' +CAMINHO_NO_MENU = '/sistema/integracao/materializacao/' + + +@pytest.fixture(autouse=True) +def base_url_configurada(settings): + 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: + materia.texto_original.save(nome, ContentFile(conteudo), save=True) + return materia + + +@pytest.fixture() +def operador(db): + """Usuário com `pode_integrar` — a permissão que já gateia a API.""" + usuario = baker.make('auth.User', username='operador-teste') + usuario.set_password('senha-de-teste') + usuario.save() + usuario.user_permissions.add(Permission.objects.get( + content_type__app_label='integracao_hub', codename='pode_integrar')) + cliente = Client() + cliente.force_login(usuario) + return cliente + + +@pytest.fixture() +def sem_permissao(db): + usuario = baker.make('auth.User', username='qualquer-um') + cliente = Client() + cliente.force_login(usuario) + return cliente + + +# ---------------------------------------------------------------- a passada + +@pytest.mark.django_db(transaction=False) +def test_passada_registra_contadores_e_fecha_o_lock(db): + criar_materia() + + call_command('materializar_pdfs_para_assinatura') + + passada = PassadaMaterializacao.objects.get() + assert passada.gerados == 1 + assert passada.terminada_em is not None + # NULL, nunca False: é o que libera o índice único para a próxima passada. + assert passada.em_andamento is None + assert passada.disparo == PassadaMaterializacao.DISPARO_LACO + + +@pytest.mark.django_db(transaction=False) +def test_passada_manual_guarda_quem_disparou(db): + criar_materia() + + call_command('materializar_pdfs_para_assinatura', + '--somente-novos', disparada_por='fulano') + + passada = PassadaMaterializacao.objects.get() + assert passada.disparo == PassadaMaterializacao.DISPARO_MANUAL + assert passada.disparada_por == 'fulano' + assert passada.somente_novos is True + + +@pytest.mark.django_db(transaction=False) +def test_lock_dispensa_passada_concorrente(db): + """Com uma passada aberta, a segunda não roda — nem cria linha.""" + aberta = PassadaMaterializacao.objects.create() + criar_materia() + + call_command('materializar_pdfs_para_assinatura') + + assert PassadaMaterializacao.objects.count() == 1 + assert PassadaMaterializacao.objects.get().pk == aberta.pk + # E, sobretudo, não converteu nada por baixo do lock. + assert not MateriaComFalhaMaterializacao.objects.exists() + + +@pytest.mark.django_db(transaction=False) +def test_passada_orfa_e_fechada_e_libera_o_lock(db): + """Processo morto sem fechar a linha travaria a rotina para sempre.""" + orfa = PassadaMaterializacao.objects.create() + # `iniciada_em` é auto_now_add; só um UPDATE direto envelhece a linha. + PassadaMaterializacao.objects.filter(pk=orfa.pk).update( + iniciada_em=timezone.now() - timezone.timedelta(hours=7)) + criar_materia() + + call_command('materializar_pdfs_para_assinatura') + + orfa.refresh_from_db() + assert orfa.abandonada is True + assert orfa.em_andamento is None + nova = PassadaMaterializacao.objects.exclude(pk=orfa.pk).get() + assert nova.gerados == 1 + + +# ------------------------------------------------------- matérias travadas + +@pytest.mark.django_db(transaction=False) +def test_falha_vira_linha_e_sucesso_apaga(db): + """A lista da tela é 'travadas agora', não histórico de tentativas.""" + # DOCX cai no caminho do OnlyOffice, que não existe no teste — falha. + materia = criar_materia(conteudo=b'nao-e-pdf', nome='texto.docx') + + call_command('materializar_pdfs_para_assinatura') + + falha = MateriaComFalhaMaterializacao.objects.get(materia=materia) + assert falha.motivo + + # Agora o texto vira PDF: o caminho não passa pelo OnlyOffice e funciona. + materia.texto_original.save('texto.pdf', ContentFile(PDF), save=True) + call_command('materializar_pdfs_para_assinatura') + + assert not MateriaComFalhaMaterializacao.objects.filter( + materia=materia).exists() + + +@pytest.mark.django_db(transaction=False) +def test_motivo_predominante_aponta_o_que_derruba_o_lote(db): + passada = PassadaMaterializacao.objects.create( + motivos={'código -4': 861, 'outro': 2}) + + motivo, quantas = passada.motivo_predominante + + assert motivo == 'código -4' + assert quantas == 861 + + +# ------------------------------------------------------------------- tela + +@pytest.mark.django_db(transaction=False) +def test_painel_exige_pode_integrar(sem_permissao): + resposta = sem_permissao.get( + reverse('integracao_hub_painel_materializacao')) + + assert resposta.status_code == 403 + + +@pytest.mark.django_db(transaction=False) +def test_painel_mostra_a_configuracao_que_decide_a_conversao(operador, settings): + """A causa do incidente de 25/08 era um valor que exigia shell para ler.""" + settings.SAPL_INTERNAL_URL = 'https://demo.exemplo.gov.br' + + resposta = operador.get(reverse('integracao_hub_painel_materializacao')) + + assert resposta.status_code == 200 + assert b'demo.exemplo.gov.br' in resposta.content + + +@pytest.mark.django_db(transaction=False) +def test_painel_diz_quando_a_rotina_nunca_rodou(operador): + """Tabela vazia É um diagnóstico — foi a resposta certa em 25/08.""" + resposta = operador.get(reverse('integracao_hub_painel_materializacao')) + + assert resposta.context['nunca_rodou'] is True + + +@pytest.mark.django_db(transaction=False) +def test_painel_nao_confunde_passada_aberta_com_resultado(operador): + """Linha em andamento tem contadores zerados; não pode virar 'a última'.""" + concluida = PassadaMaterializacao.objects.create() + concluida.gerados = 7 + concluida.em_andamento = None + concluida.terminada_em = timezone.now() + concluida.save() + PassadaMaterializacao.objects.create() # a aberta, contadores em zero + + resposta = operador.get(reverse('integracao_hub_painel_materializacao')) + + assert resposta.context['ultima_concluida'].pk == concluida.pk + assert resposta.context['em_andamento'] is not None + + +# ---------------------------------------------------------------- disparo + +@pytest.mark.django_db(transaction=False) +def test_disparo_roda_sempre_em_somente_novos(operador): + """Perder esta flag apaga assinatura já feita, em lote (§5.1).""" + with mock.patch('sapl.integracao_hub.painel.subprocess.Popen') as popen: + resposta = operador.post( + reverse('integracao_hub_disparar_materializacao')) + + assert resposta.status_code == 302 + comando = popen.call_args[0][0] + assert '--somente-novos' in comando + assert 'materializar_pdfs_para_assinatura' in comando + assert 'operador-teste' in comando + + +@pytest.mark.django_db(transaction=False) +def test_disparo_recusa_com_passada_em_andamento(operador): + PassadaMaterializacao.objects.create() + + with mock.patch('sapl.integracao_hub.painel.subprocess.Popen') as popen: + operador.post(reverse('integracao_hub_disparar_materializacao')) + + popen.assert_not_called() + + +@pytest.mark.django_db(transaction=False) +def test_disparo_exige_pode_integrar(sem_permissao): + with mock.patch('sapl.integracao_hub.painel.subprocess.Popen') as popen: + resposta = sem_permissao.post( + reverse('integracao_hub_disparar_materializacao')) + + assert resposta.status_code == 403 + popen.assert_not_called() + + +@pytest.mark.django_db(transaction=False) +def test_menu_aponta_para_a_rota_do_painel(db): + """O menu usa caminho literal; este teste impede a divergência silenciosa. + + `templatetags/menus.py` prefixa nome de rota sem `:` com o app da página + (`sapl.base` na tela de Tabelas Auxiliares) e levanta exceção se não + resolver — derrubando a tela inteira. Por isso o YAML traz o caminho cru, e + por isso ele precisa de guarda. + """ + assert reverse( + 'integracao_hub_painel_materializacao') == CAMINHO_NO_MENU + + with open('sapl/templates/menu_tabelas_auxiliares.yaml', + encoding='utf-8') as arquivo: + assert CAMINHO_NO_MENU in arquivo.read() diff --git a/sapl/integracao_hub/urls.py b/sapl/integracao_hub/urls.py index d9f08db1f..ce7451b77 100644 --- a/sapl/integracao_hub/urls.py +++ b/sapl/integracao_hub/urls.py @@ -1,5 +1,6 @@ from django.conf.urls import url +from .painel import DispararMaterializacaoView, PainelMaterializacaoView from .views import (AssinaturasConcluidasPollView, AssinaturasPendentesPollView, DocumentoAlvoView, @@ -61,4 +62,16 @@ urlpatterns = [ url(r'^api/integracao/reconciliacao/$', InventarioView.as_view(), name='integracao_hub_reconciliacao'), + + # Painel de operação (HTML, sessão + pode_integrar) — fora do prefixo + # `api/integracao/` de propósito: aquilo é a superfície que o hub consome + # com token; isto é tela de gente, e mora sob `sistema/` como as demais + # ferramentas administrativas do SAPL. + url(r'^sistema/integracao/materializacao/$', + PainelMaterializacaoView.as_view(), + name='integracao_hub_painel_materializacao'), + + url(r'^sistema/integracao/materializacao/disparar/$', + DispararMaterializacaoView.as_view(), + name='integracao_hub_disparar_materializacao'), ] diff --git a/sapl/templates/menu_tabelas_auxiliares.yaml b/sapl/templates/menu_tabelas_auxiliares.yaml index 66036791e..defb58fda 100644 --- a/sapl/templates/menu_tabelas_auxiliares.yaml +++ b/sapl/templates/menu_tabelas_auxiliares.yaml @@ -225,4 +225,17 @@ css_class: btn btn-link - title: {% trans 'Publicador' %} url: sapl.lexml:lexmlpublicador_list - css_class: btn btn-link \ No newline at end of file + css_class: btn btn-link +- title: {% trans 'Integração com o Aplicativo' %} + css_class: head_title + children: + # Caminho literal, não nome de rota: o resolvedor deste menu (templatetags/ + # menus.py) prefixa nome sem ':' com o app da PÁGINA, que aqui é `sapl.base`, + # e levantaria exceção derrubando a tela inteira de Tabelas Auxiliares. O ramo + # de caminho literal é suportado e passa direto. O teste + # `test_painel.py::test_menu_aponta_para_a_rota_do_painel` trava este caminho + # contra a rota, para os dois não divergirem em silêncio. + - title: {% trans 'Materialização de documentos para assinatura' %} + url: /sistema/integracao/materializacao/ + css_class: btn btn-link + check_permission: integracao_hub.pode_integrar