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