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 |
```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,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