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'
services:
@ -12,30 +20,22 @@ services:
- ..:/sapl-dev
ports:
- "8000:8000"
env_file:
- ../sapl/.env # Lê DATABASE_URL, SECRET_KEY, DEBUG, etc.
environment:
SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^'
DJANGO_DEBUG: 'True'
DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl
# Garante que DEBUG do .env seja reconhecido pelo settings.py
DJANGO_DEBUG: '${DEBUG:-True}'
TZ: America/Sao_Paulo
ONLYOFFICE_URL: 'http://onlyoffice:80'
depends_on:
- onlyoffice
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
extra_hosts:
# Permite alcançar bancos externos (ex: legisinc.com.br, sgvp.com.br)
- "host.docker.internal:host-gateway"
volumes:
onlyoffice_data:
onlyoffice_log:
onlyoffice_fonts:
# Descomente para usar OnlyOffice localmente
# onlyoffice:
# container_name: onlyoffice-documentserver
# 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
cd /root/dev/sapl
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements/requirements.txt
docker compose -f docker/docker-compose-local.yml up --build
```
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
DATABASE_URL=postgresql://kemuel:kasepulvida@sgvp.com.br:5432/sapl
DATABASE_URL=postgresql://usuario:senha@host:5432/banco
SECRET_KEY=<sua-chave-secreta>
DEBUG=True
EMAIL_USE_TLS=True
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
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
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
beautifulsoup4==4.13.5
django-debug-toolbar==3.2.4
ipdb==0.13.13
fancycompleter==0.11.1
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'),
choices=TIPO_NOME_ASSINATURA,
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
cronometro_discurso = models.DurationField(
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}
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)
def enable_sapn(request):
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)
from sapl.utils_template import adicionar_cabecalho_materia
from .models import (AcompanhamentoMateria, Anexada, Autoria,
from .models import (AcompanhamentoMateria, Anexada, Autoria, AutoriaProposicao,
DespachoInicial, DocumentoAcessorio, Numeracao,
Proposicao, Relatoria, TipoMateriaLegislativa,
Tramitacao, UnidadeTramitacao)
@ -979,12 +979,26 @@ class AnexadaForm(ModelForm):
fields = ['tipo', 'numero', 'ano', 'data_anexacao', 'data_desanexacao']
CHOICE_STATUS_ASSINATURA = [
('', _('Todas')),
('pendente', _('⚠ Pendente de Assinatura')),
('assinada', _('✔ Assinada')),
]
class MateriaLegislativaFilterSet(django_filters.FilterSet):
ano = django_filters.ChoiceFilter(required=False,
label='Ano da Matéria',
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__primeiro_autor = django_filters.BooleanFilter(
@ -1074,6 +1088,17 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet):
self.filters['o'].label = _('Ordenação')
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(
[('tipo', 5), ('ementa', 7)])
row2 = to_row(
@ -1131,6 +1156,7 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet):
HTML(autor_label),
HTML(autor_modal),
row4,
row_assinatura,
),
Button('btn_pesquisa_avancada', 'Pesquisa Avançada >>>',
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
def qs(self):
qs = qs_override_django_filter(self)
@ -1949,6 +1999,19 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
numero_materia_futuro = forms.IntegerField(
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:
model = Proposicao
fields = ['tipo',
@ -1994,6 +2057,7 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
dismiss=False), 12)),
to_column(('descricao', 12)),
to_column(('observacao', 12)),
to_column(('coautores', 12)),
]
@ -2131,6 +2195,13 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
'ano_materia'
].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):
texto_original = self.cleaned_data.get('texto_original', False)
@ -2222,7 +2293,9 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
inst.texto_original.delete()
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
sequencia_numeracao = BaseAppConfig.attr(
@ -2241,9 +2314,27 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
self.gerar_hash(inst, receber_recibo)
inst.save()
self._salvar_coautores(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):
@ -2746,6 +2837,21 @@ class ConfirmarProposicaoForm(ProposicaoForm):
'Autoria registrada para (%s)'
) % 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
from sapl.materia.models import AnexoProposicao
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')
unique_together = (("tipo", "numero", "ano"),)
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):
return _('%(tipo)s%(numero)s de %(ano)s') % {
@ -634,6 +637,9 @@ class DocumentoAcessorio(models.Model):
verbose_name = _('Documento Acessório')
verbose_name_plural = _('Documentos Acessórios')
ordering = ('data', 'id')
permissions = (
("can_remove_assinatura_doc", "Pode remover assinatura digital de documento acessório"),
)
def __str__(self):
return _('%(tipo)s - %(nome)s de %(data)s por %(autor)s') % {
@ -1158,6 +1164,40 @@ class Proposicao(models.Model):
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):
STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')),
('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}")
messages.error(request, 'Erro inesperado ao gerar o PDF.')
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,
MateriaLegislativaCrud,
MateriaLegislativaPesquisaView, MateriaTaView,
MateriasPendentesAssinaturaView,
NumeracaoCrud, OrgaoCrud, OrigemCrud,
PrimeiraTramitacaoEmLoteView, ProposicaoCrud,
ProposicaoDevolvida, ProposicaoPendente,
@ -33,6 +34,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
MateriaPesquisaSimplesView,
DespachoInicialMultiCreateView,
get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo,
get_pdf_multiplos,
configEtiquetaMateriaLegislativaCrud,
PesquisarStatusTramitacaoView, HistoricoProposicaoView)
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_download, docacessorio_onlyoffice_callback,
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 (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
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_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.protocoloadm.views import (
@ -122,6 +126,10 @@ urlpatterns_materia = [
url(r'^materia/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/$',
AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'),
url(r'^materia/(?P<pk>\d+)/acompanhar-confirmar$',
@ -158,6 +166,8 @@ urlpatterns_materia = [
name='zip_completo_materia'),
url(r'^materia/pdf-completo/(?P<pk>\d+)$', get_pdf_completo,
name='pdf_completo_materia'),
url(r'^materia/pdf-multiplos/$', get_pdf_multiplos,
name='pdf_multiplos_materias'),
# OnlyOffice endpoints para Matéria Legislativa
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,
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
url(r'^materia/(?P<pk>\d+)/assinar/a1/$', materia_assinar_a1,
name='materia_assinar_a1'),
@ -182,6 +196,8 @@ urlpatterns_materia = [
name='materia_assinar_a3_preparar'),
url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', 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,
name='materia_pdf_assinado'),
url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura,
@ -190,6 +206,12 @@ urlpatterns_materia = [
name='materia_remover_assinatura'),
url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3,
name='detectar_aplicacao_a3'),
url(r'^materia/assinatura/api-status/$', assinatura_api_status,
name='assinatura_api_status'),
url(r'^materia/(?P<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)
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,
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
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinar/a1/$', 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,
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)
url(r'^materia/documentoacessorio/(?P<pk>\d+)/verificar/$', 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.contrib import messages
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.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError
from django.db.models import Max, Q
@ -38,7 +38,7 @@ from django.utils.decorators import method_decorator
import sapl
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.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE
from sapl.compilacao.views import IntegracaoTaView
@ -641,6 +641,53 @@ class ProposicaoPendenteSetor(PermissionRequiredMixin, ListView):
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):
template_name = "materia/revisar_proposicao_setor.html"
model = Proposicao
@ -1949,13 +1996,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
)
# 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)
context['assinaturas'] = assinaturas
context['ja_assinou'] = any(
a.get('signed_by') == u.username
for a in assinaturas
) if u.is_authenticated else False
context['pode_remover_assinatura'] = (
u.is_authenticated and _pode_remover_assinatura(u)
)
return context
@ -1969,6 +2019,29 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
u.has_perm('materia.add_documentoacessorio')
)
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
def hook_arquivo(self, obj, default, url):
@ -2482,13 +2555,17 @@ class MateriaLegislativaCrud(Crud):
)
# 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)
context['assinaturas'] = assinaturas
context['ja_assinou'] = any(
a.get('signed_by') == self.request.user.username
for a in assinaturas
) if self.request.user.is_authenticated else False
context['pode_remover_assinatura'] = (
self.request.user.is_authenticated and
_pode_remover_assinatura(self.request.user)
)
return context
@ -2742,6 +2819,33 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
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
@ -3810,3 +3914,77 @@ def configEtiquetaMateriaLegislativaCrud(request):
else:
form = ConfigEtiquetaMateriaLegislativaForms(instance=config)
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!
SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234')
# SECURITY WARNING: don't run with debug turned on in production!
#DEBUG = config('DEBUG', default=False, cast=bool)
DEBUG = config('DJANGO_DEBUG', default=False, cast=bool)
# Aceita tanto DEBUG quanto DJANGO_DEBUG (compatibilidade com docker-compose legado)
DEBUG = config('DEBUG', default=False, cast=bool) or config('DJANGO_DEBUG', default=False, cast=bool)
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
@ -234,6 +234,7 @@ TEMPLATES = [
'sapl.context_processors.mail_service_configured',
'sapl.context_processors.google_recaptcha_configured',
'sapl.context_processors.enable_sapn',
'sapl.context_processors.pendencias_assinatura',
],
'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)
SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='')
# Microserviço de Assinatura Digital
# Quando ASSINATURA_API_URL estiver configurado, o SAPL delega a assinatura
# dos PDFs ao microserviço em vez de usar pyhanko localmente.
ASSINATURA_API_URL = config('ASSINATURA_API_URL', default='')
ASSINATURA_API_KEY = config('ASSINATURA_API_KEY', default='')
ASSINATURA_API_TIMEOUT = config('ASSINATURA_API_TIMEOUT', cast=int, default=120)
# Integração externa de matérias da sessão
SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='')
SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='')

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();
});
}
// ── 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 %}
<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 %}
<li class="nav-item">
<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' %}:
- assinatura_nome
- permite_remover_assinatura
{% trans 'Módulo Painel' %}:
- 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 %}
<!-- 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 -->
<div class="modal fade" id="assinaturaModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
@ -92,6 +139,8 @@
<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." %}
</div>
</form>
</div>
@ -253,6 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
init: function(materiaId, urlPrefix) {
this.materiaId = materiaId;
this.urlPrefix = urlPrefix || ('/materia/' + materiaId);
this._pdfPreviaUrl = this.urlPrefix + '/pdf-previa';
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() {
self.mostrarPrevia();
});
// Confirmação na prévia → executa assinatura
document.getElementById('btn-confirmar-assinatura').addEventListener('click', function() {
$('#previaDocumentoModal').modal('hide');
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
var btnTentarNovamente = document.querySelector('.btn-tentar-novamente-a3');
if (btnTentarNovamente) {
@ -378,6 +445,70 @@ document.addEventListener('DOMContentLoaded', function() {
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() {
if (this.tipoSelecionado === 'A1') {
this.assinarA1();
@ -402,12 +533,32 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
this.mostrarStatus('processando');
this.atualizarProgresso(10, 'Enviando certificado...');
var formData = new FormData();
formData.append('certificado', certificado);
formData.append('senha', senha);
this.mostrarStatus('processando'); this.atualizarProgresso(5, 'Enviando certificado…');
var formData = new FormData(form);
// Animação de progresso enquanto o servidor processa
var progressoTimer = null;
var progressoAtual = 5;
var fases = [
{ ate: 30, label: 'Validando certificado…', demora: 800 },
{ ate: 55, label: 'Gerando página de autenticação…', demora: 600 },
{ ate: 75, label: 'Assinando documento…', demora: 500 },
{ ate: 88, label: 'Enviando ao microserviço…', demora: 400 },
{ ate: 95, label: 'Aguardando resposta…', demora: 300 },
];
var faseIdx = 0;
function avancarProgresso() {
if (faseIdx >= fases.length) return;
var fase = fases[faseIdx];
if (progressoAtual < fase.ate) {
progressoAtual = Math.min(progressoAtual + 2, fase.ate);
self.atualizarProgresso(progressoAtual, fase.label);
} else {
faseIdx++;
}
progressoTimer = setTimeout(avancarProgresso, fase.demora);
}
progressoTimer = setTimeout(avancarProgresso, 600);
fetch(this.urlPrefix + '/assinar/a1/', {
method: 'POST',
@ -417,19 +568,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
})
.then(function(response) {
self.atualizarProgresso(60, 'Processando assinatura...');
clearTimeout(progressoTimer);
self.atualizarProgresso(97, 'Processando resposta…');
if (!response.ok) {
return response.json().then(function(d) {
throw new Error(d.error || ('Erro HTTP ' + response.status));
});
}
return response.json();
})
.then(function(data) {
self.atualizarProgresso(100, 'Finalizando...');
clearTimeout(progressoTimer);
self.atualizarProgresso(100, 'Concluído.');
if (data.success) {
self.mostrarSucesso(data);
} else {
self.mostrarErro(data.error);
self.mostrarErro(data.error || 'Erro desconhecido ao assinar.');
}
})
.catch(function(error) {
clearTimeout(progressoTimer);
self.mostrarErro('Erro de comunicação: ' + error.message);
});
},
@ -487,6 +645,14 @@ document.addEventListener('DOMContentLoaded', function() {
this.tipoSelecionado = 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('formulario-a1').style.display = 'none';
document.getElementById('formulario-a3').style.display = 'none';
@ -515,7 +681,6 @@ document.addEventListener('DOMContentLoaded', function() {
window.AssinaturaModal = AssinaturaModal;
});
</script>
<style>
.tipo-certificado-card {
cursor: pointer;
@ -552,4 +717,32 @@ document.addEventListener('DOMContentLoaded', function() {
#a3-cert-info {
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>

10
sapl/templates/materia/documentoacessorio_detail.html

@ -78,6 +78,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a>
{% 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>
@ -98,4 +105,7 @@
});
</script>
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %}

418
sapl/templates/materia/documentoacessorio_list.html

@ -58,6 +58,17 @@
<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>
</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>
{% if pode_upload %}
@ -169,4 +180,411 @@
})();
</script>
{% 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 %}

18
sapl/templates/materia/materialegislativa_detail.html

@ -25,13 +25,13 @@
<i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a>
{% 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">
<i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %}
</button>
{% 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' %}">
<i class="fa fa-file-pdf-o"></i> {% trans "Todos em PDF" %}
</a>
@ -230,6 +230,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a>
{% 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>
@ -240,7 +247,8 @@
{% block extra_js %}
{{ 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" %}
<script>
document.addEventListener('DOMContentLoaded', function() {
@ -250,4 +258,8 @@
});
</script>
{% endif %}
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %}

667
sapl/templates/materia/materialegislativa_filter.html

@ -7,36 +7,152 @@
{% load static %}
{% 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 %}
<div class="float-left">
{# Exportações (CSV, XLS, etc) #}
{% with 'sapl.materia:pesquisar_materia' as url_reverse %}
{% include "crud/format_options.html" %}
{% 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>
<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>
</div>
{% endif %}
</div>
<div class="actions btn-group float-right pb-4" role="group">
<div class="sapl-ab-right">
{% switch "SOLR_SWITCH" %}
<a href="{% url 'sapl.base:haystack_search' %}" class="btn btn-outline-primary">
Pesquisa Textual
<a href="{% url 'sapl.base:haystack_search' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-search"></i>
<span class="d-none d-md-inline"> Pesquisa Textual</span>
</a>
{% endswitch %}
{% if perms.materia.add_materialegislativa %}
<a href="{% url 'sapl.materia:materialegislativa_create' %}" class="btn btn-outline-primary">
{% blocktrans with verbose_name=view.verbose_name %} Adicionar Matéria Legislativa {% endblocktrans %}
<a href="{% url 'sapl.materia:materialegislativa_create' %}" class="btn btn-outline-primary btn-sm">
<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>
{% endif %}
{% 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 %}
</div>
{# ── /Linha 2 ────────────────────────────────────────────────────── #}
</div>
{% endblock %}
{% block detail_content %}
{% if not show_results %}
{% crispy filter.form %}
{% endif %}
@ -58,8 +174,16 @@
{% endif %}
{% for m in page_obj %}
<tr>
<tr class="materia-row" data-materia-id="{{ m.id }}">
<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>
<a href="{% url 'sapl.relatorios:etiqueta_materia_legislativa' m.pk %}"><img src="{% webpack_static 'img/etiqueta.png' %}" alt="Etiqueta Individual"></a>
</br>
@ -215,6 +339,19 @@
Texto Original
</a>
</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>
{% 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>
{% include "paginacao.html" %}
{% 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 %}
{% block table_content %}
{% endblock table_content %}
{% block extra_js %}
<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">
$( document ).ready(function() {
@ -265,7 +910,6 @@
function pesquisaAvancada(){
$('.pesquisa_avancada').toggle();
var id_btn = "#btn_pesquisa_avancada_id";
if ($(id_btn).val().endsWith('>>>')){
$(id_btn).val($(id_btn).val().replace('>>>', '<<<'))
@ -276,7 +920,6 @@
function votacaoNominal(id){
$('#div_' + id).toggle();
var id_link = "#link_votacao_nominal_" + id;
if ($(id_link).text().indexOf('>>>') > -1){
$(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>
{% 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 -->
{% if object.conteudo_gerado_related or object.materia_de_vinculo %}
<div class="prop-section">

53
sapl/templates/materia/proposicao_form.html

@ -2,8 +2,46 @@
{% load i18n %}
{% 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>
/* 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
===================================================== */
@ -188,10 +226,23 @@
padding: 8px 25px;
}
</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">
$(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"
// =====================================================

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%}
- title: {% trans 'Matérias Legislativas' %}
url: sapl.materia:pesquisar_materia
- title: {% trans 'Matérias Pendentes de Assinatura' %}
url: sapl.materia:materias_pendentes_assinatura
- title: {% trans 'Pautas das Sessões' %}
url: sapl.sessao:pauta_sessao
- title: {% trans 'Proposições' %}

Loading…
Cancel
Save