Browse Source

Merge pull request #9 from Move-System/feat/assinatura-integracao-f1

Feat(Assinatura): pendencia, PDF-alvo e recepcao de assinaturas para a integracao AB#1473
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
ac41128d5a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 0
      sapl/integracao_hub/management/__init__.py
  2. 0
      sapl/integracao_hub/management/commands/__init__.py
  3. 134
      sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py
  4. 45
      sapl/integracao_hub/migrations/0003_assinaturarecebida_documentoparaassinatura.py
  5. 18
      sapl/integracao_hub/migrations/0004_assinaturarecebida_operado_por.py
  6. 95
      sapl/integracao_hub/models.py
  7. 186
      sapl/integracao_hub/serializacao.py
  8. 498
      sapl/integracao_hub/tests/test_assinatura.py
  9. 122
      sapl/integracao_hub/tests/test_materializacao.py
  10. 27
      sapl/integracao_hub/urls.py
  11. 257
      sapl/integracao_hub/views.py

0
sapl/integracao_hub/management/__init__.py

0
sapl/integracao_hub/management/commands/__init__.py

134
sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py

@ -0,0 +1,134 @@
import hashlib
import logging
import os
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.db import transaction
from sapl.integracao_hub.models import DocumentoParaAssinatura
from sapl.materia.models import MateriaLegislativa
logger = logging.getLogger(__name__)
class _RequisicaoDeSistema:
"""Substituto mínimo de request para reusar `_gerar_pdf_da_materia` fora do HTTP.
A rotina da sprint usa o request em dois pontos do caminho DOCX:
`build_onlyoffice_url` (que prefere `SAPL_INTERNAL_URL` e cai no request
sem ela aqui caímos em `SITE_URL`) e `generate_file_key` (pk do usuário,
que num cron não existe 0 identifica o sistema). O caminho PDF não toca
o request. Reuso em vez de cópia: a conversão OnlyOffice tem UM dono (§5.1).
"""
class _UsuarioDeSistema:
pk = 0
user = _UsuarioDeSistema()
def build_absolute_uri(self, caminho):
base = getattr(settings, 'SITE_URL', '') or ''
return base.rstrip('/') + caminho
class Command(BaseCommand):
help = ('Materializa o PDF-alvo da assinatura (refinamento §5/§5.1): varre '
'matérias protocoladas com texto_original, gera o PDF uma única '
'vez e, em retificação do texto, regenera o alvo e ZERA o processo '
'de assinatura (decisão do arquiteto, 19/08/2026). Idempotente — '
'feito para cron.')
def handle(self, *args, **options):
materias = (MateriaLegislativa.objects
.filter(numero_protocolo__isnull=False,
texto_original__isnull=False)
.exclude(texto_original='')
.order_by('id'))
gerados = retificados = pulados = falhas = 0
for materia in materias.iterator():
try:
resultado = self._materializar(materia)
except Exception as exc: # noqa — uma matéria não trava as demais (§5.1)
logger.exception(
'materializar_pdfs: falha inesperada na matéria %s: %s',
materia.pk, exc)
falhas += 1
continue
if resultado == 'gerado':
gerados += 1
elif resultado == 'retificado':
retificados += 1
elif resultado == 'falha':
falhas += 1
else:
pulados += 1
self.stdout.write(
'materializar_pdfs: %s gerados, %s retificados, %s em dia, '
'%s falhas' % (gerados, retificados, pulados, falhas))
def _materializar(self, materia):
materia.texto_original.open('rb')
try:
conteudo_origem = materia.texto_original.read()
finally:
materia.texto_original.close()
hash_origem = hashlib.sha256(conteudo_origem).hexdigest()
alvo = DocumentoParaAssinatura.objects.filter(materia=materia).first()
if alvo is not None and alvo.hash_origem == hash_origem:
return 'em dia' # idempotência: nada mudou desde a geração
# Reuso da rotina da sprint: PDF copia os bytes, DOCX converte no
# OnlyOffice — a conversão acontece UMA vez, aqui, fora do caminho
# quente das requisições (§5.1).
from sapl.materia.views_assinatura import _gerar_pdf_da_materia
pdf_bytes, erro = _gerar_pdf_da_materia(
materia, _RequisicaoDeSistema())
if erro:
logger.error(
'materializar_pdfs: matéria %s não convertida (%s) — segue '
'visível só na tela do SAPL até o próximo ciclo', materia.pk,
erro)
return 'falha'
nome = 'materia_%s_alvo.pdf' % materia.pk
hash_alvo = hashlib.sha256(pdf_bytes).hexdigest()
with transaction.atomic():
if alvo is None:
alvo = DocumentoParaAssinatura(
materia=materia, hash_sha256=hash_alvo,
hash_origem=hash_origem)
alvo.arquivo.save(nome, ContentFile(pdf_bytes), save=True)
logger.info(
'materializar_pdfs: PDF-alvo da matéria %s gerado (%s)',
materia.pk, hash_alvo)
return 'gerado'
# RETIFICAÇÃO (§5.1, decisão do arquiteto 19/08): texto_original
# mudou depois da conversão → o alvo está defasado. Regenera E zera
# o processo de assinatura — mesmo efeito da rotina
# `materia_remover_assinatura` que o SAPL já tem. Assinatura sobre
# texto retificado é impossível por construção.
alvo.arquivo.delete(save=False)
alvo.hash_sha256 = hash_alvo
alvo.hash_origem = hash_origem
alvo.arquivo.save(nome, ContentFile(pdf_bytes), save=True)
if materia.pdf_assinado:
materia.pdf_assinado.delete(save=False)
materia.pdf_assinado = None
materia.assinatura_info = None
materia.assinado_em = None
materia.assinado_por = None
materia.codigo_autenticacao = None
materia.save()
logger.info(
'materializar_pdfs: matéria %s RETIFICADA — alvo regenerado (%s) '
'e assinaturas zeradas', materia.pk, hash_alvo)
return 'retificado'

45
sapl/integracao_hub/migrations/0003_assinaturarecebida_documentoparaassinatura.py

@ -0,0 +1,45 @@
# Generated by Django 2.2.28 on 2026-08-20 17:26
from django.db import migrations, models
import django.db.models.deletion
import sapl.integracao_hub.models
class Migration(migrations.Migration):
dependencies = [
('materia', '0094_add_anexoproposicao'),
('integracao_hub', '0002_anexoproposicao'),
]
operations = [
migrations.CreateModel(
name='DocumentoParaAssinatura',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('arquivo', models.FileField(upload_to=sapl.integracao_hub.models._caminho_pdf_alvo, verbose_name='PDF-alvo da assinatura')),
('hash_sha256', models.CharField(max_length=64, verbose_name='SHA-256 do PDF-alvo')),
('hash_origem', models.CharField(max_length=64, verbose_name='SHA-256 do texto_original usado na geração')),
('gerado_em', models.DateTimeField(auto_now=True, verbose_name='Gerado em')),
('materia', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='documento_para_assinatura', to='materia.MateriaLegislativa', verbose_name='Matéria Legislativa')),
],
options={
'verbose_name': 'Documento para Assinatura',
'verbose_name_plural': 'Documentos para Assinatura',
},
),
migrations.CreateModel(
name='AssinaturaRecebida',
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')),
('hash_assinado', models.CharField(max_length=64, verbose_name='SHA-256 do PDF assinado recebido')),
('recebido_em', models.DateTimeField(auto_now_add=True, verbose_name='Recebido em')),
('materia', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='materia.MateriaLegislativa', verbose_name='Matéria Legislativa')),
],
options={
'verbose_name': 'Assinatura Recebida do Hub',
'verbose_name_plural': 'Assinaturas Recebidas do Hub',
},
),
]

18
sapl/integracao_hub/migrations/0004_assinaturarecebida_operado_por.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-08-20 17:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('integracao_hub', '0003_assinaturarecebida_documentoparaassinatura'),
]
operations = [
migrations.AddField(
model_name='assinaturarecebida',
name='operado_por',
field=models.CharField(blank=True, help_text='Rastro operacional: quem DISPAROU o ato (o próprio vereador ou um assessor agindo por ele). NÃO é a autoria jurídica — o signatário é sempre o vereador titular (assinatura é ato pessoal e indelegável). Hoje o evento do app ainda não carrega a identidade do assessor logado; até lá recebe o próprio titular.', max_length=150, verbose_name='Operado por'),
),
]

95
sapl/integracao_hub/models.py

@ -39,6 +39,101 @@ def _caminho_anexo(instancia, nome):
return 'integracao_hub/proposicao_%s/%s' % (instancia.proposicao_id, nome)
def _caminho_pdf_alvo(instancia, nome):
return 'integracao_hub/materia_%s/%s' % (instancia.materia_id, nome)
class DocumentoParaAssinatura(models.Model):
"""O PDF-alvo persistido da matéria — o invariante do documento único (§5).
O PDF que o SAPL assina, o que o app exibe e o que aparece assinado são o
MESMO binário: gerado uma única vez pela materialização (management command
`materializar_pdfs_para_assinatura`) e referenciado por hash em cada salto.
Vive neste app isolado, não em `MateriaLegislativa`, pelo mesmo racional do
`AnexoProposicao`: custo zero de rebase do fork (refinamento §10).
`hash_origem` é o sha256 do `texto_original` usado na geração é ele que
detecta retificação: mudou o texto depois da conversão, o alvo está defasado
e o processo de assinatura zera (decisão do arquiteto 19/08, §5.1).
"""
materia = models.OneToOneField(
'materia.MateriaLegislativa',
on_delete=models.PROTECT,
related_name='documento_para_assinatura',
verbose_name=_('Matéria Legislativa'))
arquivo = models.FileField(
upload_to=_caminho_pdf_alvo,
verbose_name=_('PDF-alvo da assinatura'))
hash_sha256 = models.CharField(
max_length=64,
verbose_name=_('SHA-256 do PDF-alvo'))
hash_origem = models.CharField(
max_length=64,
verbose_name=_('SHA-256 do texto_original usado na geração'))
gerado_em = models.DateTimeField(
auto_now=True,
verbose_name=_('Gerado em'))
class Meta:
verbose_name = _('Documento para Assinatura')
verbose_name_plural = _('Documentos para Assinatura')
def __str__(self):
return 'PDF-alvo da matéria %s' % self.materia_id
class AssinaturaRecebida(models.Model):
"""Dedupe do `POST /api/integracao/assinaturas/` — padrão do EventoRecebido.
Uma linha por assinatura entregue pelo hub; chave repetida é reentrega e
devolve a mesma resposta sem efeito colateral. `hash_assinado` fica aqui
para a reentrega responder o que a primeira entrega respondeu. A linha
também marca a ORIGEM da gravação (anti-eco do refinamento §5.1): o hub
correlaciona o que ele mesmo entregou.
"""
chave_idempotencia = models.UUIDField(
unique=True,
verbose_name=_('Chave de Idempotência'))
materia = models.ForeignKey(
'materia.MateriaLegislativa',
on_delete=models.PROTECT,
related_name='+',
verbose_name=_('Matéria Legislativa'))
hash_assinado = models.CharField(
max_length=64,
verbose_name=_('SHA-256 do PDF assinado recebido'))
operado_por = models.CharField(
max_length=150,
blank=True,
verbose_name=_('Operado por'),
help_text=_(
'Rastro operacional: quem DISPAROU o ato (o próprio vereador ou um '
'assessor agindo por ele). NÃO é a autoria jurídica — o signatário '
'é sempre o vereador titular (assinatura é ato pessoal e '
'indelegável). Hoje o evento do app ainda não carrega a identidade '
'do assessor logado; até lá recebe o próprio titular.'))
recebido_em = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Recebido em'))
class Meta:
verbose_name = _('Assinatura Recebida do Hub')
verbose_name_plural = _('Assinaturas Recebidas do Hub')
def __str__(self):
return str(self.chave_idempotencia)
class AnexoProposicao(models.Model):
"""Anexo GERAL vindo do app (foto, vídeo, qualquer mídia) — não é o texto oficial.

186
sapl/integracao_hub/serializacao.py

@ -1,6 +1,13 @@
import hashlib
import os
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from sapl.base.models import Autor, OperadorAutor
from sapl.materia.models import MateriaLegislativa
from sapl.parlamentares.models import Parlamentar, Votante
def _iso(valor):
@ -80,3 +87,182 @@ 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 _operadores_do_autor(autor):
return list(autor.operadorautor_set.select_related('user').order_by('id'))
def resolver_titular(autor):
"""User do VEREADOR TITULAR do autor, ou None se indeterminável.
Assinatura é ato pessoal e indelegável: o certificado ICP é do vereador, e
o assessor NUNCA assina no lugar dele opera (rastro operacional à
parte). A identidade jurídica do signatário é sempre o titular.
O vínculo estrutural parlamentaruser é o **Votante** do Parlamentar que o
Autor representa (content_type=parlamentar). No dado real de Franco, o autor
CESINHA tem operadores {cesinha, Juciana} mas votante {cesinha}: o votante
isola o titular da assessora casar username com o nome do autor seria
coincidência frágil, o Votante é o vínculo confiável.
Regras:
- parlamentar com exatamente 1 votante titular (o caso normal);
- parlamentar sem votante cadastrado cai no operador único; se houver
mais de um operador e nenhum votante, o titular é INDETERMINÁVEL (None)
o chamador falha visível, melhor que atribuir a autoria ao assessor;
- parlamentar com >1 votante ambíguo None;
- autor não-parlamentar (órgão, comissão) sem conceito de votante: o
operador único resolve, senão None.
"""
related = autor.autor_related
if isinstance(related, Parlamentar):
votantes = {v.user_id: v.user
for v in related.votante_set.select_related('user')}
if len(votantes) == 1:
return next(iter(votantes.values()))
if len(votantes) > 1:
return None # titular ambíguo — não adivinha
# sem votante: só resolve se houver um operador único
operadores = _operadores_do_autor(autor)
if len(operadores) == 1:
return operadores[0].user
return None
def _autores_pendentes(materia):
"""Pendência é POR AUTOR (refinamento §2), derivada — não é tabela.
pendente(autor, matéria) = autor autoria titular(autor)
assinatura_info.signed_by. O titular é o vereador (via Votante), não um
operador qualquer: é a assinatura DELE que fecha a pendência. Titular
indeterminável conta como pendente (não para confirmar que assinou) a
matéria fica visível e o erro aparece no ato de assinar, não some calada.
"""
assinados = {
a.get('signed_by')
for a in _normalizar_assinatura_info(materia.assinatura_info)}
pendentes = []
for autoria in materia.autoria_set.select_related('autor'):
titular = resolver_titular(autoria.autor)
if titular is None or titular.username not in 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):
"""Resolve signed_by → autor_id (contrato documento-assinado).
signed_by é o VEREADOR TITULAR, então a resolução espelha `resolver_titular`
ao contrário: username Votante Parlamentar Autor (content_type
parlamentar), preferindo o autor que está na autoria da matéria (é a
pendência dele que a assinatura fecha). Fallback via OperadorAutor cobre
registros antigos assinados localmente antes desta regra. Sem casamento na
autoria, devolve o melhor palpite (best-effort de exibição); None se nada
resolver o consumidor ainda tem o signed_by.
"""
ct_parlamentar = ContentType.objects.get_for_model(Parlamentar)
parlamentar_ids = list(
Votante.objects.filter(user__username=username)
.values_list('parlamentar_id', flat=True))
autores_titular = list(
Autor.objects.filter(content_type=ct_parlamentar,
object_id__in=parlamentar_ids)
.values_list('id', flat=True)) if parlamentar_ids else []
for autor_id in autores_titular:
if autor_id in ids_da_autoria:
return autor_id
autores_operador = list(
OperadorAutor.objects.filter(user__username=username)
.order_by('id').values_list('autor_id', flat=True))
for autor_id in autores_operador:
if autor_id in ids_da_autoria:
return autor_id
if autores_titular:
return autores_titular[0]
return autores_operador[0] if autores_operador else None
def serializar_materia_assinada(materia, request):
assinaturas_info = _normalizar_assinatura_info(materia.assinatura_info)
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),
# Rastro operacional (quem disparou o ato) — separado da autoria
# jurídica (signed_by). Ausente nos registros da sprint.
'operado_por': info.get('operado_por'),
})
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,
}

498
sapl/integracao_hub/tests/test_assinatura.py

@ -0,0 +1,498 @@
import hashlib
import uuid
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from model_bakery import baker
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient
from django.contrib.contenttypes.models import ContentType
from sapl.base.models import Autor, OperadorAutor
from sapl.integracao_hub.models import (AssinaturaRecebida,
DocumentoParaAssinatura)
from sapl.materia.models import Autoria, MateriaLegislativa
from sapl.parlamentares.models import Parlamentar, Votante
BASE = '/api/integracao/poll/'
URL_ASSINATURAS = '/api/integracao/assinaturas/'
PDF_ALVO = b'%PDF-1.4 alvo-oficial'
PDF_ASSINADO = b'%PDF-1.4 alvo-oficial-com-assinatura'
@pytest.fixture()
def cliente_hub(db):
usuario = baker.make('auth.User', username='hub-teste')
permissao = Permission.objects.get(
content_type__app_label='integracao_hub', codename='pode_integrar')
usuario.user_permissions.add(permissao)
# get_or_create: sapl/api/signals.py:8 ja cria o token no post_save do usuario.
# Um create() aqui colide com a UNIQUE de authtoken_token.
token, _ = Token.objects.get_or_create(user=usuario)
cliente = APIClient()
cliente.credentials(HTTP_AUTHORIZATION='Token %s' % token.key)
return cliente
def criar_materia_com_alvo(conteudo=PDF_ALVO):
materia = baker.make(MateriaLegislativa, numero_protocolo=200)
materia.texto_original.save(
'texto.pdf', ContentFile(conteudo), save=True)
alvo = DocumentoParaAssinatura(
materia=materia,
hash_sha256=hashlib.sha256(conteudo).hexdigest(),
hash_origem=hashlib.sha256(conteudo).hexdigest())
alvo.arquivo.save('materia_%s_alvo.pdf' % materia.pk,
ContentFile(conteudo), save=True)
return materia, alvo
def criar_autor_com_operador(username):
autor = baker.make(Autor, nome='Vereador %s' % username)
usuario = baker.make('auth.User', username=username)
baker.make(OperadorAutor, autor=autor, user=usuario)
return autor
def criar_autor_parlamentar(titular, assessores=(), com_votante=True):
"""Autor de parlamentar como no dado real de Franco (CESINHA).
O titular é o Votante do parlamentar; assessores são operadores. Sem
votante e com >1 operador, o titular fica indeterminável de propósito.
"""
parlamentar = baker.make(Parlamentar, nome_parlamentar=titular.upper())
ct = ContentType.objects.get_for_model(Parlamentar)
autor = baker.make(Autor, nome=titular.upper(),
content_type=ct, object_id=parlamentar.pk)
user_titular = baker.make('auth.User', username=titular)
baker.make(OperadorAutor, autor=autor, user=user_titular)
if com_votante:
baker.make(Votante, parlamentar=parlamentar, user=user_titular)
for assessor in assessores:
user_assessor = baker.make('auth.User', username=assessor)
baker.make(OperadorAutor, autor=autor, user=user_assessor)
return autor, parlamentar
# ---------------------------------------------------------------------------
# Poll de pendentes (refinamento §2/§3/§5.1)
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_pendentes_so_devolve_materia_com_pdf_alvo(cliente_hub):
"""§5.1: DOCX não convertido não sai do SAPL — sem alvo, sem pendência."""
com_alvo, _ = criar_materia_com_alvo()
sem_alvo = baker.make(MateriaLegislativa, numero_protocolo=201)
sem_alvo.texto_original.save(
'texto.docx', ContentFile(b'docx-nao-convertido'), save=True)
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', {'id_gt': 0})
assert resposta.status_code == 200
ids = [item['materia']['id'] for item in resposta.data['resultados']]
assert com_alvo.pk in ids
assert sem_alvo.pk not in ids
@pytest.mark.django_db(transaction=False)
def test_pendencia_e_por_autor_um_assinou_outro_segue_pendente(cliente_hub):
"""Refinamento §2: a primeira assinatura NAO some com a pendência dos demais."""
materia, _ = criar_materia_com_alvo()
assinante = criar_autor_com_operador('ver-a')
pendente = criar_autor_com_operador('ver-b')
baker.make(Autoria, materia=materia, autor=assinante)
baker.make(Autoria, materia=materia, autor=pendente)
materia.assinatura_info = [{'signed_by': 'ver-a',
'nome_assinante': 'Vereador ver-a'}]
materia.save()
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', {'id_gt': 0})
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assert item['autores_pendentes'] == [pendente.pk]
@pytest.mark.django_db(transaction=False)
def test_pendentes_traz_documento_com_hash_e_url(cliente_hub):
materia, alvo = criar_materia_com_alvo()
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', {'id_gt': 0})
documento = resposta.data['resultados'][0]['documento']
assert documento['mime'] == 'application/pdf'
assert documento['tamanho_bytes'] == len(PDF_ALVO)
assert documento['hash_sha256'] == hashlib.sha256(PDF_ALVO).hexdigest()
assert documento['url'].endswith(
'/api/integracao/documentos-assinatura/%s/alvo/' % materia.pk)
@pytest.mark.django_db(transaction=False)
def test_pendentes_cursor_avanca_por_id_de_materia(cliente_hub):
primeira, _ = criar_materia_com_alvo()
segunda, _ = criar_materia_com_alvo()
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/',
{'id_gt': primeira.pk})
ids = [item['materia']['id'] for item in resposta.data['resultados']]
assert primeira.pk not in ids
assert segunda.pk in ids
# ---------------------------------------------------------------------------
# Poll de concluídas (cursor composto assinado_em|id)
# ---------------------------------------------------------------------------
def assinar_localmente(materia, username='ver-a'):
materia.pdf_assinado.save(
'materia_%s_assinado_1.pdf' % materia.pk,
ContentFile(PDF_ASSINADO), save=False)
materia.assinatura_info = [{
'signed_by': username,
'nome_assinante': 'Vereador %s' % username,
'data_assinatura': '19/08/2026 10:00',
'tipo_certificado': 'A1',
}]
materia.assinado_em = timezone.now()
materia.codigo_autenticacao = 'ABCD1234ABCD1234'
materia.save()
@pytest.mark.django_db(transaction=False)
def test_concluidas_devolve_documento_assinado_e_autor_resolvido(cliente_hub):
materia, _ = criar_materia_com_alvo()
autor = criar_autor_com_operador('ver-a')
baker.make(Autoria, materia=materia, autor=autor)
assinar_localmente(materia)
nao_assinada, _ = criar_materia_com_alvo()
resposta = cliente_hub.get(
BASE + 'assinaturas-concluidas/',
{'desde': (timezone.now() - timedelta(days=1)).isoformat(),
'id_gt': 0})
assert resposta.status_code == 200
ids = [item['materia']['id'] for item in resposta.data['resultados']]
assert materia.pk in ids
assert nao_assinada.pk not in ids
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
documento = item['documento_assinado']
assert documento['hash_sha256'] == \
hashlib.sha256(PDF_ASSINADO).hexdigest() # calculado do arquivo
assert documento['tamanho_bytes'] == len(PDF_ASSINADO)
assert documento['url'].endswith(
'/api/integracao/documentos-assinatura/%s/assinado/' % materia.pk)
assert item['codigo_autenticacao'] == 'ABCD1234ABCD1234'
assinatura = item['assinaturas'][0]
assert assinatura['signed_by'] == 'ver-a'
assert assinatura['nome'] == 'Vereador ver-a'
assert assinatura['tipo_certificado'] == 'A1'
# Contrato documento-assinado: autor resolvido de signed_by via
# OperadorAutor — é por ele que o consumidor marca a pendência fechada.
assert assinatura['autor_id'] == autor.pk
@pytest.mark.django_db(transaction=False)
def test_concluidas_cursor_composto_nao_repete_no_empate(cliente_hub):
instante = timezone.now()
primeira, _ = criar_materia_com_alvo()
segunda, _ = criar_materia_com_alvo()
for materia in (primeira, segunda):
assinar_localmente(materia)
MateriaLegislativa.objects.filter(pk=materia.pk).update(
assinado_em=instante)
resposta = cliente_hub.get(
BASE + 'assinaturas-concluidas/',
{'desde': instante.isoformat(), 'id_gt': primeira.pk})
ids = [item['materia']['id'] for item in resposta.data['resultados']]
assert primeira.pk not in ids
assert segunda.pk in ids
# ---------------------------------------------------------------------------
# Download dos bytes (token + pode_integrar)
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_download_do_alvo_e_do_assinado(cliente_hub):
materia, _ = criar_materia_com_alvo()
assinar_localmente(materia)
alvo = cliente_hub.get(
'/api/integracao/documentos-assinatura/%s/alvo/' % materia.pk)
assinado = cliente_hub.get(
'/api/integracao/documentos-assinatura/%s/assinado/' % materia.pk)
assert alvo.status_code == 200
assert b''.join(alvo.streaming_content) == PDF_ALVO
assert assinado.status_code == 200
assert b''.join(assinado.streaming_content) == PDF_ASSINADO
@pytest.mark.django_db(transaction=False)
def test_download_sem_documento_da_404(cliente_hub):
materia = baker.make(MateriaLegislativa)
resposta = cliente_hub.get(
'/api/integracao/documentos-assinatura/%s/alvo/' % materia.pk)
assert resposta.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_download_sem_token_da_401(db):
materia, _ = criar_materia_com_alvo()
resposta = APIClient().get(
'/api/integracao/documentos-assinatura/%s/alvo/' % materia.pk)
assert resposta.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/integracao/assinaturas/ (refinamento §5, F2)
# ---------------------------------------------------------------------------
def corpo_assinatura(materia, autor, **extras):
dados = {
'chave_idempotencia': str(uuid.uuid4()),
'materia': materia.pk,
'autor': autor.pk,
'hash_alvo_esperado': hashlib.sha256(PDF_ALVO).hexdigest(),
'nome': 'Vereador ver-a',
'tipo_certificado': 'A1',
'pdf_assinado': SimpleUploadedFile(
'assinado.pdf', PDF_ASSINADO, 'application/pdf'),
}
dados.update(extras)
return dados
@pytest.fixture()
def materia_pronta(db):
materia, _ = criar_materia_com_alvo()
autor = criar_autor_com_operador('ver-a')
baker.make(Autoria, materia=materia, autor=autor)
return materia, autor
@pytest.mark.django_db(transaction=False)
def test_grava_assinatura_no_formato_da_sprint(cliente_hub, materia_pronta):
materia, autor = materia_pronta
resposta = cliente_hub.post(
URL_ASSINATURAS, corpo_assinatura(materia, autor),
format='multipart')
assert resposta.status_code == 201
assert resposta.data['materia_id'] == materia.pk
assert resposta.data['hash_assinado'] == \
hashlib.sha256(PDF_ASSINADO).hexdigest()
materia.refresh_from_db()
assert 'materia_%s_assinado_' % materia.pk in materia.pdf_assinado.name
materia.pdf_assinado.open('rb')
assert materia.pdf_assinado.read() == PDF_ASSINADO
info = materia.assinatura_info[0]
assert info['signed_by'] == 'ver-a' # username do OperadorAutor do autor
assert info['nome'] == 'Vereador ver-a'
assert info['tipo_certificado'] == 'A1'
assert materia.assinado_em is not None
# Primeira assinatura gera o código público, como no fluxo local.
assert materia.codigo_autenticacao == \
hashlib.sha256(PDF_ALVO).hexdigest()[:16].upper()
@pytest.mark.django_db(transaction=False)
def test_append_preserva_multiassinatura(cliente_hub, materia_pronta):
materia, autor = materia_pronta
materia.assinatura_info = [{'signed_by': 'ver-x',
'nome_assinante': 'Vereador X'}]
materia.save()
resposta = cliente_hub.post(
URL_ASSINATURAS, corpo_assinatura(materia, autor),
format='multipart')
assert resposta.status_code == 201
materia.refresh_from_db()
assert [a['signed_by'] for a in materia.assinatura_info] == \
['ver-x', 'ver-a']
@pytest.mark.django_db(transaction=False)
def test_reentrega_devolve_200_sem_duplicar(cliente_hub, materia_pronta):
materia, autor = materia_pronta
chave = str(uuid.uuid4())
primeira = cliente_hub.post(
URL_ASSINATURAS,
corpo_assinatura(materia, autor, chave_idempotencia=chave),
format='multipart')
segunda = cliente_hub.post(
URL_ASSINATURAS,
corpo_assinatura(materia, autor, chave_idempotencia=chave),
format='multipart')
assert primeira.status_code == 201
assert segunda.status_code == 200
assert segunda.data['hash_assinado'] == primeira.data['hash_assinado']
assert AssinaturaRecebida.objects.count() == 1
materia.refresh_from_db()
assert len(materia.assinatura_info) == 1 # reentrega não duplica o append
@pytest.mark.django_db(transaction=False)
def test_hash_divergente_da_409(cliente_hub, materia_pronta):
"""Retificação no meio do caminho (§5.1): o alvo mudou entre o poll e a
entrega assinar binário defasado é impossível por construção."""
materia, autor = materia_pronta
resposta = cliente_hub.post(
URL_ASSINATURAS,
corpo_assinatura(materia, autor,
hash_alvo_esperado='0' * 64),
format='multipart')
assert resposta.status_code == 409
assert resposta.data['hash_atual'] == \
hashlib.sha256(PDF_ALVO).hexdigest()
materia.refresh_from_db()
assert not materia.pdf_assinado
assert materia.assinatura_info is None
@pytest.mark.django_db(transaction=False)
def test_autor_fora_da_autoria_da_422(cliente_hub, materia_pronta):
materia, _ = materia_pronta
intruso = criar_autor_com_operador('ver-z')
resposta = cliente_hub.post(
URL_ASSINATURAS, corpo_assinatura(materia, intruso),
format='multipart')
assert resposta.status_code == 422
# ---------------------------------------------------------------------------
# Titular (autoria jurídica) vs operador (rastro) — regra do arquiteto 20/08
# ---------------------------------------------------------------------------
@pytest.mark.django_db(transaction=False)
def test_multi_operador_assina_como_titular_nao_como_assessor(cliente_hub):
"""Assinatura é ato pessoal e indelegável (dado real: CESINHA).
O autor tem dois operadores o vereador 'cesinha' (Votante) e a assessora
'juciana'. signed_by TEM que ser o titular, mesmo que a assessora dispare o
ato. Escolher 'primeiro por id' gravaria a assessora como signatária.
"""
materia, _ = criar_materia_com_alvo()
autor, _ = criar_autor_parlamentar('cesinha', assessores=['juciana'])
baker.make(Autoria, materia=materia, autor=autor)
resposta = cliente_hub.post(
URL_ASSINATURAS,
corpo_assinatura(materia, autor, operado_por='juciana'),
format='multipart')
assert resposta.status_code == 201
materia.refresh_from_db()
info = materia.assinatura_info[0]
assert info['signed_by'] == 'cesinha' # titular, nunca a assessora
assert info['operado_por'] == 'juciana' # rastro: quem disparou
# assinado_por (FK) também é o titular — a autoria jurídica.
assert materia.assinado_por.username == 'cesinha'
# E o rastro fica durável na tabela de auditoria.
recebida = AssinaturaRecebida.objects.get(materia=materia)
assert recebida.operado_por == 'juciana'
@pytest.mark.django_db(transaction=False)
def test_operado_por_default_e_o_titular_quando_evento_nao_traz(cliente_hub):
"""Hoje o evento do app ainda não carrega o operador real (nota no PR):
sem 'operado_por', o rastro recai sobre o próprio titular."""
materia, _ = criar_materia_com_alvo()
autor, _ = criar_autor_parlamentar('cesinha', assessores=['juciana'])
baker.make(Autoria, materia=materia, autor=autor)
resposta = cliente_hub.post(
URL_ASSINATURAS, corpo_assinatura(materia, autor),
format='multipart')
assert resposta.status_code == 201
materia.refresh_from_db()
assert materia.assinatura_info[0]['operado_por'] == 'cesinha'
@pytest.mark.django_db(transaction=False)
def test_titular_indeterminavel_falha_visivel(cliente_hub):
"""Multi-operador SEM Votante titular: recusa em vez de adivinhar."""
materia, _ = criar_materia_com_alvo()
autor, _ = criar_autor_parlamentar(
'cesinha', assessores=['juciana'], com_votante=False)
baker.make(Autoria, materia=materia, autor=autor)
resposta = cliente_hub.post(
URL_ASSINATURAS, corpo_assinatura(materia, autor),
format='multipart')
assert resposta.status_code == 422
assert 'titular indeterminável' in resposta.data['detalhe']
materia.refresh_from_db()
assert not materia.pdf_assinado
@pytest.mark.django_db(transaction=False)
def test_concluidas_resolve_autor_pelo_titular_votante(cliente_hub):
"""Resolução inversa signed_by → autor_id via Votante (não 'primeiro por id')."""
materia, _ = criar_materia_com_alvo()
autor, _ = criar_autor_parlamentar('cesinha', assessores=['juciana'])
baker.make(Autoria, materia=materia, autor=autor)
materia.pdf_assinado.save(
'materia_%s_assinado_1.pdf' % materia.pk,
ContentFile(PDF_ASSINADO), save=False)
materia.assinatura_info = [{
'signed_by': 'cesinha', 'nome': 'Vereador cesinha',
'data': '2026-08-20T10:00:00', 'tipo_certificado': 'A1',
'operado_por': 'juciana'}]
materia.assinado_em = timezone.now()
materia.save()
resposta = cliente_hub.get(
BASE + 'assinaturas-concluidas/',
{'desde': (timezone.now() - timedelta(days=1)).isoformat(),
'id_gt': 0})
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assinatura = item['assinaturas'][0]
assert assinatura['signed_by'] == 'cesinha'
assert assinatura['autor_id'] == autor.pk # resolvido via Votante
assert assinatura['operado_por'] == 'juciana'
@pytest.mark.django_db(transaction=False)
def test_pendencia_do_titular_some_apos_assinatura_do_titular(cliente_hub):
"""Pendência por autor usa o titular: assinou o titular, some a pendência."""
materia, _ = criar_materia_com_alvo()
autor, _ = criar_autor_parlamentar('cesinha', assessores=['juciana'])
baker.make(Autoria, materia=materia, autor=autor)
materia.assinatura_info = [{'signed_by': 'cesinha'}]
materia.save()
resposta = cliente_hub.get(BASE + 'assinaturas-pendentes/', {'id_gt': 0})
item = next(i for i in resposta.data['resultados']
if i['materia']['id'] == materia.pk)
assert autor.pk not in item['autores_pendentes']

122
sapl/integracao_hub/tests/test_materializacao.py

@ -0,0 +1,122 @@
import hashlib
import pytest
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.utils import timezone
from model_bakery import baker
from sapl.base.models import Autor
from sapl.integracao_hub.models import DocumentoParaAssinatura
from sapl.materia.models import MateriaLegislativa
PDF = b'%PDF-1.4 conteudo-original'
def criar_materia(protocolo=100, conteudo=PDF, nome='texto.pdf'):
materia = baker.make(MateriaLegislativa, numero_protocolo=protocolo)
if conteudo is not None:
materia.texto_original.save(nome, ContentFile(conteudo), save=True)
return materia
@pytest.mark.django_db(transaction=False)
def test_materializa_pdf_alvo_da_materia_protocolada(db):
materia = criar_materia()
call_command('materializar_pdfs_para_assinatura')
alvo = DocumentoParaAssinatura.objects.get(materia=materia)
assert alvo.hash_sha256 == hashlib.sha256(PDF).hexdigest()
assert alvo.hash_origem == hashlib.sha256(PDF).hexdigest()
alvo.arquivo.open('rb')
assert alvo.arquivo.read() == PDF # PDF-alvo copia os bytes do original
@pytest.mark.django_db(transaction=False)
def test_ignora_materia_sem_protocolo_ou_sem_texto(db):
sem_protocolo = criar_materia(protocolo=None)
sem_texto = baker.make(MateriaLegislativa, numero_protocolo=101)
call_command('materializar_pdfs_para_assinatura')
assert not DocumentoParaAssinatura.objects.filter(
materia__in=[sem_protocolo, sem_texto]).exists()
@pytest.mark.django_db(transaction=False)
def test_idempotente_nao_regenera_sem_mudanca(db):
materia = criar_materia()
call_command('materializar_pdfs_para_assinatura')
gerado_em = DocumentoParaAssinatura.objects.get(materia=materia).gerado_em
call_command('materializar_pdfs_para_assinatura')
alvo = DocumentoParaAssinatura.objects.get(materia=materia)
assert alvo.gerado_em == gerado_em # nada mudou, nada regerado (cron-safe)
assert DocumentoParaAssinatura.objects.count() == 1
@pytest.mark.django_db(transaction=False)
def test_retificacao_regenera_alvo_e_zera_assinatura(db):
"""Decisão do arquiteto (19/08, refinamento §5.1): retificação ZERA.
Texto retificado depois da conversão alvo defasado. O command regenera o
PDF-alvo E limpa o processo de assinatura inteiro mesmo efeito da rotina
`materia_remover_assinatura` do SAPL. Assinatura sobre texto retificado é
impossível por construção.
"""
materia = criar_materia()
call_command('materializar_pdfs_para_assinatura')
# Alguém assinou o alvo antigo...
usuario = baker.make('auth.User', username='ver-a')
materia.refresh_from_db()
materia.pdf_assinado.save(
'materia_%s_assinado_1.pdf' % materia.pk,
ContentFile(b'%PDF-assinado-velho'), save=False)
materia.assinatura_info = [{'signed_by': 'ver-a', 'nome': 'Ver. A'}]
materia.assinado_em = timezone.now()
materia.assinado_por = usuario
materia.codigo_autenticacao = 'ABCD1234ABCD1234'
materia.save()
# ...e o texto_original foi retificado.
retificado = b'%PDF-1.4 texto-retificado'
materia.texto_original.save('texto.pdf', ContentFile(retificado),
save=True)
call_command('materializar_pdfs_para_assinatura')
alvo = DocumentoParaAssinatura.objects.get(materia=materia)
assert alvo.hash_sha256 == hashlib.sha256(retificado).hexdigest()
assert alvo.hash_origem == hashlib.sha256(retificado).hexdigest()
materia.refresh_from_db()
assert not materia.pdf_assinado
assert materia.assinatura_info is None
assert materia.assinado_em is None
assert materia.assinado_por is None
assert materia.codigo_autenticacao is None
@pytest.mark.django_db(transaction=False)
def test_falha_de_conversao_de_uma_materia_nao_trava_as_demais(
db, monkeypatch):
quebrada = criar_materia(protocolo=102, nome='texto.docx',
conteudo=b'docx-sem-onlyoffice')
boa = criar_materia(protocolo=103)
def gerar(materia, request):
if materia.pk == quebrada.pk:
return None, 'OnlyOffice fora do ar'
return PDF, None
monkeypatch.setattr(
'sapl.materia.views_assinatura._gerar_pdf_da_materia', gerar)
call_command('materializar_pdfs_para_assinatura')
assert not DocumentoParaAssinatura.objects.filter(
materia=quebrada).exists()
assert DocumentoParaAssinatura.objects.filter(materia=boa).exists()

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'),

257
sapl/integracao_hub/views.py

@ -14,13 +14,22 @@ 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 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 (resolver_titular,
serializar_materia_assinada,
serializar_pendencia,
serializar_proposicao,
serializar_tramitacao)
LIMITE_PADRAO = 100
LIMITE_MAXIMO = 500
@ -307,3 +316,245 @@ 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))
# 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)

Loading…
Cancel
Save