mirror of https://github.com/interlegis/sapl.git
Browse Source
* 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
committed by
GitHub
8 changed files with 656 additions and 140 deletions
@ -0,0 +1,281 @@ |
|||||
|
""" |
||||
|
Regra canônica de pendência de assinatura — uma só, para todas as telas. |
||||
|
|
||||
|
Pendência é **por autor**, não por documento. Uma matéria com dois autores em |
||||
|
que só o primeiro assinou continua pendente para o segundo: o `pdf_assinado` já |
||||
|
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`) já 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 |
||||
|
há a quem atribuir a pendência. Aí 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 — 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. |
||||
|
""" |
||||
|
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 dá 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 nº 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. |
||||
|
|
||||
|
Só 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]) |
||||
@ -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 já 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 já 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() |
||||
Loading…
Reference in new issue