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