Browse Source

Fix(Assinatura): 2ª assinatura reenvia o código emitido, e pendência passa a ser por autor AB#1473 (#23)

* Fix(Assinatura): 2a assinatura reenvia o codigo ja emitido, e para de tomar 400 AB#1473

O microservico recusava toda segunda assinatura:

    HTTP 400: O PDF ja tem assinatura: informe 'codigo_autenticacao' (o codigo
    emitido na primeira assinatura, devolvido no header X-Codigo-Autenticacao).
    Ele nao pode ser recalculado a partir do PDF assinado.

Ele estava certo. A exigencia esta escrita na C2 desde o refinamento
(`Convergencia_Assinadores_C1_Parametros.md`, "o PDF muda ao ser assinado; o
hash de agora nao reproduz o codigo impresso na pagina") e o helper daqui ja
tinha o parametro para atender: `hash_doc`.

O que faltava era o fio. Nenhuma das quatro chamadas de
`_assinar_pdf_com_pagina_auth` passava `hash_doc`, entao a linha

    codigo_autenticacao=(hash_doc or '') if ja_tem_pdf_assinado else None

mandava string vazia justamente quando o codigo era obrigatorio. O valor
sempre esteve no banco (`materia.codigo_autenticacao`, gravado na 1a
assinatura) — ninguem o lia de volta.

Regressao da C3 (917bdba9). Antes dela a 2a assinatura ia por
`assinar_pdf_via_api` com coordenada explicita e sem `auth_page`, caminho em
que o micro nao pede o codigo; a C3 mandou o fluxo normal para `auth_page=auto`
sem ligar essa ponta. Nao e bug do microservico nem da delegacao — e a
implementacao do contrato pela metade.

Junto vai o `hash_doc = ''` que o backend local fazia por cima do valor
recebido do chamador: ele apagava o "Hash:" do carimbo exatamente na assinatura
em que o codigo ja existe para ser impresso.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix(Assinatura): pendencia e por autor — coautor que nao assinou nao some da lista AB#1473

IND 820/2026, dois autores: Kinho assinou, e a materia sumiu das pendencias do
Eric — que ainda precisa assinar. No app do AMU ela continuava la. As duas
telas discordavam, e a que escondia era a do sistema onde a assinatura
acontece.

A regra certa ja existia no repo, no `integracao_hub` (`_autores_pendentes`),
com teste e tudo:

    pendente(autor, materia) = autor em autoria
                               e titular(autor) nao em assinatura_info.signed_by

O SAPL web perguntava outra coisa, em quatro lugares: "o documento tem
`pdf_assinado`?". Pendencia por DOCUMENTO. Na primeira assinatura o campo
preenche e a materia sai da lista de todo mundo. O `Assinatura_Pelo_App_
Refinamento.md` §2 ja tinha apontado isso como "provavel bug latente da
sprint" e deixado a validacao em aberto — esta e a resposta.

A regra vira fonte unica em `sapl/materia/pendencias.py`, e o hub reexporta de
la em vez de manter a segunda copia. Duas copias da mesma regra foi como
chegamos aqui: a do hub evoluiu, a do web nao, e ninguem viu ate um coautor
reclamar. Consumidores migrados: badge do menu, tela de pendentes, filtro da
pesquisa, modal do lote e o e-mail diario.

Sem autor no contexto (pesquisa livre com "Status de Assinatura: pendente")
nao ha a quem atribuir a pendencia. Ai vale a forma agregada `assinaturas <
max(autores, 1)`, em SQL com guarda de tipo no jsonb. O `max(..., 1)` preserva
o comportamento antigo para materia sem autoria cadastrada, que sem nenhuma
assinatura continua aparecendo em vez de sumir da pesquisa.

Tres coisas que apareceram no caminho e entram junto:

- `OperadorAutor.objects.get(user=...)` estourava MultipleObjectsReturned para
  o assessor de mais de um vereador. Como a chamada estava dentro de um
  `except Exception`, o badge zerava em silencio em vez de somar os dois.
- A invalidacao de cache so limpava a chave de quem assinou. Mas esta
  assinatura muda a contagem dos OUTROS coautores, que ficavam com o numero
  velho ate o TTL.
- `DocumentoAcessorio` pendente filtrava so `pdf_assinado=''` e deixava passar
  os NULL (todos os anteriores ao campo existir). Doc acessorio nao tem
  autoria, entao ali a pendencia por documento esta certa — o que faltava era
  cobrir os dois estados de "vazio".

Verificado no banco de Franco: IND 820/2026 volta como pendente para o Eric e
nao para o Kinho, com badge, tela, pesquisa, lote e e-mail contando os mesmos
9 itens. 10 testes novos, incluindo o titular indeterminavel e o
`assinatura_info` no formato legado (dict em vez de lista).

O fluxo A3 (token) segue recusando materia com `pdf_assinado` — nunca suportou
multiassinatura. Limitacao anterior a esta correcao, nao tratada aqui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
92fd181a20
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 43
      sapl/context_processors.py
  2. 68
      sapl/integracao_hub/serializacao.py
  3. 45
      sapl/materia/forms.py
  4. 22
      sapl/materia/management/commands/notificar_pendentes_assinatura.py
  5. 281
      sapl/materia/pendencias.py
  6. 261
      sapl/materia/tests/test_pendencias_assinatura.py
  7. 51
      sapl/materia/views.py
  8. 25
      sapl/materia/views_assinatura.py

43
sapl/context_processors.py

@ -38,45 +38,44 @@ def pendencias_assinatura(request):
return {'pendencias_assinatura_total': 0, 'pendencias_assinatura_url': ''} return {'pendencias_assinatura_total': 0, 'pendencias_assinatura_url': ''}
from django.core.cache import cache from django.core.cache import cache
cache_key = f'pendencias_assinatura_user_{request.user.pk}' from sapl.materia.pendencias import CACHE_KEY, CACHE_TTL
cache_key = CACHE_KEY.format(request.user.pk)
cached = cache.get(cache_key) cached = cache.get(cache_key)
if cached is None: if cached is None:
try: try:
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import MateriaLegislativa
from sapl.base.models import OperadorAutor from sapl.materia.pendencias import (
autores_do_usuario, filtrar_pendentes)
# Busca o Autor vinculado ao usuário
try: # Pendência é POR AUTOR: a matéria que um coautor já assinou segue
autor = OperadorAutor.objects.get(user=request.user).autor # pendente para os demais. Contar `pdf_assinado` vazio — como se
autor_pk = autor.pk # fazia aqui — zerava o badge do coautor na primeira assinatura.
except OperadorAutor.DoesNotExist: autores = autores_do_usuario(request.user)
autor_pk = None
if autores:
if autor_pk: total = filtrar_pendentes(
total = MateriaLegislativa.objects.filter( MateriaLegislativa.objects.all(), autores=autores).count()
autoria__autor_id=autor_pk, # A URL leva ao mesmo recorte: um autor por vez na pesquisa, o
texto_original__isnull=False, # primeiro deles (o assessor de dois vereadores vê o total no
).exclude( # badge e refina na tela).
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).distinct().count()
url = ( url = (
reverse('sapl.materia:pesquisar_materia') reverse('sapl.materia:pesquisar_materia')
+ f'?autoria__autor={autor_pk}&status_assinatura=pendente' + f'?autoria__autor={autores[0].pk}&status_assinatura=pendente'
) )
else: else:
total = 0 total = 0
url = '' url = ''
except Exception: except Exception:
logging.getLogger(__name__).exception(
'Falha ao calcular pendências de assinatura')
total = 0 total = 0
url = '' url = ''
cached = {'total': total, 'url': url} cached = {'total': total, 'url': url}
cache.set(cache_key, cached, 120) # cache de 2 minutos cache.set(cache_key, cached, CACHE_TTL)
return { return {
'pendencias_assinatura_total': cached['total'], 'pendencias_assinatura_total': cached['total'],

68
sapl/integracao_hub/serializacao.py

@ -141,65 +141,15 @@ def _url_absoluta(request, nome_rota, materia_id):
reverse(nome_rota, kwargs={'materia_id': materia_id})) reverse(nome_rota, kwargs={'materia_id': materia_id}))
def _operadores_do_autor(autor): # A regra de pendência mora em `sapl.materia.pendencias` — fonte única, também
return list(autor.operadorautor_set.select_related('user').order_by('id')) # usada pelo SAPL web (badge, tela de pendentes, filtro da pesquisa, e-mail
# diário). Ficou duas vezes no repo por um tempo, e as duas divergiram: aqui era
# por autor (certo), lá era por documento — o app do AMU mostrava a pendência do
def resolver_titular(autor): # coautor que a tela do SAPL escondia. Reexportado com os nomes antigos para não
"""User do VEREADOR TITULAR do autor, ou None se indeterminável. # mexer em quem já importa daqui.
from sapl.materia.pendencias import ( # noqa: E402 (reexport)
Assinatura é ato pessoal e indelegável: o certificado ICP é do vereador, e _operadores_do_autor, autores_pendentes as _autores_pendentes,
o assessor NUNCA assina no lugar dele opera (rastro operacional à resolver_titular)
parte). A identidade jurídica do signatário é sempre o titular.
O vínculo estrutural parlamentaruser é o **Votante** do Parlamentar que o
Autor representa (content_type=parlamentar). No dado real de Franco, o autor
CESINHA tem operadores {cesinha, Juciana} mas votante {cesinha}: o votante
isola o titular da assessora casar username com o nome do autor seria
coincidência frágil, o Votante é o vínculo confiável.
Regras:
- parlamentar com exatamente 1 votante titular (o caso normal);
- parlamentar sem votante cadastrado cai no operador único; se houver
mais de um operador e nenhum votante, o titular é INDETERMINÁVEL (None)
o chamador falha visível, melhor que atribuir a autoria ao assessor;
- parlamentar com >1 votante ambíguo None;
- autor não-parlamentar (órgão, comissão) sem conceito de votante: o
operador único resolve, senão None.
"""
related = autor.autor_related
if isinstance(related, Parlamentar):
votantes = {v.user_id: v.user
for v in related.votante_set.select_related('user')}
if len(votantes) == 1:
return next(iter(votantes.values()))
if len(votantes) > 1:
return None # titular ambíguo — não adivinha
# sem votante: só resolve se houver um operador único
operadores = _operadores_do_autor(autor)
if len(operadores) == 1:
return operadores[0].user
return None
def _autores_pendentes(materia):
"""Pendência é POR AUTOR (refinamento §2), derivada — não é tabela.
pendente(autor, matéria) = autor autoria titular(autor)
assinatura_info.signed_by. O titular é o vereador (via Votante), não um
operador qualquer: é a assinatura DELE que fecha a pendência. Titular
indeterminável conta como pendente (não para confirmar que assinou) a
matéria fica visível e o erro aparece no ato de assinar, não some calada.
"""
assinados = {
a.get('signed_by')
for a in _normalizar_assinatura_info(materia.assinatura_info)}
pendentes = []
for autoria in materia.autoria_set.select_related('autor'):
titular = resolver_titular(autoria.autor)
if titular is None or titular.username not in assinados:
pendentes.append(autoria.autor_id)
return pendentes
def serializar_pendencia(alvo, request): def serializar_pendencia(alvo, request):

45
sapl/materia/forms.py

@ -1184,28 +1184,35 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet):
) )
) )
def _autores_do_filtro(self):
"""Autor do próprio filtro (`?autoria__autor=`), quando houver.
É o que torna o "pendente" da pesquisa igual ao do badge do menu: os
dois links que o SAPL gera trazem o autor junto, e é dele a pendência.
Sem autor no querystring não a quem atribuí-la, e vale a forma
agregada de `sapl.materia.pendencias`.
"""
from sapl.base.models import Autor
autor_pk = (self.data or {}).get('autoria__autor')
if not autor_pk:
return []
try:
return list(Autor.objects.filter(pk=int(autor_pk)))
except (TypeError, ValueError):
return []
def filter_status_assinatura(self, queryset, name, value): def filter_status_assinatura(self, queryset, name, value):
# Pendência é POR AUTOR (`sapl.materia.pendencias`). A versão anterior
# olhava só `pdf_assinado`: a matéria que um coautor já tinha assinado
# sumia de "pendente" para o outro, que ainda precisava assinar.
from sapl.materia.pendencias import filtrar_assinadas, filtrar_pendentes
autores = self._autores_do_filtro()
if value == 'pendente': if value == 'pendente':
# Tem texto original mas não tem PDF assinado return filtrar_pendentes(queryset, autores=autores)
return queryset.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
pdf_assinado__isnull=True
) | queryset.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
pdf_assinado=''
)
elif value == 'assinada': elif value == 'assinada':
return queryset.exclude( return filtrar_assinadas(queryset, autores=autores)
pdf_assinado__isnull=True
).exclude(
pdf_assinado=''
)
return queryset return queryset
@property @property

22
sapl/materia/management/commands/notificar_pendentes_assinatura.py

@ -2,7 +2,8 @@
Management command: notificar_pendentes_assinatura Management command: notificar_pendentes_assinatura
Envia e-mail diario a cada autor que possui materias com assinatura digital Envia e-mail diario a cada autor que possui materias com assinatura digital
pendente (texto_original preenchido, pdf_assinado vazio). pendente. Pendencia e POR AUTOR (sapl.materia.pendencias): a materia que um
coautor ja assinou continua pendente para os demais.
Uso: Uso:
python manage.py notificar_pendentes_assinatura python manage.py notificar_pendentes_assinatura
@ -43,12 +44,12 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
# Imports aqui dentro para evitar problemas no bootstrap do Django # Imports aqui dentro para evitar problemas no bootstrap do Django
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.db.models import Q
from django.template import loader from django.template import loader
from django.urls import reverse from django.urls import reverse
from sapl.base.models import CasaLegislativa, OperadorAutor from sapl.base.models import CasaLegislativa, OperadorAutor
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import MateriaLegislativa
from sapl.materia.pendencias import filtrar_pendentes
from sapl.settings import EMAIL_SEND_USER from sapl.settings import EMAIL_SEND_USER
from sapl.utils import mail_service_configured from sapl.utils import mail_service_configured
@ -70,14 +71,9 @@ class Command(BaseCommand):
casa_nome = '{} de {} - {}'.format(casa.nome, casa.municipio, casa.uf) casa_nome = '{} de {} - {}'.format(casa.nome, casa.municipio, casa.uf)
base_url = 'https://{}'.format(casa.endereco_web) if getattr(casa, 'endereco_web', None) else '' base_url = 'https://{}'.format(casa.endereco_web) if getattr(casa, 'endereco_web', None) else ''
# Base queryset: materias pendentes de assinatura # Base: todas as materias. O recorte de pendencia e feito por autor,
qs_pendentes = MateriaLegislativa.objects.filter( # dentro do laco — cobrar do vereador so o que falta a assinatura DELE.
texto_original__isnull=False, qs_base = MateriaLegislativa.objects.select_related('tipo')
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).select_related('tipo').order_by('-data_apresentacao', '-id')
# Apenas OperadorAutores com e-mail cadastrado # Apenas OperadorAutores com e-mail cadastrado
operadores = ( operadores = (
@ -110,8 +106,10 @@ class Command(BaseCommand):
if not email: if not email:
continue continue
# Materias pendentes deste autor # Materias pendentes deste autor (regra canonica por autor)
materias_qs = qs_pendentes.filter(autoria__autor=autor).distinct() materias_qs = filtrar_pendentes(
qs_base, autores=[autor]
).order_by('-data_apresentacao', '-id')
total = materias_qs.count() total = materias_qs.count()
if total == 0: if total == 0:

281
sapl/materia/pendencias.py

@ -0,0 +1,281 @@
"""
Regra canônica de pendência de assinatura uma , para todas as telas.
Pendência é **por autor**, não por documento. Uma matéria com dois autores em
que o primeiro assinou continua pendente para o segundo: o `pdf_assinado`
está preenchido, mas falta a assinatura dele.
Era exatamente isso que quebrava. O SAPL web perguntava "o documento tem PDF
assinado?" em quatro lugares diferentes (contador do menu, tela de pendentes,
filtro da pesquisa, e-mail diário) e, na primeira assinatura, a matéria sumia
para os demais coautores. O hub (`integracao_hub`) fazia certo, por autor
ou seja, o app do AMU mostrava a pendência que o SAPL escondia.
Este módulo é a fonte única dessa regra. O `integracao_hub.serializacao`
reexporta daqui em vez de manter a segunda cópia.
pendente(autor, matéria) autor autoria(matéria)
titular(autor) assinatura_info.signed_by
Sem autor no contexto (pesquisa livre com "Status de Assinatura: pendente") não
a quem atribuir a pendência. vale a forma agregada "ainda falta
assinatura de autor" — comparando quantas assinaturas o PDF tem com quantos
autores a matéria tem. Nos casos normais as duas formas coincidem; a agregada é
a aproximação assumida, e continua sendo estritamente melhor que a antiga
("nenhuma assinatura ainda").
"""
import logging
logger = logging.getLogger(__name__)
# =============================================================================
# Titular do autor
# =============================================================================
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.
"""
from sapl.parlamentares.models import Parlamentar
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_do_usuario(user):
"""Autores que este usuário opera (lista — um assessor pode operar vários).
O código antigo usava `OperadorAutor.objects.get(user=...)`, que estoura
MultipleObjectsReturned justamente para o assessor de mais de um vereador
e, como a chamada estava dentro de um `except Exception`, o contador zerava
em silêncio em vez de somar os dois.
"""
from sapl.base.models import OperadorAutor
if user is None or not getattr(user, 'is_authenticated', False):
return []
return [op.autor for op in OperadorAutor.objects
.select_related('autor').filter(user=user).order_by('id')]
# =============================================================================
# Pendência por autor
# =============================================================================
def autores_pendentes(materia):
"""IDs dos autores que ainda não assinaram esta matéria (regra canônica).
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.
"""
from sapl.materia.views_assinatura import _normalizar_assinatura_info
assinados = {
a.get('signed_by')
for a in _normalizar_assinatura_info(materia.assinatura_info)}
pendentes = []
for autoria in materia.autoria_set.select_related('autor'):
titular = resolver_titular(autoria.autor)
if titular is None or titular.username not in assinados:
pendentes.append(autoria.autor_id)
return pendentes
def _q_assinada_por(username):
"""Q que casa matéria já assinada por `username` no `assinatura_info`.
Duas formas porque o campo tem dois formatos no banco: a lista de hoje e o
dict único do formato legado (o mesmo motivo de `_normalizar_assinatura_info`
existir). Em jsonb, `@> '[{...}]'` casa a lista e `@> '{...}'` casa o dict
o OR cobre os dois sem migração de dados.
"""
from django.db.models import Q
return (Q(assinatura_info__contains=[{'signed_by': username}]) |
Q(assinatura_info__contains={'signed_by': username}))
def _com_texto_original(qs):
"""Só faz sentido cobrar assinatura de matéria que tem texto para assinar."""
return qs.filter(texto_original__isnull=False).exclude(texto_original='')
def _q_pendente_para_autor(autor):
from django.db.models import Q
condicao = Q(autoria__autor=autor)
titular = resolver_titular(autor)
if titular is not None:
condicao &= ~_q_assinada_por(titular.username)
else:
# Titular indeterminável: não dá para afirmar que assinou. Fica pendente.
logger.info(
f'[pendencias] Titular indeterminavel para o autor {autor.pk} '
f'({autor.nome}): as materias dele seguem pendentes.'
)
return condicao
def _anotar_contagens(qs):
"""Anota nº de assinaturas no PDF e nº de autores da matéria.
O de assinaturas sai do próprio `assinatura_info` em SQL (jsonb), com
guarda de tipo: `jsonb_array_length` estoura em dict legado e em NULL, e
esses dois casos valem 1 e 0 respectivamente.
"""
from django.db.models import Count, IntegerField, OuterRef, Subquery
from django.db.models.expressions import RawSQL
from django.db.models.functions import Coalesce
from sapl.materia.models import Autoria, MateriaLegislativa
tabela = MateriaLegislativa._meta.db_table
n_assinaturas = RawSQL(
'CASE '
'WHEN "{t}"."assinatura_info" IS NULL THEN 0 '
'WHEN jsonb_typeof("{t}"."assinatura_info") = \'array\' '
'THEN jsonb_array_length("{t}"."assinatura_info") '
'ELSE 1 END'.format(t=tabela),
[],
output_field=IntegerField(),
)
n_autores = (Autoria.objects
.filter(materia=OuterRef('pk'))
.order_by()
.values('materia')
.annotate(c=Count('autor_id', distinct=True))
.values('c'))
return qs.annotate(
_n_assinaturas=n_assinaturas,
_n_autores=Coalesce(
Subquery(n_autores, output_field=IntegerField()), 0),
)
def _q_agregada_pendente():
"""`assinaturas < max(autores, 1)` — a forma sem autor no contexto.
O `max(..., 1)` preserva o comportamento antigo para matéria sem autoria
cadastrada: sem nenhuma assinatura, ela continua aparecendo como pendente
em vez de sumir da pesquisa.
"""
from django.db.models import F, IntegerField, Q, Value
from django.db.models.functions import Greatest
return Q(_n_assinaturas__lt=Greatest(
F('_n_autores'), Value(1), output_field=IntegerField()))
def filtrar_pendentes(qs, autores=None):
"""Matérias pendentes de assinatura.
autores : lista de Autor (ou None). Com autores, aplica a regra canônica
por autor; sem eles, a forma agregada.
"""
from django.db.models import Q
qs = _com_texto_original(qs)
if autores:
condicao = Q()
for autor in autores:
condicao |= _q_pendente_para_autor(autor)
return qs.filter(condicao).distinct()
return _anotar_contagens(qs).filter(_q_agregada_pendente()).distinct()
def filtrar_assinadas(qs, autores=None):
"""Complemento exato de `filtrar_pendentes` — nada cai entre as duas."""
from django.db.models import Q
if autores:
condicao = Q()
for autor in autores:
condicao |= _q_pendente_para_autor(autor)
return (_com_texto_original(qs)
.filter(autoria__autor__in=autores)
.exclude(condicao)
.distinct())
return (_anotar_contagens(_com_texto_original(qs))
.exclude(_q_agregada_pendente())
.distinct())
def pendentes_para_usuario(qs, user):
"""Pendências deste usuário logado (via os autores que ele opera)."""
autores = autores_do_usuario(user)
if not autores:
return qs.none()
return filtrar_pendentes(qs, autores=autores)
# =============================================================================
# Cache do contador do menu
# =============================================================================
CACHE_KEY = 'pendencias_assinatura_user_{}'
CACHE_TTL = 120
def invalidar_cache_pendencias(materia=None, user=None):
"""Zera o contador de todo mundo afetado por uma assinatura.
limpar a chave de quem assinou não bastava: a assinatura de um autor
muda a contagem dos OUTROS coautores (a matéria deixa de ser pendente para
ele e continua para eles), e eles ficavam com o número velho até o TTL.
"""
from django.core.cache import cache
from sapl.base.models import OperadorAutor
user_ids = set()
if user is not None and getattr(user, 'pk', None):
user_ids.add(user.pk)
if materia is not None:
try:
autor_ids = list(materia.autoria_set.values_list('autor_id', flat=True))
user_ids.update(
OperadorAutor.objects.filter(autor_id__in=autor_ids)
.values_list('user_id', flat=True))
except Exception as exc: # cache é acessório: nunca derruba a assinatura
logger.warning(f'[pendencias] Falha ao invalidar cache: {exc}')
if user_ids:
cache.delete_many([CACHE_KEY.format(pk) for pk in user_ids])

261
sapl/materia/tests/test_pendencias_assinatura.py

@ -0,0 +1,261 @@
"""
Testes das duas regressões de assinatura corrigidas juntas:
1. A 2ª assinatura via microserviço precisa reenviar o `codigo_autenticacao`
emitido na 1ª (o hash não é recalculável a partir do PDF assinado). O
parâmetro existia em `_assinar_pdf_com_pagina_auth` mas nenhuma view o
passava, e o microserviço respondia 400.
2. Pendência é POR AUTOR: a matéria que um coautor assinou continua pendente
para os demais. O SAPL web olhava `pdf_assinado` e a escondia de todo mundo
na primeira assinatura.
"""
import pytest
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import baker
from sapl.base.models import Autor, OperadorAutor
from sapl.materia.models import Autoria, MateriaLegislativa
from sapl.parlamentares.models import Parlamentar, Votante
PDF = b'%PDF-1.4 documento'
def _autor_parlamentar(nome, username):
"""Autor com titular resolvível por Votante — o vínculo real do SAPL."""
user = baker.make('auth.User', username=username)
parlamentar = baker.make(Parlamentar, nome_parlamentar=nome, ativo=True)
baker.make(Votante, parlamentar=parlamentar, user=user)
autor = baker.make(
Autor, nome=nome,
content_type=ContentType.objects.get_for_model(Parlamentar),
object_id=parlamentar.pk)
baker.make(OperadorAutor, autor=autor, user=user)
return autor, user
@pytest.fixture()
def materia_coautoria(db):
"""Matéria de dois autores, assinada só pelo primeiro."""
materia = baker.make(
MateriaLegislativa,
numero=820, ano=2026,
texto_original=SimpleUploadedFile('texto.pdf', PDF),
)
autor_a, user_a = _autor_parlamentar('KINHO ANDRADE', 'kinho')
autor_b, user_b = _autor_parlamentar('ERIC VALINI', 'eric')
baker.make(Autoria, materia=materia, autor=autor_a, primeiro_autor=True)
baker.make(Autoria, materia=materia, autor=autor_b)
materia.pdf_assinado.save('assinado.pdf', ContentFile(PDF), save=False)
materia.assinatura_info = [{'signed_by': 'kinho', 'nome_assinante': 'KINHO'}]
materia.codigo_autenticacao = 'D6BF54DFA21C2CD2'
materia.save()
return materia, (autor_a, user_a), (autor_b, user_b)
# =============================================================================
# 1. codigo_autenticacao na 2ª assinatura
# =============================================================================
def test_segunda_assinatura_reenvia_codigo_autenticacao(
materia_coautoria, client, monkeypatch):
"""Sem o código, o microserviço recusa com 400: ele não recalcula o hash."""
from sapl.materia import assinatura_api_client, views_assinatura
materia, _, (_, user_b) = materia_coautoria
enviado = {}
class _Resultado:
pdf = b'%PDF-1.4 assinado-2x'
codigo_autenticacao = 'D6BF54DFA21C2CD2'
signature_index = 1
auth_page_aplicada = False
auth_page_suportado = True
def _fake_assinar(pdf_bytes, **kwargs):
enviado.update(kwargs)
return _Resultado()
monkeypatch.setattr(views_assinatura, '_usar_api_externa', lambda: True)
monkeypatch.setattr(views_assinatura, '_metadados_certificado_via_api',
lambda *a, **k: {})
monkeypatch.setattr(views_assinatura, '_ler_brasao', lambda: None)
monkeypatch.setattr(assinatura_api_client,
'assinar_pdf_com_pagina_autenticacao', _fake_assinar)
client.force_login(user_b)
resposta = client.post(
f'/materia/{materia.pk}/assinar/a1/',
{'certificado': SimpleUploadedFile('cert.pfx', b'pfx'), 'senha': 'x'},
)
assert resposta.status_code == 200, resposta.content
assert enviado['codigo_autenticacao'] == 'D6BF54DFA21C2CD2'
def test_primeira_assinatura_nao_manda_codigo(db, client, monkeypatch):
"""Na 1ª o microserviço deriva o código do próprio PDF — mandar seria errado."""
from sapl.materia import assinatura_api_client, views_assinatura
materia = baker.make(
MateriaLegislativa, numero=821, ano=2026,
texto_original=SimpleUploadedFile('texto.pdf', PDF))
_, user = _autor_parlamentar('KINHO ANDRADE', 'kinho1')
enviado = {}
class _Resultado:
pdf = b'%PDF-1.4 assinado'
codigo_autenticacao = 'NOVOCODIGO123456'
signature_index = 0
auth_page_aplicada = True
auth_page_suportado = True
monkeypatch.setattr(views_assinatura, '_usar_api_externa', lambda: True)
monkeypatch.setattr(views_assinatura, '_metadados_certificado_via_api',
lambda *a, **k: {})
monkeypatch.setattr(views_assinatura, '_ler_brasao', lambda: None)
monkeypatch.setattr(views_assinatura, '_gerar_pdf_da_materia',
lambda *a, **k: (PDF, None))
monkeypatch.setattr(
assinatura_api_client, 'assinar_pdf_com_pagina_autenticacao',
lambda pdf_bytes, **kw: (enviado.update(kw), _Resultado())[1])
client.force_login(user)
resposta = client.post(
f'/materia/{materia.pk}/assinar/a1/',
{'certificado': SimpleUploadedFile('cert.pfx', b'pfx'), 'senha': 'x'})
assert resposta.status_code == 200, resposta.content
assert enviado['codigo_autenticacao'] is None
materia.refresh_from_db()
assert materia.codigo_autenticacao == 'NOVOCODIGO123456'
# =============================================================================
# 2. Pendência por autor
# =============================================================================
def test_coautor_que_nao_assinou_segue_pendente(materia_coautoria):
from sapl.materia.pendencias import autores_pendentes, filtrar_pendentes
materia, (autor_a, _), (autor_b, _) = materia_coautoria
qs = MateriaLegislativa.objects.all()
assert autores_pendentes(materia) == [autor_b.pk]
assert filtrar_pendentes(qs, autores=[autor_b]).filter(pk=materia.pk).exists()
assert not filtrar_pendentes(qs, autores=[autor_a]).filter(pk=materia.pk).exists()
def test_pendente_e_assinada_sao_complementares(materia_coautoria):
from sapl.materia.pendencias import filtrar_assinadas, filtrar_pendentes
materia, (autor_a, _), (autor_b, _) = materia_coautoria
qs = MateriaLegislativa.objects.all()
assert filtrar_assinadas(qs, autores=[autor_a]).filter(pk=materia.pk).exists()
assert not filtrar_assinadas(qs, autores=[autor_b]).filter(pk=materia.pk).exists()
def test_badge_do_coautor_nao_zera_apos_primeira_assinatura(materia_coautoria):
from django.core.cache import cache
from django.test import RequestFactory
from sapl.context_processors import pendencias_assinatura
materia, (_, user_a), (_, user_b) = materia_coautoria
cache.clear()
def _total(user):
cache.clear()
pedido = RequestFactory().get('/')
pedido.user = user
return pendencias_assinatura(pedido)['pendencias_assinatura_total']
assert _total(user_b) == 1 # Eric ainda deve assinar
assert _total(user_a) == 0 # Kinho já assinou
def test_filtro_da_pesquisa_acompanha_o_badge(materia_coautoria):
"""Badge e resultado da pesquisa precisam contar a mesma coisa."""
from sapl.materia.forms import MateriaLegislativaFilterSet
materia, (autor_a, _), (autor_b, _) = materia_coautoria
qs = MateriaLegislativa.objects.all()
def _pks(autor_pk, status):
fs = MateriaLegislativaFilterSet(
data={'autoria__autor': str(autor_pk), 'status_assinatura': status},
queryset=qs)
return list(fs.qs.values_list('pk', flat=True))
assert materia.pk in _pks(autor_b.pk, 'pendente')
assert materia.pk not in _pks(autor_a.pk, 'pendente')
assert materia.pk in _pks(autor_a.pk, 'assinada')
def test_forma_agregada_sem_autor_no_contexto(materia_coautoria):
"""Pesquisa livre: ainda falta assinatura de autor → pendente."""
from sapl.materia.pendencias import filtrar_assinadas, filtrar_pendentes
materia, _, _ = materia_coautoria
qs = MateriaLegislativa.objects.all()
assert filtrar_pendentes(qs).filter(pk=materia.pk).exists()
assert not filtrar_assinadas(qs).filter(pk=materia.pk).exists()
def test_materia_sem_autoria_e_sem_assinatura_continua_pendente(db):
"""Comportamento antigo preservado: `max(autores, 1)` na forma agregada."""
from sapl.materia.pendencias import filtrar_pendentes
materia = baker.make(
MateriaLegislativa, numero=999, ano=2026,
texto_original=SimpleUploadedFile('texto.pdf', PDF))
assert filtrar_pendentes(
MateriaLegislativa.objects.all()).filter(pk=materia.pk).exists()
def test_titular_indeterminavel_conta_como_pendente(db):
"""Sem como confirmar que assinou, a matéria fica visível — não some calada."""
from sapl.materia.pendencias import filtrar_pendentes, resolver_titular
materia = baker.make(
MateriaLegislativa, numero=998, ano=2026,
texto_original=SimpleUploadedFile('texto.pdf', PDF))
parlamentar = baker.make(Parlamentar, nome_parlamentar='AMBIGUO', ativo=True)
baker.make(Votante, parlamentar=parlamentar,
user=baker.make('auth.User', username='v1'))
baker.make(Votante, parlamentar=parlamentar,
user=baker.make('auth.User', username='v2'))
autor = baker.make(
Autor, nome='AMBIGUO',
content_type=ContentType.objects.get_for_model(Parlamentar),
object_id=parlamentar.pk)
baker.make(Autoria, materia=materia, autor=autor)
materia.assinatura_info = [{'signed_by': 'v1'}]
materia.save()
assert resolver_titular(autor) is None
assert filtrar_pendentes(
MateriaLegislativa.objects.all(),
autores=[autor]).filter(pk=materia.pk).exists()
def test_assinatura_info_no_formato_legado_dict(db):
"""O campo tem dois formatos no banco; a regra precisa ler os dois."""
from sapl.materia.pendencias import filtrar_pendentes
materia = baker.make(
MateriaLegislativa, numero=997, ano=2026,
texto_original=SimpleUploadedFile('texto.pdf', PDF))
autor, _ = _autor_parlamentar('LEGADO', 'legado')
baker.make(Autoria, materia=materia, autor=autor)
materia.assinatura_info = {'signed_by': 'legado'} # dict, não lista
materia.save()
assert not filtrar_pendentes(
MateriaLegislativa.objects.all(),
autores=[autor]).filter(pk=materia.pk).exists()

51
sapl/materia/views.py

@ -648,24 +648,25 @@ class MateriasPendentesAssinaturaView(LoginRequiredMixin, ListView):
paginate_by = 20 paginate_by = 20
login_url = '/login/' login_url = '/login/'
def get_autores(self):
from sapl.materia.pendencias import autores_do_usuario
return autores_do_usuario(self.request.user)
def get_autor(self): def get_autor(self):
try: # Um só autor no contexto da tela (título, link de pesquisa). O
return OperadorAutor.objects.get(user=self.request.user).autor # assessor de mais de um vereador vê a lista somada e refina no filtro.
except OperadorAutor.DoesNotExist: autores = self.get_autores()
return None return autores[0] if autores else None
def get_queryset(self): def get_queryset(self):
autor = self.get_autor() from sapl.materia.pendencias import filtrar_pendentes
qs = MateriaLegislativa.objects.filter( # Pendência é POR AUTOR (`sapl.materia.pendencias`): a matéria que um
texto_original__isnull=False # coautor já assinou continua pendente para os outros. O filtro antigo
).exclude( # era por documento (`pdf_assinado` vazio) e a fazia sumir da lista de
texto_original='' # todo mundo assim que a primeira assinatura entrava.
).filter( qs = filtrar_pendentes(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='') MateriaLegislativa.objects.all(), autores=self.get_autores())
) return qs.order_by('-data_apresentacao', '-id')
if autor:
qs = qs.filter(autoria__autor=autor)
return qs.order_by('-data_apresentacao', '-id').distinct()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -2158,9 +2159,13 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
if pode_assinar_lote: if pode_assinar_lote:
materia_pk = self.kwargs.get('pk') or self.kwargs.get('root_pk') materia_pk = self.kwargs.get('pk') or self.kwargs.get('root_pk')
# Doc acessório não tem autoria, então a pendência dele é por
# documento mesmo. Só o `pdf_assinado=''` deixava passar os
# registros com NULL (todos os anteriores ao campo existir).
qs_pendentes = DocumentoAcessorio.objects.filter( qs_pendentes = DocumentoAcessorio.objects.filter(
materia__pk=materia_pk, materia__pk=materia_pk,
pdf_assinado='', ).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).order_by('data', 'nome') ).order_by('data', 'nome')
docs_lote = [ docs_lote = [
{'id': d.pk, 'descricao': f'{d.nome} ({d.tipo}) — {d.data}'} {'id': d.pk, 'descricao': f'{d.nome} ({d.tipo}) — {d.data}'}
@ -2950,18 +2955,20 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
# Matérias pendentes de assinatura para o botão de lote # Matérias pendentes de assinatura para o botão de lote
status_assinatura = self.request.GET.get('status_assinatura') status_assinatura = self.request.GET.get('status_assinatura')
if status_assinatura == 'pendente' and context['show_results']: if status_assinatura == 'pendente' and context['show_results']:
from django.db.models import Q as _Q
# object_list já foi filtrado pelo filter_status_assinatura — # object_list já foi filtrado pelo filter_status_assinatura —
# precisamos obter os IDs primeiro para evitar problemas com # precisamos obter os IDs primeiro para evitar problemas com
# querysets compostos por union (|) que não suportam .filter() extra # querysets compostos por union (|) que não suportam .filter() extra
try: try:
ids_lote = list(self.object_list.values_list('id', flat=True)[:200]) ids_lote = list(self.object_list.values_list('id', flat=True)[:200])
from .models import MateriaLegislativa from .models import MateriaLegislativa
qs_lote = MateriaLegislativa.objects.filter( from .pendencias import autores_do_usuario, filtrar_pendentes
pk__in=ids_lote, # O lote oferece o que FALTA ASSINAR PARA QUEM ESTÁ NA TELA:
texto_original__isnull=False, # com autor conhecido, a regra por autor; sem ele, a agregada.
).exclude(texto_original='').filter( # Filtrar por `pdf_assinado` vazio escondia do coautor exatamente
_Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='') # as matérias que ele ainda precisa assinar.
qs_lote = filtrar_pendentes(
MateriaLegislativa.objects.filter(pk__in=ids_lote),
autores=autores_do_usuario(self.request.user),
).select_related('tipo').values_list( ).select_related('tipo').values_list(
'id', 'tipo__sigla', 'numero', 'ano' 'id', 'tipo__sigla', 'numero', 'ano'
) )

25
sapl/materia/views_assinatura.py

@ -545,7 +545,9 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante name=nome_assinante
) )
hash_doc = '' # será preenchido pelo chamador se necessário # `hash_doc` vem do chamador (codigo_autenticacao gravado na 1ª
# assinatura). Zerá-lo aqui — como se fazia — apagava o "Hash:"
# do carimbo justamente na assinatura em que ele já existe.
stamp_style = _criar_stamp_style(nome_assinante, cargo, hash_doc) stamp_style = _criar_stamp_style(nome_assinante, cargo, hash_doc)
pdf_signer = PdfSigner(meta, signer=signer, stamp_style=stamp_style) pdf_signer = PdfSigner(meta, signer=signer, stamp_style=stamp_style)
pdf_signer.sign_pdf( pdf_signer.sign_pdf(
@ -1226,6 +1228,7 @@ def materia_assinar_a1(request, pk):
senha=senha, senha=senha,
tipo_cert_input='a1', tipo_cert_input='a1',
posicao_custom=posicao_custom, posicao_custom=posicao_custom,
hash_doc=materia.codigo_autenticacao or '',
) )
except ImportError: except ImportError:
logger.error("pyhanko não está instalado") logger.error("pyhanko não está instalado")
@ -1257,8 +1260,11 @@ def materia_assinar_a1(request, pk):
materia.assinado_por = request.user materia.assinado_por = request.user
materia.save() materia.save()
from django.core.cache import cache as _cache # Invalida o badge de TODOS os coautores, não só de quem assinou: esta
_cache.delete(f'pendencias_assinatura_user_{request.user.pk}') # assinatura muda a contagem deles também (a matéria some da minha lista e
# continua na deles) e eles ficariam com o número velho até o TTL.
from sapl.materia.pendencias import invalidar_cache_pendencias
invalidar_cache_pendencias(materia=materia, user=request.user)
logger.info( logger.info(
f"Matéria {materia.pk} assinada por {request.user.username} " f"Matéria {materia.pk} assinada por {request.user.username} "
@ -1835,6 +1841,7 @@ def docacessorio_assinar_a1(request, pk):
senha=senha, senha=senha,
tipo_cert_input='a1', tipo_cert_input='a1',
posicao_custom=posicao_custom, posicao_custom=posicao_custom,
hash_doc=docacessorio.codigo_autenticacao or '',
) )
except ImportError: except ImportError:
logger.error("pyhanko não está instalado") logger.error("pyhanko não está instalado")
@ -2414,6 +2421,7 @@ def materia_assinar_lote(request):
certificado_bytes=cert_bytes, certificado_bytes=cert_bytes,
senha=senha, senha=senha,
tipo_cert_input='a1', tipo_cert_input='a1',
hash_doc=materia.codigo_autenticacao or '',
) )
filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
@ -2439,10 +2447,14 @@ def materia_assinar_lote(request):
resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)})
erro_count += 1 erro_count += 1
# Invalida cache de pendências uma vez ao final do lote # Invalida cache de pendências uma vez ao final do lote — de quem assinou
# e dos coautores de cada matéria assinada.
if sucesso_count > 0: if sucesso_count > 0:
from django.core.cache import cache as _cache from sapl.materia.pendencias import invalidar_cache_pendencias
_cache.delete(f'pendencias_assinatura_user_{request.user.pk}') for r in resultados:
if r.get('success'):
invalidar_cache_pendencias(materia=materias_map.get(r['pk']))
invalidar_cache_pendencias(user=request.user)
return JsonResponse({ return JsonResponse({
'success': True, 'success': True,
@ -2740,6 +2752,7 @@ def docacessorio_assinar_lote(request):
certificado_bytes=cert_bytes, certificado_bytes=cert_bytes,
senha=senha, senha=senha,
tipo_cert_input='a1', tipo_cert_input='a1',
hash_doc=doc.codigo_autenticacao or '',
) )
filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf" filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf"

Loading…
Cancel
Save