Browse Source

Merge pull request #27 from Move-System/feat/fila-prioridade-materializacao

Feat(Materializacao): fila de prioridade materializa materia protocolada em segundos AB#1480
pull/3858/head
Kemuel-sepulvida 3 weeks ago
committed by GitHub
parent
commit
e9ad6f7113
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      docker/docker-compose-dev.yml
  2. 5
      docker/startup_scripts/start.sh
  3. 1
      sapl/integracao_hub/__init__.py
  4. 5
      sapl/integracao_hub/apps.py
  5. 132
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  6. 31
      sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py
  7. 42
      sapl/integracao_hub/models.py
  8. 5
      sapl/integracao_hub/painel.py
  9. 49
      sapl/integracao_hub/receivers.py
  10. 8
      sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html
  11. 175
      sapl/integracao_hub/tests/test_fila_prioridade.py
  12. 14
      sapl/integracao_hub/tests/test_materializacao.py

3
docker/docker-compose-dev.yml

@ -19,7 +19,8 @@ services:
# producao): sem ela, materia protocolada nunca vira pendencia no app. # producao): sem ela, materia protocolada nunca vira pendencia no app.
command: > command: >
sh -c "python3 manage.py materializar_pdfs_para_assinatura sh -c "python3 manage.py materializar_pdfs_para_assinatura
--intervalo ${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300} & --intervalo ${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300}
--tick ${MATERIALIZACAO_TICK_SEGUNDOS:-15} &
exec python3 manage.py runserver 0:8000" exec python3 manage.py runserver 0:8000"
volumes: volumes:
- ..:/sapl-dev - ..:/sapl-dev

5
docker/startup_scripts/start.sh

@ -267,8 +267,9 @@ setup_cache_dir() {
# seguinte recupera sozinha o DOCX que o OnlyOffice ainda nao converteu. # seguinte recupera sozinha o DOCX que o OnlyOffice ainda nao converteu.
start_materializacao_assinatura() { start_materializacao_assinatura() {
local intervalo="${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300}" local intervalo="${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300}"
log "Starting materializacao do PDF-alvo (a cada ${intervalo}s)..." local tick="${MATERIALIZACAO_TICK_SEGUNDOS:-15}"
python manage.py materializar_pdfs_para_assinatura --intervalo "$intervalo" & log "Starting materializacao do PDF-alvo (varredura a cada ${intervalo}s, fila de prioridade a cada ${tick}s)..."
python manage.py materializar_pdfs_para_assinatura --intervalo "$intervalo" --tick "$tick" &
} }
start_services() { start_services() {

1
sapl/integracao_hub/__init__.py

@ -0,0 +1 @@
default_app_config = 'sapl.integracao_hub.apps.IntegracaoHubConfig'

5
sapl/integracao_hub/apps.py

@ -6,3 +6,8 @@ class IntegracaoHubConfig(AppConfig):
name = 'sapl.integracao_hub' name = 'sapl.integracao_hub'
label = 'integracao_hub' label = 'integracao_hub'
verbose_name = _('Integração com o Hub') verbose_name = _('Integração com o Hub')
def ready(self):
# Liga o receiver que alimenta a fila de prioridade da materialização
# (ADR 0014). Padrão do sapl.base: importar registra os @receiver.
from sapl.integracao_hub import receivers # noqa

132
sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py

@ -12,6 +12,7 @@ from django.utils import timezone
from sapl.integracao_hub.models import (DocumentoParaAssinatura, from sapl.integracao_hub.models import (DocumentoParaAssinatura,
MateriaComFalhaMaterializacao, MateriaComFalhaMaterializacao,
MateriaParaMaterializar,
PassadaMaterializacao) PassadaMaterializacao)
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import MateriaLegislativa
@ -111,11 +112,29 @@ class Command(BaseCommand):
type=int, type=int,
default=0, default=0,
metavar='SEGUNDOS', metavar='SEGUNDOS',
help=('Segundos entre passadas. 0 (padrao) roda uma vez e sai — ' help=('Segundos entre VARREDURAS COMPLETAS do acervo. 0 (padrao) '
'o modo para invocacao manual. Maior que zero fica em laco, ' 'roda uma varredura e sai — o modo para invocacao manual. '
'que e como o container sobe a rotina (start.sh): sem isso a ' 'Maior que zero fica em laco, que e como o container sobe a '
'materializacao vira passo manual e materia protocolada NUNCA ' 'rotina (start.sh): sem isso a materializacao vira passo '
'vira pendencia no app, em silencio.')) 'manual e materia protocolada NUNCA vira pendencia no app, '
'em silencio. No laco, entre uma varredura e outra, o tick '
'curto (--tick) processa a fila de prioridade (ADR 0014).'))
parser.add_argument(
'--tick',
type=int,
default=15,
metavar='SEGUNDOS',
help=('Cadencia do laco entre varreduras: a cada tick a fila de '
'prioridade (materias marcadas pelo post_save no momento do '
'protocolo/retificacao) e processada — e o que faz a materia '
'recem-protocolada virar pendencia em segundos, nao em '
'minutos. Tick vazio nao grava passada nem toca o banco alem '
'de um SELECT na fila.'))
parser.add_argument(
'--fila',
action='store_true',
help=('Processa a fila de prioridade UMA vez e sai, sem varredura. '
'Modo de teste/diagnostico do tick.'))
parser.add_argument( parser.add_argument(
'--somente-novos', '--somente-novos',
action='store_true', action='store_true',
@ -149,21 +168,39 @@ class Command(BaseCommand):
'que o servidor do OnlyOffice alcance.') 'que o servidor do OnlyOffice alcance.')
intervalo = options['intervalo'] intervalo = options['intervalo']
tick = max(1, options['tick'])
somente_novos = options['somente_novos'] somente_novos = options['somente_novos']
disparada_por = options['disparada_por'] disparada_por = options['disparada_por']
if options['fila']:
self._passada_prioritaria(somente_novos, disparada_por)
return
if intervalo <= 0: if intervalo <= 0:
self._passada_registrada(somente_novos, disparada_por) 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 — varredura completa a cada %ss, fila de '
'prioridade a cada %ss (Ctrl-C para sair)' % (intervalo, tick))
# A proxima varredura conta a partir do FIM da anterior: uma varredura
# que leva uma hora num acervo grande nao pode emendar na seguinte com
# so um tick de folga.
proxima_varredura = 0.0
while True: while True:
try:
if time.monotonic() >= proxima_varredura:
try: try:
self._passada_registrada(somente_novos, disparada_por) self._passada_registrada(somente_novos, disparada_por)
finally:
# Reagenda mesmo quando a varredura estoura: sem isso a
# varredura quebrada re-rodaria a cada tick, martelando
# o acervo inteiro a cada 15s em vez de a cada 300s.
proxima_varredura = time.monotonic() + intervalo
else:
self._passada_prioritaria(somente_novos)
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(tick)
def _fechar_orfas(self): def _fechar_orfas(self):
"""Libera o lock de passada cujo processo morreu sem fechar a linha. """Libera o lock de passada cujo processo morreu sem fechar a linha.
@ -188,17 +225,40 @@ class Command(BaseCommand):
'materializar_pdfs: passada %s abandonada (aberta desde %s) — ' 'materializar_pdfs: passada %s abandonada (aberta desde %s) — '
'lock liberado', orfa.pk, orfa.iniciada_em) 'lock liberado', orfa.pk, orfa.iniciada_em)
def _passada_registrada(self, somente_novos=False, disparada_por=''): def _passada_prioritaria(self, somente_novos=False, disparada_por=''):
"""Só as matérias marcadas pelo evento — o caminho dos segundos (ADR 0014).
Fila vazia é o caso de quase todo tick: sai sem gravar passada nem
disputar o lock. Com fila, roda como passada normal (mesmo lock, mesma
linha na tela) restrita às marcadas se a varredura completa estiver no
meio, o lock dispensa esta passada e as marcas ficam para o próximo
tick.
"""
marcas = list(MateriaParaMaterializar.objects
.select_related('materia')
.order_by('marcada_em', 'materia_id'))
if not marcas:
return None
return self._passada_registrada(somente_novos, disparada_por,
marcas=marcas)
def _passada_registrada(self, somente_novos=False, disparada_por='',
marcas=None):
"""Envelope da passada: abre a linha, roda, fecha — sempre fecha. """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 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 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. laço, tick de prioridade e botão nunca convertam o mesmo documento ao
mesmo tempo.
""" """
self._fechar_orfas() self._fechar_orfas()
disparo = (PassadaMaterializacao.DISPARO_MANUAL if disparada_por if marcas is not None:
else PassadaMaterializacao.DISPARO_LACO) disparo = PassadaMaterializacao.DISPARO_PRIORIDADE
elif disparada_por:
disparo = PassadaMaterializacao.DISPARO_MANUAL
else:
disparo = PassadaMaterializacao.DISPARO_LACO
try: try:
# `atomic` aqui não é transação de negócio: sem ele a IntegrityError # `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 # do índice único envenena a transação corrente e o rollback leva
@ -216,23 +276,53 @@ class Command(BaseCommand):
return None return None
try: try:
self._passada(somente_novos, passada) self._passada(somente_novos, passada, marcas)
finally: finally:
passada.em_andamento = None passada.em_andamento = None
passada.terminada_em = timezone.now() passada.terminada_em = timezone.now()
passada.save() passada.save()
return passada return passada
def _passada(self, somente_novos=False, passada=None): def _pares_da_fila(self, marcas):
"""(matéria, marca) das marcadas ainda elegíveis; as demais desmarcam.
A matéria pode ter saído do filtro entre o evento e o tick (protocolo
anulado pelo protocoloadm, texto removido) processá-la seria erro,
deixar a marca seria fila que nunca esvazia.
"""
pares = []
for marca in marcas:
materia = marca.materia
if not materia.numero_protocolo or not materia.texto_original:
self._desmarcar(marca)
continue
pares.append((materia, marca))
return pares
def _desmarcar(self, marca):
"""Tira da fila SÓ se a marca não avançou depois da leitura.
Retificação que chega no meio da conversão remarca com `marcada_em`
novo o filtro deixa essa marca viva e o próximo tick regenera sobre
o texto novo. Sem o filtro, o evento se perderia até a varredura.
"""
MateriaParaMaterializar.objects.filter(
pk=marca.pk, marcada_em__lte=marca.marcada_em).delete()
def _passada(self, somente_novos=False, passada=None, marcas=None):
if marcas is 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)
.exclude(texto_original='') .exclude(texto_original='')
.order_by('id')) .order_by('id'))
pares = ((materia, None) for materia in materias.iterator())
else:
pares = self._pares_da_fila(marcas)
gerados = retificados = pulados = falhas = adiados = 0 gerados = retificados = pulados = falhas = adiados = 0
motivos = {} motivos = {}
for materia in materias.iterator(): for materia, marca in pares:
try: try:
resultado, motivo = self._materializar(materia, somente_novos) resultado, motivo = self._materializar(materia, somente_novos)
except Exception as exc: # noqa — uma matéria não trava as demais (§5.1) except Exception as exc: # noqa — uma matéria não trava as demais (§5.1)
@ -243,7 +333,14 @@ class Command(BaseCommand):
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__) self._registrar_falha(materia, type(exc).__name__)
# Falha também desmarca: a falha já está registrada para a tela
# e a varredura completa retenta — manter a marca faria o tick
# martelar o OnlyOffice a cada 15s com o ambiente quebrado.
if marca is not None:
self._desmarcar(marca)
continue continue
if marca is not None:
self._desmarcar(marca)
if resultado == 'gerado': if resultado == 'gerado':
gerados += 1 gerados += 1
self._limpar_falha(materia) self._limpar_falha(materia)
@ -424,7 +521,14 @@ class Command(BaseCommand):
materia.assinado_em = None materia.assinado_em = None
materia.assinado_por = None materia.assinado_por = None
materia.codigo_autenticacao = None materia.codigo_autenticacao = None
# O post_save de MateriaLegislativa alimenta a fila de prioridade
# (ADR 0014); este save é a PRÓPRIA materialização zerando a
# assinatura — remarcar aqui criaria um ciclo fila→passada→fila.
materia._materializacao_em_curso = True
try:
materia.save() materia.save()
finally:
materia._materializacao_em_curso = False
logger.info( logger.info(
'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) ' 'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) '

31
sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py

@ -0,0 +1,31 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materia', '0094_add_anexoproposicao'),
('integracao_hub', '0005_painel_materializacao'),
]
operations = [
migrations.AlterField(
model_name='passadamaterializacao',
name='disparo',
field=models.CharField(choices=[('laco', 'Laço automático'), ('manual', 'Disparo manual pela tela'), ('prioridade', 'Fila de prioridade')], default='laco', max_length=10, verbose_name='Origem do disparo'),
),
migrations.CreateModel(
name='MateriaParaMaterializar',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('marcada_em', models.DateTimeField(help_text='Avança a cada remarcação. O tick de prioridade só remove a marca se ela não avançou depois da leitura — retificação no meio da conversão não se perde.', verbose_name='Marcada em')),
('materia', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='materia.MateriaLegislativa', verbose_name='Matéria Legislativa')),
],
options={
'verbose_name': 'Matéria na Fila de Materialização',
'verbose_name_plural': 'Matérias na Fila de Materialização',
'ordering': ('marcada_em', 'materia_id'),
},
),
]

42
sapl/integracao_hub/models.py

@ -210,9 +210,11 @@ class PassadaMaterializacao(models.Model):
DISPARO_LACO = 'laco' DISPARO_LACO = 'laco'
DISPARO_MANUAL = 'manual' DISPARO_MANUAL = 'manual'
DISPARO_PRIORIDADE = 'prioridade'
DISPARO_CHOICES = ( DISPARO_CHOICES = (
(DISPARO_LACO, _('Laço automático')), (DISPARO_LACO, _('Laço automático')),
(DISPARO_MANUAL, _('Disparo manual pela tela')), (DISPARO_MANUAL, _('Disparo manual pela tela')),
(DISPARO_PRIORIDADE, _('Fila de prioridade')),
) )
iniciada_em = models.DateTimeField( iniciada_em = models.DateTimeField(
@ -321,3 +323,43 @@ class MateriaComFalhaMaterializacao(models.Model):
def __str__(self): def __str__(self):
return 'Matéria %s: %s' % (self.materia_id, self.motivo) return 'Matéria %s: %s' % (self.materia_id, self.motivo)
class MateriaParaMaterializar(models.Model):
"""Fila de prioridade da materialização (ADR 0014): a marca, não o trabalho.
Fecha o gap entre o evento (matéria protocolada com texto, texto
retificado) e a varredura do laço, que rodava a cada 5 minutos sobre o
acervo inteiro. O receiver de `post_save` de `MateriaLegislativa` grava
UMA linha aqui custo de um upsert dentro do request e quem converte
continua sendo o processo do laço, no tick curto de prioridade. É isso
que preserva o dono único da conversão OnlyOffice (§5.1): o caminho quente
das requisições nunca converte, aponta.
Uma linha por matéria, não por evento: dois saves seguidos da mesma
matéria são um único trabalho pendente. `marcada_em` avança a cada
remarcação é ela que protege a marca de ser apagada por um tick que
processou a versão anterior do texto (o tick apaga marca com
`marcada_em` menor ou igual à que ele leu).
"""
materia = models.OneToOneField(
'materia.MateriaLegislativa',
on_delete=models.CASCADE,
related_name='+',
verbose_name=_('Matéria Legislativa'))
marcada_em = models.DateTimeField(
verbose_name=_('Marcada em'),
help_text=_('Avança a cada remarcação. O tick de prioridade só '
'remove a marca se ela não avançou depois da leitura — '
'retificação no meio da conversão não se perde.'))
class Meta:
verbose_name = _('Matéria na Fila de Materialização')
verbose_name_plural = _('Matérias na Fila de Materialização')
ordering = ('marcada_em', 'materia_id')
def __str__(self):
return 'Matéria %s na fila desde %s' % (self.materia_id,
self.marcada_em)

5
sapl/integracao_hub/painel.py

@ -42,7 +42,7 @@ from django.urls import reverse
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao,
PassadaMaterializacao) MateriaParaMaterializar, PassadaMaterializacao)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -105,6 +105,9 @@ class PainelMaterializacaoView(PermissionRequiredMixin, TemplateView):
'total_falhas': total_falhas, 'total_falhas': total_falhas,
'falhas_ocultas': max(0, total_falhas - len(falhas)), 'falhas_ocultas': max(0, total_falhas - len(falhas)),
'alvos_materializados': DocumentoParaAssinatura.objects.count(), 'alvos_materializados': DocumentoParaAssinatura.objects.count(),
# Fila de prioridade (ADR 0014): matérias marcadas pelo evento
# aguardando o tick. Cresce e não esvazia = o laço não está de pé.
'na_fila': MateriaParaMaterializar.objects.count(),
# A configuração que decide o sucesso da conversão fica na tela # 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 # porque foi exatamente ela a causa do incidente de 25/08 — e lê-la
# exigia shell. # exigia shell.

49
sapl/integracao_hub/receivers.py

@ -0,0 +1,49 @@
"""Marca a matéria para materialização imediata — o lado 'evento' do ADR 0014.
O `post_save` de `MateriaLegislativa` é deliberadamente o ÚNICO ponto de escuta:
todos os caminhos que interessam (protocolo efetivado, texto_original salvo ou
retificado, proposição incorporada, edição pelo CRUD) terminam num save da
matéria. Escutar cada view seria uma lista que envelhece; escutar o modelo é uma
linha que não envelhece.
O receiver grava a marca (um upsert) quem converte é o laço do
`materializar_pdfs_para_assinatura`, no tick de prioridade. O request nunca
paga a conversão OnlyOffice (§5.1: a conversão tem UM dono).
"""
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from sapl.materia.models import MateriaLegislativa
logger = logging.getLogger(__name__)
@receiver(post_save, sender=MateriaLegislativa,
dispatch_uid='integracao_hub_marcar_para_materializar')
def marcar_para_materializar(sender, instance, raw=False, **kwargs):
# A retificação feita pela PRÓPRIA rotina salva a matéria (zera a
# assinatura, §5.1) — remarcar aqui criaria um ciclo: cada passada
# alimentaria a fila que ela mesma consome.
if getattr(instance, '_materializacao_em_curso', False):
return
if raw: # loaddata/fixtures não é evento de negócio
return
# Só matéria que a materialização enxerga (mesmo filtro da varredura):
# protocolada e com texto. As demais entrariam na fila para o tick
# descartar — ruído puro.
if not instance.numero_protocolo or not instance.texto_original:
return
from sapl.integracao_hub.models import MateriaParaMaterializar
try:
MateriaParaMaterializar.objects.update_or_create(
materia=instance,
defaults={'marcada_em': timezone.now()})
except Exception as exc: # noqa — a marca é aceleração, não requisito:
# falhar aqui NUNCA pode derrubar o save da matéria; a varredura
# completa (rede de segurança) materializa no ciclo dela.
logger.warning(
'integracao_hub: falha ao marcar matéria %s para materialização '
'(%s) — a varredura completa cobre', instance.pk, exc)

8
sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html

@ -46,6 +46,12 @@
<p> <p>
<strong>{{ alvos_materializados }}</strong> documento(s) já materializado(s) <strong>{{ alvos_materializados }}</strong> documento(s) já materializado(s)
e disponível(is) para assinatura. e disponível(is) para assinatura.
{% if na_fila %}
<span class="text-info">
<strong>{{ na_fila }}</strong> matéria(s) na fila de prioridade,
aguardando o próximo tick da rotina.
</span>
{% endif %}
{% if total_falhas %} {% if total_falhas %}
<span class="text-danger"> <span class="text-danger">
<strong>{{ total_falhas }}</strong> matéria(s) travada(s) agora. <strong>{{ total_falhas }}</strong> matéria(s) travada(s) agora.
@ -200,6 +206,8 @@
<td> <td>
{% if passada.disparo == 'manual' %} {% if passada.disparo == 'manual' %}
{{ passada.disparada_por|default:"manual" }} {{ passada.disparada_por|default:"manual" }}
{% elif passada.disparo == 'prioridade' %}
fila
{% else %} {% else %}
laço laço
{% endif %} {% endif %}

175
sapl/integracao_hub/tests/test_fila_prioridade.py

@ -0,0 +1,175 @@
"""Fila de prioridade da materialização (ADR 0014).
O contrato sob teste: o save da matéria protocolada com texto MARCA (post_save,
custo de um upsert), e o tick da rotina CONVERTE (`--fila` é o tick avulso).
O request nunca paga conversão; a matéria vira pendência em segundos; a
varredura completa segue existindo como rede de segurança.
"""
import hashlib
import pytest
from django.core.files.base import ContentFile
from django.core.management import call_command
from model_bakery import baker
from sapl.integracao_hub.models import (DocumentoParaAssinatura,
MateriaParaMaterializar,
PassadaMaterializacao)
from sapl.materia.models import MateriaLegislativa
PDF = b'%PDF-1.4 conteudo-original'
@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
# ---------------------------------------------------------------------------
# O evento marca — e só marca o que a materialização enxerga
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_save_de_materia_protocolada_com_texto_entra_na_fila(db):
materia = criar_materia()
assert MateriaParaMaterializar.objects.filter(materia=materia).exists()
@pytest.mark.django_db(transaction=False)
def test_materia_sem_protocolo_ou_sem_texto_fica_fora_da_fila(db):
criar_materia(protocolo=None)
baker.make(MateriaLegislativa, numero_protocolo=101) # sem texto
assert not MateriaParaMaterializar.objects.exists()
@pytest.mark.django_db(transaction=False)
def test_dois_saves_da_mesma_materia_sao_uma_marca_so(db):
materia = criar_materia()
materia.save()
assert MateriaParaMaterializar.objects.filter(
materia=materia).count() == 1
# ---------------------------------------------------------------------------
# O tick converte — só as marcadas, e esvazia a fila
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_tick_materializa_so_as_marcadas_e_esvazia_a_fila(db):
marcada = criar_materia(protocolo=100)
fora_da_fila = criar_materia(protocolo=101)
MateriaParaMaterializar.objects.filter(materia=fora_da_fila).delete()
call_command('materializar_pdfs_para_assinatura', '--fila')
alvo = DocumentoParaAssinatura.objects.get(materia=marcada)
assert alvo.hash_sha256 == hashlib.sha256(PDF).hexdigest()
# A não marcada fica para a varredura completa — o tick não varre acervo.
assert not DocumentoParaAssinatura.objects.filter(
materia=fora_da_fila).exists()
assert not MateriaParaMaterializar.objects.exists()
passada = PassadaMaterializacao.objects.get()
assert passada.disparo == PassadaMaterializacao.DISPARO_PRIORIDADE
assert passada.gerados == 1
assert passada.terminada_em is not None
@pytest.mark.django_db(transaction=False)
def test_tick_com_fila_vazia_nao_grava_passada(db):
call_command('materializar_pdfs_para_assinatura', '--fila')
assert not PassadaMaterializacao.objects.exists()
@pytest.mark.django_db(transaction=False)
def test_retificacao_pelo_tick_regenera_e_nao_realimenta_a_fila(db):
"""O save que a retificação faz (zerar assinatura) não pode remarcar.
Sem a supressão, cada passada de retificação realimentaria a fila que ela
mesma consome a matéria nunca sairia do tick.
"""
materia = criar_materia()
call_command('materializar_pdfs_para_assinatura', '--fila')
assert not MateriaParaMaterializar.objects.exists()
retificado = b'%PDF-1.4 texto-retificado'
materia.texto_original.save('texto.pdf', ContentFile(retificado),
save=True)
assert MateriaParaMaterializar.objects.filter(materia=materia).exists()
call_command('materializar_pdfs_para_assinatura', '--fila')
alvo = DocumentoParaAssinatura.objects.get(materia=materia)
assert alvo.hash_origem == hashlib.sha256(retificado).hexdigest()
assert not MateriaParaMaterializar.objects.exists()
@pytest.mark.django_db(transaction=False)
def test_falha_desmarca_para_nao_martelar_o_onlyoffice(db, monkeypatch):
"""Falha registra a matéria travada e sai da fila — retenta na varredura.
Manter a marca faria o tick retentar a cada 15s com o ambiente quebrado.
"""
materia = criar_materia(nome='texto.docx', conteudo=b'docx-qualquer')
monkeypatch.setattr(
'sapl.integracao_hub.management.commands.'
'materializar_pdfs_para_assinatura._origem_servida_confere',
lambda materia, hash_origem: (True, None))
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia',
lambda materia, request: (None, 'OnlyOffice fora do ar'))
call_command('materializar_pdfs_para_assinatura', '--fila')
assert not DocumentoParaAssinatura.objects.filter(
materia=materia).exists()
assert not MateriaParaMaterializar.objects.exists()
assert materia.falha_materializacao.motivo == 'OnlyOffice fora do ar'
@pytest.mark.django_db(transaction=False)
def test_lock_ocupado_dispensa_o_tick_e_preserva_a_marca(db):
"""Varredura no meio: o tick é dispensado e a fila espera o próximo.
É o mesmo lock único de `em_andamento` a marca não pode se perder
porque a varredura estava rodando na hora.
"""
materia = criar_materia()
PassadaMaterializacao.objects.create() # em_andamento=True (lock)
call_command('materializar_pdfs_para_assinatura', '--fila')
assert not DocumentoParaAssinatura.objects.filter(
materia=materia).exists()
assert MateriaParaMaterializar.objects.filter(materia=materia).exists()
@pytest.mark.django_db(transaction=False)
def test_materia_que_saiu_do_filtro_desmarca_sem_converter(db):
"""Protocolo anulado entre o evento e o tick: desmarca, não processa.
Processar seria materializar matéria fora do filtro da rotina; manter a
marca seria fila que nunca esvazia.
"""
materia = criar_materia()
materia.numero_protocolo = None
materia.save() # o receiver ignora (sem protocolo), a marca antiga fica
call_command('materializar_pdfs_para_assinatura', '--fila')
assert not DocumentoParaAssinatura.objects.filter(
materia=materia).exists()
assert not MateriaParaMaterializar.objects.exists()

14
sapl/integracao_hub/tests/test_materializacao.py

@ -154,7 +154,9 @@ def test_com_intervalo_fica_em_laco_e_dorme_entre_passadas(db, monkeypatch):
Sem esse laço a materialização vira passo manual de implantação, e matéria Sem esse laço a materialização vira passo manual de implantação, e matéria
protocolada nunca vira pendência no app falha muda, sem erro nenhum. protocolada nunca vira pendência no app falha muda, sem erro nenhum.
Aqui o sleep corta o laço na terceira chamada para o teste terminar. Desde o ADR 0014 o laço dorme o TICK (fila de prioridade), não o intervalo
a varredura completa é agendada por relógio, entre os ticks. Aqui o sleep
corta o laço na terceira chamada para o teste terminar.
""" """
criar_materia(protocolo=701) criar_materia(protocolo=701)
dormidas = [] dormidas = []
@ -170,9 +172,10 @@ def test_com_intervalo_fica_em_laco_e_dorme_entre_passadas(db, monkeypatch):
sleep_que_interrompe) sleep_que_interrompe)
with pytest.raises(KeyboardInterrupt): with pytest.raises(KeyboardInterrupt):
call_command('materializar_pdfs_para_assinatura', intervalo=30) call_command('materializar_pdfs_para_assinatura', intervalo=30,
tick=10)
assert dormidas == [30, 30, 30] assert dormidas == [10, 10, 10]
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
@ -181,9 +184,12 @@ def test_laco_sobrevive_a_passada_que_estoura(db, monkeypatch):
from sapl.integracao_hub.management.commands import ( from sapl.integracao_hub.management.commands import (
materializar_pdfs_para_assinatura as cmd) materializar_pdfs_para_assinatura as cmd)
# Matéria marcada mantém a fila de prioridade ocupada: a segunda passada do
# teste é um tick, e ele também não pode derrubar o laço.
criar_materia(protocolo=702)
passadas = [] passadas = []
def passada_que_explode(self, somente_novos=False): def passada_que_explode(self, *args, **kwargs):
passadas.append(1) passadas.append(1)
raise RuntimeError('banco caiu no meio da varredura') raise RuntimeError('banco caiu no meio da varredura')

Loading…
Cancel
Save