Browse Source

Merge pull request #12 from Move-System/chore/conciliacao-3.1.2-na-dev

Chore(Conciliacao): traz a 3.1.2 para a dev sem perder nada AB#1473 AB#1480
pull/3858/head
Kemuel-sepulvida 4 weeks ago
committed by GitHub
parent
commit
884be2a989
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 48
      docker/docker-compose-dev.yml
  2. 32
      docs/LOCALHOST_SETUP.md
  3. 1
      requirements/dev-requirements.txt
  4. 104
      sapl/api/views_comissoes.py
  5. 18
      sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py
  6. 10
      sapl/base/models.py
  7. 6
      sapl/base/views.py
  8. 58
      sapl/context_processors.py
  9. 345
      sapl/materia/assinatura_api_client.py
  10. 112
      sapl/materia/forms.py
  11. 199
      sapl/materia/management/commands/notificar_pendentes_assinatura.py
  12. 30
      sapl/materia/migrations/0095_add_autoria_proposicao.py
  13. 21
      sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py
  14. 19
      sapl/materia/migrations/0097_add_dispensa_protocolo_tipoproposicao.py
  15. 46
      sapl/materia/migrations/0098_marca_oficio_como_documento_gabinete.py
  16. 51
      sapl/materia/models.py
  17. 207
      sapl/materia/onlyoffice_materia_views.py
  18. 48
      sapl/materia/urls.py
  19. 332
      sapl/materia/views.py
  20. 1803
      sapl/materia/views_assinatura.py
  21. 22
      sapl/materia/views_ged.py
  22. 11
      sapl/rules/__init__.py
  23. 13
      sapl/settings.py
  24. 293
      sapl/static/js/materia_impressao_em_massa.js
  25. 22
      sapl/static/js/materia_pesquisa_download_pdfs.js
  26. 13
      sapl/templates/base.html
  27. 1
      sapl/templates/base/layouts.yaml
  28. 77
      sapl/templates/email/pendentes_assinatura.html
  29. 17
      sapl/templates/email/pendentes_assinatura.txt
  30. 31
      sapl/templates/index.html
  31. 420
      sapl/templates/materia/assinatura_doc_lote_modal.html
  32. 217
      sapl/templates/materia/assinatura_modal.html
  33. 136
      sapl/templates/materia/despachos_pendentes_lote_list.html
  34. 10
      sapl/templates/materia/documentoacessorio_detail.html
  35. 15
      sapl/templates/materia/documentoacessorio_list.html
  36. 151
      sapl/templates/materia/ged_historico.html
  37. 22
      sapl/templates/materia/materialegislativa_detail.html
  38. 701
      sapl/templates/materia/materialegislativa_filter.html
  39. 126
      sapl/templates/materia/materias_pendentes_assinatura_list.html
  40. 23
      sapl/templates/materia/proposicao_detail.html
  41. 228
      sapl/templates/materia/proposicao_form.html
  42. 3
      sapl/templates/materia/proposicao_list.html
  43. 135
      sapl/templates/materia/remover_assinatura_modal.html
  44. 4
      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

32
docs/LOCALHOST_SETUP.md

@ -61,9 +61,10 @@ EMAIL_USE_TLS=True
EMAIL_PORT=587
```
> **`DJANGO_DEBUG`, não `DEBUG`**: o `settings.py` lê o modo debug do Django de
> `DJANGO_DEBUG` (linha 38). A variável `DEBUG` só controla o log de conexão do banco.
> Defina as duas.
> **`DEBUG` ou `DJANGO_DEBUG`**: desde a conciliação com a `3.1.2`, o `settings.py`
> aceita as duas (`settings.py:38` — `DEBUG or DJANGO_DEBUG`, compatibilidade com o
> docker-compose legado). Definir uma basta; definir as duas não faz mal. Em versões
> anteriores deste documento, só `DJANGO_DEBUG` valia.
>
> **Senha com caractere especial** precisa ser URL-encoded no `DATABASE_URL`:
> `@``%40`, `:``%3A`, `/``%2F`. Ex.: `S3nh@2026` vira `S3nh%402026`.
@ -219,3 +220,28 @@ curl -s http://localhost:8001/ | grep -o '<title>[^<]*</title>'
Acesse <http://localhost:8001> — o título deve ser
`SGVP - Câmara Municipal de Franco da Rocha`. Use os usuários já cadastrados no banco
(os mesmos do remoto, se você fez a cópia da seção 4) para logar.
## 7) Problemas comuns
Vindos do roteiro que a `3.1.2` mantinha em paralelo.
### O container não alcança o banco remoto
O `docker-compose-dev.yml` já configura `extra_hosts: host-gateway`. Se ainda assim
falhar, teste o alcance antes de mexer no Django:
```bash
nc -zv <host-do-banco> 5432
```
### Static files não carregam com Gunicorn
```bash
docker exec -it sapl-dev python manage.py collectstatic --noinput
```
> O roteiro da `3.1.2` descrevia também um `docker/docker-compose-local.yml` (aplicação
> + Postgres no mesmo `up`). **Esse arquivo não existe** em nenhuma das duas linhas — os
> composes versionados são `docker-compose.yaml`, `docker-compose-dev.yml` e
> `docker-compose-dev-db.yml`. As instruções que dependiam dele ficaram de fora da
> conciliação de propósito; se o arquivo existir na sua máquina, ele nunca foi commitado.

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

104
sapl/api/views_comissoes.py

@ -1,12 +1,17 @@
from django.apps.registry import apps
from django.db.models import Q
from django.utils import timezone
from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
customize, wrapper_queryset_response_for_drf_action
from sapl.comissoes.models import Comissao
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from sapl.comissoes.models import Comissao, Composicao, Participacao
from sapl.materia.models import MateriaEmTramitacao
from sapl.parlamentares.models import Legislatura
ApiViewSetConstrutor.build_class(
@ -15,9 +20,106 @@ ApiViewSetConstrutor.build_class(
]
)
class _ParticipacaoSerializer(serializers.ModelSerializer):
parlamentar_id = serializers.IntegerField(source='parlamentar.id')
nome_parlamentar = serializers.CharField(source='parlamentar.nome_parlamentar')
nome_completo = serializers.CharField(source='parlamentar.nome_completo')
cargo_id = serializers.IntegerField(source='cargo.id')
cargo_nome = serializers.CharField(source='cargo.nome')
cargo_ordenacao = serializers.IntegerField(source='cargo.id_ordenacao', allow_null=True)
class Meta:
model = Participacao
fields = [
'id',
'parlamentar_id',
'nome_parlamentar',
'nome_completo',
'cargo_id',
'cargo_nome',
'cargo_ordenacao',
'titular',
'data_designacao',
'data_desligamento',
]
class _ComissaoVigenteSerializer(serializers.ModelSerializer):
tipo_nome = serializers.CharField(source='tipo.nome')
membros = serializers.SerializerMethodField()
class Meta:
model = Comissao
fields = [
'id',
'nome',
'sigla',
'tipo_nome',
'ativa',
'data_criacao',
'data_extincao',
'email',
'membros',
]
def get_membros(self, comissao):
hoje = self.context.get('hoje')
legislatura = self.context.get('legislatura')
if not legislatura:
return []
# Composições cujo período cobre a legislatura vigente
composicoes = Composicao.objects.filter(
comissao=comissao,
periodo__data_inicio__lte=legislatura.data_fim,
).filter(
Q(periodo__data_fim__isnull=True) |
Q(periodo__data_fim__gte=legislatura.data_inicio)
)
# Participações sem data de desligamento ou desligamento futuro
participacoes = Participacao.objects.filter(
composicao__in=composicoes,
).filter(
Q(data_desligamento__isnull=True) |
Q(data_desligamento__gte=hoje)
).select_related(
'parlamentar', 'cargo'
).order_by('cargo__id_ordenacao', 'parlamentar__nome_parlamentar')
return _ParticipacaoSerializer(participacoes, many=True).data
@customize(Comissao)
class _ComissaoViewSet:
@action(detail=False, url_path='vigentes')
def vigentes(self, request, *args, **kwargs):
"""
Retorna as comissões vigentes com seus membros (cargo + vereador)
da composição dentro da legislatura vigente.
"""
hoje = timezone.localdate()
legislatura = Legislatura.objects.filter(
data_inicio__lte=hoje,
data_fim__gte=hoje,
).first()
qs = Comissao.objects.filter(
Q(ativa=True) |
Q(data_extincao__isnull=True) |
Q(data_extincao__gte=hoje)
).distinct().select_related('tipo').order_by('nome')
serializer = _ComissaoVigenteSerializer(
qs,
many=True,
context={'hoje': hoje, 'legislatura': legislatura, 'request': request},
)
return Response(serializer.data)
@action(detail=True)
def materiaemtramitacao(self, request, *args, **kwargs):
return self.get_materiaemtramitacao(**kwargs)

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'),

6
sapl/base/views.py

@ -47,6 +47,7 @@ from sapl.parlamentares.models import (
Filiacao, Legislatura, Mandato, Parlamentar)
from sapl.protocoloadm.models import (Anexado, Protocolo)
from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas)
from sapl.rules import is_procurador_juridico
from sapl.sessao.models import (Bancada, SessaoPlenaria)
from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE
from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured,
@ -77,10 +78,15 @@ class IndexView(TemplateView):
# Verifica se é Operador de Protocolo Administrativo
context['is_operador_protocolo'] = self.request.user.has_perm(
'protocoloadm.add_documentoadministrativo')
# Procurador Jurídico: tela inicial restrita a Matérias
# Legislativas (customização Franco da Rocha)
context['is_procurador_juridico'] = is_procurador_juridico(
self.request.user)
else:
context['is_parlamentar'] = False
context['autor_id'] = None
context['is_operador_protocolo'] = False
context['is_procurador_juridico'] = False
return context

58
sapl/context_processors.py

@ -32,6 +32,64 @@ 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'],
}
def ged_configurado(request):
"""Injeta flag indicando se o GED (acervo histórico) está configurado."""
from sapl.materia.views_ged import GED_URL
return {'ged_configurado': bool(GED_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}'

112
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)
@ -1547,7 +1597,8 @@ class TipoProposicaoForm(ModelForm):
'content_type',
'tipo_conteudo_related_radio',
'tipo_conteudo_related',
'perfis']
'perfis',
'dispensa_protocolo']
widgets = {'tipo_conteudo_related': forms.HiddenInput(),
'perfis': widgets.CheckboxSelectMultiple()}
@ -1562,6 +1613,7 @@ class TipoProposicaoForm(ModelForm):
Row(
to_column(('descricao', 12)),
to_column(('perfis', 12)),
to_column(('dispensa_protocolo', 12)),
),
5
)
@ -1949,6 +2001,17 @@ 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.CheckboxSelectMultiple(attrs={
'class': 'coautores-checkbox',
}),
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'},
),
]

19
sapl/materia/migrations/0097_add_dispensa_protocolo_tipoproposicao.py

@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materia', '0096_add_permite_remover_assinatura_e_permissoes'),
]
operations = [
migrations.AddField(
model_name='tipoproposicao',
name='dispensa_protocolo',
field=models.BooleanField(
default=False,
help_text='Quando marcado, proposições deste tipo não são enviadas ao Protocolo e não se tornam Matéria Legislativa. Ficam restritas ao gabinete do autor. Use para documentos de uso próprio do gabinete, como Ofícios, que não precisam de validação da Casa.',
verbose_name='Documento de gabinete'),
),
]

46
sapl/materia/migrations/0098_marca_oficio_como_documento_gabinete.py

@ -0,0 +1,46 @@
import unicodedata
from django.db import migrations
# Tipos de proposição que já nascem como documento de gabinete, sem
# necessidade de configuração manual em Tabelas Auxiliares.
DESCRICOES_GABINETE = {'oficio'}
def _normalizar(descricao):
"""Remove acentos, espaços das pontas e caixa, para comparar descrições.
A descrição é digitada pelo usuário em cada Casa, então aparece como
'Ofício', 'OFICIO', 'oficio' etc.
"""
texto = unicodedata.normalize('NFKD', descricao or '')
texto = ''.join(c for c in texto if not unicodedata.combining(c))
return texto.strip().lower()
def _aplicar(apps, valor):
TipoProposicao = apps.get_model('materia', 'TipoProposicao')
for tipo in TipoProposicao.objects.all():
if _normalizar(tipo.descricao) in DESCRICOES_GABINETE:
tipo.dispensa_protocolo = valor
tipo.save(update_fields=['dispensa_protocolo'])
def marcar(apps, schema_editor):
_aplicar(apps, True)
def desmarcar(apps, schema_editor):
_aplicar(apps, False)
class Migration(migrations.Migration):
dependencies = [
('materia', '0097_add_dispensa_protocolo_tipoproposicao'),
]
operations = [
migrations.RunPython(marcar, desmarcar),
]

51
sapl/materia/models.py

@ -71,6 +71,15 @@ class TipoProposicao(models.Model):
menos um Perfil Estrutural de Texto Articulado.
"""))
dispensa_protocolo = models.BooleanField(
default=False,
verbose_name=_('Documento de gabinete'),
help_text=_('Quando marcado, proposições deste tipo não são enviadas '
'ao Protocolo e não se tornam Matéria Legislativa. Ficam '
'restritas ao gabinete do autor. Use para documentos de '
'uso próprio do gabinete, como Ofícios, que não precisam '
'de validação da Casa.'))
class Meta:
verbose_name = _('Tipo de Proposição')
verbose_name_plural = _('Tipos de Proposições')
@ -345,7 +354,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 +646,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 +1173,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)

48
sapl/materia/urls.py

@ -1,5 +1,6 @@
from django.conf.urls import include, url
from sapl.materia.views_ged import ged_historico
from sapl.materia.views import (AcompanhamentoConfirmarView,
AcompanhamentoExcluirView,
AcompanhamentoMateriaView, AnexadaCrud,
@ -16,6 +17,8 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
LegislacaoCitadaCrud, MateriaAssuntoCrud,
MateriaLegislativaCrud,
MateriaLegislativaPesquisaView, MateriaTaView,
MateriasPendentesAssinaturaView,
DespachosPendentesLoteView,
NumeracaoCrud, OrgaoCrud, OrigemCrud,
PrimeiraTramitacaoEmLoteView, ProposicaoCrud,
ProposicaoDevolvida, ProposicaoPendente,
@ -33,6 +36,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 +50,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 +128,13 @@ 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/despachos-pendentes-lote$',
DespachosPendentesLoteView.as_view(), name='despachos_pendentes_lote'),
url(r'^materia/(?P<pk>\d+)/acompanhar-materia/$',
AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'),
url(r'^materia/(?P<pk>\d+)/acompanhar-confirmar$',
@ -158,6 +171,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 +190,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 +201,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 +211,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 +236,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 +250,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'),
@ -320,5 +355,10 @@ urlpatterns_sistema = [
url(r'^sistema/materia/config-etiqueta-materia-legislativas/',configEtiquetaMateriaLegislativaCrud, name="configEtiquetaMateriaLegislativaCrud"),
]
urlpatterns_ged = [
url(r'^materia/acervo-historico/$', ged_historico,
name='ged_historico'),
]
urlpatterns = urlpatterns_impressos + urlpatterns_materia + \
urlpatterns_proposicao + urlpatterns_sistema
urlpatterns_proposicao + urlpatterns_sistema + urlpatterns_ged

332
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
@ -483,7 +483,8 @@ class TipoProposicaoCrud(CrudAux):
class BaseMixin(CrudAux.BaseMixin):
list_field_names = [
"descricao", "content_type", 'tipo_conteudo_related']
"descricao", "content_type", 'tipo_conteudo_related',
'dispensa_protocolo']
class CreateView(CrudAux.CreateView):
form_class = TipoProposicaoForm
@ -641,6 +642,135 @@ 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'
)
# Flag pra mostrar atalho "Assinar Despachos em Lote" só pra
# Presidente da Mesa Diretora (feature dedicada — assina em lote
# todos os DocumentoAcessorio do tipo Despacho ainda pendentes)
from sapl.rules import SGVP_GROUP_PRESIDENTE_MESA
u = self.request.user
context['is_presidente_mesa'] = u.is_authenticated and (
u.is_superuser or
u.groups.filter(name=SGVP_GROUP_PRESIDENTE_MESA).exists()
)
return context
class DespachosPendentesLoteView(LoginRequiredMixin, ListView):
"""
Lista todos os Documentos Acessórios do tipo "Despacho" que ainda
não têm assinatura digital (pdf_assinado vazio), para que o
Presidente da Mesa Diretora possa assiná-los em lote.
Reusa o backend `docacessorio_assinar_lote` (views_assinatura.py),
que aceita PKs de documentos de múltiplas matérias. Reusa também
o modal de assinatura em lote existente em
`documentoacessorio_list.html` (copiado no template desta view).
Acesso restrito ao grupo `Presidente da Mesa Diretora` (ou
superuser, para depuração). Demais usuários recebem 403.
"""
template_name = 'materia/despachos_pendentes_lote_list.html'
model = DocumentoAcessorio
paginate_by = 50
login_url = '/login/'
def _is_presidente(self):
from sapl.rules import SGVP_GROUP_PRESIDENTE_MESA
u = self.request.user
return u.is_superuser or u.groups.filter(
name=SGVP_GROUP_PRESIDENTE_MESA
).exists()
def dispatch(self, request, *args, **kwargs):
# LoginRequiredMixin já trata anônimo
if request.user.is_authenticated and not self._is_presidente():
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
'Acesso restrito ao grupo "Presidente da Mesa Diretora".'
)
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
# Filtro frouxo por nome do tipo — pega "Despacho", "Despacho do
# Presidente", "Despacho Inicial", etc. Decisão de produto.
return DocumentoAcessorio.objects.filter(
tipo__descricao__icontains='despacho'
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).select_related('materia', 'materia__tipo', 'tipo').order_by(
'-data', '-id'
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# `docs_pendentes_lote` no MESMO formato que o modal de
# documentoacessorio_list.html espera (id + descricao). Aqui a
# descrição inclui referência à matéria pra o presidente
# conseguir identificar de qual matéria é o despacho.
docs = list(context['object_list'])
context['docs_pendentes_lote'] = [
{
'id': d.pk,
'descricao': (
f'{d.nome}{d.materia.tipo.sigla} '
f'{d.materia.numero}/{d.materia.ano} '
f'({d.tipo}) — {d.data}'
),
}
for d in docs
]
paginator = context['paginator']
page_obj = context['page_obj']
context['page_range'] = make_pagination(
page_obj.number, paginator.num_pages
)
context['total'] = paginator.count
return context
class RevisarProposicaoSetor(PermissionRequiredMixin, UpdateView):
template_name = "materia/revisar_proposicao_setor.html"
model = Proposicao
@ -904,6 +1034,13 @@ class UnidadeTramitacaoCrud(CrudAux):
form_class = UnidadeTramitacaoForm
# Tipos de proposição marcados como "Documento de gabinete" não saem do
# gabinete do autor: não vão ao Protocolo nem ao Setor Legislativo.
MSG_DISPENSA_PROTOCOLO = _(
'Este é um documento de gabinete e não passa pelo Protocolo. '
'Ele fica restrito ao seu gabinete.')
class ProposicaoCrud(Crud):
model = Proposicao
help_topic = 'proposicao'
@ -989,7 +1126,9 @@ class ProposicaoCrud(Crud):
msg_error = ''
if p and p.autor.operadores.filter(id=request.user.id).exists():
if action == 'send':
if p.data_envio and p.data_recebimento:
if p.tipo and p.tipo.dispensa_protocolo:
msg_error = MSG_DISPENSA_PROTOCOLO
elif p.data_envio and p.data_recebimento:
msg_error = _('Proposição já foi enviada e recebida.')
elif p.data_envio:
msg_error = _('Proposição já foi enviada.')
@ -1085,7 +1224,9 @@ class ProposicaoCrud(Crud):
elif action == 'send_setor':
app_config = sapl.base.models.AppConfig.objects.all().last()
if not app_config or not app_config.revisao_setor_legislativo:
if p.tipo and p.tipo.dispensa_protocolo:
msg_error = MSG_DISPENSA_PROTOCOLO
elif not app_config or not app_config.revisao_setor_legislativo:
msg_error = _('Revisão pelo Setor Legislativo não está habilitada.')
elif p.data_envio:
msg_error = _('Proposição já foi enviada ao protocolo.')
@ -1340,7 +1481,12 @@ class ProposicaoCrud(Crud):
status_filter = self.request.GET.get('status', '')
if status_filter == 'elaboracao':
qs = qs.filter(data_envio__isnull=True, cancelado=False)
qs = qs.filter(
data_envio__isnull=True, cancelado=False
).exclude(tipo__dispensa_protocolo=True)
elif status_filter == 'gabinete':
qs = qs.filter(tipo__dispensa_protocolo=True,
data_envio__isnull=True, cancelado=False)
elif status_filter == 'aguardando':
qs = qs.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False)
elif status_filter == 'incorporada':
@ -1357,7 +1503,8 @@ class ProposicaoCrud(Crud):
qs_base = super().get_queryset()
stats = {
'total': qs_base.count(),
'elaboracao': qs_base.filter(data_envio__isnull=True, cancelado=False).count(),
'elaboracao': qs_base.filter(data_envio__isnull=True, cancelado=False).exclude(tipo__dispensa_protocolo=True).count(),
'gabinete': qs_base.filter(tipo__dispensa_protocolo=True, data_envio__isnull=True, cancelado=False).count(),
'aguardando': qs_base.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False).count(),
'incorporada': qs_base.filter(data_recebimento__isnull=False, cancelado=False).count(),
'devolvida': qs_base.filter(data_devolucao__isnull=False, cancelado=False).count(),
@ -1392,6 +1539,10 @@ class ProposicaoCrud(Crud):
status = 'aguardando'
status_label = 'Aguardando Recebimento'
status_icon = 'fa-clock-o'
elif obj.tipo and obj.tipo.dispensa_protocolo:
status = 'gabinete'
status_label = 'Documento de Gabinete'
status_icon = 'fa-briefcase'
else:
status = 'elaboracao'
status_label = 'Em Elaboração'
@ -1822,6 +1973,12 @@ def montar_helper_documento_acessorio(self):
' class="btn btn-dark">Cancelar</a>')]))
# Valores pré-preenchidos no Documento Acessório do Procurador Jurídico
# (customização Franco da Rocha)
DESCRICAO_PARECER_JURIDICO = 'Parecer Jurídico'
NOME_PARECER_APROVADO = 'Aprovado'
class DocumentoAcessorioCrud(MasterDetailCrud):
model = DocumentoAcessorio
parent_field = 'materia'
@ -1833,10 +1990,28 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
class CreateView(MasterDetailCrud.CreateView):
form_class = DocumentoAcessorioForm
logger = logging.getLogger(__name__)
def get_initial(self):
from sapl.rules import is_procurador_juridico
initial = super(CreateView, self).get_initial()
initial['data'] = timezone.now().date()
# Procurador Jurídico já abre o formulário preenchido como
# Parecer Jurídico aprovado (customização Franco da Rocha).
# Os campos continuam editáveis.
if is_procurador_juridico(self.request.user):
tipo = TipoDocumento.objects.filter(
descricao__iexact=DESCRICAO_PARECER_JURIDICO).first()
if tipo:
initial['tipo'] = tipo
else:
self.logger.warning(
'Tipo de Documento "%s" não cadastrado: o campo Tipo '
'do Documento Acessório não será pré-preenchido.',
DESCRICAO_PARECER_JURIDICO)
initial['nome'] = NOME_PARECER_APROVADO
return initial
def get_success_url(self):
@ -1949,13 +2124,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 +2147,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 +2683,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 +2947,41 @@ 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'] = []
# Flag pra mostrar atalho "Assinar Despachos em Lote" só pra
# Presidente da Mesa Diretora (vê DespachosPendentesLoteView)
from sapl.rules import SGVP_GROUP_PRESIDENTE_MESA
u = self.request.user
context['is_presidente_mesa'] = u.is_authenticated and (
u.is_superuser or
u.groups.filter(name=SGVP_GROUP_PRESIDENTE_MESA).exists()
)
return context
@ -3810,3 +4050,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

1803
sapl/materia/views_assinatura.py

File diff suppressed because it is too large

22
sapl/materia/views_ged.py

@ -0,0 +1,22 @@
"""
View pública para acesso ao acervo histórico via GED (sistema legado).
Issue #1326 / Task #1348 – Adicionar tela do GED dentro do Legislativo.
"""
from decouple import config
from django.shortcuts import render
from django.views.decorators.cache import cache_control
# Sem default: se GED_URL não estiver no .env, retorna string vazia.
GED_URL = config('GED_URL', default='')
@cache_control(public=True, max_age=300)
def ged_historico(request):
"""
Exibe o sistema GED (acervo histórico matérias anteriores a 2026)
incorporado em iframe. Acesso público, sem autenticação.
Se GED_URL não estiver configurado, exibe mensagem informativa.
"""
return render(request, 'materia/ged_historico.html', {
'ged_url': GED_URL or '',
})

11
sapl/rules/__init__.py

@ -97,3 +97,14 @@ SGVP_GROUPS = [
SGVP_GROUPS_DELETE = [
]
def is_procurador_juridico(user):
"""Identifica o Procurador Jurídico pelo grupo SGVP_GROUP_NORMA.
Customização da Câmara de Franco da Rocha: o usuário desse grupo tem a
tela inicial reduzida ao módulo de Matérias Legislativas e recebe o
Documento Acessório pré-preenchido como Parecer Jurídico aprovado.
"""
return bool(user and user.is_authenticated and
user.groups.filter(name=SGVP_GROUP_NORMA).exists())

13
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'
@ -235,6 +235,8 @@ TEMPLATES = [
'sapl.context_processors.mail_service_configured',
'sapl.context_processors.google_recaptcha_configured',
'sapl.context_processors.enable_sapn',
'sapl.context_processors.pendencias_assinatura',
'sapl.context_processors.ged_configurado',
],
'debug': DEBUG
},
@ -318,6 +320,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.

31
sapl/templates/index.html

@ -6,6 +6,21 @@
{% block base_content%}
<div class="container-home">
<div id="homeIndex">
{% if is_procurador_juridico %}
<!-- Procurador Jurídico: tela inicial restrita a Matérias Legislativas -->
<div class="homeBlock">
<div class="card-icon">
<i class="fas fa-file-alt"></i>
</div>
<h3 class="card-title">Matérias Legislativas</h3>
<p class="card-description">Projetos de lei e proposições</p>
<div class="card-actions">
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn-action" title="Pesquisar Matérias">
<i class="fas fa-search"></i> Pesquisar
</a>
</div>
</div>
{% else %}
<!-- Proposições -->
{% if is_parlamentar %}
<div class="homeBlock">
@ -155,6 +170,21 @@
</a>
</div>
</div>
<!-- Acervo Histórico (GED) -->
{% if ged_configurado %}
<div class="homeBlock">
<div class="card-icon">
<i class="fas fa-archive"></i>
</div>
<h3 class="card-title">Acervo Histórico</h3>
<p class="card-description">Matérias anteriores a 2026 (sistema legado)</p>
<div class="card-actions">
<a href="{% url 'sapl.materia:ged_historico' %}" class="btn-action" title="Acessar Acervo Histórico">
<i class="fas fa-folder-open"></i> Acessar
</a>
</div>
</div>
{% endif %}
<!-- Relatórios -->
<div class="homeBlock">
@ -233,6 +263,7 @@
</div>
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

420
sapl/templates/materia/assinatura_doc_lote_modal.html

@ -0,0 +1,420 @@
{% comment %}
Partial reusavel: Modal de Assinatura Digital em Lote de Documentos
Acessorios. Espera no contexto a variavel `docs_pendentes_lote`
(lista de dicts com chaves `id` e `descricao`). O caller deve
envolver com if docs_pendentes_lote include endif e renderizar em
algum lugar um botao com id="btn-assinar-doc-lote" para disparar o
modal.
Backend: POST para sapl.materia:docacessorio_assinar_lote
(views_assinatura.py::docacessorio_assinar_lote).
Usado por:
- sapl/templates/materia/documentoacessorio_list.html
(assinar docs acessorios pendentes de UMA materia)
- sapl/templates/materia/despachos_pendentes_lote_list.html
(assinar despachos pendentes de TODAS as materias - Presidente da Mesa)
{% endcomment %}
<div class="modal fade" id="assinaturaDocLoteModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaDocLoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title" id="assinaturaDocLoteModalLabel">
<i class="fa fa-certificate"></i>
Assinar Documentos Acessórios em Lote &mdash;
<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&hellip;
</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&hellip;</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 &mdash; <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>

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>

136
sapl/templates/materia/despachos_pendentes_lote_list.html

@ -0,0 +1,136 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load static %}
{% block actions %}
<div class="actions btn-group float-right pb-4" role="group">
<a href="{% url 'sapl.materia:materias_pendentes_assinatura' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {% trans "Voltar para Minhas Matérias" %}
</a>
</div>
{% endblock %}
{% block detail_content %}
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap" style="gap:.75rem;">
<div>
<h4 class="mb-0">
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:1rem;">
<i class="fas fa-stamp mr-1"></i> {% trans "Despachos Pendentes de Assinatura" %}
</span>
</h4>
<small class="text-muted mt-1 d-block">
<i class="fas fa-user-tie mr-1"></i>
{% trans "Acesso exclusivo do" %} <strong>{% trans "Presidente da Mesa Diretora" %}</strong>
</small>
</div>
<div>
{% if total == 0 %}
<span class="badge badge-success px-3 py-2" style="font-size:.95rem;">
<i class="fas fa-check-circle mr-1"></i> {% trans "Nenhum despacho pendente" %}
</span>
{% elif total == 1 %}
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;">
1 {% trans "despacho pendente" %}
</span>
{% else %}
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;">
{{ total }} {% trans "despachos pendentes" %}
</span>
{% endif %}
</div>
</div>
{% if not object_list %}
<div class="alert alert-success d-flex align-items-center" role="alert">
<i class="fas fa-check-circle fa-2x mr-3"></i>
<div>
<strong>{% trans "Tudo em dia!" %}</strong><br>
{% trans "Não há despachos pendentes de assinatura digital no sistema." %}
</div>
</div>
{% else %}
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3 flex-wrap"
role="alert" style="border-left: 5px solid #f0ad4e; gap:1rem;">
<div>
<i class="fas fa-exclamation-triangle fa-lg mr-2"></i>
{% trans "Os despachos abaixo aguardam assinatura digital. Você pode assiná-los todos em lote com seu certificado digital." %}
</div>
<button type="button" id="btn-assinar-doc-lote"
class="btn btn-warning"
title="{% trans 'Assinar digitalmente todos os despachos selecionados em lote' %}">
<i class="fa fa-certificate"></i>
{% trans "Assinar Despachos em Lote" %}
<span class="badge badge-light text-dark ml-1">{{ docs_pendentes_lote|length }}</span>
</button>
</div>
<table class="table table-hover table-bordered">
<thead class="thead-light">
<tr>
<th style="width:170px;">{% trans "Matéria" %}</th>
<th>{% trans "Nome do Despacho" %}</th>
<th style="width:140px;">{% trans "Tipo" %}</th>
<th style="width:140px;">{% trans "Autor" %}</th>
<th style="width:110px;" class="text-center">{% trans "Data" %}</th>
<th style="width:90px;" class="text-center">{% trans "Ações" %}</th>
</tr>
</thead>
<tbody>
{% for d in object_list %}
<tr>
<td>
<a href="{% url 'sapl.materia:materialegislativa_detail' d.materia.pk %}"
class="font-weight-bold"
title="{% trans 'Abrir matéria' %}">
{{ d.materia.tipo.sigla }} {{ d.materia.numero }}/{{ d.materia.ano }}
</a>
</td>
<td>
<a href="{% url 'sapl.materia:documentoacessorio_detail' d.pk %}"
title="{% trans 'Abrir documento' %}">
{{ d.nome }}
</a>
</td>
<td>
<small class="text-muted">{{ d.tipo }}</small>
</td>
<td>
{% if d.autor %}
{{ d.autor }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-center">
{% if d.data %}
{{ d.data|date:"d/m/Y" }}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-center">
{% if d.arquivo %}
<a href="{{ d.arquivo.url }}" target="_blank"
class="btn btn-sm btn-outline-primary"
title="{% trans 'Baixar arquivo original' %}">
<i class="fas fa-download"></i>
</a>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "paginacao.html" %}
{# Modal de assinatura em lote (partial reusado de documentoacessorio_list.html) #}
{% include "materia/assinatura_doc_lote_modal.html" %}
{% endif %}
{% endblock detail_content %}

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 %}

15
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,8 @@
})();
</script>
{% endif %}
{% if docs_pendentes_lote %}
{% include "materia/assinatura_doc_lote_modal.html" %}
{% endif %}
{% endblock %}

151
sapl/templates/materia/ged_historico.html

@ -0,0 +1,151 @@
{% extends "base.html" %}
{% load i18n staticfiles %}
{% block head_title %}Acervo Histórico – Matérias Anteriores a 2026 – {{ nome_casa }}{% endblock %}
{% block head_content %}
{{ block.super }}
<style>
html, body { height: 100%; overflow: hidden; }
/* Esconde footer nesta página */
#footer, footer.footer { display: none !important; }
/* Remove container padrão do conteúdo */
#content.content.page__row { padding: 0 !important; margin: 0 !important; }
#content .container { max-width: 100% !important; padding: 0 !important; margin: 0 !important; width: 100% !important; }
#ged-wrapper {
display: flex;
flex-direction: column;
height: calc(100vh - 56px);
}
#ged-header {
flex-shrink: 0;
background: linear-gradient(135deg, #1a3a5c 0%, #2e6da4 100%);
color: #fff;
padding: 12px 24px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 2px 6px rgba(0,0,0,.25);
}
#ged-header .ged-icon { font-size: 1.8rem; opacity: .9; }
#ged-header .ged-title { flex: 1; }
#ged-header .ged-title h1 { font-size: 1.05rem; font-weight: 700; margin: 0 0 2px; }
#ged-header .ged-title p { font-size: .75rem; margin: 0; opacity: .85; }
#ged-header .ged-actions a { color: #fff; border-color: rgba(255,255,255,.6); font-size: .8rem; }
#ged-header .ged-actions a:hover { background: rgba(255,255,255,.15); border-color: #fff; }
#ged-frame-container { flex: 1; position: relative; overflow: hidden; }
#ged-loading {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: #f8f9fa; z-index: 10; gap: 12px;
color: #555; font-size: .9rem; transition: opacity .4s;
}
#ged-loading.hidden { opacity: 0; pointer-events: none; }
#ged-error {
display: none; flex-direction: column; align-items: center; justify-content: center;
height: 100%; gap: 12px; color: #555; text-align: center; padding: 24px;
}
#ged-error i { font-size: 3rem; color: #c0392b; }
#ged-frame { width: 100%; height: 100%; border: none; display: block; }
</style>
{% endblock %}
{% block content_subnav %}{% endblock %}
{% block footer_container %}{% endblock %}
{% block content_container %}
{% if ged_url %}
<div id="ged-wrapper">
<div id="ged-header">
<div class="ged-icon"><i class="fas fa-archive"></i></div>
<div class="ged-title">
<h1>Acervo Histórico – Matérias Legislativas</h1>
<p>Documentos do sistema anterior (GED) · registros anteriores a 2026</p>
</div>
<div class="ged-actions d-none d-md-flex" style="gap:8px;">
<a href="{{ ged_url }}" target="_blank" rel="noopener noreferrer"
class="btn btn-sm btn-outline-light" title="Abrir em nova aba">
<i class="fas fa-external-link-alt"></i> Abrir em nova aba
</a>
</div>
</div>
<div id="ged-frame-container">
<div id="ged-loading">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Carregando…</span>
</div>
<span>Carregando acervo histórico…</span>
</div>
<div id="ged-error">
<i class="fas fa-exclamation-triangle"></i>
<strong>Não foi possível carregar o acervo histórico.</strong>
<p class="mb-1">O sistema GED pode estar temporariamente indisponível.</p>
<a href="{{ ged_url }}" target="_blank" rel="noopener noreferrer"
class="btn btn-primary btn-sm mt-2">
<i class="fas fa-external-link-alt"></i> Tentar acessar diretamente
</a>
</div>
<iframe
id="ged-frame"
src="{{ ged_url }}"
title="Acervo Histórico – Matérias Legislativas (GED)"
loading="lazy"
allow="fullscreen"
></iframe>
</div>
</div>
<script>
(function () {
var frame = document.getElementById('ged-frame');
var loading = document.getElementById('ged-loading');
var error = document.getElementById('ged-error');
frame.addEventListener('load', function () {
loading.classList.add('hidden');
setTimeout(function () { loading.style.display = 'none'; }, 450);
});
var timeout = setTimeout(function () {
if (!loading.classList.contains('hidden')) {
loading.style.display = 'none';
frame.style.display = 'none';
error.style.display = 'flex';
}
}, 15000);
frame.addEventListener('load', function () { clearTimeout(timeout); });
})();
</script>
{% else %}
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center;
min-height: 60vh; text-align:center; padding: 40px 24px; color:#555;">
<i class="fas fa-archive" style="font-size:4rem; color:#adb5bd; margin-bottom:20px;"></i>
<h2 style="font-size:1.4rem; font-weight:700; color:#343a40; margin-bottom:12px;">
Acervo Histórico não disponível
</h2>
<p style="max-width:480px; font-size:.95rem; line-height:1.6; margin-bottom:24px;">
Esta instalação não possui proposições legadas configuradas.<br>
Caso necessite acessar matérias de sistemas anteriores, entre em contato com o administrador do sistema.
</p>
<a href="/" class="btn btn-outline-secondary">
<i class="fas fa-home"></i> Voltar à página inicial
</a>
</div>
{% endif %}
{% endblock content_container %}

22
sapl/templates/materia/materialegislativa_detail.html

@ -19,19 +19,19 @@
<i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %}
</a>
{% endif %}
{% if object.numero_protocolo and object.texto_original %}
{% if object.texto_original %}
{% if object.pdf_assinado %}
<a class="btn btn-success" href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" target="_blank">
<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 %}
{% 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() {
@ -249,5 +257,9 @@
}
});
</script>
{% endif %}
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %}
{% endblock extra_js %}

701
sapl/templates/materia/materialegislativa_filter.html

@ -7,36 +7,162 @@
{% 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>
{% if show_results %}
<div class="float-left">
{% with 'sapl.materia:pesquisar_materia' as url_reverse %}
{% include "crud/format_options.html" %}
<div class="sapl-ab">
{# ── Linha 1: ações + navegação ─────────────────────────────────── #}
<div class="sapl-ab-row1">
<div class="sapl-ab-left">
{% if show_results %}
{# Exportações (CSV, XLS, etc) #}
{% with 'sapl.materia:pesquisar_materia' as url_reverse %}
{% include "crud/format_options.html" %}
{% endwith %}
{# 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>
{% endif %}
</div>
<div class="sapl-ab-right">
{% switch "SOLR_SWITCH" %}
<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 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 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 %}
<button id="btn-download-todos-pdfs" class="btn btn-outline-secondary" title="Baixar todos os PDFs das matérias filtradas">
<i class="fas fa-download"></i>
{# 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>
</div>
{% endif %}
{% endif %}
<div class="actions btn-group float-right pb-4" role="group">
{% switch "SOLR_SWITCH" %}
<a href="{% url 'sapl.base:haystack_search' %}" class="btn btn-outline-primary">
Pesquisa Textual
</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 %}
{# Assinar Despachos em Lote — atalho exclusivo do Presidente da Mesa Diretora #}
{% if is_presidente_mesa %}
<a href="{% url 'sapl.materia:despachos_pendentes_lote' %}"
class="btn btn-warning btn-sm"
title="Assinar todos os despachos pendentes do sistema em lote (acesso exclusivo do Presidente da Mesa Diretora)">
<i class="fas fa-stamp"></i>
Assinar Despachos em Lote
</a>
{% endif %}
{% if show_results %}
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary">{% trans 'Fazer nova pesquisa' %}</a>
{% endif %}
</div>
{# ── /Linha 2 ────────────────────────────────────────────────────── #}
</div>
{% endblock %}
{% block detail_content %}
{% if not show_results %}
{% crispy filter.form %}
{% endif %}
@ -58,8 +184,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 +349,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,13 +397,521 @@
</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>
<script type="text/javascript" >
{# ── 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() {
$('.link_votacao_nominal').on('click', function(event) {
event.preventDefault();
@ -265,7 +920,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 +930,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('>>>', '<<<'))

126
sapl/templates/materia/materias_pendentes_assinatura_list.html

@ -0,0 +1,126 @@
{% extends "crud/detail.html" %}
{% load i18n %}
{% load static %}
{% block actions %}
<div class="actions btn-group float-right pb-4" role="group">
{% if is_presidente_mesa %}
<a href="{% url 'sapl.materia:despachos_pendentes_lote' %}"
class="btn btn-warning"
title="{% trans 'Assinar todos os despachos pendentes em lote (acesso exclusivo do Presidente da Mesa Diretora)' %}">
<i class="fas fa-stamp"></i> {% trans "Assinar Despachos em Lote" %}
</a>
{% endif %}
<a href="{{ url_pesquisa_completa }}" class="btn btn-outline-secondary">
<i class="fas fa-search"></i> {% trans "Ver na Pesquisa Completa" %}
</a>
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary">
<i class="fas fa-list"></i> {% trans "Pesquisar Matérias" %}
</a>
</div>
{% endblock %}
{% block detail_content %}
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap" style="gap:.75rem;">
<div>
<h4 class="mb-0">
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:1rem;">
<i class="fas fa-clock mr-1"></i> Matérias Pendentes de Assinatura
</span>
</h4>
{% if autor %}
<small class="text-muted mt-1 d-block">
<i class="fas fa-user mr-1"></i> Autor: <strong>{{ autor }}</strong>
</small>
{% endif %}
</div>
<div>
{% if total == 0 %}
<span class="badge badge-success px-3 py-2" style="font-size:.95rem;">
<i class="fas fa-check-circle mr-1"></i> Nenhuma pendência
</span>
{% elif total == 1 %}
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;">
1 matéria pendente
</span>
{% else %}
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;">
{{ total }} matérias pendentes
</span>
{% endif %}
</div>
</div>
{% if not object_list %}
<div class="alert alert-success d-flex align-items-center" role="alert">
<i class="fas fa-check-circle fa-2x mr-3"></i>
<div>
<strong>Tudo em dia!</strong><br>
Não há matérias com documento pendente de assinatura digital.
</div>
</div>
{% else %}
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert"
style="border-left: 5px solid #f0ad4e;">
<i class="fas fa-exclamation-triangle fa-lg mr-2"></i>
As matérias abaixo possuem <strong>&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 %}

23
sapl/templates/materia/proposicao_detail.html

@ -351,7 +351,13 @@
<a href="{% url 'sapl.materia:onlyoffice_editor' object.pk %}" class="btn btn-primary">
<i class="fa fa-edit"></i> {% trans "Editar Documento" %}
</a>
{% if object.texto_original or object.texto_articulado.exists %}
{% if object.tipo.dispensa_protocolo %}
{# Documento de gabinete: não vai ao Protocolo nem ao Setor Legislativo #}
<span class="badge badge-secondary align-middle p-2">
<i class="fa fa-briefcase"></i>
{% trans "Documento de gabinete — não passa pelo Protocolo" %}
</span>
{% elif object.texto_original or object.texto_articulado.exists %}
<a href="{{ view.detail_url }}?action=send" class="btn btn-success">
<i class="fa fa-paper-plane"></i> {% trans "Enviar Proposição" %}
</a>
@ -460,6 +466,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">

228
sapl/templates/materia/proposicao_form.html

@ -2,8 +2,88 @@
{% load i18n %}
{% load crispy_forms_tags %}
{% block extra_js %}
{% block extra_css %}
<style>
/* =====================================================
Componente de co-autores com busca + checkboxes
===================================================== */
#coautores-search-box {
width: 100%;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 0.95em;
margin-bottom: 8px;
box-sizing: border-box;
}
#coautores-search-box:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.2);
}
#coautores-list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid #ced4da;
border-radius: 6px;
background: #fff;
padding: 6px 0;
}
#coautores-list-container .coautores-item {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s;
}
#coautores-list-container .coautores-item:hover {
background: #f0f7ff;
}
#coautores-list-container .coautores-item input[type="checkbox"] {
margin-right: 10px;
width: 16px;
height: 16px;
accent-color: #007bff;
cursor: pointer;
flex-shrink: 0;
}
#coautores-list-container .coautores-item label {
margin: 0;
cursor: pointer;
font-weight: normal;
color: #343a40;
font-size: 0.95em;
}
#coautores-no-results {
padding: 10px 12px;
color: #6c757d;
font-style: italic;
font-size: 0.9em;
display: none;
}
#coautores-counter {
font-size: 0.85em;
color: #6c757d;
margin-top: 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 +268,119 @@
padding: 8px 25px;
}
</style>
{% endblock %}
{% block extra_js %}
<script type="text/javascript">
$(document).ready(function(){
// =====================================================
// Componente checkbox + busca para co-autores
// =====================================================
(function() {
var $fieldDiv = $('#div_id_coautores');
// O Django renderiza os checkboxes nativos dentro de um <ul> — vamos substituí-los
var $originalWidget = $fieldDiv.find('ul, .checkbox-select, select');
// Coletar todos os checkboxes originais gerados pelo CheckboxSelectMultiple
var checkboxes = [];
$fieldDiv.find('input[type="checkbox"]').each(function() {
checkboxes.push({
value: $(this).val(),
label: $(this).closest('label').text().trim() ||
$fieldDiv.find('label[for="' + $(this).attr('id') + '"]').text().trim(),
checked: $(this).prop('checked'),
id: $(this).attr('id'),
name: $(this).attr('name')
});
});
if (checkboxes.length === 0) return; // Sem autores cadastrados, não renderizar
// Esconder widget original (manter checkboxes no DOM para submit)
$fieldDiv.find('ul').css('display', 'none');
// Inserir campo de busca e container customizado após a label
var $label = $fieldDiv.find('label').first();
var $helpText = $fieldDiv.find('.form-text, small.text-muted');
var $searchInput = $('<input>', {
type: 'text',
id: 'coautores-search-box',
placeholder: '{% trans "Buscar co-autor por nome..." %}',
autocomplete: 'off'
});
var $listContainer = $('<div>', { id: 'coautores-list-container' });
var $noResults = $('<div>', { id: 'coautores-no-results', text: '{% trans "Nenhum autor encontrado." %}' });
var $counter = $('<div>', { id: 'coautores-counter' });
$listContainer.append($noResults);
// Renderizar itens
function renderItem(cb) {
var $item = $('<div>', { 'class': 'coautores-item' });
var $chk = $('<input>', {
type: 'checkbox',
id: cb.id + '_custom',
value: cb.value,
checked: cb.checked
});
var $lbl = $('<label>', {
'for': cb.id + '_custom',
text: cb.label
});
$item.append($chk).append($lbl);
// Sincronizar com checkbox original (para submit)
$chk.on('change', function() {
$('#' + cb.id).prop('checked', $(this).prop('checked'));
updateCounter();
});
return $item;
}
checkboxes.forEach(function(cb) {
$listContainer.append(renderItem(cb));
});
function updateCounter() {
var total = $listContainer.find('input[type="checkbox"]:checked').length;
if (total === 0) {
$counter.text('');
} else if (total === 1) {
$counter.text('1 {% trans "co-autor selecionado" %}');
} else {
$counter.text(total + ' {% trans "co-autores selecionados" %}');
}
}
// Busca em tempo real
$searchInput.on('input', function() {
var term = $(this).val().toLowerCase().trim();
var visible = 0;
$listContainer.find('.coautores-item').each(function() {
var name = $(this).find('label').text().toLowerCase();
if (!term || name.indexOf(term) !== -1) {
$(this).show();
visible++;
} else {
$(this).hide();
}
});
$noResults.toggle(visible === 0);
});
// Inserir no DOM
$label.after($searchInput, $listContainer, $counter);
if ($helpText.length) {
$counter.after($helpText.detach());
}
updateCounter();
})();
// =====================================================
// Configuração do botão "Novo Tipo"
// =====================================================
@ -200,7 +389,6 @@ $(document).ready(function(){
var $tipoSelect = $("select[name=tipo]");
if ($tipoLabel.length > 0 && $tipoSelect.length > 0) {
// Criar header com label e botão
var $header = $('<div class="tipo-header"></div>');
var $btnNovoTipo = $(`
<a href="/sistema/proposicao/tipo/create"
@ -210,8 +398,6 @@ $(document).ready(function(){
<i class="fa fa-plus"></i> Novo Tipo
</a>
`);
// Mover label para o header e adicionar botão
$tipoLabel.wrap($header);
$tipoLabel.after($btnNovoTipo);
}
@ -223,54 +409,37 @@ $(document).ready(function(){
var tipoTextoValue = $("input[name=tipo_texto]:checked").val();
if (tipoTextoValue === 'D') {
// Arquivo Digital: mostrar campo de upload
$("#div_id_texto_original").slideDown(200);
$("#onlyoffice-info").slideUp(200);
} else if (tipoTextoValue === 'T') {
// Texto Articulado: esconder ambos
$("#div_id_texto_original").slideUp(200);
$("#onlyoffice-info").slideUp(200);
} else if (tipoTextoValue === 'O') {
// OnlyOffice: mostrar mensagem informativa
$("#div_id_texto_original").slideUp(200);
$("#onlyoffice-info").slideDown(200);
}
// Atualizar visual dos labels
$("#div_id_tipo_texto .form-check label, #div_id_tipo_texto .form-check-inline").removeClass('checked');
$("input[name=tipo_texto]:checked").closest('label').addClass('checked');
}
// Event listener para mudança de tipo de texto
$("input[name=tipo_texto]").change(function() {
updateTipoTextoDisplay();
});
// =====================================================
// Mostrar opções de tipo de texto
// =====================================================
function showTipoTextoOptions() {
var $tipoTextoContainer = $("input[name=tipo_texto]").closest('.form-group').parent();
$tipoTextoContainer.removeClass('hidden');
$("input[name=tipo_texto]").prop('disabled', false);
$("input[name=tipo_texto]").closest('label').removeClass('disabled');
// Selecionar primeira opção se nenhuma estiver selecionada
if ($("input[name=tipo_texto]:checked").length === 0) {
$("input[name=tipo_texto]").first().prop('checked', true);
}
updateTipoTextoDisplay();
}
// Mostrar opções ao carregar a página
showTipoTextoOptions();
// Atualizar quando tipo de proposição mudar
$("select[name=tipo]").change(function() {
showTipoTextoOptions();
});
$("select[name=tipo]").change(function() { showTipoTextoOptions(); });
// =====================================================
// Busca de matéria legislativa para vinculação
@ -281,15 +450,11 @@ $(document).ready(function(){
'ano': $("input[name=ano_materia]").val(),
'numero': $("input[name=numero_materia]").val(),
};
// Validar se todos os campos estão preenchidos
if (!formData.tipo || !formData.ano || !formData.numero) {
$(".ementa_materia").html('').addClass('hidden');
return;
}
var url = '{% url "sapl.api:materialegislativa-list" %}';
$.get(url, formData).done(function(data) {
if (data.pagination.total_entries === 1) {
$(".ementa_materia")
@ -309,24 +474,15 @@ $(document).ready(function(){
});
}
// Event listeners para busca de matéria
$("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]")
.on('change keyup', function() {
buscarMateria();
});
.on('change keyup', function() { buscarMateria(); });
// =====================================================
// Inicialização
// =====================================================
// Disparar eventos iniciais
$("select[name=tipo]").trigger('change');
buscarMateria();
// Garantir que o onlyoffice-info comece escondido
$("#onlyoffice-info").hide();
// Atualizar display inicial
setTimeout(updateTipoTextoDisplay, 100);
});

3
sapl/templates/materia/proposicao_list.html

@ -213,6 +213,9 @@
<a href="?status=elaboracao" class="{% if status_filter == 'elaboracao' %}active{% endif %}">
<strong>{{ stats.elaboracao|default:"0" }}</strong> em elaboração
</a>
<a href="?status=gabinete" class="{% if status_filter == 'gabinete' %}active{% endif %}">
<strong>{{ stats.gabinete|default:"0" }}</strong> de gabinete
</a>
<a href="?status=aguardando" class="{% if status_filter == 'aguardando' %}active{% endif %}">
<strong>{{ stats.aguardando|default:"0" }}</strong> enviadas
</a>

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>

4
sapl/templates/navbar.yaml

@ -49,6 +49,10 @@
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 'Acervo Histórico (antes de 2026)' %}
url: sapl.materia:ged_historico
- 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