diff --git a/docker/docker-compose-dev.yml b/docker/docker-compose-dev.yml index 027cd1cab..8f8eeb1bf 100644 --- a/docker/docker-compose-dev.yml +++ b/docker/docker-compose-dev.yml @@ -1,3 +1,11 @@ +# Ambiente de desenvolvimento: código montado como volume (hot-reload), +# banco controlado pelo DATABASE_URL definido em sapl/.env. +# +# Uso: +# docker compose -f docker/docker-compose-dev.yml --env-file sapl/.env up --build +# +# Troque o banco a qualquer momento editando apenas o DATABASE_URL no sapl/.env + version: '3.7' services: @@ -12,30 +20,22 @@ services: - ..:/sapl-dev ports: - "8000:8000" + env_file: + - ../sapl/.env # Lê DATABASE_URL, SECRET_KEY, DEBUG, etc. environment: - SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^' - DJANGO_DEBUG: 'True' - DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl + # Garante que DEBUG do .env seja reconhecido pelo settings.py + DJANGO_DEBUG: '${DEBUG:-True}' TZ: America/Sao_Paulo - ONLYOFFICE_URL: 'http://onlyoffice:80' - depends_on: - - onlyoffice - - onlyoffice: - container_name: onlyoffice-documentserver - image: onlyoffice/documentserver:latest - ports: - - "8001:80" - environment: - - JWT_ENABLED=false - - JWT_SECRET=your-secret-key-change-this - volumes: - - onlyoffice_data:/var/www/onlyoffice/Data - - onlyoffice_log:/var/log/onlyoffice - - onlyoffice_fonts:/usr/share/fonts/truetype/custom - restart: unless-stopped + extra_hosts: + # Permite alcançar bancos externos (ex: legisinc.com.br, sgvp.com.br) + - "host.docker.internal:host-gateway" -volumes: - onlyoffice_data: - onlyoffice_log: - onlyoffice_fonts: + # Descomente para usar OnlyOffice localmente + # onlyoffice: + # container_name: onlyoffice-documentserver + # image: onlyoffice/documentserver:latest + # ports: + # - "8001:80" + # environment: + # - JWT_ENABLED=false + # restart: unless-stopped diff --git a/docs/LOCALHOST_SETUP.md b/docs/LOCALHOST_SETUP.md index 55f9b54b7..ec109057b 100644 --- a/docs/LOCALHOST_SETUP.md +++ b/docs/LOCALHOST_SETUP.md @@ -1,40 +1,137 @@ -# Subir o SAPL apontando para o banco remoto (localhost) +# Subir o SAPL em localhost -Passo a passo para subir o ambiente local usando o Postgres já disponível em `sgvp.com.br:5432`. +A abordagem recomendada usa **Docker**, pois replica fielmente o ambiente de produção. -## 1) Preparar o ambiente Python +Há dois modos de operação: + +| Modo | Quando usar | +|---|---| +| **Localhost completo** (banco local) | Desenvolvimento do zero, sem dependência de banco remoto | +| **Banco remoto** | Testar com dados reais de produção/homologação | + +--- + +## Modo 1: Localhost completo (banco PostgreSQL local) + +Sobe a aplicação **e** o banco juntos, sem precisar de `.env` nem banco externo. ```bash cd /root/dev/sapl -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements/requirements.txt +docker compose -f docker/docker-compose-local.yml up --build +``` + +Na primeira vez, rode as migrations dentro do container: + +```bash +docker exec -it sapl-dev python manage.py migrate +docker exec -it sapl-dev python manage.py createsuperuser +``` + +Servidor disponível em: **http://localhost:8000** +Banco disponível em: `localhost:5432` (usuário: `sapl` / senha: `sapl` / banco: `sapl`) + +Para parar: + +```bash +docker compose -f docker/docker-compose-local.yml down ``` -## 2) Configurar variáveis de ambiente +> Os dados do banco ficam no volume Docker `sapl-pgdata` e persistem entre reinicializações. +> Para apagar tudo: `docker compose -f docker/docker-compose-local.yml down -v` + +--- + +## Modo 2: Banco remoto (apontando para banco externo) + +O banco é controlado pelo `sapl/.env`. Basta trocar o `DATABASE_URL` e reiniciar o container. + +--- + +## 1) Configurar o `sapl/.env` -Edite `sapl/.env` com os valores do banco remoto: +Edite `sapl/.env` com as credenciais do banco desejado: ```env -DATABASE_URL=postgresql://kemuel:kasepulvida@sgvp.com.br:5432/sapl +DATABASE_URL=postgresql://usuario:senha@host:5432/banco SECRET_KEY= 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 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 +``` diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt index b7b662d5e..983776189 100644 --- a/requirements/dev-requirements.txt +++ b/requirements/dev-requirements.txt @@ -2,7 +2,6 @@ autopep8==2.3.2 beautifulsoup4==4.13.5 -django-debug-toolbar==3.2.4 ipdb==0.13.13 fancycompleter==0.11.1 pdbpp==0.11.7 diff --git a/sapl/context_processors.py b/sapl/context_processors.py index 590ab5eac..820589d30 100644 --- a/sapl/context_processors.py +++ b/sapl/context_processors.py @@ -32,6 +32,58 @@ def google_recaptcha_configured(request): return {'google_recaptcha_configured': True} +def pendencias_assinatura(request): + """Injeta contagem e URL de matérias pendentes de assinatura para o usuário logado.""" + if not request.user.is_authenticated: + return {'pendencias_assinatura_total': 0, 'pendencias_assinatura_url': ''} + + from django.core.cache import cache + cache_key = f'pendencias_assinatura_user_{request.user.pk}' + cached = cache.get(cache_key) + + if cached is None: + try: + from django.db.models import Q + from django.urls import reverse + from sapl.materia.models import MateriaLegislativa + from sapl.base.models import OperadorAutor + + # Busca o Autor vinculado ao usuário + try: + autor = OperadorAutor.objects.get(user=request.user).autor + autor_pk = autor.pk + except OperadorAutor.DoesNotExist: + autor_pk = None + + if autor_pk: + total = MateriaLegislativa.objects.filter( + autoria__autor_id=autor_pk, + texto_original__isnull=False, + ).exclude( + texto_original='' + ).filter( + Q(pdf_assinado__isnull=True) | Q(pdf_assinado='') + ).distinct().count() + url = ( + reverse('sapl.materia:pesquisar_materia') + + f'?autoria__autor={autor_pk}&status_assinatura=pendente' + ) + else: + total = 0 + url = '' + except Exception: + total = 0 + url = '' + + cached = {'total': total, 'url': url} + cache.set(cache_key, cached, 120) # cache de 2 minutos + + return { + 'pendencias_assinatura_total': cached['total'], + 'pendencias_assinatura_url': cached['url'], + } + + @cached_call("site-title", timeout=60 * 2) def enable_sapn(request): verbose_name = _('SGVP') \ diff --git a/sapl/materia/management/commands/notificar_pendentes_assinatura.py b/sapl/materia/management/commands/notificar_pendentes_assinatura.py new file mode 100644 index 000000000..a95006526 --- /dev/null +++ b/sapl/materia/management/commands/notificar_pendentes_assinatura.py @@ -0,0 +1,199 @@ +""" +Management command: notificar_pendentes_assinatura + +Envia e-mail diario a cada autor que possui materias com assinatura digital +pendente (texto_original preenchido, pdf_assinado vazio). + +Uso: + python manage.py notificar_pendentes_assinatura + +Agendar (crontab) -- exemplo para rodar todo dia as 8h: + 0 8 * * * cd /app && python manage.py notificar_pendentes_assinatura >> /var/log/sapl_notif_assinatura.log 2>&1 + +Opcoes: + --dry-run Lista os destinatarios e contagens sem enviar e-mails. + --max-materias Numero maximo de materias listadas por e-mail (padrao: 20). +""" +import logging + +from django.core.management.base import BaseCommand + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_MATERIAS = 20 + + +class Command(BaseCommand): + help = 'Envia e-mail diario aos autores com materias pendentes de assinatura digital' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + default=False, + help='Apenas lista os destinatarios sem enviar e-mails.', + ) + parser.add_argument( + '--max-materias', + type=int, + default=DEFAULT_MAX_MATERIAS, + help='Maximo de materias listadas por e-mail (padrao: 20).', + ) + + def handle(self, *args, **options): + # Imports aqui dentro para evitar problemas no bootstrap do Django + from django.core.mail import EmailMultiAlternatives, get_connection + from django.db.models import Q + from django.template import loader + from django.urls import reverse + + from sapl.base.models import CasaLegislativa, OperadorAutor + from sapl.materia.models import MateriaLegislativa + from sapl.settings import EMAIL_SEND_USER + from sapl.utils import mail_service_configured + + dry_run = options['dry_run'] + max_mat = options['max_materias'] + + if not dry_run and not mail_service_configured(): + self.stderr.write(self.style.ERROR( + 'Servico de e-mail nao configurado. ' + 'Verifique EMAIL_HOST no arquivo .env.' + )) + return + + casa = CasaLegislativa.objects.first() + if not casa: + self.stderr.write(self.style.ERROR('Casa Legislativa nao configurada.')) + return + + casa_nome = '{} de {} - {}'.format(casa.nome, casa.municipio, casa.uf) + base_url = 'https://{}'.format(casa.endereco_web) if getattr(casa, 'endereco_web', None) else '' + + # Base queryset: materias pendentes de assinatura + qs_pendentes = MateriaLegislativa.objects.filter( + texto_original__isnull=False, + ).exclude( + texto_original='' + ).filter( + Q(pdf_assinado__isnull=True) | Q(pdf_assinado='') + ).select_related('tipo').order_by('-data_apresentacao', '-id') + + # Apenas OperadorAutores com e-mail cadastrado + operadores = ( + OperadorAutor.objects + .select_related('autor', 'user') + .filter(user__email__gt='') + ) + + if not operadores.exists(): + self.stdout.write(self.style.WARNING( + 'Nenhum OperadorAutor com e-mail encontrado. Nada a enviar.' + )) + return + + url_pesquisa_base = reverse('sapl.materia:pesquisar_materia') + + enviados = 0 + sem_pendencias = 0 + + connection = None if dry_run else get_connection() + if connection: + connection.open() + + try: + for op in operadores: + autor = op.autor + user = op.user + email = (user.email or '').strip() + + if not email: + continue + + # Materias pendentes deste autor + materias_qs = qs_pendentes.filter(autoria__autor=autor).distinct() + total = materias_qs.count() + + if total == 0: + sem_pendencias += 1 + continue + + materias_listadas = list(materias_qs[:max_mat]) + total_omitidas = max(0, total - max_mat) + + url_pesquisa = ( + '{}?autoria__autor={}&status_assinatura=pendente'.format( + url_pesquisa_base, autor.pk + ) + ) + + context = { + 'casa_legislativa': casa_nome, + 'nome_autor': autor.nome, + 'total': total, + 'materias': materias_listadas, + 'total_omitidas': total_omitidas, + 'base_url': base_url, + 'url_pesquisa': url_pesquisa, + } + + subject = '[SGVP] {} materia(s) aguardando sua assinatura digital'.format(total) + + if dry_run: + self.stdout.write(self.style.SUCCESS( + '[DRY-RUN] -> {} ({}): {} pendente(s)'.format(email, autor.nome, total) + )) + for m in materias_listadas: + self.stdout.write( + ' * {} {}/{} -- {}'.format( + m.tipo.sigla, m.numero, m.ano, m.ementa[:60] + ) + ) + if total_omitidas: + self.stdout.write(' ... e mais {} outra(s).'.format(total_omitidas)) + continue + + # Renderiza templates + txt_body = loader.get_template('email/pendentes_assinatura.txt').render(context) + html_body = loader.get_template('email/pendentes_assinatura.html').render(context) + + try: + msg = EmailMultiAlternatives( + subject=subject, + body=txt_body, + from_email=EMAIL_SEND_USER, + to=[email], + connection=connection, + ) + msg.attach_alternative(html_body, 'text/html') + msg.send() + enviados += 1 + logger.info( + '[notificar_pendentes_assinatura] E-mail enviado para ' + '{} ({}) -- {} pendente(s).'.format(email, autor.nome, total) + ) + self.stdout.write(self.style.SUCCESS( + 'E-mail enviado para {} ({}) -- {} pendente(s).'.format( + email, autor.nome, total + ) + )) + except Exception as exc: + logger.error( + '[notificar_pendentes_assinatura] Falha ao enviar para ' + '{}: {}'.format(email, exc) + ) + self.stderr.write(self.style.ERROR( + 'Falha ao enviar para {}: {}'.format(email, exc) + )) + + finally: + if connection: + connection.close() + + if not dry_run: + self.stdout.write(self.style.SUCCESS( + '\nConcluido: {} e-mail(s) enviado(s). ' + '{} autor(es) sem pendencias (nao notificados).'.format( + enviados, sem_pendencias + ) + )) diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index c0939bc67..a3e67b8f2 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -52,7 +52,7 @@ from sapl.materia.onlyoffice_materia_views import ( from sapl.materia.views_assinatura import ( materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura, - detectar_aplicacao_a3, + detectar_aplicacao_a3, materia_assinar_lote, docacessorio_assinar_a1, docacessorio_pdf_assinado, docacessorio_verificar_assinatura, docacessorio_remover_assinatura, materia_verificar_documento, docacessorio_verificar_documento @@ -187,6 +187,8 @@ urlpatterns_materia = [ name='materia_assinar_a3_preparar'), url(r'^materia/(?P\d+)/assinar/a3/finalizar/$', materia_assinar_a3_finalizar, name='materia_assinar_a3_finalizar'), + url(r'^materia/assinar-em-lote/$', materia_assinar_lote, + name='materia_assinar_lote'), url(r'^materia/(?P\d+)/pdf-assinado/$', materia_pdf_assinado, name='materia_pdf_assinado'), url(r'^materia/(?P\d+)/verificar-assinatura/$', materia_verificar_assinatura, diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 1e888c5c9..e8007d655 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -2789,6 +2789,24 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): context['show_results'] = show_results_filter_set(qr) + # Matérias pendentes de assinatura para o botão de lote + status_assinatura = self.request.GET.get('status_assinatura') + if status_assinatura == 'pendente' and context['show_results']: + from django.db.models import Q as _Q + qs_lote = self.object_list.filter( + texto_original__isnull=False, + ).filter( + _Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='') + ).exclude(texto_original='').select_related('tipo').values_list( + 'id', 'tipo__sigla', 'numero', 'ano' + )[:200] + context['materias_pendentes_lote'] = [ + {'id': pk, 'descricao': f'{sigla} {numero}/{ano}'} + for pk, sigla, numero, ano in qs_lote + ] + else: + context['materias_pendentes_lote'] = [] + return context diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index 4ed6641ba..f626e88c8 100644 --- a/sapl/materia/views_assinatura.py +++ b/sapl/materia/views_assinatura.py @@ -21,7 +21,7 @@ from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from sapl.base.models import AppConfig +from sapl.base.models import AppConfig, OperadorAutor from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa from sapl.utils import build_onlyoffice_url @@ -900,6 +900,10 @@ def materia_assinar_a1(request, pk): materia.assinado_por = request.user materia.save() + # Invalida cache de pendências para atualizar sino na navbar + from django.core.cache import cache as _cache + _cache.delete(f'pendencias_assinatura_user_{request.user.pk}') + logger.info(f"Matéria {materia.pk} assinada por {request.user.username}") return JsonResponse({ @@ -1762,3 +1766,252 @@ def docacessorio_verificar_documento(request, pk): } return render(request, 'materia/verificar_assinatura.html', context) + + +# ============================================================================= +# Assinatura em Lote de Matérias Legislativas +# ============================================================================= + +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def materia_assinar_lote(request): + """ + Assina em lote matérias pendentes com certificado A1. + + POST multipart: + - certificado: arquivo .pfx / .p12 + - senha: senha do certificado + - ids: JSON array com os PKs das matérias ex: "[1,2,3]" + OU múltiplos campos ids[] (form-data) + + Retorna JSON com resultado por matéria: + { + "total": 3, "sucesso": 2, "erros": 1, + "resultados": [ + {"pk": 1, "success": true, "descricao": "PL 1/2025"}, + {"pk": 2, "success": false, "descricao": "PL 2/2025", "error": "..."} + ] + } + """ + # Permite acesso se o usuário tem permissão Django OU é OperadorAutor de algum autor + _tem_perm_django = request.user.has_perm('materia.change_materialegislativa') + try: + _autor_lote = OperadorAutor.objects.get(user=request.user).autor + except OperadorAutor.DoesNotExist: + _autor_lote = None + + if not (_tem_perm_django or _autor_lote): + return JsonResponse( + {'success': False, 'error': 'Sem permissão para assinar matérias.'}, + status=403 + ) + + # ── IDs das matérias ───────────────────────────────────────────────────── + ids_raw = request.POST.get('ids', '') + ids_multi = request.POST.getlist('ids[]') + + if ids_multi: + pks = [int(i) for i in ids_multi if str(i).isdigit()] + elif ids_raw: + try: + parsed = json.loads(ids_raw) + pks = [int(i) for i in parsed if str(i).isdigit() or isinstance(i, int)] + except (json.JSONDecodeError, ValueError): + return JsonResponse( + {'success': False, 'error': 'Parâmetro "ids" inválido. Envie um array JSON.'}, + status=400 + ) + else: + return JsonResponse({'success': False, 'error': 'Nenhuma matéria selecionada.'}, status=400) + + if not pks: + return JsonResponse({'success': False, 'error': 'Lista de IDs vazia.'}, status=400) + + if len(pks) > 200: + return JsonResponse( + {'success': False, 'error': 'Limite máximo de 200 matérias por lote.'}, + status=400 + ) + + # ── Certificado ────────────────────────────────────────────────────────── + certificado_file = request.FILES.get('certificado') + senha = request.POST.get('senha', '') + + if not certificado_file: + return JsonResponse({'success': False, 'error': 'Certificado não informado.'}, status=400) + if not senha: + return JsonResponse({'success': False, 'error': 'Senha do certificado não informada.'}, status=400) + + cert_bytes = certificado_file.read() + + import tempfile as tmp_module + with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: + tmp_cert.write(cert_bytes) + tmp_cert_path = tmp_cert.name + + try: + from pyhanko.sign import signers + signer = signers.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + except Exception as cert_error: + logger.error(f"[lote] Erro ao carregar certificado: {cert_error}") + err_msg = str(cert_error) + if 'password' in err_msg.lower() or 'mac' in err_msg.lower(): + detail = 'Senha incorreta ou arquivo inválido.' + elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): + detail = 'Arquivo não é um certificado válido (.pfx/.p12).' + else: + detail = f'Detalhes: {err_msg}' + return JsonResponse({'success': False, 'error': f'Erro ao carregar certificado: {detail}'}, status=400) + finally: + if os.path.exists(tmp_cert_path): + os.unlink(tmp_cert_path) + + cert_info = signer.signing_cert + error_response = _validar_certificado(cert_info) + if error_response: + data = json.loads(error_response.content) + return JsonResponse({'success': False, 'error': data.get('error', 'Certificado inválido.')}, status=400) + + # ── Assinatura por matéria ──────────────────────────────────────────────── + materias = MateriaLegislativa.objects.filter(pk__in=pks) + materias_map = {m.pk: m for m in materias} + + resultados = [] + sucesso_count = 0 + erro_count = 0 + + for pk in pks: + materia = materias_map.get(pk) + if not materia: + resultados.append({'pk': pk, 'success': False, 'descricao': f'ID {pk}', 'error': 'Matéria não encontrada.'}) + erro_count += 1 + continue + + descricao = f'{materia.tipo.sigla} {materia.numero}/{materia.ano}' + + if materia.pdf_assinado: + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Já possui PDF assinado. Ignorada.'}) + erro_count += 1 + continue + + assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info) + if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Você já assinou esta matéria.'}) + erro_count += 1 + continue + + pdf_bytes, error = _gerar_pdf_da_materia(materia, request) + if error: + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': error}) + erro_count += 1 + continue + + temp_stamped_path = None + try: + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + from PyPDF4 import PdfFileReader, PdfFileWriter as PyPDF4Writer + + nome_assinante, cargo, tipo_cert = _obter_info_assinante(request, cert_info) + data_assinatura = timezone.localtime(timezone.now()) + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo) + + nova_assinatura_info = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_box = last_page.mediaBox + page_width = float(page_box.getWidth()) + page_height = float(page_box.getHeight()) + + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info], codigo, url_verificacao, + page_width, page_height + ) + + auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PyPDF4Writer() + for page_num in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(page_num)) + output_pdf.addPage(auth_page_pdf.getPage(0)) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_f: + temp_stamped_path = tmp_f.name + output_pdf.write(tmp_f) + + with open(temp_stamped_path, 'rb') as f: + stamped_bytes = f.read() + + signed_buffer = io.BytesIO() + with io.BytesIO(stamped_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + meta = signers.PdfSignatureMetadata( + field_name='AssinaturaDigital', + location='Câmara Municipal', + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + name=nome_assinante + ) + signers.sign_pdf(w, meta, signer=signer, output=signed_buffer) + + signed_buffer.seek(0) + signed_pdf_content = signed_buffer.read() + + filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) + materia.codigo_autenticacao = codigo + + nova_assinatura_record = { + 'tipo_certificado': 'A1', + 'tipo_certificado_display': f'{tipo_cert} – A1', + 'subject': str(cert_info.subject), + 'issuer': str(cert_info.issuer), + 'serial': str(cert_info.serial_number), + 'valid_from': cert_info.not_valid_before.isoformat(), + 'valid_to': cert_info.not_valid_after.isoformat(), + 'signed_by': request.user.username, + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_formatada, + 'validade_juridica': 'Assinatura Eletrônica Qualificada' + } + assinaturas_existentes.append(nova_assinatura_record) + materia.assinatura_info = assinaturas_existentes + materia.assinado_em = timezone.now() + materia.assinado_por = request.user + materia.save() + + logger.info(f"[lote] Matéria {pk} assinada por {request.user.username}") + resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) + sucesso_count += 1 + + except Exception as e: + logger.error(f"[lote] Erro ao assinar matéria {pk}: {e}") + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) + erro_count += 1 + finally: + if temp_stamped_path and os.path.exists(temp_stamped_path): + os.unlink(temp_stamped_path) + + # Invalida cache de pendências uma vez ao final do lote + if sucesso_count > 0: + from django.core.cache import cache as _cache + _cache.delete(f'pendencias_assinatura_user_{request.user.pk}') + + return JsonResponse({ + 'success': True, + 'total': len(pks), + 'sucesso': sucesso_count, + 'erros': erro_count, + 'resultados': resultados, + }) diff --git a/sapl/settings.py b/sapl/settings.py index 82f00cfb6..1e5d55dde 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -34,8 +34,8 @@ PROJECT_DIR = Path(__file__).ancestor(2) # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234') # SECURITY WARNING: don't run with debug turned on in production! -#DEBUG = config('DEBUG', default=False, cast=bool) -DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) +# Aceita tanto DEBUG quanto DJANGO_DEBUG (compatibilidade com docker-compose legado) +DEBUG = config('DEBUG', default=False, cast=bool) or config('DJANGO_DEBUG', default=False, cast=bool) MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -234,6 +234,7 @@ TEMPLATES = [ 'sapl.context_processors.mail_service_configured', 'sapl.context_processors.google_recaptcha_configured', 'sapl.context_processors.enable_sapn', + 'sapl.context_processors.pendencias_assinatura', ], 'debug': DEBUG }, diff --git a/sapl/templates/base.html b/sapl/templates/base.html index 071d26499..c952f89df 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -74,6 +74,19 @@ {% block sections_navbar %} {% navbar 'navbar.yaml' %} {% endblock sections_navbar %}