Browse Source

Merge pull request #2 from Move-System/sp_importante_virou_urgente

1° Entrega da Sprint "o importante virou urgente"
pull/3858/head
Kemuel-sepulvida 4 months ago
committed by GitHub
parent
commit
4b7527c27a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 48
      docker/docker-compose-dev.yml
  2. 127
      docs/LOCALHOST_SETUP.md
  3. 1
      requirements/dev-requirements.txt
  4. 18
      sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py
  5. 10
      sapl/base/models.py
  6. 52
      sapl/context_processors.py
  7. 345
      sapl/materia/assinatura_api_client.py
  8. 110
      sapl/materia/forms.py
  9. 199
      sapl/materia/management/commands/notificar_pendentes_assinatura.py
  10. 30
      sapl/materia/migrations/0095_add_autoria_proposicao.py
  11. 21
      sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py
  12. 42
      sapl/materia/models.py
  13. 207
      sapl/materia/onlyoffice_materia_views.py
  14. 36
      sapl/materia/urls.py
  15. 186
      sapl/materia/views.py
  16. 1841
      sapl/materia/views_assinatura.py
  17. 12
      sapl/settings.py
  18. 293
      sapl/static/js/materia_impressao_em_massa.js
  19. 22
      sapl/static/js/materia_pesquisa_download_pdfs.js
  20. 13
      sapl/templates/base.html
  21. 1
      sapl/templates/base/layouts.yaml
  22. 77
      sapl/templates/email/pendentes_assinatura.html
  23. 17
      sapl/templates/email/pendentes_assinatura.txt
  24. 217
      sapl/templates/materia/assinatura_modal.html
  25. 10
      sapl/templates/materia/documentoacessorio_detail.html
  26. 418
      sapl/templates/materia/documentoacessorio_list.html
  27. 18
      sapl/templates/materia/materialegislativa_detail.html
  28. 667
      sapl/templates/materia/materialegislativa_filter.html
  29. 119
      sapl/templates/materia/materias_pendentes_assinatura_list.html
  30. 15
      sapl/templates/materia/proposicao_detail.html
  31. 53
      sapl/templates/materia/proposicao_form.html
  32. 135
      sapl/templates/materia/remover_assinatura_modal.html
  33. 2
      sapl/templates/navbar.yaml

48
docker/docker-compose-dev.yml

@ -1,3 +1,11 @@
# Ambiente de desenvolvimento: código montado como volume (hot-reload),
# banco controlado pelo DATABASE_URL definido em sapl/.env.
#
# Uso:
# docker compose -f docker/docker-compose-dev.yml --env-file sapl/.env up --build
#
# Troque o banco a qualquer momento editando apenas o DATABASE_URL no sapl/.env
version: '3.7' version: '3.7'
services: services:
@ -12,30 +20,22 @@ services:
- ..:/sapl-dev - ..:/sapl-dev
ports: ports:
- "8000:8000" - "8000:8000"
env_file:
- ../sapl/.env # Lê DATABASE_URL, SECRET_KEY, DEBUG, etc.
environment: environment:
SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^' # Garante que DEBUG do .env seja reconhecido pelo settings.py
DJANGO_DEBUG: 'True' DJANGO_DEBUG: '${DEBUG:-True}'
DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
ONLYOFFICE_URL: 'http://onlyoffice:80' extra_hosts:
depends_on: # Permite alcançar bancos externos (ex: legisinc.com.br, sgvp.com.br)
- onlyoffice - "host.docker.internal:host-gateway"
onlyoffice:
container_name: onlyoffice-documentserver
image: onlyoffice/documentserver:latest
ports:
- "8001:80"
environment:
- JWT_ENABLED=false
- JWT_SECRET=your-secret-key-change-this
volumes:
- onlyoffice_data:/var/www/onlyoffice/Data
- onlyoffice_log:/var/log/onlyoffice
- onlyoffice_fonts:/usr/share/fonts/truetype/custom
restart: unless-stopped
volumes: # Descomente para usar OnlyOffice localmente
onlyoffice_data: # onlyoffice:
onlyoffice_log: # container_name: onlyoffice-documentserver
onlyoffice_fonts: # image: onlyoffice/documentserver:latest
# ports:
# - "8001:80"
# environment:
# - JWT_ENABLED=false
# restart: unless-stopped

127
docs/LOCALHOST_SETUP.md

@ -1,40 +1,137 @@
# Subir o SAPL apontando para o banco remoto (localhost) # Subir o SAPL em localhost
Passo a passo para subir o ambiente local usando o Postgres já disponível em `sgvp.com.br:5432`. A abordagem recomendada usa **Docker**, pois replica fielmente o ambiente de produção.
## 1) Preparar o ambiente Python Há dois modos de operação:
| Modo | Quando usar |
|---|---|
| **Localhost completo** (banco local) | Desenvolvimento do zero, sem dependência de banco remoto |
| **Banco remoto** | Testar com dados reais de produção/homologação |
---
## Modo 1: Localhost completo (banco PostgreSQL local)
Sobe a aplicação **e** o banco juntos, sem precisar de `.env` nem banco externo.
```bash ```bash
cd /root/dev/sapl cd /root/dev/sapl
python3 -m venv .venv docker compose -f docker/docker-compose-local.yml up --build
source .venv/bin/activate ```
pip install -r requirements/requirements.txt
Na primeira vez, rode as migrations dentro do container:
```bash
docker exec -it sapl-dev python manage.py migrate
docker exec -it sapl-dev python manage.py createsuperuser
```
Servidor disponível em: **http://localhost:8000**
Banco disponível em: `localhost:5432` (usuário: `sapl` / senha: `sapl` / banco: `sapl`)
Para parar:
```bash
docker compose -f docker/docker-compose-local.yml down
``` ```
## 2) Configurar variáveis de ambiente > Os dados do banco ficam no volume Docker `sapl-pgdata` e persistem entre reinicializações.
> Para apagar tudo: `docker compose -f docker/docker-compose-local.yml down -v`
---
## Modo 2: Banco remoto (apontando para banco externo)
O banco é controlado pelo `sapl/.env`. Basta trocar o `DATABASE_URL` e reiniciar o container.
---
## 1) Configurar o `sapl/.env`
Edite `sapl/.env` com os valores do banco remoto: Edite `sapl/.env` com as credenciais do banco desejado:
```env ```env
DATABASE_URL=postgresql://kemuel:kasepulvida@sgvp.com.br:5432/sapl DATABASE_URL=postgresql://usuario:senha@host:5432/banco
SECRET_KEY=<sua-chave-secreta> SECRET_KEY=<sua-chave-secreta>
DEBUG=True DEBUG=True
EMAIL_USE_TLS=True EMAIL_USE_TLS=True
EMAIL_PORT=587 EMAIL_PORT=587
``` ```
> Observação: não rode `migrate` contra esse banco se ele for de produção. > **Dica:** para trocar de banco, basta editar `DATABASE_URL` e reiniciar — sem alterar nenhum outro arquivo.
> **Atenção:** não rode `migrate` se o banco for de produção.
---
## 2) Subir o ambiente com Docker
```bash
cd /root/dev/sapl
docker compose -f docker/docker-compose-dev.yml --env-file sapl/.env up --build
```
O código-fonte é montado como volume — alterações em `.py` são recarregadas automaticamente.
Servidor disponível em: **http://localhost:8000**
---
## 3) Parar o ambiente
```bash
docker compose -f docker/docker-compose-dev.yml down
```
---
## 4) Rodar comandos Django no container
```bash
docker exec -it sapl-dev python manage.py showmigrations --plan
docker exec -it sapl-dev python manage.py shell
```
---
## 3) Testar conexão (opcional) ## Por que Docker em vez de venv + runserver direto?
| | venv + runserver | Docker (recomendado) |
|---|---|---|
| `DEBUG=True` no .env | Não funcionava (settings.py lia `DJANGO_DEBUG`) | ✅ Corrigido, funciona |
| Banco remoto | Funciona se a porta estiver acessível | ✅ Funciona via `extra_hosts: host-gateway` |
| Proximidade com prod | ❌ Diferenças de config e WSGI | ✅ Mesmo Dockerfile |
---
## Alternativa: venv + runserver (sem Docker)
```bash ```bash
python manage.py showmigrations --plan cd /root/dev/sapl
source .venv/bin/activate
pip install -r requirements/requirements.txt
python manage.py runserver 0.0.0.0:8000
``` ```
## 4) Subir o servidor Django > O `settings.py` foi corrigido para aceitar `DEBUG=True` (além do legado `DJANGO_DEBUG=True`).
---
## Problemas comuns
### Container não alcança o banco remoto
O `docker-compose-dev.yml` já configura `extra_hosts: host-gateway`. Se ainda assim falhar:
```bash ```bash
python manage.py runserver 0.0.0.0:8001 nc -zv <host-do-banco> 5432
``` ```
Se preferir sem autoreload: `python manage.py runserver 0.0.0.0:8001 --noreload`. ### `DEBUG=True` não ativa o modo debug
O `settings.py` foi corrigido para aceitar tanto `DEBUG` quanto `DJANGO_DEBUG`.
Certifique-se que o valor está sem aspas: `DEBUG=True`.
### Static files não carregam com Gunicorn
```bash
docker exec -it sapl-dev python manage.py collectstatic --noinput
```

1
requirements/dev-requirements.txt

@ -2,7 +2,6 @@
autopep8==2.3.2 autopep8==2.3.2
beautifulsoup4==4.13.5 beautifulsoup4==4.13.5
django-debug-toolbar==3.2.4
ipdb==0.13.13 ipdb==0.13.13
fancycompleter==0.11.1 fancycompleter==0.11.1
pdbpp==0.11.7 pdbpp==0.11.7

18
sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py

@ -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?'),
),
]

10
sapl/base/models.py

@ -238,6 +238,16 @@ class AppConfig(models.Model):
verbose_name=_('Nome utilizado na assinatura digital'), verbose_name=_('Nome utilizado na assinatura digital'),
choices=TIPO_NOME_ASSINATURA, choices=TIPO_NOME_ASSINATURA,
default='P') default='P')
permite_remover_assinatura = models.BooleanField(
verbose_name=_('Permitir remoção de assinatura digital?'),
choices=YES_NO_CHOICES,
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.'
)
)
# MÓDULO PAINEL # MÓDULO PAINEL
cronometro_discurso = models.DurationField( cronometro_discurso = models.DurationField(
verbose_name=_('Cronômetro do Discurso'), verbose_name=_('Cronômetro do Discurso'),

52
sapl/context_processors.py

@ -32,6 +32,58 @@ def google_recaptcha_configured(request):
return {'google_recaptcha_configured': True} return {'google_recaptcha_configured': True}
def pendencias_assinatura(request):
"""Injeta contagem e URL de matérias pendentes de assinatura para o usuário logado."""
if not request.user.is_authenticated:
return {'pendencias_assinatura_total': 0, 'pendencias_assinatura_url': ''}
from django.core.cache import cache
cache_key = f'pendencias_assinatura_user_{request.user.pk}'
cached = cache.get(cache_key)
if cached is None:
try:
from django.db.models import Q
from django.urls import reverse
from sapl.materia.models import MateriaLegislativa
from sapl.base.models import OperadorAutor
# Busca o Autor vinculado ao usuário
try:
autor = OperadorAutor.objects.get(user=request.user).autor
autor_pk = autor.pk
except OperadorAutor.DoesNotExist:
autor_pk = None
if autor_pk:
total = MateriaLegislativa.objects.filter(
autoria__autor_id=autor_pk,
texto_original__isnull=False,
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).distinct().count()
url = (
reverse('sapl.materia:pesquisar_materia')
+ f'?autoria__autor={autor_pk}&status_assinatura=pendente'
)
else:
total = 0
url = ''
except Exception:
total = 0
url = ''
cached = {'total': total, 'url': url}
cache.set(cache_key, cached, 120) # cache de 2 minutos
return {
'pendencias_assinatura_total': cached['total'],
'pendencias_assinatura_url': cached['url'],
}
@cached_call("site-title", timeout=60 * 2) @cached_call("site-title", timeout=60 * 2)
def enable_sapn(request): def enable_sapn(request):
verbose_name = _('SGVP') \ verbose_name = _('SGVP') \

345
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}'

110
sapl/materia/forms.py

@ -45,7 +45,7 @@ from sapl.utils import (autor_label, autor_modal, timing,
GoogleRecapthaMixin, get_client_ip) GoogleRecapthaMixin, get_client_ip)
from sapl.utils_template import adicionar_cabecalho_materia from sapl.utils_template import adicionar_cabecalho_materia
from .models import (AcompanhamentoMateria, Anexada, Autoria, from .models import (AcompanhamentoMateria, Anexada, Autoria, AutoriaProposicao,
DespachoInicial, DocumentoAcessorio, Numeracao, DespachoInicial, DocumentoAcessorio, Numeracao,
Proposicao, Relatoria, TipoMateriaLegislativa, Proposicao, Relatoria, TipoMateriaLegislativa,
Tramitacao, UnidadeTramitacao) Tramitacao, UnidadeTramitacao)
@ -979,12 +979,26 @@ class AnexadaForm(ModelForm):
fields = ['tipo', 'numero', 'ano', 'data_anexacao', 'data_desanexacao'] fields = ['tipo', 'numero', 'ano', 'data_anexacao', 'data_desanexacao']
CHOICE_STATUS_ASSINATURA = [
('', _('Todas')),
('pendente', _('⚠ Pendente de Assinatura')),
('assinada', _('✔ Assinada')),
]
class MateriaLegislativaFilterSet(django_filters.FilterSet): class MateriaLegislativaFilterSet(django_filters.FilterSet):
ano = django_filters.ChoiceFilter(required=False, ano = django_filters.ChoiceFilter(required=False,
label='Ano da Matéria', label='Ano da Matéria',
choices=choice_anos_com_materias) choices=choice_anos_com_materias)
status_assinatura = django_filters.ChoiceFilter(
required=False,
label=_('Status de Assinatura'),
choices=CHOICE_STATUS_ASSINATURA,
method='filter_status_assinatura'
)
autoria__autor = django_filters.CharFilter(widget=forms.HiddenInput()) autoria__autor = django_filters.CharFilter(widget=forms.HiddenInput())
autoria__primeiro_autor = django_filters.BooleanFilter( autoria__primeiro_autor = django_filters.BooleanFilter(
@ -1074,6 +1088,17 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet):
self.filters['o'].label = _('Ordenação') self.filters['o'].label = _('Ordenação')
self.form.fields['tipo_listagem'] = self.tipo_listagem self.form.fields['tipo_listagem'] = self.tipo_listagem
row_assinatura = to_row([
(HTML('''
<div class="alert alert-warning d-flex align-items-center py-2 mb-0"
style="border-left: 4px solid #f0ad4e; background:#fffbf0;">
<span class="mr-2" style="font-size:1.2em;"></span>
<strong class="mr-2">Assinatura Digital:</strong>
</div>
'''), 3),
('status_assinatura', 4),
])
row1 = to_row( row1 = to_row(
[('tipo', 5), ('ementa', 7)]) [('tipo', 5), ('ementa', 7)])
row2 = to_row( row2 = to_row(
@ -1131,6 +1156,7 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet):
HTML(autor_label), HTML(autor_label),
HTML(autor_modal), HTML(autor_modal),
row4, row4,
row_assinatura,
), ),
Button('btn_pesquisa_avancada', 'Pesquisa Avançada >>>', Button('btn_pesquisa_avancada', 'Pesquisa Avançada >>>',
css_id='btn_pesquisa_avancada_id', css_id='btn_pesquisa_avancada_id',
@ -1158,6 +1184,30 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet):
) )
) )
def filter_status_assinatura(self, queryset, name, value):
if value == 'pendente':
# Tem texto original mas não tem PDF assinado
return queryset.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
pdf_assinado__isnull=True
) | queryset.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
pdf_assinado=''
)
elif value == 'assinada':
return queryset.exclude(
pdf_assinado__isnull=True
).exclude(
pdf_assinado=''
)
return queryset
@property @property
def qs(self): def qs(self):
qs = qs_override_django_filter(self) qs = qs_override_django_filter(self)
@ -1949,6 +1999,19 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
numero_materia_futuro = forms.IntegerField( numero_materia_futuro = forms.IntegerField(
label='Número (Opcional)', required=False) label='Número (Opcional)', required=False)
coautores = forms.ModelMultipleChoiceField(
label=_('Co-autores'),
required=False,
queryset=Autor.objects.all(),
widget=forms.SelectMultiple(attrs={
'class': 'select2-coautores',
'style': 'width: 100%',
'data-placeholder': _('Selecione os co-autores...')
}),
help_text=_('Selecione os demais autores deste documento. '
'Eles serão adicionados como co-autores ao incorporar a proposição.')
)
class Meta: class Meta:
model = Proposicao model = Proposicao
fields = ['tipo', fields = ['tipo',
@ -1994,6 +2057,7 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
dismiss=False), 12)), dismiss=False), 12)),
to_column(('descricao', 12)), to_column(('descricao', 12)),
to_column(('observacao', 12)), to_column(('observacao', 12)),
to_column(('coautores', 12)),
] ]
@ -2131,6 +2195,13 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
'ano_materia' 'ano_materia'
].initial = self.instance.materia_de_vinculo.ano ].initial = self.instance.materia_de_vinculo.ano
# Pré-popular co-autores existentes
coautores_pks = list(
self.instance.coautores.values_list('autor_id', flat=True)
)
if coautores_pks:
self.fields['coautores'].initial = coautores_pks
def clean_texto_original(self): def clean_texto_original(self):
texto_original = self.cleaned_data.get('texto_original', False) texto_original = self.cleaned_data.get('texto_original', False)
@ -2222,7 +2293,9 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
inst.texto_original.delete() inst.texto_original.delete()
self.gerar_hash(inst, receber_recibo) self.gerar_hash(inst, receber_recibo)
return super().save(commit) result = super().save(commit)
self._salvar_coautores(result)
return result
inst.ano = timezone.now().year inst.ano = timezone.now().year
sequencia_numeracao = BaseAppConfig.attr( sequencia_numeracao = BaseAppConfig.attr(
@ -2241,9 +2314,27 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
self.gerar_hash(inst, receber_recibo) self.gerar_hash(inst, receber_recibo)
inst.save() inst.save()
self._salvar_coautores(inst)
return inst return inst
def _salvar_coautores(self, inst):
"""Sincroniza os co-autores selecionados no formulário com AutoriaProposicao."""
coautores = self.cleaned_data.get('coautores', [])
# Remove co-autores não mais selecionados
inst.coautores.exclude(autor__in=coautores).delete()
# Adiciona novos co-autores
autores_existentes = set(
inst.coautores.values_list('autor_id', flat=True)
)
for autor in coautores:
if autor.pk not in autores_existentes:
AutoriaProposicao.objects.create(
proposicao=inst,
autor=autor,
primeiro_autor=False
)
class DevolverProposicaoForm(forms.ModelForm): class DevolverProposicaoForm(forms.ModelForm):
@ -2746,6 +2837,21 @@ class ConfirmarProposicaoForm(ProposicaoForm):
'Autoria registrada para (%s)' 'Autoria registrada para (%s)'
) % str(autoria.autor)) ) % str(autoria.autor))
# Transferir co-autores da proposição para Autoria da matéria
for coautoria in proposicao.coautores.all():
# Não duplicar se o co-autor for o mesmo que o autor principal
if coautoria.autor != proposicao.autor:
Autoria.objects.get_or_create(
autor=coautoria.autor,
materia=materia,
defaults={
'primeiro_autor': coautoria.primeiro_autor
}
)
self.instance.results['messages']['success'].append(_(
'Co-autoria registrada para (%s)'
) % str(coautoria.autor))
# Transferir anexos da proposição para DocumentoAcessorio # Transferir anexos da proposição para DocumentoAcessorio
from sapl.materia.models import AnexoProposicao from sapl.materia.models import AnexoProposicao
anexos = AnexoProposicao.objects.filter( anexos = AnexoProposicao.objects.filter(

199
sapl/materia/management/commands/notificar_pendentes_assinatura.py

@ -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
)
))

30
sapl/materia/migrations/0095_add_autoria_proposicao.py

@ -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')},
},
),
]

21
sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py

@ -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'},
),
]

42
sapl/materia/models.py

@ -345,7 +345,10 @@ class MateriaLegislativa(models.Model):
verbose_name_plural = _('Matérias Legislativas') verbose_name_plural = _('Matérias Legislativas')
unique_together = (("tipo", "numero", "ano"),) unique_together = (("tipo", "numero", "ano"),)
ordering = ['-ano', 'tipo', 'numero'] ordering = ['-ano', 'tipo', 'numero']
permissions = (("can_access_impressos", "Can access impressos"),) permissions = (
("can_access_impressos", "Can access impressos"),
("can_remove_assinatura", "Pode remover assinatura digital"),
)
def __str__(self): def __str__(self):
return _('%(tipo)s%(numero)s de %(ano)s') % { return _('%(tipo)s%(numero)s de %(ano)s') % {
@ -634,6 +637,9 @@ class DocumentoAcessorio(models.Model):
verbose_name = _('Documento Acessório') verbose_name = _('Documento Acessório')
verbose_name_plural = _('Documentos Acessórios') verbose_name_plural = _('Documentos Acessórios')
ordering = ('data', 'id') ordering = ('data', 'id')
permissions = (
("can_remove_assinatura_doc", "Pode remover assinatura digital de documento acessório"),
)
def __str__(self): def __str__(self):
return _('%(tipo)s - %(nome)s de %(data)s por %(autor)s') % { return _('%(tipo)s - %(nome)s de %(data)s por %(autor)s') % {
@ -1158,6 +1164,40 @@ class Proposicao(models.Model):
update_fields=update_fields) update_fields=update_fields)
class AutoriaProposicao(models.Model):
"""
Modelo para co-autores de uma Proposição.
Permite que o autor da proposição indique múltiplos co-autores
que serão transferidos para a Autoria da matéria ao incorporar.
"""
proposicao = models.ForeignKey(
Proposicao,
on_delete=models.CASCADE,
verbose_name=_('Proposição'),
related_name='coautores'
)
autor = models.ForeignKey(
Autor,
on_delete=models.PROTECT,
verbose_name=_('Co-autor')
)
primeiro_autor = models.BooleanField(
verbose_name=_('Primeiro Autor'),
choices=YES_NO_CHOICES,
default=False
)
class Meta:
verbose_name = _('Co-autoria da Proposição')
verbose_name_plural = _('Co-autorias da Proposição')
unique_together = (('proposicao', 'autor'),)
ordering = ('-primeiro_autor', 'autor__nome')
def __str__(self):
return _('Co-autoria: %(autor)s - %(proposicao)s') % {
'autor': self.autor, 'proposicao': self.proposicao}
class HistoricoProposicao(models.Model): class HistoricoProposicao(models.Model):
STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')), STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')),
('R', 'RECEBIDA', _('Recebida')), ('R', 'RECEBIDA', _('Recebida')),

207
sapl/materia/onlyoffice_materia_views.py

@ -821,3 +821,210 @@ def materia_gerar_pdf_assinatura(request, pk):
logger.error(f"Erro inesperado na geração de PDF: {e}") logger.error(f"Erro inesperado na geração de PDF: {e}")
messages.error(request, 'Erro inesperado ao gerar o PDF.') messages.error(request, 'Erro inesperado ao gerar o PDF.')
return redirect('sapl.materia:materialegislativa_detail', pk=pk) return redirect('sapl.materia:materialegislativa_detail', pk=pk)
# ============================================================
# Prévia de PDF para Documento Acessório
# ============================================================
# ============================================================
# Prévia de PDF para Documento Acessório
# ============================================================
@login_required
@require_http_methods(["GET"])
def materia_gerar_pdf_previa(request, pk):
"""
Gera o PDF da matéria para prévia antes da assinatura.
Diferente do materia_gerar_pdf_assinatura, não exige numero_protocolo.
Se for PDF, retorna direto. Caso seja DOCX, converte via OnlyOffice.
"""
import requests as http_requests
import xml.etree.ElementTree as ET
materia = get_object_or_404(MateriaLegislativa, pk=pk)
if not materia.texto_original:
return HttpResponse('Matéria não possui documento de texto original.', status=404)
file_name = materia.texto_original.name.lower()
if file_name.endswith('.pdf'):
try:
with open(materia.texto_original.path, 'rb') as f:
content = f.read()
response = HttpResponse(content, content_type='application/pdf')
response['Content-Disposition'] = (
f'inline; filename="Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf"'
)
return response
except Exception as e:
logger.error(f"Erro ao ler PDF da materia {pk}: {e}")
return HttpResponse('Erro ao ler o arquivo PDF.', status=500)
download_url = build_onlyoffice_url(
request,
reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': pk})
)
conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx'
conversion_data = {
"async": False,
"filetype": "docx",
"key": generate_file_key("materia_previa", pk, request.user.pk),
"outputtype": "pdf",
"title": f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf",
"url": download_url,
}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
conversion_data['token'] = token
try:
headers = {'Content-Type': 'application/json'}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
headers['Authorization'] = f'Bearer {header_token}'
conversion_response = http_requests.post(
conversion_url,
json=conversion_data,
headers=headers,
timeout=60
)
if conversion_response.status_code != 200:
return HttpResponse('Erro ao converter o documento.', status=500)
try:
root = ET.fromstring(conversion_response.text)
except ET.ParseError as e:
logger.error(f"Erro ao parsear resposta XML (materia previa): {e}")
return HttpResponse('Erro ao processar resposta do serviço.', status=500)
error_elem = root.find('Error')
if error_elem is not None:
return HttpResponse(f'Erro na conversão: {error_elem.text}', status=500)
file_url_elem = root.find('FileUrl')
if file_url_elem is None or not file_url_elem.text:
return HttpResponse('URL do PDF não retornada.', status=500)
pdf_response = http_requests.get(file_url_elem.text, timeout=60)
if pdf_response.status_code != 200:
return HttpResponse('Erro ao baixar PDF convertido.', status=500)
filename = f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf"
filename = filename.replace(' ', '_').replace('/', '-')
response = HttpResponse(pdf_response.content, content_type='application/pdf')
response['Content-Disposition'] = f'inline; filename="{filename}"'
return response
except http_requests.exceptions.Timeout:
logger.error(f"Timeout na conversão OnlyOffice (materia previa pk={pk})")
return HttpResponse('Tempo limite excedido ao converter o documento.', status=504)
except http_requests.exceptions.ConnectionError:
logger.error(f"Erro de conexão com OnlyOffice (materia previa pk={pk})")
return HttpResponse('Não foi possível conectar ao serviço de conversão.', status=502)
except Exception as e:
logger.error(f"Erro inesperado na previa PDF (materia pk={pk}): {e}")
return HttpResponse('Erro inesperado ao gerar o PDF.', status=500)
@login_required
@require_http_methods(["GET"])
def docacessorio_gerar_pdf_previa(request, pk):
"""
Gera o PDF do documento acessório para prévia antes da assinatura.
Se for PDF, retorna direto. Caso seja DOCX, converte via OnlyOffice.
"""
import requests as http_requests
import xml.etree.ElementTree as ET
docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk)
if not docacessorio.arquivo:
return HttpResponse('Documento sem arquivo.', status=404)
file_name = docacessorio.arquivo.name.lower()
if file_name.endswith('.pdf'):
try:
with open(docacessorio.arquivo.path, 'rb') as f:
content = f.read()
response = HttpResponse(content, content_type='application/pdf')
response['Content-Disposition'] = f'inline; filename="DocAcessorio_{pk}.pdf"'
return response
except Exception as e:
logger.error(f"Erro ao ler arquivo PDF do doc acessorio: {e}")
return HttpResponse('Erro ao ler o arquivo PDF.', status=500)
download_url = build_onlyoffice_url(
request,
reverse('sapl.materia:docacessorio_onlyoffice_download', kwargs={'pk': pk})
)
conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx'
conversion_data = {
"async": False,
"filetype": "docx",
"key": generate_file_key("docacessorio_pdf", pk, request.user.pk),
"outputtype": "pdf",
"title": f"DocAcessorio_{pk}.pdf",
"url": download_url,
}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
conversion_data['token'] = token
try:
headers = {'Content-Type': 'application/json'}
if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None):
import jwt
header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
headers['Authorization'] = f'Bearer {header_token}'
conversion_response = http_requests.post(
conversion_url,
json=conversion_data,
headers=headers,
timeout=60
)
if conversion_response.status_code != 200:
return HttpResponse('Erro ao converter o documento.', status=500)
try:
root = ET.fromstring(conversion_response.text)
except ET.ParseError as e:
logger.error(f"Erro ao parsear resposta XML: {e}")
return HttpResponse('Erro ao processar resposta do serviço.', status=500)
error_elem = root.find('Error')
if error_elem is not None:
return HttpResponse(f'Erro na conversão: {error_elem.text}', status=500)
file_url_elem = root.find('FileUrl')
if file_url_elem is None or not file_url_elem.text:
return HttpResponse('URL do PDF não retornada.', status=500)
pdf_response = http_requests.get(file_url_elem.text, timeout=60)
if pdf_response.status_code != 200:
return HttpResponse('Erro ao baixar PDF convertido.', status=500)
response = HttpResponse(pdf_response.content, content_type='application/pdf')
response['Content-Disposition'] = f'inline; filename="DocAcessorio_{pk}.pdf"'
return response
except Exception as e:
logger.error(f"Erro inesperado na previa PDF (docacessorio): {e}")
return HttpResponse('Erro inesperado ao gerar o PDF.', status=500)

36
sapl/materia/urls.py

@ -16,6 +16,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
LegislacaoCitadaCrud, MateriaAssuntoCrud, LegislacaoCitadaCrud, MateriaAssuntoCrud,
MateriaLegislativaCrud, MateriaLegislativaCrud,
MateriaLegislativaPesquisaView, MateriaTaView, MateriaLegislativaPesquisaView, MateriaTaView,
MateriasPendentesAssinaturaView,
NumeracaoCrud, OrgaoCrud, OrigemCrud, NumeracaoCrud, OrgaoCrud, OrigemCrud,
PrimeiraTramitacaoEmLoteView, ProposicaoCrud, PrimeiraTramitacaoEmLoteView, ProposicaoCrud,
ProposicaoDevolvida, ProposicaoPendente, ProposicaoDevolvida, ProposicaoPendente,
@ -33,6 +34,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
MateriaPesquisaSimplesView, MateriaPesquisaSimplesView,
DespachoInicialMultiCreateView, DespachoInicialMultiCreateView,
get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo, get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo,
get_pdf_multiplos,
configEtiquetaMateriaLegislativaCrud, configEtiquetaMateriaLegislativaCrud,
PesquisarStatusTramitacaoView, HistoricoProposicaoView) PesquisarStatusTramitacaoView, HistoricoProposicaoView)
from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download, from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download,
@ -46,15 +48,17 @@ from sapl.materia.onlyoffice_materia_views import (
docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config, docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config,
docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback, docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback,
docacessorio_check_doc, docacessorio_forcesave, docacessorio_check_doc, docacessorio_forcesave,
materia_gerar_pdf_assinatura materia_gerar_pdf_assinatura, materia_gerar_pdf_previa, docacessorio_gerar_pdf_previa
) )
from sapl.materia.views_assinatura import ( from sapl.materia.views_assinatura import (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura, materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura,
detectar_aplicacao_a3, detectar_aplicacao_a3, assinatura_api_status, materia_assinar_lote,
docacessorio_assinar_a1, docacessorio_pdf_assinado, docacessorio_assinar_a1, docacessorio_pdf_assinado,
docacessorio_verificar_assinatura, docacessorio_remover_assinatura, docacessorio_verificar_assinatura, docacessorio_remover_assinatura,
materia_verificar_documento, docacessorio_verificar_documento materia_verificar_documento, docacessorio_verificar_documento,
docacessorio_assinar_lote,
materia_pagina_assinatura_png, docacessorio_pagina_assinatura_png,
) )
from sapl.norma.views import NormaPesquisaSimplesView from sapl.norma.views import NormaPesquisaSimplesView
from sapl.protocoloadm.views import ( from sapl.protocoloadm.views import (
@ -122,6 +126,10 @@ urlpatterns_materia = [
url(r'^materia/pesquisar-materia$', url(r'^materia/pesquisar-materia$',
MateriaLegislativaPesquisaView.as_view(), name='pesquisar_materia'), MateriaLegislativaPesquisaView.as_view(), name='pesquisar_materia'),
url(r'^materia/pendentes-assinatura$',
MateriasPendentesAssinaturaView.as_view(), name='materias_pendentes_assinatura'),
url(r'^materia/(?P<pk>\d+)/acompanhar-materia/$', url(r'^materia/(?P<pk>\d+)/acompanhar-materia/$',
AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'), AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'),
url(r'^materia/(?P<pk>\d+)/acompanhar-confirmar$', url(r'^materia/(?P<pk>\d+)/acompanhar-confirmar$',
@ -158,6 +166,8 @@ urlpatterns_materia = [
name='zip_completo_materia'), name='zip_completo_materia'),
url(r'^materia/pdf-completo/(?P<pk>\d+)$', get_pdf_completo, url(r'^materia/pdf-completo/(?P<pk>\d+)$', get_pdf_completo,
name='pdf_completo_materia'), name='pdf_completo_materia'),
url(r'^materia/pdf-multiplos/$', get_pdf_multiplos,
name='pdf_multiplos_materias'),
# OnlyOffice endpoints para Matéria Legislativa # OnlyOffice endpoints para Matéria Legislativa
url(r'^materia/(?P<pk>\d+)/onlyoffice/editor$', materia_onlyoffice_editor, url(r'^materia/(?P<pk>\d+)/onlyoffice/editor$', materia_onlyoffice_editor,
@ -175,6 +185,10 @@ urlpatterns_materia = [
url(r'^materia/(?P<pk>\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura, url(r'^materia/(?P<pk>\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura,
name='materia_pdf_assinatura'), name='materia_pdf_assinatura'),
# Prévia de PDF da Matéria antes da assinatura (sem restrição de protocolo)
url(r'^materia/(?P<pk>\d+)/pdf-previa$', materia_gerar_pdf_previa,
name='materia_pdf_previa'),
# Assinatura Digital de Matéria Legislativa # Assinatura Digital de Matéria Legislativa
url(r'^materia/(?P<pk>\d+)/assinar/a1/$', materia_assinar_a1, url(r'^materia/(?P<pk>\d+)/assinar/a1/$', materia_assinar_a1,
name='materia_assinar_a1'), name='materia_assinar_a1'),
@ -182,6 +196,8 @@ urlpatterns_materia = [
name='materia_assinar_a3_preparar'), name='materia_assinar_a3_preparar'),
url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar, url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar,
name='materia_assinar_a3_finalizar'), name='materia_assinar_a3_finalizar'),
url(r'^materia/assinar-em-lote/$', materia_assinar_lote,
name='materia_assinar_lote'),
url(r'^materia/(?P<pk>\d+)/pdf-assinado/$', materia_pdf_assinado, url(r'^materia/(?P<pk>\d+)/pdf-assinado/$', materia_pdf_assinado,
name='materia_pdf_assinado'), name='materia_pdf_assinado'),
url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura, url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura,
@ -190,6 +206,12 @@ urlpatterns_materia = [
name='materia_remover_assinatura'), name='materia_remover_assinatura'),
url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3, url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3,
name='detectar_aplicacao_a3'), name='detectar_aplicacao_a3'),
url(r'^materia/assinatura/api-status/$', assinatura_api_status,
name='assinatura_api_status'),
url(r'^materia/(?P<pk>\d+)/assinatura/pagina-preview/$', materia_pagina_assinatura_png,
name='materia_pagina_assinatura_png'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinatura/pagina-preview/$', docacessorio_pagina_assinatura_png,
name='docacessorio_pagina_assinatura_png'),
# Verificação pública de autenticidade (sem login) # Verificação pública de autenticidade (sem login)
url(r'^materia/(?P<pk>\d+)/verificar/$', materia_verificar_documento, url(r'^materia/(?P<pk>\d+)/verificar/$', materia_verificar_documento,
@ -209,6 +231,10 @@ urlpatterns_materia = [
url(r'^materia/documentoacessorio/(?P<pk>\d+)/forcesave$', docacessorio_forcesave, url(r'^materia/documentoacessorio/(?P<pk>\d+)/forcesave$', docacessorio_forcesave,
name='docacessorio_forcesave'), name='docacessorio_forcesave'),
# Prévia de PDF do Documento Acessório antes da assinatura
url(r'^materia/documentoacessorio/(?P<pk>\d+)/pdf-previa$', docacessorio_gerar_pdf_previa,
name='docacessorio_pdf_previa'),
# Assinatura Digital de Documento Acessório # Assinatura Digital de Documento Acessório
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinar/a1/$', docacessorio_assinar_a1, url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinar/a1/$', docacessorio_assinar_a1,
name='docacessorio_assinar_a1'), name='docacessorio_assinar_a1'),
@ -219,6 +245,10 @@ urlpatterns_materia = [
url(r'^materia/documentoacessorio/(?P<pk>\d+)/remover-assinatura/$', docacessorio_remover_assinatura, url(r'^materia/documentoacessorio/(?P<pk>\d+)/remover-assinatura/$', docacessorio_remover_assinatura,
name='docacessorio_remover_assinatura'), name='docacessorio_remover_assinatura'),
# Assinatura em Lote de Documentos Acessórios
url(r'^materia/documentoacessorio/assinar-em-lote/$', docacessorio_assinar_lote,
name='docacessorio_assinar_lote'),
# Verificação pública de autenticidade de Documento Acessório (sem login) # Verificação pública de autenticidade de Documento Acessório (sem login)
url(r'^materia/documentoacessorio/(?P<pk>\d+)/verificar/$', docacessorio_verificar_documento, url(r'^materia/documentoacessorio/(?P<pk>\d+)/verificar/$', docacessorio_verificar_documento,
name='docacessorio_verificar_documento'), name='docacessorio_verificar_documento'),

186
sapl/materia/views.py

@ -15,7 +15,7 @@ from crispy_forms.layout import Div, HTML, Submit
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError
from django.db.models import Max, Q from django.db.models import Max, Q
@ -38,7 +38,7 @@ from django.utils.decorators import method_decorator
import sapl import sapl
from sapl.base.email_utils import do_envia_email_confirmacao from sapl.base.email_utils import do_envia_email_confirmacao
from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig, OperadorAutor
from sapl.comissoes.models import Participacao from sapl.comissoes.models import Participacao
from sapl.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE from sapl.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE
from sapl.compilacao.views import IntegracaoTaView from sapl.compilacao.views import IntegracaoTaView
@ -641,6 +641,53 @@ class ProposicaoPendenteSetor(PermissionRequiredMixin, ListView):
return context return context
class MateriasPendentesAssinaturaView(LoginRequiredMixin, ListView):
template_name = 'materia/materias_pendentes_assinatura_list.html'
model = MateriaLegislativa
paginate_by = 20
login_url = '/login/'
def get_autor(self):
try:
return OperadorAutor.objects.get(user=self.request.user).autor
except OperadorAutor.DoesNotExist:
return None
def get_queryset(self):
autor = self.get_autor()
qs = MateriaLegislativa.objects.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
)
if autor:
qs = qs.filter(autoria__autor=autor)
return qs.order_by('-data_apresentacao', '-id').distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
autor = self.get_autor()
context['autor'] = autor
paginator = context['paginator']
page_obj = context['page_obj']
context['page_range'] = make_pagination(page_obj.number, paginator.num_pages)
context['total'] = paginator.count
# URL para pesquisar todas filtrando por autor + pendente
if autor:
context['url_pesquisa_completa'] = (
reverse('sapl.materia:pesquisar_materia')
+ f'?autoria__autor={autor.pk}&status_assinatura=pendente'
)
else:
context['url_pesquisa_completa'] = (
reverse('sapl.materia:pesquisar_materia')
+ '?status_assinatura=pendente'
)
return context
class RevisarProposicaoSetor(PermissionRequiredMixin, UpdateView): class RevisarProposicaoSetor(PermissionRequiredMixin, UpdateView):
template_name = "materia/revisar_proposicao_setor.html" template_name = "materia/revisar_proposicao_setor.html"
model = Proposicao model = Proposicao
@ -1949,13 +1996,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
) )
# Normaliza assinatura_info para lista (backward-compatible) # Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info) assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas context['assinaturas'] = assinaturas
context['ja_assinou'] = any( context['ja_assinou'] = any(
a.get('signed_by') == u.username a.get('signed_by') == u.username
for a in assinaturas for a in assinaturas
) if u.is_authenticated else False ) if u.is_authenticated else False
context['pode_remover_assinatura'] = (
u.is_authenticated and _pode_remover_assinatura(u)
)
return context return context
@ -1969,6 +2019,29 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
u.has_perm('materia.add_documentoacessorio') u.has_perm('materia.add_documentoacessorio')
) )
context['tipos_documento'] = TipoDocumento.objects.all() context['tipos_documento'] = TipoDocumento.objects.all()
# Documentos acessórios pendentes de assinatura para o lote
pode_assinar_lote = u.is_authenticated and (
u.is_superuser or
u.has_perm('materia.change_documentoacessorio')
)
if not pode_assinar_lote:
pode_assinar_lote = u.is_authenticated and OperadorAutor.objects.filter(user=u).exists()
if pode_assinar_lote:
materia_pk = self.kwargs.get('pk') or self.kwargs.get('root_pk')
qs_pendentes = DocumentoAcessorio.objects.filter(
materia__pk=materia_pk,
pdf_assinado='',
).order_by('data', 'nome')
docs_lote = [
{'id': d.pk, 'descricao': f'{d.nome} ({d.tipo}) — {d.data}'}
for d in qs_pendentes
]
context['docs_pendentes_lote'] = docs_lote
else:
context['docs_pendentes_lote'] = []
return context return context
def hook_arquivo(self, obj, default, url): def hook_arquivo(self, obj, default, url):
@ -2482,13 +2555,17 @@ class MateriaLegislativaCrud(Crud):
) )
# Normaliza assinatura_info para lista (backward-compatible) # Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info) assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas context['assinaturas'] = assinaturas
context['ja_assinou'] = any( context['ja_assinou'] = any(
a.get('signed_by') == self.request.user.username a.get('signed_by') == self.request.user.username
for a in assinaturas for a in assinaturas
) if self.request.user.is_authenticated else False ) if self.request.user.is_authenticated else False
context['pode_remover_assinatura'] = (
self.request.user.is_authenticated and
_pode_remover_assinatura(self.request.user)
)
return context return context
@ -2742,6 +2819,33 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
context['show_results'] = show_results_filter_set(qr) context['show_results'] = show_results_filter_set(qr)
# Matérias pendentes de assinatura para o botão de lote
status_assinatura = self.request.GET.get('status_assinatura')
if status_assinatura == 'pendente' and context['show_results']:
from django.db.models import Q as _Q
# object_list já foi filtrado pelo filter_status_assinatura —
# precisamos obter os IDs primeiro para evitar problemas com
# querysets compostos por union (|) que não suportam .filter() extra
try:
ids_lote = list(self.object_list.values_list('id', flat=True)[:200])
from .models import MateriaLegislativa
qs_lote = MateriaLegislativa.objects.filter(
pk__in=ids_lote,
texto_original__isnull=False,
).exclude(texto_original='').filter(
_Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='')
).select_related('tipo').values_list(
'id', 'tipo__sigla', 'numero', 'ano'
)
context['materias_pendentes_lote'] = [
{'id': pk, 'descricao': f'{sigla} {numero}/{ano}'}
for pk, sigla, numero, ano in qs_lote
]
except Exception:
context['materias_pendentes_lote'] = []
else:
context['materias_pendentes_lote'] = []
return context return context
@ -3810,3 +3914,77 @@ def configEtiquetaMateriaLegislativaCrud(request):
else: else:
form = ConfigEtiquetaMateriaLegislativaForms(instance=config) form = ConfigEtiquetaMateriaLegislativaForms(instance=config)
return render(request, 'materia/config_etiqueta_materia.html', {'form': form}) return render(request, 'materia/config_etiqueta_materia.html', {'form': form})
def get_pdf_multiplos(request):
"""
Gera PDF unificado com os documentos das matérias informadas via
GET ?ids=1,2,3 ou POST body JSON {"ids": [1,2,3]}.
Retorna o PDF inline para impressão direta no browser.
Limite: 50 matérias por chamada.
"""
logger_local = logging.getLogger(__name__)
username = 'Usuário anônimo' if request.user.is_anonymous else request.user.username
if request.method == 'POST':
import json as _json
try:
body = _json.loads(request.body)
ids_raw = body.get('ids', '')
except Exception:
ids_raw = request.POST.get('ids', '')
else:
ids_raw = request.GET.get('ids', '')
try:
import json as _json
if isinstance(ids_raw, list):
ids = [int(i) for i in ids_raw]
elif isinstance(ids_raw, str) and ids_raw.startswith('['):
ids = [int(i) for i in _json.loads(ids_raw)]
else:
ids = [int(i.strip()) for i in str(ids_raw).split(',') if i.strip().isdigit()]
except Exception:
return JsonResponse({'error': 'IDs invalidos'}, status=400)
if not ids:
return JsonResponse({'error': 'Nenhum ID informado'}, status=400)
ids = ids[:50]
MEDIA_ROOT_local = settings.MEDIA_ROOT
materias = MateriaLegislativa.objects.filter(pk__in=ids).select_related('tipo')
pdf_files = []
for materia in materias:
if materia.pdf_assinado:
f = os.path.join(MEDIA_ROOT_local, str(materia.pdf_assinado))
if os.path.exists(f) and f.lower().endswith('.pdf'):
pdf_files.append(f)
continue
if materia.texto_original:
f = os.path.join(MEDIA_ROOT_local, str(materia.texto_original))
if os.path.exists(f) and f.lower().endswith('.pdf'):
pdf_files.append(f)
if not pdf_files:
return JsonResponse({'error': 'Nenhum PDF disponivel para as materias selecionadas.'}, status=404)
try:
merger = PdfFileMerger(strict=False)
for f in pdf_files:
merger.append(fileobj=f)
data = BytesIO()
merger.write(data)
merger.close()
pdf_bytes = data.getvalue()
except Exception as e:
logger_local.error("user={}. Erro ao gerar PDF multiplos: {}".format(username, str(e)))
return JsonResponse({'error': 'Erro ao gerar PDF: ' + str(e)}, status=500)
logger_local.info("user={}. Gerou PDF multiplos ({} materias, {} PDFs)".format(
username, len(materias), len(pdf_files)))
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = 'inline; filename="materias_selecionadas.pdf"'
return response

1841
sapl/materia/views_assinatura.py

File diff suppressed because it is too large

12
sapl/settings.py

@ -34,8 +34,8 @@ PROJECT_DIR = Path(__file__).ancestor(2)
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234') SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
#DEBUG = config('DEBUG', default=False, cast=bool) # Aceita tanto DEBUG quanto DJANGO_DEBUG (compatibilidade com docker-compose legado)
DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) DEBUG = config('DEBUG', default=False, cast=bool) or config('DJANGO_DEBUG', default=False, cast=bool)
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
@ -234,6 +234,7 @@ TEMPLATES = [
'sapl.context_processors.mail_service_configured', 'sapl.context_processors.mail_service_configured',
'sapl.context_processors.google_recaptcha_configured', 'sapl.context_processors.google_recaptcha_configured',
'sapl.context_processors.enable_sapn', 'sapl.context_processors.enable_sapn',
'sapl.context_processors.pendencias_assinatura',
], ],
'debug': DEBUG 'debug': DEBUG
}, },
@ -317,6 +318,13 @@ ONLYOFFICE_JWT_ENABLED = config('ONLYOFFICE_JWT_ENABLED', cast=bool, default=Fal
# consegue acessar o SAPL pela mesma URL que o navegador) # consegue acessar o SAPL pela mesma URL que o navegador)
SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='') 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 # Integração externa de matérias da sessão
SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='') SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='')
SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='') SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='')

293
sapl/static/js/materia_impressao_em_massa.js

@ -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>&times;</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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Arranque ──────────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

22
sapl/static/js/materia_pesquisa_download_pdfs.js

@ -208,5 +208,27 @@
downloadAllPDFs(); downloadAllPDFs();
}); });
} }
// ── Filtro rápido de assinatura ──────────────────────────────────
const selectAssinatura = document.getElementById('select-status-assinatura');
if (selectAssinatura) {
selectAssinatura.addEventListener('change', function () {
const status = this.value;
const url = new URL(window.location.href);
const params = url.searchParams;
// Volta sempre para a página 1
params.delete('page');
if (status === '') {
params.delete('status_assinatura');
} else {
params.set('status_assinatura', status);
}
window.location.href = url.pathname + '?' + params.toString();
});
}
// ─────────────────────────────────────────────────────────────────
}); });
})(); })();

13
sapl/templates/base.html

@ -74,6 +74,19 @@
{% block sections_navbar %} {% navbar 'navbar.yaml' %} {% endblock sections_navbar %} {% block sections_navbar %} {% navbar 'navbar.yaml' %} {% endblock sections_navbar %}
<ul class="nav navbar-nav justify-content-end" id="autenticacao"> <ul class="nav navbar-nav justify-content-end" id="autenticacao">
{% if user.is_authenticated and pendencias_assinatura_total > 0 %}
<li class="nav-item">
<a class="nav-link position-relative"
href="{{ pendencias_assinatura_url }}"
title="{{ pendencias_assinatura_total }} matéria(s) pendente(s) de assinatura">
<i class="fa fa-bell text-warning" style="font-size:1.2rem;"></i>
<span class="badge badge-danger"
style="position:absolute;top:4px;right:2px;font-size:.65rem;padding:2px 5px;border-radius:10px;">
{{ pendencias_assinatura_total }}
</span>
</a>
</li>
{% endif %}
{% if not user.is_authenticated %} {% if not user.is_authenticated %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link d-flex align-items-center" href="{% url 'sapl.base:login' %}"> <a class="nav-link d-flex align-items-center" href="{% url 'sapl.base:login' %}">

1
sapl/templates/base/layouts.yaml

@ -48,6 +48,7 @@ AppConfig:
{% trans 'Módulo Assinatura Digital' %}: {% trans 'Módulo Assinatura Digital' %}:
- assinatura_nome - assinatura_nome
- permite_remover_assinatura
{% trans 'Módulo Painel' %}: {% trans 'Módulo Painel' %}:
- cronometro_discurso cronometro_aparte - cronometro_discurso cronometro_aparte

77
sapl/templates/email/pendentes_assinatura.html

@ -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">
&#128276; {{ 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 &#8594;
</a>
</div>
<div class="footer">
<p>Esta é uma mensagem automática. Por favor, não a responda.</p>
<p>&copy; {{ casa_legislativa }}</p>
</div>
</div>
</body>
</html>

17
sapl/templates/email/pendentes_assinatura.txt

@ -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.

217
sapl/templates/materia/assinatura_modal.html

@ -1,5 +1,52 @@
{% load i18n %} {% load i18n %}
<!-- Modal de Prévia do Documento -->
<div class="modal fade" id="previaDocumentoModal" tabindex="-1" role="dialog" aria-labelledby="previaDocumentoModalLabel" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-xl" role="document" style="max-width: 90vw;">
<div class="modal-content" style="height: 90vh;">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title" id="previaDocumentoModalLabel">
<i class="fa fa-eye"></i> {% trans "Prévia do Documento — Confirme antes de assinar" %}
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body p-0" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<div id="previa-loading" class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3"></i>
<h5>{% trans "Gerando prévia do documento..." %}</h5>
<p class="text-muted">{% trans "Aguarde enquanto o documento é convertido para visualização." %}</p>
</div>
<div id="previa-erro" class="p-4" style="display: none;">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i>
<strong>{% trans "Não foi possível gerar a prévia do documento." %}</strong>
<p class="mb-0 mt-2" id="previa-erro-msg">{% trans "Verifique se o documento está disponível." %}</p>
</div>
<p>{% trans "Você pode prosseguir com a assinatura mesmo sem a prévia." %}</p>
</div>
<iframe id="previa-iframe"
src=""
style="display: none; width: 100%; flex: 1; border: none; min-height: 0;"
title="Prévia do documento"></iframe>
</div>
<div class="modal-footer bg-light">
<div class="alert alert-info w-100 mb-2 py-2">
<i class="fa fa-info-circle"></i>
{% trans "Revise o documento acima. A assinatura digital tem validade jurídica e não pode ser desfeita sem permissão especial." %}
</div>
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="btn-cancelar-previa">
<i class="fa fa-times"></i> {% trans "Cancelar — Fazer ajustes" %}
</button>
<button type="button" class="btn btn-success" id="btn-confirmar-assinatura" disabled>
<i class="fa fa-check-circle"></i> {% trans "Documento OK — Prosseguir com Assinatura" %}
</button>
</div>
</div>
</div>
</div>
<!-- Modal de Assinatura Digital --> <!-- Modal de Assinatura Digital -->
<div class="modal fade" id="assinaturaModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaModalLabel" aria-hidden="true"> <div class="modal fade" id="assinaturaModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
@ -92,6 +139,8 @@
<i class="fa fa-exclamation-triangle"></i> <i class="fa fa-exclamation-triangle"></i>
{% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %} {% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %}
</div> </div>
</form> </form>
</div> </div>
@ -253,6 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
init: function(materiaId, urlPrefix) { init: function(materiaId, urlPrefix) {
this.materiaId = materiaId; this.materiaId = materiaId;
this.urlPrefix = urlPrefix || ('/materia/' + materiaId); this.urlPrefix = urlPrefix || ('/materia/' + materiaId);
this._pdfPreviaUrl = this.urlPrefix + '/pdf-previa';
this.bindEvents(); this.bindEvents();
}, },
@ -273,11 +323,28 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Botão assinar // Botão assinar → abre prévia primeiro
document.getElementById('btn-assinar').addEventListener('click', function() { document.getElementById('btn-assinar').addEventListener('click', function() {
self.mostrarPrevia();
});
// Confirmação na prévia → executa assinatura
document.getElementById('btn-confirmar-assinatura').addEventListener('click', function() {
$('#previaDocumentoModal').modal('hide');
self.assinar(); self.assinar();
}); });
// Habilitar botão de confirmação após alguns segundos (forçar leitura)
$('#previaDocumentoModal').on('shown.bs.modal', function() {
var btn = document.getElementById('btn-confirmar-assinatura');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-hourglass-half"></i> {% trans "Aguarde para confirmar..." %}';
setTimeout(function() {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-check-circle"></i> {% trans "Documento OK — Prosseguir com Assinatura" %}';
}, 4000);
});
// Tentar novamente A3 // Tentar novamente A3
var btnTentarNovamente = document.querySelector('.btn-tentar-novamente-a3'); var btnTentarNovamente = document.querySelector('.btn-tentar-novamente-a3');
if (btnTentarNovamente) { if (btnTentarNovamente) {
@ -378,6 +445,70 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('a3-nao-detectado').style.display = 'block'; document.getElementById('a3-nao-detectado').style.display = 'block';
}, },
mostrarPrevia: function() {
var self = this;
// Valida campos antes de abrir a prévia
if (this.tipoSelecionado === 'A1') {
var certificado = document.getElementById('certificado-a1').files[0];
var senha = document.getElementById('senha-a1').value;
if (!certificado) { alert('{% trans "Selecione o arquivo do certificado." %}'); return; }
if (!senha) { alert('{% trans "Digite a senha do certificado." %}'); return; }
}
// Reset prévia
document.getElementById('previa-loading').style.display = 'block';
document.getElementById('previa-erro').style.display = 'none';
var iframe = document.getElementById('previa-iframe');
iframe.style.display = 'none';
iframe.src = '';
document.getElementById('btn-confirmar-assinatura').disabled = true;
// Abre modal de prévia
$('#previaDocumentoModal').modal('show');
// Carrega PDF na iframe
var pdfUrl = this._pdfPreviaUrl;
iframe.onload = function() {
document.getElementById('previa-loading').style.display = 'none';
// Verifica se carregou com sucesso (se a URL retornou 200)
try {
// tenta acessar o conteúdo — se for PDF embutido, não há acesso ao contentDocument
document.getElementById('previa-iframe').style.display = 'block';
} catch(e) {
document.getElementById('previa-iframe').style.display = 'block';
}
};
iframe.onerror = function() {
document.getElementById('previa-loading').style.display = 'none';
document.getElementById('previa-erro').style.display = 'block';
document.getElementById('previa-erro-msg').textContent =
'{% trans "Erro ao carregar o documento." %}';
document.getElementById('btn-confirmar-assinatura').disabled = false;
document.getElementById('btn-confirmar-assinatura').innerHTML =
'<i class="fa fa-check-circle"></i> {% trans "Prosseguir com Assinatura" %}';
};
// Usa fetch para verificar disponibilidade antes de colocar no iframe
fetch(pdfUrl, { method: 'GET', credentials: 'same-origin' })
.then(function(resp) {
if (!resp.ok) { throw new Error('status ' + resp.status); }
document.getElementById('previa-loading').style.display = 'none';
iframe.src = pdfUrl;
iframe.style.display = 'block';
})
.catch(function(err) {
document.getElementById('previa-loading').style.display = 'none';
document.getElementById('previa-erro').style.display = 'block';
document.getElementById('previa-erro-msg').textContent =
'{% trans "Não foi possível gerar a prévia" %}: ' + err.message;
// Permite assinar mesmo sem prévia
document.getElementById('btn-confirmar-assinatura').disabled = false;
document.getElementById('btn-confirmar-assinatura').innerHTML =
'<i class="fa fa-check-circle"></i> {% trans "Prosseguir com Assinatura" %}';
});
},
assinar: function() { assinar: function() {
if (this.tipoSelecionado === 'A1') { if (this.tipoSelecionado === 'A1') {
this.assinarA1(); this.assinarA1();
@ -402,12 +533,32 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
this.mostrarStatus('processando'); this.mostrarStatus('processando'); this.atualizarProgresso(5, 'Enviando certificado…');
this.atualizarProgresso(10, 'Enviando certificado...'); var formData = new FormData(form);
var formData = new FormData(); // Animação de progresso enquanto o servidor processa
formData.append('certificado', certificado); var progressoTimer = null;
formData.append('senha', senha); 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/', { fetch(this.urlPrefix + '/assinar/a1/', {
method: 'POST', method: 'POST',
@ -417,19 +568,26 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}) })
.then(function(response) { .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(); return response.json();
}) })
.then(function(data) { .then(function(data) {
self.atualizarProgresso(100, 'Finalizando...'); clearTimeout(progressoTimer);
self.atualizarProgresso(100, 'Concluído.');
if (data.success) { if (data.success) {
self.mostrarSucesso(data); self.mostrarSucesso(data);
} else { } else {
self.mostrarErro(data.error); self.mostrarErro(data.error || 'Erro desconhecido ao assinar.');
} }
}) })
.catch(function(error) { .catch(function(error) {
clearTimeout(progressoTimer);
self.mostrarErro('Erro de comunicação: ' + error.message); self.mostrarErro('Erro de comunicação: ' + error.message);
}); });
}, },
@ -487,6 +645,14 @@ document.addEventListener('DOMContentLoaded', function() {
this.tipoSelecionado = null; this.tipoSelecionado = null;
this.a3AppInfo = null; this.a3AppInfo = null;
// Fecha prévia se estiver aberta
$('#previaDocumentoModal').modal('hide');
var iframe = document.getElementById('previa-iframe');
iframe.src = '';
iframe.style.display = 'none';
document.getElementById('previa-loading').style.display = 'block';
document.getElementById('previa-erro').style.display = 'none';
document.getElementById('selecao-tipo-certificado').style.display = 'block'; document.getElementById('selecao-tipo-certificado').style.display = 'block';
document.getElementById('formulario-a1').style.display = 'none'; document.getElementById('formulario-a1').style.display = 'none';
document.getElementById('formulario-a3').style.display = 'none'; document.getElementById('formulario-a3').style.display = 'none';
@ -515,7 +681,6 @@ document.addEventListener('DOMContentLoaded', function() {
window.AssinaturaModal = AssinaturaModal; window.AssinaturaModal = AssinaturaModal;
}); });
</script> </script>
<style> <style>
.tipo-certificado-card { .tipo-certificado-card {
cursor: pointer; cursor: pointer;
@ -552,4 +717,32 @@ document.addEventListener('DOMContentLoaded', function() {
#a3-cert-info { #a3-cert-info {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
/* Prévia do documento */
#previaDocumentoModal .modal-content {
display: flex;
flex-direction: column;
}
#previaDocumentoModal .modal-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
#previaDocumentoModal .modal-footer {
flex-direction: column;
align-items: stretch;
}
#previaDocumentoModal .modal-footer .alert {
font-size: 0.875rem;
}
#btn-confirmar-assinatura {
font-size: 1.05rem;
padding: 0.6rem 1.5rem;
}
</style> </style>

10
sapl/templates/materia/documentoacessorio_detail.html

@ -78,6 +78,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %} <i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a> </a>
{% endif %} {% endif %}
{% if pode_remover_assinatura %}
<button type="button" class="btn btn-sm btn-danger btn-remover-assinatura"
data-url="{% url 'sapl.materia:docacessorio_remover_assinatura' object.pk %}"
data-nome="{{ object }}">
<i class="fa fa-trash"></i> {% trans "Remover Assinatura" %}
</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -98,4 +105,7 @@
}); });
</script> </script>
{% endif %} {% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %} {% endblock extra_js %}

418
sapl/templates/materia/documentoacessorio_list.html

@ -58,6 +58,17 @@
<div class="actions btn-group float-right" role="group"> <div class="actions btn-group float-right" role="group">
<a href="{% url 'sapl.materia:compress_docacessorios' root_pk %}" class="btn btn-outline-primary">{% trans 'Baixar documentos compactados' %}</a> <a href="{% url 'sapl.materia:compress_docacessorios' root_pk %}" class="btn btn-outline-primary">{% trans 'Baixar documentos compactados' %}</a>
</div> </div>
{% if docs_pendentes_lote %}
<div class="actions btn-group float-right ml-2" role="group">
<button type="button" id="btn-assinar-doc-lote"
class="btn btn-outline-warning"
title="Assinar digitalmente os {{ docs_pendentes_lote|length }} documento(s) acessório(s) pendentes">
<i class="fa fa-certificate"></i>
Assinar em Lote
<span class="badge badge-warning text-dark ml-1">{{ docs_pendentes_lote|length }}</span>
</button>
</div>
{% endif %}
</div> </div>
{% if pode_upload %} {% if pode_upload %}
@ -169,4 +180,411 @@
})(); })();
</script> </script>
{% endif %} {% endif %}
{% if docs_pendentes_lote %}
{# ── Modal de Assinatura em Lote – Documentos Acessórios ─────────────── #}
<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">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
</script>
{% endif %}
{% endblock %} {% endblock %}

18
sapl/templates/materia/materialegislativa_detail.html

@ -25,13 +25,13 @@
<i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %} <i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a> </a>
{% endif %} {% endif %}
{% if is_autor and not ja_assinou %} {% if is_autor or can_edit_materia %}
{% if not ja_assinou and object.texto_original %}
<button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal"> <button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal">
<i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %} <i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %}
</button> </button>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if object.documentoacessorio_set.all.exists %}
<a class="btn btn-danger" href="{% url 'sapl.materia:pdf_completo_materia' object.pk %}" title="{% trans 'Mescla matéria e acessórios em um único PDF' %}"> <a class="btn btn-danger" href="{% url 'sapl.materia:pdf_completo_materia' object.pk %}" title="{% trans 'Mescla matéria e acessórios em um único PDF' %}">
<i class="fa fa-file-pdf-o"></i> {% trans "Todos em PDF" %} <i class="fa fa-file-pdf-o"></i> {% trans "Todos em PDF" %}
</a> </a>
@ -230,6 +230,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %} <i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a> </a>
{% endif %} {% endif %}
{% if pode_remover_assinatura %}
<button type="button" class="btn btn-sm btn-danger btn-remover-assinatura"
data-url="{% url 'sapl.materia:materia_remover_assinatura' object.pk %}"
data-nome="{{ object }}">
<i class="fa fa-trash"></i> {% trans "Remover Assinatura" %}
</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -240,7 +247,8 @@
{% block extra_js %} {% block extra_js %}
{{ block.super }} {{ block.super }}
{% if object.numero_protocolo and object.texto_original and is_autor and not ja_assinou %} {% if object.texto_original and not ja_assinou %}
{% if is_autor or can_edit_materia %}
{% include "materia/assinatura_modal.html" %} {% include "materia/assinatura_modal.html" %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -250,4 +258,8 @@
}); });
</script> </script>
{% endif %} {% endif %}
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %} {% endblock extra_js %}

667
sapl/templates/materia/materialegislativa_filter.html

@ -7,36 +7,152 @@
{% load static %} {% load static %}
{% block actions %} {% block actions %}
<style>
.sapl-ab {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding-bottom: .75rem;
}
/* Linha 1: botões de ação (esquerda) + navegação (direita) */
.sapl-ab-row1 {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
}
/* Linha 2: filtro de assinatura + assinar em lote — só aparece quando necessário */
.sapl-ab-row2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.sapl-ab-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.sapl-ab-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.sapl-ab-sep {
width: 1px;
height: 28px;
background: #dee2e6;
flex-shrink: 0;
margin: 0 2px;
}
#select-status-assinatura { min-width: 190px; max-width: 230px; }
@media (max-width: 576px) {
#select-status-assinatura { min-width: 140px; }
.sapl-ab-sep { display: none; }
}
</style>
<div class="sapl-ab">
{# ── Linha 1: ações + navegação ─────────────────────────────────── #}
<div class="sapl-ab-row1">
<div class="sapl-ab-left">
{% if show_results %} {% if show_results %}
<div class="float-left"> {# Exportações (CSV, XLS, etc) #}
{% with 'sapl.materia:pesquisar_materia' as url_reverse %} {% with 'sapl.materia:pesquisar_materia' as url_reverse %}
{% include "crud/format_options.html" %} {% include "crud/format_options.html" %}
{% endwith %} {% endwith %}
<button id="btn-download-todos-pdfs" class="btn btn-outline-secondary" title="Baixar todos os PDFs das matérias filtradas">
{# Download todos os PDFs desta página #}
<button id="btn-download-todos-pdfs"
class="btn btn-outline-secondary btn-sm"
title="Baixar todos os PDFs das matérias filtradas desta página">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
<span> Baixar PDFs</span>
</button>
<div class="sapl-ab-sep"></div>
{# Impressão em massa #}
<button id="btn-imprimir-selecionados"
class="btn btn-outline-danger btn-sm"
title="Ativar seleção para impressão em massa">
<i class="fas fa-print"></i>
<span> Imprimir Matérias</span>
<span class="badge badge-danger ml-1" id="badge-print-total" style="display:none;">0</span>
</button>
<button id="btn-selecionar-todos-print"
class="btn btn-outline-secondary btn-sm"
title="Selecionar / desselecionar todos desta página"
style="display:none;">
<i class="fas fa-check-square"></i>
<span> Todos</span>
</button> </button>
</div>
{% endif %} {% endif %}
</div>
<div class="actions btn-group float-right pb-4" role="group"> <div class="sapl-ab-right">
{% switch "SOLR_SWITCH" %} {% switch "SOLR_SWITCH" %}
<a href="{% url 'sapl.base:haystack_search' %}" class="btn btn-outline-primary"> <a href="{% url 'sapl.base:haystack_search' %}" class="btn btn-outline-primary btn-sm">
Pesquisa Textual <i class="fas fa-search"></i>
<span class="d-none d-md-inline"> Pesquisa Textual</span>
</a> </a>
{% endswitch %} {% endswitch %}
{% if perms.materia.add_materialegislativa %} {% if perms.materia.add_materialegislativa %}
<a href="{% url 'sapl.materia:materialegislativa_create' %}" class="btn btn-outline-primary"> <a href="{% url 'sapl.materia:materialegislativa_create' %}" class="btn btn-outline-primary btn-sm">
{% blocktrans with verbose_name=view.verbose_name %} Adicionar Matéria Legislativa {% endblocktrans %} <i class="fas fa-plus"></i>
<span class="d-none d-md-inline"> {% blocktrans with verbose_name=view.verbose_name %}Adicionar Matéria Legislativa{% endblocktrans %}</span>
</a> </a>
{% endif %} {% endif %}
{% if show_results %} {% if show_results %}
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary">{% trans 'Fazer nova pesquisa' %}</a> <a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-redo-alt"></i>
<span class="d-none d-sm-inline"> {% trans 'Nova pesquisa' %}</span>
</a>
{% endif %}
</div>
</div>
{# ── /Linha 1 ────────────────────────────────────────────────────── #}
{# ── Linha 2: filtro de assinatura + assinar em lote ─────────────── #}
<div class="sapl-ab-row2">
<label class="mb-0 text-secondary font-weight-bold" style="white-space:nowrap; font-size:.82rem;">
<i class="fas fa-signature mr-1"></i>Assinatura:
</label>
{% with status_atual=request.GET.status_assinatura %}
<select id="select-status-assinatura" class="custom-select custom-select-sm">
<option value="" {% if not status_atual %}selected{% endif %}>Todas as matérias</option>
<option value="pendente" {% if status_atual == 'pendente' %}selected{% endif %}>⚠ Pendente de Assinatura</option>
<option value="assinada" {% if status_atual == 'assinada' %}selected{% endif %}>✔ Assinada</option>
</select>
{% endwith %}
{# Assinar em Lote — só aparece quando há pendentes #}
{% if materias_pendentes_lote %}
<button type="button"
id="btn-assinar-em-lote"
class="btn btn-outline-warning btn-sm"
title="Assinar digitalmente as {{ materias_pendentes_lote|length }} matéria(s) pendentes desta pesquisa">
<i class="fa fa-certificate"></i>
Assinar em Lote
<span class="badge badge-warning text-dark ml-1" id="badge-lote-total">{{ materias_pendentes_lote|length }}</span>
</button>
{% endif %} {% endif %}
</div> </div>
{# ── /Linha 2 ────────────────────────────────────────────────────── #}
</div>
{% endblock %} {% endblock %}
{% block detail_content %} {% block detail_content %}
{% if not show_results %} {% if not show_results %}
{% crispy filter.form %} {% crispy filter.form %}
{% endif %} {% endif %}
@ -58,8 +174,16 @@
{% endif %} {% endif %}
{% for m in page_obj %} {% for m in page_obj %}
<tr> <tr class="materia-row" data-materia-id="{{ m.id }}">
<td> <td>
{# Checkbox para seleção de impressão em massa #}
<div class="print-select-col" style="display:none; float:left; margin-right:8px; margin-top:2px;">
<input type="checkbox"
class="print-chk form-check-input"
data-materia-id="{{ m.id }}"
title="Selecionar para impressão"
style="width:18px;height:18px;cursor:pointer;">
</div>
<strong><a href="{% url 'sapl.materia:materialegislativa_detail' m.id %}">{{m.tipo.sigla}} {{m.numero}}/{{m.ano}} - {{m.tipo}}</strong></a> <strong><a href="{% url 'sapl.materia:materialegislativa_detail' m.id %}">{{m.tipo.sigla}} {{m.numero}}/{{m.ano}} - {{m.tipo}}</strong></a>
<a href="{% url 'sapl.relatorios:etiqueta_materia_legislativa' m.pk %}"><img src="{% webpack_static 'img/etiqueta.png' %}" alt="Etiqueta Individual"></a> <a href="{% url 'sapl.relatorios:etiqueta_materia_legislativa' m.pk %}"><img src="{% webpack_static 'img/etiqueta.png' %}" alt="Etiqueta Individual"></a>
</br> </br>
@ -215,6 +339,19 @@
Texto Original Texto Original
</a> </a>
</strong> </strong>
&nbsp;
{% if m.pdf_assinado %}
<a href="{% url 'sapl.materia:materia_pdf_assinado' m.pk %}" target="_blank"
class="badge badge-success"
title="PDF assinado disponível — clique para baixar">
<i class="fas fa-signature"></i> Assinado{% if m.assinado_em %} em {{ m.assinado_em|date:"d/m/Y" }}{% endif %}
</a>
{% elif m.texto_original %}
<span class="badge badge-warning text-dark"
title="Documento ainda não possui assinatura digital">
<i class="fas fa-clock"></i> Pendente de Assinatura
</span>
{% endif %}
</br> </br>
{% endif %} {% endif %}
{% if m.texto_articulado.exists %}<strong><a href="{% url 'sapl.materia:materia_ta' m.id%}">Texto Articulado</a></strong></br>{% endif %} {% if m.texto_articulado.exists %}<strong><a href="{% url 'sapl.materia:materia_ta' m.id%}">Texto Articulado</a></strong></br>{% endif %}
@ -250,11 +387,519 @@
</table> </table>
{% include "paginacao.html" %} {% include "paginacao.html" %}
{% endif %} {% endif %}
{# ── Toolbar flutuante de impressão em massa ─────────────────────────── #}
{% if show_results %}
<div id="print-toolbar"
style="display:none; position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
z-index:1050; background:#fff; border:2px solid #dc3545;
border-radius:50px; padding:10px 20px;
box-shadow:0 8px 32px rgba(220,53,69,.25), 0 2px 8px rgba(0,0,0,.12);
white-space:nowrap;">
<div class="d-flex align-items-center" style="gap:10px;">
{# Contagem #}
<span class="text-danger font-weight-bold" style="font-size:.95rem;">
<i class="fas fa-print mr-1"></i>
<span id="print-toolbar-count">0</span> selecionado(s)
</span>
<div class="border-left" style="height:22px;"></div>
{# Ações #}
<button id="print-toolbar-btn-imprimir"
class="btn btn-danger btn-sm rounded-pill px-3"
title="Gerar PDF unificado e abrir para impressão">
<i class="fas fa-print mr-1"></i> Imprimir
</button>
<button id="print-toolbar-btn-download"
class="btn btn-outline-danger btn-sm rounded-pill px-3"
title="Baixar PDF unificado">
<i class="fas fa-download mr-1"></i> Baixar PDF
</button>
<div class="border-left" style="height:22px;"></div>
<button id="print-toolbar-btn-limpar"
class="btn btn-link btn-sm text-secondary p-0"
title="Cancelar seleção">
<i class="fas fa-times-circle fa-lg"></i>
</button>
</div>
{# Info + loading (ocultos por padrão) #}
<div class="text-center mt-1" style="font-size:.72rem; color:#888;">
<i class="fas fa-info-circle"></i>
Máx. 200 docs. Apenas matérias com PDF disponível serão incluídas.
</div>
<div id="print-toolbar-loading" style="display:none;" class="mt-2">
<div class="progress" style="height:4px; border-radius:2px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-danger" style="width:100%"></div>
</div>
<div class="text-center mt-1" style="font-size:.75rem; color:#888;">Gerando PDF, aguarde…</div>
</div>
</div>
{% endif %}
{# ────────────────────────────────────────────────────────────────────── #}
{% endblock detail_content %} {% endblock detail_content %}
{% block table_content %} {% block table_content %}
{% endblock table_content %} {% endblock table_content %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'js/materia_pesquisa_download_pdfs.js' %}"></script> <script src="{% static 'js/materia_pesquisa_download_pdfs.js' %}"></script>
<script src="{% static 'js/materia_impressao_em_massa.js' %}"></script>
{% if materias_pendentes_lote %}
{# ── Modal de Assinatura em Lote ─────────────────────────────────────── #}
<div class="modal fade" id="assinaturaLoteModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaLoteModalLabel" 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="assinaturaLoteModalLabel">
<i class="fa fa-certificate"></i>
Assinar em Lote — <strong id="lote-titulo-total">{{ materias_pendentes_lote|length }}</strong> matéria(s) pendente(s)
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Fechar">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{# ── Passo 0: seleção de matérias ── #}
<div id="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 as matérias a assinar
</span>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary mr-1" id="lote-btn-todas">
<i class="fa fa-check-square-o"></i> Todas
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="lote-btn-nenhuma">
<i class="fa fa-square-o"></i> Nenhuma
</button>
</div>
</div>
<div id="lote-lista-checkboxes" style="max-height:320px;overflow-y:auto;border:1px solid #dee2e6;border-radius:4px;padding:8px;">
{% for m in materias_pendentes_lote %}
<div class="form-check py-1" style="border-bottom:1px solid #f0f0f0;">
<input class="form-check-input lote-chk" type="checkbox"
id="lote-chk-{{ m.id }}" value="{{ m.id }}" checked>
<label class="form-check-label w-100" for="lote-chk-{{ m.id }}" style="cursor:pointer;">
{{ m.descricao }}
</label>
</div>
{% endfor %}
</div>
<p class="text-muted small mt-2 mb-0">
<span id="lote-selecao-contagem">{{ materias_pendentes_lote|length }}</span> matéria(s) selecionada(s)
</p>
</div>
{# ── Passo 1: formulário de certificado ── #}
<div id="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="lote-alerta-total">{{ materias_pendentes_lote|length }}</span> matéria(s)</strong>
com validade jurídica nos termos da MP 2.200-2/2001.
Certifique-se de que os documentos estão corretos antes de prosseguir.
</div>
<form id="form-assinatura-lote" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-group">
<label for="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="lote-certificado"
name="certificado" accept=".pfx,.p12" required>
<label class="custom-file-label" for="lote-certificado">
Selecione o arquivo do certificado...
</label>
</div>
</div>
<div class="form-group">
<label for="lote-senha">
<i class="fa fa-lock"></i> Senha do Certificado
</label>
<input type="password" class="form-control" id="lote-senha"
name="senha" placeholder="Digite a senha do certificado" required>
</div>
</form>
</div>
{# ── Passo 1.5: prévia dos documentos ── #}
<div id="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 matéria para visualizar o PDF em uma nova aba.
A assinatura digital tem validade jurídica e não pode ser desfeita sem permissão especial.
</div>
<div id="lote-previa-lista" style="max-height:300px;overflow-y:auto;border:1px solid #dee2e6;border-radius:4px;padding:8px;" class="mb-3">
{# Preenchido via JS com as matérias selecionadas + link para PDF #}
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="lote-previa-confirmacao">
<label class="form-check-label font-weight-bold text-danger" for="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="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="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="lote-status-texto">Iniciando…</p>
<div id="lote-resultados-parciais" class="mt-2" style="max-height:220px;overflow-y:auto;"></div>
</div>
{# ── Passo 3: resumo final ── #}
<div id="lote-resumo-step" style="display:none;">
<div id="lote-resumo-alerta"></div>
<div id="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="lote-btn-fechar">
<i class="fa fa-times"></i> Fechar
</button>
{# Passo 0 → 1 #}
<button type="button" class="btn btn-warning" id="lote-btn-proximo">
<i class="fa fa-arrow-right"></i>
Continuar — <span id="lote-btn-proximo-contagem">{{ materias_pendentes_lote|length }}</span> Matéria(s)
</button>
{# Passo 1 → 1.5 #}
<button type="button" class="btn btn-outline-secondary" id="lote-btn-voltar" style="display:none;">
<i class="fa fa-arrow-left"></i> Voltar
</button>
<button type="button" class="btn btn-info" id="lote-btn-previa" style="display:none;">
<i class="fa fa-eye"></i> Visualizar Documentos
</button>
{# Passo 1.5 → 2 #}
<button type="button" class="btn btn-outline-secondary" id="lote-btn-voltar-form" style="display:none;">
<i class="fa fa-arrow-left"></i> Voltar
</button>
<button type="button" class="btn btn-warning" id="lote-btn-assinar" style="display:none;" disabled>
<i class="fa fa-certificate"></i>
Assinar <span id="lote-btn-assinar-contagem">{{ materias_pendentes_lote|length }}</span> Matéria(s)
</button>
<button type="button" class="btn btn-success" id="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 () {
// Dados injetados pelo servidor: [{id: N, descricao: "..."}, ...]
var MATERIAS_LOTE = {{ materias_pendentes_lote|safe }};
var URL_LOTE = "{% url 'sapl.materia:materia_assinar_lote' %}";
function getCSRF() {
var el = document.querySelector('#form-assinatura-lote [name=csrfmiddlewaretoken]');
if (el) return el.value;
var match = document.cookie.match(/csrftoken=([^;]+)/);
return match ? match[1] : '';
}
// Retorna lista de IDs marcados nos checkboxes
function getIdsSelecionados() {
return Array.from(
document.querySelectorAll('.lote-chk:checked')
).map(function(c){ return parseInt(c.value, 10); });
}
// Atualiza contagens em todos os elementos dinâmicos
function atualizarContagem() {
var n = getIdsSelecionados().length;
document.getElementById('lote-selecao-contagem').textContent = n;
document.getElementById('lote-btn-proximo-contagem').textContent = n;
document.getElementById('lote-btn-assinar-contagem').textContent = n;
document.getElementById('lote-alerta-total').textContent = n;
document.getElementById('lote-titulo-total').textContent = n;
// badge no botão da página
var badge = document.getElementById('badge-lote-total');
if (badge) badge.textContent = n;
// desabilita "Continuar" se nenhuma selecionada
document.getElementById('lote-btn-proximo').disabled = (n === 0);
}
// Delega evento nos checkboxes
document.getElementById('lote-lista-checkboxes').addEventListener('change', function(e){
if (e.target && e.target.classList.contains('lote-chk')) {
atualizarContagem();
}
});
// Selecionar todas / nenhuma
document.getElementById('lote-btn-todas').addEventListener('click', function(){
document.querySelectorAll('.lote-chk').forEach(function(c){ c.checked = true; });
atualizarContagem();
});
document.getElementById('lote-btn-nenhuma').addEventListener('click', function(){
document.querySelectorAll('.lote-chk').forEach(function(c){ c.checked = false; });
atualizarContagem();
});
// Atualiza label do file input
document.getElementById('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('lote-selecao-step').style.display = 'block';
document.getElementById('lote-form-step').style.display = 'none';
document.getElementById('lote-previa-step').style.display = 'none';
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'none';
document.getElementById('lote-btn-proximo').style.display = 'inline-block';
document.getElementById('lote-btn-voltar').style.display = 'none';
document.getElementById('lote-btn-previa').style.display = 'none';
document.getElementById('lote-btn-voltar-form').style.display= 'none';
document.getElementById('lote-btn-assinar').style.display = 'none';
document.getElementById('lote-btn-recarregar').style.display = 'none';
document.getElementById('lote-btn-fechar').disabled = false;
}
function irParaForm() {
document.getElementById('lote-selecao-step').style.display = 'none';
document.getElementById('lote-form-step').style.display = 'block';
document.getElementById('lote-previa-step').style.display = 'none';
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'none';
document.getElementById('lote-btn-proximo').style.display = 'none';
document.getElementById('lote-btn-voltar').style.display = 'inline-block';
document.getElementById('lote-btn-previa').style.display = 'inline-block';
document.getElementById('lote-btn-voltar-form').style.display= 'none';
document.getElementById('lote-btn-assinar').style.display = 'none';
document.getElementById('lote-btn-recarregar').style.display = 'none';
}
function irParaPrevia() {
var certFile = document.getElementById('lote-certificado').files[0];
var senha = document.getElementById('lote-senha').value;
if (!certFile) { alert('Selecione o arquivo do certificado.'); return; }
if (!senha) { alert('Digite a senha do certificado.'); return; }
var idsSel = getIdsSelecionados();
// Monta lista de matérias com link para prévia do PDF
var html = '';
MATERIAS_LOTE.filter(function(m){ return idsSel.indexOf(m.id) !== -1; })
.forEach(function(m) {
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(m.descricao) + '</strong></span>' +
'<a href="/materia/' + m.id + '/pdf-previa" target="_blank" ' +
'class="btn btn-sm btn-outline-primary ml-2" title="Visualizar PDF">' +
'<i class="fa fa-eye"></i> Visualizar PDF</a>' +
'</div>';
});
document.getElementById('lote-previa-lista').innerHTML = html || '<p class="text-muted">Nenhuma matéria selecionada.</p>';
// Reset checkbox de confirmação
var chk = document.getElementById('lote-previa-confirmacao');
chk.checked = false;
document.getElementById('lote-btn-assinar').disabled = true;
document.getElementById('lote-selecao-step').style.display = 'none';
document.getElementById('lote-form-step').style.display = 'none';
document.getElementById('lote-previa-step').style.display = 'block';
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'none';
document.getElementById('lote-btn-proximo').style.display = 'none';
document.getElementById('lote-btn-voltar').style.display = 'none';
document.getElementById('lote-btn-previa').style.display = 'none';
document.getElementById('lote-btn-voltar-form').style.display= 'inline-block';
document.getElementById('lote-btn-assinar').style.display = 'inline-block';
document.getElementById('lote-btn-recarregar').style.display = 'none';
}
// Abre modal ao clicar no botão externo
document.getElementById('btn-assinar-em-lote').addEventListener('click', function () {
// Reset checkboxes para todos marcados
document.querySelectorAll('.lote-chk').forEach(function(c){ c.checked = true; });
document.getElementById('form-assinatura-lote').reset();
document.querySelector('#form-assinatura-lote .custom-file-label').textContent =
'Selecione o arquivo do certificado...';
atualizarContagem();
irParaSelecao();
$('#assinaturaLoteModal').modal('show');
});
// Passo 0 → 1: Continuar
document.getElementById('lote-btn-proximo').addEventListener('click', function () {
if (getIdsSelecionados().length === 0) {
alert('Selecione ao menos uma matéria para assinar.');
return;
}
irParaForm();
});
// Passo 1 → 0: Voltar
document.getElementById('lote-btn-voltar').addEventListener('click', irParaSelecao);
// Passo 1 → 1.5: Visualizar Prévia
document.getElementById('lote-btn-previa').addEventListener('click', irParaPrevia);
// Passo 1.5 → 1: Voltar ao formulário
document.getElementById('lote-btn-voltar-form').addEventListener('click', irParaForm);
// Habilitar botão assinar apenas quando confirmação marcada
document.getElementById('lote-previa-confirmacao').addEventListener('change', function() {
document.getElementById('lote-btn-assinar').disabled = !this.checked;
});
// Botão Assinar (passo 1.5 → 2)
document.getElementById('lote-btn-assinar').addEventListener('click', function () {
var certFile = document.getElementById('lote-certificado').files[0];
var senha = document.getElementById('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('Nenhuma matéria selecionada.'); return; }
var fd = new FormData();
fd.append('certificado', certFile);
fd.append('senha', senha);
fd.append('ids', JSON.stringify(idsSel));
document.getElementById('lote-previa-step').style.display = 'none';
document.getElementById('lote-progresso-step').style.display = 'block';
document.getElementById('lote-btn-voltar-form').style.display= 'none';
document.getElementById('lote-btn-assinar').style.display = 'none';
document.getElementById('lote-btn-fechar').disabled = true;
// 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',
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('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));
});
});
function setProgresso(pct, texto) {
var bar = document.getElementById('lote-progress-bar');
bar.style.width = pct + '%';
bar.textContent = pct + '%';
document.getElementById('lote-status-texto').textContent = texto;
}
function mostrarResumo(data) {
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'block';
document.getElementById('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('lote-resumo-alerta').innerHTML =
'<div class="alert ' + alertaCls + '">' +
'<i class="fa fa-' + icone + '"></i> ' +
'<strong>' + data.sucesso + ' assinada(s)</strong> com sucesso' +
(temErro ? ', <strong>' + data.erros + '</strong> com erro(s).' : '.') +
' Total: ' + data.total + ' matéria(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('lote-resumo-lista').innerHTML = html;
}
function mostrarErroFatal(msg) {
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'block';
document.getElementById('lote-resumo-alerta').innerHTML =
'<div class="alert alert-danger"><i class="fa fa-times-circle"></i> ' +
'<strong>Erro:</strong> ' + escHtml(msg) + '</div>';
document.getElementById('lote-resumo-lista').innerHTML = '';
}
function escHtml(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
</script>
{% endif %}
<script type="text/javascript"> <script type="text/javascript">
$( document ).ready(function() { $( document ).ready(function() {
@ -265,7 +910,6 @@
function pesquisaAvancada(){ function pesquisaAvancada(){
$('.pesquisa_avancada').toggle(); $('.pesquisa_avancada').toggle();
var id_btn = "#btn_pesquisa_avancada_id"; var id_btn = "#btn_pesquisa_avancada_id";
if ($(id_btn).val().endsWith('>>>')){ if ($(id_btn).val().endsWith('>>>')){
$(id_btn).val($(id_btn).val().replace('>>>', '<<<')) $(id_btn).val($(id_btn).val().replace('>>>', '<<<'))
@ -276,7 +920,6 @@
function votacaoNominal(id){ function votacaoNominal(id){
$('#div_' + id).toggle(); $('#div_' + id).toggle();
var id_link = "#link_votacao_nominal_" + id; var id_link = "#link_votacao_nominal_" + id;
if ($(id_link).text().indexOf('>>>') > -1){ if ($(id_link).text().indexOf('>>>') > -1){
$(id_link).text($(id_link).text().replace('>>>', '<<<')) $(id_link).text($(id_link).text().replace('>>>', '<<<'))

119
sapl/templates/materia/materias_pendentes_assinatura_list.html

@ -0,0 +1,119 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load static %}
{% block actions %}
<div class="actions btn-group float-right pb-4" role="group">
<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>&nbsp;Texto Original&nbsp;</strong> mas ainda
<strong>&nbsp;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 %}

15
sapl/templates/materia/proposicao_detail.html

@ -460,6 +460,21 @@
</div> </div>
{% endif %} {% endif %}
<!-- Co-autores -->
{% if object.coautores.exists %}
<div class="prop-section">
<div class="prop-section-title">{% trans "Co-autores" %}</div>
<div class="prop-meta">
{% for coautoria in object.coautores.all %}
<div class="prop-meta-item" style="flex: 0 0 auto; min-width: 200px;">
<label>{{ coautoria.autor.tipo }}</label>
<span><i class="fa fa-user"></i> {{ coautoria.autor.nome }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Conteúdo Gerado / Vínculo --> <!-- Conteúdo Gerado / Vínculo -->
{% if object.conteudo_gerado_related or object.materia_de_vinculo %} {% if object.conteudo_gerado_related or object.materia_de_vinculo %}
<div class="prop-section"> <div class="prop-section">

53
sapl/templates/materia/proposicao_form.html

@ -2,8 +2,46 @@
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block extra_js %} {% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<style> <style>
/* Select2 co-autores */
.select2-container--default .select2-selection--multiple {
border: 1px solid #ced4da;
border-radius: 4px;
min-height: 38px;
padding: 2px 6px;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice {
background-color: #007bff;
border: none;
color: #fff;
border-radius: 3px;
padding: 2px 8px;
font-size: 0.9em;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: #fff;
margin-right: 5px;
}
#div_id_coautores {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border: 1px solid #e9ecef;
margin-bottom: 15px;
}
#div_id_coautores > label {
font-weight: 600;
color: #495057;
margin-bottom: 8px;
display: block;
}
#div_id_coautores .form-text {
color: #6c757d;
font-size: 0.875em;
margin-top: 5px;
}
/* ===================================================== /* =====================================================
Estilo para o container do tipo de proposição Estilo para o container do tipo de proposição
===================================================== */ ===================================================== */
@ -188,10 +226,23 @@
padding: 8px 25px; padding: 8px 25px;
} }
</style> </style>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
// =====================================================
// Inicializar Select2 para co-autores
// =====================================================
$('.select2-coautores').select2({
placeholder: '{% trans "Selecione os co-autores..." %}',
allowClear: true,
language: 'pt-BR',
width: '100%'
});
// ===================================================== // =====================================================
// Configuração do botão "Novo Tipo" // Configuração do botão "Novo Tipo"
// ===================================================== // =====================================================

135
sapl/templates/materia/remover_assinatura_modal.html

@ -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">&times;</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>

2
sapl/templates/navbar.yaml

@ -49,6 +49,8 @@
check_permission: materia.can_access_impressos {% comment %} FIXME transformar para checagens de menu_[funcionalidade]{% endcomment%} check_permission: materia.can_access_impressos {% comment %} FIXME transformar para checagens de menu_[funcionalidade]{% endcomment%}
- title: {% trans 'Matérias Legislativas' %} - title: {% trans 'Matérias Legislativas' %}
url: sapl.materia:pesquisar_materia url: sapl.materia:pesquisar_materia
- title: {% trans 'Matérias Pendentes de Assinatura' %}
url: sapl.materia:materias_pendentes_assinatura
- title: {% trans 'Pautas das Sessões' %} - title: {% trans 'Pautas das Sessões' %}
url: sapl.sessao:pauta_sessao url: sapl.sessao:pauta_sessao
- title: {% trans 'Proposições' %} - title: {% trans 'Proposições' %}

Loading…
Cancel
Save