Browse Source

Merge pull request #13 from Move-System/feat/assinatura-unificada-lote-local

Feat(Assinatura): unifica C3 e as telas de lote — um compositor por caminho AB#1473
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
c04db1832e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 274
      sapl/materia/assinatura_api_client.py
  2. 631
      sapl/materia/views_assinatura.py

274
sapl/materia/assinatura_api_client.py

@ -14,13 +14,47 @@ Contrato real da API (descoberto via /openapi.json):
Resposta 200: Content-Type: application/pdf bytes do PDF assinado Resposta 200: Content-Type: application/pdf bytes do PDF assinado
Resposta 4xx/5xx: JSON com campo "detail" Resposta 4xx/5xx: JSON com campo "detail"
A partir da fase C3 (AB#1473) o /sign também sabe COMPOR a página de autenticação
(código, URL de verificação, QR e os blocos da grade) capacidade que antes o
SAPL tinha. Quem quer delegar a composição usa
`assinar_pdf_com_pagina_autenticacao()`, que manda o bloco de parâmetros do
`auth_page` e devolve os cabeçalhos de resposta junto com o PDF.
Outros endpoints: Outros endpoints:
POST /validate-pfx valida certificado PFX POST /validate-pfx valida certificado PFX
POST /validate valida assinaturas de um PDF POST /validate valida assinaturas de um PDF
POST /sign/batch assina múltiplos PDFs em lote POST /sign/batch carimba em lote (ver a regra abaixo)
GET / health check GET / health check
Duas composições convivem aqui qual usar
------------------------------------------
Quem **compõe** a página de autenticação (código, URL de verificação, QR, blocos
da grade) decide qual função chamar:
- **Composição no microserviço** `assinar_pdf_com_pagina_autenticacao()`, um
documento por vez. É o caminho da C3 e o padrão para assinatura individual de
matéria e de documento acessório: o SAPL manda os dados da casa legislativa e
recebe o PDF composto, o código no `X-Codigo-Autenticacao` e os metadados do
certificado via `/validate-pfx`.
- **Composição no SAPL** `assinar_pdf_lote_via_api()`, para as telas "Assinar
Despachos em Lote". O `/sign/batch` **não compõe**: seus parâmetros valem para
o lote inteiro, e a composição é por documento. Então o chamador compõe cada
PDF localmente e manda coordenadas explícitas; o batch carimba.
As duas produzem o mesmo artefato a grade e o algoritmo do código são os
mesmos dos dois lados (sha256[:16].upper(), congelado). O que muda é onde o
desenho acontece.
Mandar para `assinar_pdf_lote_via_api()` um PDF **não composto** produz documento
sem página de autenticação e sem código verificável é o erro que a condição de
validade no docstring daquela função existe para evitar.
Antes de mudar esse desenho (por exemplo, fazer o lote delegar), fale com o
arquiteto: a decisão da C3 (AB#1473) é que o microserviço é o centro evolutivo
das assinaturas, e um lote delegado exige `auth_page` por item, do lado de .
""" """
import json
import logging import logging
import requests import requests
@ -56,6 +90,178 @@ def _url(endpoint):
return f'{base}/{endpoint}' return f'{base}/{endpoint}'
class ResultadoAssinatura:
"""
Resposta do /sign quando a composição da página é delegada ao microserviço.
Além do PDF, o microserviço devolve em cabeçalhos o que ele sabe depois de
compor: o código impresso na página (que o cidadão digita na verificação
pública) e qual bloco da grade esta assinatura ocupou.
"""
__slots__ = ('pdf', 'codigo_autenticacao', 'signature_index',
'auth_page_aplicada', 'auth_page_suportado')
def __init__(self, pdf, codigo_autenticacao, signature_index,
auth_page_aplicada, auth_page_suportado):
self.pdf = pdf
self.codigo_autenticacao = codigo_autenticacao
self.signature_index = signature_index
#: True quando ESTA chamada anexou a página de autenticação
self.auth_page_aplicada = auth_page_aplicada
#: False quando o microserviço não devolveu os cabeçalhos do auth_page —
#: ou seja, é uma versão anterior à C2 e ignorou os parâmetros enviados.
self.auth_page_suportado = auth_page_suportado
def _post_sign(files, data):
"""
POST /sign com o tratamento de erro comum aos dois modos de uso.
Retorna o `requests.Response` de sucesso; lança AssinaturaAPIError caso
contrário. O corpo da resposta é o PDF assinado.
"""
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
try:
response = requests.post(
_url('sign'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.ConnectionError as exc:
logger.error(f'[assinatura-api] Falha de conexao: {exc}')
raise AssinaturaAPIError(
'Nao foi possivel conectar ao microservico de assinatura. '
'Verifique se o servico esta disponivel.'
)
except requests.exceptions.Timeout:
raise AssinaturaAPIError(
f'Timeout ao aguardar resposta do microservico de assinatura '
f'(limite: {timeout}s).'
)
except requests.exceptions.RequestException as exc:
logger.error(f'[assinatura-api] Erro inesperado: {exc}')
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or detail.get('error') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}')
raise AssinaturaAPIError(msg, status_code=response.status_code)
# A API retorna o PDF assinado diretamente como application/pdf
content_type = response.headers.get('Content-Type', '')
if 'pdf' not in content_type and len(response.content) < 100:
raise AssinaturaAPIError(
f'Resposta inesperada do microservico (Content-Type: {content_type}).'
)
return response
def assinar_pdf_com_pagina_autenticacao(
pdf_bytes, *, certificado_bytes, senha,
verification_url_base, assinaturas,
casa_legislativa=None, signer_name=None, signer_role=None,
codigo_autenticacao=None, brasao_bytes=None, brasao_filename='brasao.png',
reason=None, location=None):
"""
Assina delegando ao microserviço a composição da página de autenticação.
Diferença para `assinar_pdf_via_api`: aqui o SAPL **não** monta a página nem
calcula a posição do bloco manda os dados da casa legislativa e o
microserviço compõe (fase C3 da convergência dos assinadores, AB#1473).
Por isso nenhum `signature_*` é enviado: quem conhece a grade é o
microserviço, e mandar coordenada junto faz o `auth_page` e a posição
explícita disputarem o mesmo campo.
Parâmetros
----------
verification_url_base : str URL pública de verificação SEM o `?codigo=`
(o microserviço acrescenta)
assinaturas : list[dict] blocos a desenhar
([{nome_assinante, cargo, data_assinatura}])
casa_legislativa : str nome impresso no cabeçalho da página
signer_name / signer_role nome e cargo exibidos (no legislativo, o nome
parlamentar do vereador, não o CN do certificado)
codigo_autenticacao : str código emitido. OBRIGATÓRIO da segunda
assinatura em diante: o PDF mudou ao ser
assinado e o hash de agora não reproduz o que
está impresso na página. Na primeira, deixe
vazio o microserviço deriva do próprio PDF.
brasao_bytes : bytes brasão da câmara. Mande em TODAS as assinaturas
da mesma matéria: ele desloca a grade em 3 mm e
omiti-lo depois desalinharia os blocos seguintes.
Retorna: ResultadoAssinatura.
Lança: AssinaturaAPIError em caso de erro.
"""
if not _api_configurada():
raise AssinaturaAPIError(
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).'
)
files = {
'pdf': ('documento.pdf', pdf_bytes, 'application/pdf'),
'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream'),
}
if brasao_bytes:
files['brasao'] = (brasao_filename, brasao_bytes, 'application/octet-stream')
data = {
'pfx_password': senha,
'auth_page': 'auto',
'verification_url_base': verification_url_base,
'assinaturas': json.dumps(assinaturas or [], ensure_ascii=False),
}
if casa_legislativa:
data['casa_legislativa'] = casa_legislativa
if signer_name:
data['signer_name'] = signer_name
if signer_role:
data['signer_role'] = signer_role
if codigo_autenticacao:
data['codigo_autenticacao'] = codigo_autenticacao
if reason:
data['reason'] = reason
if location:
data['location'] = location
response = _post_sign(files, data)
headers = response.headers
# A ausência do cabeçalho é o sinal de que o microserviço IGNOROU o auth_page
# (versão anterior à C2). Quem chama precisa saber: sem a composição, o PDF
# sai sem página de autenticação e sem código verificável.
auth_page_suportado = 'X-Auth-Page-Applied' in headers
try:
signature_index = int(headers.get('X-Signature-Index', ''))
except (TypeError, ValueError):
signature_index = None
resultado = ResultadoAssinatura(
pdf=response.content,
codigo_autenticacao=(headers.get('X-Codigo-Autenticacao') or '').strip(),
signature_index=signature_index,
auth_page_aplicada=(headers.get('X-Auth-Page-Applied', '').strip().lower()
== 'true'),
auth_page_suportado=auth_page_suportado,
)
logger.info(
'[assinatura-api] PDF assinado com pagina de autenticacao composta pelo '
f'microservico (bloco={resultado.signature_index}, '
f'pagina_anexada={resultado.auth_page_aplicada}).'
)
return resultado
def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha, def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha,
reason=None, location=None, reason=None, location=None,
signature_page=None, signature_page=None,
@ -64,6 +270,10 @@ def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha,
""" """
Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes. Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes.
Modo "posição explícita": o PDF chega composto e o chamador diz onde o
carimbo vai. Para delegar a composição da página de autenticação ao
microserviço, use `assinar_pdf_com_pagina_autenticacao`.
Parâmetros Parâmetros
---------- ----------
pdf_bytes : bytes PDF a assinar pdf_bytes : bytes PDF a assinar
@ -103,46 +313,7 @@ def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha,
if signature_height is not None: if signature_height is not None:
data['signature_height'] = str(signature_height) data['signature_height'] = str(signature_height)
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) response = _post_sign(files, data)
try:
response = requests.post(
_url('sign'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.ConnectionError as exc:
logger.error(f'[assinatura-api] Falha de conexao: {exc}')
raise AssinaturaAPIError(
'Nao foi possivel conectar ao microservico de assinatura. '
'Verifique se o servico esta disponivel.'
)
except requests.exceptions.Timeout:
raise AssinaturaAPIError(
f'Timeout ao aguardar resposta do microservico de assinatura '
f'(limite: {timeout}s).'
)
except requests.exceptions.RequestException as exc:
logger.error(f'[assinatura-api] Erro inesperado: {exc}')
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or detail.get('error') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}')
raise AssinaturaAPIError(msg, status_code=response.status_code)
# A API retorna o PDF assinado diretamente como application/pdf
content_type = response.headers.get('Content-Type', '')
if 'pdf' not in content_type and len(response.content) < 100:
raise AssinaturaAPIError(
f'Resposta inesperada do microservico (Content-Type: {content_type}).'
)
logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.') logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.')
return response.content return response.content
@ -152,8 +323,25 @@ def assinar_pdf_lote_via_api(itens, *, certificado_bytes, senha,
reason=None, location=None, reason=None, location=None,
download_workers=8): download_workers=8):
""" """
Assina múltiplos PDFs em uma única chamada POST /sign/batch e baixa os Assina em lote PDFs **que chegam compostos e posicionados**, via POST
resultados em paralelo via download_url do S3. /sign/batch, e baixa os resultados em paralelo pelo download_url do S3.
Condição de validade leia antes de usar
-----------------------------------------
O `/sign/batch` **não compõe** a página de autenticação: ele recebe um único
conjunto de parâmetros para o lote inteiro, e a composição é por documento
(a URL de verificação carrega o id da matéria, o código sai do hash daquele
PDF). Aqui o batch é ** carimbo**.
Por isso o chamador precisa, para cada item: compor a página localmente
(`_compor_pagina_auth_localmente` e companhia, em views_assinatura), gerar o
código, montar a URL de verificação com o pk e calcular o bloco da grade
e então mandar as coordenadas explícitas. É o que as telas "Assinar
Despachos em Lote" fazem (`docacessorio_assinar_lote`, `materia_assinar_lote`).
Mandar um PDF **não composto** para produz documento sem página de
autenticação e sem código verificável. Se o que você quer é delegar a
composição, o caminho é `assinar_pdf_com_pagina_autenticacao` (um a um).
Parâmetros Parâmetros
---------- ----------

631
sapl/materia/views_assinatura.py

@ -85,72 +85,178 @@ def _extrair_posicao_custom(post_data):
'sig_height': float(sig_height), 'sig_height': float(sig_height),
'sig_page': int(sig_page), 'sig_page': int(sig_page),
} }
except (ValueError, TypeError): except (ValueError, TypeError) as exc:
# Descartar a posição em silêncio faz a assinatura cair no bloco automático
# da grade em vez de onde foi pedido — diferença de layout que ninguém
# consegue explicar depois olhando só o PDF.
logger.warning(
f'Posicao customizada da assinatura ignorada (valores invalidos): {exc}'
)
return None return None
def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, class ErroAssinaturaUsuario(Exception):
assinaturas_existentes,
certificado_bytes=None, senha=None,
assinatura_a3_bytes=None, cert_chain_bytes=None,
tipo_cert_input='a1',
posicao_custom=None):
""" """
Orquestra a assinatura digital completa de um PDF: Erro que o operador entende e consegue corrigir sozinho.
- Monta a página de autenticação (1ª assinatura) ou assina incrementalmente
(assinaturas subsequentes).
- Usa a API externa quando ASSINATURA_API_URL estiver configurado;
caso contrário usa pyhanko localmente.
posicao_custom : dict opcional com chaves sig_left, sig_bottom, sig_width, Senha errada, arquivo que não é um .pfx, certificado vencido: nada disso é
sig_height (em pontos PDF) e sig_page (1-based). falha do sistema, e responder 500 com o texto cru do erro assusta quem
Quando fornecido, substitui o cálculo automático de grid. está assinando. Separado do Exception genérico para as views devolverem 400
com a mensagem que é o que o caminho local fazia em `_validar_certificado`.
"""
Retorna (signed_pdf_bytes, nova_assinatura_dict, codigo_autenticacao_ou_None).
- codigo_autenticacao_ou_None é não-None apenas na 1ª assinatura.
Lança Exception em caso de erro. def _erro_de_assinatura(exc):
""" """
from PyPDF4 import PdfFileReader, PdfFileWriter Converte um AssinaturaAPIError na exceção certa para o chamador levantar.
import base64 as _base64
ja_tem_pdf_assinado = bool(assinaturas_existentes) HTTP 400 do microserviço é recusa de validação (certificado fora da validade,
senha incorreta, parâmetro faltando) problema do usuário. Qualquer outra
coisa (conexão, timeout, 5xx) é falha de infraestrutura e continua sendo erro
genérico.
"""
if getattr(exc, 'status_code', None) == 400:
return ErroAssinaturaUsuario(str(exc))
return Exception(str(exc))
# ── Informações do assinante ──────────────────────────────────────────────
# Para API externa: não há signer local; tentamos extrair info do certificado
# de forma leve (apenas para metadados visuais).
if _usar_api_externa():
from sapl.materia.assinatura_api_client import assinar_pdf_via_api, AssinaturaAPIError
# Obter nome/cargo via relação User → Autor (sem pyhanko) def _metadados_certificado_via_api(certificado_bytes, senha):
"""
os metadados do certificado no microserviço (POST /validate-pfx).
Existe por dois motivos:
1. Preencher `subject`, `issuer`, `serial`, `valid_from` e `valid_to` no
`assinatura_info`. Quando o SAPL delega a assinatura, ele não abre o PFX
e sem esta chamada esses campos ficavam string vazia, deixando emissor,
série e validade em branco em tudo que o `assinatura_info`.
2. Recusar certificado fora da validade antes de assinar, com a mesma
mensagem do caminho local (`_validar_certificado`).
A senha é usada nesta requisição e não é persistida em lugar nenhum.
Retorna dict com as chaves no formato do `assinatura_info` (vazio se o
microserviço não respondeu). Lança ErroAssinaturaUsuario para PFX recusado.
"""
from sapl.materia.assinatura_api_client import (
AssinaturaAPIError, validar_pfx_via_api,
)
try:
info = validar_pfx_via_api(certificado_bytes, senha) or {}
except AssinaturaAPIError as exc:
if getattr(exc, 'status_code', None) == 400:
# PFX ilegível ou senha incorreta: o /sign recusaria do mesmo jeito,
# e aqui a mensagem chega antes de enviar o documento.
raise ErroAssinaturaUsuario(str(exc))
# Indisponibilidade do /validate-pfx não pode impedir a assinatura: o
# /sign também recusa certificado vencido. Só perdemos os metadados.
logger.warning(
f'[assinatura-api] Nao foi possivel ler os metadados do certificado '
f'(/validate-pfx): {exc}. A assinatura segue sem eles.'
)
return {}
if info.get('is_valid_now') is False:
validade = ' a '.join(
v for v in (info.get('not_valid_before'), info.get('not_valid_after')) if v
)
raise ErroAssinaturaUsuario(
'Certificado expirado ou ainda não válido.'
+ (f' Validade: {validade}.' if validade else '')
)
serial = info.get('serial_number')
return {
'subject': info.get('subject') or '',
'issuer': info.get('issuer') or '',
'serial': '' if serial is None else str(serial),
'valid_from': info.get('not_valid_before') or '',
'valid_to': info.get('not_valid_after') or '',
}
def _nome_e_cargo_do_assinante(request):
"""
Nome e cargo que vão IMPRESSOS no documento, via Autor.operadores Parlamentar.
Fonte única para os dois backends. Existia em duas cópias uma aqui e outra
inline no ramo da API que hoje coincidiam por acaso: mudar o critério em uma
faria o mesmo vereador sair com nomes diferentes conforme o backend da casa.
Quem assina é o vereador, então o nome impresso é o dele. O operador (assessor)
fica registrado à parte, em `signed_by`.
"""
nome_assinante = request.user.get_full_name() or request.user.username nome_assinante = request.user.get_full_name() or request.user.username
cargo = 'Usuário do Sistema' cargo = 'Usuário do Sistema'
tipo_cert_display = 'Certificado Digital'
try: try:
from sapl.base.models import Autor from sapl.base.models import Autor
from sapl.parlamentares.models import Parlamentar from sapl.parlamentares.models import Parlamentar
autor = Autor.objects.filter(operadores=request.user).first() autor = Autor.objects.filter(operadores=request.user).first()
if autor: if autor:
tipo_desc = autor.tipo.descricao if autor.tipo else '' # Tipo do autor vira cargo (ex.: "Parlamentar" → "Vereador(a)")
if tipo_desc == 'Parlamentar': tipo_descricao = autor.tipo.descricao if autor.tipo else ''
if tipo_descricao == 'Parlamentar':
cargo = 'Vereador(a)' cargo = 'Vereador(a)'
elif tipo_desc: elif tipo_descricao:
cargo = tipo_desc cargo = tipo_descricao
if isinstance(autor.autor_related, Parlamentar): if isinstance(autor.autor_related, Parlamentar):
parl = autor.autor_related parlamentar = autor.autor_related
tipo_nome = AppConfig.attr('assinatura_nome') tipo_nome = AppConfig.attr('assinatura_nome')
nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar nome_assinante = (
except Exception: parlamentar.nome_completo if tipo_nome == 'C'
pass else parlamentar.nome_parlamentar
)
except Exception as exc:
# Cair no username do operador significa imprimir no PDF um nome que não é
# o do vereador. Não derruba a assinatura, mas não pode passar em silêncio.
logger.warning(
f'Nao foi possivel resolver o parlamentar de {request.user.username}: '
f'{exc}. A assinatura sai com "{nome_assinante}".'
)
data_assinatura = timezone.localtime(timezone.now()) return nome_assinante, cargo
data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S')
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
codigo_retorno = None def _tipo_certificado_pelo_emissor(issuer):
"""Rotula o certificado a partir do emissor (mesma regra nos dois backends)."""
issuer_str = str(issuer or '').upper()
if 'ICP-BRASIL' in issuer_str or 'ICP BRASIL' in issuer_str:
return 'ICP-Brasil'
return 'Certificado Digital'
def _ler_brasao():
"""
Bytes do logotipo da casa, ou None.
Precisa ir em TODAS as assinaturas da mesma matéria: o brasão desloca a grade
de blocos em 3 mm, então mandá-lo na primeira desalinharia os blocos
seguintes com o que está desenhado na página.
"""
caminho = _encontrar_logo()
if not caminho:
return None
try:
with open(caminho, 'rb') as f:
return f.read()
except OSError as exc:
logger.warning(f'Nao foi possivel ler o brasao em {caminho}: {exc}')
return None
def _compor_pagina_auth_localmente(pdf_bytes, request, tipo_doc, pk_doc, blocos):
"""
Anexa a página de autenticação ao PDF aqui mesmo (caminho pré-C3).
Continua em uso pelo backend local (pyhanko no Django) e pelo caso de posição
explícita, em que o microserviço não pode compor. Retorna (pdf, codigo).
"""
from PyPDF4 import PdfFileReader, PdfFileWriter
if not ja_tem_pdf_assinado:
# 1ª assinatura: montar PDF com página de autenticação ANTES de enviar
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) last_page = original_pdf.getPage(original_pdf.getNumPages() - 1)
page_width = float(last_page.mediaBox.getWidth()) page_width = float(last_page.mediaBox.getWidth())
@ -158,15 +264,8 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
codigo = _gerar_codigo_autenticacao(pdf_bytes) codigo = _gerar_codigo_autenticacao(pdf_bytes)
url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo)
nova_assinatura_info_visual = {
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_simples,
}
auth_page_bytes = _gerar_pagina_autenticacao( auth_page_bytes = _gerar_pagina_autenticacao(
[nova_assinatura_info_visual], codigo, url_verificacao, blocos, codigo, url_verificacao, page_width, page_height
page_width, page_height
) )
auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes))
@ -175,39 +274,104 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
output_pdf.addPage(original_pdf.getPage(page_num)) output_pdf.addPage(original_pdf.getPage(page_num))
output_pdf.addPage(auth_page_pdf.getPage(0)) output_pdf.addPage(auth_page_pdf.getPage(0))
buf_combinado = io.BytesIO() buf = io.BytesIO()
output_pdf.write(buf_combinado) output_pdf.write(buf)
pdf_para_assinar = buf_combinado.getvalue() return buf.getvalue(), codigo
codigo_retorno = codigo
else:
pdf_para_assinar = pdf_bytes # já inclui página de autenticação def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
assinaturas_existentes,
certificado_bytes=None, senha=None,
assinatura_a3_bytes=None, cert_chain_bytes=None,
tipo_cert_input='a1',
posicao_custom=None,
hash_doc=''):
"""
Orquestra a assinatura digital completa de um PDF.
Dois backends, escolhidos por ASSINATURA_API_URL:
- **API externa (padrão quando configurada)**: desde a fase C3 (AB#1473) quem
compõe a página de autenticação é o microserviço. O SAPL manda os dados da
casa legislativa (URL de verificação, nome, brasão, bloco do assinante) e
recebe de volta o PDF composto e assinado, mais o código impresso. Não
manda posição: a grade é do microserviço, e é isso que faz SAPL e app do AMU
produzirem o mesmo artefato sem reimplementar o desenho.
- **pyhanko local (ASSINATURA_API_URL vazio)**: compõe a página aqui e assina
no próprio Django, como sempre fez.
posicao_custom : dict opcional com chaves sig_left, sig_bottom, sig_width,
sig_height (em pontos PDF) e sig_page (1-based).
Quando fornecido, substitui o cálculo automático de grid
e, na API externa, mantém a composição local, porque
coordenada explícita e composição no microserviço disputam
o mesmo campo.
hash_doc : código de autenticação emitido para o documento. Vai para
o carimbo visual no backend local e, na API externa, é o
`codigo_autenticacao` devolvido ao microserviço da 2ª
assinatura em diante.
Retorna (signed_pdf_bytes, nova_assinatura_dict, codigo_autenticacao_ou_None).
- codigo_autenticacao_ou_None é não-None apenas na 1ª assinatura.
Lança ErroAssinaturaUsuario quando o problema é do operador (certificado
vencido, senha incorreta) e Exception nos demais casos.
"""
from PyPDF4 import PdfFileReader, PdfFileWriter
import base64 as _base64
ja_tem_pdf_assinado = bool(assinaturas_existentes)
# ── Informações do assinante ──────────────────────────────────────────────
# Na API externa o SAPL não abre o PFX: quem assina é o microserviço. O nome
# que vai no PDF é o do parlamentar (relação User → Autor), e os dados do
# certificado vêm do /validate-pfx.
if _usar_api_externa():
from sapl.materia.assinatura_api_client import (
AssinaturaAPIError,
assinar_pdf_com_pagina_autenticacao,
assinar_pdf_via_api,
)
# O tipo do certificado só é conhecido de verdade pelo /validate-pfx (C3);
# este é o rótulo de partida, sobrescrito quando os metadados chegam.
tipo_cert_display = 'Certificado Digital'
# Nome/cargo pela MESMA função do backend local: o vereador não pode sair
# com nome diferente conforme a casa usa microserviço ou pyhanko.
nome_assinante, cargo = _nome_e_cargo_do_assinante(request)
data_assinatura = timezone.localtime(timezone.now())
data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S')
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
codigo_retorno = None
# Metadados do certificado: o /validate-pfx responde ANTES de assinar quem
# emitiu, qual a série e até quando vale. Sem esta chamada esses campos iam
# vazios para o assinatura_info — e o SAPL exibia "Válido até:" em branco.
# Ela também é a checagem de validade que o caminho local faz em
# `_validar_certificado`: certificado vencido não chega a ser enviado.
cert_api = _metadados_certificado_via_api(certificado_bytes, senha)
# O bloco desta assinatura — o único que a página precisa desenhar agora.
bloco_atual = {
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_simples,
}
# Calcula a posição do campo de assinatura na última página (página de autenticação)
# para que a assinatura visual fique no bloco correto do grid.
# Se posicao_custom foi fornecido pelo usuário, usa ele diretamente.
if posicao_custom: if posicao_custom:
sig_page = posicao_custom.get('sig_page') # Posição explícita pedida pelo chamador. Quando o microserviço compõe a
sig_left = posicao_custom.get('sig_left') # página, é ELE quem decide a posição do bloco (a grade é dele), então
sig_bottom = posicao_custom.get('sig_bottom') # `auth_page` e coordenada explícita não convivem: pedir os dois faria a
sig_width = posicao_custom.get('sig_width') # coordenada do usuário ser silenciosamente descartada. Neste caso — e só
sig_height = posicao_custom.get('sig_height') # nele — a composição continua sendo feita aqui, como antes da C3.
else: if not ja_tem_pdf_assinado:
n_assinatura = len(assinaturas_existentes) pdf_para_assinar, codigo_retorno = _compor_pagina_auth_localmente(
try: pdf_bytes, request, tipo_doc, pk_doc, [bloco_atual]
temp_pypdf = PdfFileReader(io.BytesIO(pdf_para_assinar))
auth_pg = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1)
auth_w = float(auth_pg.mediaBox.getWidth())
auth_h = float(auth_pg.mediaBox.getHeight())
sig_x1, sig_y1, sig_x2, sig_y2 = _posicao_bloco_assinatura(
n_assinatura, auth_w, auth_h
) )
sig_page = temp_pypdf.getNumPages() # 1-based, última página else:
sig_left = sig_x1 pdf_para_assinar = pdf_bytes # já inclui página de autenticação
sig_bottom = sig_y1
sig_width = sig_x2 - sig_x1
sig_height = sig_y2 - sig_y1
except Exception:
sig_page = sig_left = sig_bottom = sig_width = sig_height = None
try: try:
pdf_assinado_bytes = assinar_pdf_via_api( pdf_assinado_bytes = assinar_pdf_via_api(
@ -215,24 +379,68 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
certificado_bytes=certificado_bytes, certificado_bytes=certificado_bytes,
senha=senha, senha=senha,
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
location='Câmara Municipal', location=_obter_nome_casa_legislativa(),
signature_page=sig_page, signature_page=posicao_custom.get('sig_page'),
signature_left=sig_left, signature_left=posicao_custom.get('sig_left'),
signature_bottom=sig_bottom, signature_bottom=posicao_custom.get('sig_bottom'),
signature_width=sig_width, signature_width=posicao_custom.get('sig_width'),
signature_height=sig_height, signature_height=posicao_custom.get('sig_height'),
) )
except AssinaturaAPIError as exc: except AssinaturaAPIError as exc:
raise Exception(str(exc)) raise _erro_de_assinatura(exc)
else:
# Caminho normal (C3): o microserviço compõe a página de autenticação —
# código, URL de verificação, QR e os blocos — e escolhe o bloco desta
# assinatura contando as que já existem no PDF. O SAPL só manda o que é
# da casa legislativa. Nenhum `signature_*` vai junto, de propósito.
try:
resultado = assinar_pdf_com_pagina_autenticacao(
pdf_bytes,
certificado_bytes=certificado_bytes,
senha=senha,
verification_url_base=_construir_url_verificacao_base(
request, tipo_doc, pk_doc
),
assinaturas=[bloco_atual],
casa_legislativa=_obter_nome_casa_legislativa(),
signer_name=nome_assinante,
signer_role=cargo,
# Da 2ª assinatura em diante o código já está impresso na página:
# o PDF mudou ao ser assinado e o hash de agora não o reproduz.
codigo_autenticacao=(hash_doc or '') if ja_tem_pdf_assinado else None,
brasao_bytes=_ler_brasao(),
brasao_filename=os.path.basename(_encontrar_logo() or 'brasao.png'),
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
location=_obter_nome_casa_legislativa(),
)
except AssinaturaAPIError as exc:
raise _erro_de_assinatura(exc)
if not resultado.auth_page_suportado:
# Sem os cabeçalhos do auth_page o microserviço ignorou a composição:
# o PDF sairia sem página de autenticação e sem código verificável.
# Falhar aqui é melhor do que gravar um documento inverificável.
raise Exception(
'O microserviço de assinatura não compôs a página de '
'autenticação (resposta sem X-Auth-Page-Applied). Atualize o '
'microserviço para a versão com suporte a auth_page.'
)
pdf_assinado_bytes = resultado.pdf
if resultado.auth_page_aplicada:
codigo_retorno = resultado.codigo_autenticacao
nova_assinatura = { nova_assinatura = {
'tipo_certificado': tipo_cert_input.upper(), 'tipo_certificado': tipo_cert_input.upper(),
'tipo_certificado_display': f'Certificado Digital – {tipo_cert_input.upper()}', 'tipo_certificado_display': (
'subject': '', f'{_tipo_certificado_pelo_emissor(cert_api.get("issuer", ""))}'
'issuer': '', f'{tipo_cert_input.upper()}'
'serial': '', ),
'valid_from': '', 'subject': cert_api.get('subject', ''),
'valid_to': '', 'issuer': cert_api.get('issuer', ''),
'serial': cert_api.get('serial', ''),
'valid_from': cert_api.get('valid_from', ''),
'valid_to': cert_api.get('valid_to', ''),
'signed_by': request.user.username, 'signed_by': request.user.username,
'nome_assinante': nome_assinante, 'nome_assinante': nome_assinante,
'cargo': cargo, 'cargo': cargo,
@ -261,17 +469,26 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
passphrase=senha.encode('utf-8') passphrase=senha.encode('utf-8')
) )
except Exception as cert_error: except Exception as cert_error:
raise Exception(f'Erro ao carregar certificado: {cert_error}') logger.warning(f'Falha ao abrir o PFX: {cert_error}')
raise ErroAssinaturaUsuario(_mensagem_de_erro_do_pfx(cert_error))
finally: finally:
if os.path.exists(tmp_cert_path): if os.path.exists(tmp_cert_path):
os.unlink(tmp_cert_path) os.unlink(tmp_cert_path)
if signer is None:
raise ErroAssinaturaUsuario(
'Não foi possível carregar o certificado. Verifique se o arquivo '
'.pfx/.p12 é válido e contém uma chave de assinatura.'
)
cert_info = signer.signing_cert cert_info = signer.signing_cert
error_response = _validar_certificado(cert_info) error_response = _validar_certificado(cert_info)
if error_response: if error_response:
import json as _json import json as _json
data = _json.loads(error_response.content) data = _json.loads(error_response.content)
raise Exception(data.get('error', 'Certificado inválido.')) # Certificado vencido é problema do operador, não falha do sistema: 400
# com a mensagem, igual ao que o microserviço devolve no outro backend.
raise ErroAssinaturaUsuario(data.get('error', 'Certificado inválido.'))
# Criar objeto fake para _obter_info_assinante # Criar objeto fake para _obter_info_assinante
class _CertWrapper: class _CertWrapper:
@ -324,7 +541,7 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
meta = signers.PdfSignatureMetadata( meta = signers.PdfSignatureMetadata(
field_name=sig_field_name, field_name=sig_field_name,
location='Câmara Municipal', location=_obter_nome_casa_legislativa(),
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante name=nome_assinante
) )
@ -343,46 +560,21 @@ def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc,
else: else:
# Primeira assinatura: página de autenticação + pyhanko # Primeira assinatura: página de autenticação + pyhanko
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) stamped_bytes, codigo = _compor_pagina_auth_localmente(
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) pdf_bytes, request, tipo_doc, pk_doc,
page_width = float(last_page.mediaBox.getWidth()) [{
page_height = float(last_page.mediaBox.getHeight())
codigo = _gerar_codigo_autenticacao(pdf_bytes)
url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo)
nova_assinatura_info_visual = {
'nome_assinante': nome_assinante, 'nome_assinante': nome_assinante,
'cargo': cargo, 'cargo': cargo,
'data_assinatura': data_simples, 'data_assinatura': data_simples,
} }],
auth_page_bytes = _gerar_pagina_autenticacao(
[nova_assinatura_info_visual], codigo, url_verificacao,
page_width, page_height
) )
auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes))
output_pdf = PdfFileWriter()
for page_num in range(original_pdf.getNumPages()):
output_pdf.addPage(original_pdf.getPage(page_num))
output_pdf.addPage(auth_page_pdf.getPage(0))
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f:
tmp_path = tmp_f.name
output_pdf.write(tmp_f)
try:
with open(tmp_path, 'rb') as f:
stamped_bytes = f.read()
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
signed_buffer = io.BytesIO() signed_buffer = io.BytesIO()
with io.BytesIO(stamped_bytes) as inf: with io.BytesIO(stamped_bytes) as inf:
w = IncrementalPdfFileWriter(inf) w = IncrementalPdfFileWriter(inf)
meta = signers.PdfSignatureMetadata( meta = signers.PdfSignatureMetadata(
field_name='AssinaturaDigital', field_name='AssinaturaDigital',
location='Câmara Municipal', location=_obter_nome_casa_legislativa(),
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante name=nome_assinante
) )
@ -437,17 +629,24 @@ def _gerar_qrcode_image(url):
return buf return buf
def _construir_url_verificacao(request, tipo, pk, codigo): def _construir_url_verificacao_base(request, tipo, pk):
"""Monta URL pública de verificação.""" """
URL pública de verificação SEM o `?codigo=`.
É o que vai para o microserviço: o SAPL sabe a URL pública da casa, mas o
código quem gera é quem compõe a página então a montagem final é .
"""
if tipo == 'materia': if tipo == 'materia':
url_name = 'sapl.materia:materia_verificar_documento' url_name = 'sapl.materia:materia_verificar_documento'
else: else:
url_name = 'sapl.materia:docacessorio_verificar_documento' url_name = 'sapl.materia:docacessorio_verificar_documento'
base_url = request.build_absolute_uri( return request.build_absolute_uri(reverse(url_name, kwargs={'pk': pk}))
reverse(url_name, kwargs={'pk': pk})
)
return f'{base_url}?codigo={codigo}' def _construir_url_verificacao(request, tipo, pk, codigo):
"""Monta URL pública de verificação."""
return f'{_construir_url_verificacao_base(request, tipo, pk)}?codigo={codigo}'
def _obter_nome_casa_legislativa(): def _obter_nome_casa_legislativa():
@ -736,89 +935,30 @@ def _criar_stamp_style(nome_assinante, cargo, hash_doc=''):
def _obter_info_assinante(request, cert_info): def _obter_info_assinante(request, cert_info):
""" """
Obtém informações do assinante (nome, cargo, tipo_cert). Obtém informações do assinante (nome, cargo, tipo_cert).
Busca via Autor.operadores Parlamentar (GenericFK).
Retorna (nome_assinante, cargo, tipo_cert). Retorna (nome_assinante, cargo, tipo_cert).
""" """
nome_assinante = request.user.get_full_name() or request.user.username nome_assinante, cargo = _nome_e_cargo_do_assinante(request)
cargo = "Usuário do Sistema" return nome_assinante, cargo, _tipo_certificado_pelo_emissor(cert_info.issuer)
try:
from sapl.base.models import Autor
from sapl.parlamentares.models import Parlamentar
autor = Autor.objects.filter(operadores=request.user).first()
if autor:
# Usa tipo do autor como cargo (ex: "Parlamentar" → "Vereador(a)")
tipo_descricao = autor.tipo.descricao if autor.tipo else ''
if tipo_descricao == 'Parlamentar':
cargo = "Vereador(a)"
elif tipo_descricao:
cargo = tipo_descricao
# Se o autor está vinculado a um Parlamentar, usa o nome dele
if isinstance(autor.autor_related, Parlamentar):
parlamentar = autor.autor_related
tipo_nome = AppConfig.attr('assinatura_nome')
if tipo_nome == 'C':
nome_assinante = parlamentar.nome_completo
else:
nome_assinante = parlamentar.nome_parlamentar
except Exception:
pass
issuer_str = str(cert_info.issuer).upper()
if 'ICP-BRASIL' in issuer_str or 'ICP BRASIL' in issuer_str:
tipo_cert = "ICP-Brasil"
else:
tipo_cert = "Certificado Digital"
return nome_assinante, cargo, tipo_cert
def _mensagem_de_erro_do_pfx(exc):
def _carregar_certificado(certificado_file, senha):
"""
Carrega certificado PKCS12 de um arquivo.
Retorna (signer, error_response) se error_response não é None, retornar direto.
""" """
from pyhanko.sign import signers Traduz a falha do pyhanko ao abrir o PKCS12 para algo que o operador resolve.
cert_data = certificado_file.read() Sem isto, senha errada vira `Exception` genérica e a view responde 500 com o
texto cru da biblioteca quem está assinando não tem como saber que errou
import tempfile as tmp_module a senha. É a mesma tradução que o caminho via microserviço faz ao converter o
with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: HTTP 400 em `ErroAssinaturaUsuario`; os dois backends precisam falar igual.
tmp_cert.write(cert_data) """
tmp_cert_path = tmp_cert.name texto = str(exc)
minusculo = texto.lower()
try: if 'password' in minusculo or 'mac' in minusculo:
signer = signers.SimpleSigner.load_pkcs12( detalhe = 'Senha incorreta.'
pfx_file=tmp_cert_path, elif 'decode' in minusculo or 'parse' in minusculo:
passphrase=senha.encode('utf-8') detalhe = 'Arquivo não é um certificado válido (.pfx/.p12).'
)
if os.path.exists(tmp_cert_path):
os.unlink(tmp_cert_path)
except Exception as cert_error:
logger.error(f"Erro ao carregar certificado: {cert_error}")
if os.path.exists(tmp_cert_path):
os.unlink(tmp_cert_path)
error_msg = str(cert_error)
if 'password' in error_msg.lower() or 'mac' in error_msg.lower():
error_detail = 'Senha incorreta.'
elif 'decode' in error_msg.lower() or 'parse' in error_msg.lower():
error_detail = 'Arquivo não é um certificado válido (.pfx/.p12).'
else: else:
error_detail = f'Detalhes: {error_msg}' detalhe = f'Detalhes: {texto}'
return None, JsonResponse({ return f'Erro ao carregar certificado: {detalhe}'
'success': False,
'error': f'Erro ao carregar certificado: {error_detail}'
}, status=400)
if signer is None:
return None, JsonResponse({
'success': False,
'error': 'Não foi possível carregar o certificado. Verifique se o arquivo .pfx/.p12 é válido e contém uma chave de assinatura.'
}, status=400)
return signer, None
def _validar_certificado(cert_info): def _validar_certificado(cert_info):
@ -1093,6 +1233,14 @@ def materia_assinar_a1(request, pk):
'success': False, 'success': False,
'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.'
}, status=500) }, status=500)
except ErroAssinaturaUsuario as e:
# Certificado vencido, senha incorreta: o operador resolve sozinho, então
# a resposta é 400 com a mensagem — não 500 com o erro cru.
logger.warning(f"Assinatura da matéria {pk} recusada: {e}")
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
except Exception as e: except Exception as e:
logger.error(f"Erro ao assinar PDF da matéria {pk}: {e}") logger.error(f"Erro ao assinar PDF da matéria {pk}: {e}")
return JsonResponse({'success': False, 'error': str(e)}, status=500) return JsonResponse({'success': False, 'error': str(e)}, status=500)
@ -1272,7 +1420,7 @@ def materia_assinar_a3_finalizar(request, pk):
# Metadados da assinatura # Metadados da assinatura
meta = signers.PdfSignatureMetadata( meta = signers.PdfSignatureMetadata(
field_name='AssinaturaDigital', field_name='AssinaturaDigital',
location='Câmara Municipal', location=_obter_nome_casa_legislativa(),
reason='Assinatura Digital de Matéria Legislativa (A3)', reason='Assinatura Digital de Matéria Legislativa (A3)',
name=request.user.get_full_name() or request.user.username name=request.user.get_full_name() or request.user.username
) )
@ -1694,6 +1842,14 @@ def docacessorio_assinar_a1(request, pk):
'success': False, 'success': False,
'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.'
}, status=500) }, status=500)
except ErroAssinaturaUsuario as e:
# Certificado vencido, senha incorreta: o operador resolve sozinho, então
# a resposta é 400 com a mensagem — não 500 com o erro cru.
logger.warning(f"Assinatura do doc acessório {pk} recusada: {e}")
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
except Exception as e: except Exception as e:
logger.error(f"Erro ao assinar PDF do doc acessório {pk}: {e}") logger.error(f"Erro ao assinar PDF do doc acessório {pk}: {e}")
return JsonResponse({'success': False, 'error': str(e)}, status=500) return JsonResponse({'success': False, 'error': str(e)}, status=500)
@ -2153,33 +2309,18 @@ def materia_assinar_lote(request):
ja_tem = bool(assinaturas_existentes) ja_tem = bool(assinaturas_existentes)
if not ja_tem: if not ja_tem:
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) # Composição local, documento por documento — é o que torna o
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) # /sign/batch utilizável: ele só carimba. Mesmo helper do caminho
page_width = float(last_page.mediaBox.getWidth()) # individual local, para não existir um terceiro compositor.
page_height = float(last_page.mediaBox.getHeight()) pdf_para_assinar, codigo = _compor_pagina_auth_localmente(
pdf_bytes, request, 'materia', pk,
codigo = _gerar_codigo_autenticacao(pdf_bytes) [{
url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo)
codigos_map[pk] = codigo
nova_assinatura_info_visual = {
'nome_assinante': nome_assinante, 'nome_assinante': nome_assinante,
'cargo': cargo, 'cargo': cargo,
'data_assinatura': data_simples, 'data_assinatura': data_simples,
} }],
auth_page_bytes = _gerar_pagina_autenticacao(
[nova_assinatura_info_visual], codigo, url_verificacao,
page_width, page_height
) )
codigos_map[pk] = codigo
auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes))
output_pdf = PdfFileWriter()
for i in range(original_pdf.getNumPages()):
output_pdf.addPage(original_pdf.getPage(i))
output_pdf.addPage(auth_pdf.getPage(0))
buf = io.BytesIO()
output_pdf.write(buf)
pdf_para_assinar = buf.getvalue()
else: else:
pdf_para_assinar = pdf_bytes pdf_para_assinar = pdf_bytes
@ -2496,23 +2637,17 @@ def docacessorio_assinar_lote(request):
ja_tem = bool(assinaturas_existentes) ja_tem = bool(assinaturas_existentes)
if not ja_tem: if not ja_tem:
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) # Mesma composição local do lote de matéria e do caminho
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) # individual local — um compositor só (ver _compor_pagina_auth_localmente).
page_width = float(last_page.mediaBox.getWidth()) pdf_para_assinar, codigo = _compor_pagina_auth_localmente(
page_height = float(last_page.mediaBox.getHeight()) pdf_bytes, request, 'docacessorio', pk,
codigo = _gerar_codigo_autenticacao(pdf_bytes) [{
url_verificacao = _construir_url_verificacao(request, 'docacessorio', pk, codigo) 'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_simples,
}],
)
codigos_map[pk] = codigo codigos_map[pk] = codigo
nova_assinatura_info_visual = {'nome_assinante': nome_assinante, 'cargo': cargo, 'data_assinatura': data_simples}
auth_page_bytes = _gerar_pagina_autenticacao([nova_assinatura_info_visual], codigo, url_verificacao, page_width, page_height)
auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes))
output_pdf = PdfFileWriter()
for i in range(original_pdf.getNumPages()):
output_pdf.addPage(original_pdf.getPage(i))
output_pdf.addPage(auth_pdf.getPage(0))
buf = io.BytesIO()
output_pdf.write(buf)
pdf_para_assinar = buf.getvalue()
else: else:
pdf_para_assinar = pdf_bytes pdf_para_assinar = pdf_bytes

Loading…
Cancel
Save