mirror of https://github.com/interlegis/sapl.git
Browse Source
Passo 1 de 2 da conciliacao das linhas do SAPL. A dev vinha da integracao (hub, inventario, anexos, polls) e a 3.1.2 vinha do produto (assinatura, GED, proposicao, Franco da Rocha). Nenhuma das duas continha a outra: a dev estava 33 commits a frente e 20 atras. Traz os 20 commits que faltavam. Como a sp_importante_virou_urgente esta inteiramente contida na 3.1.2 (zero commits exclusivos), ela vem junto e nao precisa de merge proprio. As branches feature/add-agents-tracking e feat/integracao-hub-app foram conferidas arquivo a arquivo e estao superadas: o que adicionam (OnlyOffice, templates de documento, AGENTS.md, autor_nome no poll) ja esta na dev por outro caminho. O unico conflito real foi o docs/LOCALHOST_SETUP.md — dois roteiros escritos em paralelo para a mesma tarefa. Resolvido a favor do roteiro da dev (mais novo, validado em 13/08, com a arvore de decisao sobre migration), com tres ajustes: - a nota sobre DEBUG foi corrigida. Ela dizia "DJANGO_DEBUG, nao DEBUG"; depois deste merge o settings.py aceita as duas (linha 38, vindo da 3.1.2). Manter a nota antiga seria documentar o codigo errado a partir de agora. - "Problemas comuns" da 3.1.2 foi incorporado (alcance do banco remoto, collectstatic com Gunicorn). - as instrucoes que dependiam de docker/docker-compose-local.yml ficaram de fora, com o motivo registrado no proprio documento: esse arquivo nao existe em nenhuma das duas linhas. Verificado no resultado do merge, e nao por suposicao: - as quatro funcionalidades que so existiam na 3.1.2 chegaram inteiras — telas de "Assinar Despachos em Lote" (docacessorio_assinar_lote, materia_assinar_lote), preview PNG da pagina de assinatura (as tres funcoes), remocao de assinatura (_pode_remover_assinatura) e o helper _carregar_certificado; - nenhum marcador de conflito sobrou em .py, .md ou .html; - sapl/materia e sapl/base compilam; - todo nome importado de views_assinatura em urls.py e views.py existe — que era o modo de falha mais perigoso aqui (urls.py importa no topo do modulo, entao um nome faltando derruba o boot); - a integracao da dev (sapl/integracao_hub) segue no lugar. O passo 2 e a unificacao da assinatura: aplicar a delegacao da C3 por cima mantendo as telas de lote com composicao local, decisao registrada em docs-ia-projects#63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>pull/3858/head
44 changed files with 5593 additions and 588 deletions
@ -0,0 +1,18 @@ |
|||
# Generated by Django 2.2.28 on 2026-05-14 11:00 |
|||
|
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('base', '0064_appconfig_revisao_setor_legislativo'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddField( |
|||
model_name='appconfig', |
|||
name='permite_remover_assinatura', |
|||
field=models.BooleanField(choices=[(True, 'Sim'), (False, 'Não')], default=False, help_text='Quando habilitado, superusuários e usuários com a permissão "Pode remover assinatura digital" poderão remover assinaturas de matérias e documentos acessórios para permitir edições.', verbose_name='Permitir remoção de assinatura digital?'), |
|||
), |
|||
] |
|||
@ -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}' |
|||
@ -0,0 +1,199 @@ |
|||
""" |
|||
Management command: notificar_pendentes_assinatura |
|||
|
|||
Envia e-mail diario a cada autor que possui materias com assinatura digital |
|||
pendente (texto_original preenchido, pdf_assinado vazio). |
|||
|
|||
Uso: |
|||
python manage.py notificar_pendentes_assinatura |
|||
|
|||
Agendar (crontab) -- exemplo para rodar todo dia as 8h: |
|||
0 8 * * * cd /app && python manage.py notificar_pendentes_assinatura >> /var/log/sapl_notif_assinatura.log 2>&1 |
|||
|
|||
Opcoes: |
|||
--dry-run Lista os destinatarios e contagens sem enviar e-mails. |
|||
--max-materias Numero maximo de materias listadas por e-mail (padrao: 20). |
|||
""" |
|||
import logging |
|||
|
|||
from django.core.management.base import BaseCommand |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
DEFAULT_MAX_MATERIAS = 20 |
|||
|
|||
|
|||
class Command(BaseCommand): |
|||
help = 'Envia e-mail diario aos autores com materias pendentes de assinatura digital' |
|||
|
|||
def add_arguments(self, parser): |
|||
parser.add_argument( |
|||
'--dry-run', |
|||
action='store_true', |
|||
default=False, |
|||
help='Apenas lista os destinatarios sem enviar e-mails.', |
|||
) |
|||
parser.add_argument( |
|||
'--max-materias', |
|||
type=int, |
|||
default=DEFAULT_MAX_MATERIAS, |
|||
help='Maximo de materias listadas por e-mail (padrao: 20).', |
|||
) |
|||
|
|||
def handle(self, *args, **options): |
|||
# Imports aqui dentro para evitar problemas no bootstrap do Django |
|||
from django.core.mail import EmailMultiAlternatives, get_connection |
|||
from django.db.models import Q |
|||
from django.template import loader |
|||
from django.urls import reverse |
|||
|
|||
from sapl.base.models import CasaLegislativa, OperadorAutor |
|||
from sapl.materia.models import MateriaLegislativa |
|||
from sapl.settings import EMAIL_SEND_USER |
|||
from sapl.utils import mail_service_configured |
|||
|
|||
dry_run = options['dry_run'] |
|||
max_mat = options['max_materias'] |
|||
|
|||
if not dry_run and not mail_service_configured(): |
|||
self.stderr.write(self.style.ERROR( |
|||
'Servico de e-mail nao configurado. ' |
|||
'Verifique EMAIL_HOST no arquivo .env.' |
|||
)) |
|||
return |
|||
|
|||
casa = CasaLegislativa.objects.first() |
|||
if not casa: |
|||
self.stderr.write(self.style.ERROR('Casa Legislativa nao configurada.')) |
|||
return |
|||
|
|||
casa_nome = '{} de {} - {}'.format(casa.nome, casa.municipio, casa.uf) |
|||
base_url = 'https://{}'.format(casa.endereco_web) if getattr(casa, 'endereco_web', None) else '' |
|||
|
|||
# Base queryset: materias pendentes de assinatura |
|||
qs_pendentes = MateriaLegislativa.objects.filter( |
|||
texto_original__isnull=False, |
|||
).exclude( |
|||
texto_original='' |
|||
).filter( |
|||
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='') |
|||
).select_related('tipo').order_by('-data_apresentacao', '-id') |
|||
|
|||
# Apenas OperadorAutores com e-mail cadastrado |
|||
operadores = ( |
|||
OperadorAutor.objects |
|||
.select_related('autor', 'user') |
|||
.filter(user__email__gt='') |
|||
) |
|||
|
|||
if not operadores.exists(): |
|||
self.stdout.write(self.style.WARNING( |
|||
'Nenhum OperadorAutor com e-mail encontrado. Nada a enviar.' |
|||
)) |
|||
return |
|||
|
|||
url_pesquisa_base = reverse('sapl.materia:pesquisar_materia') |
|||
|
|||
enviados = 0 |
|||
sem_pendencias = 0 |
|||
|
|||
connection = None if dry_run else get_connection() |
|||
if connection: |
|||
connection.open() |
|||
|
|||
try: |
|||
for op in operadores: |
|||
autor = op.autor |
|||
user = op.user |
|||
email = (user.email or '').strip() |
|||
|
|||
if not email: |
|||
continue |
|||
|
|||
# Materias pendentes deste autor |
|||
materias_qs = qs_pendentes.filter(autoria__autor=autor).distinct() |
|||
total = materias_qs.count() |
|||
|
|||
if total == 0: |
|||
sem_pendencias += 1 |
|||
continue |
|||
|
|||
materias_listadas = list(materias_qs[:max_mat]) |
|||
total_omitidas = max(0, total - max_mat) |
|||
|
|||
url_pesquisa = ( |
|||
'{}?autoria__autor={}&status_assinatura=pendente'.format( |
|||
url_pesquisa_base, autor.pk |
|||
) |
|||
) |
|||
|
|||
context = { |
|||
'casa_legislativa': casa_nome, |
|||
'nome_autor': autor.nome, |
|||
'total': total, |
|||
'materias': materias_listadas, |
|||
'total_omitidas': total_omitidas, |
|||
'base_url': base_url, |
|||
'url_pesquisa': url_pesquisa, |
|||
} |
|||
|
|||
subject = '[SGVP] {} materia(s) aguardando sua assinatura digital'.format(total) |
|||
|
|||
if dry_run: |
|||
self.stdout.write(self.style.SUCCESS( |
|||
'[DRY-RUN] -> {} ({}): {} pendente(s)'.format(email, autor.nome, total) |
|||
)) |
|||
for m in materias_listadas: |
|||
self.stdout.write( |
|||
' * {} {}/{} -- {}'.format( |
|||
m.tipo.sigla, m.numero, m.ano, m.ementa[:60] |
|||
) |
|||
) |
|||
if total_omitidas: |
|||
self.stdout.write(' ... e mais {} outra(s).'.format(total_omitidas)) |
|||
continue |
|||
|
|||
# Renderiza templates |
|||
txt_body = loader.get_template('email/pendentes_assinatura.txt').render(context) |
|||
html_body = loader.get_template('email/pendentes_assinatura.html').render(context) |
|||
|
|||
try: |
|||
msg = EmailMultiAlternatives( |
|||
subject=subject, |
|||
body=txt_body, |
|||
from_email=EMAIL_SEND_USER, |
|||
to=[email], |
|||
connection=connection, |
|||
) |
|||
msg.attach_alternative(html_body, 'text/html') |
|||
msg.send() |
|||
enviados += 1 |
|||
logger.info( |
|||
'[notificar_pendentes_assinatura] E-mail enviado para ' |
|||
'{} ({}) -- {} pendente(s).'.format(email, autor.nome, total) |
|||
) |
|||
self.stdout.write(self.style.SUCCESS( |
|||
'E-mail enviado para {} ({}) -- {} pendente(s).'.format( |
|||
email, autor.nome, total |
|||
) |
|||
)) |
|||
except Exception as exc: |
|||
logger.error( |
|||
'[notificar_pendentes_assinatura] Falha ao enviar para ' |
|||
'{}: {}'.format(email, exc) |
|||
) |
|||
self.stderr.write(self.style.ERROR( |
|||
'Falha ao enviar para {}: {}'.format(email, exc) |
|||
)) |
|||
|
|||
finally: |
|||
if connection: |
|||
connection.close() |
|||
|
|||
if not dry_run: |
|||
self.stdout.write(self.style.SUCCESS( |
|||
'\nConcluido: {} e-mail(s) enviado(s). ' |
|||
'{} autor(es) sem pendencias (nao notificados).'.format( |
|||
enviados, sem_pendencias |
|||
) |
|||
)) |
|||
@ -0,0 +1,30 @@ |
|||
# Generated by Django 2.2.28 on 2026-05-12 12:00 |
|||
|
|||
from django.db import migrations, models |
|||
import django.db.models.deletion |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('base', '0064_appconfig_revisao_setor_legislativo'), |
|||
('materia', '0094_add_anexoproposicao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.CreateModel( |
|||
name='AutoriaProposicao', |
|||
fields=[ |
|||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
|||
('primeiro_autor', models.BooleanField(choices=[(True, 'Sim'), (False, 'Não')], default=False, verbose_name='Primeiro Autor')), |
|||
('autor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Co-autor')), |
|||
('proposicao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coautores', to='materia.Proposicao', verbose_name='Proposição')), |
|||
], |
|||
options={ |
|||
'verbose_name': 'Co-autoria da Proposição', |
|||
'verbose_name_plural': 'Co-autorias da Proposição', |
|||
'ordering': ('-primeiro_autor', 'autor__nome'), |
|||
'unique_together': {('proposicao', 'autor')}, |
|||
}, |
|||
), |
|||
] |
|||
@ -0,0 +1,21 @@ |
|||
# Generated by Django 2.2.28 on 2026-05-14 11:00 |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('materia', '0095_add_autoria_proposicao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AlterModelOptions( |
|||
name='documentoacessorio', |
|||
options={'ordering': ('data', 'id'), 'permissions': (('can_remove_assinatura_doc', 'Pode remover assinatura digital de documento acessório'),), 'verbose_name': 'Documento Acessório', 'verbose_name_plural': 'Documentos Acessórios'}, |
|||
), |
|||
migrations.AlterModelOptions( |
|||
name='materialegislativa', |
|||
options={'ordering': ['-ano', 'tipo', 'numero'], 'permissions': (('can_access_impressos', 'Can access impressos'), ('can_remove_assinatura', 'Pode remover assinatura digital')), 'verbose_name': 'Matéria Legislativa', 'verbose_name_plural': 'Matérias Legislativas'}, |
|||
), |
|||
] |
|||
@ -0,0 +1,19 @@ |
|||
from django.db import migrations, models |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('materia', '0096_add_permite_remover_assinatura_e_permissoes'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.AddField( |
|||
model_name='tipoproposicao', |
|||
name='dispensa_protocolo', |
|||
field=models.BooleanField( |
|||
default=False, |
|||
help_text='Quando marcado, proposições deste tipo não são enviadas ao Protocolo e não se tornam Matéria Legislativa. Ficam restritas ao gabinete do autor. Use para documentos de uso próprio do gabinete, como Ofícios, que não precisam de validação da Casa.', |
|||
verbose_name='Documento de gabinete'), |
|||
), |
|||
] |
|||
@ -0,0 +1,46 @@ |
|||
import unicodedata |
|||
|
|||
from django.db import migrations |
|||
|
|||
|
|||
# Tipos de proposição que já nascem como documento de gabinete, sem |
|||
# necessidade de configuração manual em Tabelas Auxiliares. |
|||
DESCRICOES_GABINETE = {'oficio'} |
|||
|
|||
|
|||
def _normalizar(descricao): |
|||
"""Remove acentos, espaços das pontas e caixa, para comparar descrições. |
|||
|
|||
A descrição é digitada pelo usuário em cada Casa, então aparece como |
|||
'Ofício', 'OFICIO', 'oficio' etc. |
|||
""" |
|||
texto = unicodedata.normalize('NFKD', descricao or '') |
|||
texto = ''.join(c for c in texto if not unicodedata.combining(c)) |
|||
return texto.strip().lower() |
|||
|
|||
|
|||
def _aplicar(apps, valor): |
|||
TipoProposicao = apps.get_model('materia', 'TipoProposicao') |
|||
for tipo in TipoProposicao.objects.all(): |
|||
if _normalizar(tipo.descricao) in DESCRICOES_GABINETE: |
|||
tipo.dispensa_protocolo = valor |
|||
tipo.save(update_fields=['dispensa_protocolo']) |
|||
|
|||
|
|||
def marcar(apps, schema_editor): |
|||
_aplicar(apps, True) |
|||
|
|||
|
|||
def desmarcar(apps, schema_editor): |
|||
_aplicar(apps, False) |
|||
|
|||
|
|||
class Migration(migrations.Migration): |
|||
|
|||
dependencies = [ |
|||
('materia', '0097_add_dispensa_protocolo_tipoproposicao'), |
|||
] |
|||
|
|||
operations = [ |
|||
migrations.RunPython(marcar, desmarcar), |
|||
] |
|||
File diff suppressed because it is too large
@ -0,0 +1,22 @@ |
|||
""" |
|||
View pública para acesso ao acervo histórico via GED (sistema legado). |
|||
Issue #1326 / Task #1348 – Adicionar tela do GED dentro do Legislativo. |
|||
""" |
|||
from decouple import config |
|||
from django.shortcuts import render |
|||
from django.views.decorators.cache import cache_control |
|||
|
|||
# Sem default: se GED_URL não estiver no .env, retorna string vazia. |
|||
GED_URL = config('GED_URL', default='') |
|||
|
|||
|
|||
@cache_control(public=True, max_age=300) |
|||
def ged_historico(request): |
|||
""" |
|||
Exibe o sistema GED (acervo histórico – matérias anteriores a 2026) |
|||
incorporado em iframe. Acesso público, sem autenticação. |
|||
Se GED_URL não estiver configurado, exibe mensagem informativa. |
|||
""" |
|||
return render(request, 'materia/ged_historico.html', { |
|||
'ged_url': GED_URL or '', |
|||
}) |
|||
@ -0,0 +1,293 @@ |
|||
/** |
|||
* materia_impressao_em_massa.js |
|||
* |
|||
* Impressão / download em massa de matérias legislativas. |
|||
* |
|||
* Funcionalidades: |
|||
* - Ativa modo de seleção ao clicar em "Imprimir Selecionados" (barra de ações) |
|||
* - Exibe checkbox em cada linha de resultado |
|||
* - Toolbar flutuante mostra contagem e ações (Imprimir / Baixar PDF / Limpar) |
|||
* - Botão "Todos" seleciona/desseleciona todos da página atual |
|||
* - Ao clicar Imprimir: chama /materia/pdf-multiplos/?ids=... → abre PDF no |
|||
* browser para impressão direta via window.print() em novo tab |
|||
* - Ao clicar Baixar PDF: mesmo endpoint mas força download via blob |
|||
*/ |
|||
|
|||
(function () { |
|||
'use strict'; |
|||
|
|||
var URL_PDF_MULTIPLOS = '/materia/pdf-multiplos/'; |
|||
var MAX_SELECAO = 200; |
|||
|
|||
var modoAtivo = false; |
|||
|
|||
// ── Elementos ────────────────────────────────────────────────────────────
|
|||
var btnImprimirSelecionados = null; // botão na barra de ações (topo)
|
|||
var btnSelecionarTodos = null; |
|||
var toolbar = null; |
|||
var toolbarCount = null; |
|||
var toolbarBtnImprimir = null; |
|||
var toolbarBtnDownload = null; |
|||
var toolbarBtnLimpar = null; |
|||
var toolbarLoading = null; |
|||
|
|||
// ── Inicialização ─────────────────────────────────────────────────────────
|
|||
function init() { |
|||
btnImprimirSelecionados = document.getElementById('btn-imprimir-selecionados'); |
|||
btnSelecionarTodos = document.getElementById('btn-selecionar-todos-print'); |
|||
toolbar = document.getElementById('print-toolbar'); |
|||
toolbarCount = document.getElementById('print-toolbar-count'); |
|||
toolbarBtnImprimir = document.getElementById('print-toolbar-btn-imprimir'); |
|||
toolbarBtnDownload = document.getElementById('print-toolbar-btn-download'); |
|||
toolbarBtnLimpar = document.getElementById('print-toolbar-btn-limpar'); |
|||
toolbarLoading = document.getElementById('print-toolbar-loading'); |
|||
|
|||
if (!btnImprimirSelecionados || !toolbar) return; // não está na página de resultados
|
|||
|
|||
// Botões já visíveis no novo layout — garante estado inicial correto
|
|||
btnImprimirSelecionados.style.display = ''; |
|||
if (btnSelecionarTodos) btnSelecionarTodos.style.display = 'none'; // aparece só quando modo ativo
|
|||
|
|||
btnImprimirSelecionados.addEventListener('click', function () { |
|||
if (!modoAtivo) { |
|||
ativarModo(); |
|||
} else { |
|||
var ids = getIdsSelecionados(); |
|||
if (ids.length === 0) { |
|||
mostrarAlerta('Selecione ao menos um documento para imprimir.'); |
|||
return; |
|||
} |
|||
abrirPDF(ids, true); |
|||
} |
|||
}); |
|||
|
|||
btnSelecionarTodos.addEventListener('click', function () { |
|||
if (!modoAtivo) { ativarModo(); } |
|||
var checks = document.querySelectorAll('.print-chk'); |
|||
var todasMarcadas = Array.from(checks).every(function (c) { return c.checked; }); |
|||
checks.forEach(function (c) { c.checked = !todasMarcadas; }); |
|||
atualizarContagem(); |
|||
}); |
|||
|
|||
toolbarBtnImprimir.addEventListener('click', function () { |
|||
var ids = getIdsSelecionados(); |
|||
if (ids.length === 0) { mostrarAlerta('Selecione ao menos um documento.'); return; } |
|||
abrirPDF(ids, true); |
|||
}); |
|||
|
|||
toolbarBtnDownload.addEventListener('click', function () { |
|||
var ids = getIdsSelecionados(); |
|||
if (ids.length === 0) { mostrarAlerta('Selecione ao menos um documento.'); return; } |
|||
baixarPDF(ids); |
|||
}); |
|||
|
|||
toolbarBtnLimpar.addEventListener('click', function () { |
|||
desativarModo(); |
|||
}); |
|||
|
|||
// Delegação de eventos nos checkboxes (gerados dinamicamente)
|
|||
document.addEventListener('change', function (e) { |
|||
if (e.target && e.target.classList.contains('print-chk')) { |
|||
atualizarContagem(); |
|||
} |
|||
}); |
|||
|
|||
// Clique na linha inteira (quando modo ativo) seleciona o checkbox
|
|||
document.addEventListener('click', function (e) { |
|||
if (!modoAtivo) return; |
|||
var row = e.target.closest('.materia-row'); |
|||
if (!row) return; |
|||
// Evita toggle duplo se clicou direto no checkbox ou num link
|
|||
if (e.target.classList.contains('print-chk')) return; |
|||
if (e.target.closest('a')) return; |
|||
var chk = row.querySelector('.print-chk'); |
|||
if (chk) { |
|||
chk.checked = !chk.checked; |
|||
atualizarContagem(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// ── Modo de seleção ───────────────────────────────────────────────────────
|
|||
function ativarModo() { |
|||
modoAtivo = true; |
|||
// Mostra checkboxes em todas as linhas
|
|||
document.querySelectorAll('.print-select-col').forEach(function (el) { |
|||
el.style.display = 'inline-block'; |
|||
}); |
|||
// Estilo visual nas linhas
|
|||
document.querySelectorAll('.materia-row').forEach(function (row) { |
|||
row.style.cursor = 'pointer'; |
|||
}); |
|||
// Atualiza botão de ações — destaca em vermelho sólido
|
|||
btnImprimirSelecionados.classList.replace('btn-outline-danger', 'btn-danger'); |
|||
// Mostra badge e botão Todos
|
|||
var badge = document.getElementById('badge-print-total'); |
|||
if (badge) badge.style.display = ''; |
|||
if (btnSelecionarTodos) btnSelecionarTodos.style.display = ''; |
|||
|
|||
toolbar.style.display = 'block'; |
|||
atualizarContagem(); |
|||
} |
|||
|
|||
function desativarModo() { |
|||
modoAtivo = false; |
|||
// Desmarca todos e oculta checkboxes
|
|||
document.querySelectorAll('.print-chk').forEach(function (c) { c.checked = false; }); |
|||
document.querySelectorAll('.print-select-col').forEach(function (el) { |
|||
el.style.display = 'none'; |
|||
}); |
|||
document.querySelectorAll('.materia-row').forEach(function (row) { |
|||
row.style.cursor = ''; |
|||
row.classList.remove('table-active'); |
|||
}); |
|||
// Restaura botão para outline
|
|||
btnImprimirSelecionados.classList.replace('btn-danger', 'btn-outline-danger'); |
|||
// Oculta badge e botão Todos
|
|||
var badge = document.getElementById('badge-print-total'); |
|||
if (badge) { badge.style.display = 'none'; badge.textContent = '0'; } |
|||
if (btnSelecionarTodos) btnSelecionarTodos.style.display = 'none'; |
|||
|
|||
toolbar.style.display = 'none'; |
|||
ocultarAlertaToolbar(); |
|||
} |
|||
|
|||
// ── Contagem ──────────────────────────────────────────────────────────────
|
|||
function getIdsSelecionados() { |
|||
return Array.from(document.querySelectorAll('.print-chk:checked')) |
|||
.map(function (c) { return parseInt(c.getAttribute('data-materia-id'), 10); }) |
|||
.slice(0, MAX_SELECAO); |
|||
} |
|||
|
|||
function atualizarContagem() { |
|||
var ids = getIdsSelecionados(); |
|||
var n = ids.length; |
|||
|
|||
// Badge na toolbar flutuante
|
|||
if (toolbarCount) toolbarCount.textContent = n; |
|||
|
|||
// Badge no botão de ações
|
|||
var badge = document.getElementById('badge-print-total'); |
|||
if (badge) badge.textContent = n; |
|||
|
|||
// Destaque visual nas linhas selecionadas
|
|||
document.querySelectorAll('.materia-row').forEach(function (row) { |
|||
var chk = row.querySelector('.print-chk'); |
|||
if (chk && chk.checked) { |
|||
row.classList.add('table-active'); |
|||
} else { |
|||
row.classList.remove('table-active'); |
|||
} |
|||
}); |
|||
|
|||
// Aviso de limite
|
|||
if (n >= MAX_SELECAO) { |
|||
mostrarAlertaToolbar('Limite de ' + MAX_SELECAO + ' documentos atingido. Desmarque alguns para selecionar outros.'); |
|||
} else { |
|||
ocultarAlertaToolbar(); |
|||
} |
|||
} |
|||
|
|||
// ── PDF: imprimir ─────────────────────────────────────────────────────────
|
|||
function abrirPDF(ids, imprimir) { |
|||
setLoading(true); |
|||
var url = URL_PDF_MULTIPLOS + '?ids=' + ids.join(','); |
|||
|
|||
// Abre em nova aba; quando carregado o browser oferece impressão
|
|||
var win = window.open(url, '_blank'); |
|||
if (!win) { |
|||
// Pop-up bloqueado — fallback: link direto
|
|||
var a = document.createElement('a'); |
|||
a.href = url; |
|||
a.target = '_blank'; |
|||
a.rel = 'noopener'; |
|||
document.body.appendChild(a); |
|||
a.click(); |
|||
document.body.removeChild(a); |
|||
} |
|||
// Aguarda pequeno delay e remove loading (não temos evento de "carregou" na outra aba)
|
|||
setTimeout(function () { setLoading(false); }, 2000); |
|||
} |
|||
|
|||
// ── PDF: baixar como arquivo ──────────────────────────────────────────────
|
|||
function baixarPDF(ids) { |
|||
setLoading(true); |
|||
var url = URL_PDF_MULTIPLOS + '?ids=' + ids.join(','); |
|||
|
|||
fetch(url, { credentials: 'same-origin' }) |
|||
.then(function (resp) { |
|||
if (!resp.ok) { |
|||
return resp.json().then(function (d) { |
|||
throw new Error(d.error || ('Erro HTTP ' + resp.status)); |
|||
}); |
|||
} |
|||
return resp.blob(); |
|||
}) |
|||
.then(function (blob) { |
|||
var blobUrl = window.URL.createObjectURL(blob); |
|||
var a = document.createElement('a'); |
|||
a.href = blobUrl; |
|||
a.download = 'materias_selecionadas.pdf'; |
|||
a.style.display = 'none'; |
|||
document.body.appendChild(a); |
|||
a.click(); |
|||
document.body.removeChild(a); |
|||
window.URL.revokeObjectURL(blobUrl); |
|||
setLoading(false); |
|||
}) |
|||
.catch(function (err) { |
|||
setLoading(false); |
|||
mostrarAlerta('Erro ao gerar PDF: ' + err.message); |
|||
}); |
|||
} |
|||
|
|||
// ── Helpers UI ────────────────────────────────────────────────────────────
|
|||
function setLoading(on) { |
|||
if (!toolbarLoading) return; |
|||
toolbarLoading.style.display = on ? 'block' : 'none'; |
|||
if (toolbarBtnImprimir) toolbarBtnImprimir.disabled = on; |
|||
if (toolbarBtnDownload) toolbarBtnDownload.disabled = on; |
|||
} |
|||
|
|||
function mostrarAlerta(msg) { |
|||
// Toast simples usando Bootstrap alert
|
|||
var div = document.createElement('div'); |
|||
div.className = 'alert alert-warning alert-dismissible fade show'; |
|||
div.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;min-width:300px;max-width:500px;'; |
|||
div.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + escHtml(msg) + |
|||
'<button type="button" class="close" data-dismiss="alert"><span>×</span></button>'; |
|||
document.body.appendChild(div); |
|||
setTimeout(function () { |
|||
if (div.parentNode) div.parentNode.removeChild(div); |
|||
}, 5000); |
|||
} |
|||
|
|||
function mostrarAlertaToolbar(msg) { |
|||
var existing = document.getElementById('print-toolbar-limit-alert'); |
|||
if (existing) return; |
|||
var div = document.createElement('div'); |
|||
div.id = 'print-toolbar-limit-alert'; |
|||
div.className = 'alert alert-warning py-1 px-2 mt-2 mb-0 small'; |
|||
div.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + escHtml(msg); |
|||
if (toolbar) toolbar.appendChild(div); |
|||
} |
|||
|
|||
function ocultarAlertaToolbar() { |
|||
var el = document.getElementById('print-toolbar-limit-alert'); |
|||
if (el && el.parentNode) el.parentNode.removeChild(el); |
|||
} |
|||
|
|||
function escHtml(s) { |
|||
return String(s) |
|||
.replace(/&/g, '&').replace(/</g, '<') |
|||
.replace(/>/g, '>').replace(/"/g, '"'); |
|||
} |
|||
|
|||
// ── Arranque ──────────────────────────────────────────────────────────────
|
|||
if (document.readyState === 'loading') { |
|||
document.addEventListener('DOMContentLoaded', init); |
|||
} else { |
|||
init(); |
|||
} |
|||
|
|||
})(); |
|||
@ -0,0 +1,77 @@ |
|||
{% load i18n %} |
|||
<!DOCTYPE html> |
|||
<html> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<style> |
|||
body { font-family: Arial, sans-serif; color: #333; background: #f5f5f5; margin: 0; padding: 0; } |
|||
.container { max-width: 600px; margin: 20px auto; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); } |
|||
.header { background: #343a40; padding: 24px 32px; text-align: center; } |
|||
.header h1 { color: #fff; margin: 0; font-size: 20px; } |
|||
.header p { color: #adb5bd; margin: 6px 0 0; font-size: 13px; } |
|||
.alert-bar { background: #ffc107; padding: 12px 32px; text-align: center; font-weight: bold; color: #212529; } |
|||
.body { padding: 28px 32px; } |
|||
.body p { line-height: 1.6; } |
|||
table { width: 100%; border-collapse: collapse; margin-top: 16px; } |
|||
th { background: #f8f9fa; text-align: left; padding: 8px 12px; font-size: 13px; color: #666; border-bottom: 2px solid #dee2e6; } |
|||
td { padding: 8px 12px; font-size: 14px; border-bottom: 1px solid #f0f0f0; } |
|||
tr:last-child td { border-bottom: none; } |
|||
.btn { display: inline-block; margin-top: 20px; padding: 12px 28px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 4px; font-weight: bold; } |
|||
.footer { background: #f8f9fa; padding: 16px 32px; text-align: center; font-size: 12px; color: #aaa; } |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div class="container"> |
|||
<div class="header"> |
|||
<h1>{{ casa_legislativa }}</h1> |
|||
<p>Sistema de Gestão e Votação Parlamentar</p> |
|||
</div> |
|||
|
|||
<div class="alert-bar"> |
|||
🔔 {{ total }} matéria(s) aguardando sua assinatura digital |
|||
</div> |
|||
|
|||
<div class="body"> |
|||
<p>Olá, <strong>{{ nome_autor }}</strong>.</p> |
|||
<p> |
|||
As seguintes matérias legislativas estão com <strong>assinatura digital pendente</strong> |
|||
e precisam da sua atenção: |
|||
</p> |
|||
|
|||
<table> |
|||
<thead> |
|||
<tr> |
|||
<th>#</th> |
|||
<th>Matéria</th> |
|||
<th>Ementa</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{% for m in materias %} |
|||
<tr> |
|||
<td>{{ forloop.counter }}</td> |
|||
<td><strong>{{ m.tipo.sigla }} {{ m.numero }}/{{ m.ano }}</strong></td> |
|||
<td style="color:#555;">{{ m.ementa|truncatechars:80 }}</td> |
|||
</tr> |
|||
{% endfor %} |
|||
</tbody> |
|||
</table> |
|||
|
|||
{% if total_omitidas %} |
|||
<p style="color:#888; font-size:13px;"> |
|||
… e mais {{ total_omitidas }} outra(s) matéria(s) não listada(s) acima. |
|||
</p> |
|||
{% endif %} |
|||
|
|||
<a class="btn" href="{{ base_url }}{{ url_pesquisa }}"> |
|||
Acessar matérias pendentes → |
|||
</a> |
|||
</div> |
|||
|
|||
<div class="footer"> |
|||
<p>Esta é uma mensagem automática. Por favor, não a responda.</p> |
|||
<p>© {{ casa_legislativa }}</p> |
|||
</div> |
|||
</div> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,17 @@ |
|||
{{ casa_legislativa }} — SGVP |
|||
===================================================== |
|||
|
|||
Olá, {{ nome_autor }}. |
|||
|
|||
Você possui {{ total }} matéria(s) aguardando assinatura digital: |
|||
|
|||
{% for m in materias %} {{ forloop.counter }}. {{ m.tipo.sigla }} {{ m.numero }}/{{ m.ano }} — {{ m.ementa|truncatechars:80 }} |
|||
{% endfor %} |
|||
{% if total_omitidas %} |
|||
... e mais {{ total_omitidas }} outra(s) matéria(s). |
|||
{% endif %} |
|||
|
|||
Acesse: {{ base_url }}{{ url_pesquisa }} |
|||
|
|||
--- |
|||
Esta é uma mensagem automática. Por favor, não a responda. |
|||
@ -0,0 +1,420 @@ |
|||
{% comment %} |
|||
Partial reusavel: Modal de Assinatura Digital em Lote de Documentos |
|||
Acessorios. Espera no contexto a variavel `docs_pendentes_lote` |
|||
(lista de dicts com chaves `id` e `descricao`). O caller deve |
|||
envolver com if docs_pendentes_lote include endif e renderizar em |
|||
algum lugar um botao com id="btn-assinar-doc-lote" para disparar o |
|||
modal. |
|||
|
|||
Backend: POST para sapl.materia:docacessorio_assinar_lote |
|||
(views_assinatura.py::docacessorio_assinar_lote). |
|||
|
|||
Usado por: |
|||
- sapl/templates/materia/documentoacessorio_list.html |
|||
(assinar docs acessorios pendentes de UMA materia) |
|||
- sapl/templates/materia/despachos_pendentes_lote_list.html |
|||
(assinar despachos pendentes de TODAS as materias - Presidente da Mesa) |
|||
{% endcomment %} |
|||
<div class="modal fade" id="assinaturaDocLoteModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaDocLoteModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog modal-lg" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header bg-warning"> |
|||
<h5 class="modal-title" id="assinaturaDocLoteModalLabel"> |
|||
<i class="fa fa-certificate"></i> |
|||
Assinar Documentos Acessórios em Lote — |
|||
<strong id="doc-lote-titulo-total">{{ docs_pendentes_lote|length }}</strong> pendente(s) |
|||
</h5> |
|||
<button type="button" class="close" data-dismiss="modal" aria-label="Fechar"> |
|||
<span aria-hidden="true">×</span> |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="modal-body"> |
|||
|
|||
{# Passo 0: seleção #} |
|||
<div id="doc-lote-selecao-step"> |
|||
<div class="d-flex justify-content-between align-items-center mb-2"> |
|||
<span class="font-weight-bold"> |
|||
<i class="fa fa-list-ul"></i> Selecione os documentos a assinar |
|||
</span> |
|||
<div> |
|||
<button type="button" class="btn btn-sm btn-outline-secondary mr-1" id="doc-lote-btn-todas"> |
|||
<i class="fa fa-check-square-o"></i> Todos |
|||
</button> |
|||
<button type="button" class="btn btn-sm btn-outline-secondary" id="doc-lote-btn-nenhuma"> |
|||
<i class="fa fa-square-o"></i> Nenhum |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<div id="doc-lote-lista-checkboxes" style="max-height:320px;overflow-y:auto;border:1px solid #dee2e6;border-radius:4px;padding:8px;"> |
|||
{% for d in docs_pendentes_lote %} |
|||
<div class="form-check py-1" style="border-bottom:1px solid #f0f0f0;"> |
|||
<input class="form-check-input doc-lote-chk" type="checkbox" |
|||
id="doc-lote-chk-{{ d.id }}" value="{{ d.id }}" checked> |
|||
<label class="form-check-label w-100" for="doc-lote-chk-{{ d.id }}" style="cursor:pointer;"> |
|||
{{ d.descricao }} |
|||
</label> |
|||
</div> |
|||
{% endfor %} |
|||
</div> |
|||
<p class="text-muted small mt-2 mb-0"> |
|||
<span id="doc-lote-selecao-contagem">{{ docs_pendentes_lote|length }}</span> documento(s) selecionado(s) |
|||
</p> |
|||
</div> |
|||
|
|||
{# Passo 1: certificado #} |
|||
<div id="doc-lote-form-step" style="display:none;"> |
|||
<div class="alert alert-warning"> |
|||
<i class="fa fa-exclamation-triangle"></i> |
|||
<strong>Atenção:</strong> Esta operação assinará digitalmente |
|||
<strong><span id="doc-lote-alerta-total">{{ docs_pendentes_lote|length }}</span> documento(s)</strong> |
|||
com validade jurídica nos termos da MP 2.200-2/2001. |
|||
</div> |
|||
<form id="form-assinatura-doc-lote" enctype="multipart/form-data"> |
|||
{% csrf_token %} |
|||
<div class="form-group"> |
|||
<label for="doc-lote-certificado"> |
|||
<i class="fa fa-file"></i> Certificado Digital (.pfx / .p12) |
|||
</label> |
|||
<div class="custom-file"> |
|||
<input type="file" class="custom-file-input" id="doc-lote-certificado" |
|||
name="certificado" accept=".pfx,.p12" required> |
|||
<label class="custom-file-label" for="doc-lote-certificado"> |
|||
Selecione o arquivo do certificado... |
|||
</label> |
|||
</div> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="doc-lote-senha"> |
|||
<i class="fa fa-lock"></i> Senha do Certificado |
|||
</label> |
|||
<input type="password" class="form-control" id="doc-lote-senha" |
|||
name="senha" placeholder="Digite a senha do certificado" required> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
{# Passo 1.5: prévia #} |
|||
<div id="doc-lote-previa-step" style="display:none;"> |
|||
<div class="alert alert-info mb-3"> |
|||
<i class="fa fa-eye"></i> |
|||
<strong>Revise os documentos antes de assinar.</strong> |
|||
Clique em cada item para visualizar o PDF em nova aba. |
|||
</div> |
|||
<div id="doc-lote-previa-lista" style="max-height:300px;overflow-y:auto;border:1px solid #dee2e6;border-radius:4px;padding:8px;" class="mb-3"></div> |
|||
<div class="form-check mt-3"> |
|||
<input class="form-check-input" type="checkbox" id="doc-lote-previa-confirmacao"> |
|||
<label class="form-check-label font-weight-bold text-danger" for="doc-lote-previa-confirmacao"> |
|||
<i class="fa fa-check-square-o"></i> |
|||
Confirmo que revisei os documentos e que estão corretos para assinatura. |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
{# Passo 2: progresso #} |
|||
<div id="doc-lote-progresso-step" style="display:none;"> |
|||
<h6 class="mb-3"> |
|||
<i class="fa fa-spinner fa-spin text-primary"></i> |
|||
Processando assinaturas… |
|||
</h6> |
|||
<div class="progress mb-3" style="height:22px;"> |
|||
<div id="doc-lote-progress-bar" |
|||
class="progress-bar progress-bar-striped progress-bar-animated bg-warning" |
|||
role="progressbar" style="width:0%">0%</div> |
|||
</div> |
|||
<p class="text-muted small" id="doc-lote-status-texto">Iniciando…</p> |
|||
</div> |
|||
|
|||
{# Passo 3: resumo #} |
|||
<div id="doc-lote-resumo-step" style="display:none;"> |
|||
<div id="doc-lote-resumo-alerta"></div> |
|||
<div id="doc-lote-resumo-lista" style="max-height:300px;overflow-y:auto;"></div> |
|||
</div> |
|||
|
|||
</div> |
|||
|
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="doc-lote-btn-fechar"> |
|||
<i class="fa fa-times"></i> Fechar |
|||
</button> |
|||
<button type="button" class="btn btn-warning" id="doc-lote-btn-proximo"> |
|||
<i class="fa fa-arrow-right"></i> |
|||
Continuar — <span id="doc-lote-btn-proximo-contagem">{{ docs_pendentes_lote|length }}</span> Doc(s) |
|||
</button> |
|||
<button type="button" class="btn btn-outline-secondary" id="doc-lote-btn-voltar" style="display:none;"> |
|||
<i class="fa fa-arrow-left"></i> Voltar |
|||
</button> |
|||
<button type="button" class="btn btn-info" id="doc-lote-btn-previa" style="display:none;"> |
|||
<i class="fa fa-eye"></i> Visualizar Documentos |
|||
</button> |
|||
<button type="button" class="btn btn-outline-secondary" id="doc-lote-btn-voltar-form" style="display:none;"> |
|||
<i class="fa fa-arrow-left"></i> Voltar |
|||
</button> |
|||
<button type="button" class="btn btn-warning" id="doc-lote-btn-assinar" style="display:none;" disabled> |
|||
<i class="fa fa-certificate"></i> |
|||
Assinar <span id="doc-lote-btn-assinar-contagem">{{ docs_pendentes_lote|length }}</span> Doc(s) |
|||
</button> |
|||
<button type="button" class="btn btn-success" id="doc-lote-btn-recarregar" style="display:none;" |
|||
onclick="location.reload()"> |
|||
<i class="fa fa-refresh"></i> Atualizar página |
|||
</button> |
|||
</div> |
|||
|
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
(function () { |
|||
var DOCS_LOTE = {{ docs_pendentes_lote|safe }}; |
|||
var URL_LOTE = "{% url 'sapl.materia:docacessorio_assinar_lote' %}"; |
|||
|
|||
function getCSRF() { |
|||
var el = document.querySelector('#form-assinatura-doc-lote [name=csrfmiddlewaretoken]'); |
|||
if (el) return el.value; |
|||
var match = document.cookie.match(/csrftoken=([^;]+)/); |
|||
return match ? match[1] : ''; |
|||
} |
|||
|
|||
function getIdsSelecionados() { |
|||
return Array.from(document.querySelectorAll('.doc-lote-chk:checked')) |
|||
.map(function(c){ return parseInt(c.value, 10); }); |
|||
} |
|||
|
|||
function atualizarContagem() { |
|||
var n = getIdsSelecionados().length; |
|||
document.getElementById('doc-lote-selecao-contagem').textContent = n; |
|||
document.getElementById('doc-lote-btn-proximo-contagem').textContent = n; |
|||
document.getElementById('doc-lote-btn-assinar-contagem').textContent = n; |
|||
document.getElementById('doc-lote-alerta-total').textContent = n; |
|||
document.getElementById('doc-lote-titulo-total').textContent = n; |
|||
document.getElementById('doc-lote-btn-proximo').disabled = (n === 0); |
|||
} |
|||
|
|||
document.getElementById('doc-lote-lista-checkboxes').addEventListener('change', function(e){ |
|||
if (e.target && e.target.classList.contains('doc-lote-chk')) atualizarContagem(); |
|||
}); |
|||
|
|||
document.getElementById('doc-lote-btn-todas').addEventListener('click', function(){ |
|||
document.querySelectorAll('.doc-lote-chk').forEach(function(c){ c.checked = true; }); |
|||
atualizarContagem(); |
|||
}); |
|||
document.getElementById('doc-lote-btn-nenhuma').addEventListener('click', function(){ |
|||
document.querySelectorAll('.doc-lote-chk').forEach(function(c){ c.checked = false; }); |
|||
atualizarContagem(); |
|||
}); |
|||
|
|||
document.getElementById('doc-lote-certificado').addEventListener('change', function(){ |
|||
var label = this.nextElementSibling; |
|||
label.textContent = this.files[0] ? this.files[0].name : 'Selecione o arquivo do certificado...'; |
|||
}); |
|||
|
|||
function irParaSelecao() { |
|||
document.getElementById('doc-lote-selecao-step').style.display = 'block'; |
|||
document.getElementById('doc-lote-form-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-previa-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-progresso-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-resumo-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-proximo').style.display = 'inline-block'; |
|||
document.getElementById('doc-lote-btn-voltar').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-previa').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-voltar-form').style.display= 'none'; |
|||
document.getElementById('doc-lote-btn-assinar').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-recarregar').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-fechar').disabled = false; |
|||
} |
|||
|
|||
function irParaForm() { |
|||
document.getElementById('doc-lote-selecao-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-form-step').style.display = 'block'; |
|||
document.getElementById('doc-lote-previa-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-progresso-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-resumo-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-proximo').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-voltar').style.display = 'inline-block'; |
|||
document.getElementById('doc-lote-btn-previa').style.display = 'inline-block'; |
|||
document.getElementById('doc-lote-btn-voltar-form').style.display= 'none'; |
|||
document.getElementById('doc-lote-btn-assinar').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-recarregar').style.display = 'none'; |
|||
} |
|||
|
|||
function irParaPrevia() { |
|||
var certFile = document.getElementById('doc-lote-certificado').files[0]; |
|||
var senha = document.getElementById('doc-lote-senha').value; |
|||
if (!certFile) { alert('Selecione o arquivo do certificado.'); return; } |
|||
if (!senha) { alert('Digite a senha do certificado.'); return; } |
|||
|
|||
var idsSel = getIdsSelecionados(); |
|||
var html = ''; |
|||
DOCS_LOTE.filter(function(d){ return idsSel.indexOf(d.id) !== -1; }) |
|||
.forEach(function(d) { |
|||
html += '<div class="d-flex align-items-center justify-content-between py-2" ' + |
|||
'style="border-bottom:1px solid #f0f0f0;">' + |
|||
'<span><i class="fa fa-file-pdf-o text-danger mr-1"></i>' + |
|||
'<strong>' + escHtml(d.descricao) + '</strong></span>' + |
|||
'<a href="/materia/documentoacessorio/' + d.id + '/pdf-previa" target="_blank" ' + |
|||
'class="btn btn-sm btn-outline-primary ml-2" title="Visualizar PDF">' + |
|||
'<i class="fa fa-eye"></i> Visualizar</a>' + |
|||
'</div>'; |
|||
}); |
|||
document.getElementById('doc-lote-previa-lista').innerHTML = html || '<p class="text-muted">Nenhum documento selecionado.</p>'; |
|||
|
|||
var chk = document.getElementById('doc-lote-previa-confirmacao'); |
|||
chk.checked = false; |
|||
document.getElementById('doc-lote-btn-assinar').disabled = true; |
|||
|
|||
document.getElementById('doc-lote-selecao-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-form-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-previa-step').style.display = 'block'; |
|||
document.getElementById('doc-lote-progresso-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-resumo-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-proximo').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-voltar').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-previa').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-voltar-form').style.display= 'inline-block'; |
|||
document.getElementById('doc-lote-btn-assinar').style.display = 'inline-block'; |
|||
document.getElementById('doc-lote-btn-recarregar').style.display = 'none'; |
|||
} |
|||
|
|||
document.getElementById('btn-assinar-doc-lote').addEventListener('click', function(){ |
|||
document.querySelectorAll('.doc-lote-chk').forEach(function(c){ c.checked = true; }); |
|||
document.getElementById('form-assinatura-doc-lote').reset(); |
|||
document.querySelector('#form-assinatura-doc-lote .custom-file-label').textContent = |
|||
'Selecione o arquivo do certificado...'; |
|||
atualizarContagem(); |
|||
irParaSelecao(); |
|||
$('#assinaturaDocLoteModal').modal('show'); |
|||
}); |
|||
|
|||
document.getElementById('doc-lote-btn-proximo').addEventListener('click', function(){ |
|||
if (getIdsSelecionados().length === 0) { alert('Selecione ao menos um documento.'); return; } |
|||
irParaForm(); |
|||
}); |
|||
|
|||
document.getElementById('doc-lote-btn-voltar').addEventListener('click', irParaSelecao); |
|||
document.getElementById('doc-lote-btn-previa').addEventListener('click', irParaPrevia); |
|||
document.getElementById('doc-lote-btn-voltar-form').addEventListener('click', irParaForm); |
|||
|
|||
document.getElementById('doc-lote-previa-confirmacao').addEventListener('change', function(){ |
|||
document.getElementById('doc-lote-btn-assinar').disabled = !this.checked; |
|||
}); |
|||
|
|||
document.getElementById('doc-lote-btn-assinar').addEventListener('click', function(){ |
|||
var certFile = document.getElementById('doc-lote-certificado').files[0]; |
|||
var senha = document.getElementById('doc-lote-senha').value; |
|||
var idsSel = getIdsSelecionados(); |
|||
|
|||
if (!certFile) { alert('Selecione o arquivo do certificado.'); return; } |
|||
if (!senha) { alert('Digite a senha do certificado.'); return; } |
|||
if (!idsSel.length) { alert('Nenhum documento selecionado.'); return; } |
|||
|
|||
var fd = new FormData(); |
|||
fd.append('certificado', certFile); |
|||
fd.append('senha', senha); |
|||
fd.append('ids', JSON.stringify(idsSel)); |
|||
|
|||
document.getElementById('doc-lote-previa-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-progresso-step').style.display = 'block'; |
|||
document.getElementById('doc-lote-btn-voltar-form').style.display= 'none'; |
|||
document.getElementById('doc-lote-btn-assinar').style.display = 'none'; |
|||
document.getElementById('doc-lote-btn-fechar').disabled = true; |
|||
|
|||
// 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', |
|||
body: fd, |
|||
headers: { 'X-CSRFToken': getCSRF() } |
|||
}) |
|||
.then(function(r){ |
|||
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)); |
|||
}); |
|||
}); |
|||
|
|||
function setProgresso(pct, texto) { |
|||
var bar = document.getElementById('doc-lote-progress-bar'); |
|||
bar.style.width = pct + '%'; |
|||
bar.textContent = pct + '%'; |
|||
document.getElementById('doc-lote-status-texto').textContent = texto; |
|||
} |
|||
|
|||
function mostrarResumo(data) { |
|||
document.getElementById('doc-lote-progresso-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-resumo-step').style.display = 'block'; |
|||
document.getElementById('doc-lote-btn-recarregar').style.display = 'inline-block'; |
|||
|
|||
var temErro = data.erros > 0; |
|||
var alertaCls = data.sucesso > 0 ? (temErro ? 'alert-warning' : 'alert-success') : 'alert-danger'; |
|||
var icone = data.sucesso > 0 ? (temErro ? 'exclamation-triangle' : 'check-circle') : 'times-circle'; |
|||
|
|||
document.getElementById('doc-lote-resumo-alerta').innerHTML = |
|||
'<div class="alert ' + alertaCls + '">' + |
|||
'<i class="fa fa-' + icone + '"></i> ' + |
|||
'<strong>' + data.sucesso + ' assinado(s)</strong> com sucesso' + |
|||
(temErro ? ', <strong>' + data.erros + '</strong> com erro(s).' : '.') + |
|||
' Total: ' + data.total + ' documento(s).</div>'; |
|||
|
|||
var html = '<ul class="list-group">'; |
|||
(data.resultados || []).forEach(function(r){ |
|||
var cls = r.success ? 'list-group-item-success' : 'list-group-item-danger'; |
|||
var icon = r.success ? 'check text-success' : 'times text-danger'; |
|||
html += '<li class="list-group-item list-group-item-sm ' + cls + '">' + |
|||
'<i class="fa fa-' + icon + ' mr-1"></i>' + |
|||
'<strong>' + escHtml(r.descricao) + '</strong>' + |
|||
(r.error ? ' — <small class="text-muted">' + escHtml(r.error) + '</small>' : '') + |
|||
'</li>'; |
|||
}); |
|||
html += '</ul>'; |
|||
document.getElementById('doc-lote-resumo-lista').innerHTML = html; |
|||
} |
|||
|
|||
function mostrarErroFatal(msg) { |
|||
document.getElementById('doc-lote-progresso-step').style.display = 'none'; |
|||
document.getElementById('doc-lote-resumo-step').style.display = 'block'; |
|||
document.getElementById('doc-lote-resumo-alerta').innerHTML = |
|||
'<div class="alert alert-danger"><i class="fa fa-times-circle"></i> ' + |
|||
'<strong>Erro:</strong> ' + escHtml(msg) + '</div>'; |
|||
document.getElementById('doc-lote-resumo-lista').innerHTML = ''; |
|||
} |
|||
|
|||
function escHtml(s) { |
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
|||
} |
|||
})(); |
|||
</script> |
|||
@ -0,0 +1,136 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load static %} |
|||
|
|||
{% block actions %} |
|||
<div class="actions btn-group float-right pb-4" role="group"> |
|||
<a href="{% url 'sapl.materia:materias_pendentes_assinatura' %}" class="btn btn-outline-secondary"> |
|||
<i class="fas fa-arrow-left"></i> {% trans "Voltar para Minhas Matérias" %} |
|||
</a> |
|||
</div> |
|||
{% endblock %} |
|||
|
|||
{% block detail_content %} |
|||
|
|||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap" style="gap:.75rem;"> |
|||
<div> |
|||
<h4 class="mb-0"> |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:1rem;"> |
|||
<i class="fas fa-stamp mr-1"></i> {% trans "Despachos Pendentes de Assinatura" %} |
|||
</span> |
|||
</h4> |
|||
<small class="text-muted mt-1 d-block"> |
|||
<i class="fas fa-user-tie mr-1"></i> |
|||
{% trans "Acesso exclusivo do" %} <strong>{% trans "Presidente da Mesa Diretora" %}</strong> |
|||
</small> |
|||
</div> |
|||
<div> |
|||
{% if total == 0 %} |
|||
<span class="badge badge-success px-3 py-2" style="font-size:.95rem;"> |
|||
<i class="fas fa-check-circle mr-1"></i> {% trans "Nenhum despacho pendente" %} |
|||
</span> |
|||
{% elif total == 1 %} |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;"> |
|||
1 {% trans "despacho pendente" %} |
|||
</span> |
|||
{% else %} |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;"> |
|||
{{ total }} {% trans "despachos pendentes" %} |
|||
</span> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
|
|||
{% if not object_list %} |
|||
<div class="alert alert-success d-flex align-items-center" role="alert"> |
|||
<i class="fas fa-check-circle fa-2x mr-3"></i> |
|||
<div> |
|||
<strong>{% trans "Tudo em dia!" %}</strong><br> |
|||
{% trans "Não há despachos pendentes de assinatura digital no sistema." %} |
|||
</div> |
|||
</div> |
|||
{% else %} |
|||
|
|||
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 flex-wrap" |
|||
role="alert" style="border-left: 5px solid #f0ad4e; gap:1rem;"> |
|||
<div> |
|||
<i class="fas fa-exclamation-triangle fa-lg mr-2"></i> |
|||
{% trans "Os despachos abaixo aguardam assinatura digital. Você pode assiná-los todos em lote com seu certificado digital." %} |
|||
</div> |
|||
<button type="button" id="btn-assinar-doc-lote" |
|||
class="btn btn-warning" |
|||
title="{% trans 'Assinar digitalmente todos os despachos selecionados em lote' %}"> |
|||
<i class="fa fa-certificate"></i> |
|||
{% trans "Assinar Despachos em Lote" %} |
|||
<span class="badge badge-light text-dark ml-1">{{ docs_pendentes_lote|length }}</span> |
|||
</button> |
|||
</div> |
|||
|
|||
<table class="table table-hover table-bordered"> |
|||
<thead class="thead-light"> |
|||
<tr> |
|||
<th style="width:170px;">{% trans "Matéria" %}</th> |
|||
<th>{% trans "Nome do Despacho" %}</th> |
|||
<th style="width:140px;">{% trans "Tipo" %}</th> |
|||
<th style="width:140px;">{% trans "Autor" %}</th> |
|||
<th style="width:110px;" class="text-center">{% trans "Data" %}</th> |
|||
<th style="width:90px;" class="text-center">{% trans "Ações" %}</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{% for d in object_list %} |
|||
<tr> |
|||
<td> |
|||
<a href="{% url 'sapl.materia:materialegislativa_detail' d.materia.pk %}" |
|||
class="font-weight-bold" |
|||
title="{% trans 'Abrir matéria' %}"> |
|||
{{ d.materia.tipo.sigla }} {{ d.materia.numero }}/{{ d.materia.ano }} |
|||
</a> |
|||
</td> |
|||
<td> |
|||
<a href="{% url 'sapl.materia:documentoacessorio_detail' d.pk %}" |
|||
title="{% trans 'Abrir documento' %}"> |
|||
{{ d.nome }} |
|||
</a> |
|||
</td> |
|||
<td> |
|||
<small class="text-muted">{{ d.tipo }}</small> |
|||
</td> |
|||
<td> |
|||
{% if d.autor %} |
|||
{{ d.autor }} |
|||
{% else %} |
|||
<span class="text-muted">—</span> |
|||
{% endif %} |
|||
</td> |
|||
<td class="text-center"> |
|||
{% if d.data %} |
|||
{{ d.data|date:"d/m/Y" }} |
|||
{% else %} |
|||
<span class="text-muted">—</span> |
|||
{% endif %} |
|||
</td> |
|||
<td class="text-center"> |
|||
{% if d.arquivo %} |
|||
<a href="{{ d.arquivo.url }}" target="_blank" |
|||
class="btn btn-sm btn-outline-primary" |
|||
title="{% trans 'Baixar arquivo original' %}"> |
|||
<i class="fas fa-download"></i> |
|||
</a> |
|||
{% else %} |
|||
<span class="text-muted">—</span> |
|||
{% endif %} |
|||
</td> |
|||
</tr> |
|||
{% endfor %} |
|||
</tbody> |
|||
</table> |
|||
|
|||
{% include "paginacao.html" %} |
|||
|
|||
{# Modal de assinatura em lote (partial reusado de documentoacessorio_list.html) #} |
|||
{% include "materia/assinatura_doc_lote_modal.html" %} |
|||
|
|||
{% endif %} |
|||
|
|||
{% endblock detail_content %} |
|||
@ -0,0 +1,151 @@ |
|||
{% extends "base.html" %} |
|||
{% load i18n staticfiles %} |
|||
|
|||
{% block head_title %}Acervo Histórico – Matérias Anteriores a 2026 – {{ nome_casa }}{% endblock %} |
|||
|
|||
{% block head_content %} |
|||
{{ block.super }} |
|||
<style> |
|||
html, body { height: 100%; overflow: hidden; } |
|||
|
|||
/* Esconde footer nesta página */ |
|||
#footer, footer.footer { display: none !important; } |
|||
|
|||
/* Remove container padrão do conteúdo */ |
|||
#content.content.page__row { padding: 0 !important; margin: 0 !important; } |
|||
#content .container { max-width: 100% !important; padding: 0 !important; margin: 0 !important; width: 100% !important; } |
|||
|
|||
#ged-wrapper { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: calc(100vh - 56px); |
|||
} |
|||
|
|||
#ged-header { |
|||
flex-shrink: 0; |
|||
background: linear-gradient(135deg, #1a3a5c 0%, #2e6da4 100%); |
|||
color: #fff; |
|||
padding: 12px 24px; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 14px; |
|||
box-shadow: 0 2px 6px rgba(0,0,0,.25); |
|||
} |
|||
#ged-header .ged-icon { font-size: 1.8rem; opacity: .9; } |
|||
#ged-header .ged-title { flex: 1; } |
|||
#ged-header .ged-title h1 { font-size: 1.05rem; font-weight: 700; margin: 0 0 2px; } |
|||
#ged-header .ged-title p { font-size: .75rem; margin: 0; opacity: .85; } |
|||
#ged-header .ged-actions a { color: #fff; border-color: rgba(255,255,255,.6); font-size: .8rem; } |
|||
#ged-header .ged-actions a:hover { background: rgba(255,255,255,.15); border-color: #fff; } |
|||
|
|||
#ged-frame-container { flex: 1; position: relative; overflow: hidden; } |
|||
|
|||
#ged-loading { |
|||
position: absolute; inset: 0; |
|||
display: flex; flex-direction: column; align-items: center; justify-content: center; |
|||
background: #f8f9fa; z-index: 10; gap: 12px; |
|||
color: #555; font-size: .9rem; transition: opacity .4s; |
|||
} |
|||
#ged-loading.hidden { opacity: 0; pointer-events: none; } |
|||
|
|||
#ged-error { |
|||
display: none; flex-direction: column; align-items: center; justify-content: center; |
|||
height: 100%; gap: 12px; color: #555; text-align: center; padding: 24px; |
|||
} |
|||
#ged-error i { font-size: 3rem; color: #c0392b; } |
|||
|
|||
#ged-frame { width: 100%; height: 100%; border: none; display: block; } |
|||
</style> |
|||
{% endblock %} |
|||
|
|||
{% block content_subnav %}{% endblock %} |
|||
{% block footer_container %}{% endblock %} |
|||
|
|||
{% block content_container %} |
|||
{% if ged_url %} |
|||
<div id="ged-wrapper"> |
|||
|
|||
<div id="ged-header"> |
|||
<div class="ged-icon"><i class="fas fa-archive"></i></div> |
|||
<div class="ged-title"> |
|||
<h1>Acervo Histórico – Matérias Legislativas</h1> |
|||
<p>Documentos do sistema anterior (GED) · registros anteriores a 2026</p> |
|||
</div> |
|||
<div class="ged-actions d-none d-md-flex" style="gap:8px;"> |
|||
<a href="{{ ged_url }}" target="_blank" rel="noopener noreferrer" |
|||
class="btn btn-sm btn-outline-light" title="Abrir em nova aba"> |
|||
<i class="fas fa-external-link-alt"></i> Abrir em nova aba |
|||
</a> |
|||
</div> |
|||
</div> |
|||
|
|||
<div id="ged-frame-container"> |
|||
|
|||
<div id="ged-loading"> |
|||
<div class="spinner-border text-primary" role="status"> |
|||
<span class="sr-only">Carregando…</span> |
|||
</div> |
|||
<span>Carregando acervo histórico…</span> |
|||
</div> |
|||
|
|||
<div id="ged-error"> |
|||
<i class="fas fa-exclamation-triangle"></i> |
|||
<strong>Não foi possível carregar o acervo histórico.</strong> |
|||
<p class="mb-1">O sistema GED pode estar temporariamente indisponível.</p> |
|||
<a href="{{ ged_url }}" target="_blank" rel="noopener noreferrer" |
|||
class="btn btn-primary btn-sm mt-2"> |
|||
<i class="fas fa-external-link-alt"></i> Tentar acessar diretamente |
|||
</a> |
|||
</div> |
|||
|
|||
<iframe |
|||
id="ged-frame" |
|||
src="{{ ged_url }}" |
|||
title="Acervo Histórico – Matérias Legislativas (GED)" |
|||
loading="lazy" |
|||
allow="fullscreen" |
|||
></iframe> |
|||
</div> |
|||
|
|||
</div> |
|||
|
|||
<script> |
|||
(function () { |
|||
var frame = document.getElementById('ged-frame'); |
|||
var loading = document.getElementById('ged-loading'); |
|||
var error = document.getElementById('ged-error'); |
|||
|
|||
frame.addEventListener('load', function () { |
|||
loading.classList.add('hidden'); |
|||
setTimeout(function () { loading.style.display = 'none'; }, 450); |
|||
}); |
|||
|
|||
var timeout = setTimeout(function () { |
|||
if (!loading.classList.contains('hidden')) { |
|||
loading.style.display = 'none'; |
|||
frame.style.display = 'none'; |
|||
error.style.display = 'flex'; |
|||
} |
|||
}, 15000); |
|||
|
|||
frame.addEventListener('load', function () { clearTimeout(timeout); }); |
|||
})(); |
|||
</script> |
|||
|
|||
{% else %} |
|||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; |
|||
min-height: 60vh; text-align:center; padding: 40px 24px; color:#555;"> |
|||
<i class="fas fa-archive" style="font-size:4rem; color:#adb5bd; margin-bottom:20px;"></i> |
|||
<h2 style="font-size:1.4rem; font-weight:700; color:#343a40; margin-bottom:12px;"> |
|||
Acervo Histórico não disponível |
|||
</h2> |
|||
<p style="max-width:480px; font-size:.95rem; line-height:1.6; margin-bottom:24px;"> |
|||
Esta instalação não possui proposições legadas configuradas.<br> |
|||
Caso necessite acessar matérias de sistemas anteriores, entre em contato com o administrador do sistema. |
|||
</p> |
|||
<a href="/" class="btn btn-outline-secondary"> |
|||
<i class="fas fa-home"></i> Voltar à página inicial |
|||
</a> |
|||
</div> |
|||
{% endif %} |
|||
{% endblock content_container %} |
|||
@ -0,0 +1,126 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load static %} |
|||
|
|||
{% block actions %} |
|||
<div class="actions btn-group float-right pb-4" role="group"> |
|||
{% if is_presidente_mesa %} |
|||
<a href="{% url 'sapl.materia:despachos_pendentes_lote' %}" |
|||
class="btn btn-warning" |
|||
title="{% trans 'Assinar todos os despachos pendentes em lote (acesso exclusivo do Presidente da Mesa Diretora)' %}"> |
|||
<i class="fas fa-stamp"></i> {% trans "Assinar Despachos em Lote" %} |
|||
</a> |
|||
{% endif %} |
|||
<a href="{{ url_pesquisa_completa }}" class="btn btn-outline-secondary"> |
|||
<i class="fas fa-search"></i> {% trans "Ver na Pesquisa Completa" %} |
|||
</a> |
|||
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary"> |
|||
<i class="fas fa-list"></i> {% trans "Pesquisar Matérias" %} |
|||
</a> |
|||
</div> |
|||
{% endblock %} |
|||
|
|||
{% block detail_content %} |
|||
|
|||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap" style="gap:.75rem;"> |
|||
<div> |
|||
<h4 class="mb-0"> |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:1rem;"> |
|||
<i class="fas fa-clock mr-1"></i> Matérias Pendentes de Assinatura |
|||
</span> |
|||
</h4> |
|||
{% if autor %} |
|||
<small class="text-muted mt-1 d-block"> |
|||
<i class="fas fa-user mr-1"></i> Autor: <strong>{{ autor }}</strong> |
|||
</small> |
|||
{% endif %} |
|||
</div> |
|||
<div> |
|||
{% if total == 0 %} |
|||
<span class="badge badge-success px-3 py-2" style="font-size:.95rem;"> |
|||
<i class="fas fa-check-circle mr-1"></i> Nenhuma pendência |
|||
</span> |
|||
{% elif total == 1 %} |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;"> |
|||
1 matéria pendente |
|||
</span> |
|||
{% else %} |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;"> |
|||
{{ total }} matérias pendentes |
|||
</span> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
|
|||
{% if not object_list %} |
|||
<div class="alert alert-success d-flex align-items-center" role="alert"> |
|||
<i class="fas fa-check-circle fa-2x mr-3"></i> |
|||
<div> |
|||
<strong>Tudo em dia!</strong><br> |
|||
Não há matérias com documento pendente de assinatura digital. |
|||
</div> |
|||
</div> |
|||
{% else %} |
|||
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert" |
|||
style="border-left: 5px solid #f0ad4e;"> |
|||
<i class="fas fa-exclamation-triangle fa-lg mr-2"></i> |
|||
As matérias abaixo possuem <strong> Texto Original </strong> mas ainda |
|||
<strong> não foram assinadas digitalmente</strong>. |
|||
</div> |
|||
|
|||
<table class="table table-hover table-bordered"> |
|||
<thead class="thead-light"> |
|||
<tr> |
|||
<th style="width:160px;">Matéria</th> |
|||
<th>Ementa</th> |
|||
<th style="width:130px;">Apresentação</th> |
|||
<th style="width:130px;">Situação</th> |
|||
<th style="width:110px;" class="text-center">Ações</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{% for m in object_list %} |
|||
<tr> |
|||
<td> |
|||
<a href="{% url 'sapl.materia:materialegislativa_detail' m.pk %}" |
|||
class="font-weight-bold"> |
|||
{{ m.tipo.sigla }} {{ m.numero }}/{{ m.ano }} |
|||
</a> |
|||
<br> |
|||
<small class="text-muted">{{ m.tipo }}</small> |
|||
</td> |
|||
<td> |
|||
<span title="{{ m.ementa }}"> |
|||
{{ m.ementa|truncatechars:120 }} |
|||
</span> |
|||
</td> |
|||
<td class="text-center"> |
|||
{% if m.data_apresentacao %} |
|||
{{ m.data_apresentacao|date:"d/m/Y" }} |
|||
{% else %} |
|||
<span class="text-muted">—</span> |
|||
{% endif %} |
|||
</td> |
|||
<td class="text-center"> |
|||
{% if m.em_tramitacao %} |
|||
<span class="badge badge-info">Em Tramitação</span> |
|||
{% else %} |
|||
<span class="badge badge-secondary">Arquivada</span> |
|||
{% endif %} |
|||
</td> |
|||
<td class="text-center"> |
|||
<a href="{% url 'sapl.materia:materialegislativa_detail' m.pk %}" |
|||
class="btn btn-sm btn-outline-primary" |
|||
title="Abrir matéria para assinar"> |
|||
<i class="fas fa-signature"></i> Assinar |
|||
</a> |
|||
</td> |
|||
</tr> |
|||
{% endfor %} |
|||
</tbody> |
|||
</table> |
|||
|
|||
{% include "paginacao.html" %} |
|||
{% endif %} |
|||
|
|||
{% endblock detail_content %} |
|||
@ -0,0 +1,135 @@ |
|||
{% load i18n %} |
|||
|
|||
<!-- Modal de Confirmação: Remover Assinatura Digital --> |
|||
<div class="modal fade" id="removerAssinaturaModal" tabindex="-1" role="dialog" |
|||
aria-labelledby="removerAssinaturaModalLabel" aria-hidden="true"> |
|||
<div class="modal-dialog" role="document"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header bg-danger text-white"> |
|||
<h5 class="modal-title" id="removerAssinaturaModalLabel"> |
|||
<i class="fa fa-exclamation-triangle"></i> |
|||
{% trans "Remover Assinatura Digital" %} |
|||
</h5> |
|||
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close"> |
|||
<span aria-hidden="true">×</span> |
|||
</button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<div id="remover-assinatura-confirmacao"> |
|||
<div class="alert alert-warning"> |
|||
<i class="fa fa-exclamation-triangle"></i> |
|||
<strong>{% trans "Atenção: esta ação não pode ser desfeita." %}</strong> |
|||
</div> |
|||
<p> |
|||
{% trans "Você está prestes a remover a assinatura digital do documento:" %} |
|||
</p> |
|||
<p class="font-weight-bold" id="remover-assinatura-nome-doc"></p> |
|||
<p> |
|||
{% trans "A remoção da assinatura digital apagará o PDF assinado e todos os metadados da assinatura. O documento voltará ao estado não assinado e poderá ser editado novamente." %} |
|||
</p> |
|||
<p class="text-danger"> |
|||
<i class="fa fa-info-circle"></i> |
|||
{% trans "Esta operação fica registrada no log do sistema." %} |
|||
</p> |
|||
</div> |
|||
|
|||
<div id="remover-assinatura-processando" style="display:none;" class="text-center py-3"> |
|||
<i class="fa fa-spinner fa-spin fa-2x text-danger mb-2"></i> |
|||
<p>{% trans "Removendo assinatura..." %}</p> |
|||
</div> |
|||
|
|||
<div id="remover-assinatura-sucesso" style="display:none;"> |
|||
<div class="alert alert-success"> |
|||
<i class="fa fa-check-circle"></i> |
|||
{% trans "Assinatura removida com sucesso. A página será recarregada." %} |
|||
</div> |
|||
</div> |
|||
|
|||
<div id="remover-assinatura-erro" style="display:none;"> |
|||
<div class="alert alert-danger"> |
|||
<i class="fa fa-times-circle"></i> |
|||
<strong>{% trans "Erro ao remover assinatura:" %}</strong> |
|||
<p id="remover-assinatura-mensagem-erro" class="mb-0 mt-1"></p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="btn-cancelar-remocao"> |
|||
<i class="fa fa-times"></i> {% trans "Cancelar" %} |
|||
</button> |
|||
<button type="button" class="btn btn-danger" id="btn-confirmar-remocao"> |
|||
<i class="fa fa-trash"></i> {% trans "Confirmar Remoção" %} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
(function () { |
|||
'use strict'; |
|||
|
|||
var urlRemocao = null; |
|||
|
|||
// Abre o modal ao clicar em qualquer botão .btn-remover-assinatura |
|||
document.addEventListener('click', function (e) { |
|||
var btn = e.target.closest('.btn-remover-assinatura'); |
|||
if (!btn) return; |
|||
|
|||
urlRemocao = btn.dataset.url; |
|||
var nome = btn.dataset.nome || ''; |
|||
|
|||
// Reset modal |
|||
document.getElementById('remover-assinatura-confirmacao').style.display = 'block'; |
|||
document.getElementById('remover-assinatura-processando').style.display = 'none'; |
|||
document.getElementById('remover-assinatura-sucesso').style.display = 'none'; |
|||
document.getElementById('remover-assinatura-erro').style.display = 'none'; |
|||
document.getElementById('remover-assinatura-nome-doc').textContent = nome; |
|||
document.getElementById('btn-confirmar-remocao').style.display = 'inline-block'; |
|||
document.getElementById('btn-cancelar-remocao').textContent = 'Cancelar'; |
|||
|
|||
$('#removerAssinaturaModal').modal('show'); |
|||
}); |
|||
|
|||
document.getElementById('btn-confirmar-remocao').addEventListener('click', function () { |
|||
if (!urlRemocao) return; |
|||
|
|||
// Obter CSRF token |
|||
var csrfToken = null; |
|||
var cookieMatch = document.cookie.match(/csrftoken=([^;]+)/); |
|||
if (cookieMatch) csrfToken = cookieMatch[1]; |
|||
|
|||
// UI: processando |
|||
document.getElementById('remover-assinatura-confirmacao').style.display = 'none'; |
|||
document.getElementById('remover-assinatura-processando').style.display = 'block'; |
|||
document.getElementById('btn-confirmar-remocao').style.display = 'none'; |
|||
|
|||
fetch(urlRemocao, { |
|||
method: 'POST', |
|||
headers: { |
|||
'X-CSRFToken': csrfToken, |
|||
'X-Requested-With': 'XMLHttpRequest' |
|||
} |
|||
}) |
|||
.then(function (response) { return response.json(); }) |
|||
.then(function (data) { |
|||
document.getElementById('remover-assinatura-processando').style.display = 'none'; |
|||
if (data.success) { |
|||
document.getElementById('remover-assinatura-sucesso').style.display = 'block'; |
|||
document.getElementById('btn-cancelar-remocao').textContent = 'Fechar'; |
|||
setTimeout(function () { location.reload(); }, 1500); |
|||
} else { |
|||
document.getElementById('remover-assinatura-erro').style.display = 'block'; |
|||
document.getElementById('remover-assinatura-mensagem-erro').textContent = data.error || 'Erro desconhecido.'; |
|||
document.getElementById('btn-cancelar-remocao').textContent = 'Fechar'; |
|||
} |
|||
}) |
|||
.catch(function (err) { |
|||
document.getElementById('remover-assinatura-processando').style.display = 'none'; |
|||
document.getElementById('remover-assinatura-erro').style.display = 'block'; |
|||
document.getElementById('remover-assinatura-mensagem-erro').textContent = 'Erro de comunicação: ' + err.message; |
|||
document.getElementById('btn-cancelar-remocao').textContent = 'Fechar'; |
|||
}); |
|||
}); |
|||
}()); |
|||
</script> |
|||
Loading…
Reference in new issue