Browse Source

Feat(Materializacao): painel de operacao da materializacao do PDF-alvo AB#1480 (#26)

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
Kemuel-sepulvida 3 weeks ago
committed by GitHub
parent
commit
3c21d844f9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .gitignore
  2. 2
      knowledge
  3. 134
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  4. 52
      sapl/integracao_hub/migrations/0005_painel_materializacao.py
  5. 137
      sapl/integracao_hub/models.py
  6. 181
      sapl/integracao_hub/painel.py
  7. 236
      sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html
  8. 255
      sapl/integracao_hub/tests/test_painel.py
  9. 13
      sapl/integracao_hub/urls.py
  10. 15
      sapl/templates/menu_tabelas_auxiliares.yaml

4
.gitignore

@ -126,4 +126,6 @@ start.sh
*.sql *.sql
dump_*.sql dump_*.sql
DADOS DADOS
# Backups de .env criados por script de deploy — carregam SECRET_KEY e chaves de API
.env.bak*

2
knowledge

@ -1 +1 @@
Subproject commit 2ee89cb5c88e602f8a61175146d054485747cca0 Subproject commit f8c0df145abbb482cb80760a298f2bdff5e07578

134
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.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand, CommandError 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 from sapl.materia.models import MateriaLegislativa
logger = logging.getLogger(__name__) 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: class _RequisicaoDeSistema:
"""Substituto mínimo de request para reusar `_gerar_pdf_da_materia` fora do HTTP. """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. ' 'ato isolado de retificar um texto, nao para uma varredura. '
'A retificacao adiada aparece no resumo e roda no ciclo ' 'A retificacao adiada aparece no resumo e roda no ciclo '
'normal, uma a uma.')) '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): def handle(self, *args, **options):
# Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por # Erro de CONFIGURACAO morre aqui, alto e cedo — nao vira 861 falhas por
@ -133,21 +150,80 @@ class Command(BaseCommand):
intervalo = options['intervalo'] intervalo = options['intervalo']
somente_novos = options['somente_novos'] somente_novos = options['somente_novos']
disparada_por = options['disparada_por']
if intervalo <= 0: if intervalo <= 0:
self._passada(somente_novos) self._passada_registrada(somente_novos, disparada_por)
return return
self.stdout.write( self.stdout.write(
'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo) 'materializar_pdfs: laco a cada %ss (Ctrl-C para sair)' % intervalo)
while True: while True:
try: try:
self._passada(somente_novos) self._passada_registrada(somente_novos, disparada_por)
except Exception as exc: # noqa — o laco NUNCA morre: se morrer, except Exception as exc: # noqa — o laco NUNCA morre: se morrer,
# a materializacao para de vez e ninguem percebe ate a materia # a materializacao para de vez e ninguem percebe ate a materia
# nao aparecer para assinar. # nao aparecer para assinar.
logger.exception('materializar_pdfs: passada falhou: %s', exc) logger.exception('materializar_pdfs: passada falhou: %s', exc)
time.sleep(intervalo) 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 materias = (MateriaLegislativa.objects
.filter(numero_protocolo__isnull=False, .filter(numero_protocolo__isnull=False,
texto_original__isnull=False) texto_original__isnull=False)
@ -166,18 +242,35 @@ class Command(BaseCommand):
falhas += 1 falhas += 1
motivos[type(exc).__name__] = motivos.get( motivos[type(exc).__name__] = motivos.get(
type(exc).__name__, 0) + 1 type(exc).__name__, 0) + 1
self._registrar_falha(materia, type(exc).__name__)
continue continue
if resultado == 'gerado': if resultado == 'gerado':
gerados += 1 gerados += 1
self._limpar_falha(materia)
elif resultado == 'retificado': elif resultado == 'retificado':
retificados += 1 retificados += 1
self._limpar_falha(materia)
elif resultado == 'falha': elif resultado == 'falha':
falhas += 1 falhas += 1
motivos[motivo] = motivos.get(motivo, 0) + 1 motivos[motivo] = motivos.get(motivo, 0) + 1
self._registrar_falha(materia, motivo)
elif resultado == 'adiado': 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 adiados += 1
else: else:
pulados += 1 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( self.stdout.write(
'materializar_pdfs: %s gerados, %s retificados, %s em dia, ' '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`. # que se le e se age. Em 22/08/2026 essas 873 eram um unico `codigo -8`.
self._relatar_motivos(motivos, falhas) 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): def _relatar_motivos(self, motivos, falhas):
if not motivos: if not motivos:
return return
@ -221,6 +328,23 @@ class Command(BaseCommand):
'ONLYOFFICE_JWT_ENABLED=True e ONLYOFFICE_JWT_SECRET com o ' 'ONLYOFFICE_JWT_ENABLED=True e ONLYOFFICE_JWT_SECRET com o '
'mesmo segredo do servidor do OnlyOffice.' 'mesmo segredo do servidor do OnlyOffice.'
% getattr(settings, 'ONLYOFFICE_URL', '<ausente>')) % getattr(settings, 'ONLYOFFICE_URL', '<ausente>'))
# 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', '<ausente>'),
_base_url_de_sistema() or '<ausente>'))
self.stderr.write(aviso) self.stderr.write(aviso)
logger.error(aviso) logger.error(aviso)

52
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',),
},
),
]

137
sapl/integracao_hub/models.py

@ -1,3 +1,4 @@
from django.contrib.postgres.fields import JSONField
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -184,3 +185,139 @@ class AnexoProposicao(models.Model):
def __str__(self): def __str__(self):
return '%s (%s)' % (self.nome_original, self.proposicao_id) 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 apurava todos estes contadores; eles morriam no `stdout` do
processo. Aqui eles ficam, e a tela 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)

181
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 existia dentro
da VPS, produzindo `código -4` em 861 matérias). Todos os números necessários
para responder aquilo em dez segundos 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 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)

236
sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html

@ -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 %}

255
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 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 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()

13
sapl/integracao_hub/urls.py

@ -1,5 +1,6 @@
from django.conf.urls import url from django.conf.urls import url
from .painel import DispararMaterializacaoView, PainelMaterializacaoView
from .views import (AssinaturasConcluidasPollView, from .views import (AssinaturasConcluidasPollView,
AssinaturasPendentesPollView, AssinaturasPendentesPollView,
DocumentoAlvoView, DocumentoAlvoView,
@ -61,4 +62,16 @@ urlpatterns = [
url(r'^api/integracao/reconciliacao/$', url(r'^api/integracao/reconciliacao/$',
InventarioView.as_view(), InventarioView.as_view(),
name='integracao_hub_reconciliacao'), 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'),
] ]

15
sapl/templates/menu_tabelas_auxiliares.yaml

@ -225,4 +225,17 @@
css_class: btn btn-link css_class: btn btn-link
- title: {% trans 'Publicador' %} - title: {% trans 'Publicador' %}
url: sapl.lexml:lexmlpublicador_list url: sapl.lexml:lexmlpublicador_list
css_class: btn btn-link 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

Loading…
Cancel
Save