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 |
```bash |
||||
cd /root/dev/sapl |
cd /root/dev/sapl |
||||
python3 -m venv .venv |
docker compose -f docker/docker-compose-local.yml up --build |
||||
source .venv/bin/activate |
``` |
||||
pip install -r requirements/requirements.txt |
|
||||
|
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 |
```env |
||||
DATABASE_URL=postgresql://kemuel:kasepulvida@sgvp.com.br:5432/sapl |
DATABASE_URL=postgresql://usuario:senha@host:5432/banco |
||||
SECRET_KEY=<sua-chave-secreta> |
SECRET_KEY=<sua-chave-secreta> |
||||
DEBUG=True |
DEBUG=True |
||||
EMAIL_USE_TLS=True |
EMAIL_USE_TLS=True |
||||
EMAIL_PORT=587 |
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 |
```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 |
```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