From 39e92cf4fb96fcd87d1c0b7f7899b9afef5f5c10 Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:26:04 -0300 Subject: [PATCH 1/6] Feat(Assinatura): PDF-alvo persistido e dedupe de recepcao no app isolado AB#1473 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O invariante do documento unico (refinamento §5): o PDF que o SAPL assina, o que o app exibe e o que aparece assinado sao o MESMO binario. Hoje o fork viola isso por construcao — o PDF nasce no momento da assinatura, e dois momentos de geracao podem produzir dois binarios. - DocumentoParaAssinatura: o alvo persistido, OneToOne com a materia, com sha256 do PDF e sha256 do texto_original usado na geracao (hash_origem) — e ele que detecta retificacao (§5.1). Vive no app isolado, nao em MateriaLegislativa: custo zero de rebase do fork, mesmo racional do AnexoProposicao. - AssinaturaRecebida: dedupe do POST de assinaturas, padrao do EventoRecebido; guarda o hash respondido para a reentrega devolver a mesma resposta, e marca a origem da gravacao (anti-eco §5.1). Co-Authored-By: Claude Fable 5 --- sapl/integracao_hub/models.py | 84 +++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index 3f9e95ee1..54a280fa7 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -39,6 +39,90 @@ 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')) + + 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. From 3683aa05c59ceebbe8f9cbcae94f621773f1da78 Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:26:04 -0300 Subject: [PATCH 2/6] Feat(Assinatura): materializacao idempotente do PDF-alvo, retificacao zera AB#1473 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Management command para cron na VPS (refinamento §5.1): a conversao DOCX->PDF acontece UMA vez, no SAPL (dono do OnlyOffice), fora do caminho quente das requisicoes. Reusa _gerar_pdf_da_materia da sprint em vez de duplicar — PDF copia os bytes, DOCX converte. - varre materias protocoladas com texto_original; sem alvo gera, com alvo em dia pula (idempotente), hash_origem divergente e RETIFICACAO - retificacao ZERA o processo de assinatura (decisao do arquiteto, 19/08/2026): regenera o alvo e limpa pdf_assinado, assinatura_info, assinado_em, assinado_por e codigo_autenticacao — mesmo efeito da rotina remover-assinatura. Assinar texto retificado e impossivel por construcao. - falha de conversao de uma materia loga e segue: OnlyOffice fora do ar nao trava o ciclo, a pendencia aparece no ciclo seguinte Co-Authored-By: Claude Fable 5 --- sapl/integracao_hub/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../materializar_pdfs_para_assinatura.py | 134 ++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 sapl/integracao_hub/management/__init__.py create mode 100644 sapl/integracao_hub/management/commands/__init__.py create mode 100644 sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py diff --git a/sapl/integracao_hub/management/__init__.py b/sapl/integracao_hub/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/management/commands/__init__.py b/sapl/integracao_hub/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py b/sapl/integracao_hub/management/commands/materializar_pdfs_para_assinatura.py new file mode 100644 index 000000000..88e7fb583 --- /dev/null +++ b/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 só usa o request em dois pontos do caminho DOCX: + `build_onlyoffice_url` (que prefere `SAPL_INTERNAL_URL` e só 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' From 3c006508f54cd924645d9249b8a0a901bd2bde49 Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:26:04 -0300 Subject: [PATCH 3/6] Feat(Assinatura): fontes de poll, bytes do PDF e recepcao de assinatura AB#1473 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 --- sapl/integracao_hub/serializacao.py | 129 +++++++++++++++ sapl/integracao_hub/urls.py | 27 ++- sapl/integracao_hub/views.py | 246 +++++++++++++++++++++++++++- 3 files changed, 397 insertions(+), 5 deletions(-) diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index acfefcc9a..e3f740f1d 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/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, + } diff --git a/sapl/integracao_hub/urls.py b/sapl/integracao_hub/urls.py index a403cc14e..d9f08db1f 100644 --- a/sapl/integracao_hub/urls.py +++ b/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\d+)/alvo/$', + DocumentoAlvoView.as_view(), + name='integracao_hub_documento_alvo'), + + url(r'^api/integracao/documentos-assinatura/(?P\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'), diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py index 9e8e54ec3..daa58657b 100644 --- a/sapl/integracao_hub/views.py +++ b/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). + + 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)) + + 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) From 2ee660dd9625759b7fc60992122e79fff0e7c80b Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:26:04 -0300 Subject: [PATCH 4/6] Test(Assinatura): materializacao, pendencia por autor e recepcao idempotente AB#1473 Cobre as regras que sustentam o contrato: retificacao zera o processo de assinatura; pendencia por autor nao some para o coautor apos a primeira assinatura; poll so devolve materia com PDF-alvo; POST idempotente por chave e 409 em hash divergente. Usernames explicitos e curtos nos fixtures (o auditing do SAPL grava em varchar(100) dentro do atomic). Co-Authored-By: Claude Fable 5 --- sapl/integracao_hub/tests/test_assinatura.py | 362 ++++++++++++++++++ .../tests/test_materializacao.py | 122 ++++++ 2 files changed, 484 insertions(+) create mode 100644 sapl/integracao_hub/tests/test_assinatura.py create mode 100644 sapl/integracao_hub/tests/test_materializacao.py diff --git a/sapl/integracao_hub/tests/test_assinatura.py b/sapl/integracao_hub/tests/test_assinatura.py new file mode 100644 index 000000000..bee96283c --- /dev/null +++ b/sapl/integracao_hub/tests/test_assinatura.py @@ -0,0 +1,362 @@ +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 sapl.base.models import Autor, OperadorAutor +from sapl.integracao_hub.models import (AssinaturaRecebida, + DocumentoParaAssinatura) +from sapl.materia.models import Autoria, MateriaLegislativa + +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 + + +# --------------------------------------------------------------------------- +# 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 diff --git a/sapl/integracao_hub/tests/test_materializacao.py b/sapl/integracao_hub/tests/test_materializacao.py new file mode 100644 index 000000000..f5e283411 --- /dev/null +++ b/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() From 218956017c9f30eb317f1c2593aae30c06f364b3 Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:27:04 -0300 Subject: [PATCH 5/6] Feat(Assinatura): migracao dos modelos de PDF-alvo e dedupe AB#1473 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gerada por makemigrations no container padrao de teste — duas tabelas novas no app isolado, nenhuma alteracao em tabela do core. Co-Authored-By: Claude Fable 5 --- ...inaturarecebida_documentoparaassinatura.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 sapl/integracao_hub/migrations/0003_assinaturarecebida_documentoparaassinatura.py diff --git a/sapl/integracao_hub/migrations/0003_assinaturarecebida_documentoparaassinatura.py b/sapl/integracao_hub/migrations/0003_assinaturarecebida_documentoparaassinatura.py new file mode 100644 index 000000000..f7943208e --- /dev/null +++ b/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', + }, + ), + ] From 53f3c3aa351233046e1d384a2c2882e31263ef13 Mon Sep 17 00:00:00 2001 From: kemuel Date: Thu, 20 Aug 2026 14:41:49 -0300 Subject: [PATCH 6/6] Feat(Assinatura): signatario e o vereador titular, assessor so opera AB#1473 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcao da regra juridica (decisao do arquiteto, 20/08/2026): assinatura e ato pessoal e indelegavel — o certificado ICP e do vereador; o assessor pode DISPARAR o ato pelo app, mas NUNCA aparece como autor da assinatura. A versao anterior resolvia o signatario por 'primeiro operador por id', o que gravaria a assessora no lugar do vereador (no dado real de Franco, o autor CESINHA tem operadores {cesinha, Juciana} e so cesinha e o titular). Vinculo estrutural do titular = o Votante do Parlamentar que o Autor representa (parlamentares_votante.user). Confirmado no banco: CESINHA (parlamentar 2) tem votante {cesinha}, isolando o titular da assessora — o Votante e o vinculo confiavel, casar username com o nome do autor seria coincidencia fragil. Duas camadas distintas: - signed_by / assinado_por / nome = SEMPRE o vereador titular (autoria juridica); resolvido por resolver_titular() e sua inversa no poll de concluidas (signed_by -> autor via Votante, nao mais 'primeiro por id'); - operado_por = quem disparou o ato (rastro operacional, auditoria interna) — novo campo em AssinaturaRecebida + chave no assinatura_info. Titular indeterminavel (multi-operador sem Votante ou com Votante ambiguo) = recusa 422 com mensagem clara, em vez de adivinhar — falha visivel e melhor que atribuir a autoria errada. NOTA PARA O APP: o evento DocumentoAssinado (origem amu) ainda nao carrega a identidade do assessor logado; ate o app enviar 'operado_por', o rastro recai sobre o proprio titular. Campo ja preparado no SAPL para quando o app enviar. Co-Authored-By: Claude Fable 5 --- .../0004_assinaturarecebida_operado_por.py | 18 +++ sapl/integracao_hub/models.py | 11 ++ sapl/integracao_hub/serializacao.py | 113 +++++++++++---- sapl/integracao_hub/tests/test_assinatura.py | 136 ++++++++++++++++++ sapl/integracao_hub/views.py | 37 +++-- 5 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 sapl/integracao_hub/migrations/0004_assinaturarecebida_operado_por.py diff --git a/sapl/integracao_hub/migrations/0004_assinaturarecebida_operado_por.py b/sapl/integracao_hub/migrations/0004_assinaturarecebida_operado_por.py new file mode 100644 index 000000000..b1a982334 --- /dev/null +++ b/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'), + ), + ] diff --git a/sapl/integracao_hub/models.py b/sapl/integracao_hub/models.py index 54a280fa7..079d03b61 100644 --- a/sapl/integracao_hub/models.py +++ b/sapl/integracao_hub/models.py @@ -111,6 +111,17 @@ class AssinaturaRecebida(models.Model): 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')) diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index e3f740f1d..6ac7c0304 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/sapl/integracao_hub/serializacao.py @@ -1,11 +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 OperadorAutor +from sapl.base.models import Autor, OperadorAutor from sapl.materia.models import MateriaLegislativa +from sapl.parlamentares.models import Parlamentar, Votante def _iso(valor): @@ -107,23 +109,63 @@ def _url_absoluta(request, nome_rota, materia_id): 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 — só opera (rastro operacional à + parte). A identidade jurídica do signatário é sempre o titular. + + O vínculo estrutural parlamentar→user é 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: só 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 ∧ 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). + 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 dá 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.all(): - usernames = { - operador.user.username - for operador in autoria.autor.operadorautor_set.all()} - if not (usernames & assinados): + 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 @@ -150,30 +192,43 @@ def serializar_pendencia(alvo, request): } -def _autor_do_signed_by(username, ids_da_autoria, mapa_operadores): - """Resolve signed_by → autor_id via OperadorAutor (contrato documento-assinado). +def _autor_do_signed_by(username, ids_da_autoria): + """Resolve signed_by → autor_id (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. + 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. """ - autores = mapa_operadores.get(username, []) - for autor_id in autores: + 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 - return autores[0] if autores else None + + 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) - 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)) @@ -187,8 +242,10 @@ def serializar_materia_assinada(materia, request): '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), + '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 { diff --git a/sapl/integracao_hub/tests/test_assinatura.py b/sapl/integracao_hub/tests/test_assinatura.py index bee96283c..f8e84c774 100644 --- a/sapl/integracao_hub/tests/test_assinatura.py +++ b/sapl/integracao_hub/tests/test_assinatura.py @@ -11,10 +11,13 @@ 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/' @@ -57,6 +60,26 @@ def criar_autor_com_operador(username): 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 só 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) # --------------------------------------------------------------------------- @@ -360,3 +383,116 @@ def test_autor_fora_da_autoria_da_422(cliente_hub, materia_pronta): 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'] diff --git a/sapl/integracao_hub/views.py b/sapl/integracao_hub/views.py index daa58657b..0d7dba4bc 100644 --- a/sapl/integracao_hub/views.py +++ b/sapl/integracao_hub/views.py @@ -17,7 +17,7 @@ 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, OperadorAutor +from sapl.base.models import Autor from sapl.materia.forms import ProposicaoForm from sapl.materia.models import (MateriaLegislativa, Proposicao, Tramitacao) @@ -25,7 +25,8 @@ from sapl.utils import get_client_ip from .models import (AnexoProposicao, AssinaturaRecebida, DocumentoParaAssinatura, EventoRecebido) -from .serializacao import (serializar_materia_assinada, +from .serializacao import (resolver_titular, + serializar_materia_assinada, serializar_pendencia, serializar_proposicao, serializar_tramitacao) @@ -454,12 +455,16 @@ class RecepcaoAssinaturaView(IntegracaoHubView): '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: + # 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 não tem operador (OperadorAutor) — sem username ' - 'para signed_by' % autor.pk) + '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: @@ -480,6 +485,13 @@ class RecepcaoAssinaturaView(IntegracaoHubView): 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' % ( @@ -490,15 +502,16 @@ class RecepcaoAssinaturaView(IntegracaoHubView): # APPEND no formato da sprint — multiassinatura incremental. assinaturas = self._normalizar(materia.assinatura_info) assinaturas.append({ - 'signed_by': operador.user.username, + '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 = operador.user + 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 @@ -515,7 +528,7 @@ class RecepcaoAssinaturaView(IntegracaoHubView): AssinaturaRecebida.objects.create( chave_idempotencia=chave, materia=materia, - hash_assinado=hash_assinado) + 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( @@ -529,8 +542,8 @@ class RecepcaoAssinaturaView(IntegracaoHubView): 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) + '(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)