Sistema de Apoio ao Processo Legislativo
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.
 
 
 
 
 

560 lines
23 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 django.core.files.base import ContentFile
from django.http import FileResponse, Http404
from sapl.base.models import Autor
from sapl.materia.forms import ProposicaoForm
from sapl.materia.models import (MateriaLegislativa, Proposicao,
Tramitacao)
from sapl.utils import get_client_ip
from .models import (AnexoProposicao, AssinaturaRecebida,
DocumentoParaAssinatura, EventoRecebido)
from .serializacao import (resolver_titular,
serializar_materia_assinada,
serializar_pendencia,
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,
})
class AssinaturasPendentesPollView(PollView):
"""Fonte de poll da pendência de assinatura (refinamento §3, keyset por id).
SÓ devolve matéria com o PDF-alvo já materializado (§5.1): DOCX ainda não
convertido não sai do SAPL — segue visível apenas na tela local. O cursor
anda sobre o id da MATÉRIA (o alvo é OneToOne), então página sem pendência
por autor ainda avança o cursor — item com `autores_pendentes` vazio é
ruído inofensivo, nunca loop.
"""
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)
alvos = (DocumentoParaAssinatura.objects
.filter(materia_id__gt=id_gt)
.select_related('materia')
.prefetch_related(
'materia__autoria_set__autor__operadorautor_set__user')
.order_by('materia_id')[:self._limite(request)])
return Response({'resultados': [
serializar_pendencia(alvo, request) for alvo in alvos]})
class AssinaturasConcluidasPollView(PollView):
"""Matérias com `pdf_assinado` — cursor composto (assinado_em, id).
Mesmo keyset das fontes por data (proposicoes-enviadas): `(campo > desde)
OU (campo = desde E id > id_gt)` — empate de timestamp não trava o cursor
(refinamento da reconciliação §1.1). Multiassinatura reapresenta a matéria
porque `assinado_em` avança a cada ato — o dedupe do hub mata a releitura
do mesmo estado (marcador = hash do documento).
"""
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)
depois_do_instante = Q(assinado_em__gt=desde)
no_mesmo_instante = Q(assinado_em=desde) & Q(id__gt=id_gt)
itens = (MateriaLegislativa.objects
.filter(depois_do_instante | no_mesmo_instante)
.exclude(pdf_assinado__isnull=True)
.exclude(pdf_assinado='')
.order_by('assinado_em', 'id')[:self._limite(request)])
return Response({'resultados': [
serializar_materia_assinada(m, request) for m in itens]})
class DocumentoAssinaturaView(IntegracaoHubView):
"""Serve os bytes do PDF ao hub (token + pode_integrar) — refinamento §5.
O hub baixa daqui e confere o sha256 do poll antes de repassar: o
Authorization nunca vaza para o consumidor final (padrão dos anexos).
"""
campo = None # 'alvo' | 'assinado'
def get(self, request, materia_id, *args, **kwargs):
if self.campo == 'alvo':
alvo = DocumentoParaAssinatura.objects.filter(
materia_id=materia_id).first()
arquivo = alvo.arquivo if alvo else None
else:
materia = MateriaLegislativa.objects.filter(
pk=materia_id).first()
arquivo = materia.pdf_assinado if (
materia and materia.pdf_assinado) else None
if not arquivo:
raise Http404
return FileResponse(
arquivo.open('rb'), content_type='application/pdf')
class DocumentoAlvoView(DocumentoAssinaturaView):
campo = 'alvo'
class DocumentoAssinadoView(DocumentoAssinaturaView):
campo = 'assinado'
class RecepcaoAssinaturaView(IntegracaoHubView):
"""Recebe do hub o PDF assinado pelo app (refinamento §5, F2).
Idempotente por chave (padrão do EventoRecebido); o hash do alvo é
conferido ANTES de gravar — retificação no meio do caminho (alvo
regenerado entre o poll e a entrega) devolve 409 e a assinatura sobre
binário defasado é impossível por construção. A gravação usa o MESMO
formato da sprint (`assinatura_info`, nome-padrão do arquivo): para a
tela do SAPL, indistinguível de assinatura local.
"""
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')
recebida = AssinaturaRecebida.objects.filter(
chave_idempotencia=chave).first()
if recebida:
self.logger.info(
'integracao_hub: assinatura %s reentregue — devolvendo '
'resposta original (matéria %s)', chave, recebida.materia_id)
return Response(
{'materia_id': recebida.materia_id,
'hash_assinado': recebida.hash_assinado},
status=status.HTTP_200_OK)
materia = MateriaLegislativa.objects.filter(
pk=request.data.get('materia')).first()
if materia is None:
return self._erro(
'materia %s inexistente no SAPL' % request.data.get('materia'))
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'))
if not materia.autoria_set.filter(autor=autor).exists():
return self._erro(
'autor %s não está na autoria da matéria %s — a pendência '
'nunca existiu para ele' % (autor.pk, materia.pk))
# Autoria jurídica = SEMPRE o vereador titular (ato pessoal e
# indelegável). O assessor pode OPERAR o ato, mas nunca aparece como
# signatário. Titular indeterminável falha visível — melhor que gravar
# a assinatura no nome errado.
titular = resolver_titular(autor)
if titular is None:
return self._erro(
'autor %s com titular indeterminável (múltiplos operadores e '
'nenhum/ambíguo Votante do parlamentar) — cadastrar o Votante '
'titular no SAPL' % autor.pk)
arquivo = request.FILES.get('pdf_assinado')
if arquivo is None:
return self._erro('arquivo pdf_assinado ausente')
alvo = DocumentoParaAssinatura.objects.filter(materia=materia).first()
hash_esperado = (request.data.get('hash_alvo_esperado') or '').lower()
if alvo is None or alvo.hash_sha256 != hash_esperado:
# Retificação no meio do caminho: o alvo de hoje não é o binário
# que o app exibiu/assinou (§5.1). O hub relê a pendência nova.
return Response(
{'detalhe': 'hash do PDF-alvo divergente — alvo retificado '
'após a solicitação',
'hash_atual': alvo.hash_sha256 if alvo else None},
status=status.HTTP_409_CONFLICT)
conteudo = arquivo.read()
hash_assinado = hashlib.sha256(conteudo).hexdigest()
agora = timezone.now()
# Rastro operacional: quem DISPAROU o ato (o vereador ou um assessor
# agindo por ele). Registro interno, NÃO altera a autoria. O evento do
# app ainda não carrega a identidade do assessor logado; até lá recai
# sobre o próprio titular (ver nota no PR).
operado_por = (request.data.get('operado_por')
or titular.username)
try:
with transaction.atomic():
nome = 'materia_%s_assinado_%s.pdf' % (
materia.pk, int(agora.timestamp()))
materia.pdf_assinado.save(
nome, ContentFile(conteudo), save=False)
# APPEND no formato da sprint — multiassinatura incremental.
assinaturas = self._normalizar(materia.assinatura_info)
assinaturas.append({
'signed_by': titular.username,
'nome': request.data.get('nome') or autor.nome,
'data': agora.isoformat(),
'tipo_certificado':
request.data.get('tipo_certificado') or '',
'operado_por': operado_por,
})
materia.assinatura_info = assinaturas
materia.assinado_em = agora
materia.assinado_por = titular
if not materia.codigo_autenticacao:
# Primeira assinatura gera o código público de verificação,
# como no fluxo local — a partir dos bytes do ALVO (é o
# documento que a página de autenticação identifica).
from sapl.materia.views_assinatura import \
_gerar_codigo_autenticacao
alvo.arquivo.open('rb')
try:
materia.codigo_autenticacao = \
_gerar_codigo_autenticacao(alvo.arquivo.read())
finally:
alvo.arquivo.close()
materia.save()
AssinaturaRecebida.objects.create(
chave_idempotencia=chave, materia=materia,
hash_assinado=hash_assinado, operado_por=operado_por)
except IntegrityError:
# Entrega concorrente da mesma chave: devolve o que já foi gravado.
recebida = AssinaturaRecebida.objects.filter(
chave_idempotencia=chave).first()
if recebida is None:
raise
return Response(
{'materia_id': recebida.materia_id,
'hash_assinado': recebida.hash_assinado},
status=status.HTTP_200_OK)
self.logger.info(
'integracao_hub: assinatura %s gravada na matéria %s '
'(signed_by=%s, autor=%s, operado_por=%s)', chave, materia.pk,
titular.username, autor.pk, operado_por)
return Response(
{'materia_id': materia.pk, 'hash_assinado': hash_assinado},
status=status.HTTP_201_CREATED)
def _normalizar(self, info):
if info is None:
return []
if isinstance(info, dict):
return [info]
return list(info)
def _erro(self, detalhe):
return Response({'detalhe': detalhe},
status=status.HTTP_422_UNPROCESSABLE_ENTITY)