Browse Source

Feat(Assinatura): Assinatura digital de PDF para Documento Acessório

Adiciona funcionalidade de assinatura digital com certificado A1 para
DocumentoAcessório, replicando o fluxo já existente em MateriaLegislativa.

- Novos campos no model: pdf_assinado, assinatura_info, assinado_em, assinado_por
- Views: assinar A1, PDF assinado, verificar e remover assinatura
- Template detail com botão de assinar, card de info e modal parametrizado
- Modal reutilizável via urlPrefix (retrocompatível com MateriaLegislativa)
- Contexto is_autor baseado nos operadores de autores da matéria-pai
pull/3858/head
rangelbruno 7 months ago
committed by rangelbruno
parent
commit
b165998e80
  1. 39
      sapl/materia/migrations/0090_add_assinatura_digital_documentoacessorio.py
  2. 29
      sapl/materia/models.py
  3. 14
      sapl/materia/urls.py
  4. 13
      sapl/materia/views.py
  5. 544
      sapl/materia/views_assinatura.py
  6. 7
      sapl/templates/materia/assinatura_modal.html
  7. 78
      sapl/templates/materia/documentoacessorio_detail.html

39
sapl/materia/migrations/0090_add_assinatura_digital_documentoacessorio.py

@ -0,0 +1,39 @@
# Generated by Django 2.2.28 on 2026-02-11 18:43
from django.conf import settings
import django.contrib.postgres.fields.jsonb
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', '0089_aumenta_justificativa_devolucao_750'),
]
operations = [
migrations.AddField(
model_name='documentoacessorio',
name='assinado_em',
field=models.DateTimeField(blank=True, null=True, verbose_name='Data/Hora da Assinatura'),
),
migrations.AddField(
model_name='documentoacessorio',
name='assinado_por',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='docacessorios_assinados', to=settings.AUTH_USER_MODEL, verbose_name='Assinado por'),
),
migrations.AddField(
model_name='documentoacessorio',
name='assinatura_info',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, help_text='Metadados do certificado digital usado na assinatura', null=True, verbose_name='Informações da Assinatura'),
),
migrations.AddField(
model_name='documentoacessorio',
name='pdf_assinado',
field=models.FileField(blank=True, max_length=300, null=True, storage=sapl.utils.OverwriteStorage(), upload_to=sapl.materia.models.anexo_upload_path, verbose_name='PDF Assinado'),
),
]

29
sapl/materia/models.py

@ -590,6 +590,35 @@ class DocumentoAcessorio(models.Model):
data_ultima_atualizacao = models.DateTimeField( data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True, verbose_name=_('Data')) blank=True, null=True, auto_now=True, verbose_name=_('Data'))
# Campos para assinatura digital
pdf_assinado = models.FileField(
max_length=300,
upload_to=anexo_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='docacessorios_assinados'
)
class Meta: class Meta:
verbose_name = _('Documento Acessório') verbose_name = _('Documento Acessório')
verbose_name_plural = _('Documentos Acessórios') verbose_name_plural = _('Documentos Acessórios')

14
sapl/materia/urls.py

@ -48,7 +48,9 @@ from sapl.materia.onlyoffice_materia_views import (
from sapl.materia.views_assinatura import ( from sapl.materia.views_assinatura import (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura, materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura,
detectar_aplicacao_a3 detectar_aplicacao_a3,
docacessorio_assinar_a1, docacessorio_pdf_assinado,
docacessorio_verificar_assinatura, docacessorio_remover_assinatura
) )
from sapl.norma.views import NormaPesquisaSimplesView from sapl.norma.views import NormaPesquisaSimplesView
from sapl.protocoloadm.views import ( from sapl.protocoloadm.views import (
@ -191,6 +193,16 @@ urlpatterns_materia = [
name='docacessorio_check_doc'), name='docacessorio_check_doc'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/forcesave$', docacessorio_forcesave, url(r'^materia/documentoacessorio/(?P<pk>\d+)/forcesave$', docacessorio_forcesave,
name='docacessorio_forcesave'), name='docacessorio_forcesave'),
# Assinatura Digital de Documento Acessório
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinar/a1/$', docacessorio_assinar_a1,
name='docacessorio_assinar_a1'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/pdf-assinado/$', docacessorio_pdf_assinado,
name='docacessorio_pdf_assinado'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/verificar-assinatura/$', docacessorio_verificar_assinatura,
name='docacessorio_verificar_assinatura'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/remover-assinatura/$', docacessorio_remover_assinatura,
name='docacessorio_remover_assinatura'),
] ]

13
sapl/materia/views.py

@ -1759,6 +1759,19 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
class DetailView(MasterDetailCrud.DetailView): class DetailView(MasterDetailCrud.DetailView):
template_name = "materia/documentoacessorio_detail.html" template_name = "materia/documentoacessorio_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
is_autor = False
if self.request.user.is_authenticated:
materia = self.object.materia
for autoria in materia.autoria_set.all():
if autoria.autor.operadores.filter(
id=self.request.user.id).exists():
is_autor = True
break
context['is_autor'] = is_autor
return context
class ListView(MasterDetailCrud.ListView): class ListView(MasterDetailCrud.ListView):
def hook_arquivo(self, obj, default, url): def hook_arquivo(self, obj, default, url):

544
sapl/materia/views_assinatura.py

@ -20,7 +20,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from sapl.base.models import AppConfig from sapl.base.models import AppConfig
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -808,3 +808,545 @@ def detectar_aplicacao_a3(request):
'portas_conhecidas': portas_conhecidas, 'portas_conhecidas': portas_conhecidas,
'instrucoes': 'O frontend deve tentar conectar a cada porta para detectar a aplicação.' 'instrucoes': 'O frontend deve tentar conectar a cada porta para detectar a aplicação.'
}) })
# =============================================================================
# Views de Assinatura Digital para Documento Acessório
# =============================================================================
def _gerar_pdf_do_docacessorio(docacessorio, request):
"""
Gera o PDF do documento acessório para assinatura.
Se é 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
from django.urls import reverse
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 = request.build_absolute_uri(
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}"
@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)
if docacessorio.pdf_assinado:
return JsonResponse({
'success': False,
'error': 'Este documento já possui um PDF assinado.'
}, status=400)
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
from pyhanko.keys import load_cert_from_pemder
from pyhanko_certvalidator import ValidationContext
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 JsonResponse({
'success': False,
'error': f'Erro ao carregar certificado: {error_detail}'
}, status=400)
cert_info = signer.signing_cert
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)
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped:
temp_stamped_path = temp_stamped.name
try:
from pyhanko.sign.fields import SigSeedSubFilter
from pyhanko.pdf_utils import text
from pyhanko.sign.general import SigningError
from PyPDF4 import PdfFileReader, PdfFileWriter
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
import re
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')
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)"
tipo_nome = AppConfig.attr('assinatura_nome')
if tipo_nome == 'C':
nome_assinante = parlamentar.nome_completo
else:
nome_assinante = parlamentar.nome_parlamentar
except:
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"
cpf = ""
try:
if parlamentar and parlamentar.cpf:
cpf = parlamentar.cpf
except:
pass
if not cpf:
subject_str = str(cert_info.subject)
cpf_match = re.search(r'\d{3}\.?\d{3}\.?\d{3}-?\d{2}', subject_str)
if cpf_match:
cpf = cpf_match.group()
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
# Criar carimbo visual e adicionar ao PDF ANTES de assinar
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())
stamp_buffer = io.BytesIO()
c = canvas.Canvas(stamp_buffer, pagesize=(page_width, page_height))
y_pos = 15 * mm
x_pos = 10 * mm
largura_carimbo = 70 * mm
altura_carimbo = 22 * mm
logo_width = 18 * mm
c.setStrokeColorRGB(0.5, 0.5, 0.5)
c.setLineWidth(0.5)
c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo)
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_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}")
try:
logo_path = None
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
possible_paths = [
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'),
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'),
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.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):
logo_path = path
break
if logo_path:
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)
stamp_pdf = PdfFileReader(stamp_buffer)
output_pdf = PdfFileWriter()
for page_num in range(original_pdf.getNumPages()):
page = original_pdf.getPage(page_num)
if page_num == original_pdf.getNumPages() - 1:
page.mergePage(stamp_pdf.getPage(0))
output_pdf.addPage(page)
with open(temp_stamped_path, 'wb') as f:
output_pdf.write(f)
# Assinar o PDF que já contém o carimbo
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()
filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False)
docacessorio.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'
}
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.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)

7
sapl/templates/materia/assinatura_modal.html

@ -250,8 +250,9 @@ document.addEventListener('DOMContentLoaded', function() {
materiaId: null, materiaId: null,
a3AppInfo: null, a3AppInfo: null,
init: function(materiaId) { init: function(materiaId, urlPrefix) {
this.materiaId = materiaId; this.materiaId = materiaId;
this.urlPrefix = urlPrefix || ('/materia/' + materiaId);
this.bindEvents(); this.bindEvents();
}, },
@ -408,7 +409,7 @@ document.addEventListener('DOMContentLoaded', function() {
formData.append('certificado', certificado); formData.append('certificado', certificado);
formData.append('senha', senha); formData.append('senha', senha);
fetch('/materia/' + this.materiaId + '/assinar/a1/', { fetch(this.urlPrefix + '/assinar/a1/', {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: { headers: {
@ -468,7 +469,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('sucesso-certificado-info').innerHTML = info; document.getElementById('sucesso-certificado-info').innerHTML = info;
var btnBaixar = document.getElementById('btn-baixar-assinado'); var btnBaixar = document.getElementById('btn-baixar-assinado');
btnBaixar.href = '/materia/' + this.materiaId + '/pdf-assinado/'; btnBaixar.href = this.urlPrefix + '/pdf-assinado/';
btnBaixar.style.display = 'inline-block'; btnBaixar.style.display = 'inline-block';
document.getElementById('btn-fechar-modal').textContent = 'Fechar'; document.getElementById('btn-fechar-modal').textContent = 'Fechar';

78
sapl/templates/materia/documentoacessorio_detail.html

@ -1,5 +1,5 @@
{% extends "crud/detail.html" %} {% extends "crud/detail.html" %}
{% load i18n %} {% load i18n tz %}
{% block sub_actions %} {% block sub_actions %}
{{ block.super }} {{ block.super }}
@ -10,4 +10,80 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if object.arquivo %}
<div class="actions btn-group btn-group-sm" role="group">
{% if object.pdf_assinado %}
<a class="btn btn-success" href="{% url 'sapl.materia:docacessorio_pdf_assinado' object.pk %}" target="_blank">
<i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a>
{% elif is_autor %}
<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 %} {% endblock sub_actions %}
{% block detail_content %}
{{ block.super }}
{% 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:docacessorio_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.arquivo and not object.pdf_assinado and is_autor %}
{% include "materia/assinatura_modal.html" %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.AssinaturaModal) {
window.AssinaturaModal.init({{ object.pk }}, '/materia/documentoacessorio/{{ object.pk }}');
}
});
</script>
{% endif %}
{% endblock extra_js %}

Loading…
Cancel
Save