Browse Source

Feat(Reconciliacao): inventario de conferencia e keyset (data, id) nos polls AB#1480

Implementa o lado SAPL do refinamento da reconciliacao.

INVENTARIO — GET /api/integracao/reconciliacao/?id_gt=&tramitacao_id_gt=
devolve apenas IDS de proposicoes e tramitacoes acima do corte, com a flag
`truncado` avisando quando a faixa encheu. E lista de conferencia, nao
segunda via dos dados: o hub compara com o que conhece e descobre o que
ficou de fora. Corte por ID porque Proposicao NAO tem campo de criacao — as
datas que existem sao de estado (envio, recebimento, devolucao) e nenhuma
responde "quando apareceu". Mesmo filtro dos polls (cancelado=False), senao
a reconciliacao apontaria canceladas como ausentes para sempre.

KEYSET (data, id) nos polls por data — corrige o travamento do refinamento
§1.1: com cursor so de data e >=, uma pagina inteira de registros com o
MESMO timestamp devolvia sempre o mesmo cursor, ele nao avancava e a fonte
relia a mesma pagina para sempre, sem erro e sem nunca progredir. Agora
id_gt desempata dentro do instante.

TESTES: 9 novos (inventario + o cenario exato do travamento). Corrigidos
tambem tres defeitos das fixtures existentes, que nunca haviam rodado:
Token.objects.create colidia com o token que sapl/api/signals.py:8 cria no
post_save; TipoProposicao exige content_type; e Tramitacao sem status
quebrava a asercao. Resultado: 25 de 29 passam.

Os 4 que faltam sao de recepcao e falham por problema interno do
sapl.rules em banco de teste novo (ContentType matching query does not
exist), NAO por este diff — na versao da dev, sem estas mudancas, o mesmo
arquivo da 10 erros. O caminho de recepcao esta provado em producao
(proposicao 1071, criada pela ida em 14/08).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
kemuel 1 month ago
parent
commit
105c1cd37d
  1. 15
      sapl/integracao_hub/tests/test_poll.py
  2. 19
      sapl/integracao_hub/tests/test_recepcao.py
  3. 148
      sapl/integracao_hub/tests/test_reconciliacao.py
  4. 7
      sapl/integracao_hub/urls.py
  5. 65
      sapl/integracao_hub/views.py

15
sapl/integracao_hub/tests/test_poll.py

@ -8,7 +8,8 @@ from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient
from sapl.base.models import Autor
from sapl.materia.models import Proposicao, Tramitacao
from sapl.materia.models import (Proposicao, StatusTramitacao,
Tramitacao)
BASE = '/api/integracao/poll/'
@ -19,7 +20,9 @@ def cliente_hub(db):
permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao)
token = Token.objects.create(user=usuario)
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente
@ -130,7 +133,9 @@ def test_desde_invalido_da_400(cliente_hub):
@pytest.mark.django_db(transaction=False)
def test_tramitacoes_por_cursor_de_id(cliente_hub):
tramitacao = baker.make(Tramitacao)
# status explicito: o envelope canonico exige situacao.id_origem + descricao,
# entao tramitacao sem status nao exercita o que importa.
tramitacao = baker.make(Tramitacao, status=baker.make(StatusTramitacao))
resposta = cliente_hub.get(BASE + 'tramitacoes/', {'id_gt': 0})
@ -154,7 +159,9 @@ def test_limite_e_respeitado(cliente_hub):
@pytest.mark.django_db(transaction=False)
def test_poll_sem_permissao_da_403(db):
usuario = baker.make('auth.User')
token = Token.objects.create(user=usuario)
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)

19
sapl/integracao_hub/tests/test_recepcao.py

@ -9,7 +9,10 @@ from rest_framework.test import APIClient
from sapl.base.models import AppConfig, Autor
from sapl.integracao_hub.models import EventoRecebido
from sapl.materia.models import Proposicao, TipoProposicao
from django.contrib.contenttypes.models import ContentType
from sapl.materia.models import (MateriaLegislativa, Proposicao,
TipoProposicao)
URL = '/api/integracao/proposicoes/'
@ -25,7 +28,9 @@ def cliente_hub(db):
permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao)
token = Token.objects.create(user=usuario)
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente
@ -38,7 +43,11 @@ def autor(db):
@pytest.fixture()
def tipo(db):
return baker.make(TipoProposicao)
# content_type e NOT NULL e o baker nao preenche FK sozinho — no SAPL ele diz que
# conteudo a proposicao gera ao ser recebida (materia, no caso).
return baker.make(
TipoProposicao,
content_type=ContentType.objects.get_for_model(MateriaLegislativa))
def corpo(autor, tipo, **extras):
@ -152,7 +161,9 @@ def test_chave_invalida_da_422(cliente_hub, app_config, autor, tipo):
@pytest.mark.django_db(transaction=False)
def test_sem_permissao_da_403(db, app_config, autor, tipo):
usuario = baker.make('auth.User')
token = Token.objects.create(user=usuario)
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)

148
sapl/integracao_hub/tests/test_reconciliacao.py

@ -0,0 +1,148 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.utils import timezone
from model_bakery import baker
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient
from sapl.materia.models import Proposicao, Tramitacao
INVENTARIO = '/api/integracao/reconciliacao/'
ENVIADAS = '/api/integracao/poll/proposicoes-enviadas/'
@pytest.fixture()
def cliente_hub(db):
usuario = baker.make('auth.User')
usuario.user_permissions.add(
Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar'))
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente
# ── Inventário: a lista de conferência da reconciliação ────────────────────────
@pytest.mark.django_db(transaction=False)
def test_inventario_devolve_ids_acima_do_corte(cliente_hub):
antiga = baker.make(Proposicao, cancelado=False)
nova = baker.make(Proposicao, cancelado=False)
resposta = cliente_hub.get(INVENTARIO, {'id_gt': antiga.pk})
assert resposta.status_code == 200
assert nova.pk in resposta.data['proposicoes']
assert antiga.pk not in resposta.data['proposicoes']
@pytest.mark.django_db(transaction=False)
def test_inventario_ignora_cancelada(cliente_hub):
# Sem isso a reconciliação apontaria a cancelada como ausente para sempre —
# o poller nunca a traz, e o hub nunca a conheceria.
cancelada = baker.make(Proposicao, cancelado=True)
resposta = cliente_hub.get(INVENTARIO, {'id_gt': 0})
assert cancelada.pk not in resposta.data['proposicoes']
@pytest.mark.django_db(transaction=False)
def test_inventario_avisa_quando_a_faixa_encheu(cliente_hub):
baker.make(Proposicao, cancelado=False, _quantity=3)
resposta = cliente_hub.get(INVENTARIO, {'id_gt': 0, 'limite': 2})
assert len(resposta.data['proposicoes']) == 2
assert resposta.data['truncado'] is True # o hub sabe que falta pedir mais
@pytest.mark.django_db(transaction=False)
def test_inventario_cursores_independentes_para_tramitacao(cliente_hub):
# Proposição e tramitação são sequências separadas: um cursor só misturaria as faixas.
proposicao = baker.make(Proposicao, cancelado=False)
tramitacao = baker.make(Tramitacao)
resposta = cliente_hub.get(
INVENTARIO, {'id_gt': proposicao.pk - 1, 'tramitacao_id_gt': tramitacao.pk})
assert proposicao.pk in resposta.data['proposicoes']
assert tramitacao.pk not in resposta.data['tramitacoes']
@pytest.mark.django_db(transaction=False)
def test_inventario_sem_permissao_da_403(db):
usuario = baker.make('auth.User')
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
assert cliente.get(INVENTARIO, {'id_gt': 0}).status_code == 403
# ── Keyset: o empate de timestamp que travava a fonte para sempre ──────────────
@pytest.mark.django_db(transaction=False)
def test_paginacao_por_data_desempata_por_id_e_avanca(cliente_hub):
"""O cenário que travava: uma página inteira com o MESMO timestamp.
Com cursor de data e `>=`, a segunda chamada devolvia exatamente a mesma
página o cursor não avançava e nada depois daquele instante era lido, sem
erro nenhum. Com (data, id) a segunda página continua de onde a primeira parou.
"""
instante = timezone.now() - timedelta(hours=1)
tres = [baker.make(Proposicao, data_envio=instante, cancelado=False)
for _ in range(3)]
ids = sorted(p.pk for p in tres)
primeira = cliente_hub.get(
ENVIADAS, {'desde': instante.isoformat(), 'id_gt': 0, 'limite': 2})
assert [p['id'] for p in primeira.data['resultados']] == ids[:2]
# O hub avança o cursor para (mesmo instante, último id lido).
segunda = cliente_hub.get(
ENVIADAS, {'desde': instante.isoformat(), 'id_gt': ids[1], 'limite': 2})
assert [p['id'] for p in segunda.data['resultados']] == [ids[2]] # progrediu
@pytest.mark.django_db(transaction=False)
def test_paginacao_por_data_nao_repete_o_ja_lido_no_mesmo_instante(cliente_hub):
instante = timezone.now() - timedelta(hours=2)
p1 = baker.make(Proposicao, data_envio=instante, cancelado=False)
resposta = cliente_hub.get(
ENVIADAS, {'desde': instante.isoformat(), 'id_gt': p1.pk})
assert p1.pk not in [p['id'] for p in resposta.data['resultados']]
@pytest.mark.django_db(transaction=False)
def test_paginacao_por_data_traz_o_que_veio_depois_do_instante(cliente_hub):
instante = timezone.now() - timedelta(hours=3)
depois = baker.make(
Proposicao, data_envio=instante + timedelta(minutes=5), cancelado=False)
resposta = cliente_hub.get(
ENVIADAS, {'desde': instante.isoformat(), 'id_gt': 999999})
# id_gt alto não pode esconder quem tem timestamp maior — o desempate só vale
# dentro do mesmo instante.
assert depois.pk in [p['id'] for p in resposta.data['resultados']]
@pytest.mark.django_db(transaction=False)
def test_paginacao_por_data_id_gt_invalido_da_400(cliente_hub):
resposta = cliente_hub.get(
ENVIADAS, {'desde': timezone.now().isoformat(), 'id_gt': 'abc'})
assert resposta.status_code == 400

7
sapl/integracao_hub/urls.py

@ -1,6 +1,7 @@
from django.conf.urls import url
from .views import (ProposicoesCadastradasPollView,
from .views import (InventarioView,
ProposicoesCadastradasPollView,
ProposicoesDevolvidasPollView,
ProposicoesEnviadasPollView,
ProposicoesRecebidasPollView,
@ -31,4 +32,8 @@ urlpatterns = [
url(r'^api/integracao/poll/tramitacoes/$',
TramitacoesPollView.as_view(),
name='integracao_hub_poll_tramitacoes'),
url(r'^api/integracao/reconciliacao/$',
InventarioView.as_view(),
name='integracao_hub_reconciliacao'),
]

65
sapl/integracao_hub/views.py

@ -2,6 +2,7 @@ import logging
import uuid
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework import status
@ -186,6 +187,15 @@ class ProposicoesCadastradasPollView(PollView):
class PollPorDataView(PollView):
"""Paginação por chave composta (data, id).
Com o cursor de data e `>=`, uma página inteira de registros com o MESMO
timestamp devolvia sempre o mesmo valor de cursor: ele não avançava e a fonte
relia a mesma página para sempre, sem erro e sem nunca progredir. Chave composta
elimina o empate como classe de problema `id_gt` desempata dentro do mesmo
instante (refinamento da reconciliação §1.1).
"""
campo_cursor = None
def get(self, request, *args, **kwargs):
@ -194,9 +204,17 @@ class PollPorDataView(PollView):
return Response(
{'detalhe': 'desde deve ser um datetime ISO-8601'},
status=status.HTTP_400_BAD_REQUEST)
filtro = {'%s__gte' % self.campo_cursor: desde, 'cancelado': False}
id_gt = self._id_gt(request)
if id_gt is None:
return Response({'detalhe': 'id_gt deve ser inteiro'},
status=status.HTTP_400_BAD_REQUEST)
# (campo > desde) OU (campo = desde E id > id_gt) — keyset, sem pular nem repetir.
depois_do_instante = Q(**{'%s__gt' % self.campo_cursor: desde})
no_mesmo_instante = (Q(**{self.campo_cursor: desde}) & Q(id__gt=id_gt))
itens = (Proposicao.objects
.filter(**filtro)
.filter(depois_do_instante | no_mesmo_instante, cancelado=False)
.select_related('tipo', 'autor', 'content_type')
.order_by(self.campo_cursor, 'id')[:self._limite(request)])
return self._resposta(itens, serializar_proposicao)
@ -227,3 +245,46 @@ class TramitacoesPollView(PollView):
.select_related('status')
.order_by('id')[:self._limite(request)])
return self._resposta(itens, serializar_tramitacao)
class InventarioView(PollView):
"""Lista de conferência para a reconciliação do hub (refinamento §3).
Devolve **ids**, não objetos: é para o hub comparar com o que ele conhece e
descobrir o que ficou de fora, não uma segunda via dos dados. O corte é por id
porque `Proposicao` não tem campo de criação o que existe são as datas de
estado (envio, recebimento, devolução), e nenhuma delas serve para "quando
apareceu". Mesmo filtro dos polls (`cancelado=False`), senão a reconciliação
apontaria para sempre as canceladas como ausentes.
O SAPL continua sem conhecer o canônico (spec §1, princípio 2): ele diz o
que tem.
"""
def get(self, request, *args, **kwargs):
id_gt = self._id_gt(request)
if id_gt is None:
return Response({'detalhe': 'id_gt deve ser inteiro'},
status=status.HTTP_400_BAD_REQUEST)
try:
tramitacao_id_gt = int(
request.query_params.get('tramitacao_id_gt', id_gt))
except ValueError:
return Response({'detalhe': 'tramitacao_id_gt deve ser inteiro'},
status=status.HTTP_400_BAD_REQUEST)
limite = self._limite(request)
proposicoes = list(
Proposicao.objects.filter(id__gt=id_gt, cancelado=False)
.order_by('id').values_list('id', flat=True)[:limite])
tramitacoes = list(
Tramitacao.objects.filter(id__gt=tramitacao_id_gt)
.order_by('id').values_list('id', flat=True)[:limite])
return Response({
'proposicoes': proposicoes,
'tramitacoes': tramitacoes,
# Diz se a pagina encheu: o hub sabe que precisa pedir a proxima faixa
# em vez de concluir que o resto simplesmente nao existe.
'truncado': len(proposicoes) >= limite or len(tramitacoes) >= limite,
})

Loading…
Cancel
Save