Browse Source

Feat(Assinatura): Suporte a múltiplas assinaturas digitais em Matéria e Doc Acessório

Permite que múltiplos co-autores assinem o mesmo documento sequencialmente.
O primeiro assinante usa o fluxo atual (ReportLab + pyhanko), e os subsequentes
adicionam assinatura incremental preservando as anteriores. Template mostra
botões "Ver PDF Assinado" e "Assinar" simultaneamente, e o card lista todos
os assinantes. Backward-compatible com assinatura_info legado (dict → lista).

Co-Authored-By: rangelbruno <rangelbruno@users.noreply.github.com>
pull/3858/head
rangelbruno 7 months ago
parent
commit
4c93190a9b
  1. 19
      sapl/materia/views.py
  2. 655
      sapl/materia/views_assinatura.py
  3. 3
      sapl/templates/materia/assinatura_modal.html
  4. 56
      sapl/templates/materia/documentoacessorio_detail.html
  5. 56
      sapl/templates/materia/materialegislativa_detail.html

19
sapl/materia/views.py

@ -1819,6 +1819,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
) )
) )
) )
# Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas
context['ja_assinou'] = any(
a.get('signed_by') == u.username
for a in assinaturas
) if u.is_authenticated else False
return context return context
class ListView(MasterDetailCrud.ListView): class ListView(MasterDetailCrud.ListView):
@ -2184,6 +2194,15 @@ class MateriaLegislativaCrud(Crud):
) )
) )
# Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas
context['ja_assinou'] = any(
a.get('signed_by') == self.request.user.username
for a in assinaturas
) if self.request.user.is_authenticated else False
return context return context
class ListView(Crud.ListView, RedirectView): class ListView(Crud.ListView, RedirectView):

655
sapl/materia/views_assinatura.py

@ -25,6 +25,28 @@ from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa
logger = logging.getLogger(__name__) 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
def _calcular_posicao_carimbo(n):
"""
Calcula posição (x, y) do n-ésimo carimbo (n começa em 0).
Layout em grade: 2 colunas, múltiplas linhas.
"""
from reportlab.lib.units import mm
col = n % 2
row = n // 2
x = (10 + col * 75) * mm
y = (15 + row * 25) * mm
return x, y
def _gerar_pdf_da_materia(materia, request): def _gerar_pdf_da_materia(materia, request):
""" """
Gera o PDF da matéria para assinatura. Gera o PDF da matéria para assinatura.
@ -132,13 +154,16 @@ def materia_assinar_a1(request, pk):
# Permissão: qualquer usuário autenticado pode assinar # Permissão: qualquer usuário autenticado pode assinar
# (a autenticação é garantida pelo decorator @login_required) # (a autenticação é garantida pelo decorator @login_required)
# Verifica se já está assinada # Verifica se o usuário atual já assinou
if materia.pdf_assinado: assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info)
if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes):
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'error': 'Esta matéria já possui um PDF assinado.' 'error': 'Você já assinou esta matéria.'
}, status=400) }, status=400)
ja_tem_pdf_assinado = bool(materia.pdf_assinado)
# Obtém dados do formulário # Obtém dados do formulário
certificado_file = request.FILES.get('certificado') certificado_file = request.FILES.get('certificado')
senha = request.POST.get('senha', '') senha = request.POST.get('senha', '')
@ -232,7 +257,7 @@ def materia_assinar_a1(request, pk):
temp_stamped_path = temp_stamped.name temp_stamped_path = temp_stamped.name
try: try:
from pyhanko.sign.fields import SigSeedSubFilter from pyhanko.sign.fields import SigSeedSubFilter, SigFieldSpec
from pyhanko.pdf_utils import text from pyhanko.pdf_utils import text
from pyhanko.sign.general import SigningError from pyhanko.sign.general import SigningError
from PyPDF4 import PdfFileReader, PdfFileWriter from PyPDF4 import PdfFileReader, PdfFileWriter
@ -290,136 +315,208 @@ def materia_assinar_a1(request, pk):
# Formata data # Formata data
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
# ===== PASSO 1: Criar carimbo visual e adicionar ao PDF ANTES de assinar ===== # Número da assinatura (0-indexed)
n_assinatura = len(assinaturas_existentes)
# Lê o PDF original para obter as dimensões reais da última página sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital'
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) if ja_tem_pdf_assinado:
page_box = last_page.mediaBox # ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado =====
page_width = float(page_box.getWidth())
page_height = float(page_box.getHeight()) with open(materia.pdf_assinado.path, 'rb') as f:
existing_pdf_bytes = f.read()
# Cria PDF com o carimbo usando as mesmas dimensões da página original
stamp_buffer = io.BytesIO() # Criar carimbo visual via pyhanko TextStampStyle + SigFieldSpec
c = canvas.Canvas(stamp_buffer, pagesize=(page_width, page_height)) x_pos, y_pos = _calcular_posicao_carimbo(n_assinatura)
largura_carimbo = 70 * mm
# Posição do carimbo (canto inferior esquerdo) altura_carimbo = 22 * mm
y_pos = 15 * mm
x_pos = 10 * mm # Converter para pontos (pyhanko usa pontos)
largura_carimbo = 70 * mm x1 = x_pos
altura_carimbo = 22 * mm y1 = y_pos
logo_width = 18 * mm x2 = x_pos + largura_carimbo
y2 = y_pos + altura_carimbo
# Desenha borda fina do carimbo
c.setStrokeColorRGB(0.5, 0.5, 0.5) nome_upper = nome_assinante.upper()
c.setLineWidth(0.5) if len(nome_upper) > 28:
c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo) nome_upper = nome_upper[:28] + "..."
# Texto do carimbo (lado esquerdo) signed_buffer = io.BytesIO()
c.setFont("Helvetica", 6) with io.BytesIO(existing_pdf_bytes) as inf:
c.setFillColorRGB(0.3, 0.3, 0.3) w = IncrementalPdfFileWriter(inf)
c.drawString(x_pos + 3*mm, y_pos + 17*mm, "Assinado digitalmente por")
# Determina a última página
c.setFont("Helvetica-Bold", 7) from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader
c.setFillColorRGB(0, 0, 0) temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes))
nome_upper = nome_assinante.upper() last_page_idx = temp_reader.root['/Pages']['/Count'] - 1
if len(nome_upper) > 28:
nome_upper = nome_upper[:28] + "..." # Adiciona campo de assinatura com posição visual
c.drawString(x_pos + 3*mm, y_pos + 12*mm, nome_upper) sig_field = SigFieldSpec(
sig_field_name=sig_field_name,
c.setFont("Helvetica", 6) on_page=last_page_idx,
c.setFillColorRGB(0.3, 0.3, 0.3) box=(x1, y1, x2, y2)
if cpf: )
c.drawString(x_pos + 3*mm, y_pos + 7*mm, f"CPF: {cpf}") fields.append_signature_field(w, sig_field)
c.drawString(x_pos + 3*mm, y_pos + 3*mm, f"Data: {data_simples}")
else: # Cria stamp style para o carimbo visual
c.drawString(x_pos + 3*mm, y_pos + 5*mm, f"Data: {data_simples}") from pyhanko.stamp import TextStampStyle
stamp_text = f"Assinado digitalmente por\n{nome_upper}"
if cpf:
stamp_text += f"\nCPF: {cpf}"
stamp_text += f"\nData: {data_simples}"
stamp_style = TextStampStyle(
stamp_text=stamp_text,
background=None,
)
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
)
signers.sign_pdf(
w,
meta,
signer=signer,
output=signed_buffer,
existing_fields_only=True
)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
# Logo da câmara (lado direito) else:
try: # ===== PRIMEIRA ASSINATURA: Carimbo ReportLab + pyhanko sign =====
logo_path = None
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Lê o PDF original para obter as dimensões reais da última página
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
possible_paths = [ last_page = original_pdf.getPage(original_pdf.getNumPages() - 1)
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo-camara-padrao.png'), page_box = last_page.mediaBox
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/logo.png'), page_width = float(page_box.getWidth())
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/pdflogo.png'), page_height = float(page_box.getHeight())
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'),
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'), # Cria PDF com o carimbo usando as mesmas dimensões da página original
] stamp_buffer = io.BytesIO()
c = canvas.Canvas(stamp_buffer, pagesize=(page_width, page_height))
logo_dir = os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo')
if os.path.exists(logo_dir): # Posição do carimbo (canto inferior esquerdo) — posição 0
for f in os.listdir(logo_dir): x_pos, y_pos = _calcular_posicao_carimbo(0)
if f.lower().endswith(('.png', '.jpg', '.jpeg')): largura_carimbo = 70 * mm
possible_paths.insert(0, os.path.join(logo_dir, f)) altura_carimbo = 22 * mm
logo_width = 18 * mm
for path in possible_paths:
if os.path.exists(path): # Desenha borda fina do carimbo
logo_path = path c.setStrokeColorRGB(0.5, 0.5, 0.5)
break c.setLineWidth(0.5)
c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo)
if logo_path:
logo = ImageReader(logo_path) # Texto do carimbo (lado esquerdo)
logo_x = x_pos + largura_carimbo - logo_width - 2*mm c.setFont("Helvetica", 6)
logo_y = y_pos + 2*mm c.setFillColorRGB(0.3, 0.3, 0.3)
c.drawImage(logo, logo_x, logo_y, c.drawString(x_pos + 3*mm, y_pos + 17*mm, "Assinado digitalmente por")
width=logo_width, height=logo_width,
preserveAspectRatio=True, mask='auto') c.setFont("Helvetica-Bold", 7)
except Exception as logo_error: c.setFillColorRGB(0, 0, 0)
logger.warning(f"Não foi possível adicionar logo: {logo_error}") nome_upper = nome_assinante.upper()
if len(nome_upper) > 28:
c.save() nome_upper = nome_upper[:28] + "..."
stamp_buffer.seek(0) c.drawString(x_pos + 3*mm, y_pos + 12*mm, nome_upper)
# Mescla o carimbo com o PDF original (ANTES de assinar) c.setFont("Helvetica", 6)
stamp_pdf = PdfFileReader(stamp_buffer) c.setFillColorRGB(0.3, 0.3, 0.3)
output_pdf = PdfFileWriter() if cpf:
c.drawString(x_pos + 3*mm, y_pos + 7*mm, f"CPF: {cpf}")
for page_num in range(original_pdf.getNumPages()): c.drawString(x_pos + 3*mm, y_pos + 3*mm, f"Data: {data_simples}")
page = original_pdf.getPage(page_num) else:
if page_num == original_pdf.getNumPages() - 1: # Última página c.drawString(x_pos + 3*mm, y_pos + 5*mm, f"Data: {data_simples}")
page.mergePage(stamp_pdf.getPage(0))
output_pdf.addPage(page) # Logo da câmara (lado direito)
try:
# Salva o PDF com carimbo em arquivo temporário logo_path = None
with open(temp_stamped_path, 'wb') as f: base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
output_pdf.write(f)
possible_paths = [
# ===== PASSO 2: Assinar o PDF que já contém o carimbo ===== 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'),
with open(temp_stamped_path, 'rb') as stamped_file: os.path.join(base_dir, 'sapl/static/sapl/frontend/img/pdflogo.png'),
stamped_bytes = stamped_file.read() os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'),
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'),
signed_buffer = io.BytesIO() ]
with io.BytesIO(stamped_bytes) as inf:
w = IncrementalPdfFileWriter(inf) logo_dir = os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo')
if os.path.exists(logo_dir):
# Metadados da assinatura for f in os.listdir(logo_dir):
meta = signers.PdfSignatureMetadata( if f.lower().endswith(('.png', '.jpg', '.jpeg')):
field_name='AssinaturaDigital', possible_paths.insert(0, os.path.join(logo_dir, f))
location='Câmara Municipal',
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', for path in possible_paths:
name=nome_assinante if os.path.exists(path):
) logo_path = path
break
# Executa assinatura
signers.sign_pdf( if logo_path:
w, logo = ImageReader(logo_path)
meta, logo_x = x_pos + largura_carimbo - logo_width - 2*mm
signer=signer, logo_y = y_pos + 2*mm
output=signed_buffer c.drawImage(logo, logo_x, logo_y,
) width=logo_width, height=logo_width,
preserveAspectRatio=True, mask='auto')
signed_buffer.seek(0) except Exception as logo_error:
signed_pdf_content = signed_buffer.read() logger.warning(f"Não foi possível adicionar logo: {logo_error}")
c.save()
stamp_buffer.seek(0)
# Mescla o carimbo com o PDF original (ANTES de assinar)
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: # Última página
page.mergePage(stamp_pdf.getPage(0))
output_pdf.addPage(page)
# Salva o PDF com carimbo em arquivo temporário
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)
# Metadados da assinatura
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
signers.sign_pdf(
w,
meta,
signer=signer,
output=signed_buffer
)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
# Salva o PDF assinado no modelo # Salva o PDF assinado no modelo
filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False)
# Salva informações da assinatura # Salva informações da assinatura (lista de dicts)
materia.assinatura_info = { nova_assinatura = {
'tipo_certificado': 'A1', 'tipo_certificado': 'A1',
'tipo_certificado_display': f'{tipo_cert} – A1', 'tipo_certificado_display': f'{tipo_cert} – A1',
'subject': str(cert_info.subject), 'subject': str(cert_info.subject),
@ -433,6 +530,8 @@ def materia_assinar_a1(request, pk):
'data_assinatura': data_formatada, 'data_assinatura': data_formatada,
'validade_juridica': 'Assinatura Eletrônica Qualificada' 'validade_juridica': 'Assinatura Eletrônica Qualificada'
} }
assinaturas_existentes.append(nova_assinatura)
materia.assinatura_info = assinaturas_existentes
materia.assinado_em = timezone.now() materia.assinado_em = timezone.now()
materia.assinado_por = request.user materia.assinado_por = request.user
materia.save() materia.save()
@ -917,12 +1016,16 @@ def docacessorio_assinar_a1(request, pk):
""" """
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
if docacessorio.pdf_assinado: # 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({ return JsonResponse({
'success': False, 'success': False,
'error': 'Este documento já possui um PDF assinado.' 'error': 'Você já assinou este documento.'
}, status=400) }, status=400)
ja_tem_pdf_assinado = bool(docacessorio.pdf_assinado)
certificado_file = request.FILES.get('certificado') certificado_file = request.FILES.get('certificado')
senha = request.POST.get('senha', '') senha = request.POST.get('senha', '')
@ -1007,7 +1110,7 @@ def docacessorio_assinar_a1(request, pk):
temp_stamped_path = temp_stamped.name temp_stamped_path = temp_stamped.name
try: try:
from pyhanko.sign.fields import SigSeedSubFilter from pyhanko.sign.fields import SigSeedSubFilter, SigFieldSpec
from pyhanko.pdf_utils import text from pyhanko.pdf_utils import text
from pyhanko.sign.general import SigningError from pyhanko.sign.general import SigningError
from PyPDF4 import PdfFileReader, PdfFileWriter from PyPDF4 import PdfFileReader, PdfFileWriter
@ -1058,122 +1161,190 @@ def docacessorio_assinar_a1(request, pk):
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
# Criar carimbo visual e adicionar ao PDF ANTES de assinar # Número da assinatura (0-indexed)
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) n_assinatura = len(assinaturas_existentes)
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital'
page_box = last_page.mediaBox
page_width = float(page_box.getWidth()) if ja_tem_pdf_assinado:
page_height = float(page_box.getHeight()) # ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado =====
stamp_buffer = io.BytesIO() with open(docacessorio.pdf_assinado.path, 'rb') as f:
c = canvas.Canvas(stamp_buffer, pagesize=(page_width, page_height)) existing_pdf_bytes = f.read()
y_pos = 15 * mm x_pos, y_pos = _calcular_posicao_carimbo(n_assinatura)
x_pos = 10 * mm largura_carimbo = 70 * mm
largura_carimbo = 70 * mm altura_carimbo = 22 * mm
altura_carimbo = 22 * mm
logo_width = 18 * mm x1 = x_pos
y1 = y_pos
c.setStrokeColorRGB(0.5, 0.5, 0.5) x2 = x_pos + largura_carimbo
c.setLineWidth(0.5) y2 = y_pos + altura_carimbo
c.rect(x_pos, y_pos, largura_carimbo, altura_carimbo)
nome_upper = nome_assinante.upper()
c.setFont("Helvetica", 6) if len(nome_upper) > 28:
c.setFillColorRGB(0.3, 0.3, 0.3) nome_upper = nome_upper[:28] + "..."
c.drawString(x_pos + 3*mm, y_pos + 17*mm, "Assinado digitalmente por")
signed_buffer = io.BytesIO()
c.setFont("Helvetica-Bold", 7) with io.BytesIO(existing_pdf_bytes) as inf:
c.setFillColorRGB(0, 0, 0) w = IncrementalPdfFileWriter(inf)
nome_upper = nome_assinante.upper()
if len(nome_upper) > 28: from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader
nome_upper = nome_upper[:28] + "..." temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes))
c.drawString(x_pos + 3*mm, y_pos + 12*mm, nome_upper) last_page_idx = temp_reader.root['/Pages']['/Count'] - 1
c.setFont("Helvetica", 6) sig_field = SigFieldSpec(
c.setFillColorRGB(0.3, 0.3, 0.3) sig_field_name=sig_field_name,
if cpf: on_page=last_page_idx,
c.drawString(x_pos + 3*mm, y_pos + 7*mm, f"CPF: {cpf}") box=(x1, y1, x2, y2)
c.drawString(x_pos + 3*mm, y_pos + 3*mm, f"Data: {data_simples}") )
else: fields.append_signature_field(w, sig_field)
c.drawString(x_pos + 3*mm, y_pos + 5*mm, f"Data: {data_simples}")
from pyhanko.stamp import TextStampStyle
try:
logo_path = None stamp_text = f"Assinado digitalmente por\n{nome_upper}"
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) if cpf:
stamp_text += f"\nCPF: {cpf}"
possible_paths = [ stamp_text += f"\nData: {data_simples}"
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'), stamp_style = TextStampStyle(
os.path.join(base_dir, 'sapl/static/sapl/frontend/img/pdflogo.png'), stamp_text=stamp_text,
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logo.png'), background=None,
os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo/logotipo.png'), )
]
meta = signers.PdfSignatureMetadata(
logo_dir = os.path.join(settings.MEDIA_ROOT, 'sapl/public/casa/logotipo') field_name=sig_field_name,
if os.path.exists(logo_dir): location='Câmara Municipal',
for f in os.listdir(logo_dir): reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
if f.lower().endswith(('.png', '.jpg', '.jpeg')): name=nome_assinante
possible_paths.insert(0, os.path.join(logo_dir, f)) )
for path in possible_paths: signers.sign_pdf(
if os.path.exists(path): w,
logo_path = path meta,
break signer=signer,
output=signed_buffer,
if logo_path: existing_fields_only=True
logo = ImageReader(logo_path) )
logo_x = x_pos + largura_carimbo - logo_width - 2*mm
logo_y = y_pos + 2*mm signed_buffer.seek(0)
c.drawImage(logo, logo_x, logo_y, signed_pdf_content = signed_buffer.read()
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( else:
w, # ===== PRIMEIRA ASSINATURA: Carimbo ReportLab + pyhanko sign =====
meta,
signer=signer, original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
output=signed_buffer 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))
x_pos, y_pos = _calcular_posicao_carimbo(0)
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}")
signed_buffer.seek(0) try:
signed_pdf_content = signed_buffer.read() 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)
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" filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False)
docacessorio.assinatura_info = { nova_assinatura = {
'tipo_certificado': 'A1', 'tipo_certificado': 'A1',
'tipo_certificado_display': f'{tipo_cert} – A1', 'tipo_certificado_display': f'{tipo_cert} – A1',
'subject': str(cert_info.subject), 'subject': str(cert_info.subject),
@ -1187,6 +1358,8 @@ def docacessorio_assinar_a1(request, pk):
'data_assinatura': data_formatada, 'data_assinatura': data_formatada,
'validade_juridica': 'Assinatura Eletrônica Qualificada' 'validade_juridica': 'Assinatura Eletrônica Qualificada'
} }
assinaturas_existentes.append(nova_assinatura)
docacessorio.assinatura_info = assinaturas_existentes
docacessorio.assinado_em = timezone.now() docacessorio.assinado_em = timezone.now()
docacessorio.assinado_por = request.user docacessorio.assinado_por = request.user
docacessorio.save() docacessorio.save()

3
sapl/templates/materia/assinatura_modal.html

@ -473,6 +473,9 @@ document.addEventListener('DOMContentLoaded', function() {
btnBaixar.style.display = 'inline-block'; btnBaixar.style.display = 'inline-block';
document.getElementById('btn-fechar-modal').textContent = 'Fechar'; document.getElementById('btn-fechar-modal').textContent = 'Fechar';
// Recarrega a página após 1.5s para atualizar os botões/card
setTimeout(function() { location.reload(); }, 1500);
}, },
mostrarErro: function(mensagem) { mostrarErro: function(mensagem) {

56
sapl/templates/materia/documentoacessorio_detail.html

@ -16,7 +16,8 @@
<a class="btn btn-success" href="{% url 'sapl.materia:docacessorio_pdf_assinado' object.pk %}" target="_blank"> <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" %} <i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a> </a>
{% elif pode_assinar %} {% endif %}
{% if pode_assinar and not ja_assinou %}
<button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal"> <button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal">
<i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %} <i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %}
</button> </button>
@ -33,32 +34,37 @@
<div class="card border-success"> <div class="card border-success">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<i class="fa fa-certificate"></i> {% trans "Assinatura Digital" %} <i class="fa fa-certificate"></i> {% trans "Assinatura Digital" %}
{% if assinaturas|length > 1 %}
<span class="badge badge-light ml-1">{{ assinaturas|length }} {% trans "assinaturas" %}</span>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <p class="mb-2"><strong>{% trans "Status:" %}</strong>
<div class="col-md-6"> <span class="badge badge-success">{% trans "Documento Assinado" %}</span>
<p class="mb-1"><strong>{% trans "Status:" %}</strong> </p>
<span class="badge badge-success">{% trans "Documento Assinado" %}</span> {% for assinatura in assinaturas %}
</p> <div class="{% if not forloop.last %}mb-2 pb-2 border-bottom{% endif %}">
{% if object.assinado_em %} <div class="row">
<p class="mb-1"><strong>{% trans "Data/Hora:" %}</strong> {{ object.assinado_em|localtime|date:"d/m/Y H:i:s" }}</p> <div class="col-md-6">
{% endif %} <p class="mb-1"><strong>{% trans "Assinado por:" %}</strong> {{ assinatura.nome_assinante }}</p>
{% if object.assinado_por %} <p class="mb-1"><strong>{% trans "Data/Hora:" %}</strong> {{ assinatura.data_assinatura }}</p>
<p class="mb-1"><strong>{% trans "Assinado por:" %}</strong> {{ object.assinado_por.get_full_name|default:object.assinado_por.username }}</p> </div>
{% endif %} <div class="col-md-6">
</div> <p class="mb-1"><strong>{% trans "Tipo de Certificado:" %}</strong> {{ assinatura.tipo_certificado_display|default:assinatura.tipo_certificado|default:"A1" }}</p>
<div class="col-md-6"> {% if assinatura.cargo %}
{% if object.assinatura_info %} <p class="mb-1"><strong>{% trans "Cargo:" %}</strong> {{ assinatura.cargo }}</p>
<p class="mb-1"><strong>{% trans "Tipo de Certificado:" %}</strong> {{ object.assinatura_info.tipo_certificado|default:"A1" }}</p> {% endif %}
{% if object.assinatura_info.subject %} </div>
<p class="mb-1"><strong>{% trans "Certificado:" %}</strong> {{ object.assinatura_info.subject|truncatechars:50 }}</p> </div>
{% 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> {% empty %}
{% 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 %}
{% 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 %}
{% endfor %}
<div class="mt-2"> <div class="mt-2">
<a href="{% url 'sapl.materia:docacessorio_pdf_assinado' object.pk %}" class="btn btn-sm btn-success" target="_blank"> <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" %} <i class="fa fa-download"></i> {% trans "Baixar PDF Assinado" %}
@ -76,7 +82,7 @@
{% block extra_js %} {% block extra_js %}
{{ block.super }} {{ block.super }}
{% if object.arquivo and not object.pdf_assinado and pode_assinar %} {% if object.arquivo and pode_assinar and not ja_assinou %}
{% include "materia/assinatura_modal.html" %} {% include "materia/assinatura_modal.html" %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

56
sapl/templates/materia/materialegislativa_detail.html

@ -19,7 +19,8 @@
<a class="btn btn-sm btn-success" href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" target="_blank"> <a class="btn btn-sm btn-success" href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" target="_blank">
<i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %} <i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a> </a>
{% elif is_autor %} {% endif %}
{% if is_autor and not ja_assinou %}
<button type="button" class="btn btn-sm btn-warning" data-toggle="modal" data-target="#assinaturaModal"> <button type="button" class="btn btn-sm btn-warning" data-toggle="modal" data-target="#assinaturaModal">
<i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %} <i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %}
</button> </button>
@ -180,32 +181,37 @@
<div class="card border-success"> <div class="card border-success">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<i class="fa fa-certificate"></i> {% trans "Assinatura Digital" %} <i class="fa fa-certificate"></i> {% trans "Assinatura Digital" %}
{% if assinaturas|length > 1 %}
<span class="badge badge-light ml-1">{{ assinaturas|length }} {% trans "assinaturas" %}</span>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <p class="mb-2"><strong>{% trans "Status:" %}</strong>
<div class="col-md-6"> <span class="badge badge-success">{% trans "Documento Assinado" %}</span>
<p class="mb-1"><strong>{% trans "Status:" %}</strong> </p>
<span class="badge badge-success">{% trans "Documento Assinado" %}</span> {% for assinatura in assinaturas %}
</p> <div class="{% if not forloop.last %}mb-2 pb-2 border-bottom{% endif %}">
{% if object.assinado_em %} <div class="row">
<p class="mb-1"><strong>{% trans "Data/Hora:" %}</strong> {{ object.assinado_em|localtime|date:"d/m/Y H:i:s" }}</p> <div class="col-md-6">
{% endif %} <p class="mb-1"><strong>{% trans "Assinado por:" %}</strong> {{ assinatura.nome_assinante }}</p>
{% if object.assinado_por %} <p class="mb-1"><strong>{% trans "Data/Hora:" %}</strong> {{ assinatura.data_assinatura }}</p>
<p class="mb-1"><strong>{% trans "Assinado por:" %}</strong> {{ object.assinado_por.get_full_name|default:object.assinado_por.username }}</p> </div>
{% endif %} <div class="col-md-6">
</div> <p class="mb-1"><strong>{% trans "Tipo de Certificado:" %}</strong> {{ assinatura.tipo_certificado_display|default:assinatura.tipo_certificado|default:"A1" }}</p>
<div class="col-md-6"> {% if assinatura.cargo %}
{% if object.assinatura_info %} <p class="mb-1"><strong>{% trans "Cargo:" %}</strong> {{ assinatura.cargo }}</p>
<p class="mb-1"><strong>{% trans "Tipo de Certificado:" %}</strong> {{ object.assinatura_info.tipo_certificado|default:"A1" }}</p> {% endif %}
{% if object.assinatura_info.subject %} </div>
<p class="mb-1"><strong>{% trans "Certificado:" %}</strong> {{ object.assinatura_info.subject|truncatechars:50 }}</p> </div>
{% 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> {% empty %}
{% 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 %}
{% 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 %}
{% endfor %}
<div class="mt-2"> <div class="mt-2">
<a href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" class="btn btn-sm btn-success" target="_blank"> <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" %} <i class="fa fa-download"></i> {% trans "Baixar PDF Assinado" %}
@ -223,7 +229,7 @@
{% block extra_js %} {% block extra_js %}
{{ block.super }} {{ block.super }}
{% if object.numero_protocolo and object.texto_original and not object.pdf_assinado %} {% if object.numero_protocolo and object.texto_original and is_autor and not ja_assinou %}
{% include "materia/assinatura_modal.html" %} {% include "materia/assinatura_modal.html" %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

Loading…
Cancel
Save