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)