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': ''}
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)
if cached is None:
try:
from django.db.models import Q
from django.urls import reverse
from sapl.materia.models import MateriaLegislativa
from sapl.base.models import OperadorAutor
# Busca o Autor vinculado ao usuário
try:
autor = OperadorAutor.objects.get(user=request.user).autor
autor_pk = autor.pk
except OperadorAutor.DoesNotExist:
autor_pk = None
if autor_pk:
total = MateriaLegislativa.objects.filter(
autoria__autor_id=autor_pk,
texto_original__isnull=False,
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).distinct().count()
from sapl.materia.pendencias import (
autores_do_usuario, filtrar_pendentes)
# Pendência é POR AUTOR: a matéria que um coautor já assinou segue
# pendente para os demais. Contar `pdf_assinado` vazio — como se
# fazia aqui — zerava o badge do coautor na primeira assinatura.
autores = autores_do_usuario(request.user)
if autores:
total = filtrar_pendentes(
MateriaLegislativa.objects.all(), autores=autores).count()
# A URL leva ao mesmo recorte: um autor por vez na pesquisa, o
# primeiro deles (o assessor de dois vereadores vê o total no
# badge e refina na tela).
url = (
reverse('sapl.materia:pesquisar_materia')
+ f'?autoria__autor={autor_pk}&status_assinatura=pendente'
+ f'?autoria__autor={autores[0].pk}&status_assinatura=pendente'
)
else:
total = 0
url = ''
except Exception:
logging.getLogger(__name__).exception(
'Falha ao calcular pendências de assinatura')
total = 0
url = ''
cached = {'total': total, 'url': url}
cache.set(cache_key, cached, 120) # cache de 2 minutos
cache.set(cache_key, cached, CACHE_TTL)
return {
'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}))
def _operadores_do_autor(autor):
return list(autor.operadorautor_set.select_related('user').order_by('id'))
def resolver_titular(autor):
"""User do VEREADOR TITULAR do autor, ou None se indeterminável.
Assinatura é ato pessoal e indelegável: o certificado ICP é do vereador, e
o assessor NUNCA assina no lugar dele opera (rastro operacional à
parte). A identidade jurídica do signatário é sempre o titular.
O vínculo estrutural parlamentaruser é o **Votante** do Parlamentar que o
Autor representa (content_type=parlamentar). No dado real de Franco, o autor
CESINHA tem operadores {cesinha, Juciana} mas votante {cesinha}: o votante
isola o titular da assessora casar username com o nome do autor seria
coincidência frágil, o Votante é o vínculo confiável.
Regras:
- parlamentar com exatamente 1 votante titular (o caso normal);
- parlamentar sem votante cadastrado cai no operador único; se houver
mais de um operador e nenhum votante, o titular é INDETERMINÁVEL (None)
o chamador falha visível, melhor que atribuir a autoria ao assessor;
- parlamentar com >1 votante ambíguo None;
- autor não-parlamentar (órgão, comissão) sem conceito de votante: o
operador único resolve, senão None.
"""
related = autor.autor_related
if isinstance(related, Parlamentar):
votantes = {v.user_id: v.user
for v in related.votante_set.select_related('user')}
if len(votantes) == 1:
return next(iter(votantes.values()))
if len(votantes) > 1:
return None # titular ambíguo — não adivinha
# sem votante: só resolve se houver um operador único
operadores = _operadores_do_autor(autor)
if len(operadores) == 1:
return operadores[0].user
return None
def _autores_pendentes(materia):
"""Pendência é POR AUTOR (refinamento §2), derivada — não é tabela.
pendente(autor, matéria) = autor autoria titular(autor)
assinatura_info.signed_by. O titular é o vereador (via Votante), não um
operador qualquer: é a assinatura DELE que fecha a pendência. Titular
indeterminável conta como pendente (não para confirmar que assinou) a
matéria fica visível e o erro aparece no ato de assinar, não some calada.
"""
assinados = {
a.get('signed_by')
for a in _normalizar_assinatura_info(materia.assinatura_info)}
pendentes = []
for autoria in materia.autoria_set.select_related('autor'):
titular = resolver_titular(autoria.autor)
if titular is None or titular.username not in assinados:
pendentes.append(autoria.autor_id)
return pendentes
# A regra de pendência mora em `sapl.materia.pendencias` — fonte única, também
# 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
# coautor que a tela do SAPL escondia. Reexportado com os nomes antigos para não
# mexer em quem já importa daqui.
from sapl.materia.pendencias import ( # noqa: E402 (reexport)
_operadores_do_autor, autores_pendentes as _autores_pendentes,
resolver_titular)
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):
# 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':
# Tem texto original mas não tem PDF assinado
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=''
)
return filtrar_pendentes(queryset, autores=autores)
elif value == 'assinada':
return queryset.exclude(
pdf_assinado__isnull=True
).exclude(
pdf_assinado=''
)
return filtrar_assinadas(queryset, autores=autores)
return queryset
@property

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

@ -2,7 +2,8 @@
Management command: notificar_pendentes_assinatura
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:
python manage.py notificar_pendentes_assinatura
@ -43,12 +44,12 @@ class Command(BaseCommand):
def handle(self, *args, **options):
# Imports aqui dentro para evitar problemas no bootstrap do Django
from django.core.mail import EmailMultiAlternatives, get_connection
from django.db.models import Q
from django.template import loader
from django.urls import reverse
from sapl.base.models import CasaLegislativa, OperadorAutor
from sapl.materia.models import MateriaLegislativa
from sapl.materia.pendencias import filtrar_pendentes
from sapl.settings import EMAIL_SEND_USER
from sapl.utils import mail_service_configured
@ -70,14 +71,9 @@ class Command(BaseCommand):
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 queryset: materias pendentes de assinatura
qs_pendentes = MateriaLegislativa.objects.filter(
texto_original__isnull=False,
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).select_related('tipo').order_by('-data_apresentacao', '-id')
# Base: todas as materias. O recorte de pendencia e feito por autor,
# dentro do laco — cobrar do vereador so o que falta a assinatura DELE.
qs_base = MateriaLegislativa.objects.select_related('tipo')
# Apenas OperadorAutores com e-mail cadastrado
operadores = (
@ -110,8 +106,10 @@ class Command(BaseCommand):
if not email:
continue
# Materias pendentes deste autor
materias_qs = qs_pendentes.filter(autoria__autor=autor).distinct()
# Materias pendentes deste autor (regra canonica por autor)
materias_qs = filtrar_pendentes(
qs_base, autores=[autor]
).order_by('-data_apresentacao', '-id')
total = materias_qs.count()
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
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):
try:
return OperadorAutor.objects.get(user=self.request.user).autor
except OperadorAutor.DoesNotExist:
return None
# Um só autor no contexto da tela (título, link de pesquisa). O
# assessor de mais de um vereador vê a lista somada e refina no filtro.
autores = self.get_autores()
return autores[0] if autores else None
def get_queryset(self):
autor = self.get_autor()
qs = MateriaLegislativa.objects.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
)
if autor:
qs = qs.filter(autoria__autor=autor)
return qs.order_by('-data_apresentacao', '-id').distinct()
from sapl.materia.pendencias import filtrar_pendentes
# Pendência é POR AUTOR (`sapl.materia.pendencias`): a matéria que um
# coautor já assinou continua pendente para os outros. O filtro antigo
# era por documento (`pdf_assinado` vazio) e a fazia sumir da lista de
# todo mundo assim que a primeira assinatura entrava.
qs = filtrar_pendentes(
MateriaLegislativa.objects.all(), autores=self.get_autores())
return qs.order_by('-data_apresentacao', '-id')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -2158,9 +2159,13 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
if pode_assinar_lote:
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(
materia__pk=materia_pk,
pdf_assinado='',
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).order_by('data', 'nome')
docs_lote = [
{'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
status_assinatura = self.request.GET.get('status_assinatura')
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 —
# precisamos obter os IDs primeiro para evitar problemas com
# querysets compostos por union (|) que não suportam .filter() extra
try:
ids_lote = list(self.object_list.values_list('id', flat=True)[:200])
from .models import MateriaLegislativa
qs_lote = MateriaLegislativa.objects.filter(
pk__in=ids_lote,
texto_original__isnull=False,
).exclude(texto_original='').filter(
_Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='')
from .pendencias import autores_do_usuario, filtrar_pendentes
# O lote oferece o que FALTA ASSINAR PARA QUEM ESTÁ NA TELA:
# com autor conhecido, a regra por autor; sem ele, a agregada.
# Filtrar por `pdf_assinado` vazio escondia do coautor exatamente
# 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(
'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',
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)
pdf_signer = PdfSigner(meta, signer=signer, stamp_style=stamp_style)
pdf_signer.sign_pdf(
@ -1226,6 +1228,7 @@ def materia_assinar_a1(request, pk):
senha=senha,
tipo_cert_input='a1',
posicao_custom=posicao_custom,
hash_doc=materia.codigo_autenticacao or '',
)
except ImportError:
logger.error("pyhanko não está instalado")
@ -1257,8 +1260,11 @@ def materia_assinar_a1(request, pk):
materia.assinado_por = request.user
materia.save()
from django.core.cache import cache as _cache
_cache.delete(f'pendencias_assinatura_user_{request.user.pk}')
# Invalida o badge de TODOS os coautores, não só de quem assinou: esta
# 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(
f"Matéria {materia.pk} assinada por {request.user.username} "
@ -1835,6 +1841,7 @@ def docacessorio_assinar_a1(request, pk):
senha=senha,
tipo_cert_input='a1',
posicao_custom=posicao_custom,
hash_doc=docacessorio.codigo_autenticacao or '',
)
except ImportError:
logger.error("pyhanko não está instalado")
@ -2414,6 +2421,7 @@ def materia_assinar_lote(request):
certificado_bytes=cert_bytes,
senha=senha,
tipo_cert_input='a1',
hash_doc=materia.codigo_autenticacao or '',
)
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)})
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:
from django.core.cache import cache as _cache
_cache.delete(f'pendencias_assinatura_user_{request.user.pk}')
from sapl.materia.pendencias import invalidar_cache_pendencias
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({
'success': True,
@ -2740,6 +2752,7 @@ def docacessorio_assinar_lote(request):
certificado_bytes=cert_bytes,
senha=senha,
tipo_cert_input='a1',
hash_doc=doc.codigo_autenticacao or '',
)
filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf"

Loading…
Cancel
Save