mirror of https://github.com/interlegis/sapl.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
309 lines
12 KiB
309 lines
12 KiB
import logging
|
|
import hashlib
|
|
import uuid
|
|
|
|
from django.db import IntegrityError, transaction
|
|
from django.db.models import Q
|
|
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 AnexoProposicao, 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'))
|
|
|
|
# Anexos do app são GERAIS (foto, vídeo — evidência da demanda), não o texto
|
|
# oficial (decisão do arquiteto, 17/08/2026). O texto_original continua sendo do
|
|
# fluxo próprio do SAPL; o que chega aqui vira AnexoProposicao, só gravação por ora.
|
|
arquivos = request.FILES.getlist('arquivos')
|
|
|
|
form = ProposicaoForm(data=self._dados_do_form(request))
|
|
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)
|
|
for arquivo in arquivos:
|
|
self._gravar_anexo(proposicao, arquivo)
|
|
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(),
|
|
# Sem 'D': anexo geral não é texto digital da proposição — o documento
|
|
# oficial nasce no fluxo do SAPL (Editar Documento / template).
|
|
'tipo_texto': '',
|
|
'user': request.user.pk,
|
|
'ip': get_client_ip(request),
|
|
'ultima_edicao': timezone.now(),
|
|
}
|
|
|
|
def _gravar_anexo(self, proposicao, arquivo):
|
|
conteudo = arquivo.read()
|
|
arquivo.seek(0)
|
|
AnexoProposicao.objects.create(
|
|
proposicao=proposicao,
|
|
arquivo=arquivo,
|
|
nome_original=arquivo.name or 'sem-nome',
|
|
mime=getattr(arquivo, 'content_type', '') or '',
|
|
tamanho_bytes=len(conteudo),
|
|
hash_sha256=hashlib.sha256(conteudo).hexdigest())
|
|
|
|
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', 'autor', 'content_type')
|
|
.order_by('id')[:self._limite(request)])
|
|
return self._resposta(itens, serializar_proposicao)
|
|
|
|
|
|
class PollPorDataView(PollView):
|
|
"""Paginação por chave composta (data, id).
|
|
|
|
Com o cursor só de data e `>=`, uma página inteira de registros com o MESMO
|
|
timestamp devolvia sempre o mesmo valor de cursor: ele não avançava e a fonte
|
|
relia a mesma página para sempre, sem erro e sem nunca progredir. Chave composta
|
|
elimina o empate como classe de problema — `id_gt` desempata dentro do mesmo
|
|
instante (refinamento da reconciliação §1.1).
|
|
"""
|
|
|
|
campo_cursor = None
|
|
|
|
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)
|
|
id_gt = self._id_gt(request)
|
|
if id_gt is None:
|
|
return Response({'detalhe': 'id_gt deve ser inteiro'},
|
|
status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
# (campo > desde) OU (campo = desde E id > id_gt) — keyset, sem pular nem repetir.
|
|
depois_do_instante = Q(**{'%s__gt' % self.campo_cursor: desde})
|
|
no_mesmo_instante = (Q(**{self.campo_cursor: desde}) & Q(id__gt=id_gt))
|
|
|
|
itens = (Proposicao.objects
|
|
.filter(depois_do_instante | no_mesmo_instante, cancelado=False)
|
|
.select_related('tipo', 'autor', '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)
|
|
|
|
|
|
class InventarioView(PollView):
|
|
"""Lista de conferência para a reconciliação do hub (refinamento §3).
|
|
|
|
Devolve **ids**, não objetos: é para o hub comparar com o que ele conhece e
|
|
descobrir o que ficou de fora, não uma segunda via dos dados. O corte é por id
|
|
porque `Proposicao` não tem campo de criação — o que existe são as datas de
|
|
estado (envio, recebimento, devolução), e nenhuma delas serve para "quando
|
|
apareceu". Mesmo filtro dos polls (`cancelado=False`), senão a reconciliação
|
|
apontaria para sempre as canceladas como ausentes.
|
|
|
|
O SAPL continua sem conhecer o canônico (spec §1, princípio 2): ele só diz o
|
|
que tem.
|
|
"""
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
id_gt = self._id_gt(request)
|
|
if id_gt is None:
|
|
return Response({'detalhe': 'id_gt deve ser inteiro'},
|
|
status=status.HTTP_400_BAD_REQUEST)
|
|
try:
|
|
tramitacao_id_gt = int(
|
|
request.query_params.get('tramitacao_id_gt', id_gt))
|
|
except ValueError:
|
|
return Response({'detalhe': 'tramitacao_id_gt deve ser inteiro'},
|
|
status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
limite = self._limite(request)
|
|
proposicoes = list(
|
|
Proposicao.objects.filter(id__gt=id_gt, cancelado=False)
|
|
.order_by('id').values_list('id', flat=True)[:limite])
|
|
# Tramitacao vem com a materia junto porque a conferencia do hub NAO e por
|
|
# id de tramitacao: o hub so integra tramitacao de materia que ele conhece
|
|
# (proposicao que virou materia depois do marco). Devolver so o id fazia o
|
|
# hub contar como lacuna toda tramitacao do acervo — falso positivo eterno,
|
|
# visto em 14/08: 500 "ausentes" que na verdade eram historico.
|
|
tramitacoes = [
|
|
{'id': t['id'], 'materia': t['materia_id']}
|
|
for t in Tramitacao.objects.filter(id__gt=tramitacao_id_gt)
|
|
.order_by('id').values('id', 'materia_id')[:limite]]
|
|
|
|
return Response({
|
|
'proposicoes': proposicoes,
|
|
'tramitacoes': tramitacoes,
|
|
# Diz se a pagina encheu: o hub sabe que precisa pedir a proxima faixa
|
|
# em vez de concluir que o resto simplesmente nao existe.
|
|
'truncado': len(proposicoes) >= limite or len(tramitacoes) >= limite,
|
|
})
|
|
|