From 4c93190a9b977beccf194f145664fcb692ee6723 Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Sat, 21 Feb 2026 16:26:54 -0300 Subject: [PATCH] =?UTF-8?q?Feat(Assinatura):=20Suporte=20a=20m=C3=BAltipla?= =?UTF-8?q?s=20assinaturas=20digitais=20em=20Mat=C3=A9ria=20e=20Doc=20Aces?= =?UTF-8?q?s=C3=B3rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sapl/materia/views.py | 19 + sapl/materia/views_assinatura.py | 655 +++++++++++------- sapl/templates/materia/assinatura_modal.html | 3 + .../materia/documentoacessorio_detail.html | 56 +- .../materia/materialegislativa_detail.html | 56 +- 5 files changed, 498 insertions(+), 291 deletions(-) diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 385161499..df6ac80f0 100644 --- a/sapl/materia/views.py +++ b/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 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 class ListView(Crud.ListView, RedirectView): diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index 0ec1e7acb..41bf96faa 100644 --- a/sapl/materia/views_assinatura.py +++ b/sapl/materia/views_assinatura.py @@ -25,6 +25,28 @@ from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa 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): """ 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 # (a autenticação é garantida pelo decorator @login_required) - # Verifica se já está assinada - if materia.pdf_assinado: + # Verifica se o usuário atual já assinou + assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info) + if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): return JsonResponse({ 'success': False, - 'error': 'Esta matéria já possui um PDF assinado.' + 'error': 'Você já assinou esta matéria.' }, status=400) + ja_tem_pdf_assinado = bool(materia.pdf_assinado) + # Obtém dados do formulário certificado_file = request.FILES.get('certificado') senha = request.POST.get('senha', '') @@ -232,7 +257,7 @@ def materia_assinar_a1(request, pk): temp_stamped_path = temp_stamped.name try: - from pyhanko.sign.fields import SigSeedSubFilter + from pyhanko.sign.fields import SigSeedSubFilter, SigFieldSpec from pyhanko.pdf_utils import text from pyhanko.sign.general import SigningError from PyPDF4 import PdfFileReader, PdfFileWriter @@ -290,136 +315,208 @@ def materia_assinar_a1(request, pk): # Formata data data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') - # ===== PASSO 1: Criar carimbo visual e adicionar ao PDF ANTES de assinar ===== - - # Lê o PDF original para obter as dimensões reais da última página - 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()) - - # 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)) - - # Posição do carimbo (canto inferior esquerdo) - y_pos = 15 * mm - x_pos = 10 * mm - largura_carimbo = 70 * mm - 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_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}") + # Número da assinatura (0-indexed) + n_assinatura = len(assinaturas_existentes) + sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital' + + if ja_tem_pdf_assinado: + # ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado ===== + + with open(materia.pdf_assinado.path, 'rb') as f: + existing_pdf_bytes = f.read() + + # Criar carimbo visual via pyhanko TextStampStyle + SigFieldSpec + x_pos, y_pos = _calcular_posicao_carimbo(n_assinatura) + largura_carimbo = 70 * mm + altura_carimbo = 22 * mm + + # Converter para pontos (pyhanko usa pontos) + x1 = x_pos + y1 = y_pos + x2 = x_pos + largura_carimbo + y2 = y_pos + altura_carimbo + + nome_upper = nome_assinante.upper() + if len(nome_upper) > 28: + nome_upper = nome_upper[:28] + "..." + + signed_buffer = io.BytesIO() + with io.BytesIO(existing_pdf_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + + # Determina a última página + from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader + temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes)) + last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 + + # Adiciona campo de assinatura com posição visual + sig_field = SigFieldSpec( + sig_field_name=sig_field_name, + on_page=last_page_idx, + box=(x1, y1, x2, y2) + ) + fields.append_signature_field(w, sig_field) + + # Cria stamp style para o carimbo visual + 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) - 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) - - # 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) - - # ===== PASSO 2: 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() + else: + # ===== PRIMEIRA ASSINATURA: Carimbo ReportLab + pyhanko sign ===== + + # Lê o PDF original para obter as dimensões reais da última página + 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()) + + # 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)) + + # Posição do carimbo (canto inferior esquerdo) — posição 0 + x_pos, y_pos = _calcular_posicao_carimbo(0) + largura_carimbo = 70 * mm + 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_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: + 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) + + # 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 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 = { + # Salva informações da assinatura (lista de dicts) + nova_assinatura = { 'tipo_certificado': 'A1', 'tipo_certificado_display': f'{tipo_cert} – A1', 'subject': str(cert_info.subject), @@ -433,6 +530,8 @@ def materia_assinar_a1(request, pk): 'data_assinatura': data_formatada, 'validade_juridica': 'Assinatura Eletrônica Qualificada' } + assinaturas_existentes.append(nova_assinatura) + materia.assinatura_info = assinaturas_existentes materia.assinado_em = timezone.now() materia.assinado_por = request.user materia.save() @@ -917,12 +1016,16 @@ def docacessorio_assinar_a1(request, 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({ 'success': False, - 'error': 'Este documento já possui um PDF assinado.' + 'error': 'Você já assinou este documento.' }, status=400) + ja_tem_pdf_assinado = bool(docacessorio.pdf_assinado) + certificado_file = request.FILES.get('certificado') senha = request.POST.get('senha', '') @@ -1007,7 +1110,7 @@ def docacessorio_assinar_a1(request, pk): temp_stamped_path = temp_stamped.name try: - from pyhanko.sign.fields import SigSeedSubFilter + from pyhanko.sign.fields import SigSeedSubFilter, SigFieldSpec from pyhanko.pdf_utils import text from pyhanko.sign.general import SigningError 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') - # 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 - ) + # Número da assinatura (0-indexed) + n_assinatura = len(assinaturas_existentes) + sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital' + + if ja_tem_pdf_assinado: + # ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado ===== + + with open(docacessorio.pdf_assinado.path, 'rb') as f: + existing_pdf_bytes = f.read() + + x_pos, y_pos = _calcular_posicao_carimbo(n_assinatura) + largura_carimbo = 70 * mm + altura_carimbo = 22 * mm + + x1 = x_pos + y1 = y_pos + x2 = x_pos + largura_carimbo + y2 = y_pos + altura_carimbo + + nome_upper = nome_assinante.upper() + if len(nome_upper) > 28: + nome_upper = nome_upper[:28] + "..." + + signed_buffer = io.BytesIO() + with io.BytesIO(existing_pdf_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + + from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader + temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes)) + last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 + + sig_field = SigFieldSpec( + sig_field_name=sig_field_name, + on_page=last_page_idx, + box=(x1, y1, x2, y2) + ) + fields.append_signature_field(w, sig_field) + + 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() - signers.sign_pdf( - w, - meta, - signer=signer, - output=signed_buffer - ) + else: + # ===== PRIMEIRA ASSINATURA: Carimbo ReportLab + pyhanko sign ===== + + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_box = last_page.mediaBox + page_width = float(page_box.getWidth()) + page_height = float(page_box.getHeight()) + + 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) - signed_pdf_content = signed_buffer.read() + 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) + + 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 = { + nova_assinatura = { 'tipo_certificado': 'A1', 'tipo_certificado_display': f'{tipo_cert} – A1', 'subject': str(cert_info.subject), @@ -1187,6 +1358,8 @@ def docacessorio_assinar_a1(request, pk): 'data_assinatura': data_formatada, 'validade_juridica': 'Assinatura Eletrônica Qualificada' } + assinaturas_existentes.append(nova_assinatura) + docacessorio.assinatura_info = assinaturas_existentes docacessorio.assinado_em = timezone.now() docacessorio.assinado_por = request.user docacessorio.save() diff --git a/sapl/templates/materia/assinatura_modal.html b/sapl/templates/materia/assinatura_modal.html index 4c1503532..25c15ba62 100644 --- a/sapl/templates/materia/assinatura_modal.html +++ b/sapl/templates/materia/assinatura_modal.html @@ -473,6 +473,9 @@ document.addEventListener('DOMContentLoaded', function() { btnBaixar.style.display = 'inline-block'; 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) { diff --git a/sapl/templates/materia/documentoacessorio_detail.html b/sapl/templates/materia/documentoacessorio_detail.html index c6b7fef47..06a74ded6 100644 --- a/sapl/templates/materia/documentoacessorio_detail.html +++ b/sapl/templates/materia/documentoacessorio_detail.html @@ -16,7 +16,8 @@ {% trans "Ver PDF Assinado" %} - {% elif pode_assinar %} + {% endif %} + {% if pode_assinar and not ja_assinou %} @@ -33,32 +34,37 @@
{% trans "Assinatura Digital" %} + {% if assinaturas|length > 1 %} + {{ assinaturas|length }} {% trans "assinaturas" %} + {% endif %}
-
-
-

{% trans "Status:" %} - {% trans "Documento Assinado" %} -

- {% if object.assinado_em %} -

{% trans "Data/Hora:" %} {{ object.assinado_em|localtime|date:"d/m/Y H:i:s" }}

- {% endif %} - {% if object.assinado_por %} -

{% trans "Assinado por:" %} {{ object.assinado_por.get_full_name|default:object.assinado_por.username }}

- {% endif %} -
-
- {% if object.assinatura_info %} -

{% trans "Tipo de Certificado:" %} {{ object.assinatura_info.tipo_certificado|default:"A1" }}

- {% if object.assinatura_info.subject %} -

{% trans "Certificado:" %} {{ object.assinatura_info.subject|truncatechars:50 }}

- {% endif %} - {% if object.assinatura_info.valid_to %} -

{% trans "Validade:" %} {{ object.assinatura_info.valid_to|slice:":10" }}

- {% endif %} - {% endif %} +

{% trans "Status:" %} + {% trans "Documento Assinado" %} +

+ {% for assinatura in assinaturas %} +
+
+
+

{% trans "Assinado por:" %} {{ assinatura.nome_assinante }}

+

{% trans "Data/Hora:" %} {{ assinatura.data_assinatura }}

+
+
+

{% trans "Tipo de Certificado:" %} {{ assinatura.tipo_certificado_display|default:assinatura.tipo_certificado|default:"A1" }}

+ {% if assinatura.cargo %} +

{% trans "Cargo:" %} {{ assinatura.cargo }}

+ {% endif %} +
+
-
+ {% empty %} + {% if object.assinado_por %} +

{% trans "Assinado por:" %} {{ object.assinado_por.get_full_name|default:object.assinado_por.username }}

+ {% endif %} + {% if object.assinado_em %} +

{% trans "Data/Hora:" %} {{ object.assinado_em|localtime|date:"d/m/Y H:i:s" }}

+ {% endif %} + {% endfor %}
{% trans "Baixar PDF Assinado" %} @@ -76,7 +82,7 @@ {% block extra_js %} {{ 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" %}