mirror of https://github.com/interlegis/sapl.git
Browse Source
Feat(Materializacao): fila de prioridade materializa materia protocolada em segundos AB#1480pull/3858/head
committed by
GitHub
12 changed files with 455 additions and 29 deletions
@ -0,0 +1 @@ |
|||
default_app_config = 'sapl.integracao_hub.apps.IntegracaoHubConfig' |
|||
@ -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'), |
|||
}, |
|||
), |
|||
] |
|||
@ -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 só 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) |
|||
@ -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 só 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 só |
|||
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() |
|||
Loading…
Reference in new issue