Sistema de Apoio ao Processo Legislativo
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.
 
 
 
 
 

1754 lines
62 KiB

"""
Views para assinatura digital de PDFs de Matérias Legislativas.
Suporta certificados A1 (arquivo .pfx/.p12) e A3 (token USB/smartcard).
"""
import hashlib
import io
import json
import logging
import os
import tempfile
from datetime import datetime
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.files.base import ContentFile
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from sapl.base.models import AppConfig
from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa
from sapl.utils import build_onlyoffice_url
logger = logging.getLogger(__name__)
def _normalizar_assinatura_info(info):
"""Converte assinatura_info legado (dict) para lista de dicts."""
if info is None:
return []
if isinstance(info, dict):
return [info]
return info
# =============================================================================
# Funções auxiliares para página de autenticação
# =============================================================================
def _gerar_codigo_autenticacao(pdf_bytes):
"""Gera código de autenticação SHA-256 truncado (16 caracteres hex)."""
return hashlib.sha256(pdf_bytes).hexdigest()[:16].upper()
def _gerar_qrcode_image(url):
"""Gera imagem PNG de QR Code em memória (BytesIO)."""
import qrcode
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=2,
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf
def _construir_url_verificacao(request, tipo, pk, codigo):
"""Monta URL pública de verificação."""
if tipo == 'materia':
url_name = 'sapl.materia:materia_verificar_documento'
else:
url_name = 'sapl.materia:docacessorio_verificar_documento'
base_url = request.build_absolute_uri(
reverse(url_name, kwargs={'pk': pk})
)
return f'{base_url}?codigo={codigo}'
def _obter_nome_casa_legislativa():
"""Obtém o nome da Casa Legislativa do AppConfig."""
try:
from sapl.base.models import CasaLegislativa
casa = CasaLegislativa.objects.first()
if casa:
return casa.nome
except Exception:
pass
return "Casa Legislativa"
def _encontrar_logo():
"""Procura logo da câmara para usar na página de autenticação."""
try:
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
possible_paths = [
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'),
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'),
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo-camara-padrao.png'),
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo.png'),
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/pdflogo.png'),
]
logo_dir = os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo')
if os.path.exists(logo_dir):
for f in os.listdir(logo_dir):
if f.lower().endswith(('.png', '.jpg', '.jpeg')):
possible_paths.insert(0, os.path.join(logo_dir, f))
for path in possible_paths:
if os.path.exists(path):
return path
except Exception:
pass
return None
def _calcular_blocks_y_start(page_height):
"""Calcula posição Y onde blocos de assinatura começam na página de autenticação."""
from reportlab.lib.units import mm
y = page_height - 40 * mm
if _encontrar_logo():
y -= 3 * mm
y -= 6 * mm # título
y -= 4 * mm # subtítulo
y -= 10 * mm # gap separador
return y
def _posicao_bloco_assinatura(idx, page_width, page_height):
"""
Calcula posição (x1, y1, x2, y2) para bloco de assinatura no índice dado.
Usa o mesmo grid da página de autenticação (ReportLab).
"""
from reportlab.lib.units import mm
margin = 20 * mm
col_width = (page_width - 3 * margin) / 2
block_height = 20 * mm
block_spacing = 2 * mm
col_x = [margin, margin + col_width + margin]
y_start = _calcular_blocks_y_start(page_height)
col = idx % 2
row = idx // 2
x = col_x[col]
y_top = y_start - row * (block_height + block_spacing)
return (x, y_top - block_height, x + col_width, y_top)
def _gerar_pagina_autenticacao(assinaturas_info, codigo, url_verificacao,
page_width, page_height):
"""
Gera uma página PDF de autenticação com:
- Cabeçalho com nome da casa legislativa
- Blocos de assinatura em grade 2 colunas
- Código de autenticação
- QR Code
- URL de verificação
- Aviso legal (MP 2.200-2/2001)
Retorna bytes do PDF de uma página.
"""
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=(page_width, page_height))
nome_casa = _obter_nome_casa_legislativa()
center_x = page_width / 2
# ---- Cabeçalho ----
y = page_height - 40 * mm
# Logo da câmara (pequeno, acima do título)
logo_path = _encontrar_logo()
if logo_path:
try:
logo = ImageReader(logo_path)
logo_size = 15 * mm
c.drawImage(logo, center_x - logo_size / 2, y + 2 * mm,
width=logo_size, height=logo_size,
preserveAspectRatio=True, mask='auto')
y -= 3 * mm
except Exception:
pass
c.setFont("Helvetica-Bold", 14)
c.setFillColorRGB(0, 0, 0)
c.drawCentredString(center_x, y, "PÁGINA DE AUTENTICAÇÃO")
y -= 6 * mm
c.setFont("Helvetica", 10)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawCentredString(center_x, y, nome_casa)
y -= 4 * mm
# Linha separadora
c.setStrokeColorRGB(0.7, 0.7, 0.7)
c.setLineWidth(0.5)
margin = 20 * mm
c.line(margin, y, page_width - margin, y)
y -= 10 * mm
# ---- Blocos de assinatura (grade 2 colunas) ----
# Usa mesmas dimensões de _posicao_bloco_assinatura para consistência
# com os stamps pyhanko das assinaturas subsequentes.
col_width = (page_width - 3 * margin) / 2
block_height = 20 * mm
block_spacing = 2 * mm
col_x = [margin, margin + col_width + margin]
# Posição mínima Y: footer fixo no rodapé
footer_y = 68 * mm
for idx, assinatura in enumerate(assinaturas_info):
col = idx % 2
row = idx // 2
bx = col_x[col]
by_top = y - row * (block_height + block_spacing)
# Se vai ultrapassar a área do footer, para
if by_top - block_height < footer_y:
break
# Borda do bloco
c.setStrokeColorRGB(0.6, 0.6, 0.6)
c.setLineWidth(0.5)
c.rect(bx, by_top - block_height, col_width, block_height)
# Conteúdo do bloco
text_x = bx + 3 * mm
text_y = by_top - 3.5 * mm
c.setFont("Helvetica", 6)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(text_x, text_y, "Assinado digitalmente por")
text_y -= 3.5 * mm
c.setFont("Helvetica-Bold", 7)
c.setFillColorRGB(0, 0, 0)
nome = assinatura.get('nome_assinante', 'N/A').upper()
if len(nome) > 40:
nome = nome[:40] + "..."
c.drawString(text_x, text_y, nome)
text_y -= 3 * mm
c.setFont("Helvetica", 6)
c.setFillColorRGB(0.3, 0.3, 0.3)
cargo = assinatura.get('cargo', '')
if cargo:
c.drawString(text_x, text_y, cargo)
text_y -= 3 * mm
data = assinatura.get('data_assinatura', '')
c.drawString(text_x, text_y, f"Data: {data}")
text_y -= 3 * mm
c.setFont("Helvetica", 5)
c.setFillColorRGB(0.4, 0.4, 0.4)
c.drawString(text_x, text_y, f"Hash: {codigo}")
# ---- Footer fixo no rodapé ----
y_footer = footer_y
# Código de Autenticação
c.setFont("Helvetica-Bold", 11)
c.setFillColorRGB(0, 0, 0)
c.drawCentredString(center_x, y_footer,
f"Código de Autenticação: {codigo}")
y_footer -= 8 * mm
# QR Code
try:
qr_buf = _gerar_qrcode_image(url_verificacao)
qr_img = ImageReader(qr_buf)
qr_size = 25 * mm
c.drawImage(qr_img, center_x - qr_size / 2, y_footer - qr_size,
width=qr_size, height=qr_size)
y_footer -= qr_size + 3 * mm
except Exception as e:
logger.warning(f"Não foi possível gerar QR Code: {e}")
y_footer -= 3 * mm
# URL de verificação
c.setFont("Helvetica", 7)
c.setFillColorRGB(0.2, 0.2, 0.8)
c.drawCentredString(center_x, y_footer,
f"Verifique em: {url_verificacao}")
y_footer -= 8 * mm
# Aviso legal
c.setFont("Helvetica", 7)
c.setFillColorRGB(0.4, 0.4, 0.4)
c.drawCentredString(center_x, y_footer,
"Documento assinado digitalmente nos termos da")
y_footer -= 3.5 * mm
c.drawCentredString(center_x, y_footer,
"Medida Provisória nº 2.200-2/2001.")
c.save()
buf.seek(0)
return buf.read()
def _criar_stamp_style(nome_assinante, cargo, hash_doc=''):
"""
Cria um TextStampStyle customizado para assinaturas subsequentes,
mantendo visual consistente com a página de autenticação gerada
na primeira assinatura (Helvetica, borda simples, mesmo layout).
"""
from pyhanko.stamp import TextStampStyle, TextBoxStyle
# Monta texto do carimbo com cargo (se houver) + hash
linhas = ['Assinado digitalmente por', '%(signer)s']
if cargo:
linhas.append(cargo)
linhas.append('Data: %(ts)s')
if hash_doc:
linhas.append(f'Hash: {hash_doc}')
# Usa Helvetica para consistência com a página de autenticação (ReportLab)
font_kwargs = {}
try:
from pyhanko.pdf_utils.font import SimpleFontEngineFactory
font_kwargs['font'] = SimpleFontEngineFactory(
name='/Helvetica', avg_width=0.5
)
except (ImportError, Exception):
pass # Usa fonte padrão se Helvetica não disponível
return TextStampStyle(
stamp_text='\n'.join(linhas),
text_box_style=TextBoxStyle(
font_size=7,
leading=10,
border_width=1,
**font_kwargs,
),
border_width=0,
background=None,
background_opacity=0,
timestamp_format='%d/%m/%Y %H:%M',
)
def _obter_info_assinante(request, cert_info):
"""
Obtém informações do assinante (nome, cargo, tipo_cert).
Busca via Autor.operadores → Parlamentar (GenericFK).
Retorna (nome_assinante, cargo, tipo_cert).
"""
nome_assinante = request.user.get_full_name() or request.user.username
cargo = "Usuário do Sistema"
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 _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
cert_data = certificado_file.read()
import tempfile as tmp_module
with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert:
tmp_cert.write(cert_data)
tmp_cert_path = tmp_cert.name
try:
signer = signers.SimpleSigner.load_pkcs12(
pfx_file=tmp_cert_path,
passphrase=senha.encode('utf-8')
)
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:
error_detail = f'Detalhes: {error_msg}'
return None, JsonResponse({
'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):
"""
Valida datas do certificado.
Retorna error_response ou None se válido.
"""
now = timezone.now()
valid_before = cert_info.not_valid_before
valid_after = cert_info.not_valid_after
if valid_before.tzinfo is None:
import pytz
valid_before = pytz.UTC.localize(valid_before)
if valid_after.tzinfo is None:
import pytz
valid_after = pytz.UTC.localize(valid_after)
if now < valid_before or now > valid_after:
return JsonResponse({
'success': False,
'error': 'Certificado expirado ou ainda não válido.'
}, status=400)
return None
# =============================================================================
# Geração de PDFs
# =============================================================================
def _gerar_pdf_da_materia(materia, request):
"""
Gera o PDF da matéria para assinatura.
Primeiro tenta usar o PDF existente, depois converte DOCX via OnlyOffice.
Retorna bytes do PDF ou None em caso de erro.
"""
import requests as http_requests
import xml.etree.ElementTree as ET
# Se não tem documento, retorna None
if not materia.texto_original:
return None, "Matéria não possui documento de texto original."
file_name = materia.texto_original.name.lower()
# Se já é PDF, retorna diretamente
if file_name.endswith('.pdf'):
try:
with open(materia.texto_original.path, 'rb') as f:
return f.read(), None
except Exception as e:
logger.error(f"Erro ao ler arquivo PDF: {e}")
return None, f"Erro ao ler o arquivo PDF: {e}"
# Converter DOCX para PDF via OnlyOffice
from sapl.materia.onlyoffice_materia_views import generate_file_key
download_url = build_onlyoffice_url(
request,
reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': materia.pk})
)
# URL da API de conversão do OnlyOffice
conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx'
conversion_data = {
"async": False,
"filetype": "docx",
"key": generate_file_key("materia_sign", materia.pk, request.user.pk),
"outputtype": "pdf",
"title": f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf",
"url": download_url,
}
# Adiciona JWT se estiver habilitado
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
conversion_data['token'] = token
try:
headers = {'Content-Type': 'application/json'}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
headers['Authorization'] = f'Bearer {header_token}'
conversion_response = http_requests.post(
conversion_url,
json=conversion_data,
headers=headers,
timeout=60
)
if conversion_response.status_code != 200:
return None, f"Erro na conversão OnlyOffice: status={conversion_response.status_code}"
root = ET.fromstring(conversion_response.text)
error_elem = root.find('Error')
if error_elem is not None:
return None, f"Erro na conversão do documento: código {error_elem.text}"
file_url_elem = root.find('FileUrl')
if file_url_elem is None or not file_url_elem.text:
return None, "URL do PDF não retornada pelo OnlyOffice"
pdf_url = file_url_elem.text
pdf_response = http_requests.get(pdf_url, timeout=60)
if pdf_response.status_code != 200:
return None, f"Erro ao baixar PDF convertido: status={pdf_response.status_code}"
return pdf_response.content, None
except Exception as e:
logger.error(f"Erro na geração de PDF: {e}")
return None, f"Erro inesperado: {e}"
def _gerar_pdf_do_docacessorio(docacessorio, request):
"""
Gera o PDF do documento acessório para assinatura.
Se já é PDF, retorna direto. Se é DOCX, converte via OnlyOffice.
Retorna (bytes, None) ou (None, erro).
"""
import requests as http_requests
import xml.etree.ElementTree as ET
if not docacessorio.arquivo:
return None, "Documento acessório não possui arquivo."
file_name = docacessorio.arquivo.name.lower()
# Se já é PDF, retorna diretamente
if file_name.endswith('.pdf'):
try:
with open(docacessorio.arquivo.path, 'rb') as f:
return f.read(), None
except Exception as e:
logger.error(f"Erro ao ler arquivo PDF: {e}")
return None, f"Erro ao ler o arquivo PDF: {e}"
# Converter DOCX para PDF via OnlyOffice
from sapl.materia.onlyoffice_materia_views import generate_file_key
download_url = build_onlyoffice_url(
request,
reverse('sapl.materia:docacessorio_onlyoffice_download', kwargs={'pk': docacessorio.pk})
)
conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx'
conversion_data = {
"async": False,
"filetype": "docx",
"key": generate_file_key("docacessorio_sign", docacessorio.pk, request.user.pk),
"outputtype": "pdf",
"title": f"DocAcessorio_{docacessorio.pk}.pdf",
"url": download_url,
}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
conversion_data['token'] = token
try:
headers = {'Content-Type': 'application/json'}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
headers['Authorization'] = f'Bearer {header_token}'
conversion_response = http_requests.post(
conversion_url,
json=conversion_data,
headers=headers,
timeout=60
)
if conversion_response.status_code != 200:
return None, f"Erro na conversão OnlyOffice: status={conversion_response.status_code}"
root = ET.fromstring(conversion_response.text)
error_elem = root.find('Error')
if error_elem is not None:
return None, f"Erro na conversão do documento: código {error_elem.text}"
file_url_elem = root.find('FileUrl')
if file_url_elem is None or not file_url_elem.text:
return None, "URL do PDF não retornada pelo OnlyOffice"
pdf_url = file_url_elem.text
pdf_response = http_requests.get(pdf_url, timeout=60)
if pdf_response.status_code != 200:
return None, f"Erro ao baixar PDF convertido: status={pdf_response.status_code}"
return pdf_response.content, None
except Exception as e:
logger.error(f"Erro na geração de PDF do doc acessório: {e}")
return None, f"Erro inesperado: {e}"
# =============================================================================
# Assinatura Digital de Matéria Legislativa
# =============================================================================
@login_required
@csrf_exempt
@require_http_methods(["POST"])
def materia_assinar_a1(request, pk):
"""
Assina o PDF da matéria com certificado A1 (arquivo .pfx/.p12).
Parâmetros POST:
- certificado: arquivo .pfx ou .p12
- senha: senha do certificado
"""
materia = get_object_or_404(MateriaLegislativa, pk=pk)
# Verifica se o usuário atual já assinou
assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info)
if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes):
return JsonResponse({
'success': False,
'error': 'Você já assinou esta matéria.'
}, status=400)
ja_tem_pdf_assinado = bool(materia.pdf_assinado)
# Obtém dados do formulário
certificado_file = request.FILES.get('certificado')
senha = request.POST.get('senha', '')
if not certificado_file:
return JsonResponse({
'success': False,
'error': 'Certificado não informado.'
}, status=400)
if not senha:
return JsonResponse({
'success': False,
'error': 'Senha do certificado não informada.'
}, status=400)
# Gera o PDF da matéria
pdf_bytes, error = _gerar_pdf_da_materia(materia, request)
if error:
return JsonResponse({
'success': False,
'error': error
}, status=400)
try:
from pyhanko.sign import signers, fields
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
signer, error_response = _carregar_certificado(certificado_file, senha)
if error_response:
return error_response
cert_info = signer.signing_cert
error_response = _validar_certificado(cert_info)
if error_response:
return error_response
# Cria arquivo temporário para o PDF com página de autenticação
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped:
temp_stamped_path = temp_stamped.name
try:
from pyhanko.sign.fields import SigFieldSpec
from PyPDF4 import PdfFileReader, PdfFileWriter
from reportlab.lib.units import mm
# Informações do assinante
nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info)
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')
# Número da assinatura (0-indexed)
n_assinatura = len(assinaturas_existentes)
sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital'
if ja_tem_pdf_assinado:
# ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado =====
# A página de autenticação já existe (criada na 1ª assinatura).
# Posicionar campo de assinatura na última página (autenticação).
with open(materia.pdf_assinado.path, 'rb') as f:
existing_pdf_bytes = f.read()
# Calcular posição do campo de assinatura na página de autenticação
# Usa o mesmo grid da página gerada na primeira assinatura
temp_pypdf = PdfFileReader(io.BytesIO(existing_pdf_bytes))
auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1)
auth_page_width = float(auth_page.mediaBox.getWidth())
auth_page_height = float(auth_page.mediaBox.getHeight())
x1, y1, x2, y2 = _posicao_bloco_assinatura(
n_assinatura, auth_page_width, auth_page_height
)
signed_buffer = io.BytesIO()
with io.BytesIO(existing_pdf_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
# Determina a última página
from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader
from pyhanko.sign.signers.pdf_signer import PdfSigner
temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes))
last_page_idx = temp_reader.root['/Pages']['/Count'] - 1
# Adiciona campo de assinatura com posição visual
sig_field = SigFieldSpec(
sig_field_name=sig_field_name,
on_page=last_page_idx,
box=(x1, y1, x2, y2)
)
fields.append_signature_field(w, sig_field)
meta = signers.PdfSignatureMetadata(
field_name=sig_field_name,
location='Câmara Municipal',
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante
)
# Usa PdfSigner com stamp_style customizado para
# manter visual consistente com a página de autenticação
hash_doc = materia.codigo_autenticacao or ''
stamp_style = _criar_stamp_style(
nome_assinante, cargo, hash_doc
)
pdf_signer = PdfSigner(
meta,
signer=signer,
stamp_style=stamp_style,
)
pdf_signer.sign_pdf(
w,
existing_fields_only=True,
appearance_text_params={'signer': nome_assinante},
output=signed_buffer,
)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
else:
# ===== PRIMEIRA ASSINATURA: Página de autenticação + pyhanko sign =====
# Lê o PDF original para obter as dimensões
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1)
page_box = last_page.mediaBox
page_width = float(page_box.getWidth())
page_height = float(page_box.getHeight())
# Gerar código de autenticação
codigo = _gerar_codigo_autenticacao(pdf_bytes)
# Construir URL de verificação
url_verificacao = _construir_url_verificacao(
request, 'materia', pk, codigo
)
# Nova assinatura info (para incluir na página de autenticação)
nova_assinatura_info = {
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_simples,
}
# Gerar a página de autenticação
auth_page_bytes = _gerar_pagina_autenticacao(
[nova_assinatura_info],
codigo, url_verificacao,
page_width, page_height
)
# Montar PDF: original + página de autenticação
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))
# Anexar página de autenticação
output_pdf.addPage(auth_page_pdf.getPage(0))
# Salvar em arquivo temporário
with open(temp_stamped_path, 'wb') as f:
output_pdf.write(f)
# Assinar o PDF combinado
with open(temp_stamped_path, 'rb') as stamped_file:
stamped_bytes = stamped_file.read()
signed_buffer = io.BytesIO()
with io.BytesIO(stamped_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
meta = signers.PdfSignatureMetadata(
field_name='AssinaturaDigital',
location='Câmara Municipal',
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante
)
signers.sign_pdf(
w,
meta,
signer=signer,
output=signed_buffer
)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
# Salvar código de autenticação
materia.codigo_autenticacao = codigo
# Salva o PDF assinado no modelo
filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False)
# Salva informações da assinatura (lista de dicts)
nova_assinatura = {
'tipo_certificado': 'A1',
'tipo_certificado_display': f'{tipo_cert} – A1',
'subject': str(cert_info.subject),
'issuer': str(cert_info.issuer),
'serial': str(cert_info.serial_number),
'valid_from': cert_info.not_valid_before.isoformat(),
'valid_to': cert_info.not_valid_after.isoformat(),
'signed_by': request.user.username,
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_formatada,
'validade_juridica': 'Assinatura Eletrônica Qualificada'
}
assinaturas_existentes.append(nova_assinatura)
materia.assinatura_info = assinaturas_existentes
materia.assinado_em = timezone.now()
materia.assinado_por = request.user
materia.save()
logger.info(f"Matéria {materia.pk} assinada por {request.user.username}")
return JsonResponse({
'success': True,
'message': 'PDF assinado com sucesso!',
'certificado': {
'nome': str(cert_info.subject),
'validade': cert_info.not_valid_after.strftime('%d/%m/%Y')
}
})
finally:
# Remove arquivo temporário
if os.path.exists(temp_stamped_path):
os.unlink(temp_stamped_path)
except ImportError:
logger.error("pyhanko não está instalado")
return JsonResponse({
'success': False,
'error': 'Biblioteca de assinatura não instalada. Contate o administrador.'
}, status=500)
except Exception as e:
logger.error(f"Erro ao assinar PDF: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao assinar o PDF: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["POST"])
def materia_assinar_a3_preparar(request, pk):
"""
Prepara a assinatura A3 gerando o hash do PDF para ser assinado pelo token.
Retorna:
- hash: hash SHA-256 do PDF em hexadecimal
- pdf_base64: PDF codificado em base64 (para assinatura no cliente)
"""
import base64
materia = get_object_or_404(MateriaLegislativa, pk=pk)
# Verifica permissão
if not request.user.has_perm('materia.change_materialegislativa'):
return JsonResponse({
'success': False,
'error': 'Você não tem permissão para assinar esta matéria.'
}, status=403)
# Verifica se já está assinada
if materia.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Esta matéria já possui um PDF assinado.'
}, status=400)
# Gera o PDF da matéria
pdf_bytes, error = _gerar_pdf_da_materia(materia, request)
if error:
return JsonResponse({
'success': False,
'error': error
}, status=400)
# Calcula o hash SHA-256
pdf_hash = hashlib.sha256(pdf_bytes).hexdigest()
# Codifica o PDF em base64
pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8')
return JsonResponse({
'success': True,
'hash': pdf_hash,
'pdf_base64': pdf_base64,
'materia_id': materia.pk
})
@login_required
@require_http_methods(["POST"])
@csrf_exempt
def materia_assinar_a3_finalizar(request, pk):
"""
Finaliza a assinatura A3 incorporando a assinatura recebida do token.
Parâmetros POST (JSON):
- signature: assinatura em base64
- certificate: certificado em base64
- certificate_chain: cadeia de certificados em base64 (opcional)
"""
import base64
materia = get_object_or_404(MateriaLegislativa, pk=pk)
# Verifica permissão
if not request.user.has_perm('materia.change_materialegislativa'):
return JsonResponse({
'success': False,
'error': 'Você não tem permissão para assinar esta matéria.'
}, status=403)
# Verifica se já está assinada
if materia.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Esta matéria já possui um PDF assinado.'
}, status=400)
try:
data = json.loads(request.body.decode('utf-8'))
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Dados inválidos.'
}, status=400)
signature_b64 = data.get('signature')
certificate_b64 = data.get('certificate')
if not signature_b64 or not certificate_b64:
return JsonResponse({
'success': False,
'error': 'Assinatura ou certificado não informados.'
}, status=400)
# Gera o PDF da matéria
pdf_bytes, error = _gerar_pdf_da_materia(materia, request)
if error:
return JsonResponse({
'success': False,
'error': error
}, status=400)
try:
from pyhanko.sign import signers, fields
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.sign.signers.pdf_cms import ExternalSigner
from cryptography import x509
from cryptography.hazmat.backends import default_backend
# Decodifica o certificado
cert_der = base64.b64decode(certificate_b64)
cert = x509.load_der_x509_certificate(cert_der, default_backend())
# Verifica validade do certificado
now = datetime.utcnow()
if now < cert.not_valid_before or now > cert.not_valid_after:
return JsonResponse({
'success': False,
'error': 'Certificado expirado ou ainda não válido.'
}, status=400)
# Decodifica a assinatura
signature = base64.b64decode(signature_b64)
# Cria arquivo temporário para o PDF assinado
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_signed:
temp_signed_path = temp_signed.name
try:
with io.BytesIO(pdf_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
# Adiciona campo de assinatura
sig_field = fields.SigFieldSpec(
sig_field_name='AssinaturaDigital',
box=(50, 50, 250, 100)
)
# Metadados da assinatura
meta = signers.PdfSignatureMetadata(
field_name='AssinaturaDigital',
location='Câmara Municipal',
reason='Assinatura Digital de Matéria Legislativa (A3)',
name=request.user.get_full_name() or request.user.username
)
# Para assinatura A3, precisamos de integração mais complexa
# Por enquanto, retornamos erro informando que A3 requer aplicação local
return JsonResponse({
'success': False,
'error': 'Assinatura A3 requer aplicação local. Use o Assinador SERPRO ou similar.'
}, status=501)
finally:
if os.path.exists(temp_signed_path):
os.unlink(temp_signed_path)
except ImportError as e:
logger.error(f"Biblioteca não instalada: {e}")
return JsonResponse({
'success': False,
'error': 'Biblioteca de assinatura não instalada.'
}, status=500)
except Exception as e:
logger.error(f"Erro ao finalizar assinatura A3: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao processar assinatura: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def materia_pdf_assinado(request, pk):
"""
Retorna o PDF assinado da matéria para download/visualização.
"""
materia = get_object_or_404(MateriaLegislativa, pk=pk)
if not materia.pdf_assinado:
messages.error(request, 'Esta matéria não possui PDF assinado.')
return redirect('sapl.materia:materialegislativa_detail', pk=pk)
try:
with open(materia.pdf_assinado.path, 'rb') as f:
content = f.read()
filename = f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}_ASSINADO.pdf"
filename = filename.replace(' ', '_').replace('/', '-')
response = HttpResponse(content, content_type='application/pdf')
response['Content-Disposition'] = f'inline; filename="{filename}"'
return response
except Exception as e:
logger.error(f"Erro ao ler PDF assinado: {e}")
messages.error(request, 'Erro ao ler o arquivo PDF assinado.')
return redirect('sapl.materia:materialegislativa_detail', pk=pk)
@login_required
@require_http_methods(["GET"])
def materia_verificar_assinatura(request, pk):
"""
Verifica a assinatura digital do PDF da matéria.
Retorna informações sobre as assinaturas encontradas.
"""
materia = get_object_or_404(MateriaLegislativa, pk=pk)
if not materia.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Esta matéria não possui PDF assinado.',
'assinado': False
})
try:
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko.pdf_utils.reader import PdfFileReader
with open(materia.pdf_assinado.path, 'rb') as f:
reader = PdfFileReader(f)
# Lista de assinaturas encontradas
assinaturas = []
# Verifica cada assinatura no PDF
for sig_field_name in reader.embedded_signatures:
try:
sig = reader.embedded_signatures[sig_field_name]
# Informações básicas da assinatura
sig_info = {
'campo': sig_field_name,
'assinante': str(sig.signer_cert.subject) if sig.signer_cert else 'Desconhecido',
'data': sig.self_reported_timestamp.isoformat() if sig.self_reported_timestamp else None,
}
assinaturas.append(sig_info)
except Exception as sig_error:
logger.warning(f"Erro ao verificar assinatura {sig_field_name}: {sig_error}")
assinaturas.append({
'campo': sig_field_name,
'erro': str(sig_error)
})
return JsonResponse({
'success': True,
'assinado': True,
'total_assinaturas': len(assinaturas),
'assinaturas': assinaturas,
'info_salva': materia.assinatura_info
})
except ImportError:
# Se pyhanko não estiver instalado, retorna info salva no modelo
return JsonResponse({
'success': True,
'assinado': True,
'info_salva': materia.assinatura_info,
'verificacao_disponivel': False
})
except Exception as e:
logger.error(f"Erro ao verificar assinatura: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao verificar assinatura: {str(e)}',
'assinado': True,
'info_salva': materia.assinatura_info
})
@login_required
@require_http_methods(["POST"])
def materia_remover_assinatura(request, pk):
"""
Remove a assinatura digital da matéria.
Apenas superusuários podem executar esta ação.
"""
if not request.user.is_superuser:
return JsonResponse({
'success': False,
'error': 'Apenas administradores podem remover assinaturas.'
}, status=403)
materia = get_object_or_404(MateriaLegislativa, pk=pk)
if not materia.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Esta matéria não possui PDF assinado.'
}, status=400)
try:
# Remove o arquivo
materia.pdf_assinado.delete(save=False)
# Limpa os campos
materia.pdf_assinado = None
materia.assinatura_info = None
materia.assinado_em = None
materia.assinado_por = None
materia.codigo_autenticacao = None
materia.save()
logger.info(f"Assinatura da matéria {materia.pk} removida por {request.user.username}")
return JsonResponse({
'success': True,
'message': 'Assinatura removida com sucesso.'
})
except Exception as e:
logger.error(f"Erro ao remover assinatura: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao remover assinatura: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def detectar_aplicacao_a3(request):
"""
Endpoint para verificar se há uma aplicação de assinatura A3 rodando localmente.
O frontend usa isso para decidir se mostra a opção A3.
"""
# Lista de portas comuns usadas por aplicações de assinatura
portas_conhecidas = [
{'nome': 'Assinador SERPRO', 'porta': 10443},
{'nome': 'Web PKI Local', 'porta': 5000},
{'nome': 'Signer Local', 'porta': 8080},
]
return JsonResponse({
'success': True,
'portas_conhecidas': portas_conhecidas,
'instrucoes': 'O frontend deve tentar conectar a cada porta para detectar a aplicação.'
})
# =============================================================================
# Views de Assinatura Digital para Documento Acessório
# =============================================================================
@login_required
@csrf_exempt
@require_http_methods(["POST"])
def docacessorio_assinar_a1(request, pk):
"""
Assina o PDF do documento acessório com certificado A1 (arquivo .pfx/.p12).
"""
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
# Verifica se o usuário atual já assinou
assinaturas_existentes = _normalizar_assinatura_info(docacessorio.assinatura_info)
if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes):
return JsonResponse({
'success': False,
'error': 'Você já assinou este documento.'
}, status=400)
ja_tem_pdf_assinado = bool(docacessorio.pdf_assinado)
certificado_file = request.FILES.get('certificado')
senha = request.POST.get('senha', '')
if not certificado_file:
return JsonResponse({
'success': False,
'error': 'Certificado não informado.'
}, status=400)
if not senha:
return JsonResponse({
'success': False,
'error': 'Senha do certificado não informada.'
}, status=400)
pdf_bytes, error = _gerar_pdf_do_docacessorio(docacessorio, request)
if error:
return JsonResponse({
'success': False,
'error': error
}, status=400)
try:
from pyhanko.sign import signers, fields
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
signer, error_response = _carregar_certificado(certificado_file, senha)
if error_response:
return error_response
cert_info = signer.signing_cert
error_response = _validar_certificado(cert_info)
if error_response:
return error_response
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped:
temp_stamped_path = temp_stamped.name
try:
from pyhanko.sign.fields import SigFieldSpec
from PyPDF4 import PdfFileReader, PdfFileWriter
from reportlab.lib.units import mm
nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info)
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')
# Número da assinatura (0-indexed)
n_assinatura = len(assinaturas_existentes)
sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital'
if ja_tem_pdf_assinado:
# ===== ASSINATURA SUBSEQUENTE =====
with open(docacessorio.pdf_assinado.path, 'rb') as f:
existing_pdf_bytes = f.read()
# Calcular posição na página de autenticação
# Usa o mesmo grid da página gerada na primeira assinatura
temp_pypdf = PdfFileReader(io.BytesIO(existing_pdf_bytes))
auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1)
auth_page_width = float(auth_page.mediaBox.getWidth())
auth_page_height = float(auth_page.mediaBox.getHeight())
x1, y1, x2, y2 = _posicao_bloco_assinatura(
n_assinatura, auth_page_width, auth_page_height
)
signed_buffer = io.BytesIO()
with io.BytesIO(existing_pdf_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader
from pyhanko.sign.signers.pdf_signer import PdfSigner
temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes))
last_page_idx = temp_reader.root['/Pages']['/Count'] - 1
sig_field = SigFieldSpec(
sig_field_name=sig_field_name,
on_page=last_page_idx,
box=(x1, y1, x2, y2)
)
fields.append_signature_field(w, sig_field)
meta = signers.PdfSignatureMetadata(
field_name=sig_field_name,
location='Câmara Municipal',
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante
)
# Usa PdfSigner com stamp_style customizado para
# manter visual consistente com a página de autenticação
hash_doc = docacessorio.codigo_autenticacao or ''
stamp_style = _criar_stamp_style(
nome_assinante, cargo, hash_doc
)
pdf_signer = PdfSigner(
meta,
signer=signer,
stamp_style=stamp_style,
)
pdf_signer.sign_pdf(
w,
existing_fields_only=True,
appearance_text_params={'signer': nome_assinante},
output=signed_buffer,
)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
else:
# ===== PRIMEIRA ASSINATURA: Página de autenticação + pyhanko sign =====
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1)
page_box = last_page.mediaBox
page_width = float(page_box.getWidth())
page_height = float(page_box.getHeight())
# Gerar código de autenticação
codigo = _gerar_codigo_autenticacao(pdf_bytes)
# Construir URL de verificação
url_verificacao = _construir_url_verificacao(
request, 'docacessorio', pk, codigo
)
# Nova assinatura info
nova_assinatura_info = {
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_simples,
}
# Gerar a página de autenticação
auth_page_bytes = _gerar_pagina_autenticacao(
[nova_assinatura_info],
codigo, url_verificacao,
page_width, page_height
)
# Montar PDF: original + página de autenticação
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 open(temp_stamped_path, 'wb') as f:
output_pdf.write(f)
with open(temp_stamped_path, 'rb') as stamped_file:
stamped_bytes = stamped_file.read()
signed_buffer = io.BytesIO()
with io.BytesIO(stamped_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
meta = signers.PdfSignatureMetadata(
field_name='AssinaturaDigital',
location='Câmara Municipal',
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante
)
signers.sign_pdf(
w,
meta,
signer=signer,
output=signed_buffer
)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
# Salvar código de autenticação
docacessorio.codigo_autenticacao = codigo
filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False)
nova_assinatura = {
'tipo_certificado': 'A1',
'tipo_certificado_display': f'{tipo_cert} – A1',
'subject': str(cert_info.subject),
'issuer': str(cert_info.issuer),
'serial': str(cert_info.serial_number),
'valid_from': cert_info.not_valid_before.isoformat(),
'valid_to': cert_info.not_valid_after.isoformat(),
'signed_by': request.user.username,
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_formatada,
'validade_juridica': 'Assinatura Eletrônica Qualificada'
}
assinaturas_existentes.append(nova_assinatura)
docacessorio.assinatura_info = assinaturas_existentes
docacessorio.assinado_em = timezone.now()
docacessorio.assinado_por = request.user
docacessorio.save()
logger.info(f"Documento acessório {docacessorio.pk} assinado por {request.user.username}")
return JsonResponse({
'success': True,
'message': 'PDF assinado com sucesso!',
'certificado': {
'nome': str(cert_info.subject),
'validade': cert_info.not_valid_after.strftime('%d/%m/%Y')
}
})
finally:
if os.path.exists(temp_stamped_path):
os.unlink(temp_stamped_path)
except ImportError:
logger.error("pyhanko não está instalado")
return JsonResponse({
'success': False,
'error': 'Biblioteca de assinatura não instalada. Contate o administrador.'
}, status=500)
except Exception as e:
logger.error(f"Erro ao assinar PDF do doc acessório: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao assinar o PDF: {str(e)}'
}, status=500)
@login_required
@require_http_methods(["GET"])
def docacessorio_pdf_assinado(request, pk):
"""
Retorna o PDF assinado do documento acessório para download/visualização.
"""
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
if not docacessorio.pdf_assinado:
messages.error(request, 'Este documento não possui PDF assinado.')
return redirect('sapl.materia:documentoacessorio_detail',
pk=docacessorio.materia.pk, dpk=pk)
try:
with open(docacessorio.pdf_assinado.path, 'rb') as f:
content = f.read()
filename = f"DocAcessorio_{docacessorio.pk}_{docacessorio.nome}_ASSINADO.pdf"
filename = filename.replace(' ', '_').replace('/', '-')
response = HttpResponse(content, content_type='application/pdf')
response['Content-Disposition'] = f'inline; filename="{filename}"'
return response
except Exception as e:
logger.error(f"Erro ao ler PDF assinado do doc acessório: {e}")
messages.error(request, 'Erro ao ler o arquivo PDF assinado.')
return redirect('sapl.materia:documentoacessorio_detail',
pk=docacessorio.materia.pk, dpk=pk)
@login_required
@require_http_methods(["GET"])
def docacessorio_verificar_assinatura(request, pk):
"""
Verifica a assinatura digital do PDF do documento acessório.
"""
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
if not docacessorio.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Este documento não possui PDF assinado.',
'assinado': False
})
try:
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko.pdf_utils.reader import PdfFileReader
with open(docacessorio.pdf_assinado.path, 'rb') as f:
reader = PdfFileReader(f)
assinaturas = []
for sig_field_name in reader.embedded_signatures:
try:
sig = reader.embedded_signatures[sig_field_name]
sig_info = {
'campo': sig_field_name,
'assinante': str(sig.signer_cert.subject) if sig.signer_cert else 'Desconhecido',
'data': sig.self_reported_timestamp.isoformat() if sig.self_reported_timestamp else None,
}
assinaturas.append(sig_info)
except Exception as sig_error:
logger.warning(f"Erro ao verificar assinatura {sig_field_name}: {sig_error}")
assinaturas.append({
'campo': sig_field_name,
'erro': str(sig_error)
})
return JsonResponse({
'success': True,
'assinado': True,
'total_assinaturas': len(assinaturas),
'assinaturas': assinaturas,
'info_salva': docacessorio.assinatura_info
})
except ImportError:
return JsonResponse({
'success': True,
'assinado': True,
'info_salva': docacessorio.assinatura_info,
'verificacao_disponivel': False
})
except Exception as e:
logger.error(f"Erro ao verificar assinatura do doc acessório: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao verificar assinatura: {str(e)}',
'assinado': True,
'info_salva': docacessorio.assinatura_info
})
@login_required
@require_http_methods(["POST"])
def docacessorio_remover_assinatura(request, pk):
"""
Remove a assinatura digital do documento acessório.
Apenas superusuários podem executar esta ação.
"""
if not request.user.is_superuser:
return JsonResponse({
'success': False,
'error': 'Apenas administradores podem remover assinaturas.'
}, status=403)
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
if not docacessorio.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Este documento não possui PDF assinado.'
}, status=400)
try:
docacessorio.pdf_assinado.delete(save=False)
docacessorio.pdf_assinado = None
docacessorio.assinatura_info = None
docacessorio.assinado_em = None
docacessorio.assinado_por = None
docacessorio.codigo_autenticacao = None
docacessorio.save()
logger.info(f"Assinatura do doc acessório {docacessorio.pk} removida por {request.user.username}")
return JsonResponse({
'success': True,
'message': 'Assinatura removida com sucesso.'
})
except Exception as e:
logger.error(f"Erro ao remover assinatura do doc acessório: {e}")
return JsonResponse({
'success': False,
'error': f'Erro ao remover assinatura: {str(e)}'
}, status=500)
# =============================================================================
# Views Públicas de Verificação de Documento
# =============================================================================
@require_http_methods(["GET"])
def materia_verificar_documento(request, pk):
"""
Página pública de verificação de autenticidade de Matéria Legislativa.
Valida o código de autenticação e exibe status + lista de assinaturas.
"""
materia = get_object_or_404(MateriaLegislativa, pk=pk)
codigo_informado = request.GET.get('codigo', '').strip().upper()
valido = (
bool(materia.codigo_autenticacao)
and bool(codigo_informado)
and materia.codigo_autenticacao == codigo_informado
)
assinaturas = _normalizar_assinatura_info(materia.assinatura_info)
# Filtrar campos sensíveis das assinaturas
assinaturas_publicas = []
for a in assinaturas:
assinaturas_publicas.append({
'nome_assinante': a.get('nome_assinante', ''),
'cargo': a.get('cargo', ''),
'data_assinatura': a.get('data_assinatura', ''),
'tipo_certificado_display': a.get('tipo_certificado_display', a.get('tipo_certificado', '')),
})
context = {
'object': materia,
'tipo_documento': 'Matéria Legislativa',
'descricao_documento': str(materia),
'ementa': materia.ementa,
'codigo_autenticacao': materia.codigo_autenticacao or '',
'codigo_informado': codigo_informado,
'valido': valido,
'assinaturas': assinaturas_publicas,
'tem_assinatura': bool(materia.pdf_assinado),
}
return render(request, 'materia/verificar_assinatura.html', context)
@require_http_methods(["GET"])
def docacessorio_verificar_documento(request, pk):
"""
Página pública de verificação de autenticidade de Documento Acessório.
Valida o código de autenticação e exibe status + lista de assinaturas.
"""
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
codigo_informado = request.GET.get('codigo', '').strip().upper()
valido = (
bool(docacessorio.codigo_autenticacao)
and bool(codigo_informado)
and docacessorio.codigo_autenticacao == codigo_informado
)
assinaturas = _normalizar_assinatura_info(docacessorio.assinatura_info)
assinaturas_publicas = []
for a in assinaturas:
assinaturas_publicas.append({
'nome_assinante': a.get('nome_assinante', ''),
'cargo': a.get('cargo', ''),
'data_assinatura': a.get('data_assinatura', ''),
'tipo_certificado_display': a.get('tipo_certificado_display', a.get('tipo_certificado', '')),
})
context = {
'object': docacessorio,
'tipo_documento': 'Documento Acessório',
'descricao_documento': str(docacessorio),
'ementa': docacessorio.ementa,
'codigo_autenticacao': docacessorio.codigo_autenticacao or '',
'codigo_informado': codigo_informado,
'valido': valido,
'assinaturas': assinaturas_publicas,
'tem_assinatura': bool(docacessorio.pdf_assinado),
'materia': docacessorio.materia,
}
return render(request, 'materia/verificar_assinatura.html', context)