mirror of https://github.com/interlegis/sapl.git
Browse Source
A falha desta rotina e MUDA: materia protocolada nao vira pendencia no app e nao
ha erro em lugar nenhum. Em 25/08 descobrir isso custou meia hora, o shell da VPS
de outra pessoa e tres idas e voltas com o operador para ler UMA variavel de
ambiente. Os numeros que respondiam a pergunta ja eram todos calculados pela
rotina — e morriam no stdout de um processo que ninguem lia.
PassadaMaterializacao / MateriaComFalhaMaterializacao
Uma linha por passada, com os contadores que ja existiam. Tabela vazia tambem
e diagnostico: em 25/08 a resposta certa era "nunca rodou neste servidor", e
nem isso era possivel saber. A tabela de falhas e por MATERIA e nao por
tentativa — com um acervo inteiro falhando a cada ciclo, historico afogaria o
caso interessante, que e a materia que falha sozinha.
em_andamento como lock de banco, nao checagem de aplicacao
NullBooleanField(unique=True) com dois valores: True rodando, NULL terminada,
nunca False. Em Postgres NULL nao colide em indice unico, entao o banco garante
no maximo uma passada em curso. Checagem na aplicacao nao daria isso: laco e
botao sao processos distintos e entre o SELECT e o INSERT cabe a corrida.
Consequencia tratada: processo morto deixaria o lock presos para sempre — a
MESMA falha muda que este painel existe para acabar. Passada aberta ha mais de
6h e fechada como abandonada pela seguinte, com os contadores marcados.
O botao dispara PROCESSO, nao thread nem flag
O refinamento propunha o botao gravar uma solicitacao lida pelo laco. Isso nao
funciona em Franco: la nao ha laco, porque o start.sh so roda em Docker e
aquele servidor nao usa Docker. Popen desacoplado (start_new_session) funciona
com ou sem laco. Thread esta descartada por dois motivos: a passada leva perto
de uma hora e morreria com o worker do gunicorn, e N workers dariam N passadas
convertendo a mesma materia.
Sempre em --somente-novos, e isso nao e configuravel de proposito: sem a flag o
comando entra em retificacao e ZERA assinatura ja feita. Decidido para o ato
isolado de retificar um texto, nunca para varredura por um clique.
Diagnostico de -4 no _relatar_motivos
So existia ramo para -8 (JWT). O -4 e o oposto: nao e token, e DOWNLOAD — o
servidor do OnlyOffice nao alcanca a URL que o SAPL entrega. A leitura natural
("a URL deve estar errada") manda conferir do jeito errado, porque a URL
funciona de dentro; _origem_servida_confere baixa do proprio SAPL e por isso
passa limpo. Em 25/08 eram 861 falhas com SAPL_INTERNAL_URL num endereco que so
existia dentro da VPS.
Menu por caminho literal, com teste
O resolvedor de menus prefixa nome de rota sem ':' com o app da pagina
(sapl.base aqui) e levantaria excecao derrubando a tela inteira de Tabelas
Auxiliares. O ramo de caminho literal passa direto, e
test_menu_aponta_para_a_rota_do_painel compara o literal com a rota para os
dois nao divergirem em silencio. O YAML aceita check_permission, entao o item
so aparece para quem tem pode_integrar.
.gitignore: .env.bak*
O padrao cobria .env mas nao os backups que o script de deploy gera, e eles
carregam SECRET_KEY, DATABASE_URL e ASSINATURA_API_KEY em claro.
14 testes verdes (pytest sapl/integracao_hub/tests/test_painel.py).
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
committed by
GitHub
10 changed files with 1021 additions and 8 deletions
@ -1 +1 @@ |
|||||
Subproject commit 2ee89cb5c88e602f8a61175146d054485747cca0 |
Subproject commit f8c0df145abbb482cb80760a298f2bdff5e07578 |
||||
@ -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',), |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -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) |
||||
@ -0,0 +1,236 @@ |
|||||
|
{% extends "base.html" %} |
||||
|
{% load i18n %} |
||||
|
|
||||
|
{% block base_content %} |
||||
|
|
||||
|
<h1>Materialização do PDF-alvo para assinatura</h1> |
||||
|
|
||||
|
<p class="text-muted"> |
||||
|
Esta rotina converte o texto original de cada matéria protocolada no PDF que o |
||||
|
vereador assina no aplicativo. <strong>Enquanto ela não roda, a matéria não |
||||
|
existe para o app</strong> — e a falha é silenciosa: nada quebra, a pendência |
||||
|
simplesmente não aparece. |
||||
|
</p> |
||||
|
|
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
{# Estado atual: a primeira pergunta é sempre "está rodando?" #} |
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
<h2>Situação</h2> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-12"> |
||||
|
{% if nunca_rodou %} |
||||
|
<div class="alert alert-danger"> |
||||
|
<strong>A rotina nunca rodou neste servidor.</strong> |
||||
|
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. |
||||
|
</div> |
||||
|
{% elif em_andamento %} |
||||
|
<div class="alert alert-info"> |
||||
|
<strong>Passada em andamento</strong> 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. |
||||
|
</div> |
||||
|
{% elif ultima_concluida %} |
||||
|
<div class="alert {% if ultima_concluida.falhas %}alert-warning{% else %}alert-success{% endif %}"> |
||||
|
<strong>Última passada concluída em {{ ultima_concluida.terminada_em }}.</strong> |
||||
|
{{ ultima_concluida.gerados }} gerado(s), |
||||
|
{{ ultima_concluida.retificados }} retificado(s), |
||||
|
{{ ultima_concluida.em_dia }} em dia, |
||||
|
{{ ultima_concluida.falhas }} falha(s). |
||||
|
</div> |
||||
|
{% endif %} |
||||
|
|
||||
|
<p> |
||||
|
<strong>{{ alvos_materializados }}</strong> documento(s) já materializado(s) |
||||
|
e disponível(is) para assinatura. |
||||
|
{% if total_falhas %} |
||||
|
<span class="text-danger"> |
||||
|
<strong>{{ total_falhas }}</strong> matéria(s) travada(s) agora. |
||||
|
</span> |
||||
|
{% endif %} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
{# Disparo manual. Sempre --somente-novos: ver docstring da view. #} |
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
<hr /> |
||||
|
<h2>Rodar agora</h2> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-12"> |
||||
|
<form method="post" |
||||
|
action="{% url 'integracao_hub_disparar_materializacao' %}"> |
||||
|
{% csrf_token %} |
||||
|
<button type="submit" class="btn btn-primary" |
||||
|
{% if em_andamento %}disabled{% endif %}> |
||||
|
Materializar documentos pendentes |
||||
|
</button> |
||||
|
{% if em_andamento %} |
||||
|
<span class="text-muted">Aguarde a passada em andamento terminar.</span> |
||||
|
{% endif %} |
||||
|
</form> |
||||
|
<p class="text-muted" style="margin-top: .5em;"> |
||||
|
Roda em segundo plano, no modo que <strong>apenas gera o documento |
||||
|
ausente</strong>. 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. |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
{# Configuração — foi exatamente ela a causa do incidente de 25/08/2026. #} |
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
<hr /> |
||||
|
<h2>Configuração da conversão</h2> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-12"> |
||||
|
<table class="table table-striped"> |
||||
|
<tbody> |
||||
|
<tr> |
||||
|
<th style="width: 30%;">Endereço deste SAPL para o conversor</th> |
||||
|
<td> |
||||
|
{% if sapl_internal_url %} |
||||
|
<code>{{ sapl_internal_url }}</code> |
||||
|
{% elif site_url %} |
||||
|
<code>{{ site_url }}</code> <span class="text-muted">(SITE_URL)</span> |
||||
|
{% else %} |
||||
|
<span class="text-danger">não configurado</span> |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<th>Servidor de conversão (OnlyOffice)</th> |
||||
|
<td><code>{{ onlyoffice_url|default:"não configurado" }}</code></td> |
||||
|
</tr> |
||||
|
<tr> |
||||
|
<th>Token de conversão (JWT)</th> |
||||
|
<td>{% if onlyoffice_jwt %}habilitado{% else %}desabilitado{% endif %}</td> |
||||
|
</tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
<p class="text-muted"> |
||||
|
O endereço acima é o que o servidor de conversão usa para |
||||
|
<strong>baixar</strong> o documento — precisa ser alcançável a partir |
||||
|
<em>dele</em>, não deste servidor. Endereço interno (<code>localhost</code>, |
||||
|
<code>127.0.0.1</code>, IP privado) funciona aqui e falha lá. |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
{# O que está travado AGORA — a segunda pergunta do operador. #} |
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
{% if falhas %} |
||||
|
<hr /> |
||||
|
<h2>Matérias travadas</h2> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-12"> |
||||
|
<table class="table table-striped"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th style="width: 12%;">Matéria</th> |
||||
|
<th>Motivo</th> |
||||
|
<th style="width: 20%;">Última ocorrência</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
{% for falha in falhas %} |
||||
|
<tr> |
||||
|
<td> |
||||
|
<a href="{% url 'sapl.materia:materialegislativa_detail' falha.materia_id %}"> |
||||
|
{{ falha.materia }} |
||||
|
</a> |
||||
|
</td> |
||||
|
<td>{{ falha.motivo }}</td> |
||||
|
<td>{{ falha.ocorrido_em }}</td> |
||||
|
</tr> |
||||
|
{% endfor %} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
{% if falhas_ocultas %} |
||||
|
<p class="text-muted"> |
||||
|
… 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. |
||||
|
</p> |
||||
|
{% endif %} |
||||
|
</div> |
||||
|
</div> |
||||
|
{% endif %} |
||||
|
|
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
{# Histórico. Existir uma linha aqui já responde "a rotina está viva?". #} |
||||
|
{# ---------------------------------------------------------------------- #} |
||||
|
<hr /> |
||||
|
<h2>Últimas passadas</h2> |
||||
|
<div class="row"> |
||||
|
<div class="col-md-12"> |
||||
|
{% if passadas %} |
||||
|
<table class="table table-striped"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th>Início</th> |
||||
|
<th>Duração</th> |
||||
|
<th>Origem</th> |
||||
|
<th>Gerados</th> |
||||
|
<th>Retificados</th> |
||||
|
<th>Em dia</th> |
||||
|
<th>Falhas</th> |
||||
|
<th>Motivo predominante</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
{% for passada in passadas %} |
||||
|
<tr> |
||||
|
<td>{{ passada.iniciada_em }}</td> |
||||
|
<td> |
||||
|
{% if passada.abandonada %} |
||||
|
<span class="text-danger" title="O processo morreu sem fechar a passada; os números desta linha estão incompletos.">interrompida</span> |
||||
|
{% elif passada.terminada_em %} |
||||
|
{{ passada.duracao }} |
||||
|
{% else %} |
||||
|
<em>em andamento</em> |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td> |
||||
|
{% if passada.disparo == 'manual' %} |
||||
|
{{ passada.disparada_por|default:"manual" }} |
||||
|
{% else %} |
||||
|
laço |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td>{{ passada.gerados }}</td> |
||||
|
<td>{{ passada.retificados }}</td> |
||||
|
<td>{{ passada.em_dia }}</td> |
||||
|
<td> |
||||
|
{% if passada.falhas %} |
||||
|
<span class="text-danger">{{ passada.falhas }}</span> |
||||
|
{% else %} |
||||
|
0 |
||||
|
{% endif %} |
||||
|
</td> |
||||
|
<td> |
||||
|
{% with predominante=passada.motivo_predominante %} |
||||
|
{% if predominante.0 %} |
||||
|
{{ predominante.0 }} <span class="text-muted">({{ predominante.1 }}×)</span> |
||||
|
{% else %} |
||||
|
— |
||||
|
{% endif %} |
||||
|
{% endwith %} |
||||
|
</td> |
||||
|
</tr> |
||||
|
{% endfor %} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
{% else %} |
||||
|
<p class="text-muted">Nenhuma passada registrada.</p> |
||||
|
{% endif %} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{% endblock base_content %} |
||||
@ -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() |
||||
Loading…
Reference in new issue