mirror of https://github.com/interlegis/sapl.git
Browse Source
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
13 changed files with 746 additions and 0 deletions
@ -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') |
|||
@ -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,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) |
|||
@ -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,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 |
|||
@ -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 |
|||
@ -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'), |
|||
] |
|||
@ -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) |
|||
Loading…
Reference in new issue