Browse Source

Merge pull request #6 from Move-System/feat/inventario-reconciliacao

Feat(Reconciliação): inventário de conferência e keyset (data, id) nos polls AB#1480
pull/3858/head
Kemuel-sepulvida 1 month ago
committed by GitHub
parent
commit
f28d4f18f8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  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 rest_framework.test import APIClient
from sapl.base.models import Autor 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/' BASE = '/api/integracao/poll/'
@ -19,7 +20,9 @@ def cliente_hub(db):
permissao = Permission.objects.get( permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar') content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao) 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 = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente return cliente
@ -130,7 +133,9 @@ def test_desde_invalido_da_400(cliente_hub):
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_tramitacoes_por_cursor_de_id(cliente_hub): 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}) 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) @pytest.mark.django_db(transaction=False)
def test_poll_sem_permissao_da_403(db): def test_poll_sem_permissao_da_403(db):
usuario = baker.make('auth.User') 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 = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) 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.base.models import AppConfig, Autor
from sapl.integracao_hub.models import EventoRecebido 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/' URL = '/api/integracao/proposicoes/'
@ -25,7 +28,9 @@ def cliente_hub(db):
permissao = Permission.objects.get( permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar') content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao) 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 = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente return cliente
@ -38,7 +43,11 @@ def autor(db):
@pytest.fixture() @pytest.fixture()
def tipo(db): 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): 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) @pytest.mark.django_db(transaction=False)
def test_sem_permissao_da_403(db, app_config, autor, tipo): def test_sem_permissao_da_403(db, app_config, autor, tipo):
usuario = baker.make('auth.User') 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 = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key) 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 django.conf.urls import url
from .views import (ProposicoesCadastradasPollView, from .views import (InventarioView,
ProposicoesCadastradasPollView,
ProposicoesDevolvidasPollView, ProposicoesDevolvidasPollView,
ProposicoesEnviadasPollView, ProposicoesEnviadasPollView,
ProposicoesRecebidasPollView, ProposicoesRecebidasPollView,
@ -31,4 +32,8 @@ urlpatterns = [
url(r'^api/integracao/poll/tramitacoes/$', url(r'^api/integracao/poll/tramitacoes/$',
TramitacoesPollView.as_view(), TramitacoesPollView.as_view(),
name='integracao_hub_poll_tramitacoes'), 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 import uuid
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from rest_framework import status from rest_framework import status
@ -186,6 +187,15 @@ class ProposicoesCadastradasPollView(PollView):
class PollPorDataView(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 campo_cursor = None
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@ -194,9 +204,17 @@ class PollPorDataView(PollView):
return Response( return Response(
{'detalhe': 'desde deve ser um datetime ISO-8601'}, {'detalhe': 'desde deve ser um datetime ISO-8601'},
status=status.HTTP_400_BAD_REQUEST) 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 itens = (Proposicao.objects
.filter(**filtro) .filter(depois_do_instante | no_mesmo_instante, cancelado=False)
.select_related('tipo', 'autor', 'content_type') .select_related('tipo', 'autor', 'content_type')
.order_by(self.campo_cursor, 'id')[:self._limite(request)]) .order_by(self.campo_cursor, 'id')[:self._limite(request)])
return self._resposta(itens, serializar_proposicao) return self._resposta(itens, serializar_proposicao)
@ -227,3 +245,46 @@ class TramitacoesPollView(PollView):
.select_related('status') .select_related('status')
.order_by('id')[:self._limite(request)]) .order_by('id')[:self._limite(request)])
return self._resposta(itens, serializar_tramitacao) 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