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