diff --git a/docker/docker-compose-dev.yml b/docker/docker-compose-dev.yml index 40717a11e..987733fd1 100644 --- a/docker/docker-compose-dev.yml +++ b/docker/docker-compose-dev.yml @@ -19,7 +19,8 @@ services: # producao): sem ela, materia protocolada nunca vira pendencia no app. command: > 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" volumes: - ..:/sapl-dev diff --git a/docker/startup_scripts/start.sh b/docker/startup_scripts/start.sh index a6fb153ee..afd512a03 100755 --- a/docker/startup_scripts/start.sh +++ b/docker/startup_scripts/start.sh @@ -267,8 +267,9 @@ setup_cache_dir() { # seguinte recupera sozinha o DOCX que o OnlyOffice ainda nao converteu. start_materializacao_assinatura() { local intervalo="${MATERIALIZACAO_INTERVALO_SEGUNDOS:-300}" - log "Starting materializacao do PDF-alvo (a cada ${intervalo}s)..." - python manage.py materializar_pdfs_para_assinatura --intervalo "$intervalo" & + local tick="${MATERIALIZACAO_TICK_SEGUNDOS:-15}" + 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() { diff --git a/sapl/integracao_hub/__init__.py b/sapl/integracao_hub/__init__.py index e69de29bb..118a38158 100644 --- a/sapl/integracao_hub/__init__.py +++ b/sapl/integracao_hub/__init__.py @@ -0,0 +1 @@ +default_app_config = 'sapl.integracao_hub.apps.IntegracaoHubConfig' diff --git a/sapl/integracao_hub/apps.py b/sapl/integracao_hub/apps.py index 895256505..af85b28ef 100644 --- a/sapl/integracao_hub/apps.py +++ b/sapl/integracao_hub/apps.py @@ -6,3 +6,8 @@ class IntegracaoHubConfig(AppConfig): name = 'sapl.integracao_hub' label = 'integracao_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 diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py index 1dfcad0a6..73f649459 100644 --- a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py +++ b/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, MateriaComFalhaMaterializacao, + MateriaParaMaterializar, PassadaMaterializacao) from sapl.materia.models import MateriaLegislativa @@ -111,11 +112,29 @@ class Command(BaseCommand): type=int, default=0, metavar='SEGUNDOS', - help=('Segundos entre passadas. 0 (padrao) roda uma vez e sai — ' - 'o modo para invocacao manual. Maior que zero fica em laco, ' - 'que e como o container sobe a rotina (start.sh): sem isso a ' - 'materializacao vira passo manual e materia protocolada NUNCA ' - 'vira pendencia no app, em silencio.')) + help=('Segundos entre VARREDURAS COMPLETAS do acervo. 0 (padrao) ' + 'roda uma varredura e sai — o modo para invocacao manual. ' + 'Maior que zero fica em laco, que e como o container sobe a ' + 'rotina (start.sh): sem isso a materializacao vira passo ' + '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( '--somente-novos', action='store_true', @@ -149,21 +168,39 @@ class Command(BaseCommand): 'que o servidor do OnlyOffice alcance.') intervalo = options['intervalo'] + tick = max(1, options['tick']) somente_novos = options['somente_novos'] disparada_por = options['disparada_por'] + if options['fila']: + self._passada_prioritaria(somente_novos, disparada_por) + return if intervalo <= 0: self._passada_registrada(somente_novos, disparada_por) return 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: try: - self._passada_registrada(somente_novos, disparada_por) + if time.monotonic() >= proxima_varredura: + try: + 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, # a materializacao para de vez e ninguem percebe ate a materia # nao aparecer para assinar. logger.exception('materializar_pdfs: passada falhou: %s', exc) - time.sleep(intervalo) + time.sleep(tick) def _fechar_orfas(self): """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) — ' '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. 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. + laço, tick de prioridade 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) + if marcas is not None: + disparo = PassadaMaterializacao.DISPARO_PRIORIDADE + elif disparada_por: + disparo = PassadaMaterializacao.DISPARO_MANUAL + else: + disparo = 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 @@ -216,23 +276,53 @@ class Command(BaseCommand): return None try: - self._passada(somente_novos, passada) + self._passada(somente_novos, passada, marcas) 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 - .filter(numero_protocolo__isnull=False, - texto_original__isnull=False) - .exclude(texto_original='') - .order_by('id')) + 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 + .filter(numero_protocolo__isnull=False, + texto_original__isnull=False) + .exclude(texto_original='') + .order_by('id')) + pares = ((materia, None) for materia in materias.iterator()) + else: + pares = self._pares_da_fila(marcas) gerados = retificados = pulados = falhas = adiados = 0 motivos = {} - for materia in materias.iterator(): + for materia, marca in pares: try: resultado, motivo = self._materializar(materia, somente_novos) 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( type(exc).__name__, 0) + 1 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 + if marca is not None: + self._desmarcar(marca) if resultado == 'gerado': gerados += 1 self._limpar_falha(materia) @@ -424,7 +521,14 @@ class Command(BaseCommand): materia.assinado_em = None materia.assinado_por = None materia.codigo_autenticacao = None - materia.save() + # 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() + finally: + materia._materializacao_em_curso = False logger.info( 'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) ' diff --git a/sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py b/sapl/integracao_hub/migrations/0006_fila_prioridade_materializacao.py new file mode 100644 index 000000000..124d040e1 --- /dev/null +++ b/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'), + }, + ), + ] diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index 66b724ccb..78d19f513 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -210,9 +210,11 @@ class PassadaMaterializacao(models.Model): DISPARO_LACO = 'laco' DISPARO_MANUAL = 'manual' + DISPARO_PRIORIDADE = 'prioridade' DISPARO_CHOICES = ( (DISPARO_LACO, _('Laço automático')), (DISPARO_MANUAL, _('Disparo manual pela tela')), + (DISPARO_PRIORIDADE, _('Fila de prioridade')), ) iniciada_em = models.DateTimeField( @@ -321,3 +323,43 @@ class MateriaComFalhaMaterializacao(models.Model): def __str__(self): 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 só 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, só 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 só 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) diff --git a/sapl/integracao_hub/painel.py b/sapl/integracao_hub/painel.py index e226b2b91..f572ce34a 100644 --- a/sapl/integracao_hub/painel.py +++ b/sapl/integracao_hub/painel.py @@ -42,7 +42,7 @@ from django.urls import reverse from django.views.generic import TemplateView, View from .models import (DocumentoParaAssinatura, MateriaComFalhaMaterializacao, - PassadaMaterializacao) + MateriaParaMaterializar, PassadaMaterializacao) logger = logging.getLogger(__name__) @@ -105,6 +105,9 @@ class PainelMaterializacaoView(PermissionRequiredMixin, TemplateView): 'total_falhas': total_falhas, 'falhas_ocultas': max(0, total_falhas - len(falhas)), '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 # porque foi exatamente ela a causa do incidente de 25/08 — e lê-la # exigia shell. diff --git a/sapl/integracao_hub/receivers.py b/sapl/integracao_hub/receivers.py new file mode 100644 index 000000000..873e4edb1 --- /dev/null +++ b/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 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) diff --git a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html index ca5225b0e..995323369 100644 --- a/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html +++ b/sapl/integracao_hub/templates/integracao_hub/painel_materializacao.html @@ -46,6 +46,12 @@

{{ alvos_materializados }} documento(s) já materializado(s) e disponível(is) para assinatura. + {% if na_fila %} + + {{ na_fila }} matéria(s) na fila de prioridade, + aguardando o próximo tick da rotina. + + {% endif %} {% if total_falhas %} {{ total_falhas }} matéria(s) travada(s) agora. @@ -200,6 +206,8 @@ {% if passada.disparo == 'manual' %} {{ passada.disparada_por|default:"manual" }} + {% elif passada.disparo == 'prioridade' %} + fila {% else %} laço {% endif %} diff --git a/sapl/integracao_hub/tests/test_fila_prioridade.py b/sapl/integracao_hub/tests/test_fila_prioridade.py new file mode 100644 index 000000000..59f9e9a29 --- /dev/null +++ b/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 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() diff --git a/sapl/integracao_hub/tests/test_materializacao.py b/sapl/integracao_hub/tests/test_materializacao.py index 93cd1e14c..fdd06140f 100644 --- a/sapl/integracao_hub/tests/test_materializacao.py +++ b/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 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) dormidas = [] @@ -170,9 +172,10 @@ def test_com_intervalo_fica_em_laco_e_dorme_entre_passadas(db, monkeypatch): sleep_que_interrompe) 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) @@ -181,9 +184,12 @@ def test_laco_sobrevive_a_passada_que_estoura(db, monkeypatch): from sapl.integracao_hub.management.commands import ( 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 = [] - def passada_que_explode(self, somente_novos=False): + def passada_que_explode(self, *args, **kwargs): passadas.append(1) raise RuntimeError('banco caiu no meio da varredura')