mirror of https://github.com/interlegis/sapl.git
13 changed files with 1117 additions and 44 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,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,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. |
|||
Loading…
Reference in new issue