mirror of https://github.com/interlegis/sapl.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
293 lines
12 KiB
293 lines
12 KiB
import hashlib
|
|
import logging
|
|
import os
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from sapl.base.models import Autor, OperadorAutor
|
|
from sapl.materia.models import MateriaLegislativa
|
|
from sapl.materia.views_assinatura import (
|
|
_construir_url_verificacao_base, _obter_nome_casa_legislativa)
|
|
from sapl.parlamentares.models import Parlamentar, Votante
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _iso(valor):
|
|
if valor is None:
|
|
return None
|
|
if hasattr(valor, 'tzinfo'):
|
|
if timezone.is_aware(valor):
|
|
valor = timezone.localtime(valor)
|
|
return valor.isoformat()
|
|
return valor.isoformat()
|
|
|
|
|
|
def _materia_da_proposicao(proposicao):
|
|
"""A matéria gerada vem da generic FK, não de um campo direto.
|
|
|
|
O `materia_gerada` que aparece no models.py está DENTRO de uma docstring
|
|
(código morto) — o vínculo real é `conteudo_gerado_related`
|
|
(content_type + object_id), que aponta para MateriaLegislativa OU para
|
|
DocumentoAcessorio. Só a matéria interessa ao contrato canônico.
|
|
"""
|
|
conteudo = proposicao.conteudo_gerado_related
|
|
if not isinstance(conteudo, MateriaLegislativa):
|
|
return None
|
|
materia = conteudo
|
|
return {
|
|
'id': materia.pk,
|
|
'tipo': {
|
|
'id': materia.tipo_id,
|
|
'sigla': materia.tipo.sigla if materia.tipo else None,
|
|
'descricao': materia.tipo.descricao if materia.tipo else None,
|
|
},
|
|
'numero': materia.numero,
|
|
'ano': materia.ano,
|
|
'numero_protocolo': materia.numero_protocolo,
|
|
}
|
|
|
|
|
|
def serializar_proposicao(proposicao):
|
|
"""Shape SAPL-nativo (spec, princípio 2): o hub traduz para o canônico."""
|
|
return {
|
|
'id': proposicao.pk,
|
|
'ano': proposicao.ano,
|
|
'numero_proposicao': proposicao.numero_proposicao,
|
|
'tipo': {
|
|
'id': proposicao.tipo_id,
|
|
'descricao': proposicao.tipo.descricao if proposicao.tipo else None,
|
|
},
|
|
'autor': proposicao.autor_id,
|
|
# Nome para exibição no acervo do consumidor (refinamento do histórico §3.2):
|
|
# nunca usado para resolver identidade — contrato §3.2.
|
|
'autor_nome': proposicao.autor.nome if proposicao.autor else None,
|
|
'ementa': proposicao.descricao,
|
|
'rascunho': proposicao.data_envio is None,
|
|
'cancelado': proposicao.cancelado,
|
|
'data_envio': _iso(proposicao.data_envio),
|
|
'data_recebimento': _iso(proposicao.data_recebimento),
|
|
'data_devolucao': _iso(proposicao.data_devolucao),
|
|
'justificativa_devolucao': proposicao.justificativa_devolucao or '',
|
|
'materia': _materia_da_proposicao(proposicao),
|
|
}
|
|
|
|
|
|
def serializar_tramitacao(tramitacao):
|
|
status = tramitacao.status
|
|
return {
|
|
'id': tramitacao.pk,
|
|
'materia': tramitacao.materia_id,
|
|
'data_tramitacao': _iso(tramitacao.data_tramitacao),
|
|
'timestamp': _iso(tramitacao.timestamp),
|
|
'texto': tramitacao.texto or '',
|
|
'urgente': tramitacao.urgente,
|
|
'unidade_destino': tramitacao.unidade_tramitacao_destino_id,
|
|
'status': {
|
|
'id': status.pk,
|
|
'sigla': status.sigla,
|
|
'descricao': status.descricao,
|
|
'indicador': status.indicador,
|
|
} if status else None,
|
|
}
|
|
|
|
|
|
def _sha256_do_arquivo(campo):
|
|
conteudo = campo.read()
|
|
campo.seek(0)
|
|
return hashlib.sha256(conteudo).hexdigest()
|
|
|
|
|
|
def _bloco_documento(campo, request, nome_rota, materia_pk, hash_sha256=None):
|
|
"""Bloco do documento, ou `None` quando o binario nao esta no MEDIA.
|
|
|
|
Referencia no banco sem arquivo em disco (dump restaurado sem a media,
|
|
volume trocado) fazia `.size`/`.read()` levantar OSError e derrubar a
|
|
resposta INTEIRA do poll com 500. O cursor ficava parado no mesmo item e a
|
|
fonte travava para sempre — uma materia podre bloqueando todas as outras.
|
|
Aqui o item continua na lista (o leitor avanca o cursor) e so o documento
|
|
vem nulo, com o buraco gritando no log.
|
|
"""
|
|
try:
|
|
tamanho = campo.size
|
|
digest = hash_sha256 if hash_sha256 is not None else _sha256_do_arquivo(campo)
|
|
except OSError:
|
|
logger.warning(
|
|
'materia %s: pdf ausente no MEDIA (%s) — item entregue sem '
|
|
'documento para o cursor nao travar', materia_pk, campo.name)
|
|
return None
|
|
return {
|
|
'nome': os.path.basename(campo.name),
|
|
'mime': 'application/pdf',
|
|
'tamanho_bytes': tamanho,
|
|
'url': _url_absoluta(request, nome_rota, materia_pk),
|
|
'hash_sha256': digest,
|
|
}
|
|
|
|
|
|
def _normalizar_assinatura_info(info):
|
|
# Mesma normalização da sprint (views_assinatura): dict legado vira lista.
|
|
if info is None:
|
|
return []
|
|
if isinstance(info, dict):
|
|
return [info]
|
|
return info
|
|
|
|
|
|
def _url_absoluta(request, nome_rota, materia_id):
|
|
return request.build_absolute_uri(
|
|
reverse(nome_rota, kwargs={'materia_id': materia_id}))
|
|
|
|
|
|
# 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):
|
|
"""Item de `assinaturas-pendentes` (§3): só existe com o PDF-alvo materializado."""
|
|
materia = alvo.materia
|
|
# `verification_url_base` e `casa_legislativa` viajam com a pendência porque
|
|
# SÓ O SAPL sabe a URL pública desta casa e o nome dela. Sem eles o AMU assina
|
|
# sem a página de autenticação — e o documento sai divergente do assinado aqui,
|
|
# que é exatamente o que a convergência dos assinadores (AB#1473) elimina.
|
|
#
|
|
# Reusamos as funções de `views_assinatura` em vez de remontar a URL aqui: elas
|
|
# são a forma canônica que o próprio SAPL usa ao chamar o microserviço. Duplicar
|
|
# a montagem é o caminho conhecido para as duas divergirem na primeira mudança.
|
|
return {
|
|
# Keyset da fonte: cursor composto `(gerado_em, id)`, o mesmo de
|
|
# assinaturas-concluidas. O `id` e o da MATERIA, nao o do registro de
|
|
# pendencia — emitir alvo.pk faria o hub pedir uma pagina que a view
|
|
# nunca entende, relendo a mesma primeira pagina para sempre.
|
|
'id': materia.pk,
|
|
# A DATA e o que salva materia que materializa TARDE. Com keyset so por
|
|
# id, alvo criado depois com id abaixo do cursor era pulado para
|
|
# sempre, calado: em 22/08/2026 as 861 materias DOCX de Franco (todas
|
|
# com id < 1076, cursor em 1078) iam sumir inteiras no dia em que a
|
|
# conversao voltasse a funcionar. `gerado_em` e auto_now, entao a
|
|
# retificacao tambem reapresenta a materia sozinha — que e o
|
|
# comportamento desejado de qualquer forma (§5.1).
|
|
'gerado_em': _iso(alvo.gerado_em),
|
|
'materia': {
|
|
'id': materia.pk,
|
|
'numero': materia.numero,
|
|
'ano': materia.ano,
|
|
'ementa': materia.ementa,
|
|
},
|
|
'autores_pendentes': _autores_pendentes(materia),
|
|
'documento': _bloco_documento(
|
|
alvo.arquivo, request, 'integracao_hub_documento_alvo',
|
|
materia.pk, hash_sha256=alvo.hash_sha256),
|
|
# ── Estado da cadeia (ADR-0013, extensao entre sistemas) ─────────────
|
|
#
|
|
# `documento` acima e sempre o ALVO BASE — e tem que continuar sendo:
|
|
# e contra ele que o receiver confere `hash_alvo_esperado` na checagem
|
|
# de retificacao (§5.1). Mas quando a materia JA TEM assinatura, o alvo
|
|
# nao e o que se deve assinar agora: assinar o alvo em branco produz um
|
|
# PDF que substitui as assinaturas anteriores em vez de somar.
|
|
#
|
|
# O encadeamento do amu-backend resolve isso olhando a pendencia irma
|
|
# ASSINADA no banco DELE — o que so funciona se a assinatura anterior
|
|
# tambem tiver sido feita pelo app, ou se o `DocumentoAssinado` da
|
|
# assinatura feita AQUI ja tiver chegado la. Assinatura feita no SAPL
|
|
# com o poll atrasado cai fora das duas hipoteses.
|
|
#
|
|
# Por isso a pendencia passa a se descrever inteira: quantas assinaturas
|
|
# ja existem, qual documento encadear e com que codigo. O consumidor
|
|
# nao precisa ter visto o evento anterior para acertar — e um consumidor
|
|
# antigo ignora os campos novos e segue como antes.
|
|
'assinaturas_existentes': [
|
|
a.get('signed_by')
|
|
for a in _normalizar_assinatura_info(materia.assinatura_info)],
|
|
'codigo_autenticacao': materia.codigo_autenticacao or None,
|
|
'documento_encadeado': _bloco_documento(
|
|
materia.pdf_assinado, request,
|
|
'integracao_hub_documento_assinado', materia.pk
|
|
) if materia.pdf_assinado else None,
|
|
'verification_url_base': _construir_url_verificacao_base(
|
|
request, 'materia', materia.pk),
|
|
'casa_legislativa': _obter_nome_casa_legislativa(),
|
|
}
|
|
|
|
|
|
def _autor_do_signed_by(username, ids_da_autoria):
|
|
"""Resolve signed_by → autor_id (contrato documento-assinado).
|
|
|
|
signed_by é o VEREADOR TITULAR, então a resolução espelha `resolver_titular`
|
|
ao contrário: username → Votante → Parlamentar → Autor (content_type
|
|
parlamentar), preferindo o autor que está na autoria da matéria (é a
|
|
pendência dele que a assinatura fecha). Fallback via OperadorAutor cobre
|
|
registros antigos assinados localmente antes desta regra. Sem casamento na
|
|
autoria, devolve o melhor palpite (best-effort de exibição); None se nada
|
|
resolver — o consumidor ainda tem o signed_by.
|
|
"""
|
|
ct_parlamentar = ContentType.objects.get_for_model(Parlamentar)
|
|
parlamentar_ids = list(
|
|
Votante.objects.filter(user__username=username)
|
|
.values_list('parlamentar_id', flat=True))
|
|
autores_titular = list(
|
|
Autor.objects.filter(content_type=ct_parlamentar,
|
|
object_id__in=parlamentar_ids)
|
|
.values_list('id', flat=True)) if parlamentar_ids else []
|
|
for autor_id in autores_titular:
|
|
if autor_id in ids_da_autoria:
|
|
return autor_id
|
|
|
|
autores_operador = list(
|
|
OperadorAutor.objects.filter(user__username=username)
|
|
.order_by('id').values_list('autor_id', flat=True))
|
|
for autor_id in autores_operador:
|
|
if autor_id in ids_da_autoria:
|
|
return autor_id
|
|
|
|
if autores_titular:
|
|
return autores_titular[0]
|
|
return autores_operador[0] if autores_operador else None
|
|
|
|
|
|
def serializar_materia_assinada(materia, request):
|
|
assinaturas_info = _normalizar_assinatura_info(materia.assinatura_info)
|
|
ids_da_autoria = set(
|
|
materia.autoria_set.values_list('autor_id', flat=True))
|
|
|
|
assinaturas = []
|
|
for info in assinaturas_info:
|
|
username = info.get('signed_by')
|
|
assinaturas.append({
|
|
'signed_by': username,
|
|
# A sprint grava nome_assinante/data_assinatura; o POST da
|
|
# integração grava nome/data — o contrato enxerga um shape só.
|
|
'nome': info.get('nome') or info.get('nome_assinante'),
|
|
'data': info.get('data') or info.get('data_assinatura'),
|
|
'tipo_certificado': info.get('tipo_certificado'),
|
|
'autor_id': _autor_do_signed_by(username, ids_da_autoria),
|
|
# Rastro operacional (quem disparou o ato) — separado da autoria
|
|
# jurídica (signed_by). Ausente nos registros da sprint.
|
|
'operado_por': info.get('operado_por'),
|
|
})
|
|
|
|
return {
|
|
# Keyset da fonte: o hub le `id` no topo para o desempate do cursor
|
|
# composto (assinado_em, id).
|
|
'id': materia.pk,
|
|
'materia': {
|
|
'id': materia.pk,
|
|
'numero': materia.numero,
|
|
'ano': materia.ano,
|
|
},
|
|
'documento_assinado': _bloco_documento(
|
|
materia.pdf_assinado, request,
|
|
'integracao_hub_documento_assinado', materia.pk),
|
|
'codigo_autenticacao': materia.codigo_autenticacao,
|
|
'assinado_em': _iso(materia.assinado_em),
|
|
'assinaturas': assinaturas,
|
|
}
|
|
|