Browse Source

Feat(Assinatura): fontes de poll, bytes do PDF e recepcao de assinatura AB#1473

A pendencia viaja como as outras fontes — poll, nao push (refinamento §3):

- assinaturas-pendentes: keyset por id da materia, SO materia com PDF-alvo
  materializado (§5.1 — DOCX nao convertido nao sai do SAPL). Pendencia e
  POR AUTOR (§2): autor da autoria cujo usuario (OperadorAutor) nao esta em
  assinatura_info.signed_by — a primeira assinatura nao some com a
  pendencia dos coautores.
- assinaturas-concluidas: cursor composto assinado_em|id (mesmo keyset das
  fontes por data — empate de timestamp nao trava cursor), com hash do
  assinado calculado do arquivo e autor_id resolvido de signed_by via
  OperadorAutor (e por ele que o consumidor fecha a pendencia).
- GET documentos-assinatura/<id>/alvo|assinado: serve os bytes ao hub
  (token + pode_integrar) — o Authorization nunca vaza ao consumidor final.
- POST assinaturas: idempotente por chave (padrao EventoRecebido), confere
  o hash do alvo ANTES de gravar (divergencia = 409, retificacao no meio do
  caminho) e grava pdf_assinado + assinatura_info no MESMO formato da
  sprint — para a tela do SAPL, indistinguivel de assinatura local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/3858/head
kemuel 4 weeks ago
parent
commit
3c006508f5
  1. 129
      sapl/integracao_hub/serializacao.py
  2. 27
      sapl/integracao_hub/urls.py
  3. 246
      sapl/integracao_hub/views.py

129
sapl/integracao_hub/serializacao.py

@ -1,5 +1,10 @@
import hashlib
import os
from django.urls import reverse
from django.utils import timezone
from sapl.base.models import OperadorAutor
from sapl.materia.models import MateriaLegislativa
@ -80,3 +85,127 @@ def serializar_tramitacao(tramitacao):
'indicador': status.indicador,
} if status else None,
}
def _sha256_do_arquivo(campo):
conteudo = campo.read()
campo.seek(0)
return hashlib.sha256(conteudo).hexdigest()
def _normalizar_assinatura_info(info):
# Mesma normalização da sprint (views_assinatura): dict legado vira lista.
if info is None:
return []
if isinstance(info, dict):
return [info]
return info
def _url_absoluta(request, nome_rota, materia_id):
return request.build_absolute_uri(
reverse(nome_rota, kwargs={'materia_id': materia_id}))
def _autores_pendentes(materia):
"""Pendência é POR AUTOR (refinamento §2), derivada — não é tabela.
pendente(autor, matéria) = autor autoria autor
assinatura_info.signed_by (username resolvido via OperadorAutor). Autor sem
operador nunca aparece em signed_by, logo segue pendente é o hub quem
corta autor sem par no mapa de identidade (§3).
"""
assinados = {
a.get('signed_by')
for a in _normalizar_assinatura_info(materia.assinatura_info)}
pendentes = []
for autoria in materia.autoria_set.all():
usernames = {
operador.user.username
for operador in autoria.autor.operadorautor_set.all()}
if not (usernames & assinados):
pendentes.append(autoria.autor_id)
return pendentes
def serializar_pendencia(alvo, request):
"""Item de `assinaturas-pendentes` (§3): só existe com o PDF-alvo materializado."""
materia = alvo.materia
return {
'materia': {
'id': materia.pk,
'numero': materia.numero,
'ano': materia.ano,
'ementa': materia.ementa,
},
'autores_pendentes': _autores_pendentes(materia),
'documento': {
'nome': os.path.basename(alvo.arquivo.name),
'mime': 'application/pdf',
'tamanho_bytes': alvo.arquivo.size,
'url': _url_absoluta(
request, 'integracao_hub_documento_alvo', materia.pk),
'hash_sha256': alvo.hash_sha256,
},
}
def _autor_do_signed_by(username, ids_da_autoria, mapa_operadores):
"""Resolve signed_by → autor_id via OperadorAutor (contrato documento-assinado).
Um usuário pode operar mais de um autor: preferimos o autor que está na
autoria da matéria (é a pendência dele que a assinatura fecha); sem
interseção, devolve o primeiro operado; sem operador, None o consumidor
ainda tem o signed_by.
"""
autores = mapa_operadores.get(username, [])
for autor_id in autores:
if autor_id in ids_da_autoria:
return autor_id
return autores[0] if autores else None
def serializar_materia_assinada(materia, request):
assinaturas_info = _normalizar_assinatura_info(materia.assinatura_info)
usernames = {a.get('signed_by') for a in assinaturas_info if a.get('signed_by')}
mapa_operadores = {}
for operador in (OperadorAutor.objects
.filter(user__username__in=usernames)
.select_related('user').order_by('id')):
mapa_operadores.setdefault(
operador.user.username, []).append(operador.autor_id)
ids_da_autoria = set(
materia.autoria_set.values_list('autor_id', flat=True))
assinaturas = []
for info in assinaturas_info:
username = info.get('signed_by')
assinaturas.append({
'signed_by': username,
# A sprint grava nome_assinante/data_assinatura; o POST da
# integração grava nome/data — o contrato enxerga um shape só.
'nome': info.get('nome') or info.get('nome_assinante'),
'data': info.get('data') or info.get('data_assinatura'),
'tipo_certificado': info.get('tipo_certificado'),
'autor_id': _autor_do_signed_by(
username, ids_da_autoria, mapa_operadores),
})
return {
'materia': {
'id': materia.pk,
'numero': materia.numero,
'ano': materia.ano,
},
'documento_assinado': {
'nome': os.path.basename(materia.pdf_assinado.name),
'mime': 'application/pdf',
'tamanho_bytes': materia.pdf_assinado.size,
'url': _url_absoluta(
request, 'integracao_hub_documento_assinado', materia.pk),
'hash_sha256': _sha256_do_arquivo(materia.pdf_assinado),
},
'codigo_autenticacao': materia.codigo_autenticacao,
'assinado_em': _iso(materia.assinado_em),
'assinaturas': assinaturas,
}

27
sapl/integracao_hub/urls.py

@ -1,10 +1,15 @@
from django.conf.urls import url
from .views import (InventarioView,
from .views import (AssinaturasConcluidasPollView,
AssinaturasPendentesPollView,
DocumentoAlvoView,
DocumentoAssinadoView,
InventarioView,
ProposicoesCadastradasPollView,
ProposicoesDevolvidasPollView,
ProposicoesEnviadasPollView,
ProposicoesRecebidasPollView,
RecepcaoAssinaturaView,
RecepcaoProposicaoView,
TramitacoesPollView)
@ -33,6 +38,26 @@ urlpatterns = [
TramitacoesPollView.as_view(),
name='integracao_hub_poll_tramitacoes'),
url(r'^api/integracao/poll/assinaturas-pendentes/$',
AssinaturasPendentesPollView.as_view(),
name='integracao_hub_poll_assinaturas_pendentes'),
url(r'^api/integracao/poll/assinaturas-concluidas/$',
AssinaturasConcluidasPollView.as_view(),
name='integracao_hub_poll_assinaturas_concluidas'),
url(r'^api/integracao/documentos-assinatura/(?P<materia_id>\d+)/alvo/$',
DocumentoAlvoView.as_view(),
name='integracao_hub_documento_alvo'),
url(r'^api/integracao/documentos-assinatura/(?P<materia_id>\d+)/assinado/$',
DocumentoAssinadoView.as_view(),
name='integracao_hub_documento_assinado'),
url(r'^api/integracao/assinaturas/$',
RecepcaoAssinaturaView.as_view(),
name='integracao_hub_recepcao_assinatura'),
url(r'^api/integracao/reconciliacao/$',
InventarioView.as_view(),
name='integracao_hub_reconciliacao'),

246
sapl/integracao_hub/views.py

@ -14,13 +14,21 @@ 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 django.core.files.base import ContentFile
from django.http import FileResponse, Http404
from sapl.base.models import Autor, OperadorAutor
from sapl.materia.forms import ProposicaoForm
from sapl.materia.models import Proposicao, Tramitacao
from sapl.materia.models import (MateriaLegislativa, Proposicao,
Tramitacao)
from sapl.utils import get_client_ip
from .models import AnexoProposicao, EventoRecebido
from .serializacao import serializar_proposicao, serializar_tramitacao
from .models import (AnexoProposicao, AssinaturaRecebida,
DocumentoParaAssinatura, EventoRecebido)
from .serializacao import (serializar_materia_assinada,
serializar_pendencia,
serializar_proposicao,
serializar_tramitacao)
LIMITE_PADRAO = 100
LIMITE_MAXIMO = 500
@ -307,3 +315,233 @@ class InventarioView(PollView):
# 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).
devolve matéria com o PDF-alvo 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))
operador = (OperadorAutor.objects.filter(autor=autor)
.select_related('user').order_by('id').first())
if operador is None:
return self._erro(
'autor %s não tem operador (OperadorAutor) — sem username '
'para signed_by' % 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()
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': operador.user.username,
'nome': request.data.get('nome') or autor.nome,
'data': agora.isoformat(),
'tipo_certificado':
request.data.get('tipo_certificado') or '',
})
materia.assinatura_info = assinaturas
materia.assinado_em = agora
materia.assinado_por = operador.user
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)
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)', chave, materia.pk,
operador.user.username, autor.pk)
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)

Loading…
Cancel
Save