Browse Source

Feat(Assinatura): signatario e o vereador titular, assessor so opera AB#1473

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 <noreply@anthropic.com>
pull/3858/head
kemuel 4 weeks ago
parent
commit
53f3c3aa35
  1. 18
      sapl/integracao_hub/migrations/0004_assinaturarecebida_operado_por.py
  2. 11
      sapl/integracao_hub/models.py
  3. 113
      sapl/integracao_hub/serializacao.py
  4. 136
      sapl/integracao_hub/tests/test_assinatura.py
  5. 37
      sapl/integracao_hub/views.py

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

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

113
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 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 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 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 {

136
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 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']

37
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)

Loading…
Cancel
Save