Browse Source

Feat(IntegracaoHub): app isolado com recepcao de proposicao e polls da volta AB#1480

Componente A da spec (POST /api/integracao/proposicoes/): TokenAuthentication
com permissao propria, criacao pelo ProposicaoForm com autor fixado na
instancia antes do save (a numeracao usa a sequencia do autor), proposicao
nasce rascunho, dedupe por EventoRecebido (reentrega devolve 200 com a
proposicao existente), throttle auto-contido.

Componente B pelo fallback nomeado na spec §4: o get_queryset do
_ProposicaoViewSet nao expoe rascunho ao usuario de integracao, entao os
cinco polls (cadastradas/enviadas/recebidas/devolvidas por cursor proprio e
tramitacoes insert-only) vivem no app isolado, com ordenacao estavel
(data, id) para o cursor do hub.

Toque no core: duas linhas (INSTALLED_APPS e include no urls) — custo de
rebase contra o Interlegis proximo de zero, principio 1 da spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/3858/head
kemuel 1 month ago
parent
commit
12b11b069b
  1. 0
      sapl/integracao_hub/__init__.py
  2. 8
      sapl/integracao_hub/apps.py
  3. 37
      sapl/integracao_hub/migrations/0001_initial.py
  4. 0
      sapl/integracao_hub/migrations/__init__.py
  5. 35
      sapl/integracao_hub/models.py
  6. 68
      sapl/integracao_hub/serializacao.py
  7. 0
      sapl/integracao_hub/tests/__init__.py
  8. 163
      sapl/integracao_hub/tests/test_poll.py
  9. 168
      sapl/integracao_hub/tests/test_recepcao.py
  10. 34
      sapl/integracao_hub/urls.py
  11. 229
      sapl/integracao_hub/views.py
  12. 1
      sapl/settings.py
  13. 3
      sapl/urls.py

0
sapl/integracao_hub/__init__.py

8
sapl/integracao_hub/apps.py

@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
class IntegracaoHubConfig(AppConfig):
name = 'sapl.integracao_hub'
label = 'integracao_hub'
verbose_name = _('Integração com o Hub')

37
sapl/integracao_hub/migrations/0001_initial.py

@ -0,0 +1,37 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('materia', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='EventoRecebido',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True,
serialize=False, verbose_name='ID')),
('chave_idempotencia', models.UUIDField(
unique=True, verbose_name='Chave de Idempotência')),
('recebido_em', models.DateTimeField(
auto_now_add=True, verbose_name='Recebido em')),
('proposicao', models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='materia.Proposicao',
verbose_name='Proposição')),
],
options={
'verbose_name': 'Evento Recebido do Hub',
'verbose_name_plural': 'Eventos Recebidos do Hub',
'permissions': (
('pode_integrar', 'Pode operar a integração com o hub'),
),
},
),
]

0
sapl/integracao_hub/migrations/__init__.py

35
sapl/integracao_hub/models.py

@ -0,0 +1,35 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
class EventoRecebido(models.Model):
"""Dedupe do Componente A (spec §3): uma linha por evento entregue pelo hub.
Chave repetida significa reentrega a resposta devolve a proposição
criada, sem efeito colateral. Nada além do mínimo do dedupe persiste aqui
(spec §6).
"""
chave_idempotencia = models.UUIDField(
unique=True,
verbose_name=_('Chave de Idempotência'))
proposicao = models.ForeignKey(
'materia.Proposicao',
on_delete=models.PROTECT,
related_name='+',
verbose_name=_('Proposição'))
recebido_em = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Recebido em'))
class Meta:
verbose_name = _('Evento Recebido do Hub')
verbose_name_plural = _('Eventos Recebidos do Hub')
permissions = (
('pode_integrar', _('Pode operar a integração com o hub')),
)
def __str__(self):
return str(self.chave_idempotencia)

68
sapl/integracao_hub/serializacao.py

@ -0,0 +1,68 @@
from django.utils import timezone
from sapl.materia.models import MateriaLegislativa
def _iso(valor):
if valor is None:
return None
if hasattr(valor, 'tzinfo'):
if timezone.is_aware(valor):
valor = timezone.localtime(valor)
return valor.isoformat()
return valor.isoformat()
def _materia_da_proposicao(proposicao):
materia = proposicao.materia_gerada
if materia is None:
conteudo = getattr(proposicao, 'conteudo_gerado_related', None)
if isinstance(conteudo, MateriaLegislativa):
materia = conteudo
if materia is None:
return None
return {
'id': materia.pk,
'tipo': materia.tipo_id,
'numero': materia.numero,
'ano': materia.ano,
'numero_protocolo': materia.numero_protocolo,
}
def serializar_proposicao(proposicao):
"""Shape SAPL-nativo (spec, princípio 2): o hub traduz para o canônico."""
return {
'id': proposicao.pk,
'ano': proposicao.ano,
'numero_proposicao': proposicao.numero_proposicao,
'tipo': proposicao.tipo_id,
'autor': proposicao.autor_id,
'ementa': proposicao.descricao,
'rascunho': proposicao.data_envio is None,
'cancelado': proposicao.cancelado,
'data_envio': _iso(proposicao.data_envio),
'data_recebimento': _iso(proposicao.data_recebimento),
'data_devolucao': _iso(proposicao.data_devolucao),
'justificativa_devolucao': proposicao.justificativa_devolucao or '',
'materia': _materia_da_proposicao(proposicao),
}
def serializar_tramitacao(tramitacao):
status = tramitacao.status
return {
'id': tramitacao.pk,
'materia': tramitacao.materia_id,
'data_tramitacao': _iso(tramitacao.data_tramitacao),
'timestamp': _iso(tramitacao.timestamp),
'texto': tramitacao.texto or '',
'urgente': tramitacao.urgente,
'unidade_destino': tramitacao.unidade_tramitacao_destino_id,
'status': {
'id': status.pk,
'sigla': status.sigla,
'descricao': status.descricao,
'indicador': status.indicador,
} if status else None,
}

0
sapl/integracao_hub/tests/__init__.py

163
sapl/integracao_hub/tests/test_poll.py

@ -0,0 +1,163 @@
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.base.models import Autor
from sapl.materia.models import Proposicao, Tramitacao
BASE = '/api/integracao/poll/'
@pytest.fixture()
def cliente_hub(db):
usuario = baker.make('auth.User')
permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao)
token = Token.objects.create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente
@pytest.mark.django_db(transaction=False)
def test_cadastradas_por_cursor_de_id_inclui_rascunho(cliente_hub):
autor = baker.make(Autor)
rascunho = baker.make(
Proposicao, autor=autor, data_envio=None, cancelado=False)
resposta = cliente_hub.get(BASE + 'proposicoes-cadastradas/',
{'id_gt': 0})
assert resposta.status_code == 200
ids = [p['id'] for p in resposta.data['resultados']]
assert rascunho.pk in ids
encontrado = next(p for p in resposta.data['resultados']
if p['id'] == rascunho.pk)
assert encontrado['rascunho'] is True
assert encontrado['autor'] == autor.pk
@pytest.mark.django_db(transaction=False)
def test_cadastradas_cursor_avanca(cliente_hub):
primeira = baker.make(Proposicao, cancelado=False)
segunda = baker.make(Proposicao, cancelado=False)
resposta = cliente_hub.get(BASE + 'proposicoes-cadastradas/',
{'id_gt': primeira.pk})
ids = [p['id'] for p in resposta.data['resultados']]
assert primeira.pk not in ids
assert segunda.pk in ids
@pytest.mark.django_db(transaction=False)
def test_cadastradas_ignora_cancelada(cliente_hub):
cancelada = baker.make(Proposicao, cancelado=True)
resposta = cliente_hub.get(BASE + 'proposicoes-cadastradas/',
{'id_gt': 0})
ids = [p['id'] for p in resposta.data['resultados']]
assert cancelada.pk not in ids
@pytest.mark.django_db(transaction=False)
def test_enviadas_por_data_ordenadas(cliente_hub):
agora = timezone.now()
antiga = baker.make(
Proposicao, data_envio=agora - timedelta(days=10), cancelado=False)
recente = baker.make(
Proposicao, data_envio=agora - timedelta(hours=1), cancelado=False)
nunca_enviada = baker.make(Proposicao, data_envio=None, cancelado=False)
resposta = cliente_hub.get(
BASE + 'proposicoes-enviadas/',
{'desde': (agora - timedelta(days=30)).isoformat()})
assert resposta.status_code == 200
ids = [p['id'] for p in resposta.data['resultados']]
assert ids == [antiga.pk, recente.pk]
assert nunca_enviada.pk not in ids
@pytest.mark.django_db(transaction=False)
def test_enviadas_desde_corta_o_passado(cliente_hub):
agora = timezone.now()
antiga = baker.make(
Proposicao, data_envio=agora - timedelta(days=10), cancelado=False)
recente = baker.make(
Proposicao, data_envio=agora - timedelta(hours=1), cancelado=False)
resposta = cliente_hub.get(
BASE + 'proposicoes-enviadas/',
{'desde': (agora - timedelta(days=1)).isoformat()})
ids = [p['id'] for p in resposta.data['resultados']]
assert ids == [recente.pk]
assert antiga.pk not in ids
@pytest.mark.django_db(transaction=False)
def test_devolvidas_trazem_justificativa(cliente_hub):
agora = timezone.now()
devolvida = baker.make(
Proposicao, data_devolucao=agora, cancelado=False,
justificativa_devolucao='Fora do escopo regimental')
resposta = cliente_hub.get(
BASE + 'proposicoes-devolvidas/',
{'desde': (agora - timedelta(days=1)).isoformat()})
encontrada = next(p for p in resposta.data['resultados']
if p['id'] == devolvida.pk)
assert encontrada['justificativa_devolucao'] == \
'Fora do escopo regimental'
@pytest.mark.django_db(transaction=False)
def test_desde_invalido_da_400(cliente_hub):
resposta = cliente_hub.get(BASE + 'proposicoes-enviadas/',
{'desde': 'ontem'})
assert resposta.status_code == 400
@pytest.mark.django_db(transaction=False)
def test_tramitacoes_por_cursor_de_id(cliente_hub):
tramitacao = baker.make(Tramitacao)
resposta = cliente_hub.get(BASE + 'tramitacoes/', {'id_gt': 0})
assert resposta.status_code == 200
encontrada = next(t for t in resposta.data['resultados']
if t['id'] == tramitacao.pk)
assert encontrada['materia'] == tramitacao.materia_id
assert encontrada['status']['sigla'] == tramitacao.status.sigla
@pytest.mark.django_db(transaction=False)
def test_limite_e_respeitado(cliente_hub):
baker.make(Proposicao, cancelado=False, _quantity=3)
resposta = cliente_hub.get(BASE + 'proposicoes-cadastradas/',
{'id_gt': 0, 'limite': 2})
assert len(resposta.data['resultados']) == 2
@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)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
resposta = cliente.get(BASE + 'proposicoes-cadastradas/', {'id_gt': 0})
assert resposta.status_code == 403

168
sapl/integracao_hub/tests/test_recepcao.py

@ -0,0 +1,168 @@
import uuid
import pytest
from django.contrib.auth.models import Permission
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import baker
from rest_framework.authtoken.models import Token
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
URL = '/api/integracao/proposicoes/'
@pytest.fixture()
def app_config(db):
return AppConfig.objects.create()
@pytest.fixture()
def cliente_hub(db):
usuario = baker.make('auth.User')
permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao)
token = Token.objects.create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente
@pytest.fixture()
def autor(db):
return baker.make(Autor)
@pytest.fixture()
def tipo(db):
return baker.make(TipoProposicao)
def corpo(autor, tipo, **extras):
dados = {
'chave_idempotencia': str(uuid.uuid4()),
'autor': autor.pk,
'tipo': tipo.pk,
'ementa': 'Indica a revitalização da praça central',
}
dados.update(extras)
return dados
@pytest.mark.django_db(transaction=False)
def test_cria_proposicao_como_rascunho(cliente_hub, app_config, autor, tipo):
resposta = cliente_hub.post(URL, corpo(autor, tipo))
assert resposta.status_code == 201
proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id'])
assert proposicao.autor == autor
assert proposicao.tipo == tipo
assert proposicao.descricao == 'Indica a revitalização da praça central'
assert proposicao.data_envio is None # nasce rascunho — spec §3
assert proposicao.numero_proposicao == 1
@pytest.mark.django_db(transaction=False)
def test_reentrega_devolve_200_sem_duplicar(cliente_hub, app_config, autor,
tipo):
dados = corpo(autor, tipo)
primeira = cliente_hub.post(URL, dados)
segunda = cliente_hub.post(URL, dados)
assert primeira.status_code == 201
assert segunda.status_code == 200
assert segunda.data['proposicao_id'] == primeira.data['proposicao_id']
assert Proposicao.objects.count() == 1
assert EventoRecebido.objects.count() == 1
@pytest.mark.django_db(transaction=False)
def test_texto_e_justificativa_viram_observacao(cliente_hub, app_config,
autor, tipo):
resposta = cliente_hub.post(URL, corpo(
autor, tipo, texto='Texto da proposição',
justificativa='Porque sim'))
assert resposta.status_code == 201
proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id'])
assert 'Texto da proposição' in proposicao.observacao
assert 'Justificativa: Porque sim' in proposicao.observacao
@pytest.mark.django_db(transaction=False)
def test_arquivo_vira_texto_original(cliente_hub, app_config, autor, tipo):
arquivo = SimpleUploadedFile(
'proposicao.pdf', b'%PDF-1.4 conteudo', 'application/pdf')
resposta = cliente_hub.post(
URL, corpo(autor, tipo, arquivos=arquivo), format='multipart')
assert resposta.status_code == 201
proposicao = Proposicao.objects.get(pk=resposta.data['proposicao_id'])
assert proposicao.texto_original
@pytest.mark.django_db(transaction=False)
def test_mais_de_um_arquivo_e_rejeitado(cliente_hub, app_config, autor, tipo):
arquivos = [
SimpleUploadedFile('a.pdf', b'%PDF-1.4 a', 'application/pdf'),
SimpleUploadedFile('b.pdf', b'%PDF-1.4 b', 'application/pdf'),
]
resposta = cliente_hub.post(
URL, corpo(autor, tipo, arquivos=arquivos), format='multipart')
assert resposta.status_code == 422
assert Proposicao.objects.count() == 0
@pytest.mark.django_db(transaction=False)
def test_autor_inexistente_da_422(cliente_hub, app_config, tipo):
autor_fantasma = type('A', (), {'pk': 99999})()
resposta = cliente_hub.post(URL, corpo(autor_fantasma, tipo))
assert resposta.status_code == 422
assert 'autor' in resposta.data['detalhe']
@pytest.mark.django_db(transaction=False)
def test_tipo_inexistente_da_422_com_erros_do_form(cliente_hub, app_config,
autor):
tipo_fantasma = type('T', (), {'pk': 99999})()
resposta = cliente_hub.post(URL, corpo(autor, tipo_fantasma))
assert resposta.status_code == 422
assert 'erros' in resposta.data
@pytest.mark.django_db(transaction=False)
def test_chave_invalida_da_422(cliente_hub, app_config, autor, tipo):
resposta = cliente_hub.post(
URL, corpo(autor, tipo, chave_idempotencia='nao-e-uuid'))
assert resposta.status_code == 422
@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)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
resposta = cliente.post(URL, corpo(autor, tipo))
assert resposta.status_code == 403
@pytest.mark.django_db(transaction=False)
def test_sem_token_da_401(db, app_config, autor, tipo):
resposta = APIClient().post(URL, corpo(autor, tipo))
assert resposta.status_code == 401

34
sapl/integracao_hub/urls.py

@ -0,0 +1,34 @@
from django.conf.urls import url
from .views import (ProposicoesCadastradasPollView,
ProposicoesDevolvidasPollView,
ProposicoesEnviadasPollView,
ProposicoesRecebidasPollView,
RecepcaoProposicaoView,
TramitacoesPollView)
urlpatterns = [
url(r'^api/integracao/proposicoes/$',
RecepcaoProposicaoView.as_view(),
name='integracao_hub_recepcao_proposicao'),
url(r'^api/integracao/poll/proposicoes-cadastradas/$',
ProposicoesCadastradasPollView.as_view(),
name='integracao_hub_poll_cadastradas'),
url(r'^api/integracao/poll/proposicoes-enviadas/$',
ProposicoesEnviadasPollView.as_view(),
name='integracao_hub_poll_enviadas'),
url(r'^api/integracao/poll/proposicoes-recebidas/$',
ProposicoesRecebidasPollView.as_view(),
name='integracao_hub_poll_recebidas'),
url(r'^api/integracao/poll/proposicoes-devolvidas/$',
ProposicoesDevolvidasPollView.as_view(),
name='integracao_hub_poll_devolvidas'),
url(r'^api/integracao/poll/tramitacoes/$',
TramitacoesPollView.as_view(),
name='integracao_hub_poll_tramitacoes'),
]

229
sapl/integracao_hub/views.py

@ -0,0 +1,229 @@
import logging
import uuid
from django.db import IntegrityError, transaction
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import BasePermission
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView
from sapl.base.models import Autor
from sapl.materia.forms import ProposicaoForm
from sapl.materia.models import Proposicao, Tramitacao
from sapl.utils import get_client_ip
from .models import EventoRecebido
from .serializacao import serializar_proposicao, serializar_tramitacao
LIMITE_PADRAO = 100
LIMITE_MAXIMO = 500
class PodeIntegrar(BasePermission):
"""Usuário de integração dedicado, com a permissão própria do app (spec §3/§6)."""
def has_permission(self, request, view):
return bool(
request.user
and request.user.is_authenticated
and request.user.has_perm('integracao_hub.pode_integrar'))
class ThrottleIntegracao(UserRateThrottle):
# Auto-contido no app para manter o toque no core em duas linhas (spec §1).
scope = 'integracao_hub'
rate = '300/min'
class IntegracaoHubView(APIView):
authentication_classes = (TokenAuthentication,)
permission_classes = (PodeIntegrar,)
throttle_classes = (ThrottleIntegracao,)
class RecepcaoProposicaoView(IntegracaoHubView):
"""Componente A da spec: recepção de proposição SAPL-nativa vinda do hub.
Regra de negócio pelo caminho oficial: a criação passa pelo ProposicaoForm,
com o autor fixado na instância antes do save() o form numera a proposição
pela sequência do autor. A proposição nasce RASCUNHO (data_envio nula); o
envio ao protocolo acontece pelo fluxo nativo, quando o autor decidir.
"""
logger = logging.getLogger(__name__)
parser_classes = (MultiPartParser, FormParser)
def post(self, request, *args, **kwargs):
try:
chave = uuid.UUID(str(request.data.get('chave_idempotencia', '')))
except ValueError:
return self._erro('chave_idempotencia ausente ou não é um UUID')
recebido = EventoRecebido.objects.filter(
chave_idempotencia=chave).first()
if recebido:
self.logger.info(
'integracao_hub: evento %s reentregue — devolvendo proposição '
'existente %s', chave, recebido.proposicao_id)
return Response({'proposicao_id': recebido.proposicao_id},
status=status.HTTP_200_OK)
autor = Autor.objects.filter(pk=request.data.get('autor')).first()
if autor is None:
return self._erro(
'autor %s inexistente no SAPL — conferir o mapa de identidade '
'no hub' % request.data.get('autor'))
arquivos = request.FILES.getlist('arquivos')
if len(arquivos) > 1:
return self._erro(
'a proposição no SAPL comporta um único texto_original; '
'recebidos %d arquivos' % len(arquivos))
form = ProposicaoForm(
data=self._dados_do_form(request),
files={'texto_original': arquivos[0]} if arquivos else None)
form.instance.autor = autor
if not form.is_valid():
return self._erro('proposição inválida', erros=form.errors)
try:
with transaction.atomic():
proposicao = form.save()
EventoRecebido.objects.create(
chave_idempotencia=chave, proposicao=proposicao)
except IntegrityError:
# Entrega concorrente do mesmo evento: quem perdeu a corrida
# devolve a proposição de quem ganhou.
recebido = EventoRecebido.objects.filter(
chave_idempotencia=chave).first()
if recebido is None:
raise
return Response({'proposicao_id': recebido.proposicao_id},
status=status.HTTP_200_OK)
self.logger.info(
'integracao_hub: evento %s criou a proposição %s (autor %s)',
chave, proposicao.pk, autor.pk)
return Response({'proposicao_id': proposicao.pk},
status=status.HTTP_201_CREATED)
def _dados_do_form(self, request):
observacao = ''
if request.data.get('texto'):
observacao = request.data['texto']
if request.data.get('justificativa'):
observacao += '\n\nJustificativa: %s' % \
request.data['justificativa']
return {
'tipo': request.data.get('tipo'),
'descricao': request.data.get('ementa'),
'observacao': observacao.strip(),
'tipo_texto': 'D' if request.FILES.getlist('arquivos') else '',
'user': request.user.pk,
'ip': get_client_ip(request),
'ultima_edicao': timezone.now(),
}
def _erro(self, detalhe, erros=None):
corpo = {'detalhe': detalhe}
if erros is not None:
corpo['erros'] = erros
return Response(corpo, status=status.HTTP_422_UNPROCESSABLE_ENTITY)
class PollView(IntegracaoHubView):
"""Base das consultas da volta (spec §4, fallback nomeado do app isolado).
A API genérica não expõe rascunho ao usuário de integração (get_queryset
do _ProposicaoViewSet filtra por autor); estas consultas vivem atrás da
permissão própria e devolvem ordenação estável para o cursor do hub.
"""
def _limite(self, request):
try:
limite = int(request.query_params.get('limite', LIMITE_PADRAO))
except ValueError:
return LIMITE_PADRAO
return max(1, min(limite, LIMITE_MAXIMO))
def _id_gt(self, request):
try:
return int(request.query_params.get('id_gt', 0))
except ValueError:
return None
def _desde(self, request):
valor = request.query_params.get('desde', '')
desde = parse_datetime(valor)
if desde and timezone.is_naive(desde):
desde = timezone.make_aware(desde)
return desde
def _resposta(self, itens, serializar):
return Response({'resultados': [serializar(i) for i in itens]})
class ProposicoesCadastradasPollView(PollView):
"""Inserts de proposição por cursor de id — inclui rascunhos (spec §4)."""
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)
itens = (Proposicao.objects
.filter(id__gt=id_gt, cancelado=False)
.select_related('materia_gerada', 'content_type')
.order_by('id')[:self._limite(request)])
return self._resposta(itens, serializar_proposicao)
class PollPorDataView(PollView):
campo_cursor = None
def get(self, request, *args, **kwargs):
desde = self._desde(request)
if desde is None:
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}
itens = (Proposicao.objects
.filter(**filtro)
.select_related('materia_gerada', 'content_type')
.order_by(self.campo_cursor, 'id')[:self._limite(request)])
return self._resposta(itens, serializar_proposicao)
class ProposicoesEnviadasPollView(PollPorDataView):
campo_cursor = 'data_envio'
class ProposicoesRecebidasPollView(PollPorDataView):
campo_cursor = 'data_recebimento'
class ProposicoesDevolvidasPollView(PollPorDataView):
campo_cursor = 'data_devolucao'
class TramitacoesPollView(PollView):
"""Tramitação é insert-only na v1 (spec §4.1.3) — cursor de id basta."""
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)
itens = (Tramitacao.objects
.filter(id__gt=id_gt)
.select_related('status')
.order_by('id')[:self._limite(request)])
return self._resposta(itens, serializar_tramitacao)

1
sapl/settings.py

@ -70,6 +70,7 @@ SGVP_APPS = (
'sapl.api',
'sapl.rules',
'sapl.tce',
'sapl.integracao_hub',
)
INSTALLED_APPS = (

3
sapl/urls.py

@ -26,6 +26,7 @@ import sapl.audiencia.urls
import sapl.base.urls
import sapl.comissoes.urls
import sapl.compilacao.urls
import sapl.integracao_hub.urls
import sapl.lexml.urls
import sapl.materia.urls
import sapl.norma.urls
@ -67,6 +68,8 @@ urlpatterns += [
url(r'', include(sapl.api.urls)),
url(r'', include(sapl.integracao_hub.urls)),
url(r'^favicon\.ico$', RedirectView.as_view(
url='/static/sapl/img/favicon.ico', permanent=True)),

Loading…
Cancel
Save