Browse Source

Feat(Matéria): Assinatura digital de PDF com certificado A1

- Implementa sistema de assinatura digital para matérias legislativas
- Suporte a certificados A1 (.pfx/.p12) com validação de senha
- Carimbo visual no PDF com nome, CPF, data e logo da câmara
- Modal de assinatura com interface amigável
- Campos no modelo: pdf_assinado, assinatura_info, assinado_em, assinado_por
- Migração 0088 para novos campos de assinatura
- Adiciona dependências: pyhanko, pyhanko-certvalidator, cryptography

UI/UX:
- Move subnav (tabs de navegação) para abaixo do menu principal
- Novo estilo minimalista para as tabs com indicador azul
- Scroll horizontal em dispositivos móveis
pull/3858/head
rangelbruno 8 months ago
parent
commit
f21c77ce4b
  1. 5
      requirements/requirements.txt
  2. 63
      sapl/materia/migrations/0088_materialegislativa_assinatura_digital.py
  3. 30
      sapl/materia/models.py
  4. 21
      sapl/materia/urls.py
  5. 810
      sapl/materia/views_assinatura.py
  6. 73
      sapl/static/sapl/css/subnav.css
  7. 14
      sapl/templates/base.html
  8. 551
      sapl/templates/materia/assinatura_modal.html
  9. 71
      sapl/templates/materia/materialegislativa_detail.html
  10. 1
      sapl/templates/menus/subnav.html

5
requirements/requirements.txt

@ -45,5 +45,10 @@ python-docx==1.1.0
PyJWT==2.8.0
requests==2.31.0
# Digital Signature (PDF signing with certificates)
pyhanko==0.21.0
pyhanko-certvalidator==0.26.3
cryptography>=41.0.0
git+https://github.com/interlegis/django-admin-bootstrapped

63
sapl/materia/migrations/0088_materialegislativa_assinatura_digital.py

@ -0,0 +1,63 @@
# Generated migration for digital signature fields
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.db import migrations, models
import django.db.models.deletion
import sapl.materia.models
import sapl.utils
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('materia', '0087_update_viewdb_materiaemtramitacao'),
]
operations = [
migrations.AddField(
model_name='materialegislativa',
name='pdf_assinado',
field=models.FileField(
blank=True,
max_length=300,
null=True,
storage=sapl.utils.OverwriteStorage(),
upload_to=sapl.materia.models.materia_upload_path,
verbose_name='PDF Assinado'
),
),
migrations.AddField(
model_name='materialegislativa',
name='assinatura_info',
field=JSONField(
blank=True,
help_text='Metadados do certificado digital usado na assinatura',
null=True,
verbose_name='Informações da Assinatura'
),
),
migrations.AddField(
model_name='materialegislativa',
name='assinado_em',
field=models.DateTimeField(
blank=True,
null=True,
verbose_name='Data/Hora da Assinatura'
),
),
migrations.AddField(
model_name='materialegislativa',
name='assinado_por',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='materias_assinadas',
to=settings.AUTH_USER_MODEL,
verbose_name='Assinado por'
),
),
]

30
sapl/materia/models.py

@ -3,6 +3,7 @@ from datetime import datetime
from django.contrib.auth.models import Group
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.db.models.functions import Concat
from django.template import defaultfilters
@ -304,6 +305,35 @@ class MateriaLegislativa(models.Model):
blank=True, null=True
)
# Campos para assinatura digital
pdf_assinado = models.FileField(
max_length=300,
upload_to=materia_upload_path,
blank=True,
null=True,
verbose_name=_('PDF Assinado'),
storage=OverwriteStorage()
)
assinatura_info = JSONField(
blank=True,
null=True,
verbose_name=_('Informações da Assinatura'),
help_text=_('Metadados do certificado digital usado na assinatura')
)
assinado_em = models.DateTimeField(
blank=True,
null=True,
verbose_name=_('Data/Hora da Assinatura')
)
assinado_por = models.ForeignKey(
get_settings_auth_user_model(),
verbose_name=_('Assinado por'),
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='materias_assinadas'
)
class Meta:
verbose_name = _('Matéria Legislativa')
verbose_name_plural = _('Matérias Legislativas')

21
sapl/materia/urls.py

@ -41,6 +41,11 @@ from sapl.materia.onlyoffice_materia_views import (
docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback,
materia_gerar_pdf_assinatura
)
from sapl.materia.views_assinatura import (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura,
detectar_aplicacao_a3
)
from sapl.norma.views import NormaPesquisaSimplesView
from sapl.protocoloadm.views import (
FichaPesquisaAdmView, FichaSelecionaAdmView
@ -149,6 +154,22 @@ urlpatterns_materia = [
url(r'^materia/(?P<pk>\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura,
name='materia_pdf_assinatura'),
# Assinatura Digital de Matéria Legislativa
url(r'^materia/(?P<pk>\d+)/assinar/a1/$', materia_assinar_a1,
name='materia_assinar_a1'),
url(r'^materia/(?P<pk>\d+)/assinar/a3/preparar/$', materia_assinar_a3_preparar,
name='materia_assinar_a3_preparar'),
url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar,
name='materia_assinar_a3_finalizar'),
url(r'^materia/(?P<pk>\d+)/pdf-assinado/$', materia_pdf_assinado,
name='materia_pdf_assinado'),
url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura,
name='materia_verificar_assinatura'),
url(r'^materia/(?P<pk>\d+)/remover-assinatura/$', materia_remover_assinatura,
name='materia_remover_assinatura'),
url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3,
name='detectar_aplicacao_a3'),
# OnlyOffice endpoints para Documento Acessório
url(r'^materia/documentoacessorio/(?P<pk>\d+)/onlyoffice/editor$', docacessorio_onlyoffice_editor,
name='docacessorio_onlyoffice_editor'),

810
sapl/materia/views_assinatura.py

@ -0,0 +1,810 @@
"""
Views para assinatura digital de PDFs de Matérias Legislativas.
Suporta certificados A1 (arquivo .pfx/.p12) e A3 (token USB/smartcard).
"""
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
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from sapl.materia.models import MateriaLegislativa
logger = logging.getLogger(__name__)
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
from django.urls import reverse
# 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 = request.build_absolute_uri(
reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': materia.pk})
)
# Substituir pelo nome do container na rede Docker
host = request.get_host()
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000')
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000')
conversion_url = 'http://onlyoffice:80/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
if 'onlyoffice/' in pdf_url and not pdf_url.startswith('http://onlyoffice:'):
pdf_url = pdf_url.replace('http://onlyoffice/', 'http://onlyoffice:80/')
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}"
@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)
# Permissão: qualquer usuário autenticado pode assinar
# (a autenticação é garantida pelo decorator @login_required)
# Verifica se já está assinada
if materia.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Esta matéria já possui um PDF assinado.'
}, status=400)
# 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
from pyhanko.keys import load_cert_from_pemder
from pyhanko_certvalidator import ValidationContext
# Lê o certificado
cert_data = certificado_file.read()
# Cria o assinador - salva temporariamente o certificado
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')
)
# Limpa arquivo temporário do certificado após carregar
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}")
# Limpa arquivo temporário do certificado
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 JsonResponse({
'success': False,
'error': f'Erro ao carregar certificado: {error_detail}'
}, status=400)
# Verifica validade do certificado
cert_info = signer.signing_cert
now = timezone.now()
# Converte datas do certificado para timezone-aware se necessário
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)
# 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:
from pyhanko.sign.fields import SigSeedSubFilter
from pyhanko.pdf_utils import text
from pyhanko.sign.general import SigningError
# Informações do assinante
nome_assinante = request.user.get_full_name() or request.user.username
data_assinatura = timezone.localtime(timezone.now())
data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S')
# Tenta obter cargo do usuário (se for parlamentar/autor)
cargo = "Usuário do Sistema"
parlamentar = None
try:
from sapl.parlamentares.models import Parlamentar
parlamentar = Parlamentar.objects.filter(
usuario=request.user
).first()
if parlamentar:
cargo = "Vereador(a)"
nome_assinante = parlamentar.nome_parlamentar
except:
pass
# Determina tipo de certificado baseado no issuer
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"
# Assina o PDF com assinatura invisível (sem campo visual do pyhanko)
with io.BytesIO(pdf_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
# Metadados da assinatura (sem campo visível)
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
)
# Executa assinatura invisível
with open(temp_signed_path, 'wb') as outf:
signers.sign_pdf(
w,
meta,
signer=signer,
output=outf
)
# Adiciona carimbo de texto visível ao PDF assinado
# Usando reportlab para adicionar o texto
from PyPDF4 import PdfFileReader, PdfFileWriter
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
# Tenta obter CPF do parlamentar ou do certificado
cpf = ""
try:
if parlamentar and parlamentar.cpf:
cpf = parlamentar.cpf
except:
pass
# Se não encontrou CPF no parlamentar, tenta extrair do certificado
if not cpf:
subject_str = str(cert_info.subject)
import re
cpf_match = re.search(r'\d{3}\.?\d{3}\.?\d{3}-?\d{2}', subject_str)
if cpf_match:
cpf = cpf_match.group()
# Formata data
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
# Cria PDF com o carimbo de assinatura
stamp_buffer = io.BytesIO()
c = canvas.Canvas(stamp_buffer, pagesize=A4)
# Posição do carimbo (canto inferior esquerdo)
y_pos = 15 * mm
x_pos = 10 * mm
largura_carimbo = 70 * mm # Aumentado para caber texto + logo
altura_carimbo = 22 * mm
logo_width = 18 * mm
# Desenha borda fina do carimbo
c.setStrokeColorRGB(0.5, 0.5, 0.5)
c.setLineWidth(0.5)
c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo)
# Texto do carimbo (lado esquerdo)
c.setFont("Helvetica", 6)
c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawString(x_pos + 3*mm, y_pos + 17*mm, "Assinado digitalmente por")
c.setFont("Helvetica-Bold", 7)
c.setFillColorRGB(0, 0, 0)
# Nome em maiúsculas, quebra se muito longo
nome_upper = nome_assinante.upper()
if len(nome_upper) > 28:
nome_upper = nome_upper[:28] + "..."
c.drawString(x_pos + 3*mm, y_pos + 12*mm, nome_upper)
c.setFont("Helvetica", 6)
c.setFillColorRGB(0.3, 0.3, 0.3)
if cpf:
c.drawString(x_pos + 3*mm, y_pos + 7*mm, f"CPF: {cpf}")
c.drawString(x_pos + 3*mm, y_pos + 3*mm, f"Data: {data_simples}")
else:
c.drawString(x_pos + 3*mm, y_pos + 5*mm, f"Data: {data_simples}")
# Logo da câmara (lado direito)
try:
# Procura o logotipo da câmara em vários locais possíveis
logo_path = None
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
possible_paths = [
# Logo padrão do sistema
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'),
# Media folder
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'),
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'),
]
# Procura qualquer imagem na pasta de logotipo do media
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):
logo_path = path
break
if logo_path:
# Desenha logo no lado direito do carimbo
logo = ImageReader(logo_path)
logo_x = x_pos + largura_carimbo - logo_width - 2*mm
logo_y = y_pos + 2*mm
c.drawImage(logo, logo_x, logo_y,
width=logo_width, height=logo_width,
preserveAspectRatio=True, mask='auto')
except Exception as logo_error:
logger.warning(f"Não foi possível adicionar logo: {logo_error}")
c.save()
stamp_buffer.seek(0)
# Mescla o carimbo com o PDF assinado
stamp_pdf = PdfFileReader(stamp_buffer)
signed_pdf = PdfFileReader(open(temp_signed_path, 'rb'))
output_pdf = PdfFileWriter()
# Adiciona o carimbo em todas as páginas
for page_num in range(signed_pdf.getNumPages()):
page = signed_pdf.getPage(page_num)
if page_num == signed_pdf.getNumPages() - 1: # Última página
page.mergePage(stamp_pdf.getPage(0))
output_pdf.addPage(page)
# Salva o PDF final com carimbo
final_buffer = io.BytesIO()
output_pdf.write(final_buffer)
final_buffer.seek(0)
signed_pdf_content = final_buffer.read()
# 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
materia.assinatura_info = {
'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'
}
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_signed_path):
os.unlink(temp_signed_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
import hashlib
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:
# Cria o signer externo com a assinatura pré-computada
# Nota: Esta é uma implementação simplificada
# Em produção, seria necessário usar o ExternalSigner corretamente
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.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 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.'
})

73
sapl/static/sapl/css/subnav.css

@ -0,0 +1,73 @@
/* Subnav - Tabs de navegação abaixo do menu principal */
.subnav-wrapper {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
padding: 0;
}
.subnav-wrapper .nav-pills {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 2px;
justify-content: flex-start;
}
.subnav-wrapper .nav-item {
margin: 0;
}
.subnav-wrapper .nav-link {
display: block;
padding: 10px 16px;
color: #495057;
text-decoration: none;
font-size: 14px;
font-weight: 500;
border-radius: 0;
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
background: transparent;
}
.subnav-wrapper .nav-link:hover {
color: #007bff;
background: rgba(0,123,255,0.05);
text-decoration: none;
}
.subnav-wrapper .nav-link.active {
color: #007bff;
border-bottom-color: #007bff;
background: #fff;
}
/* Dropdown */
.subnav-wrapper .dropdown-menu {
margin-top: 0;
border-radius: 0 0 4px 4px;
border-top: none;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
/* Responsivo */
@media (max-width: 768px) {
.subnav-wrapper .nav-pills {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.subnav-wrapper .nav-pills::-webkit-scrollbar {
display: none;
}
.subnav-wrapper .nav-link {
padding: 8px 12px;
font-size: 13px;
white-space: nowrap;
}
}

14
sapl/templates/base.html

@ -35,6 +35,7 @@
{% block webpack_loader_chunks_css %}
{% endblock webpack_loader_chunks_css %}
<link rel="stylesheet" href="{% static 'sapl/css/subnav.css' %}">
{% endblock %}
</head>
@ -56,9 +57,6 @@
</span>
</a>
</div>
<div class="hidden-print">
{% block sections_nav %} {% subnav %} {% endblock sections_nav %}
</div>
</div>
</header>
{% endblock main_header %}
@ -105,6 +103,16 @@
</div>
</nav>
{% endblock navigation %}
{# Subnav de conteúdo - aparece abaixo da navbar principal #}
{% block content_subnav %}
<div class="subnav-wrapper hidden-print">
<div class="container">
{% subnav %}
</div>
</div>
{% endblock content_subnav %}
{% else %}
<div class="btn-cancel-iframe">
<a href="?iframe=0" target="_blank"><i class="fa fa-2x fa-arrows-alt"></i></a>

551
sapl/templates/materia/assinatura_modal.html

@ -0,0 +1,551 @@
{% load i18n %}
<!-- Modal de Assinatura Digital -->
<div class="modal fade" id="assinaturaModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="assinaturaModalLabel">
<i class="fa fa-certificate"></i> {% trans "Assinatura Digital de PDF" %}
</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<!-- Seleção do tipo de certificado -->
<div id="selecao-tipo-certificado">
<div class="alert alert-info">
<i class="fa fa-info-circle"></i>
{% trans "Selecione o tipo de certificado digital que você possui:" %}
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="card h-100 tipo-certificado-card" data-tipo="A1">
<div class="card-body text-center">
<i class="fa fa-file-archive-o fa-3x text-primary mb-3"></i>
<h5 class="card-title">{% trans "Certificado A1" %}</h5>
<p class="card-text text-muted">
{% trans "Arquivo .pfx ou .p12 armazenado no computador" %}
</p>
<button type="button" class="btn btn-primary btn-selecionar-tipo" data-tipo="A1">
<i class="fa fa-check"></i> {% trans "Selecionar" %}
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 tipo-certificado-card" data-tipo="A3">
<div class="card-body text-center">
<i class="fa fa-usb fa-3x text-success mb-3"></i>
<h5 class="card-title">{% trans "Certificado A3" %}</h5>
<p class="card-text text-muted">
{% trans "Token USB ou Smartcard" %}
</p>
<button type="button" class="btn btn-success btn-selecionar-tipo" data-tipo="A3">
<i class="fa fa-check"></i> {% trans "Selecionar" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Formulário A1 -->
<div id="formulario-a1" style="display: none;">
<div class="mb-3">
<button type="button" class="btn btn-link btn-voltar-selecao">
<i class="fa fa-arrow-left"></i> {% trans "Voltar" %}
</button>
</div>
<form id="form-assinatura-a1" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-group">
<label for="certificado-a1">
<i class="fa fa-file"></i> {% trans "Arquivo do Certificado (.pfx ou .p12)" %}
</label>
<div class="custom-file">
<input type="file" class="custom-file-input" id="certificado-a1" name="certificado" accept=".pfx,.p12" required>
<label class="custom-file-label" for="certificado-a1" data-browse="{% trans 'Procurar' %}">
{% trans "Selecione o arquivo do certificado..." %}
</label>
</div>
<small class="form-text text-muted">
{% trans "Selecione o arquivo .pfx ou .p12 do seu certificado A1" %}
</small>
</div>
<div class="form-group">
<label for="senha-a1">
<i class="fa fa-lock"></i> {% trans "Senha do Certificado" %}
</label>
<input type="password" class="form-control" id="senha-a1" name="senha" required
placeholder="{% trans 'Digite a senha do certificado' %}">
<small class="form-text text-muted">
{% trans "A senha é usada apenas para esta operação e não é armazenada" %}
</small>
</div>
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i>
{% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %}
</div>
</form>
</div>
<!-- Formulário A3 -->
<div id="formulario-a3" style="display: none;">
<div class="mb-3">
<button type="button" class="btn btn-link btn-voltar-selecao">
<i class="fa fa-arrow-left"></i> {% trans "Voltar" %}
</button>
</div>
<div id="a3-detectando" class="text-center py-4">
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3"></i>
<h5>{% trans "Detectando aplicação de assinatura..." %}</h5>
<p class="text-muted">
{% trans "Procurando por aplicações de assinatura A3 instaladas no computador..." %}
</p>
</div>
<div id="a3-nao-detectado" style="display: none;">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i>
<strong>{% trans "Aplicação não detectada" %}</strong>
</div>
<p>
{% trans "Para usar certificado A3, você precisa ter uma aplicação de assinatura instalada e em execução." %}
</p>
<h6>{% trans "Opções disponíveis:" %}</h6>
<ul>
<li>
<a href="https://www.serpro.gov.br/links-fixos-superiores/assinador-digital/assinador-serpro" target="_blank">
{% trans "Assinador SERPRO" %}
</a>
- {% trans "Gratuito, desenvolvido pelo governo brasileiro" %}
</li>
<li>
<a href="https://www.lacunasoftware.com/pt/pki-express" target="_blank">
{% trans "Lacuna Web PKI" %}
</a>
- {% trans "Solução comercial com versão gratuita limitada" %}
</li>
</ul>
<p class="text-muted">
{% trans "Após instalar e iniciar a aplicação, clique em 'Tentar novamente'." %}
</p>
<button type="button" class="btn btn-primary btn-tentar-novamente-a3">
<i class="fa fa-refresh"></i> {% trans "Tentar novamente" %}
</button>
</div>
<div id="a3-detectado" style="display: none;">
<div class="alert alert-success">
<i class="fa fa-check-circle"></i>
<strong>{% trans "Aplicação detectada!" %}</strong>
<span id="a3-app-nome"></span>
</div>
<div class="form-group">
<label for="certificado-a3-select">
<i class="fa fa-certificate"></i> {% trans "Selecione o certificado:" %}
</label>
<select class="form-control" id="certificado-a3-select">
<option value="">{% trans "Carregando certificados..." %}</option>
</select>
</div>
<div id="a3-cert-info" class="card mb-3" style="display: none;">
<div class="card-body">
<h6 class="card-title">{% trans "Informações do Certificado" %}</h6>
<p class="mb-1"><strong>{% trans "Titular:" %}</strong> <span id="a3-cert-subject"></span></p>
<p class="mb-1"><strong>{% trans "Emissor:" %}</strong> <span id="a3-cert-issuer"></span></p>
<p class="mb-0"><strong>{% trans "Validade:" %}</strong> <span id="a3-cert-validade"></span></p>
</div>
</div>
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i>
{% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %}
</div>
</div>
</div>
<!-- Status da assinatura -->
<div id="status-assinatura" style="display: none;">
<div id="status-processando" class="text-center py-4">
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3"></i>
<h5>{% trans "Assinando documento..." %}</h5>
<p class="text-muted" id="status-mensagem">
{% trans "Aguarde enquanto o documento é assinado digitalmente." %}
</p>
<div class="progress mt-3" style="max-width: 400px; margin: 0 auto;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
</div>
</div>
<div id="status-sucesso" style="display: none;">
<div class="text-center py-4">
<i class="fa fa-check-circle fa-3x text-success mb-3"></i>
<h5>{% trans "Documento assinado com sucesso!" %}</h5>
<p id="sucesso-certificado-info"></p>
</div>
</div>
<div id="status-erro" style="display: none;">
<div class="alert alert-danger">
<i class="fa fa-exclamation-circle"></i>
<strong>{% trans "Erro ao assinar documento" %}</strong>
<p id="erro-mensagem" class="mb-0 mt-2"></p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="btn-fechar-modal">
<i class="fa fa-times"></i> {% trans "Fechar" %}
</button>
<button type="button" class="btn btn-primary" id="btn-assinar" style="display: none;">
<i class="fa fa-pencil"></i> {% trans "Assinar Documento" %}
</button>
<a href="#" class="btn btn-success" id="btn-baixar-assinado" style="display: none;" target="_blank">
<i class="fa fa-download"></i> {% trans "Baixar PDF Assinado" %}
</a>
</div>
</div>
</div>
</div>
<!-- Script do Modal -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Função auxiliar para obter CSRF token
function getCSRFToken() {
// Primeiro tenta do formulário
var tokenInput = document.querySelector('#form-assinatura-a1 [name=csrfmiddlewaretoken]');
if (tokenInput) {
return tokenInput.value;
}
// Fallback: busca do cookie
var name = 'csrftoken';
var cookieValue = null;
if (document.cookie && document.cookie !== '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
var AssinaturaModal = {
tipoSelecionado: null,
materiaId: null,
a3AppInfo: null,
init: function(materiaId) {
this.materiaId = materiaId;
this.bindEvents();
},
bindEvents: function() {
var self = this;
// Seleção de tipo de certificado
document.querySelectorAll('.btn-selecionar-tipo').forEach(function(btn) {
btn.addEventListener('click', function() {
self.selecionarTipo(this.dataset.tipo);
});
});
// Voltar para seleção
document.querySelectorAll('.btn-voltar-selecao').forEach(function(btn) {
btn.addEventListener('click', function() {
self.voltarSelecao();
});
});
// Botão assinar
document.getElementById('btn-assinar').addEventListener('click', function() {
self.assinar();
});
// Tentar novamente A3
var btnTentarNovamente = document.querySelector('.btn-tentar-novamente-a3');
if (btnTentarNovamente) {
btnTentarNovamente.addEventListener('click', function() {
self.detectarA3();
});
}
// Atualizar label do arquivo
document.getElementById('certificado-a1').addEventListener('change', function() {
var fileName = this.files[0] ? this.files[0].name : 'Selecione o arquivo do certificado...';
this.nextElementSibling.textContent = fileName;
});
// Reset ao fechar modal
$('#assinaturaModal').on('hidden.bs.modal', function() {
self.resetModal();
});
},
selecionarTipo: function(tipo) {
this.tipoSelecionado = tipo;
document.getElementById('selecao-tipo-certificado').style.display = 'none';
if (tipo === 'A1') {
document.getElementById('formulario-a1').style.display = 'block';
document.getElementById('btn-assinar').style.display = 'inline-block';
} else if (tipo === 'A3') {
document.getElementById('formulario-a3').style.display = 'block';
this.detectarA3();
}
},
voltarSelecao: function() {
this.tipoSelecionado = null;
document.getElementById('formulario-a1').style.display = 'none';
document.getElementById('formulario-a3').style.display = 'none';
document.getElementById('btn-assinar').style.display = 'none';
document.getElementById('selecao-tipo-certificado').style.display = 'block';
},
detectarA3: function() {
var self = this;
document.getElementById('a3-detectando').style.display = 'block';
document.getElementById('a3-nao-detectado').style.display = 'none';
document.getElementById('a3-detectado').style.display = 'none';
// Tentar detectar aplicações conhecidas
var portasParaTestar = [
{ nome: 'Assinador SERPRO', porta: 10443, url: 'https://localhost:10443/certificados' },
{ nome: 'Web PKI Local', porta: 5000, url: 'http://localhost:5000/api/certificates' }
];
var tentativas = 0;
var detectado = false;
portasParaTestar.forEach(function(app) {
fetch(app.url, { method: 'GET', mode: 'no-cors' })
.then(function() {
if (!detectado) {
detectado = true;
self.a3AppInfo = app;
self.mostrarA3Detectado(app);
}
})
.catch(function() {
tentativas++;
if (tentativas >= portasParaTestar.length && !detectado) {
self.mostrarA3NaoDetectado();
}
});
});
// Timeout para mostrar não detectado
setTimeout(function() {
if (!detectado) {
self.mostrarA3NaoDetectado();
}
}, 5000);
},
mostrarA3Detectado: function(app) {
document.getElementById('a3-detectando').style.display = 'none';
document.getElementById('a3-detectado').style.display = 'block';
document.getElementById('a3-app-nome').textContent = ' - ' + app.nome;
document.getElementById('btn-assinar').style.display = 'inline-block';
// Aqui carregaria os certificados da aplicação
// Por enquanto, mostra mensagem informativa
var select = document.getElementById('certificado-a3-select');
select.innerHTML = '<option value="">A integração com ' + app.nome + ' requer configuração adicional</option>';
},
mostrarA3NaoDetectado: function() {
document.getElementById('a3-detectando').style.display = 'none';
document.getElementById('a3-nao-detectado').style.display = 'block';
},
assinar: function() {
if (this.tipoSelecionado === 'A1') {
this.assinarA1();
} else if (this.tipoSelecionado === 'A3') {
this.assinarA3();
}
},
assinarA1: function() {
var self = this;
var form = document.getElementById('form-assinatura-a1');
var certificado = document.getElementById('certificado-a1').files[0];
var senha = document.getElementById('senha-a1').value;
if (!certificado) {
alert('Selecione o arquivo do certificado.');
return;
}
if (!senha) {
alert('Digite a senha do certificado.');
return;
}
this.mostrarStatus('processando');
this.atualizarProgresso(10, 'Enviando certificado...');
var formData = new FormData();
formData.append('certificado', certificado);
formData.append('senha', senha);
fetch('/materia/' + this.materiaId + '/assinar/a1/', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(function(response) {
self.atualizarProgresso(60, 'Processando assinatura...');
return response.json();
})
.then(function(data) {
self.atualizarProgresso(100, 'Finalizando...');
if (data.success) {
self.mostrarSucesso(data);
} else {
self.mostrarErro(data.error);
}
})
.catch(function(error) {
self.mostrarErro('Erro de comunicação: ' + error.message);
});
},
assinarA3: function() {
// A assinatura A3 requer integração com aplicação local
this.mostrarErro('A assinatura A3 requer uma aplicação local em execução. Por favor, use o Assinador SERPRO ou similar.');
},
mostrarStatus: function(tipo) {
document.getElementById('formulario-a1').style.display = 'none';
document.getElementById('formulario-a3').style.display = 'none';
document.getElementById('selecao-tipo-certificado').style.display = 'none';
document.getElementById('btn-assinar').style.display = 'none';
document.getElementById('status-assinatura').style.display = 'block';
document.getElementById('status-processando').style.display = tipo === 'processando' ? 'block' : 'none';
document.getElementById('status-sucesso').style.display = tipo === 'sucesso' ? 'block' : 'none';
document.getElementById('status-erro').style.display = tipo === 'erro' ? 'block' : 'none';
},
atualizarProgresso: function(porcentagem, mensagem) {
var progressBar = document.querySelector('#status-processando .progress-bar');
progressBar.style.width = porcentagem + '%';
if (mensagem) {
document.getElementById('status-mensagem').textContent = mensagem;
}
},
mostrarSucesso: function(data) {
this.mostrarStatus('sucesso');
var info = '';
if (data.certificado) {
info = 'Assinado por: ' + data.certificado.nome + '<br>Válido até: ' + data.certificado.validade;
}
document.getElementById('sucesso-certificado-info').innerHTML = info;
var btnBaixar = document.getElementById('btn-baixar-assinado');
btnBaixar.href = '/materia/' + this.materiaId + '/pdf-assinado/';
btnBaixar.style.display = 'inline-block';
document.getElementById('btn-fechar-modal').textContent = 'Fechar';
},
mostrarErro: function(mensagem) {
this.mostrarStatus('erro');
document.getElementById('erro-mensagem').textContent = mensagem;
},
resetModal: function() {
this.tipoSelecionado = null;
this.a3AppInfo = null;
document.getElementById('selecao-tipo-certificado').style.display = 'block';
document.getElementById('formulario-a1').style.display = 'none';
document.getElementById('formulario-a3').style.display = 'none';
document.getElementById('status-assinatura').style.display = 'none';
document.getElementById('btn-assinar').style.display = 'none';
document.getElementById('btn-baixar-assinado').style.display = 'none';
// Limpar formulário A1
document.getElementById('form-assinatura-a1').reset();
document.querySelector('#certificado-a1 + label').textContent = 'Selecione o arquivo do certificado...';
// Reset A3
document.getElementById('a3-detectando').style.display = 'block';
document.getElementById('a3-nao-detectado').style.display = 'none';
document.getElementById('a3-detectado').style.display = 'none';
// Reset status
document.getElementById('status-processando').style.display = 'block';
document.getElementById('status-sucesso').style.display = 'none';
document.getElementById('status-erro').style.display = 'none';
document.querySelector('#status-processando .progress-bar').style.width = '0%';
}
};
// Expor globalmente
window.AssinaturaModal = AssinaturaModal;
});
</script>
<style>
.tipo-certificado-card {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.tipo-certificado-card:hover {
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.tipo-certificado-card.selected {
border-color: #28a745;
background-color: #f8fff8;
}
#assinaturaModal .modal-header {
border-bottom: none;
}
#assinaturaModal .modal-footer {
border-top: none;
}
#assinaturaModal .custom-file-label::after {
content: "Procurar";
}
#status-processando .progress {
height: 8px;
}
#a3-cert-info {
background-color: #f8f9fa;
}
</style>

71
sapl/templates/materia/materialegislativa_detail.html

@ -17,9 +17,15 @@
{% endif %}
{% if object.numero_protocolo and object.texto_original %}
<div class="actions btn-group btn-group-sm" role="group">
<a class="btn btn-success" href="{% url 'sapl.materia:materia_pdf_assinatura' object.pk %}" target="_blank">
<i class="fa fa-file-pdf-o"></i> {% trans "Gerar PDF para Assinatura" %}
</a>
{% if object.pdf_assinado %}
<a class="btn btn-success" href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" target="_blank">
<i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a>
{% else %}
<button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal">
<i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %}
</button>
{% endif %}
</div>
{% endif %}
{% endblock sub_actions %}
@ -162,4 +168,63 @@
{% endif %}
</div>
{% endif %}
{% if object.pdf_assinado %}
<div class="row mt-3">
<div class="col-12">
<div class="card border-success">
<div class="card-header bg-success text-white">
<i class="fa fa-certificate"></i> {% trans "Assinatura Digital" %}
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p class="mb-1"><strong>{% trans "Status:" %}</strong>
<span class="badge badge-success">{% trans "Documento Assinado" %}</span>
</p>
{% if object.assinado_em %}
<p class="mb-1"><strong>{% trans "Data/Hora:" %}</strong> {{ object.assinado_em|localtime|date:"d/m/Y H:i:s" }}</p>
{% endif %}
{% if object.assinado_por %}
<p class="mb-1"><strong>{% trans "Assinado por:" %}</strong> {{ object.assinado_por.get_full_name|default:object.assinado_por.username }}</p>
{% endif %}
</div>
<div class="col-md-6">
{% if object.assinatura_info %}
<p class="mb-1"><strong>{% trans "Tipo de Certificado:" %}</strong> {{ object.assinatura_info.tipo_certificado|default:"A1" }}</p>
{% if object.assinatura_info.subject %}
<p class="mb-1"><strong>{% trans "Certificado:" %}</strong> {{ object.assinatura_info.subject|truncatechars:50 }}</p>
{% endif %}
{% if object.assinatura_info.valid_to %}
<p class="mb-1"><strong>{% trans "Validade:" %}</strong> {{ object.assinatura_info.valid_to|slice:":10" }}</p>
{% endif %}
{% endif %}
</div>
</div>
<div class="mt-2">
<a href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" class="btn btn-sm btn-success" target="_blank">
<i class="fa fa-download"></i> {% trans "Baixar PDF Assinado" %}
</a>
<a href="https://validar.iti.gov.br/" class="btn btn-sm btn-outline-info" target="_blank">
<i class="fa fa-external-link"></i> {% trans "Validar no ITI" %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock detail_content %}
{% block extra_js %}
{{ block.super }}
{% if object.numero_protocolo and object.texto_original and not object.pdf_assinado %}
{% include "materia/assinatura_modal.html" %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.AssinaturaModal) {
window.AssinaturaModal.init({{ object.pk }});
}
});
</script>
{% endif %}
{% endblock extra_js %}

1
sapl/templates/menus/subnav.html

@ -1,4 +1,3 @@
{% load i18n %}
{% if menu %}

Loading…
Cancel
Save