From 92fd181a202deaaf88b93f57df7e5a8421188e3c Mon Sep 17 00:00:00 2001 From: Kemuel-sepulvida Date: Sat, 22 Aug 2026 16:35:17 -0300 Subject: [PATCH] =?UTF-8?q?Fix(Assinatura):=202=C2=AA=20assinatura=20reenv?= =?UTF-8?q?ia=20o=20c=C3=B3digo=20emitido,=20e=20pend=C3=AAncia=20passa=20?= =?UTF-8?q?a=20ser=20por=20autor=20AB#1473=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Opus 5 --- sapl/context_processors.py | 43 ++- sapl/integracao_hub/serializacao.py | 68 +---- sapl/materia/forms.py | 45 +-- .../notificar_pendentes_assinatura.py | 22 +- sapl/materia/pendencias.py | 281 ++++++++++++++++++ .../tests/test_pendencias_assinatura.py | 261 ++++++++++++++++ sapl/materia/views.py | 51 ++-- sapl/materia/views_assinatura.py | 25 +- 8 files changed, 656 insertions(+), 140 deletions(-) create mode 100644 sapl/materia/pendencias.py create mode 100644 sapl/materia/tests/test_pendencias_assinatura.py diff --git a/sapl/context_processors.py b/sapl/context_processors.py index b62c56ebc..85ad1aa8d 100644 --- a/sapl/context_processors.py +++ b/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'], diff --git a/sapl/integracao_hub/serializacao.py b/sapl/integracao_hub/serializacao.py index 77b76140c..0e708328d 100644 --- a/sapl/integracao_hub/serializacao.py +++ b/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 — só opera (rastro operacional à - parte). A identidade jurídica do signatário é sempre o titular. - - O vínculo estrutural parlamentar→user é o **Votante** do Parlamentar que o - Autor representa (content_type=parlamentar). No dado real de Franco, o autor - CESINHA tem operadores {cesinha, Juciana} mas votante {cesinha}: o votante - isola o titular da assessora — casar username com o nome do autor seria - coincidência frágil, o Votante é o vínculo confiável. - - Regras: - - parlamentar com exatamente 1 votante → titular (o caso normal); - - parlamentar sem votante cadastrado → cai no operador único; se houver - mais de um operador e nenhum votante, o titular é INDETERMINÁVEL (None) — - o chamador falha visível, melhor que atribuir a autoria ao assessor; - - parlamentar com >1 votante → ambíguo → None; - - autor não-parlamentar (órgão, comissão) → sem conceito de votante: só o - operador único resolve, senão None. - """ - related = autor.autor_related - if isinstance(related, Parlamentar): - votantes = {v.user_id: v.user - for v in related.votante_set.select_related('user')} - if len(votantes) == 1: - return next(iter(votantes.values())) - if len(votantes) > 1: - return None # titular ambíguo — não adivinha - # sem votante: só resolve se houver um operador único - operadores = _operadores_do_autor(autor) - if len(operadores) == 1: - return operadores[0].user - return None - - -def _autores_pendentes(materia): - """Pendência é POR AUTOR (refinamento §2), derivada — não é tabela. - - pendente(autor, matéria) = autor ∈ autoria ∧ titular(autor) ∉ - assinatura_info.signed_by. O titular é o vereador (via Votante), não um - operador qualquer: é a assinatura DELE que fecha a pendência. Titular - indeterminável conta como pendente (não dá para confirmar que assinou) — a - matéria fica visível e o erro aparece no ato de assinar, não some calada. - """ - assinados = { - a.get('signed_by') - for a in _normalizar_assinatura_info(materia.assinatura_info)} - pendentes = [] - for autoria in materia.autoria_set.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): diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index b9a11ac92..cfd561259 100644 --- a/sapl/materia/forms.py +++ b/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 há 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 diff --git a/sapl/materia/management/commands/notificar_pendentes_assinatura.py b/sapl/materia/management/commands/notificar_pendentes_assinatura.py index a95006526..7b368a7a9 100644 --- a/sapl/materia/management/commands/notificar_pendentes_assinatura.py +++ b/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: diff --git a/sapl/materia/pendencias.py b/sapl/materia/pendencias.py new file mode 100644 index 000000000..982e24de9 --- /dev/null +++ b/sapl/materia/pendencias.py @@ -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]) diff --git a/sapl/materia/tests/test_pendencias_assinatura.py b/sapl/materia/tests/test_pendencias_assinatura.py new file mode 100644 index 000000000..1a1ea8042 --- /dev/null +++ b/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 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() diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 9430ca909..2d536c6f2 100644 --- a/sapl/materia/views.py +++ b/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' ) diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index b8dc8394e..0608fb8f1 100644 --- a/sapl/materia/views_assinatura.py +++ b/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"