mirror of https://github.com/interlegis/sapl.git
committed by
GitHub
33 changed files with 4797 additions and 557 deletions
@ -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 |
|||
``` |
|||
|
|||
@ -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?'), |
|||
), |
|||
] |
|||
@ -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}' |
|||
@ -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 |
|||
) |
|||
)) |
|||
@ -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')}, |
|||
}, |
|||
), |
|||
] |
|||
@ -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'}, |
|||
), |
|||
] |
|||
File diff suppressed because it is too large
@ -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>×</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, '&').replace(/</g, '<') |
|||
.replace(/>/g, '>').replace(/"/g, '"'); |
|||
} |
|||
|
|||
// ── Arranque ──────────────────────────────────────────────────────────────
|
|||
if (document.readyState === 'loading') { |
|||
document.addEventListener('DOMContentLoaded', init); |
|||
} else { |
|||
init(); |
|||
} |
|||
|
|||
})(); |
|||
@ -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"> |
|||
🔔 {{ 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 → |
|||
</a> |
|||
</div> |
|||
|
|||
<div class="footer"> |
|||
<p>Esta é uma mensagem automática. Por favor, não a responda.</p> |
|||
<p>© {{ casa_legislativa }}</p> |
|||
</div> |
|||
</div> |
|||
</body> |
|||
</html> |
|||
@ -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. |
|||
@ -0,0 +1,119 @@ |
|||
{% extends "crud/detail.html" %} |
|||
{% load i18n %} |
|||
{% load static %} |
|||
|
|||
{% block actions %} |
|||
<div class="actions btn-group float-right pb-4" role="group"> |
|||
<a href="{{ url_pesquisa_completa }}" class="btn btn-outline-secondary"> |
|||
<i class="fas fa-search"></i> {% trans "Ver na Pesquisa Completa" %} |
|||
</a> |
|||
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn btn-outline-primary"> |
|||
<i class="fas fa-list"></i> {% trans "Pesquisar Matérias" %} |
|||
</a> |
|||
</div> |
|||
{% endblock %} |
|||
|
|||
{% block detail_content %} |
|||
|
|||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap" style="gap:.75rem;"> |
|||
<div> |
|||
<h4 class="mb-0"> |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:1rem;"> |
|||
<i class="fas fa-clock mr-1"></i> Matérias Pendentes de Assinatura |
|||
</span> |
|||
</h4> |
|||
{% if autor %} |
|||
<small class="text-muted mt-1 d-block"> |
|||
<i class="fas fa-user mr-1"></i> Autor: <strong>{{ autor }}</strong> |
|||
</small> |
|||
{% endif %} |
|||
</div> |
|||
<div> |
|||
{% if total == 0 %} |
|||
<span class="badge badge-success px-3 py-2" style="font-size:.95rem;"> |
|||
<i class="fas fa-check-circle mr-1"></i> Nenhuma pendência |
|||
</span> |
|||
{% elif total == 1 %} |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;"> |
|||
1 matéria pendente |
|||
</span> |
|||
{% else %} |
|||
<span class="badge badge-warning text-dark px-3 py-2" style="font-size:.95rem;"> |
|||
{{ total }} matérias pendentes |
|||
</span> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
|
|||
{% if not object_list %} |
|||
<div class="alert alert-success d-flex align-items-center" role="alert"> |
|||
<i class="fas fa-check-circle fa-2x mr-3"></i> |
|||
<div> |
|||
<strong>Tudo em dia!</strong><br> |
|||
Não há matérias com documento pendente de assinatura digital. |
|||
</div> |
|||
</div> |
|||
{% else %} |
|||
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert" |
|||
style="border-left: 5px solid #f0ad4e;"> |
|||
<i class="fas fa-exclamation-triangle fa-lg mr-2"></i> |
|||
As matérias abaixo possuem <strong> Texto Original </strong> mas ainda |
|||
<strong> 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 %} |
|||
@ -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">×</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> |
|||
Loading…
Reference in new issue