Browse Source

Feat(Assinatura Digital): Como cliente, quero poder assinar vários documentos em lote [AB#1321]

pull/3858/head
KemuelAlves 4 months ago
parent
commit
749b9ef808
  1. 48
      docker/docker-compose-dev.yml
  2. 127
      docs/LOCALHOST_SETUP.md
  3. 1
      requirements/dev-requirements.txt
  4. 52
      sapl/context_processors.py
  5. 199
      sapl/materia/management/commands/notificar_pendentes_assinatura.py
  6. 4
      sapl/materia/urls.py
  7. 18
      sapl/materia/views.py
  8. 255
      sapl/materia/views_assinatura.py
  9. 5
      sapl/settings.py
  10. 13
      sapl/templates/base.html
  11. 77
      sapl/templates/email/pendentes_assinatura.html
  12. 17
      sapl/templates/email/pendentes_assinatura.txt
  13. 345
      sapl/templates/materia/materialegislativa_filter.html

48
docker/docker-compose-dev.yml

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

127
docs/LOCALHOST_SETUP.md

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

1
requirements/dev-requirements.txt

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

52
sapl/context_processors.py

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

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

4
sapl/materia/urls.py

@ -52,7 +52,7 @@ from sapl.materia.onlyoffice_materia_views import (
from sapl.materia.views_assinatura import (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura,
detectar_aplicacao_a3,
detectar_aplicacao_a3, materia_assinar_lote,
docacessorio_assinar_a1, docacessorio_pdf_assinado,
docacessorio_verificar_assinatura, docacessorio_remover_assinatura,
materia_verificar_documento, docacessorio_verificar_documento
@ -187,6 +187,8 @@ urlpatterns_materia = [
name='materia_assinar_a3_preparar'),
url(r'^materia/(?P<pk>\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar,
name='materia_assinar_a3_finalizar'),
url(r'^materia/assinar-em-lote/$', materia_assinar_lote,
name='materia_assinar_lote'),
url(r'^materia/(?P<pk>\d+)/pdf-assinado/$', materia_pdf_assinado,
name='materia_pdf_assinado'),
url(r'^materia/(?P<pk>\d+)/verificar-assinatura/$', materia_verificar_assinatura,

18
sapl/materia/views.py

@ -2789,6 +2789,24 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
context['show_results'] = show_results_filter_set(qr)
# Matérias pendentes de assinatura para o botão de lote
status_assinatura = self.request.GET.get('status_assinatura')
if status_assinatura == 'pendente' and context['show_results']:
from django.db.models import Q as _Q
qs_lote = self.object_list.filter(
texto_original__isnull=False,
).filter(
_Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='')
).exclude(texto_original='').select_related('tipo').values_list(
'id', 'tipo__sigla', 'numero', 'ano'
)[:200]
context['materias_pendentes_lote'] = [
{'id': pk, 'descricao': f'{sigla} {numero}/{ano}'}
for pk, sigla, numero, ano in qs_lote
]
else:
context['materias_pendentes_lote'] = []
return context

255
sapl/materia/views_assinatura.py

@ -21,7 +21,7 @@ from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from sapl.base.models import AppConfig
from sapl.base.models import AppConfig, OperadorAutor
from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa
from sapl.utils import build_onlyoffice_url
@ -900,6 +900,10 @@ def materia_assinar_a1(request, pk):
materia.assinado_por = request.user
materia.save()
# Invalida cache de pendências para atualizar sino na navbar
from django.core.cache import cache as _cache
_cache.delete(f'pendencias_assinatura_user_{request.user.pk}')
logger.info(f"Matéria {materia.pk} assinada por {request.user.username}")
return JsonResponse({
@ -1762,3 +1766,252 @@ def docacessorio_verificar_documento(request, pk):
}
return render(request, 'materia/verificar_assinatura.html', context)
# =============================================================================
# Assinatura em Lote de Matérias Legislativas
# =============================================================================
@login_required
@csrf_exempt
@require_http_methods(["POST"])
def materia_assinar_lote(request):
"""
Assina em lote matérias pendentes com certificado A1.
POST multipart:
- certificado: arquivo .pfx / .p12
- senha: senha do certificado
- ids: JSON array com os PKs das matérias ex: "[1,2,3]"
OU múltiplos campos ids[] (form-data)
Retorna JSON com resultado por matéria:
{
"total": 3, "sucesso": 2, "erros": 1,
"resultados": [
{"pk": 1, "success": true, "descricao": "PL 1/2025"},
{"pk": 2, "success": false, "descricao": "PL 2/2025", "error": "..."}
]
}
"""
# Permite acesso se o usuário tem permissão Django OU é OperadorAutor de algum autor
_tem_perm_django = request.user.has_perm('materia.change_materialegislativa')
try:
_autor_lote = OperadorAutor.objects.get(user=request.user).autor
except OperadorAutor.DoesNotExist:
_autor_lote = None
if not (_tem_perm_django or _autor_lote):
return JsonResponse(
{'success': False, 'error': 'Sem permissão para assinar matérias.'},
status=403
)
# ── IDs das matérias ─────────────────────────────────────────────────────
ids_raw = request.POST.get('ids', '')
ids_multi = request.POST.getlist('ids[]')
if ids_multi:
pks = [int(i) for i in ids_multi if str(i).isdigit()]
elif ids_raw:
try:
parsed = json.loads(ids_raw)
pks = [int(i) for i in parsed if str(i).isdigit() or isinstance(i, int)]
except (json.JSONDecodeError, ValueError):
return JsonResponse(
{'success': False, 'error': 'Parâmetro "ids" inválido. Envie um array JSON.'},
status=400
)
else:
return JsonResponse({'success': False, 'error': 'Nenhuma matéria selecionada.'}, status=400)
if not pks:
return JsonResponse({'success': False, 'error': 'Lista de IDs vazia.'}, status=400)
if len(pks) > 200:
return JsonResponse(
{'success': False, 'error': 'Limite máximo de 200 matérias por lote.'},
status=400
)
# ── Certificado ──────────────────────────────────────────────────────────
certificado_file = request.FILES.get('certificado')
senha = request.POST.get('senha', '')
if not certificado_file:
return JsonResponse({'success': False, 'error': 'Certificado não informado.'}, status=400)
if not senha:
return JsonResponse({'success': False, 'error': 'Senha do certificado não informada.'}, status=400)
cert_bytes = certificado_file.read()
import tempfile as tmp_module
with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert:
tmp_cert.write(cert_bytes)
tmp_cert_path = tmp_cert.name
try:
from pyhanko.sign import signers
signer = signers.SimpleSigner.load_pkcs12(
pfx_file=tmp_cert_path,
passphrase=senha.encode('utf-8')
)
except Exception as cert_error:
logger.error(f"[lote] Erro ao carregar certificado: {cert_error}")
err_msg = str(cert_error)
if 'password' in err_msg.lower() or 'mac' in err_msg.lower():
detail = 'Senha incorreta ou arquivo inválido.'
elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower():
detail = 'Arquivo não é um certificado válido (.pfx/.p12).'
else:
detail = f'Detalhes: {err_msg}'
return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400)
finally:
if os.path.exists(tmp_cert_path):
os.unlink(tmp_cert_path)
cert_info = signer.signing_cert
error_response = _validar_certificado(cert_info)
if error_response:
data = json.loads(error_response.content)
return JsonResponse({'success': False, 'error': data.get('error', 'Certificado inválido.')}, status=400)
# ── Assinatura por matéria ────────────────────────────────────────────────
materias = MateriaLegislativa.objects.filter(pk__in=pks)
materias_map = {m.pk: m for m in materias}
resultados = []
sucesso_count = 0
erro_count = 0
for pk in pks:
materia = materias_map.get(pk)
if not materia:
resultados.append({'pk': pk, 'success': False, 'descricao': f'ID {pk}', 'error': 'Matéria não encontrada.'})
erro_count += 1
continue
descricao = f'{materia.tipo.sigla} {materia.numero}/{materia.ano}'
if materia.pdf_assinado:
resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Já possui PDF assinado. Ignorada.'})
erro_count += 1
continue
assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info)
if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes):
resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Você já assinou esta matéria.'})
erro_count += 1
continue
pdf_bytes, error = _gerar_pdf_da_materia(materia, request)
if error:
resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': error})
erro_count += 1
continue
temp_stamped_path = None
try:
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from PyPDF4 import PdfFileReader, PdfFileWriter as PyPDF4Writer
nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info)
data_assinatura = timezone.localtime(timezone.now())
data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S')
data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M')
codigo = _gerar_codigo_autenticacao(pdf_bytes)
url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo)
nova_assinatura_info = {
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_simples,
}
original_pdf = PdfFileReader(io.BytesIO(pdf_bytes))
last_page = original_pdf.getPage(original_pdf.getNumPages() - 1)
page_box = last_page.mediaBox
page_width = float(page_box.getWidth())
page_height = float(page_box.getHeight())
auth_page_bytes = _gerar_pagina_autenticacao(
[nova_assinatura_info], codigo, url_verificacao,
page_width, page_height
)
auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes))
output_pdf = PyPDF4Writer()
for page_num in range(original_pdf.getNumPages()):
output_pdf.addPage(original_pdf.getPage(page_num))
output_pdf.addPage(auth_page_pdf.getPage(0))
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f:
temp_stamped_path = tmp_f.name
output_pdf.write(tmp_f)
with open(temp_stamped_path, 'rb') as f:
stamped_bytes = f.read()
signed_buffer = io.BytesIO()
with io.BytesIO(stamped_bytes) as inf:
w = IncrementalPdfFileWriter(inf)
meta = signers.PdfSignatureMetadata(
field_name='AssinaturaDigital',
location='Câmara Municipal',
reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001',
name=nome_assinante
)
signers.sign_pdf(w, meta, signer=signer, output=signed_buffer)
signed_buffer.seek(0)
signed_pdf_content = signed_buffer.read()
filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf"
materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False)
materia.codigo_autenticacao = codigo
nova_assinatura_record = {
'tipo_certificado': 'A1',
'tipo_certificado_display': f'{tipo_cert} – A1',
'subject': str(cert_info.subject),
'issuer': str(cert_info.issuer),
'serial': str(cert_info.serial_number),
'valid_from': cert_info.not_valid_before.isoformat(),
'valid_to': cert_info.not_valid_after.isoformat(),
'signed_by': request.user.username,
'nome_assinante': nome_assinante,
'cargo': cargo,
'data_assinatura': data_formatada,
'validade_juridica': 'Assinatura Eletrônica Qualificada'
}
assinaturas_existentes.append(nova_assinatura_record)
materia.assinatura_info = assinaturas_existentes
materia.assinado_em = timezone.now()
materia.assinado_por = request.user
materia.save()
logger.info(f"[lote] Matéria {pk} assinada por {request.user.username}")
resultados.append({'pk': pk, 'success': True, 'descricao': descricao})
sucesso_count += 1
except Exception as e:
logger.error(f"[lote] Erro ao assinar matéria {pk}: {e}")
resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)})
erro_count += 1
finally:
if temp_stamped_path and os.path.exists(temp_stamped_path):
os.unlink(temp_stamped_path)
# Invalida cache de pendências uma vez ao final do lote
if sucesso_count > 0:
from django.core.cache import cache as _cache
_cache.delete(f'pendencias_assinatura_user_{request.user.pk}')
return JsonResponse({
'success': True,
'total': len(pks),
'sucesso': sucesso_count,
'erros': erro_count,
'resultados': resultados,
})

5
sapl/settings.py

@ -34,8 +34,8 @@ PROJECT_DIR = Path(__file__).ancestor(2)
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234')
# SECURITY WARNING: don't run with debug turned on in production!
#DEBUG = config('DEBUG', default=False, cast=bool)
DEBUG = config('DJANGO_DEBUG', default=False, cast=bool)
# Aceita tanto DEBUG quanto DJANGO_DEBUG (compatibilidade com docker-compose legado)
DEBUG = config('DEBUG', default=False, cast=bool) or config('DJANGO_DEBUG', default=False, cast=bool)
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
@ -234,6 +234,7 @@ TEMPLATES = [
'sapl.context_processors.mail_service_configured',
'sapl.context_processors.google_recaptcha_configured',
'sapl.context_processors.enable_sapn',
'sapl.context_processors.pendencias_assinatura',
],
'debug': DEBUG
},

13
sapl/templates/base.html

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

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.

345
sapl/templates/materia/materialegislativa_filter.html

@ -33,6 +33,17 @@
<option value="assinada" {% if status_atual == 'assinada' %}selected{% endif %}>✔ Assinada</option>
</select>
{% endwith %}
{% if materias_pendentes_lote %}
<button type="button"
id="btn-assinar-em-lote"
class="btn btn-outline-warning ml-2"
style="min-width:200px"
title="Assinar digitalmente as {{ materias_pendentes_lote|length }} matéria(s) pendentes desta pesquisa">
<i class="fa fa-certificate"></i>
Assinar em Lote
<span class="badge badge-warning text-dark ml-1" id="badge-lote-total">{{ materias_pendentes_lote|length }}</span>
</button>
{% endif %}
</div>
{# ─────────────────────────────────────────────────────────────────── #}
@ -287,6 +298,340 @@
{% block extra_js %}
<script src="{% static 'js/materia_pesquisa_download_pdfs.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 2: progresso ── #}
<div id="lote-progresso-step" style="display:none;">
<h6 class="mb-3">
<i class="fa fa-spinner fa-spin text-primary"></i>
Processando assinaturas…
</h6>
<div class="progress mb-3" style="height:22px;">
<div id="lote-progress-bar"
class="progress-bar progress-bar-striped progress-bar-animated bg-warning"
role="progressbar" style="width:0%">0%</div>
</div>
<p class="text-muted small" id="lote-status-texto">Iniciando…</p>
<div id="lote-resultados-parciais" class="mt-2" style="max-height:220px;overflow-y:auto;"></div>
</div>
{# ── Passo 3: resumo final ── #}
<div id="lote-resumo-step" style="display:none;">
<div id="lote-resumo-alerta"></div>
<div id="lote-resumo-lista" style="max-height:300px;overflow-y:auto;"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="lote-btn-fechar">
<i class="fa fa-times"></i> Fechar
</button>
{# Passo 0 → 1 #}
<button type="button" class="btn btn-warning" id="lote-btn-proximo">
<i class="fa fa-arrow-right"></i>
Continuar — <span id="lote-btn-proximo-contagem">{{ materias_pendentes_lote|length }}</span> Matéria(s)
</button>
{# Passo 1: voltar / assinar #}
<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-warning" id="lote-btn-assinar" style="display:none;">
<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-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-assinar').style.display = 'none';
document.getElementById('lote-btn-recarregar').style.display = 'none';
document.getElementById('lote-btn-fechar').disabled = false;
}
// 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;
}
document.getElementById('lote-selecao-step').style.display = 'none';
document.getElementById('lote-form-step').style.display = 'block';
document.getElementById('lote-btn-proximo').style.display = 'none';
document.getElementById('lote-btn-voltar').style.display = 'inline-block';
document.getElementById('lote-btn-assinar').style.display = 'inline-block';
});
// Passo 1 → 0: Voltar
document.getElementById('lote-btn-voltar').addEventListener('click', irParaSelecao);
// Botão Assinar (passo 1 → 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-form-step').style.display = 'none';
document.getElementById('lote-progresso-step').style.display = 'block';
document.getElementById('lote-btn-voltar').style.display = 'none';
document.getElementById('lote-btn-assinar').style.display = 'none';
document.getElementById('lote-btn-fechar').disabled = true;
setProgresso(5, 'Enviando certificado e iniciando assinatura…');
fetch(URL_LOTE, {
method: 'POST',
body: fd,
headers: { 'X-CSRFToken': getCSRF() }
})
.then(function (r) {
setProgresso(90, '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) {
setProgresso(100, 'Concluído.');
document.getElementById('lote-btn-fechar').disabled = false;
mostrarResumo(data);
})
.catch(function (err) {
document.getElementById('lote-btn-fechar').disabled = false;
setProgresso(100, 'Erro.');
mostrarErroFatal(err.message || String(err));
});
});
function setProgresso(pct, texto) {
var bar = document.getElementById('lote-progress-bar');
bar.style.width = pct + '%';
bar.textContent = pct + '%';
document.getElementById('lote-status-texto').textContent = texto;
}
function mostrarResumo(data) {
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'block';
document.getElementById('lote-btn-recarregar').style.display = 'inline-block';
var temErro = data.erros > 0;
var alertaCls = data.sucesso > 0 ? (temErro ? 'alert-warning' : 'alert-success') : 'alert-danger';
var icone = data.sucesso > 0 ? (temErro ? 'exclamation-triangle' : 'check-circle') : 'times-circle';
document.getElementById('lote-resumo-alerta').innerHTML =
'<div class="alert ' + alertaCls + '">' +
'<i class="fa fa-' + icone + '"></i> ' +
'<strong>' + data.sucesso + ' assinada(s)</strong> com sucesso' +
(temErro ? ', <strong>' + data.erros + '</strong> com erro(s).' : '.') +
' Total: ' + data.total + ' matéria(s).' +
'</div>';
var html = '<ul class="list-group">';
(data.resultados || []).forEach(function (r) {
var cls = r.success ? 'list-group-item-success' : 'list-group-item-danger';
var icon = r.success ? 'check text-success' : 'times text-danger';
html += '<li class="list-group-item list-group-item-sm ' + cls + '">' +
'<i class="fa fa-' + icon + ' mr-1"></i>' +
'<strong>' + escHtml(r.descricao) + '</strong>' +
(r.error ? ' — <small class="text-muted">' + escHtml(r.error) + '</small>' : '') +
'</li>';
});
html += '</ul>';
document.getElementById('lote-resumo-lista').innerHTML = html;
}
function mostrarErroFatal(msg) {
document.getElementById('lote-progresso-step').style.display = 'none';
document.getElementById('lote-resumo-step').style.display = 'block';
document.getElementById('lote-resumo-alerta').innerHTML =
'<div class="alert alert-danger"><i class="fa fa-times-circle"></i> ' +
'<strong>Erro:</strong> ' + escHtml(msg) + '</div>';
document.getElementById('lote-resumo-lista').innerHTML = '';
}
function escHtml(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
</script>
{% endif %}
<script type="text/javascript">
$( document ).ready(function() {
$('.link_votacao_nominal').on('click', function(event) {

Loading…
Cancel
Save