Browse Source

Chore(Conciliacao): traz a 3.1.2 para a dev sem perder nada AB#1473 AB#1480

Passo 1 de 2 da conciliacao das linhas do SAPL. A dev vinha da integracao
(hub, inventario, anexos, polls) e a 3.1.2 vinha do produto (assinatura, GED,
proposicao, Franco da Rocha). Nenhuma das duas continha a outra: a dev estava
33 commits a frente e 20 atras.

Traz os 20 commits que faltavam. Como a sp_importante_virou_urgente esta
inteiramente contida na 3.1.2 (zero commits exclusivos), ela vem junto e nao
precisa de merge proprio. As branches feature/add-agents-tracking e
feat/integracao-hub-app foram conferidas arquivo a arquivo e estao superadas:
o que adicionam (OnlyOffice, templates de documento, AGENTS.md, autor_nome no
poll) ja esta na dev por outro caminho.

O unico conflito real foi o docs/LOCALHOST_SETUP.md — dois roteiros escritos em
paralelo para a mesma tarefa. Resolvido a favor do roteiro da dev (mais novo,
validado em 13/08, com a arvore de decisao sobre migration), com tres ajustes:

- a nota sobre DEBUG foi corrigida. Ela dizia "DJANGO_DEBUG, nao DEBUG"; depois
  deste merge o settings.py aceita as duas (linha 38, vindo da 3.1.2). Manter a
  nota antiga seria documentar o codigo errado a partir de agora.
- "Problemas comuns" da 3.1.2 foi incorporado (alcance do banco remoto,
  collectstatic com Gunicorn).
- as instrucoes que dependiam de docker/docker-compose-local.yml ficaram de
  fora, com o motivo registrado no proprio documento: esse arquivo nao existe em
  nenhuma das duas linhas.

Verificado no resultado do merge, e nao por suposicao:

- as quatro funcionalidades que so existiam na 3.1.2 chegaram inteiras — telas
  de "Assinar Despachos em Lote" (docacessorio_assinar_lote, materia_assinar_lote),
  preview PNG da pagina de assinatura (as tres funcoes), remocao de assinatura
  (_pode_remover_assinatura) e o helper _carregar_certificado;
- nenhum marcador de conflito sobrou em .py, .md ou .html;
- sapl/materia e sapl/base compilam;
- todo nome importado de views_assinatura em urls.py e views.py existe — que era
  o modo de falha mais perigoso aqui (urls.py importa no topo do modulo, entao
  um nome faltando derruba o boot);
- a integracao da dev (sapl/integracao_hub) segue no lugar.

O passo 2 e a unificacao da assinatura: aplicar a delegacao da C3 por cima
mantendo as telas de lote com composicao local, decisao registrada em
docs-ia-projects#63.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/3858/head
kemuel 4 weeks ago
parent
commit
46ce558e96
  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' version: '3.7'
services: services:
@ -12,30 +20,22 @@ services:
- ..:/sapl-dev - ..:/sapl-dev
ports: ports:
- "8000:8000" - "8000:8000"
env_file:
- ../sapl/.env # Lê DATABASE_URL, SECRET_KEY, DEBUG, etc.
environment: environment:
SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^' # Garante que DEBUG do .env seja reconhecido pelo settings.py
DJANGO_DEBUG: 'True' DJANGO_DEBUG: '${DEBUG:-True}'
DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl
TZ: America/Sao_Paulo TZ: America/Sao_Paulo
ONLYOFFICE_URL: 'http://onlyoffice:80' extra_hosts:
depends_on: # Permite alcançar bancos externos (ex: legisinc.com.br, sgvp.com.br)
- onlyoffice - "host.docker.internal:host-gateway"
onlyoffice:
container_name: onlyoffice-documentserver
image: onlyoffice/documentserver:latest
ports:
- "8001:80"
environment:
- JWT_ENABLED=false
- JWT_SECRET=your-secret-key-change-this
volumes:
- onlyoffice_data:/var/www/onlyoffice/Data
- onlyoffice_log:/var/log/onlyoffice
- onlyoffice_fonts:/usr/share/fonts/truetype/custom
restart: unless-stopped
volumes: # Descomente para usar OnlyOffice localmente
onlyoffice_data: # onlyoffice:
onlyoffice_log: # container_name: onlyoffice-documentserver
onlyoffice_fonts: # image: onlyoffice/documentserver:latest
# ports:
# - "8001:80"
# environment:
# - JWT_ENABLED=false
# restart: unless-stopped

32
docs/LOCALHOST_SETUP.md

@ -61,9 +61,10 @@ EMAIL_USE_TLS=True
EMAIL_PORT=587 EMAIL_PORT=587
``` ```
> **`DJANGO_DEBUG`, não `DEBUG`**: o `settings.py` lê o modo debug do Django de > **`DEBUG` ou `DJANGO_DEBUG`**: desde a conciliação com a `3.1.2`, o `settings.py`
> `DJANGO_DEBUG` (linha 38). A variável `DEBUG` só controla o log de conexão do banco. > aceita as duas (`settings.py:38` — `DEBUG or DJANGO_DEBUG`, compatibilidade com o
> Defina as duas. > 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`: > **Senha com caractere especial** precisa ser URL-encoded no `DATABASE_URL`:
> `@``%40`, `:``%3A`, `/``%2F`. Ex.: `S3nh@2026` vira `S3nh%402026`. > `@``%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 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 `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. (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 autopep8==2.3.2
beautifulsoup4==4.13.5 beautifulsoup4==4.13.5
django-debug-toolbar==3.2.4
ipdb==0.13.13 ipdb==0.13.13
fancycompleter==0.11.1 fancycompleter==0.11.1
pdbpp==0.11.7 pdbpp==0.11.7

104
sapl/api/views_comissoes.py

@ -1,12 +1,17 @@
from django.apps.registry import apps from django.apps.registry import apps
from django.db.models import Q
from django.utils import timezone
from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
customize, wrapper_queryset_response_for_drf_action 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.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.materia.models import MateriaEmTramitacao
from sapl.parlamentares.models import Legislatura
ApiViewSetConstrutor.build_class( 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) @customize(Comissao)
class _ComissaoViewSet: 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) @action(detail=True)
def materiaemtramitacao(self, request, *args, **kwargs): def materiaemtramitacao(self, request, *args, **kwargs):
return self.get_materiaemtramitacao(**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'), verbose_name=_('Nome utilizado na assinatura digital'),
choices=TIPO_NOME_ASSINATURA, choices=TIPO_NOME_ASSINATURA,
default='P') default='P')
permite_remover_assinatura = models.BooleanField(
verbose_name=_('Permitir remoção de assinatura digital?'),
choices=YES_NO_CHOICES,
default=False,
help_text=_(
'Quando habilitado, superusuários e usuários com a permissão '
'"Pode remover assinatura digital" poderão remover assinaturas '
'de matérias e documentos acessórios para permitir edições.'
)
)
# MÓDULO PAINEL # MÓDULO PAINEL
cronometro_discurso = models.DurationField( cronometro_discurso = models.DurationField(
verbose_name=_('Cronômetro do Discurso'), verbose_name=_('Cronômetro do Discurso'),

6
sapl/base/views.py

@ -47,6 +47,7 @@ from sapl.parlamentares.models import (
Filiacao, Legislatura, Mandato, Parlamentar) Filiacao, Legislatura, Mandato, Parlamentar)
from sapl.protocoloadm.models import (Anexado, Protocolo) from sapl.protocoloadm.models import (Anexado, Protocolo)
from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas) 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.sessao.models import (Bancada, SessaoPlenaria)
from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE
from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, 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 # Verifica se é Operador de Protocolo Administrativo
context['is_operador_protocolo'] = self.request.user.has_perm( context['is_operador_protocolo'] = self.request.user.has_perm(
'protocoloadm.add_documentoadministrativo') '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: else:
context['is_parlamentar'] = False context['is_parlamentar'] = False
context['autor_id'] = None context['autor_id'] = None
context['is_operador_protocolo'] = False context['is_operador_protocolo'] = False
context['is_procurador_juridico'] = False
return context return context

58
sapl/context_processors.py

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

345
sapl/materia/assinatura_api_client.py

@ -0,0 +1,345 @@
"""
Cliente para o pyHanko Sign Service (microserviço externo de assinatura digital).
Contrato real da API (descoberto via /openapi.json):
POST /sign multipart/form-data
pdf : arquivo PDF (binário)
pfx : arquivo .pfx/.p12 (binário)
pfx_password : senha do PFX
reason : motivo (opcional)
location : local (opcional)
signature_page : página 1-based (opcional)
signature_left/bottom/width/height : posição em pontos PDF (opcional)
Resposta 200: Content-Type: application/pdf bytes do PDF assinado
Resposta 4xx/5xx: JSON com campo "detail"
Outros endpoints:
POST /validate-pfx valida certificado PFX
POST /validate valida assinaturas de um PDF
POST /sign/batch assina múltiplos PDFs em lote
GET / health check
"""
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
class AssinaturaAPIError(Exception):
"""Erro retornado pelo microserviço de assinatura."""
def __init__(self, message, status_code=None):
super().__init__(message)
self.status_code = status_code
def _api_configurada():
"""Retorna True se a API externa de assinatura está configurada."""
return bool(getattr(settings, 'ASSINATURA_API_URL', '').strip())
def _montar_headers():
"""Authorization só é adicionado se ASSINATURA_API_KEY estiver preenchido."""
headers = {}
api_key = getattr(settings, 'ASSINATURA_API_KEY', '').strip()
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
return headers
def _url(endpoint):
base = settings.ASSINATURA_API_URL.rstrip('/')
endpoint = endpoint.lstrip('/')
return f'{base}/{endpoint}'
def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha,
reason=None, location=None,
signature_page=None,
signature_left=None, signature_bottom=None,
signature_width=None, signature_height=None):
"""
Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes.
Parâmetros
----------
pdf_bytes : bytes PDF a assinar
certificado_bytes : bytes arquivo .pfx/.p12
senha : str senha do certificado
reason : str motivo (exibido na assinatura visual)
location : str local (exibido na assinatura visual)
signature_page : int página 1-based (None = última)
signature_left/bottom/width/height : float coordenadas em pontos PDF
Retorna: bytes do PDF assinado.
Lança: AssinaturaAPIError em caso de erro.
"""
if not _api_configurada():
raise AssinaturaAPIError(
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).'
)
files = {
'pdf': ('documento.pdf', pdf_bytes, 'application/pdf'),
'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream'),
}
data = {'pfx_password': senha}
if reason:
data['reason'] = reason
if location:
data['location'] = location
if signature_page is not None:
data['signature_page'] = str(signature_page)
if signature_left is not None:
data['signature_left'] = str(signature_left)
if signature_bottom is not None:
data['signature_bottom'] = str(signature_bottom)
if signature_width is not None:
data['signature_width'] = str(signature_width)
if signature_height is not None:
data['signature_height'] = str(signature_height)
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
try:
response = requests.post(
_url('sign'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.ConnectionError as exc:
logger.error(f'[assinatura-api] Falha de conexao: {exc}')
raise AssinaturaAPIError(
'Nao foi possivel conectar ao microservico de assinatura. '
'Verifique se o servico esta disponivel.'
)
except requests.exceptions.Timeout:
raise AssinaturaAPIError(
f'Timeout ao aguardar resposta do microservico de assinatura '
f'(limite: {timeout}s).'
)
except requests.exceptions.RequestException as exc:
logger.error(f'[assinatura-api] Erro inesperado: {exc}')
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or detail.get('error') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}')
raise AssinaturaAPIError(msg, status_code=response.status_code)
# A API retorna o PDF assinado diretamente como application/pdf
content_type = response.headers.get('Content-Type', '')
if 'pdf' not in content_type and len(response.content) < 100:
raise AssinaturaAPIError(
f'Resposta inesperada do microservico (Content-Type: {content_type}).'
)
logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.')
return response.content
def assinar_pdf_lote_via_api(itens, *, certificado_bytes, senha,
reason=None, location=None,
download_workers=8):
"""
Assina múltiplos PDFs em uma única chamada POST /sign/batch e baixa os
resultados em paralelo via download_url do S3.
Parâmetros
----------
itens : list[dict] cada item deve ter:
'id' : identificador (qualquer hashable preservado no resultado)
'pdf_bytes' : bytes do PDF a assinar
'signature_page' : int 1-based (opcional, mesmo para todos)
'signature_left' : float (opcional)
'signature_bottom': float (opcional)
'signature_width' : float (opcional)
'signature_height': float (opcional)
certificado_bytes : bytes arquivo .pfx/.p12 (compartilhado por todos)
senha : str senha do certificado
reason, location : str metadados da assinatura
download_workers : int threads para baixar resultados do S3 (default 8)
Retorna: list[dict] na mesma ordem de `itens`, com campos:
'id' : o mesmo id do item de entrada
'ok' : True / False
'pdf_bytes' : bytes do PDF assinado (apenas quando ok=True)
'error' : mensagem de erro (apenas quando ok=False)
"""
if not _api_configurada():
raise AssinaturaAPIError(
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).'
)
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
# ── 1. Enviar todos os PDFs em uma única chamada /sign/batch ─────────────
# O campo 'signature_page/left/bottom/width/height' é único para o lote —
# usamos os valores do primeiro item (todos partilham a mesma posição).
primeiro = itens[0] if itens else {}
data = {'pfx_password': senha}
if reason:
data['reason'] = reason
if location:
data['location'] = location
if primeiro.get('signature_page') is not None:
data['signature_page'] = str(primeiro['signature_page'])
if primeiro.get('signature_left') is not None:
data['signature_left'] = str(primeiro['signature_left'])
if primeiro.get('signature_bottom') is not None:
data['signature_bottom'] = str(primeiro['signature_bottom'])
if primeiro.get('signature_width') is not None:
data['signature_width'] = str(primeiro['signature_width'])
if primeiro.get('signature_height') is not None:
data['signature_height'] = str(primeiro['signature_height'])
# multipart: múltiplos campos 'pdfs' + um 'pfx'
files = [('pfx', ('certificado.pfx', certificado_bytes, 'application/octet-stream'))]
for idx, item in enumerate(itens):
filename = f'doc{idx + 1}.pdf'
files.append(('pdfs', (filename, item['pdf_bytes'], 'application/pdf')))
try:
response = requests.post(
_url('sign/batch'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.ConnectionError as exc:
logger.error(f'[assinatura-api/batch] Falha de conexão: {exc}')
raise AssinaturaAPIError(
'Não foi possível conectar ao microserviço de assinatura.'
)
except requests.exceptions.Timeout:
raise AssinaturaAPIError(
f'Timeout ao aguardar resposta do microserviço (limite: {timeout}s).'
)
except requests.exceptions.RequestException as exc:
raise AssinaturaAPIError(f'Erro ao comunicar com o microserviço: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
logger.error(f'[assinatura-api/batch] Erro HTTP {response.status_code}: {msg}')
raise AssinaturaAPIError(msg, status_code=response.status_code)
try:
batch_result = response.json()
except Exception:
raise AssinaturaAPIError('Resposta do /sign/batch não é JSON válido.')
resultados_api = batch_result.get('results', [])
if len(resultados_api) != len(itens):
raise AssinaturaAPIError(
f'Resposta do /sign/batch retornou {len(resultados_api)} itens, '
f'esperado {len(itens)}.'
)
logger.info(f'[assinatura-api/batch] {len(resultados_api)} PDFs assinados. Baixando...')
# ── 2. Baixar PDFs assinados em paralelo via download_url ─────────────────
from concurrent.futures import ThreadPoolExecutor, as_completed
def _baixar(idx_url):
idx, url = idx_url
try:
r = requests.get(url, timeout=60)
if not r.ok:
return idx, None, f'Erro ao baixar PDF assinado: HTTP {r.status_code}'
if not r.content[:5] == b'%PDF-':
return idx, None, 'Conteúdo baixado não é um PDF válido.'
return idx, r.content, None
except Exception as exc:
return idx, None, str(exc)
urls_indexadas = [
(idx, res['download_url'])
for idx, res in enumerate(resultados_api)
]
resultados_finais = [None] * len(itens)
with ThreadPoolExecutor(max_workers=download_workers) as executor:
futures = {executor.submit(_baixar, item): item for item in urls_indexadas}
for future in as_completed(futures):
idx, pdf_bytes, error = future.result()
item_id = itens[idx]['id']
if error:
logger.error(f'[assinatura-api/batch] item {idx} (id={item_id}): {error}')
resultados_finais[idx] = {'id': item_id, 'ok': False, 'error': error}
else:
resultados_finais[idx] = {'id': item_id, 'ok': True, 'pdf_bytes': pdf_bytes}
return resultados_finais
def validar_pfx_via_api(certificado_bytes, senha):
"""
Valida um certificado PFX no microservico.
Retorna dict com informacoes do certificado ou lanca AssinaturaAPIError.
"""
if not _api_configurada():
raise AssinaturaAPIError('ASSINATURA_API_URL nao configurado.')
files = {'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream')}
data = {'pfx_password': senha}
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
try:
response = requests.post(
_url('validate-pfx'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.RequestException as exc:
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
raise AssinaturaAPIError(msg, status_code=response.status_code)
try:
return response.json()
except Exception:
return {}
def verificar_health():
"""
Verifica se o microservico esta disponivel (GET /).
Retorna (ok: bool, mensagem: str).
"""
if not _api_configurada():
return False, 'ASSINATURA_API_URL nao configurado.'
try:
response = requests.get(
_url('/'),
headers=_montar_headers(),
timeout=10,
)
if response.ok:
return True, 'Microservico disponivel.'
return False, f'Microservico respondeu com HTTP {response.status_code}.'
except requests.exceptions.RequestException as exc:
return False, f'Falha de conexao: {exc}'

112
sapl/materia/forms.py

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

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

@ -0,0 +1,199 @@
"""
Management command: notificar_pendentes_assinatura
Envia e-mail diario a cada autor que possui materias com assinatura digital
pendente (texto_original preenchido, pdf_assinado vazio).
Uso:
python manage.py notificar_pendentes_assinatura
Agendar (crontab) -- exemplo para rodar todo dia as 8h:
0 8 * * * cd /app && python manage.py notificar_pendentes_assinatura >> /var/log/sapl_notif_assinatura.log 2>&1
Opcoes:
--dry-run Lista os destinatarios e contagens sem enviar e-mails.
--max-materias Numero maximo de materias listadas por e-mail (padrao: 20).
"""
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
DEFAULT_MAX_MATERIAS = 20
class Command(BaseCommand):
help = 'Envia e-mail diario aos autores com materias pendentes de assinatura digital'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
default=False,
help='Apenas lista os destinatarios sem enviar e-mails.',
)
parser.add_argument(
'--max-materias',
type=int,
default=DEFAULT_MAX_MATERIAS,
help='Maximo de materias listadas por e-mail (padrao: 20).',
)
def handle(self, *args, **options):
# Imports aqui dentro para evitar problemas no bootstrap do Django
from django.core.mail import EmailMultiAlternatives, get_connection
from django.db.models import Q
from django.template import loader
from django.urls import reverse
from sapl.base.models import CasaLegislativa, OperadorAutor
from sapl.materia.models import MateriaLegislativa
from sapl.settings import EMAIL_SEND_USER
from sapl.utils import mail_service_configured
dry_run = options['dry_run']
max_mat = options['max_materias']
if not dry_run and not mail_service_configured():
self.stderr.write(self.style.ERROR(
'Servico de e-mail nao configurado. '
'Verifique EMAIL_HOST no arquivo .env.'
))
return
casa = CasaLegislativa.objects.first()
if not casa:
self.stderr.write(self.style.ERROR('Casa Legislativa nao configurada.'))
return
casa_nome = '{} de {} - {}'.format(casa.nome, casa.municipio, casa.uf)
base_url = 'https://{}'.format(casa.endereco_web) if getattr(casa, 'endereco_web', None) else ''
# Base queryset: materias pendentes de assinatura
qs_pendentes = MateriaLegislativa.objects.filter(
texto_original__isnull=False,
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
).select_related('tipo').order_by('-data_apresentacao', '-id')
# Apenas OperadorAutores com e-mail cadastrado
operadores = (
OperadorAutor.objects
.select_related('autor', 'user')
.filter(user__email__gt='')
)
if not operadores.exists():
self.stdout.write(self.style.WARNING(
'Nenhum OperadorAutor com e-mail encontrado. Nada a enviar.'
))
return
url_pesquisa_base = reverse('sapl.materia:pesquisar_materia')
enviados = 0
sem_pendencias = 0
connection = None if dry_run else get_connection()
if connection:
connection.open()
try:
for op in operadores:
autor = op.autor
user = op.user
email = (user.email or '').strip()
if not email:
continue
# Materias pendentes deste autor
materias_qs = qs_pendentes.filter(autoria__autor=autor).distinct()
total = materias_qs.count()
if total == 0:
sem_pendencias += 1
continue
materias_listadas = list(materias_qs[:max_mat])
total_omitidas = max(0, total - max_mat)
url_pesquisa = (
'{}?autoria__autor={}&status_assinatura=pendente'.format(
url_pesquisa_base, autor.pk
)
)
context = {
'casa_legislativa': casa_nome,
'nome_autor': autor.nome,
'total': total,
'materias': materias_listadas,
'total_omitidas': total_omitidas,
'base_url': base_url,
'url_pesquisa': url_pesquisa,
}
subject = '[SGVP] {} materia(s) aguardando sua assinatura digital'.format(total)
if dry_run:
self.stdout.write(self.style.SUCCESS(
'[DRY-RUN] -> {} ({}): {} pendente(s)'.format(email, autor.nome, total)
))
for m in materias_listadas:
self.stdout.write(
' * {} {}/{} -- {}'.format(
m.tipo.sigla, m.numero, m.ano, m.ementa[:60]
)
)
if total_omitidas:
self.stdout.write(' ... e mais {} outra(s).'.format(total_omitidas))
continue
# Renderiza templates
txt_body = loader.get_template('email/pendentes_assinatura.txt').render(context)
html_body = loader.get_template('email/pendentes_assinatura.html').render(context)
try:
msg = EmailMultiAlternatives(
subject=subject,
body=txt_body,
from_email=EMAIL_SEND_USER,
to=[email],
connection=connection,
)
msg.attach_alternative(html_body, 'text/html')
msg.send()
enviados += 1
logger.info(
'[notificar_pendentes_assinatura] E-mail enviado para '
'{} ({}) -- {} pendente(s).'.format(email, autor.nome, total)
)
self.stdout.write(self.style.SUCCESS(
'E-mail enviado para {} ({}) -- {} pendente(s).'.format(
email, autor.nome, total
)
))
except Exception as exc:
logger.error(
'[notificar_pendentes_assinatura] Falha ao enviar para '
'{}: {}'.format(email, exc)
)
self.stderr.write(self.style.ERROR(
'Falha ao enviar para {}: {}'.format(email, exc)
))
finally:
if connection:
connection.close()
if not dry_run:
self.stdout.write(self.style.SUCCESS(
'\nConcluido: {} e-mail(s) enviado(s). '
'{} autor(es) sem pendencias (nao notificados).'.format(
enviados, sem_pendencias
)
))

30
sapl/materia/migrations/0095_add_autoria_proposicao.py

@ -0,0 +1,30 @@
# Generated by Django 2.2.28 on 2026-05-12 12:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('base', '0064_appconfig_revisao_setor_legislativo'),
('materia', '0094_add_anexoproposicao'),
]
operations = [
migrations.CreateModel(
name='AutoriaProposicao',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('primeiro_autor', models.BooleanField(choices=[(True, 'Sim'), (False, 'Não')], default=False, verbose_name='Primeiro Autor')),
('autor', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Co-autor')),
('proposicao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coautores', to='materia.Proposicao', verbose_name='Proposição')),
],
options={
'verbose_name': 'Co-autoria da Proposição',
'verbose_name_plural': 'Co-autorias da Proposição',
'ordering': ('-primeiro_autor', 'autor__nome'),
'unique_together': {('proposicao', 'autor')},
},
),
]

21
sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py

@ -0,0 +1,21 @@
# Generated by Django 2.2.28 on 2026-05-14 11:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('materia', '0095_add_autoria_proposicao'),
]
operations = [
migrations.AlterModelOptions(
name='documentoacessorio',
options={'ordering': ('data', 'id'), 'permissions': (('can_remove_assinatura_doc', 'Pode remover assinatura digital de documento acessório'),), 'verbose_name': 'Documento Acessório', 'verbose_name_plural': 'Documentos Acessórios'},
),
migrations.AlterModelOptions(
name='materialegislativa',
options={'ordering': ['-ano', 'tipo', 'numero'], 'permissions': (('can_access_impressos', 'Can access impressos'), ('can_remove_assinatura', 'Pode remover assinatura digital')), 'verbose_name': 'Matéria Legislativa', 'verbose_name_plural': 'Matérias Legislativas'},
),
]

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

207
sapl/materia/onlyoffice_materia_views.py

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

48
sapl/materia/urls.py

@ -1,5 +1,6 @@
from django.conf.urls import include, url from django.conf.urls import include, url
from sapl.materia.views_ged import ged_historico
from sapl.materia.views import (AcompanhamentoConfirmarView, from sapl.materia.views import (AcompanhamentoConfirmarView,
AcompanhamentoExcluirView, AcompanhamentoExcluirView,
AcompanhamentoMateriaView, AnexadaCrud, AcompanhamentoMateriaView, AnexadaCrud,
@ -16,6 +17,8 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
LegislacaoCitadaCrud, MateriaAssuntoCrud, LegislacaoCitadaCrud, MateriaAssuntoCrud,
MateriaLegislativaCrud, MateriaLegislativaCrud,
MateriaLegislativaPesquisaView, MateriaTaView, MateriaLegislativaPesquisaView, MateriaTaView,
MateriasPendentesAssinaturaView,
DespachosPendentesLoteView,
NumeracaoCrud, OrgaoCrud, OrigemCrud, NumeracaoCrud, OrgaoCrud, OrigemCrud,
PrimeiraTramitacaoEmLoteView, ProposicaoCrud, PrimeiraTramitacaoEmLoteView, ProposicaoCrud,
ProposicaoDevolvida, ProposicaoPendente, ProposicaoDevolvida, ProposicaoPendente,
@ -33,6 +36,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
MateriaPesquisaSimplesView, MateriaPesquisaSimplesView,
DespachoInicialMultiCreateView, DespachoInicialMultiCreateView,
get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo, get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo,
get_pdf_multiplos,
configEtiquetaMateriaLegislativaCrud, configEtiquetaMateriaLegislativaCrud,
PesquisarStatusTramitacaoView, HistoricoProposicaoView) PesquisarStatusTramitacaoView, HistoricoProposicaoView)
from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download, from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download,
@ -46,15 +50,17 @@ from sapl.materia.onlyoffice_materia_views import (
docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config, docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config,
docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback, docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback,
docacessorio_check_doc, docacessorio_forcesave, docacessorio_check_doc, docacessorio_forcesave,
materia_gerar_pdf_assinatura materia_gerar_pdf_assinatura, materia_gerar_pdf_previa, docacessorio_gerar_pdf_previa
) )
from sapl.materia.views_assinatura import ( from sapl.materia.views_assinatura import (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura, materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura,
detectar_aplicacao_a3, detectar_aplicacao_a3, assinatura_api_status, materia_assinar_lote,
docacessorio_assinar_a1, docacessorio_pdf_assinado, docacessorio_assinar_a1, docacessorio_pdf_assinado,
docacessorio_verificar_assinatura, docacessorio_remover_assinatura, docacessorio_verificar_assinatura, docacessorio_remover_assinatura,
materia_verificar_documento, docacessorio_verificar_documento materia_verificar_documento, docacessorio_verificar_documento,
docacessorio_assinar_lote,
materia_pagina_assinatura_png, docacessorio_pagina_assinatura_png,
) )
from sapl.norma.views import NormaPesquisaSimplesView from sapl.norma.views import NormaPesquisaSimplesView
from sapl.protocoloadm.views import ( from sapl.protocoloadm.views import (
@ -122,6 +128,13 @@ urlpatterns_materia = [
url(r'^materia/pesquisar-materia$', url(r'^materia/pesquisar-materia$',
MateriaLegislativaPesquisaView.as_view(), name='pesquisar_materia'), MateriaLegislativaPesquisaView.as_view(), name='pesquisar_materia'),
url(r'^materia/pendentes-assinatura$',
MateriasPendentesAssinaturaView.as_view(), name='materias_pendentes_assinatura'),
url(r'^materia/despachos-pendentes-lote$',
DespachosPendentesLoteView.as_view(), name='despachos_pendentes_lote'),
url(r'^materia/(?P<pk>\d+)/acompanhar-materia/$', url(r'^materia/(?P<pk>\d+)/acompanhar-materia/$',
AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'), AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'),
url(r'^materia/(?P<pk>\d+)/acompanhar-confirmar$', url(r'^materia/(?P<pk>\d+)/acompanhar-confirmar$',
@ -158,6 +171,8 @@ urlpatterns_materia = [
name='zip_completo_materia'), name='zip_completo_materia'),
url(r'^materia/pdf-completo/(?P<pk>\d+)$', get_pdf_completo, url(r'^materia/pdf-completo/(?P<pk>\d+)$', get_pdf_completo,
name='pdf_completo_materia'), name='pdf_completo_materia'),
url(r'^materia/pdf-multiplos/$', get_pdf_multiplos,
name='pdf_multiplos_materias'),
# OnlyOffice endpoints para Matéria Legislativa # OnlyOffice endpoints para Matéria Legislativa
url(r'^materia/(?P<pk>\d+)/onlyoffice/editor$', materia_onlyoffice_editor, url(r'^materia/(?P<pk>\d+)/onlyoffice/editor$', materia_onlyoffice_editor,
@ -175,6 +190,10 @@ urlpatterns_materia = [
url(r'^materia/(?P<pk>\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura, url(r'^materia/(?P<pk>\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura,
name='materia_pdf_assinatura'), name='materia_pdf_assinatura'),
# Prévia de PDF da Matéria antes da assinatura (sem restrição de protocolo)
url(r'^materia/(?P<pk>\d+)/pdf-previa$', materia_gerar_pdf_previa,
name='materia_pdf_previa'),
# Assinatura Digital de Matéria Legislativa # Assinatura Digital de Matéria Legislativa
url(r'^materia/(?P<pk>\d+)/assinar/a1/$', materia_assinar_a1, url(r'^materia/(?P<pk>\d+)/assinar/a1/$', materia_assinar_a1,
name='materia_assinar_a1'), name='materia_assinar_a1'),
@ -182,6 +201,8 @@ urlpatterns_materia = [
name='materia_assinar_a3_preparar'), name='materia_assinar_a3_preparar'),
url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar, url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar,
name='materia_assinar_a3_finalizar'), name='materia_assinar_a3_finalizar'),
url(r'^materia/assinar-em-lote/$', materia_assinar_lote,
name='materia_assinar_lote'),
url(r'^materia/(?P<pk>\d+)/pdf-assinado/$', materia_pdf_assinado, url(r'^materia/(?P<pk>\d+)/pdf-assinado/$', materia_pdf_assinado,
name='materia_pdf_assinado'), name='materia_pdf_assinado'),
url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura, url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura,
@ -190,6 +211,12 @@ urlpatterns_materia = [
name='materia_remover_assinatura'), name='materia_remover_assinatura'),
url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3, url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3,
name='detectar_aplicacao_a3'), name='detectar_aplicacao_a3'),
url(r'^materia/assinatura/api-status/$', assinatura_api_status,
name='assinatura_api_status'),
url(r'^materia/(?P<pk>\d+)/assinatura/pagina-preview/$', materia_pagina_assinatura_png,
name='materia_pagina_assinatura_png'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinatura/pagina-preview/$', docacessorio_pagina_assinatura_png,
name='docacessorio_pagina_assinatura_png'),
# Verificação pública de autenticidade (sem login) # Verificação pública de autenticidade (sem login)
url(r'^materia/(?P<pk>\d+)/verificar/$', materia_verificar_documento, url(r'^materia/(?P<pk>\d+)/verificar/$', materia_verificar_documento,
@ -209,6 +236,10 @@ urlpatterns_materia = [
url(r'^materia/documentoacessorio/(?P<pk>\d+)/forcesave$', docacessorio_forcesave, url(r'^materia/documentoacessorio/(?P<pk>\d+)/forcesave$', docacessorio_forcesave,
name='docacessorio_forcesave'), name='docacessorio_forcesave'),
# Prévia de PDF do Documento Acessório antes da assinatura
url(r'^materia/documentoacessorio/(?P<pk>\d+)/pdf-previa$', docacessorio_gerar_pdf_previa,
name='docacessorio_pdf_previa'),
# Assinatura Digital de Documento Acessório # Assinatura Digital de Documento Acessório
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinar/a1/$', docacessorio_assinar_a1, url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinar/a1/$', docacessorio_assinar_a1,
name='docacessorio_assinar_a1'), name='docacessorio_assinar_a1'),
@ -219,6 +250,10 @@ urlpatterns_materia = [
url(r'^materia/documentoacessorio/(?P<pk>\d+)/remover-assinatura/$', docacessorio_remover_assinatura, url(r'^materia/documentoacessorio/(?P<pk>\d+)/remover-assinatura/$', docacessorio_remover_assinatura,
name='docacessorio_remover_assinatura'), name='docacessorio_remover_assinatura'),
# Assinatura em Lote de Documentos Acessórios
url(r'^materia/documentoacessorio/assinar-em-lote/$', docacessorio_assinar_lote,
name='docacessorio_assinar_lote'),
# Verificação pública de autenticidade de Documento Acessório (sem login) # Verificação pública de autenticidade de Documento Acessório (sem login)
url(r'^materia/documentoacessorio/(?P<pk>\d+)/verificar/$', docacessorio_verificar_documento, url(r'^materia/documentoacessorio/(?P<pk>\d+)/verificar/$', docacessorio_verificar_documento,
name='docacessorio_verificar_documento'), name='docacessorio_verificar_documento'),
@ -320,5 +355,10 @@ urlpatterns_sistema = [
url(r'^sistema/materia/config-etiqueta-materia-legislativas/',configEtiquetaMateriaLegislativaCrud, name="configEtiquetaMateriaLegislativaCrud"), 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 = 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.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError
from django.db.models import Max, Q from django.db.models import Max, Q
@ -38,7 +38,7 @@ from django.utils.decorators import method_decorator
import sapl import sapl
from sapl.base.email_utils import do_envia_email_confirmacao from sapl.base.email_utils import do_envia_email_confirmacao
from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig, OperadorAutor
from sapl.comissoes.models import Participacao from sapl.comissoes.models import Participacao
from sapl.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE from sapl.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE
from sapl.compilacao.views import IntegracaoTaView from sapl.compilacao.views import IntegracaoTaView
@ -483,7 +483,8 @@ class TipoProposicaoCrud(CrudAux):
class BaseMixin(CrudAux.BaseMixin): class BaseMixin(CrudAux.BaseMixin):
list_field_names = [ list_field_names = [
"descricao", "content_type", 'tipo_conteudo_related'] "descricao", "content_type", 'tipo_conteudo_related',
'dispensa_protocolo']
class CreateView(CrudAux.CreateView): class CreateView(CrudAux.CreateView):
form_class = TipoProposicaoForm form_class = TipoProposicaoForm
@ -641,6 +642,135 @@ class ProposicaoPendenteSetor(PermissionRequiredMixin, ListView):
return context return context
class MateriasPendentesAssinaturaView(LoginRequiredMixin, ListView):
template_name = 'materia/materias_pendentes_assinatura_list.html'
model = MateriaLegislativa
paginate_by = 20
login_url = '/login/'
def get_autor(self):
try:
return OperadorAutor.objects.get(user=self.request.user).autor
except OperadorAutor.DoesNotExist:
return None
def get_queryset(self):
autor = self.get_autor()
qs = MateriaLegislativa.objects.filter(
texto_original__isnull=False
).exclude(
texto_original=''
).filter(
Q(pdf_assinado__isnull=True) | Q(pdf_assinado='')
)
if autor:
qs = qs.filter(autoria__autor=autor)
return qs.order_by('-data_apresentacao', '-id').distinct()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
autor = self.get_autor()
context['autor'] = autor
paginator = context['paginator']
page_obj = context['page_obj']
context['page_range'] = make_pagination(page_obj.number, paginator.num_pages)
context['total'] = paginator.count
# URL para pesquisar todas filtrando por autor + pendente
if autor:
context['url_pesquisa_completa'] = (
reverse('sapl.materia:pesquisar_materia')
+ f'?autoria__autor={autor.pk}&status_assinatura=pendente'
)
else:
context['url_pesquisa_completa'] = (
reverse('sapl.materia:pesquisar_materia')
+ '?status_assinatura=pendente'
)
# 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): class RevisarProposicaoSetor(PermissionRequiredMixin, UpdateView):
template_name = "materia/revisar_proposicao_setor.html" template_name = "materia/revisar_proposicao_setor.html"
model = Proposicao model = Proposicao
@ -904,6 +1034,13 @@ class UnidadeTramitacaoCrud(CrudAux):
form_class = UnidadeTramitacaoForm 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): class ProposicaoCrud(Crud):
model = Proposicao model = Proposicao
help_topic = 'proposicao' help_topic = 'proposicao'
@ -989,7 +1126,9 @@ class ProposicaoCrud(Crud):
msg_error = '' msg_error = ''
if p and p.autor.operadores.filter(id=request.user.id).exists(): if p and p.autor.operadores.filter(id=request.user.id).exists():
if action == 'send': 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.') msg_error = _('Proposição já foi enviada e recebida.')
elif p.data_envio: elif p.data_envio:
msg_error = _('Proposição já foi enviada.') msg_error = _('Proposição já foi enviada.')
@ -1085,7 +1224,9 @@ class ProposicaoCrud(Crud):
elif action == 'send_setor': elif action == 'send_setor':
app_config = sapl.base.models.AppConfig.objects.all().last() 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.') msg_error = _('Revisão pelo Setor Legislativo não está habilitada.')
elif p.data_envio: elif p.data_envio:
msg_error = _('Proposição já foi enviada ao protocolo.') msg_error = _('Proposição já foi enviada ao protocolo.')
@ -1340,7 +1481,12 @@ class ProposicaoCrud(Crud):
status_filter = self.request.GET.get('status', '') status_filter = self.request.GET.get('status', '')
if status_filter == 'elaboracao': 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': elif status_filter == 'aguardando':
qs = qs.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False) qs = qs.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False)
elif status_filter == 'incorporada': elif status_filter == 'incorporada':
@ -1357,7 +1503,8 @@ class ProposicaoCrud(Crud):
qs_base = super().get_queryset() qs_base = super().get_queryset()
stats = { stats = {
'total': qs_base.count(), '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(), '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(), 'incorporada': qs_base.filter(data_recebimento__isnull=False, cancelado=False).count(),
'devolvida': qs_base.filter(data_devolucao__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 = 'aguardando'
status_label = 'Aguardando Recebimento' status_label = 'Aguardando Recebimento'
status_icon = 'fa-clock-o' 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: else:
status = 'elaboracao' status = 'elaboracao'
status_label = 'Em Elaboração' status_label = 'Em Elaboração'
@ -1822,6 +1973,12 @@ def montar_helper_documento_acessorio(self):
' class="btn btn-dark">Cancelar</a>')])) ' 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): class DocumentoAcessorioCrud(MasterDetailCrud):
model = DocumentoAcessorio model = DocumentoAcessorio
parent_field = 'materia' parent_field = 'materia'
@ -1833,10 +1990,28 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
class CreateView(MasterDetailCrud.CreateView): class CreateView(MasterDetailCrud.CreateView):
form_class = DocumentoAcessorioForm form_class = DocumentoAcessorioForm
logger = logging.getLogger(__name__)
def get_initial(self): def get_initial(self):
from sapl.rules import is_procurador_juridico
initial = super(CreateView, self).get_initial() initial = super(CreateView, self).get_initial()
initial['data'] = timezone.now().date() 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 return initial
def get_success_url(self): def get_success_url(self):
@ -1949,13 +2124,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
) )
# Normaliza assinatura_info para lista (backward-compatible) # Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info) assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas context['assinaturas'] = assinaturas
context['ja_assinou'] = any( context['ja_assinou'] = any(
a.get('signed_by') == u.username a.get('signed_by') == u.username
for a in assinaturas for a in assinaturas
) if u.is_authenticated else False ) if u.is_authenticated else False
context['pode_remover_assinatura'] = (
u.is_authenticated and _pode_remover_assinatura(u)
)
return context return context
@ -1969,6 +2147,29 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
u.has_perm('materia.add_documentoacessorio') u.has_perm('materia.add_documentoacessorio')
) )
context['tipos_documento'] = TipoDocumento.objects.all() context['tipos_documento'] = TipoDocumento.objects.all()
# Documentos acessórios pendentes de assinatura para o lote
pode_assinar_lote = u.is_authenticated and (
u.is_superuser or
u.has_perm('materia.change_documentoacessorio')
)
if not pode_assinar_lote:
pode_assinar_lote = u.is_authenticated and OperadorAutor.objects.filter(user=u).exists()
if pode_assinar_lote:
materia_pk = self.kwargs.get('pk') or self.kwargs.get('root_pk')
qs_pendentes = DocumentoAcessorio.objects.filter(
materia__pk=materia_pk,
pdf_assinado='',
).order_by('data', 'nome')
docs_lote = [
{'id': d.pk, 'descricao': f'{d.nome} ({d.tipo}) — {d.data}'}
for d in qs_pendentes
]
context['docs_pendentes_lote'] = docs_lote
else:
context['docs_pendentes_lote'] = []
return context return context
def hook_arquivo(self, obj, default, url): def hook_arquivo(self, obj, default, url):
@ -2482,13 +2683,17 @@ class MateriaLegislativaCrud(Crud):
) )
# Normaliza assinatura_info para lista (backward-compatible) # Normaliza assinatura_info para lista (backward-compatible)
from sapl.materia.views_assinatura import _normalizar_assinatura_info from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura
assinaturas = _normalizar_assinatura_info(self.object.assinatura_info) assinaturas = _normalizar_assinatura_info(self.object.assinatura_info)
context['assinaturas'] = assinaturas context['assinaturas'] = assinaturas
context['ja_assinou'] = any( context['ja_assinou'] = any(
a.get('signed_by') == self.request.user.username a.get('signed_by') == self.request.user.username
for a in assinaturas for a in assinaturas
) if self.request.user.is_authenticated else False ) if self.request.user.is_authenticated else False
context['pode_remover_assinatura'] = (
self.request.user.is_authenticated and
_pode_remover_assinatura(self.request.user)
)
return context return context
@ -2742,6 +2947,41 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
context['show_results'] = show_results_filter_set(qr) context['show_results'] = show_results_filter_set(qr)
# Matérias pendentes de assinatura para o botão de lote
status_assinatura = self.request.GET.get('status_assinatura')
if status_assinatura == 'pendente' and context['show_results']:
from django.db.models import Q as _Q
# object_list já foi filtrado pelo filter_status_assinatura —
# precisamos obter os IDs primeiro para evitar problemas com
# querysets compostos por union (|) que não suportam .filter() extra
try:
ids_lote = list(self.object_list.values_list('id', flat=True)[:200])
from .models import MateriaLegislativa
qs_lote = MateriaLegislativa.objects.filter(
pk__in=ids_lote,
texto_original__isnull=False,
).exclude(texto_original='').filter(
_Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='')
).select_related('tipo').values_list(
'id', 'tipo__sigla', 'numero', 'ano'
)
context['materias_pendentes_lote'] = [
{'id': pk, 'descricao': f'{sigla} {numero}/{ano}'}
for pk, sigla, numero, ano in qs_lote
]
except Exception:
context['materias_pendentes_lote'] = []
else:
context['materias_pendentes_lote'] = []
# 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 return context
@ -3810,3 +4050,77 @@ def configEtiquetaMateriaLegislativaCrud(request):
else: else:
form = ConfigEtiquetaMateriaLegislativaForms(instance=config) form = ConfigEtiquetaMateriaLegislativaForms(instance=config)
return render(request, 'materia/config_etiqueta_materia.html', {'form': form}) return render(request, 'materia/config_etiqueta_materia.html', {'form': form})
def get_pdf_multiplos(request):
"""
Gera PDF unificado com os documentos das matérias informadas via
GET ?ids=1,2,3 ou POST body JSON {"ids": [1,2,3]}.
Retorna o PDF inline para impressão direta no browser.
Limite: 50 matérias por chamada.
"""
logger_local = logging.getLogger(__name__)
username = 'Usuário anônimo' if request.user.is_anonymous else request.user.username
if request.method == 'POST':
import json as _json
try:
body = _json.loads(request.body)
ids_raw = body.get('ids', '')
except Exception:
ids_raw = request.POST.get('ids', '')
else:
ids_raw = request.GET.get('ids', '')
try:
import json as _json
if isinstance(ids_raw, list):
ids = [int(i) for i in ids_raw]
elif isinstance(ids_raw, str) and ids_raw.startswith('['):
ids = [int(i) for i in _json.loads(ids_raw)]
else:
ids = [int(i.strip()) for i in str(ids_raw).split(',') if i.strip().isdigit()]
except Exception:
return JsonResponse({'error': 'IDs invalidos'}, status=400)
if not ids:
return JsonResponse({'error': 'Nenhum ID informado'}, status=400)
ids = ids[:50]
MEDIA_ROOT_local = settings.MEDIA_ROOT
materias = MateriaLegislativa.objects.filter(pk__in=ids).select_related('tipo')
pdf_files = []
for materia in materias:
if materia.pdf_assinado:
f = os.path.join(MEDIA_ROOT_local, str(materia.pdf_assinado))
if os.path.exists(f) and f.lower().endswith('.pdf'):
pdf_files.append(f)
continue
if materia.texto_original:
f = os.path.join(MEDIA_ROOT_local, str(materia.texto_original))
if os.path.exists(f) and f.lower().endswith('.pdf'):
pdf_files.append(f)
if not pdf_files:
return JsonResponse({'error': 'Nenhum PDF disponivel para as materias selecionadas.'}, status=404)
try:
merger = PdfFileMerger(strict=False)
for f in pdf_files:
merger.append(fileobj=f)
data = BytesIO()
merger.write(data)
merger.close()
pdf_bytes = data.getvalue()
except Exception as e:
logger_local.error("user={}. Erro ao gerar PDF multiplos: {}".format(username, str(e)))
return JsonResponse({'error': 'Erro ao gerar PDF: ' + str(e)}, status=500)
logger_local.info("user={}. Gerou PDF multiplos ({} materias, {} PDFs)".format(
username, len(materias), len(pdf_files)))
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = 'inline; filename="materias_selecionadas.pdf"'
return response

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 = [ 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! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234') SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
#DEBUG = config('DEBUG', default=False, cast=bool) # Aceita tanto DEBUG quanto DJANGO_DEBUG (compatibilidade com docker-compose legado)
DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) DEBUG = config('DEBUG', default=False, cast=bool) or config('DJANGO_DEBUG', default=False, cast=bool)
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
@ -235,6 +235,8 @@ TEMPLATES = [
'sapl.context_processors.mail_service_configured', 'sapl.context_processors.mail_service_configured',
'sapl.context_processors.google_recaptcha_configured', 'sapl.context_processors.google_recaptcha_configured',
'sapl.context_processors.enable_sapn', 'sapl.context_processors.enable_sapn',
'sapl.context_processors.pendencias_assinatura',
'sapl.context_processors.ged_configurado',
], ],
'debug': DEBUG '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) # consegue acessar o SAPL pela mesma URL que o navegador)
SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='') SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='')
# Microserviço de Assinatura Digital
# Quando ASSINATURA_API_URL estiver configurado, o SAPL delega a assinatura
# dos PDFs ao microserviço em vez de usar pyhanko localmente.
ASSINATURA_API_URL = config('ASSINATURA_API_URL', default='')
ASSINATURA_API_KEY = config('ASSINATURA_API_KEY', default='')
ASSINATURA_API_TIMEOUT = config('ASSINATURA_API_TIMEOUT', cast=int, default=120)
# Integração externa de matérias da sessão # Integração externa de matérias da sessão
SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='') SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='')
SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='') SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='')

293
sapl/static/js/materia_impressao_em_massa.js

@ -0,0 +1,293 @@
/**
* materia_impressao_em_massa.js
*
* Impressão / download em massa de matérias legislativas.
*
* Funcionalidades:
* - Ativa modo de seleção ao clicar em "Imprimir Selecionados" (barra de ações)
* - Exibe checkbox em cada linha de resultado
* - Toolbar flutuante mostra contagem e ações (Imprimir / Baixar PDF / Limpar)
* - Botão "Todos" seleciona/desseleciona todos da página atual
* - Ao clicar Imprimir: chama /materia/pdf-multiplos/?ids=... abre PDF no
* browser para impressão direta via window.print() em novo tab
* - Ao clicar Baixar PDF: mesmo endpoint mas força download via blob
*/
(function () {
'use strict';
var URL_PDF_MULTIPLOS = '/materia/pdf-multiplos/';
var MAX_SELECAO = 200;
var modoAtivo = false;
// ── Elementos ────────────────────────────────────────────────────────────
var btnImprimirSelecionados = null; // botão na barra de ações (topo)
var btnSelecionarTodos = null;
var toolbar = null;
var toolbarCount = null;
var toolbarBtnImprimir = null;
var toolbarBtnDownload = null;
var toolbarBtnLimpar = null;
var toolbarLoading = null;
// ── Inicialização ─────────────────────────────────────────────────────────
function init() {
btnImprimirSelecionados = document.getElementById('btn-imprimir-selecionados');
btnSelecionarTodos = document.getElementById('btn-selecionar-todos-print');
toolbar = document.getElementById('print-toolbar');
toolbarCount = document.getElementById('print-toolbar-count');
toolbarBtnImprimir = document.getElementById('print-toolbar-btn-imprimir');
toolbarBtnDownload = document.getElementById('print-toolbar-btn-download');
toolbarBtnLimpar = document.getElementById('print-toolbar-btn-limpar');
toolbarLoading = document.getElementById('print-toolbar-loading');
if (!btnImprimirSelecionados || !toolbar) return; // não está na página de resultados
// Botões já visíveis no novo layout — garante estado inicial correto
btnImprimirSelecionados.style.display = '';
if (btnSelecionarTodos) btnSelecionarTodos.style.display = 'none'; // aparece só quando modo ativo
btnImprimirSelecionados.addEventListener('click', function () {
if (!modoAtivo) {
ativarModo();
} else {
var ids = getIdsSelecionados();
if (ids.length === 0) {
mostrarAlerta('Selecione ao menos um documento para imprimir.');
return;
}
abrirPDF(ids, true);
}
});
btnSelecionarTodos.addEventListener('click', function () {
if (!modoAtivo) { ativarModo(); }
var checks = document.querySelectorAll('.print-chk');
var todasMarcadas = Array.from(checks).every(function (c) { return c.checked; });
checks.forEach(function (c) { c.checked = !todasMarcadas; });
atualizarContagem();
});
toolbarBtnImprimir.addEventListener('click', function () {
var ids = getIdsSelecionados();
if (ids.length === 0) { mostrarAlerta('Selecione ao menos um documento.'); return; }
abrirPDF(ids, true);
});
toolbarBtnDownload.addEventListener('click', function () {
var ids = getIdsSelecionados();
if (ids.length === 0) { mostrarAlerta('Selecione ao menos um documento.'); return; }
baixarPDF(ids);
});
toolbarBtnLimpar.addEventListener('click', function () {
desativarModo();
});
// Delegação de eventos nos checkboxes (gerados dinamicamente)
document.addEventListener('change', function (e) {
if (e.target && e.target.classList.contains('print-chk')) {
atualizarContagem();
}
});
// Clique na linha inteira (quando modo ativo) seleciona o checkbox
document.addEventListener('click', function (e) {
if (!modoAtivo) return;
var row = e.target.closest('.materia-row');
if (!row) return;
// Evita toggle duplo se clicou direto no checkbox ou num link
if (e.target.classList.contains('print-chk')) return;
if (e.target.closest('a')) return;
var chk = row.querySelector('.print-chk');
if (chk) {
chk.checked = !chk.checked;
atualizarContagem();
}
});
}
// ── Modo de seleção ───────────────────────────────────────────────────────
function ativarModo() {
modoAtivo = true;
// Mostra checkboxes em todas as linhas
document.querySelectorAll('.print-select-col').forEach(function (el) {
el.style.display = 'inline-block';
});
// Estilo visual nas linhas
document.querySelectorAll('.materia-row').forEach(function (row) {
row.style.cursor = 'pointer';
});
// Atualiza botão de ações — destaca em vermelho sólido
btnImprimirSelecionados.classList.replace('btn-outline-danger', 'btn-danger');
// Mostra badge e botão Todos
var badge = document.getElementById('badge-print-total');
if (badge) badge.style.display = '';
if (btnSelecionarTodos) btnSelecionarTodos.style.display = '';
toolbar.style.display = 'block';
atualizarContagem();
}
function desativarModo() {
modoAtivo = false;
// Desmarca todos e oculta checkboxes
document.querySelectorAll('.print-chk').forEach(function (c) { c.checked = false; });
document.querySelectorAll('.print-select-col').forEach(function (el) {
el.style.display = 'none';
});
document.querySelectorAll('.materia-row').forEach(function (row) {
row.style.cursor = '';
row.classList.remove('table-active');
});
// Restaura botão para outline
btnImprimirSelecionados.classList.replace('btn-danger', 'btn-outline-danger');
// Oculta badge e botão Todos
var badge = document.getElementById('badge-print-total');
if (badge) { badge.style.display = 'none'; badge.textContent = '0'; }
if (btnSelecionarTodos) btnSelecionarTodos.style.display = 'none';
toolbar.style.display = 'none';
ocultarAlertaToolbar();
}
// ── Contagem ──────────────────────────────────────────────────────────────
function getIdsSelecionados() {
return Array.from(document.querySelectorAll('.print-chk:checked'))
.map(function (c) { return parseInt(c.getAttribute('data-materia-id'), 10); })
.slice(0, MAX_SELECAO);
}
function atualizarContagem() {
var ids = getIdsSelecionados();
var n = ids.length;
// Badge na toolbar flutuante
if (toolbarCount) toolbarCount.textContent = n;
// Badge no botão de ações
var badge = document.getElementById('badge-print-total');
if (badge) badge.textContent = n;
// Destaque visual nas linhas selecionadas
document.querySelectorAll('.materia-row').forEach(function (row) {
var chk = row.querySelector('.print-chk');
if (chk && chk.checked) {
row.classList.add('table-active');
} else {
row.classList.remove('table-active');
}
});
// Aviso de limite
if (n >= MAX_SELECAO) {
mostrarAlertaToolbar('Limite de ' + MAX_SELECAO + ' documentos atingido. Desmarque alguns para selecionar outros.');
} else {
ocultarAlertaToolbar();
}
}
// ── PDF: imprimir ─────────────────────────────────────────────────────────
function abrirPDF(ids, imprimir) {
setLoading(true);
var url = URL_PDF_MULTIPLOS + '?ids=' + ids.join(',');
// Abre em nova aba; quando carregado o browser oferece impressão
var win = window.open(url, '_blank');
if (!win) {
// Pop-up bloqueado — fallback: link direto
var a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Aguarda pequeno delay e remove loading (não temos evento de "carregou" na outra aba)
setTimeout(function () { setLoading(false); }, 2000);
}
// ── PDF: baixar como arquivo ──────────────────────────────────────────────
function baixarPDF(ids) {
setLoading(true);
var url = URL_PDF_MULTIPLOS + '?ids=' + ids.join(',');
fetch(url, { credentials: 'same-origin' })
.then(function (resp) {
if (!resp.ok) {
return resp.json().then(function (d) {
throw new Error(d.error || ('Erro HTTP ' + resp.status));
});
}
return resp.blob();
})
.then(function (blob) {
var blobUrl = window.URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = blobUrl;
a.download = 'materias_selecionadas.pdf';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(blobUrl);
setLoading(false);
})
.catch(function (err) {
setLoading(false);
mostrarAlerta('Erro ao gerar PDF: ' + err.message);
});
}
// ── Helpers UI ────────────────────────────────────────────────────────────
function setLoading(on) {
if (!toolbarLoading) return;
toolbarLoading.style.display = on ? 'block' : 'none';
if (toolbarBtnImprimir) toolbarBtnImprimir.disabled = on;
if (toolbarBtnDownload) toolbarBtnDownload.disabled = on;
}
function mostrarAlerta(msg) {
// Toast simples usando Bootstrap alert
var div = document.createElement('div');
div.className = 'alert alert-warning alert-dismissible fade show';
div.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;min-width:300px;max-width:500px;';
div.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + escHtml(msg) +
'<button type="button" class="close" data-dismiss="alert"><span>&times;</span></button>';
document.body.appendChild(div);
setTimeout(function () {
if (div.parentNode) div.parentNode.removeChild(div);
}, 5000);
}
function mostrarAlertaToolbar(msg) {
var existing = document.getElementById('print-toolbar-limit-alert');
if (existing) return;
var div = document.createElement('div');
div.id = 'print-toolbar-limit-alert';
div.className = 'alert alert-warning py-1 px-2 mt-2 mb-0 small';
div.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + escHtml(msg);
if (toolbar) toolbar.appendChild(div);
}
function ocultarAlertaToolbar() {
var el = document.getElementById('print-toolbar-limit-alert');
if (el && el.parentNode) el.parentNode.removeChild(el);
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Arranque ──────────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

22
sapl/static/js/materia_pesquisa_download_pdfs.js

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

13
sapl/templates/base.html

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

1
sapl/templates/base/layouts.yaml

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

77
sapl/templates/email/pendentes_assinatura.html

@ -0,0 +1,77 @@
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; color: #333; background: #f5f5f5; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 20px auto; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #343a40; padding: 24px 32px; text-align: center; }
.header h1 { color: #fff; margin: 0; font-size: 20px; }
.header p { color: #adb5bd; margin: 6px 0 0; font-size: 13px; }
.alert-bar { background: #ffc107; padding: 12px 32px; text-align: center; font-weight: bold; color: #212529; }
.body { padding: 28px 32px; }
.body p { line-height: 1.6; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { background: #f8f9fa; text-align: left; padding: 8px 12px; font-size: 13px; color: #666; border-bottom: 2px solid #dee2e6; }
td { padding: 8px 12px; font-size: 14px; border-bottom: 1px solid #f0f0f0; }
tr:last-child td { border-bottom: none; }
.btn { display: inline-block; margin-top: 20px; padding: 12px 28px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 4px; font-weight: bold; }
.footer { background: #f8f9fa; padding: 16px 32px; text-align: center; font-size: 12px; color: #aaa; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{ casa_legislativa }}</h1>
<p>Sistema de Gestão e Votação Parlamentar</p>
</div>
<div class="alert-bar">
&#128276; {{ total }} matéria(s) aguardando sua assinatura digital
</div>
<div class="body">
<p>Olá, <strong>{{ nome_autor }}</strong>.</p>
<p>
As seguintes matérias legislativas estão com <strong>assinatura digital pendente</strong>
e precisam da sua atenção:
</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Matéria</th>
<th>Ementa</th>
</tr>
</thead>
<tbody>
{% for m in materias %}
<tr>
<td>{{ forloop.counter }}</td>
<td><strong>{{ m.tipo.sigla }} {{ m.numero }}/{{ m.ano }}</strong></td>
<td style="color:#555;">{{ m.ementa|truncatechars:80 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_omitidas %}
<p style="color:#888; font-size:13px;">
… e mais {{ total_omitidas }} outra(s) matéria(s) não listada(s) acima.
</p>
{% endif %}
<a class="btn" href="{{ base_url }}{{ url_pesquisa }}">
Acessar matérias pendentes &#8594;
</a>
</div>
<div class="footer">
<p>Esta é uma mensagem automática. Por favor, não a responda.</p>
<p>&copy; {{ casa_legislativa }}</p>
</div>
</div>
</body>
</html>

17
sapl/templates/email/pendentes_assinatura.txt

@ -0,0 +1,17 @@
{{ casa_legislativa }} — SGVP
=====================================================
Olá, {{ nome_autor }}.
Você possui {{ total }} matéria(s) aguardando assinatura digital:
{% for m in materias %} {{ forloop.counter }}. {{ m.tipo.sigla }} {{ m.numero }}/{{ m.ano }} — {{ m.ementa|truncatechars:80 }}
{% endfor %}
{% if total_omitidas %}
... e mais {{ total_omitidas }} outra(s) matéria(s).
{% endif %}
Acesse: {{ base_url }}{{ url_pesquisa }}
---
Esta é uma mensagem automática. Por favor, não a responda.

31
sapl/templates/index.html

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

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

15
sapl/templates/materia/documentoacessorio_list.html

@ -58,6 +58,17 @@
<div class="actions btn-group float-right" role="group"> <div class="actions btn-group float-right" role="group">
<a href="{% url 'sapl.materia:compress_docacessorios' root_pk %}" class="btn btn-outline-primary">{% trans 'Baixar documentos compactados' %}</a> <a href="{% url 'sapl.materia:compress_docacessorios' root_pk %}" class="btn btn-outline-primary">{% trans 'Baixar documentos compactados' %}</a>
</div> </div>
{% if docs_pendentes_lote %}
<div class="actions btn-group float-right ml-2" role="group">
<button type="button" id="btn-assinar-doc-lote"
class="btn btn-outline-warning"
title="Assinar digitalmente os {{ docs_pendentes_lote|length }} documento(s) acessório(s) pendentes">
<i class="fa fa-certificate"></i>
Assinar em Lote
<span class="badge badge-warning text-dark ml-1">{{ docs_pendentes_lote|length }}</span>
</button>
</div>
{% endif %}
</div> </div>
{% if pode_upload %} {% if pode_upload %}
@ -169,4 +180,8 @@
})(); })();
</script> </script>
{% endif %} {% endif %}
{% if docs_pendentes_lote %}
{% include "materia/assinatura_doc_lote_modal.html" %}
{% endif %}
{% endblock %} {% 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" %} <i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %}
</a> </a>
{% endif %} {% endif %}
{% if object.numero_protocolo and object.texto_original %} {% if object.texto_original %}
{% if object.pdf_assinado %} {% if object.pdf_assinado %}
<a class="btn btn-success" href="{% url 'sapl.materia:materia_pdf_assinado' object.pk %}" target="_blank"> <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" %} <i class="fa fa-check-circle"></i> {% trans "Ver PDF Assinado" %}
</a> </a>
{% endif %} {% endif %}
{% if is_autor and not ja_assinou %} {% if is_autor or can_edit_materia %}
{% if not ja_assinou and object.texto_original %}
<button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal"> <button type="button" class="btn btn-warning" data-toggle="modal" data-target="#assinaturaModal">
<i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %} <i class="fa fa-certificate"></i> {% trans "Assinar PDF Digitalmente" %}
</button> </button>
{% endif %}
{% endif %} {% endif %}
{% endif %}
{% if object.documentoacessorio_set.all.exists %}
<a class="btn btn-danger" href="{% url 'sapl.materia:pdf_completo_materia' object.pk %}" title="{% trans 'Mescla matéria e acessórios em um único PDF' %}"> <a class="btn btn-danger" href="{% url 'sapl.materia:pdf_completo_materia' object.pk %}" title="{% trans 'Mescla matéria e acessórios em um único PDF' %}">
<i class="fa fa-file-pdf-o"></i> {% trans "Todos em PDF" %} <i class="fa fa-file-pdf-o"></i> {% trans "Todos em PDF" %}
</a> </a>
@ -230,6 +230,13 @@
<i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %} <i class="fa fa-qrcode"></i> {% trans "Verificar Autenticidade" %}
</a> </a>
{% endif %} {% endif %}
{% if pode_remover_assinatura %}
<button type="button" class="btn btn-sm btn-danger btn-remover-assinatura"
data-url="{% url 'sapl.materia:materia_remover_assinatura' object.pk %}"
data-nome="{{ object }}">
<i class="fa fa-trash"></i> {% trans "Remover Assinatura" %}
</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -240,7 +247,8 @@
{% block extra_js %} {% block extra_js %}
{{ block.super }} {{ block.super }}
{% if object.numero_protocolo and object.texto_original and is_autor and not ja_assinou %} {% if object.texto_original and not ja_assinou %}
{% if is_autor or can_edit_materia %}
{% include "materia/assinatura_modal.html" %} {% include "materia/assinatura_modal.html" %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -249,5 +257,9 @@
} }
}); });
</script> </script>
{% endif %}
{% endif %}
{% if pode_remover_assinatura and object.pdf_assinado %}
{% include "materia/remover_assinatura_modal.html" %}
{% endif %} {% endif %}
{% endblock extra_js %} {% endblock extra_js %}

701
sapl/templates/materia/materialegislativa_filter.html

@ -7,36 +7,162 @@
{% load static %} {% load static %}
{% block actions %} {% block actions %}
<style>
.sapl-ab {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding-bottom: .75rem;
}
/* Linha 1: botões de ação (esquerda) + navegação (direita) */
.sapl-ab-row1 {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
}
/* Linha 2: filtro de assinatura + assinar em lote — só aparece quando necessário */
.sapl-ab-row2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.sapl-ab-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.sapl-ab-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.sapl-ab-sep {
width: 1px;
height: 28px;
background: #dee2e6;
flex-shrink: 0;
margin: 0 2px;
}
#select-status-assinatura { min-width: 190px; max-width: 230px; }
@media (max-width: 576px) {
#select-status-assinatura { min-width: 140px; }
.sapl-ab-sep { display: none; }
}
</style>
{% if show_results %} <div class="sapl-ab">
<div class="float-left">
{% with 'sapl.materia:pesquisar_materia' as url_reverse %} {# ── Linha 1: ações + navegação ─────────────────────────────────── #}
{% include "crud/format_options.html" %} <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 %} {% 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> </button>
</div> {% endif %}
{% endif %}
<div class="actions btn-group float-right pb-4" role="group"> {# Assinar Despachos em Lote — atalho exclusivo do Presidente da Mesa Diretora #}
{% switch "SOLR_SWITCH" %} {% if is_presidente_mesa %}
<a href="{% url 'sapl.base:haystack_search' %}" class="btn btn-outline-primary"> <a href="{% url 'sapl.materia:despachos_pendentes_lote' %}"
Pesquisa Textual class="btn btn-warning btn-sm"
</a> title="Assinar todos os despachos pendentes do sistema em lote (acesso exclusivo do Presidente da Mesa Diretora)">
{% endswitch %} <i class="fas fa-stamp"></i>
{% if perms.materia.add_materialegislativa %} Assinar Despachos em Lote
<a href="{% url 'sapl.materia:materialegislativa_create' %}" class="btn btn-outline-primary">
{% blocktrans with verbose_name=view.verbose_name %} Adicionar Matéria Legislativa {% endblocktrans %}
</a> </a>
{% endif %} {% endif %}
{% if show_results %}
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary">{% trans 'Fazer nova pesquisa' %}</a>
{% endif %}
</div> </div>
{# ── /Linha 2 ────────────────────────────────────────────────────── #}
</div>
{% endblock %} {% endblock %}
{% block detail_content %} {% block detail_content %}
{% if not show_results %} {% if not show_results %}
{% crispy filter.form %} {% crispy filter.form %}
{% endif %} {% endif %}
@ -58,8 +184,16 @@
{% endif %} {% endif %}
{% for m in page_obj %} {% for m in page_obj %}
<tr> <tr class="materia-row" data-materia-id="{{ m.id }}">
<td> <td>
{# Checkbox para seleção de impressão em massa #}
<div class="print-select-col" style="display:none; float:left; margin-right:8px; margin-top:2px;">
<input type="checkbox"
class="print-chk form-check-input"
data-materia-id="{{ m.id }}"
title="Selecionar para impressão"
style="width:18px;height:18px;cursor:pointer;">
</div>
<strong><a href="{% url 'sapl.materia:materialegislativa_detail' m.id %}">{{m.tipo.sigla}} {{m.numero}}/{{m.ano}} - {{m.tipo}}</strong></a> <strong><a href="{% url 'sapl.materia:materialegislativa_detail' m.id %}">{{m.tipo.sigla}} {{m.numero}}/{{m.ano}} - {{m.tipo}}</strong></a>
<a href="{% url 'sapl.relatorios:etiqueta_materia_legislativa' m.pk %}"><img src="{% webpack_static 'img/etiqueta.png' %}" alt="Etiqueta Individual"></a> <a href="{% url 'sapl.relatorios:etiqueta_materia_legislativa' m.pk %}"><img src="{% webpack_static 'img/etiqueta.png' %}" alt="Etiqueta Individual"></a>
</br> </br>
@ -215,6 +349,19 @@
Texto Original Texto Original
</a> </a>
</strong> </strong>
&nbsp;
{% if m.pdf_assinado %}
<a href="{% url 'sapl.materia:materia_pdf_assinado' m.pk %}" target="_blank"
class="badge badge-success"
title="PDF assinado disponível — clique para baixar">
<i class="fas fa-signature"></i> Assinado{% if m.assinado_em %} em {{ m.assinado_em|date:"d/m/Y" }}{% endif %}
</a>
{% elif m.texto_original %}
<span class="badge badge-warning text-dark"
title="Documento ainda não possui assinatura digital">
<i class="fas fa-clock"></i> Pendente de Assinatura
</span>
{% endif %}
</br> </br>
{% endif %} {% endif %}
{% if m.texto_articulado.exists %}<strong><a href="{% url 'sapl.materia:materia_ta' m.id%}">Texto Articulado</a></strong></br>{% endif %} {% if m.texto_articulado.exists %}<strong><a href="{% url 'sapl.materia:materia_ta' m.id%}">Texto Articulado</a></strong></br>{% endif %}
@ -250,13 +397,521 @@
</table> </table>
{% include "paginacao.html" %} {% include "paginacao.html" %}
{% endif %} {% endif %}
{# ── Toolbar flutuante de impressão em massa ─────────────────────────── #}
{% if show_results %}
<div id="print-toolbar"
style="display:none; position:fixed; bottom:20px; left:50%; transform:translateX(-50%);
z-index:1050; background:#fff; border:2px solid #dc3545;
border-radius:50px; padding:10px 20px;
box-shadow:0 8px 32px rgba(220,53,69,.25), 0 2px 8px rgba(0,0,0,.12);
white-space:nowrap;">
<div class="d-flex align-items-center" style="gap:10px;">
{# Contagem #}
<span class="text-danger font-weight-bold" style="font-size:.95rem;">
<i class="fas fa-print mr-1"></i>
<span id="print-toolbar-count">0</span> selecionado(s)
</span>
<div class="border-left" style="height:22px;"></div>
{# Ações #}
<button id="print-toolbar-btn-imprimir"
class="btn btn-danger btn-sm rounded-pill px-3"
title="Gerar PDF unificado e abrir para impressão">
<i class="fas fa-print mr-1"></i> Imprimir
</button>
<button id="print-toolbar-btn-download"
class="btn btn-outline-danger btn-sm rounded-pill px-3"
title="Baixar PDF unificado">
<i class="fas fa-download mr-1"></i> Baixar PDF
</button>
<div class="border-left" style="height:22px;"></div>
<button id="print-toolbar-btn-limpar"
class="btn btn-link btn-sm text-secondary p-0"
title="Cancelar seleção">
<i class="fas fa-times-circle fa-lg"></i>
</button>
</div>
{# Info + loading (ocultos por padrão) #}
<div class="text-center mt-1" style="font-size:.72rem; color:#888;">
<i class="fas fa-info-circle"></i>
Máx. 200 docs. Apenas matérias com PDF disponível serão incluídas.
</div>
<div id="print-toolbar-loading" style="display:none;" class="mt-2">
<div class="progress" style="height:4px; border-radius:2px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-danger" style="width:100%"></div>
</div>
<div class="text-center mt-1" style="font-size:.75rem; color:#888;">Gerando PDF, aguarde…</div>
</div>
</div>
{% endif %}
{# ────────────────────────────────────────────────────────────────────── #}
{% endblock detail_content %} {% endblock detail_content %}
{% block table_content %} {% block table_content %}
{% endblock table_content %} {% endblock table_content %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'js/materia_pesquisa_download_pdfs.js' %}"></script> <script src="{% static 'js/materia_pesquisa_download_pdfs.js' %}"></script>
<script src="{% static 'js/materia_impressao_em_massa.js' %}"></script>
{% if materias_pendentes_lote %}
{# ── Modal de Assinatura em Lote ─────────────────────────────────────── #}
<div class="modal fade" id="assinaturaLoteModal" tabindex="-1" role="dialog" aria-labelledby="assinaturaLoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title" id="assinaturaLoteModalLabel">
<i class="fa fa-certificate"></i>
Assinar em Lote — <strong id="lote-titulo-total">{{ materias_pendentes_lote|length }}</strong> matéria(s) pendente(s)
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Fechar">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{# ── Passo 0: seleção de matérias ── #}
<div id="lote-selecao-step">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="font-weight-bold">
<i class="fa fa-list-ul"></i>
Selecione as matérias a assinar
</span>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary mr-1" id="lote-btn-todas">
<i class="fa fa-check-square-o"></i> Todas
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="lote-btn-nenhuma">
<i class="fa fa-square-o"></i> Nenhuma
</button>
</div>
</div>
<div id="lote-lista-checkboxes" style="max-height:320px;overflow-y:auto;border:1px solid #dee2e6;border-radius:4px;padding:8px;">
{% for m in materias_pendentes_lote %}
<div class="form-check py-1" style="border-bottom:1px solid #f0f0f0;">
<input class="form-check-input lote-chk" type="checkbox"
id="lote-chk-{{ m.id }}" value="{{ m.id }}" checked>
<label class="form-check-label w-100" for="lote-chk-{{ m.id }}" style="cursor:pointer;">
{{ m.descricao }}
</label>
</div>
{% endfor %}
</div>
<p class="text-muted small mt-2 mb-0">
<span id="lote-selecao-contagem">{{ materias_pendentes_lote|length }}</span> matéria(s) selecionada(s)
</p>
</div>
{# ── Passo 1: formulário de certificado ── #}
<div id="lote-form-step" style="display:none;">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i>
<strong>Atenção:</strong> Esta operação assinará digitalmente
<strong><span id="lote-alerta-total">{{ materias_pendentes_lote|length }}</span> matéria(s)</strong>
com validade jurídica nos termos da MP 2.200-2/2001.
Certifique-se de que os documentos estão corretos antes de prosseguir.
</div>
<form id="form-assinatura-lote" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-group">
<label for="lote-certificado">
<i class="fa fa-file"></i> Certificado Digital (.pfx / .p12)
</label>
<div class="custom-file">
<input type="file" class="custom-file-input" id="lote-certificado"
name="certificado" accept=".pfx,.p12" required>
<label class="custom-file-label" for="lote-certificado">
Selecione o arquivo do certificado...
</label>
</div>
</div>
<div class="form-group">
<label for="lote-senha">
<i class="fa fa-lock"></i> Senha do Certificado
</label>
<input type="password" class="form-control" id="lote-senha"
name="senha" placeholder="Digite a senha do certificado" required>
</div>
</form>
</div>
{# ── Passo 1.5: prévia dos documentos ── #}
<div id="lote-previa-step" style="display:none;">
<div class="alert alert-info mb-3">
<i class="fa fa-eye"></i>
<strong>Revise os documentos antes de assinar.</strong>
Clique em cada matéria para visualizar o PDF em uma nova aba.
A assinatura digital tem validade jurídica e não pode ser desfeita sem permissão especial.
</div>
<div id="lote-previa-lista" style="max-height:300px;overflow-y:auto;border:1px solid #dee2e6;border-radius:4px;padding:8px;" class="mb-3">
{# Preenchido via JS com as matérias selecionadas + link para PDF #}
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="lote-previa-confirmacao">
<label class="form-check-label font-weight-bold text-danger" for="lote-previa-confirmacao">
<i class="fa fa-check-square-o"></i>
Confirmo que revisei os documentos e que estão corretos para assinatura.
</label>
</div>
</div>
{# ── Passo 2: progresso ── #}
<div id="lote-progresso-step" style="display:none;">
<h6 class="mb-3">
<i class="fa fa-spinner fa-spin text-primary"></i>
Processando assinaturas…
</h6>
<div class="progress mb-3" style="height:22px;">
<div id="lote-progress-bar"
class="progress-bar progress-bar-striped progress-bar-animated bg-warning"
role="progressbar" style="width:0%">0%</div>
</div>
<p class="text-muted small" id="lote-status-texto">Iniciando…</p>
<div id="lote-resultados-parciais" class="mt-2" style="max-height:220px;overflow-y:auto;"></div>
</div>
<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() { $( document ).ready(function() {
$('.link_votacao_nominal').on('click', function(event) { $('.link_votacao_nominal').on('click', function(event) {
event.preventDefault(); event.preventDefault();
@ -265,7 +920,6 @@
function pesquisaAvancada(){ function pesquisaAvancada(){
$('.pesquisa_avancada').toggle(); $('.pesquisa_avancada').toggle();
var id_btn = "#btn_pesquisa_avancada_id"; var id_btn = "#btn_pesquisa_avancada_id";
if ($(id_btn).val().endsWith('>>>')){ if ($(id_btn).val().endsWith('>>>')){
$(id_btn).val($(id_btn).val().replace('>>>', '<<<')) $(id_btn).val($(id_btn).val().replace('>>>', '<<<'))
@ -276,7 +930,6 @@
function votacaoNominal(id){ function votacaoNominal(id){
$('#div_' + id).toggle(); $('#div_' + id).toggle();
var id_link = "#link_votacao_nominal_" + id; var id_link = "#link_votacao_nominal_" + id;
if ($(id_link).text().indexOf('>>>') > -1){ if ($(id_link).text().indexOf('>>>') > -1){
$(id_link).text($(id_link).text().replace('>>>', '<<<')) $(id_link).text($(id_link).text().replace('>>>', '<<<'))

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"> <a href="{% url 'sapl.materia:onlyoffice_editor' object.pk %}" class="btn btn-primary">
<i class="fa fa-edit"></i> {% trans "Editar Documento" %} <i class="fa fa-edit"></i> {% trans "Editar Documento" %}
</a> </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"> <a href="{{ view.detail_url }}?action=send" class="btn btn-success">
<i class="fa fa-paper-plane"></i> {% trans "Enviar Proposição" %} <i class="fa fa-paper-plane"></i> {% trans "Enviar Proposição" %}
</a> </a>
@ -460,6 +466,21 @@
</div> </div>
{% endif %} {% endif %}
<!-- Co-autores -->
{% if object.coautores.exists %}
<div class="prop-section">
<div class="prop-section-title">{% trans "Co-autores" %}</div>
<div class="prop-meta">
{% for coautoria in object.coautores.all %}
<div class="prop-meta-item" style="flex: 0 0 auto; min-width: 200px;">
<label>{{ coautoria.autor.tipo }}</label>
<span><i class="fa fa-user"></i> {{ coautoria.autor.nome }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Conteúdo Gerado / Vínculo --> <!-- Conteúdo Gerado / Vínculo -->
{% if object.conteudo_gerado_related or object.materia_de_vinculo %} {% if object.conteudo_gerado_related or object.materia_de_vinculo %}
<div class="prop-section"> <div class="prop-section">

228
sapl/templates/materia/proposicao_form.html

@ -2,8 +2,88 @@
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block extra_js %} {% block extra_css %}
<style> <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 Estilo para o container do tipo de proposição
===================================================== */ ===================================================== */
@ -188,10 +268,119 @@
padding: 8px 25px; padding: 8px 25px;
} }
</style> </style>
{% endblock %}
{% block extra_js %}
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(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" // Configuração do botão "Novo Tipo"
// ===================================================== // =====================================================
@ -200,7 +389,6 @@ $(document).ready(function(){
var $tipoSelect = $("select[name=tipo]"); var $tipoSelect = $("select[name=tipo]");
if ($tipoLabel.length > 0 && $tipoSelect.length > 0) { if ($tipoLabel.length > 0 && $tipoSelect.length > 0) {
// Criar header com label e botão
var $header = $('<div class="tipo-header"></div>'); var $header = $('<div class="tipo-header"></div>');
var $btnNovoTipo = $(` var $btnNovoTipo = $(`
<a href="/sistema/proposicao/tipo/create" <a href="/sistema/proposicao/tipo/create"
@ -210,8 +398,6 @@ $(document).ready(function(){
<i class="fa fa-plus"></i> Novo Tipo <i class="fa fa-plus"></i> Novo Tipo
</a> </a>
`); `);
// Mover label para o header e adicionar botão
$tipoLabel.wrap($header); $tipoLabel.wrap($header);
$tipoLabel.after($btnNovoTipo); $tipoLabel.after($btnNovoTipo);
} }
@ -223,54 +409,37 @@ $(document).ready(function(){
var tipoTextoValue = $("input[name=tipo_texto]:checked").val(); var tipoTextoValue = $("input[name=tipo_texto]:checked").val();
if (tipoTextoValue === 'D') { if (tipoTextoValue === 'D') {
// Arquivo Digital: mostrar campo de upload
$("#div_id_texto_original").slideDown(200); $("#div_id_texto_original").slideDown(200);
$("#onlyoffice-info").slideUp(200); $("#onlyoffice-info").slideUp(200);
} else if (tipoTextoValue === 'T') { } else if (tipoTextoValue === 'T') {
// Texto Articulado: esconder ambos
$("#div_id_texto_original").slideUp(200); $("#div_id_texto_original").slideUp(200);
$("#onlyoffice-info").slideUp(200); $("#onlyoffice-info").slideUp(200);
} else if (tipoTextoValue === 'O') { } else if (tipoTextoValue === 'O') {
// OnlyOffice: mostrar mensagem informativa
$("#div_id_texto_original").slideUp(200); $("#div_id_texto_original").slideUp(200);
$("#onlyoffice-info").slideDown(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'); $("#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'); $("input[name=tipo_texto]:checked").closest('label').addClass('checked');
} }
// Event listener para mudança de tipo de texto
$("input[name=tipo_texto]").change(function() { $("input[name=tipo_texto]").change(function() {
updateTipoTextoDisplay(); updateTipoTextoDisplay();
}); });
// =====================================================
// Mostrar opções de tipo de texto
// =====================================================
function showTipoTextoOptions() { function showTipoTextoOptions() {
var $tipoTextoContainer = $("input[name=tipo_texto]").closest('.form-group').parent(); var $tipoTextoContainer = $("input[name=tipo_texto]").closest('.form-group').parent();
$tipoTextoContainer.removeClass('hidden'); $tipoTextoContainer.removeClass('hidden');
$("input[name=tipo_texto]").prop('disabled', false); $("input[name=tipo_texto]").prop('disabled', false);
$("input[name=tipo_texto]").closest('label').removeClass('disabled'); $("input[name=tipo_texto]").closest('label').removeClass('disabled');
// Selecionar primeira opção se nenhuma estiver selecionada
if ($("input[name=tipo_texto]:checked").length === 0) { if ($("input[name=tipo_texto]:checked").length === 0) {
$("input[name=tipo_texto]").first().prop('checked', true); $("input[name=tipo_texto]").first().prop('checked', true);
} }
updateTipoTextoDisplay(); updateTipoTextoDisplay();
} }
// Mostrar opções ao carregar a página
showTipoTextoOptions(); showTipoTextoOptions();
$("select[name=tipo]").change(function() { showTipoTextoOptions(); });
// Atualizar quando tipo de proposição mudar
$("select[name=tipo]").change(function() {
showTipoTextoOptions();
});
// ===================================================== // =====================================================
// Busca de matéria legislativa para vinculação // Busca de matéria legislativa para vinculação
@ -281,15 +450,11 @@ $(document).ready(function(){
'ano': $("input[name=ano_materia]").val(), 'ano': $("input[name=ano_materia]").val(),
'numero': $("input[name=numero_materia]").val(), 'numero': $("input[name=numero_materia]").val(),
}; };
// Validar se todos os campos estão preenchidos
if (!formData.tipo || !formData.ano || !formData.numero) { if (!formData.tipo || !formData.ano || !formData.numero) {
$(".ementa_materia").html('').addClass('hidden'); $(".ementa_materia").html('').addClass('hidden');
return; return;
} }
var url = '{% url "sapl.api:materialegislativa-list" %}'; var url = '{% url "sapl.api:materialegislativa-list" %}';
$.get(url, formData).done(function(data) { $.get(url, formData).done(function(data) {
if (data.pagination.total_entries === 1) { if (data.pagination.total_entries === 1) {
$(".ementa_materia") $(".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]") $("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]")
.on('change keyup', function() { .on('change keyup', function() { buscarMateria(); });
buscarMateria();
});
// ===================================================== // =====================================================
// Inicialização // Inicialização
// ===================================================== // =====================================================
// Disparar eventos iniciais
$("select[name=tipo]").trigger('change'); $("select[name=tipo]").trigger('change');
buscarMateria(); buscarMateria();
// Garantir que o onlyoffice-info comece escondido
$("#onlyoffice-info").hide(); $("#onlyoffice-info").hide();
// Atualizar display inicial
setTimeout(updateTipoTextoDisplay, 100); 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 %}"> <a href="?status=elaboracao" class="{% if status_filter == 'elaboracao' %}active{% endif %}">
<strong>{{ stats.elaboracao|default:"0" }}</strong> em elaboração <strong>{{ stats.elaboracao|default:"0" }}</strong> em elaboração
</a> </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 %}"> <a href="?status=aguardando" class="{% if status_filter == 'aguardando' %}active{% endif %}">
<strong>{{ stats.aguardando|default:"0" }}</strong> enviadas <strong>{{ stats.aguardando|default:"0" }}</strong> enviadas
</a> </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%} check_permission: materia.can_access_impressos {% comment %} FIXME transformar para checagens de menu_[funcionalidade]{% endcomment%}
- title: {% trans 'Matérias Legislativas' %} - title: {% trans 'Matérias Legislativas' %}
url: sapl.materia:pesquisar_materia url: sapl.materia:pesquisar_materia
- title: {% trans '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' %} - title: {% trans 'Pautas das Sessões' %}
url: sapl.sessao:pauta_sessao url: sapl.sessao:pauta_sessao
- title: {% trans 'Proposições' %} - title: {% trans 'Proposições' %}

Loading…
Cancel
Save