diff --git a/sapl/integracao_hub/__init__.py b/sapl/integracao_hub/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/apps.py b/sapl/integracao_hub/apps.py new file mode 100644 index 000000000..895256505 --- /dev/null +++ b/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') diff --git a/sapl/integracao_hub/migrations/0001_initial.py b/sapl/integracao_hub/migrations/0001_initial.py new file mode 100644 index 000000000..a35ce68a1 --- /dev/null +++ b/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'), + ), + }, + ), + ] diff --git a/sapl/integracao_hub/migrations/__init__.py b/sapl/integracao_hub/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py new file mode 100644 index 000000000..a9a9ff723 --- /dev/null +++ b/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 já + 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) diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py new file mode 100644 index 000000000..ed8f1d581 --- /dev/null +++ b/sapl/integracao_hub/serializacao.py @@ -0,0 +1,75 @@ +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': { + 'id': materia.tipo_id, + 'sigla': materia.tipo.sigla if materia.tipo else None, + 'descricao': materia.tipo.descricao if materia.tipo else None, + }, + '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': { + 'id': proposicao.tipo_id, + 'descricao': proposicao.tipo.descricao if proposicao.tipo else None, + }, + '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, + } diff --git a/sapl/integracao_hub/tests/__init__.py b/sapl/integracao_hub/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/tests/test_poll.py b/sapl/integracao_hub/tests/test_poll.py new file mode 100644 index 000000000..30883ab1e --- /dev/null +++ b/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 diff --git a/sapl/integracao_hub/tests/test_recepcao.py b/sapl/integracao_hub/tests/test_recepcao.py new file mode 100644 index 000000000..17965500f --- /dev/null +++ b/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 diff --git a/sapl/integracao_hub/urls.py b/sapl/integracao_hub/urls.py new file mode 100644 index 000000000..a0693b90b --- /dev/null +++ b/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'), +] diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py new file mode 100644 index 000000000..64d2b664d --- /dev/null +++ b/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('tipo', 'materia_gerada__tipo', '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('tipo', 'materia_gerada__tipo', '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) diff --git a/sapl/settings.py b/sapl/settings.py index 82f00cfb6..3c08de1e4 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -70,6 +70,7 @@ SGVP_APPS = ( 'sapl.api', 'sapl.rules', 'sapl.tce', + 'sapl.integracao_hub', ) INSTALLED_APPS = ( diff --git a/sapl/urls.py b/sapl/urls.py index 3d2a44478..5d22d90e8 100644 --- a/sapl/urls.py +++ b/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)),