From 6a26350c03c9fbcdb43cf61c7f20fefa49b74b62 Mon Sep 17 00:00:00 2001 From: KemuelAlves Date: Mon, 18 May 2026 17:26:35 -0300 Subject: [PATCH] =?UTF-8?q?fix(assinatura):=20Migrar=20assinatura=20digita?= =?UTF-8?q?l=20para=20o=20microservi=C3=A7o=20[task=20AB#1349=20issue=20AB?= =?UTF-8?q?#1347]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/materia/assinatura_api_client.py | 345 +++ sapl/materia/urls.py | 11 +- sapl/materia/views_assinatura.py | 1886 +++++++++-------- sapl/settings.py | 7 + sapl/templates/materia/assinatura_modal.html | 51 +- .../materia/documentoacessorio_list.html | 28 +- .../materia/materialegislativa_filter.html | 28 +- 7 files changed, 1461 insertions(+), 895 deletions(-) create mode 100644 sapl/materia/assinatura_api_client.py diff --git a/sapl/materia/assinatura_api_client.py b/sapl/materia/assinatura_api_client.py new file mode 100644 index 000000000..7a3a7b536 --- /dev/null +++ b/sapl/materia/assinatura_api_client.py @@ -0,0 +1,345 @@ +""" +Cliente para o pyHanko Sign Service (microserviço externo de assinatura digital). + +Contrato real da API (descoberto via /openapi.json): + POST /sign — multipart/form-data + pdf : arquivo PDF (binário) + pfx : arquivo .pfx/.p12 (binário) + pfx_password : senha do PFX + reason : motivo (opcional) + location : local (opcional) + signature_page : página 1-based (opcional) + signature_left/bottom/width/height : posição em pontos PDF (opcional) + + Resposta 200: Content-Type: application/pdf → bytes do PDF assinado + Resposta 4xx/5xx: JSON com campo "detail" + +Outros endpoints: + POST /validate-pfx – valida certificado PFX + POST /validate – valida assinaturas de um PDF + POST /sign/batch – assina múltiplos PDFs em lote + GET / – health check +""" + +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class AssinaturaAPIError(Exception): + """Erro retornado pelo microserviço de assinatura.""" + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +def _api_configurada(): + """Retorna True se a API externa de assinatura está configurada.""" + return bool(getattr(settings, 'ASSINATURA_API_URL', '').strip()) + + +def _montar_headers(): + """Authorization só é adicionado se ASSINATURA_API_KEY estiver preenchido.""" + headers = {} + api_key = getattr(settings, 'ASSINATURA_API_KEY', '').strip() + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + return headers + + +def _url(endpoint): + base = settings.ASSINATURA_API_URL.rstrip('/') + endpoint = endpoint.lstrip('/') + return f'{base}/{endpoint}' + + +def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha, + reason=None, location=None, + signature_page=None, + signature_left=None, signature_bottom=None, + signature_width=None, signature_height=None): + """ + Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes. + + Parâmetros + ---------- + pdf_bytes : bytes — PDF a assinar + certificado_bytes : bytes — arquivo .pfx/.p12 + senha : str — senha do certificado + reason : str — motivo (exibido na assinatura visual) + location : str — local (exibido na assinatura visual) + signature_page : int — página 1-based (None = última) + signature_left/bottom/width/height : float — coordenadas em pontos PDF + + Retorna: bytes do PDF assinado. + Lança: AssinaturaAPIError em caso de erro. + """ + if not _api_configurada(): + raise AssinaturaAPIError( + 'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).' + ) + + files = { + 'pdf': ('documento.pdf', pdf_bytes, 'application/pdf'), + 'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream'), + } + data = {'pfx_password': senha} + + if reason: + data['reason'] = reason + if location: + data['location'] = location + if signature_page is not None: + data['signature_page'] = str(signature_page) + if signature_left is not None: + data['signature_left'] = str(signature_left) + if signature_bottom is not None: + data['signature_bottom'] = str(signature_bottom) + if signature_width is not None: + data['signature_width'] = str(signature_width) + if signature_height is not None: + data['signature_height'] = str(signature_height) + + timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) + + try: + response = requests.post( + _url('sign'), + files=files, + data=data, + headers=_montar_headers(), + timeout=timeout, + ) + except requests.exceptions.ConnectionError as exc: + logger.error(f'[assinatura-api] Falha de conexao: {exc}') + raise AssinaturaAPIError( + 'Nao foi possivel conectar ao microservico de assinatura. ' + 'Verifique se o servico esta disponivel.' + ) + except requests.exceptions.Timeout: + raise AssinaturaAPIError( + f'Timeout ao aguardar resposta do microservico de assinatura ' + f'(limite: {timeout}s).' + ) + except requests.exceptions.RequestException as exc: + logger.error(f'[assinatura-api] Erro inesperado: {exc}') + raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}') + + if not response.ok: + try: + detail = response.json() + msg = detail.get('detail') or detail.get('error') or str(detail) + except Exception: + msg = response.text[:300] or f'HTTP {response.status_code}' + logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}') + raise AssinaturaAPIError(msg, status_code=response.status_code) + + # A API retorna o PDF assinado diretamente como application/pdf + content_type = response.headers.get('Content-Type', '') + if 'pdf' not in content_type and len(response.content) < 100: + raise AssinaturaAPIError( + f'Resposta inesperada do microservico (Content-Type: {content_type}).' + ) + + logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.') + return response.content + + +def assinar_pdf_lote_via_api(itens, *, certificado_bytes, senha, + reason=None, location=None, + download_workers=8): + """ + Assina múltiplos PDFs em uma única chamada POST /sign/batch e baixa os + resultados em paralelo via download_url do S3. + + Parâmetros + ---------- + itens : list[dict] — cada item deve ter: + 'id' : identificador (qualquer hashable — preservado no resultado) + 'pdf_bytes' : bytes do PDF a assinar + 'signature_page' : int 1-based (opcional, mesmo para todos) + 'signature_left' : float (opcional) + 'signature_bottom': float (opcional) + 'signature_width' : float (opcional) + 'signature_height': float (opcional) + certificado_bytes : bytes — arquivo .pfx/.p12 (compartilhado por todos) + senha : str — senha do certificado + reason, location : str — metadados da assinatura + download_workers : int — threads para baixar resultados do S3 (default 8) + + Retorna: list[dict] na mesma ordem de `itens`, com campos: + 'id' : o mesmo id do item de entrada + 'ok' : True / False + 'pdf_bytes' : bytes do PDF assinado (apenas quando ok=True) + 'error' : mensagem de erro (apenas quando ok=False) + """ + if not _api_configurada(): + raise AssinaturaAPIError( + 'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).' + ) + + timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) + + # ── 1. Enviar todos os PDFs em uma única chamada /sign/batch ───────────── + # O campo 'signature_page/left/bottom/width/height' é único para o lote — + # usamos os valores do primeiro item (todos partilham a mesma posição). + primeiro = itens[0] if itens else {} + data = {'pfx_password': senha} + if reason: + data['reason'] = reason + if location: + data['location'] = location + if primeiro.get('signature_page') is not None: + data['signature_page'] = str(primeiro['signature_page']) + if primeiro.get('signature_left') is not None: + data['signature_left'] = str(primeiro['signature_left']) + if primeiro.get('signature_bottom') is not None: + data['signature_bottom'] = str(primeiro['signature_bottom']) + if primeiro.get('signature_width') is not None: + data['signature_width'] = str(primeiro['signature_width']) + if primeiro.get('signature_height') is not None: + data['signature_height'] = str(primeiro['signature_height']) + + # multipart: múltiplos campos 'pdfs' + um 'pfx' + files = [('pfx', ('certificado.pfx', certificado_bytes, 'application/octet-stream'))] + for idx, item in enumerate(itens): + filename = f'doc{idx + 1}.pdf' + files.append(('pdfs', (filename, item['pdf_bytes'], 'application/pdf'))) + + try: + response = requests.post( + _url('sign/batch'), + files=files, + data=data, + headers=_montar_headers(), + timeout=timeout, + ) + except requests.exceptions.ConnectionError as exc: + logger.error(f'[assinatura-api/batch] Falha de conexão: {exc}') + raise AssinaturaAPIError( + 'Não foi possível conectar ao microserviço de assinatura.' + ) + except requests.exceptions.Timeout: + raise AssinaturaAPIError( + f'Timeout ao aguardar resposta do microserviço (limite: {timeout}s).' + ) + except requests.exceptions.RequestException as exc: + raise AssinaturaAPIError(f'Erro ao comunicar com o microserviço: {exc}') + + if not response.ok: + try: + detail = response.json() + msg = detail.get('detail') or str(detail) + except Exception: + msg = response.text[:300] or f'HTTP {response.status_code}' + logger.error(f'[assinatura-api/batch] Erro HTTP {response.status_code}: {msg}') + raise AssinaturaAPIError(msg, status_code=response.status_code) + + try: + batch_result = response.json() + except Exception: + raise AssinaturaAPIError('Resposta do /sign/batch não é JSON válido.') + + resultados_api = batch_result.get('results', []) + if len(resultados_api) != len(itens): + raise AssinaturaAPIError( + f'Resposta do /sign/batch retornou {len(resultados_api)} itens, ' + f'esperado {len(itens)}.' + ) + + logger.info(f'[assinatura-api/batch] {len(resultados_api)} PDFs assinados. Baixando...') + + # ── 2. Baixar PDFs assinados em paralelo via download_url ───────────────── + from concurrent.futures import ThreadPoolExecutor, as_completed + + def _baixar(idx_url): + idx, url = idx_url + try: + r = requests.get(url, timeout=60) + if not r.ok: + return idx, None, f'Erro ao baixar PDF assinado: HTTP {r.status_code}' + if not r.content[:5] == b'%PDF-': + return idx, None, 'Conteúdo baixado não é um PDF válido.' + return idx, r.content, None + except Exception as exc: + return idx, None, str(exc) + + urls_indexadas = [ + (idx, res['download_url']) + for idx, res in enumerate(resultados_api) + ] + + resultados_finais = [None] * len(itens) + with ThreadPoolExecutor(max_workers=download_workers) as executor: + futures = {executor.submit(_baixar, item): item for item in urls_indexadas} + for future in as_completed(futures): + idx, pdf_bytes, error = future.result() + item_id = itens[idx]['id'] + if error: + logger.error(f'[assinatura-api/batch] item {idx} (id={item_id}): {error}') + resultados_finais[idx] = {'id': item_id, 'ok': False, 'error': error} + else: + resultados_finais[idx] = {'id': item_id, 'ok': True, 'pdf_bytes': pdf_bytes} + + return resultados_finais + + +def validar_pfx_via_api(certificado_bytes, senha): + """ + Valida um certificado PFX no microservico. + Retorna dict com informacoes do certificado ou lanca AssinaturaAPIError. + """ + if not _api_configurada(): + raise AssinaturaAPIError('ASSINATURA_API_URL nao configurado.') + + files = {'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream')} + data = {'pfx_password': senha} + timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120) + + try: + response = requests.post( + _url('validate-pfx'), + files=files, + data=data, + headers=_montar_headers(), + timeout=timeout, + ) + except requests.exceptions.RequestException as exc: + raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}') + + if not response.ok: + try: + detail = response.json() + msg = detail.get('detail') or str(detail) + except Exception: + msg = response.text[:300] or f'HTTP {response.status_code}' + raise AssinaturaAPIError(msg, status_code=response.status_code) + + try: + return response.json() + except Exception: + return {} + + +def verificar_health(): + """ + Verifica se o microservico esta disponivel (GET /). + Retorna (ok: bool, mensagem: str). + """ + if not _api_configurada(): + return False, 'ASSINATURA_API_URL nao configurado.' + + try: + response = requests.get( + _url('/'), + headers=_montar_headers(), + timeout=10, + ) + if response.ok: + return True, 'Microservico disponivel.' + return False, f'Microservico respondeu com HTTP {response.status_code}.' + except requests.exceptions.RequestException as exc: + return False, f'Falha de conexao: {exc}' diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index 1dc8dda06..f55a89329 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -53,11 +53,12 @@ from sapl.materia.onlyoffice_materia_views import ( 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, materia_assinar_lote, + detectar_aplicacao_a3, assinatura_api_status, materia_assinar_lote, docacessorio_assinar_a1, docacessorio_pdf_assinado, docacessorio_verificar_assinatura, docacessorio_remover_assinatura, materia_verificar_documento, docacessorio_verificar_documento, - docacessorio_assinar_lote + docacessorio_assinar_lote, + materia_pagina_assinatura_png, docacessorio_pagina_assinatura_png, ) from sapl.norma.views import NormaPesquisaSimplesView from sapl.protocoloadm.views import ( @@ -205,6 +206,12 @@ urlpatterns_materia = [ name='materia_remover_assinatura'), url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3, name='detectar_aplicacao_a3'), + url(r'^materia/assinatura/api-status/$', assinatura_api_status, + name='assinatura_api_status'), + url(r'^materia/(?P\d+)/assinatura/pagina-preview/$', materia_pagina_assinatura_png, + name='materia_pagina_assinatura_png'), + url(r'^materia/documentoacessorio/(?P\d+)/assinatura/pagina-preview/$', docacessorio_pagina_assinatura_png, + name='docacessorio_pagina_assinatura_png'), # Verificação pública de autenticidade (sem login) url(r'^materia/(?P\d+)/verificar/$', materia_verificar_documento, diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index 767019fd0..b0ea55c7c 100644 --- a/sapl/materia/views_assinatura.py +++ b/sapl/materia/views_assinatura.py @@ -54,6 +54,362 @@ def _normalizar_assinatura_info(info): return info +# ============================================================================= +# Roteamento de backend de assinatura: API externa OU pyhanko local +# ============================================================================= + +def _usar_api_externa(): + """Retorna True se a API externa de assinatura está configurada.""" + from sapl.materia.assinatura_api_client import _api_configurada + return _api_configurada() + + +def _extrair_posicao_custom(post_data): + """ + Extrai coordenadas de posicionamento customizado do POST. + Retorna dict com sig_left, sig_bottom, sig_width, sig_height, sig_page + ou None se não foram enviadas / inválidas. + """ + try: + sig_left = post_data.get('sig_left', '').strip() + sig_bottom = post_data.get('sig_bottom', '').strip() + sig_width = post_data.get('sig_width', '').strip() + sig_height = post_data.get('sig_height', '').strip() + sig_page = post_data.get('sig_page', '').strip() + if not all([sig_left, sig_bottom, sig_width, sig_height, sig_page]): + return None + return { + 'sig_left': float(sig_left), + 'sig_bottom': float(sig_bottom), + 'sig_width': float(sig_width), + 'sig_height': float(sig_height), + 'sig_page': int(sig_page), + } + except (ValueError, TypeError): + return None + + +def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, + assinaturas_existentes, + certificado_bytes=None, senha=None, + assinatura_a3_bytes=None, cert_chain_bytes=None, + tipo_cert_input='a1', + posicao_custom=None): + """ + Orquestra a assinatura digital completa de um PDF: + - Monta a página de autenticação (1ª assinatura) ou assina incrementalmente + (assinaturas subsequentes). + - Usa a API externa quando ASSINATURA_API_URL estiver configurado; + caso contrário usa pyhanko localmente. + + posicao_custom : dict opcional com chaves sig_left, sig_bottom, sig_width, + sig_height (em pontos PDF) e sig_page (1-based). + Quando fornecido, substitui o cálculo automático de grid. + + Retorna (signed_pdf_bytes, nova_assinatura_dict, codigo_autenticacao_ou_None). + - codigo_autenticacao_ou_None é não-None apenas na 1ª assinatura. + + Lança Exception em caso de erro. + """ + from PyPDF4 import PdfFileReader, PdfFileWriter + import base64 as _base64 + + ja_tem_pdf_assinado = bool(assinaturas_existentes) + + # ── Informações do assinante ────────────────────────────────────────────── + # Para API externa: não há signer local; tentamos extrair info do certificado + # de forma leve (apenas para metadados visuais). + if _usar_api_externa(): + from sapl.materia.assinatura_api_client import assinar_pdf_via_api, AssinaturaAPIError + + # Obter nome/cargo via relação User → Autor (sem pyhanko) + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + tipo_cert_display = 'Certificado Digital' + + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + tipo_desc = autor.tipo.descricao if autor.tipo else '' + if tipo_desc == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_desc: + cargo = tipo_desc + if isinstance(autor.autor_related, Parlamentar): + parl = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar + except Exception: + pass + + data_assinatura = timezone.localtime(timezone.now()) + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + codigo_retorno = None + + if not ja_tem_pdf_assinado: + # 1ª assinatura: montar PDF com página de autenticação ANTES de enviar + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) + + nova_assinatura_info_visual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info_visual], codigo, url_verificacao, + page_width, page_height + ) + + auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for page_num in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(page_num)) + output_pdf.addPage(auth_page_pdf.getPage(0)) + + buf_combinado = io.BytesIO() + output_pdf.write(buf_combinado) + pdf_para_assinar = buf_combinado.getvalue() + codigo_retorno = codigo + else: + pdf_para_assinar = pdf_bytes # já inclui página de autenticação + + # Calcula a posição do campo de assinatura na última página (página de autenticação) + # para que a assinatura visual fique no bloco correto do grid. + # Se posicao_custom foi fornecido pelo usuário, usa ele diretamente. + if posicao_custom: + sig_page = posicao_custom.get('sig_page') + sig_left = posicao_custom.get('sig_left') + sig_bottom = posicao_custom.get('sig_bottom') + sig_width = posicao_custom.get('sig_width') + sig_height = posicao_custom.get('sig_height') + else: + n_assinatura = len(assinaturas_existentes) + try: + temp_pypdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) + auth_pg = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) + auth_w = float(auth_pg.mediaBox.getWidth()) + auth_h = float(auth_pg.mediaBox.getHeight()) + sig_x1, sig_y1, sig_x2, sig_y2 = _posicao_bloco_assinatura( + n_assinatura, auth_w, auth_h + ) + sig_page = temp_pypdf.getNumPages() # 1-based, última página + sig_left = sig_x1 + sig_bottom = sig_y1 + sig_width = sig_x2 - sig_x1 + sig_height = sig_y2 - sig_y1 + except Exception: + sig_page = sig_left = sig_bottom = sig_width = sig_height = None + + try: + pdf_assinado_bytes = assinar_pdf_via_api( + pdf_para_assinar, + certificado_bytes=certificado_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location='Câmara Municipal', + signature_page=sig_page, + signature_left=sig_left, + signature_bottom=sig_bottom, + signature_width=sig_width, + signature_height=sig_height, + ) + except AssinaturaAPIError as exc: + raise Exception(str(exc)) + + nova_assinatura = { + 'tipo_certificado': tipo_cert_input.upper(), + 'tipo_certificado_display': f'Certificado Digital – {tipo_cert_input.upper()}', + 'subject': '', + 'issuer': '', + 'serial': '', + 'valid_from': '', + 'valid_to': '', + 'signed_by': request.user.username, + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_formatada, + 'validade_juridica': 'Assinatura Eletrônica Qualificada', + 'backend': 'api_externa', + } + return pdf_assinado_bytes, nova_assinatura, codigo_retorno + + else: + # ── Backend local: pyhanko ───────────────────────────────────────── + from pyhanko.sign import signers, fields + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + from pyhanko.sign.fields import SigFieldSpec + from pyhanko.sign.signers.pdf_signer import PdfSigner + from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader + + # Carregar certificado A1 + import tempfile as tmp_module + with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: + tmp_cert.write(certificado_bytes) + tmp_cert_path = tmp_cert.name + try: + signer = signers.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + except Exception as cert_error: + raise Exception(f'Erro ao carregar certificado: {cert_error}') + finally: + if os.path.exists(tmp_cert_path): + os.unlink(tmp_cert_path) + + cert_info = signer.signing_cert + error_response = _validar_certificado(cert_info) + if error_response: + import json as _json + data = _json.loads(error_response.content) + raise Exception(data.get('error', 'Certificado inválido.')) + + # Criar objeto fake para _obter_info_assinante + class _CertWrapper: + def __init__(self, c): self._c = c + @property + def issuer(self): return self._c.issuer + @property + def subject(self): return self._c.subject + @property + def serial_number(self): return self._c.serial_number + @property + def not_valid_before(self): return self._c.not_valid_before + @property + def not_valid_after(self): return self._c.not_valid_after + + nome_assinante, cargo, tipo_cert_display = _obter_info_assinante(request, cert_info) + data_assinatura = timezone.localtime(timezone.now()) + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + + n_assinatura = len(assinaturas_existentes) + sig_field_name = ( + f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 + else 'AssinaturaDigital' + ) + codigo_retorno = None + + if ja_tem_pdf_assinado: + # Assinatura subsequente (incremental) + temp_pypdf = PdfFileReader(io.BytesIO(pdf_bytes)) + auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) + auth_page_width = float(auth_page.mediaBox.getWidth()) + auth_page_height = float(auth_page.mediaBox.getHeight()) + x1, y1, x2, y2 = _posicao_bloco_assinatura( + n_assinatura, auth_page_width, auth_page_height + ) + + signed_buffer = io.BytesIO() + with io.BytesIO(pdf_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + temp_reader = PyhankoReader(io.BytesIO(pdf_bytes)) + last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 + + sig_field = SigFieldSpec( + sig_field_name=sig_field_name, + on_page=last_page_idx, + box=(x1, y1, x2, y2) + ) + fields.append_signature_field(w, sig_field) + + meta = signers.PdfSignatureMetadata( + field_name=sig_field_name, + location='Câmara Municipal', + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + name=nome_assinante + ) + hash_doc = '' # será preenchido pelo chamador se necessário + stamp_style = _criar_stamp_style(nome_assinante, cargo, hash_doc) + pdf_signer = PdfSigner(meta, signer=signer, stamp_style=stamp_style) + pdf_signer.sign_pdf( + w, + existing_fields_only=True, + appearance_text_params={'signer': nome_assinante}, + output=signed_buffer, + ) + + signed_buffer.seek(0) + pdf_assinado_bytes = signed_buffer.read() + + else: + # Primeira assinatura: página de autenticação + pyhanko + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) + nova_assinatura_info_visual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info_visual], codigo, url_verificacao, + page_width, page_height + ) + + auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for page_num in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(page_num)) + output_pdf.addPage(auth_page_pdf.getPage(0)) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f: + tmp_path = tmp_f.name + output_pdf.write(tmp_f) + + try: + with open(tmp_path, 'rb') as f: + stamped_bytes = f.read() + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + 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) + pdf_assinado_bytes = signed_buffer.read() + codigo_retorno = codigo + + nova_assinatura = { + 'tipo_certificado': 'A1', + 'tipo_certificado_display': f'{tipo_cert_display} – 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', + 'backend': 'pyhanko_local', + } + return pdf_assinado_bytes, nova_assinatura, codigo_retorno + + # ============================================================================= # Funções auxiliares para página de autenticação # ============================================================================= @@ -681,13 +1037,15 @@ def materia_assinar_a1(request, pk): """ Assina o PDF da matéria com certificado A1 (arquivo .pfx/.p12). + Usa API externa de assinatura quando ASSINATURA_API_URL estiver configurado; + caso contrário assina localmente com pyhanko. + Parâmetros POST: - certificado: arquivo .pfx ou .p12 - senha: senha do certificado """ materia = get_object_or_404(MateriaLegislativa, pk=pk) - # Verifica se o usuário atual já assinou assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info) if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): return JsonResponse({ @@ -695,248 +1053,40 @@ def materia_assinar_a1(request, pk): 'error': 'Você já assinou esta matéria.' }, status=400) - ja_tem_pdf_assinado = bool(materia.pdf_assinado) - - # Obtém dados do formulário certificado_file = request.FILES.get('certificado') senha = request.POST.get('senha', '') if not certificado_file: - return JsonResponse({ - 'success': False, - 'error': 'Certificado não informado.' - }, status=400) - + return JsonResponse({'success': False, 'error': 'Certificado não informado.'}, status=400) if not senha: - return JsonResponse({ - 'success': False, - 'error': 'Senha do certificado não informada.' - }, status=400) - - # Gera o PDF da matéria - pdf_bytes, error = _gerar_pdf_da_materia(materia, request) - if error: - return JsonResponse({ - 'success': False, - 'error': error - }, status=400) - - try: - from pyhanko.sign import signers, fields - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - - signer, error_response = _carregar_certificado(certificado_file, senha) - if error_response: - return error_response - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - return error_response - - # Cria arquivo temporário para o PDF com página de autenticação - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped: - temp_stamped_path = temp_stamped.name - - try: - from pyhanko.sign.fields import SigFieldSpec - from PyPDF4 import PdfFileReader, PdfFileWriter - from reportlab.lib.units import mm - - # Informações do assinante - nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info) - data_assinatura = timezone.localtime(timezone.now()) - data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') - - # Número da assinatura (0-indexed) - n_assinatura = len(assinaturas_existentes) - sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital' - - if ja_tem_pdf_assinado: - # ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado ===== - # A página de autenticação já existe (criada na 1ª assinatura). - # Posicionar campo de assinatura na última página (autenticação). - - with open(materia.pdf_assinado.path, 'rb') as f: - existing_pdf_bytes = f.read() - - # Calcular posição do campo de assinatura na página de autenticação - # Usa o mesmo grid da página gerada na primeira assinatura - temp_pypdf = PdfFileReader(io.BytesIO(existing_pdf_bytes)) - auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) - auth_page_width = float(auth_page.mediaBox.getWidth()) - auth_page_height = float(auth_page.mediaBox.getHeight()) - - x1, y1, x2, y2 = _posicao_bloco_assinatura( - n_assinatura, auth_page_width, auth_page_height - ) - - signed_buffer = io.BytesIO() - with io.BytesIO(existing_pdf_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - - # Determina a última página - from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader - from pyhanko.sign.signers.pdf_signer import PdfSigner - temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes)) - last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 - - # Adiciona campo de assinatura com posição visual - sig_field = SigFieldSpec( - sig_field_name=sig_field_name, - on_page=last_page_idx, - box=(x1, y1, x2, y2) - ) - fields.append_signature_field(w, sig_field) - - meta = signers.PdfSignatureMetadata( - field_name=sig_field_name, - location='Câmara Municipal', - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - name=nome_assinante - ) - - # Usa PdfSigner com stamp_style customizado para - # manter visual consistente com a página de autenticação - hash_doc = materia.codigo_autenticacao or '' - stamp_style = _criar_stamp_style( - nome_assinante, cargo, hash_doc - ) - pdf_signer = PdfSigner( - meta, - signer=signer, - stamp_style=stamp_style, - ) - pdf_signer.sign_pdf( - w, - existing_fields_only=True, - appearance_text_params={'signer': nome_assinante}, - output=signed_buffer, - ) - - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() - - else: - # ===== PRIMEIRA ASSINATURA: Página de autenticação + pyhanko sign ===== - - # Lê o PDF original para obter as dimensões - original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) - last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) - page_box = last_page.mediaBox - page_width = float(page_box.getWidth()) - page_height = float(page_box.getHeight()) - - # Gerar código de autenticação - codigo = _gerar_codigo_autenticacao(pdf_bytes) - - # Construir URL de verificação - url_verificacao = _construir_url_verificacao( - request, 'materia', pk, codigo - ) - - # Nova assinatura info (para incluir na página de autenticação) - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - - # Gerar a página de autenticação - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], - codigo, url_verificacao, - page_width, page_height - ) - - # Montar PDF: original + página de autenticação - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() - - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - - # Anexar página de autenticação - output_pdf.addPage(auth_page_pdf.getPage(0)) - - # Salvar em arquivo temporário - with open(temp_stamped_path, 'wb') as f: - output_pdf.write(f) - - # Assinar o PDF combinado - with open(temp_stamped_path, 'rb') as stamped_file: - stamped_bytes = stamped_file.read() - - signed_buffer = io.BytesIO() - with io.BytesIO(stamped_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - - meta = signers.PdfSignatureMetadata( - field_name='AssinaturaDigital', - location='Câmara Municipal', - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - name=nome_assinante - ) - - signers.sign_pdf( - w, - meta, - signer=signer, - output=signed_buffer - ) - - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() - - # Salvar código de autenticação - materia.codigo_autenticacao = codigo - - # Salva o PDF assinado no modelo - filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) - - # Salva informações da assinatura (lista de dicts) - nova_assinatura = { - 'tipo_certificado': 'A1', - 'tipo_certificado_display': f'{tipo_cert} – A1', - 'subject': str(cert_info.subject), - 'issuer': str(cert_info.issuer), - 'serial': str(cert_info.serial_number), - 'valid_from': cert_info.not_valid_before.isoformat(), - 'valid_to': cert_info.not_valid_after.isoformat(), - 'signed_by': request.user.username, - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_formatada, - 'validade_juridica': 'Assinatura Eletrônica Qualificada' - } - assinaturas_existentes.append(nova_assinatura) - materia.assinatura_info = assinaturas_existentes - materia.assinado_em = timezone.now() - materia.assinado_por = request.user - materia.save() - - # Invalida cache de pendências para atualizar sino na navbar - from django.core.cache import cache as _cache - _cache.delete(f'pendencias_assinatura_user_{request.user.pk}') + return JsonResponse({'success': False, 'error': 'Senha do certificado não informada.'}, status=400) - logger.info(f"Matéria {materia.pk} assinada por {request.user.username}") + # PDF a assinar: já assinado (subsequente) ou original (primeira assinatura) + if materia.pdf_assinado: + with open(materia.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + else: + pdf_bytes, error = _gerar_pdf_da_materia(materia, request) + if error: + return JsonResponse({'success': False, 'error': error}, status=400) - 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') - } - }) + certificado_bytes = certificado_file.read() - finally: - # Remove arquivo temporário - if os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) + # Posição personalizada enviada pelo usuário (opcional) + posicao_custom = _extrair_posicao_custom(request.POST) + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='materia', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=certificado_bytes, + senha=senha, + tipo_cert_input='a1', + posicao_custom=posicao_custom, + ) except ImportError: logger.error("pyhanko não está instalado") return JsonResponse({ @@ -944,11 +1094,38 @@ def materia_assinar_a1(request, pk): '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) + logger.error(f"Erro ao assinar PDF da matéria {pk}: {e}") + return JsonResponse({'success': False, 'error': str(e)}, status=500) + + filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + + if codigo: + materia.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + materia.assinatura_info = assinaturas_existentes + materia.assinado_em = timezone.now() + materia.assinado_por = request.user + materia.save() + + from django.core.cache import cache as _cache + _cache.delete(f'pendencias_assinatura_user_{request.user.pk}') + + logger.info( + f"Matéria {materia.pk} assinada por {request.user.username} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + + return JsonResponse({ + 'success': True, + 'message': 'PDF assinado com sucesso!', + 'backend': nova_assinatura.get('backend', ''), + 'certificado': { + 'nome': nova_assinatura.get('subject') or nova_assinatura.get('nome_assinante', ''), + 'validade': nova_assinatura.get('valid_to', ''), + } + }) @login_required @@ -1298,249 +1475,219 @@ def detectar_aplicacao_a3(request): # ============================================================================= -# Views de Assinatura Digital para Documento Acessório +# Preview de página para posicionamento de assinatura # ============================================================================= @login_required -@csrf_exempt -@require_http_methods(["POST"]) -def docacessorio_assinar_a1(request, pk): +@require_http_methods(["GET"]) +def materia_pagina_assinatura_png(request, pk): """ - Assina o PDF do documento acessório com certificado A1 (arquivo .pfx/.p12). + Renderiza a última página (página de autenticação) do PDF da matéria como + PNG e retorna JSON com: + - png_base64 : imagem da página em base64 + - page_width : largura em pontos PDF + - page_height : altura em pontos PDF + - page_number : número 1-based da página + - grid_slots : lista de slots do grid automático [{left,bottom,width,height}] """ - docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) - - # Verifica se o usuário atual já assinou - assinaturas_existentes = _normalizar_assinatura_info(docacessorio.assinatura_info) - if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): - return JsonResponse({ - 'success': False, - 'error': 'Você já assinou este documento.' - }, status=400) - - ja_tem_pdf_assinado = bool(docacessorio.pdf_assinado) + return _pagina_assinatura_png_response(request, 'materia', pk) - 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) +@login_required +@require_http_methods(["GET"]) +def docacessorio_pagina_assinatura_png(request, pk): + """Mesmo que materia_pagina_assinatura_png, mas para DocumentoAcessório.""" + return _pagina_assinatura_png_response(request, 'docacessorio', pk) - pdf_bytes, error = _gerar_pdf_do_docacessorio(docacessorio, request) - if error: - return JsonResponse({ - 'success': False, - 'error': error - }, status=400) +def _pagina_assinatura_png_response(request, tipo_doc, pk): + """Lógica comum para gerar PNG da página de autenticação.""" try: - from pyhanko.sign import signers, fields - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - - signer, error_response = _carregar_certificado(certificado_file, senha) - if error_response: - return error_response - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - return error_response - - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped: - temp_stamped_path = temp_stamped.name + import fitz # PyMuPDF + except ImportError: + return JsonResponse({'error': 'PyMuPDF não instalado. Instale com: pip install pymupdf'}, status=500) + + from PyPDF4 import PdfFileReader, PdfFileWriter + + # Obter PDF + if tipo_doc == 'materia': + obj = get_object_or_404(MateriaLegislativa, pk=pk) + if obj.pdf_assinado: + with open(obj.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + # PDF já tem página de autenticação — renderizar última + build_auth = False + else: + pdf_bytes, error = _gerar_pdf_da_materia(obj, request) + if error: + return JsonResponse({'error': error}, status=400) + build_auth = True + else: + obj = get_object_or_404(DocumentoAcessorio, pk=pk) + if obj.pdf_assinado: + with open(obj.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + build_auth = False + else: + pdf_bytes, error = _gerar_pdf_do_docacessorio(obj, request) + if error: + return JsonResponse({'error': error}, status=400) + build_auth = True + + # Se ainda não tem página de autenticação, criar uma temporária para preview + if build_auth: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + # Gerar página de autenticação placeholder (sem código real — só visual) + codigo_fake = 'PREVIEW-000000' + url_fake = request.build_absolute_uri('/') + assinaturas_existentes = _normalizar_assinatura_info( + obj.assinatura_info if hasattr(obj, 'assinatura_info') else None + ) + nome = request.user.get_full_name() or request.user.username + auth_page_bytes = _gerar_pagina_autenticacao( + [{'nome_assinante': nome, 'cargo': '—', 'data_assinatura': ''}], + codigo_fake, url_fake, page_width, page_height + ) + auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output = PdfFileWriter() + for i in range(original_pdf.getNumPages()): + output.addPage(original_pdf.getPage(i)) + output.addPage(auth_pdf.getPage(0)) + buf = io.BytesIO() + output.write(buf) + pdf_bytes = buf.getvalue() + else: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + assinaturas_existentes = _normalizar_assinatura_info( + obj.assinatura_info if hasattr(obj, 'assinatura_info') else None + ) + # Renderizar última página como PNG via PyMuPDF + import base64 + doc_fitz = fitz.open(stream=pdf_bytes, filetype='pdf') + page_idx = len(doc_fitz) - 1 # última página + page_fitz = doc_fitz[page_idx] + mat = fitz.Matrix(2.0, 2.0) # 2x zoom → melhor qualidade + pix = page_fitz.get_pixmap(matrix=mat, alpha=False) + png_bytes = pix.tobytes('png') + doc_fitz.close() + + png_b64 = base64.b64encode(png_bytes).decode('ascii') + + # Calcular slots do grid automático para mostrar no preview + n_existentes = len(assinaturas_existentes) + grid_slots = [] + for i in range(n_existentes + 4): # mostra slots já usados + 4 próximos try: - from pyhanko.sign.fields import SigFieldSpec - from PyPDF4 import PdfFileReader, PdfFileWriter - from reportlab.lib.units import mm - - nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info) - data_assinatura = timezone.localtime(timezone.now()) - data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') - - # Número da assinatura (0-indexed) - n_assinatura = len(assinaturas_existentes) - sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital' - - if ja_tem_pdf_assinado: - # ===== ASSINATURA SUBSEQUENTE ===== - - with open(docacessorio.pdf_assinado.path, 'rb') as f: - existing_pdf_bytes = f.read() - - # Calcular posição na página de autenticação - # Usa o mesmo grid da página gerada na primeira assinatura - temp_pypdf = PdfFileReader(io.BytesIO(existing_pdf_bytes)) - auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) - auth_page_width = float(auth_page.mediaBox.getWidth()) - auth_page_height = float(auth_page.mediaBox.getHeight()) - - x1, y1, x2, y2 = _posicao_bloco_assinatura( - n_assinatura, auth_page_width, auth_page_height - ) - - signed_buffer = io.BytesIO() - with io.BytesIO(existing_pdf_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - - from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader - from pyhanko.sign.signers.pdf_signer import PdfSigner - temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes)) - last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 - - sig_field = SigFieldSpec( - sig_field_name=sig_field_name, - on_page=last_page_idx, - box=(x1, y1, x2, y2) - ) - fields.append_signature_field(w, sig_field) - - meta = signers.PdfSignatureMetadata( - field_name=sig_field_name, - location='Câmara Municipal', - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - name=nome_assinante - ) - - # Usa PdfSigner com stamp_style customizado para - # manter visual consistente com a página de autenticação - hash_doc = docacessorio.codigo_autenticacao or '' - stamp_style = _criar_stamp_style( - nome_assinante, cargo, hash_doc - ) - pdf_signer = PdfSigner( - meta, - signer=signer, - stamp_style=stamp_style, - ) - pdf_signer.sign_pdf( - w, - existing_fields_only=True, - appearance_text_params={'signer': nome_assinante}, - output=signed_buffer, - ) - - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() - - else: - # ===== PRIMEIRA ASSINATURA: Página de autenticação + pyhanko sign ===== - - original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) - last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) - page_box = last_page.mediaBox - page_width = float(page_box.getWidth()) - page_height = float(page_box.getHeight()) - - # Gerar código de autenticação - codigo = _gerar_codigo_autenticacao(pdf_bytes) - - # Construir URL de verificação - url_verificacao = _construir_url_verificacao( - request, 'docacessorio', pk, codigo - ) - - # Nova assinatura info - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - - # Gerar a página de autenticação - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], - codigo, url_verificacao, - page_width, page_height - ) + x1, y1, x2, y2 = _posicao_bloco_assinatura(i, page_width, page_height) + grid_slots.append({ + 'index': i, + 'left': round(x1, 2), + 'bottom': round(y1, 2), + 'width': round(x2 - x1, 2), + 'height': round(y2 - y1, 2), + 'usado': i < n_existentes, + 'sugerido': i == n_existentes, + }) + except Exception: + break - # Montar PDF: original + página de autenticação - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() + return JsonResponse({ + 'png_base64': png_b64, + 'page_width': round(page_width, 2), + 'page_height': round(page_height, 2), + 'page_number': page_idx + 1, + 'render_scale': 2.0, + 'grid_slots': grid_slots, + }) - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) +@login_required +@require_http_methods(["GET"]) +def assinatura_api_status(request): + """ + Retorna o status do microserviço de assinatura e qual backend está ativo. + Útil para diagnóstico e para o frontend exibir informações ao usuário. + """ + from sapl.materia.assinatura_api_client import verificar_health, _api_configurada - with open(temp_stamped_path, 'wb') as f: - output_pdf.write(f) + usa_api = _api_configurada() - with open(temp_stamped_path, 'rb') as stamped_file: - stamped_bytes = stamped_file.read() + if usa_api: + ok, mensagem = verificar_health() + return JsonResponse({ + 'backend': 'api_externa', + 'api_url': settings.ASSINATURA_API_URL, + 'disponivel': ok, + 'mensagem': mensagem, + }) + else: + return JsonResponse({ + 'backend': 'pyhanko_local', + 'disponivel': True, + 'mensagem': 'Usando pyhanko local (ASSINATURA_API_URL não configurado).', + }) - 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 - ) +# ============================================================================= +# Views de Assinatura Digital para Documento Acessório +# ============================================================================= - signers.sign_pdf( - w, - meta, - signer=signer, - output=signed_buffer - ) +@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). - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() + Usa API externa de assinatura quando ASSINATURA_API_URL estiver configurado; + caso contrário assina localmente com pyhanko. + """ + docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) - # Salvar código de autenticação - docacessorio.codigo_autenticacao = codigo + assinaturas_existentes = _normalizar_assinatura_info(docacessorio.assinatura_info) + if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): + return JsonResponse({'success': False, 'error': 'Você já assinou este documento.'}, status=400) - filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) + certificado_file = request.FILES.get('certificado') + senha = request.POST.get('senha', '') - nova_assinatura = { - 'tipo_certificado': 'A1', - 'tipo_certificado_display': f'{tipo_cert} – A1', - 'subject': str(cert_info.subject), - 'issuer': str(cert_info.issuer), - 'serial': str(cert_info.serial_number), - 'valid_from': cert_info.not_valid_before.isoformat(), - 'valid_to': cert_info.not_valid_after.isoformat(), - 'signed_by': request.user.username, - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_formatada, - 'validade_juridica': 'Assinatura Eletrônica Qualificada' - } - assinaturas_existentes.append(nova_assinatura) - docacessorio.assinatura_info = assinaturas_existentes - docacessorio.assinado_em = timezone.now() - docacessorio.assinado_por = request.user - docacessorio.save() + 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) - logger.info(f"Documento acessório {docacessorio.pk} assinado por {request.user.username}") + if docacessorio.pdf_assinado: + with open(docacessorio.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + else: + pdf_bytes, error = _gerar_pdf_do_docacessorio(docacessorio, request) + if error: + return JsonResponse({'success': False, 'error': error}, status=400) - 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') - } - }) + certificado_bytes = certificado_file.read() - finally: - if os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) + # Posição personalizada enviada pelo usuário (opcional) + posicao_custom = _extrair_posicao_custom(request.POST) + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='docacessorio', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=certificado_bytes, + senha=senha, + tipo_cert_input='a1', + posicao_custom=posicao_custom, + ) except ImportError: logger.error("pyhanko não está instalado") return JsonResponse({ @@ -1548,11 +1695,35 @@ def docacessorio_assinar_a1(request, pk): '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) + logger.error(f"Erro ao assinar PDF do doc acessório {pk}: {e}") + return JsonResponse({'success': False, 'error': str(e)}, status=500) + + filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + + if codigo: + docacessorio.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + docacessorio.assinatura_info = assinaturas_existentes + docacessorio.assinado_em = timezone.now() + docacessorio.assinado_por = request.user + docacessorio.save() + + logger.info( + f"Documento acessório {docacessorio.pk} assinado por {request.user.username} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + + return JsonResponse({ + 'success': True, + 'message': 'PDF assinado com sucesso!', + 'backend': nova_assinatura.get('backend', ''), + 'certificado': { + 'nome': nova_assinatura.get('subject') or nova_assinatura.get('nome_assinante', ''), + 'validade': nova_assinatura.get('valid_to', ''), + } + }) @login_required @@ -1867,36 +2038,37 @@ def materia_assinar_lote(request): cert_bytes = certificado_file.read() - import tempfile as tmp_module - with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: - tmp_cert.write(cert_bytes) - tmp_cert_path = tmp_cert.name - - try: - from pyhanko.sign import signers - signer = signers.SimpleSigner.load_pkcs12( - pfx_file=tmp_cert_path, - passphrase=senha.encode('utf-8') - ) - except Exception as cert_error: - logger.error(f"[lote] Erro ao carregar certificado: {cert_error}") - err_msg = str(cert_error) - if 'password' in err_msg.lower() or 'mac' in err_msg.lower(): - detail = 'Senha incorreta ou arquivo inválido.' - elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): - detail = 'Arquivo não é um certificado válido (.pfx/.p12).' - else: - detail = f'Detalhes: {err_msg}' - return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400) - finally: - if os.path.exists(tmp_cert_path): - os.unlink(tmp_cert_path) - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - data = json.loads(error_response.content) - return JsonResponse({'success': False, 'error': data.get('error', 'Certificado inválido.')}, status=400) + # Pré-validação do certificado apenas quando pyhanko é o backend local. + # Na API externa a validação é feita pelo microserviço. + if not _usar_api_externa(): + import tempfile as tmp_module + with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: + tmp_cert.write(cert_bytes) + tmp_cert_path = tmp_cert.name + try: + from pyhanko.sign import signers as _signers_lote + _signer_test = _signers_lote.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + _cert_test = _signer_test.signing_cert + _err = _validar_certificado(_cert_test) + if _err: + _d = json.loads(_err.content) + return JsonResponse({'success': False, 'error': _d.get('error', 'Certificado inválido.')}, status=400) + except Exception as cert_error: + logger.error(f"[lote] Erro ao carregar certificado: {cert_error}") + err_msg = str(cert_error) + if 'password' in err_msg.lower() or 'mac' in err_msg.lower(): + detail = 'Senha incorreta ou arquivo inválido.' + elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): + detail = 'Arquivo não é um certificado válido (.pfx/.p12).' + else: + detail = f'Detalhes: {err_msg}' + return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400) + finally: + if os.path.exists(tmp_cert_path): + os.unlink(tmp_cert_path) # ── Assinatura por matéria ──────────────────────────────────────────────── materias = MateriaLegislativa.objects.filter(pk__in=pks) @@ -1906,6 +2078,11 @@ def materia_assinar_lote(request): sucesso_count = 0 erro_count = 0 + # ── Pré-processar: gerar PDFs, filtrar inválidos ────────────────────────── + # itens_para_assinar: apenas matérias que passaram na pré-validação + itens_para_assinar = [] # list of dict com metadados + pdf_bytes + meta_map = {} # pk → {descricao, assinaturas_existentes} + for pk in pks: materia = materias_map.get(pk) if not materia: @@ -1932,98 +2109,194 @@ def materia_assinar_lote(request): erro_count += 1 continue - temp_stamped_path = None - try: - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - from PyPDF4 import PdfFileReader, PdfFileWriter as PyPDF4Writer + meta_map[pk] = {'descricao': descricao, 'assinaturas_existentes': assinaturas_existentes} + itens_para_assinar.append({'pk': pk, 'pdf_bytes': pdf_bytes}) - nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info) - data_assinatura = timezone.localtime(timezone.now()) - data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + # ── Assinatura: paralela (API externa) ou sequencial (pyhanko) ──────────── + if _usar_api_externa() and itens_para_assinar: + from sapl.materia.assinatura_api_client import assinar_pdf_lote_via_api, AssinaturaAPIError as _APIError - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo) + # Preparar itens com página de autenticação e coordenadas + from PyPDF4 import PdfFileReader, PdfFileWriter + import base64 as _base64 - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + tipo_desc = autor.tipo.descricao if autor.tipo else '' + if tipo_desc == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_desc: + cargo = tipo_desc + if isinstance(autor.autor_related, Parlamentar): + parl = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar + except Exception: + pass - 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()) + data_assinatura = timezone.localtime(timezone.now()) + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], codigo, url_verificacao, - page_width, page_height - ) + itens_api = [] + codigos_map = {} # pk → codigo_autenticacao - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PyPDF4Writer() - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) + for item in itens_para_assinar: + pk = item['pk'] + pdf_bytes = item['pdf_bytes'] + assinaturas_existentes = meta_map[pk]['assinaturas_existentes'] + ja_tem = bool(assinaturas_existentes) - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f: - temp_stamped_path = tmp_f.name - output_pdf.write(tmp_f) + if not ja_tem: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) - with open(temp_stamped_path, 'rb') as f: - stamped_bytes = f.read() + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo) + codigos_map[pk] = codigo - 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 + nova_assinatura_info_visual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info_visual], codigo, url_verificacao, + page_width, page_height ) - signers.sign_pdf(w, meta, signer=signer, output=signed_buffer) - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() + auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for i in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(i)) + output_pdf.addPage(auth_pdf.getPage(0)) + buf = io.BytesIO() + output_pdf.write(buf) + pdf_para_assinar = buf.getvalue() + else: + pdf_para_assinar = pdf_bytes + + n_assinatura = len(assinaturas_existentes) + try: + temp_pdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) + auth_pg = temp_pdf.getPage(temp_pdf.getNumPages() - 1) + auth_w = float(auth_pg.mediaBox.getWidth()) + auth_h = float(auth_pg.mediaBox.getHeight()) + x1, y1, x2, y2 = _posicao_bloco_assinatura(n_assinatura, auth_w, auth_h) + sig_page = temp_pdf.getNumPages() + sig_left, sig_bottom = x1, y1 + sig_width, sig_height = x2 - x1, y2 - y1 + except Exception: + sig_page = sig_left = sig_bottom = sig_width = sig_height = None + + itens_api.append({ + 'id': pk, + 'pdf_bytes': pdf_para_assinar, + 'signature_page': sig_page, + 'signature_left': sig_left, + 'signature_bottom': sig_bottom, + 'signature_width': sig_width, + 'signature_height': sig_height, + }) + + logger.info(f'[lote] Enviando {len(itens_api)} PDFs em paralelo para API externa...') + resultados_api = assinar_pdf_lote_via_api( + itens_api, + certificado_bytes=cert_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location='Câmara Municipal', + ) + + for res_api in resultados_api: + pk = res_api['id'] + materia = materias_map[pk] + descricao = meta_map[pk]['descricao'] + assinaturas_existentes = meta_map[pk]['assinaturas_existentes'] - filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) - materia.codigo_autenticacao = codigo + if not res_api['ok']: + logger.error(f'[lote] Matéria {pk} falhou: {res_api["error"]}') + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': res_api['error']}) + erro_count += 1 + continue - nova_assinatura_record = { + nova_assinatura = { 'tipo_certificado': 'A1', - 'tipo_certificado_display': f'{tipo_cert} – A1', - 'subject': str(cert_info.subject), - 'issuer': str(cert_info.issuer), - 'serial': str(cert_info.serial_number), - 'valid_from': cert_info.not_valid_before.isoformat(), - 'valid_to': cert_info.not_valid_after.isoformat(), + 'tipo_certificado_display': 'Certificado Digital – A1', + 'subject': '', 'issuer': '', 'serial': '', + 'valid_from': '', 'valid_to': '', 'signed_by': request.user.username, 'nome_assinante': nome_assinante, 'cargo': cargo, 'data_assinatura': data_formatada, - 'validade_juridica': 'Assinatura Eletrônica Qualificada' + 'validade_juridica': 'Assinatura Eletrônica Qualificada', + 'backend': 'api_externa', } - assinaturas_existentes.append(nova_assinatura_record) + + filename = f"materia_{pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(res_api['pdf_bytes']), save=False) + if pk in codigos_map: + materia.codigo_autenticacao = codigos_map[pk] + assinaturas_existentes.append(nova_assinatura) materia.assinatura_info = assinaturas_existentes materia.assinado_em = timezone.now() materia.assinado_por = request.user materia.save() - logger.info(f"[lote] Matéria {pk} assinada por {request.user.username}") + logger.info(f'[lote] Matéria {pk} assinada por {request.user.username} (api_externa/paralelo)') resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) sucesso_count += 1 - except Exception as e: - logger.error(f"[lote] Erro ao assinar matéria {pk}: {e}") - resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) - erro_count += 1 - finally: - if temp_stamped_path and os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) + else: + # Backend local: pyhanko — sequencial + for item in itens_para_assinar: + pk = item['pk'] + materia = materias_map[pk] + descricao = meta_map[pk]['descricao'] + assinaturas_existentes = meta_map[pk]['assinaturas_existentes'] + pdf_bytes = item['pdf_bytes'] + + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='materia', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=cert_bytes, + senha=senha, + tipo_cert_input='a1', + ) + + filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + if codigo: + materia.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + materia.assinatura_info = assinaturas_existentes + materia.assinado_em = timezone.now() + materia.assinado_por = request.user + materia.save() + + logger.info( + f"[lote] Matéria {pk} assinada por {request.user.username} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) + sucesso_count += 1 + + except Exception as e: + logger.error(f"[lote] Erro ao assinar matéria {pk}: {e}") + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) + erro_count += 1 # Invalida cache de pendências uma vez ao final do lote if sucesso_count > 0: @@ -2114,38 +2387,38 @@ def docacessorio_assinar_lote(request): cert_bytes = certificado_file.read() - import tempfile as tmp_module - with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: - tmp_cert.write(cert_bytes) - tmp_cert_path = tmp_cert.name - - try: - from pyhanko.sign import signers - signer = signers.SimpleSigner.load_pkcs12( - pfx_file=tmp_cert_path, - passphrase=senha.encode('utf-8') - ) - except Exception as cert_error: - logger.error(f"[lote-doc] Erro ao carregar certificado: {cert_error}") - err_msg = str(cert_error) - if 'password' in err_msg.lower() or 'mac' in err_msg.lower(): - detail = 'Senha incorreta ou arquivo invalido.' - elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): - detail = 'Arquivo nao e um certificado valido (.pfx/.p12).' - else: - detail = f'Detalhes: {err_msg}' - return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400) - finally: - if os.path.exists(tmp_cert_path): - os.unlink(tmp_cert_path) - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - data = json.loads(error_response.content) - return JsonResponse({'success': False, 'error': data.get('error', 'Certificado invalido.')}, status=400) + # Pré-validação do certificado apenas no backend local (pyhanko). + if not _usar_api_externa(): + import tempfile as tmp_module + with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: + tmp_cert.write(cert_bytes) + tmp_cert_path = tmp_cert.name + try: + from pyhanko.sign import signers as _signers_lote + _signer_test = _signers_lote.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + _cert_test = _signer_test.signing_cert + _err = _validar_certificado(_cert_test) + if _err: + _d = json.loads(_err.content) + return JsonResponse({'success': False, 'error': _d.get('error', 'Certificado inválido.')}, status=400) + except Exception as cert_error: + logger.error(f"[lote-doc] Erro ao carregar certificado: {cert_error}") + err_msg = str(cert_error) + if 'password' in err_msg.lower() or 'mac' in err_msg.lower(): + detail = 'Senha incorreta ou arquivo invalido.' + elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): + detail = 'Arquivo nao e um certificado valido (.pfx/.p12).' + else: + detail = f'Detalhes: {err_msg}' + return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400) + finally: + if os.path.exists(tmp_cert_path): + os.unlink(tmp_cert_path) - # -- Assinatura por documento -- + # -- Pré-processar: gerar PDFs, filtrar inválidos -- docs = DocumentoAcessorio.objects.filter(pk__in=pks).select_related('materia', 'tipo', 'materia__tipo') docs_map = {d.pk: d for d in docs} @@ -2153,6 +2426,9 @@ def docacessorio_assinar_lote(request): sucesso_count = 0 erro_count = 0 + itens_para_assinar = [] + meta_map_doc = {} # pk → {descricao, assinaturas_existentes} + for pk in pks: doc = docs_map.get(pk) if not doc: @@ -2179,330 +2455,180 @@ def docacessorio_assinar_lote(request): erro_count += 1 continue - temp_stamped_path = None - try: - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - from PyPDF4 import PdfFileReader, PdfFileWriter as PyPDF4Writer + meta_map_doc[pk] = {'descricao': descricao, 'assinaturas_existentes': assinaturas_existentes} + itens_para_assinar.append({'pk': pk, 'pdf_bytes': pdf_bytes}) - nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info) - data_assinatura = timezone.localtime(timezone.now()) - data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + # -- Assinatura: /sign/batch (API externa) ou sequencial (pyhanko) -- + if _usar_api_externa() and itens_para_assinar: + from sapl.materia.assinatura_api_client import assinar_pdf_lote_via_api, AssinaturaAPIError as _APIError + from PyPDF4 import PdfFileReader, PdfFileWriter - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, 'docacessorio', pk, codigo) - - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + tipo_desc = autor.tipo.descricao if autor.tipo else '' + if tipo_desc == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_desc: + cargo = tipo_desc + if isinstance(autor.autor_related, Parlamentar): + parl = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar + except Exception: + pass - 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()) + data_assinatura = timezone.localtime(timezone.now()) + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], codigo, url_verificacao, - page_width, page_height - ) + itens_api = [] + codigos_map = {} - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PyPDF4Writer() - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) + for item in itens_para_assinar: + pk = item['pk'] + pdf_bytes = item['pdf_bytes'] + assinaturas_existentes = meta_map_doc[pk]['assinaturas_existentes'] + ja_tem = bool(assinaturas_existentes) - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f: - temp_stamped_path = tmp_f.name - output_pdf.write(tmp_f) + if not ja_tem: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, 'docacessorio', pk, codigo) + codigos_map[pk] = codigo + nova_assinatura_info_visual = {'nome_assinante': nome_assinante, 'cargo': cargo, 'data_assinatura': data_simples} + auth_page_bytes = _gerar_pagina_autenticacao([nova_assinatura_info_visual], codigo, url_verificacao, page_width, page_height) + auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for i in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(i)) + output_pdf.addPage(auth_pdf.getPage(0)) + buf = io.BytesIO() + output_pdf.write(buf) + pdf_para_assinar = buf.getvalue() + else: + pdf_para_assinar = pdf_bytes - with open(temp_stamped_path, 'rb') as f: - stamped_bytes = f.read() + n_assinatura = len(assinaturas_existentes) + try: + temp_pdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) + auth_pg = temp_pdf.getPage(temp_pdf.getNumPages() - 1) + auth_w = float(auth_pg.mediaBox.getWidth()) + auth_h = float(auth_pg.mediaBox.getHeight()) + x1, y1, x2, y2 = _posicao_bloco_assinatura(n_assinatura, auth_w, auth_h) + sig_page = temp_pdf.getNumPages() + sig_left, sig_bottom = x1, y1 + sig_width, sig_height = x2 - x1, y2 - y1 + except Exception: + sig_page = sig_left = sig_bottom = sig_width = sig_height = None + + itens_api.append({ + 'id': pk, + 'pdf_bytes': pdf_para_assinar, + 'signature_page': sig_page, + 'signature_left': sig_left, + 'signature_bottom': sig_bottom, + 'signature_width': sig_width, + 'signature_height': sig_height, + }) - signed_buffer = io.BytesIO() - with io.BytesIO(stamped_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - meta = signers.PdfSignatureMetadata( - field_name='AssinaturaDigital', - location='Camara 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) + logger.info(f'[lote-doc] Enviando {len(itens_api)} PDFs em batch para API externa...') + resultados_api = assinar_pdf_lote_via_api( + itens_api, + certificado_bytes=cert_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location='Câmara Municipal', + ) - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() + for res_api in resultados_api: + pk = res_api['id'] + doc = docs_map[pk] + descricao = meta_map_doc[pk]['descricao'] + assinaturas_existentes = meta_map_doc[pk]['assinaturas_existentes'] - filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - doc.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) - doc.codigo_autenticacao = codigo + if not res_api['ok']: + logger.error(f'[lote-doc] Doc {pk} falhou: {res_api["error"]}') + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': res_api['error']}) + erro_count += 1 + continue - nova_assinatura_record = { + nova_assinatura = { 'tipo_certificado': 'A1', - 'tipo_certificado_display': f'{tipo_cert} - A1', - 'subject': str(cert_info.subject), - 'issuer': str(cert_info.issuer), - 'serial': str(cert_info.serial_number), - 'valid_from': cert_info.not_valid_before.isoformat(), - 'valid_to': cert_info.not_valid_after.isoformat(), + 'tipo_certificado_display': 'Certificado Digital – A1', + 'subject': '', 'issuer': '', 'serial': '', + 'valid_from': '', 'valid_to': '', 'signed_by': request.user.username, 'nome_assinante': nome_assinante, 'cargo': cargo, 'data_assinatura': data_formatada, - 'validade_juridica': 'Assinatura Eletronica Qualificada' + 'validade_juridica': 'Assinatura Eletrônica Qualificada', + 'backend': 'api_externa', } - assinaturas_existentes.append(nova_assinatura_record) + filename = f"docacessorio_{pk}_assinado_{int(timezone.now().timestamp())}.pdf" + doc.pdf_assinado.save(filename, ContentFile(res_api['pdf_bytes']), save=False) + if pk in codigos_map: + doc.codigo_autenticacao = codigos_map[pk] + assinaturas_existentes.append(nova_assinatura) doc.assinatura_info = assinaturas_existentes doc.assinado_em = timezone.now() doc.assinado_por = request.user doc.save() - logger.info(f"[lote-doc] DocAcessorio {pk} assinado por {request.user.username}") + logger.info(f'[lote-doc] DocAcessorio {pk} assinado por {request.user.username} (api_externa/batch)') resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) sucesso_count += 1 - except Exception as e: - logger.error(f"[lote-doc] Erro ao assinar docacessorio {pk}: {e}") - resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) - erro_count += 1 - finally: - if temp_stamped_path and os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) - - return JsonResponse({ - 'success': True, - 'total': len(pks), - 'sucesso': sucesso_count, - 'erros': erro_count, - 'resultados': resultados, - }) - - -# ============================================================================= -# Assinatura em Lote de Documentos Acessorios -# ============================================================================= - -@login_required -@csrf_exempt -@require_http_methods(["POST"]) -def docacessorio_assinar_lote(request): - """ - Assina em lote documentos acessorios pendentes com certificado A1. - - POST multipart: - - certificado: arquivo .pfx / .p12 - - senha: senha do certificado - - ids: JSON array com os PKs dos documentos acessorios - - Retorna JSON com resultado por documento. - """ - _tem_perm_django = request.user.has_perm('materia.change_documentoacessorio') - try: - _autor_lote = OperadorAutor.objects.get(user=request.user).autor - except OperadorAutor.DoesNotExist: - _autor_lote = None - - if not (_tem_perm_django or _autor_lote or request.user.is_superuser): - return JsonResponse( - {'success': False, 'error': 'Sem permissao para assinar documentos acessorios.'}, - status=403 - ) - - ids_raw = request.POST.get('ids', '') - ids_multi = request.POST.getlist('ids[]') - - if ids_multi: - pks = [int(i) for i in ids_multi if str(i).isdigit()] - elif ids_raw: - try: - parsed = json.loads(ids_raw) - pks = [int(i) for i in parsed if str(i).isdigit() or isinstance(i, int)] - except (json.JSONDecodeError, ValueError): - return JsonResponse( - {'success': False, 'error': 'Parametro "ids" invalido. Envie um array JSON.'}, - status=400 - ) else: - return JsonResponse({'success': False, 'error': 'Nenhum documento selecionado.'}, status=400) - - if not pks: - return JsonResponse({'success': False, 'error': 'Lista de IDs vazia.'}, status=400) - - if len(pks) > 200: - return JsonResponse( - {'success': False, 'error': 'Limite maximo de 200 documentos por lote.'}, - status=400 - ) - - certificado_file = request.FILES.get('certificado') - senha = request.POST.get('senha', '') - - if not certificado_file: - return JsonResponse({'success': False, 'error': 'Certificado nao informado.'}, status=400) - if not senha: - return JsonResponse({'success': False, 'error': 'Senha do certificado nao informada.'}, status=400) - - cert_bytes = certificado_file.read() - - import tempfile as tmp_module - with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: - tmp_cert.write(cert_bytes) - tmp_cert_path = tmp_cert.name - - try: - from pyhanko.sign import signers - signer = signers.SimpleSigner.load_pkcs12( - pfx_file=tmp_cert_path, - passphrase=senha.encode('utf-8') - ) - except Exception as cert_error: - logger.error(f"[lote-doc] Erro ao carregar certificado: {cert_error}") - err_msg = str(cert_error) - if 'password' in err_msg.lower() or 'mac' in err_msg.lower(): - detail = 'Senha incorreta ou arquivo invalido.' - elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): - detail = 'Arquivo nao e um certificado valido (.pfx/.p12).' - else: - detail = f'Detalhes: {err_msg}' - return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400) - finally: - if os.path.exists(tmp_cert_path): - os.unlink(tmp_cert_path) - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - data = json.loads(error_response.content) - return JsonResponse({'success': False, 'error': data.get('error', 'Certificado invalido.')}, status=400) - - docs = DocumentoAcessorio.objects.filter(pk__in=pks).select_related('materia', 'tipo', 'materia__tipo') - docs_map = {d.pk: d for d in docs} - - resultados = [] - sucesso_count = 0 - erro_count = 0 - - for pk in pks: - doc = docs_map.get(pk) - if not doc: - resultados.append({'pk': pk, 'success': False, 'descricao': f'ID {pk}', 'error': 'Documento nao encontrado.'}) - erro_count += 1 - continue - - descricao = f'{doc.nome} - {doc.materia.tipo.sigla} {doc.materia.numero}/{doc.materia.ano}' - - if doc.pdf_assinado: - resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Ja possui PDF assinado. Ignorado.'}) - erro_count += 1 - continue - - assinaturas_existentes = _normalizar_assinatura_info(doc.assinatura_info) - if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): - resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Voce ja assinou este documento.'}) - erro_count += 1 - continue - - pdf_bytes, error = _gerar_pdf_do_docacessorio(doc, request) - if error: - resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': error}) - erro_count += 1 - continue - - temp_stamped_path = None - try: - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - from PyPDF4 import PdfFileReader, PdfFileWriter as PyPDF4Writer - - nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info) - data_assinatura = timezone.localtime(timezone.now()) - data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') - data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') - - codigo = _gerar_codigo_autenticacao(pdf_bytes) - url_verificacao = _construir_url_verificacao(request, 'docacessorio', pk, codigo) - - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - - 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()) - - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], codigo, url_verificacao, - page_width, page_height - ) - - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PyPDF4Writer() - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) - - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f: - temp_stamped_path = tmp_f.name - output_pdf.write(tmp_f) - - with open(temp_stamped_path, 'rb') as f: - stamped_bytes = f.read() - - signed_buffer = io.BytesIO() - with io.BytesIO(stamped_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - meta = signers.PdfSignatureMetadata( - field_name='AssinaturaDigital', - location='Camara Municipal', - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - name=nome_assinante + # Backend local: pyhanko — sequencial + for item in itens_para_assinar: + pk = item['pk'] + doc = docs_map[pk] + descricao = meta_map_doc[pk]['descricao'] + assinaturas_existentes = meta_map_doc[pk]['assinaturas_existentes'] + pdf_bytes = item['pdf_bytes'] + + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='docacessorio', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=cert_bytes, + senha=senha, + tipo_cert_input='a1', ) - signers.sign_pdf(w, meta, signer=signer, output=signed_buffer) - - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() - filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - doc.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) - doc.codigo_autenticacao = codigo + filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + doc.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + if codigo: + doc.codigo_autenticacao = codigo - nova_assinatura_record = { - '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 Eletronica Qualificada' - } - assinaturas_existentes.append(nova_assinatura_record) - doc.assinatura_info = assinaturas_existentes - doc.assinado_em = timezone.now() - doc.assinado_por = request.user - doc.save() + assinaturas_existentes.append(nova_assinatura) + doc.assinatura_info = assinaturas_existentes + doc.assinado_em = timezone.now() + doc.assinado_por = request.user + doc.save() - logger.info(f"[lote-doc] DocAcessorio {pk} assinado por {request.user.username}") - resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) - sucesso_count += 1 + logger.info( + f"[lote-doc] DocAcessorio {pk} assinado por {request.user.username} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) + sucesso_count += 1 - except Exception as e: - logger.error(f"[lote-doc] Erro ao assinar docacessorio {pk}: {e}") - resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) - erro_count += 1 - finally: - if temp_stamped_path and os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) + except Exception as e: + logger.error(f"[lote-doc] Erro ao assinar docacessorio {pk}: {e}") + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) + erro_count += 1 return JsonResponse({ 'success': True, @@ -2511,3 +2637,7 @@ def docacessorio_assinar_lote(request): 'erros': erro_count, 'resultados': resultados, }) + + +# (função docacessorio_assinar_lote definida acima) + diff --git a/sapl/settings.py b/sapl/settings.py index 1e5d55dde..3db706fa4 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -318,6 +318,13 @@ ONLYOFFICE_JWT_ENABLED = config('ONLYOFFICE_JWT_ENABLED', cast=bool, default=Fal # consegue acessar o SAPL pela mesma URL que o navegador) SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='') +# Microserviço de Assinatura Digital +# Quando ASSINATURA_API_URL estiver configurado, o SAPL delega a assinatura +# dos PDFs ao microserviço em vez de usar pyhanko localmente. +ASSINATURA_API_URL = config('ASSINATURA_API_URL', default='') +ASSINATURA_API_KEY = config('ASSINATURA_API_KEY', default='') +ASSINATURA_API_TIMEOUT = config('ASSINATURA_API_TIMEOUT', cast=int, default=120) + # Integração externa de matérias da sessão SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='') SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='') diff --git a/sapl/templates/materia/assinatura_modal.html b/sapl/templates/materia/assinatura_modal.html index d7c27d438..259cd2aa6 100644 --- a/sapl/templates/materia/assinatura_modal.html +++ b/sapl/templates/materia/assinatura_modal.html @@ -139,6 +139,8 @@ {% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %} + + @@ -531,12 +533,32 @@ document.addEventListener('DOMContentLoaded', function() { return; } - this.mostrarStatus('processando'); - this.atualizarProgresso(10, 'Enviando certificado...'); - - var formData = new FormData(); - formData.append('certificado', certificado); - formData.append('senha', senha); + this.mostrarStatus('processando'); this.atualizarProgresso(5, 'Enviando certificado…'); + var formData = new FormData(form); + + // Animação de progresso enquanto o servidor processa + var progressoTimer = null; + var progressoAtual = 5; + var fases = [ + { ate: 30, label: 'Validando certificado…', demora: 800 }, + { ate: 55, label: 'Gerando página de autenticação…', demora: 600 }, + { ate: 75, label: 'Assinando documento…', demora: 500 }, + { ate: 88, label: 'Enviando ao microserviço…', demora: 400 }, + { ate: 95, label: 'Aguardando resposta…', demora: 300 }, + ]; + var faseIdx = 0; + function avancarProgresso() { + if (faseIdx >= fases.length) return; + var fase = fases[faseIdx]; + if (progressoAtual < fase.ate) { + progressoAtual = Math.min(progressoAtual + 2, fase.ate); + self.atualizarProgresso(progressoAtual, fase.label); + } else { + faseIdx++; + } + progressoTimer = setTimeout(avancarProgresso, fase.demora); + } + progressoTimer = setTimeout(avancarProgresso, 600); fetch(this.urlPrefix + '/assinar/a1/', { method: 'POST', @@ -546,19 +568,26 @@ document.addEventListener('DOMContentLoaded', function() { } }) .then(function(response) { - self.atualizarProgresso(60, 'Processando assinatura...'); + clearTimeout(progressoTimer); + self.atualizarProgresso(97, 'Processando resposta…'); + if (!response.ok) { + return response.json().then(function(d) { + throw new Error(d.error || ('Erro HTTP ' + response.status)); + }); + } return response.json(); }) .then(function(data) { - self.atualizarProgresso(100, 'Finalizando...'); - + clearTimeout(progressoTimer); + self.atualizarProgresso(100, 'Concluído.'); if (data.success) { self.mostrarSucesso(data); } else { - self.mostrarErro(data.error); + self.mostrarErro(data.error || 'Erro desconhecido ao assinar.'); } }) .catch(function(error) { + clearTimeout(progressoTimer); self.mostrarErro('Erro de comunicação: ' + error.message); }); }, @@ -652,7 +681,6 @@ document.addEventListener('DOMContentLoaded', function() { window.AssinaturaModal = AssinaturaModal; }); - diff --git a/sapl/templates/materia/documentoacessorio_list.html b/sapl/templates/materia/documentoacessorio_list.html index 4ef928923..69ac34a2b 100644 --- a/sapl/templates/materia/documentoacessorio_list.html +++ b/sapl/templates/materia/documentoacessorio_list.html @@ -487,7 +487,28 @@ document.getElementById('doc-lote-btn-assinar').style.display = 'none'; document.getElementById('doc-lote-btn-fechar').disabled = true; - setProgresso(5, 'Enviando certificado e iniciando assinatura…'); + // Animação de progresso proporcional ao número de documentos + var nDocs = idsSel.length; + var fases = [ + { ate: 10, label: 'Enviando certificado e iniciando assinatura…', ms: 700 }, + { ate: 25, label: 'Validando certificado e gerando PDFs…', ms: Math.min(600, 200 + nDocs * 30) }, + { ate: 50, label: 'Preparando ' + nDocs + ' documento(s) para assinatura…', ms: Math.min(800, 200 + nDocs * 50) }, + { ate: 70, label: 'Enviando ao microserviço de assinatura…', ms: Math.min(1000, 300 + nDocs * 60) }, + { ate: 85, label: 'Aguardando resposta do servidor…', ms: Math.min(800, 300 + nDocs * 40) }, + { ate: 93, label: 'Salvando documentos assinados…', ms: 500 }, + { ate: 97, label: 'Finalizando…', ms: 300 }, + ]; + var faseIdx = 0, progrAtual = 0, progrTimer = null; + function avancarProgresso() { + if (faseIdx >= fases.length) return; + var fase = fases[faseIdx]; + if (progrAtual < fase.ate) { + progrAtual = Math.min(progrAtual + 1, fase.ate); + setProgresso(progrAtual, fase.label); + } else { faseIdx++; } + progrTimer = setTimeout(avancarProgresso, fases[Math.min(faseIdx, fases.length-1)].ms / (fase.ate - (faseIdx > 0 ? fases[faseIdx-1].ate : 0))); + } + progrTimer = setTimeout(avancarProgresso, 400); fetch(URL_LOTE, { method: 'POST', @@ -495,16 +516,19 @@ headers: { 'X-CSRFToken': getCSRF() } }) .then(function(r){ - setProgresso(90, 'Processando resposta…'); + clearTimeout(progrTimer); + setProgresso(98, 'Processando resposta…'); if (!r.ok) return r.json().then(function(d){ throw new Error(d.error || ('HTTP ' + r.status)); }); return r.json(); }) .then(function(data){ + clearTimeout(progrTimer); setProgresso(100, 'Concluído.'); document.getElementById('doc-lote-btn-fechar').disabled = false; mostrarResumo(data); }) .catch(function(err){ + clearTimeout(progrTimer); document.getElementById('doc-lote-btn-fechar').disabled = false; setProgresso(100, 'Erro.'); mostrarErroFatal(err.message || String(err)); diff --git a/sapl/templates/materia/materialegislativa_filter.html b/sapl/templates/materia/materialegislativa_filter.html index 73f7cf8b0..0c1a3ffb9 100644 --- a/sapl/templates/materia/materialegislativa_filter.html +++ b/sapl/templates/materia/materialegislativa_filter.html @@ -795,7 +795,28 @@ document.getElementById('lote-btn-assinar').style.display = 'none'; document.getElementById('lote-btn-fechar').disabled = true; - setProgresso(5, 'Enviando certificado e iniciando assinatura…'); + // Calcula fases de progresso em função do número de matérias selecionadas + var nDocs = idsSel.length; + var fases = [ + { ate: 10, label: 'Enviando certificado e iniciando assinatura…', ms: 700 }, + { ate: 25, label: 'Validando certificado e gerando PDFs…', ms: Math.min(600, 200 + nDocs * 30) }, + { ate: 50, label: 'Preparando ' + nDocs + ' documento(s) para assinatura…', ms: Math.min(800, 200 + nDocs * 50) }, + { ate: 70, label: 'Enviando ao microserviço de assinatura…', ms: Math.min(1000, 300 + nDocs * 60) }, + { ate: 85, label: 'Aguardando resposta do servidor…', ms: Math.min(800, 300 + nDocs * 40) }, + { ate: 93, label: 'Salvando documentos assinados…', ms: 500 }, + { ate: 97, label: 'Finalizando…', ms: 300 }, + ]; + var faseIdx = 0, progrAtual = 0, progrTimer = null; + function avancarProgresso() { + if (faseIdx >= fases.length) return; + var fase = fases[faseIdx]; + if (progrAtual < fase.ate) { + progrAtual = Math.min(progrAtual + 1, fase.ate); + setProgresso(progrAtual, fase.label); + } else { faseIdx++; } + progrTimer = setTimeout(avancarProgresso, fases[Math.min(faseIdx, fases.length-1)].ms / (fase.ate - (faseIdx > 0 ? fases[faseIdx-1].ate : 0))); + } + progrTimer = setTimeout(avancarProgresso, 400); fetch(URL_LOTE, { method: 'POST', @@ -803,18 +824,21 @@ headers: { 'X-CSRFToken': getCSRF() } }) .then(function (r) { - setProgresso(90, 'Processando resposta…'); + clearTimeout(progrTimer); + setProgresso(98, 'Processando resposta…'); if (!r.ok) { return r.json().then(function(d){ throw new Error(d.error || ('HTTP ' + r.status)); }); } return r.json(); }) .then(function (data) { + clearTimeout(progrTimer); setProgresso(100, 'Concluído.'); document.getElementById('lote-btn-fechar').disabled = false; mostrarResumo(data); }) .catch(function (err) { + clearTimeout(progrTimer); document.getElementById('lote-btn-fechar').disabled = false; setProgresso(100, 'Erro.'); mostrarErroFatal(err.message || String(err));