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/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py b/sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py new file mode 100644 index 000000000..465a2d8a2 --- /dev/null +++ b/sapl/base/migrations/0065_add_permite_remover_assinatura_e_permissoes.py @@ -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?'), + ), + ] diff --git a/sapl/base/models.py b/sapl/base/models.py index 9edc27862..cadddb772 100644 --- a/sapl/base/models.py +++ b/sapl/base/models.py @@ -238,6 +238,16 @@ class AppConfig(models.Model): verbose_name=_('Nome utilizado na assinatura digital'), choices=TIPO_NOME_ASSINATURA, default='P') + permite_remover_assinatura = models.BooleanField( + verbose_name=_('Permitir remoção de assinatura digital?'), + choices=YES_NO_CHOICES, + 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.' + ) + ) # MÓDULO PAINEL cronometro_discurso = models.DurationField( verbose_name=_('Cronômetro do Discurso'), 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/assinatura_api_client.py b/sapl/materia/assinatura_api_client.py new file mode 100644 index 000000000..7a3a7b536 --- /dev/null +++ b/sapl/materia/assinatura_api_client.py @@ -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}' diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index ec2afb73e..82c056ef4 100644 --- a/sapl/materia/forms.py +++ b/sapl/materia/forms.py @@ -45,7 +45,7 @@ from sapl.utils import (autor_label, autor_modal, timing, GoogleRecapthaMixin, get_client_ip) from sapl.utils_template import adicionar_cabecalho_materia -from .models import (AcompanhamentoMateria, Anexada, Autoria, +from .models import (AcompanhamentoMateria, Anexada, Autoria, AutoriaProposicao, DespachoInicial, DocumentoAcessorio, Numeracao, Proposicao, Relatoria, TipoMateriaLegislativa, Tramitacao, UnidadeTramitacao) @@ -979,12 +979,26 @@ class AnexadaForm(ModelForm): fields = ['tipo', 'numero', 'ano', 'data_anexacao', 'data_desanexacao'] +CHOICE_STATUS_ASSINATURA = [ + ('', _('Todas')), + ('pendente', _('⚠ Pendente de Assinatura')), + ('assinada', _('✔ Assinada')), +] + + class MateriaLegislativaFilterSet(django_filters.FilterSet): ano = django_filters.ChoiceFilter(required=False, label='Ano da Matéria', choices=choice_anos_com_materias) + status_assinatura = django_filters.ChoiceFilter( + required=False, + label=_('Status de Assinatura'), + choices=CHOICE_STATUS_ASSINATURA, + method='filter_status_assinatura' + ) + autoria__autor = django_filters.CharFilter(widget=forms.HiddenInput()) autoria__primeiro_autor = django_filters.BooleanFilter( @@ -1074,6 +1088,17 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet): self.filters['o'].label = _('Ordenação') self.form.fields['tipo_listagem'] = self.tipo_listagem + row_assinatura = to_row([ + (HTML(''' +
+ ✍️ + Assinatura Digital: +
+ '''), 3), + ('status_assinatura', 4), + ]) + row1 = to_row( [('tipo', 5), ('ementa', 7)]) row2 = to_row( @@ -1131,6 +1156,7 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet): HTML(autor_label), HTML(autor_modal), row4, + row_assinatura, ), Button('btn_pesquisa_avancada', 'Pesquisa Avançada >>>', css_id='btn_pesquisa_avancada_id', @@ -1158,6 +1184,30 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet): ) ) + def filter_status_assinatura(self, queryset, name, value): + if value == 'pendente': + # Tem texto original mas não tem PDF assinado + return queryset.filter( + texto_original__isnull=False + ).exclude( + texto_original='' + ).filter( + pdf_assinado__isnull=True + ) | queryset.filter( + texto_original__isnull=False + ).exclude( + texto_original='' + ).filter( + pdf_assinado='' + ) + elif value == 'assinada': + return queryset.exclude( + pdf_assinado__isnull=True + ).exclude( + pdf_assinado='' + ) + return queryset + @property def qs(self): qs = qs_override_django_filter(self) @@ -1949,6 +1999,19 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): numero_materia_futuro = forms.IntegerField( label='Número (Opcional)', required=False) + coautores = forms.ModelMultipleChoiceField( + label=_('Co-autores'), + required=False, + queryset=Autor.objects.all(), + widget=forms.SelectMultiple(attrs={ + 'class': 'select2-coautores', + 'style': 'width: 100%', + 'data-placeholder': _('Selecione os co-autores...') + }), + help_text=_('Selecione os demais autores deste documento. ' + 'Eles serão adicionados como co-autores ao incorporar a proposição.') + ) + class Meta: model = Proposicao fields = ['tipo', @@ -1994,6 +2057,7 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): dismiss=False), 12)), to_column(('descricao', 12)), to_column(('observacao', 12)), + to_column(('coautores', 12)), ] @@ -2131,6 +2195,13 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): 'ano_materia' ].initial = self.instance.materia_de_vinculo.ano + # Pré-popular co-autores existentes + coautores_pks = list( + self.instance.coautores.values_list('autor_id', flat=True) + ) + if coautores_pks: + self.fields['coautores'].initial = coautores_pks + def clean_texto_original(self): texto_original = self.cleaned_data.get('texto_original', False) @@ -2222,7 +2293,9 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): inst.texto_original.delete() self.gerar_hash(inst, receber_recibo) - return super().save(commit) + result = super().save(commit) + self._salvar_coautores(result) + return result inst.ano = timezone.now().year sequencia_numeracao = BaseAppConfig.attr( @@ -2241,9 +2314,27 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): self.gerar_hash(inst, receber_recibo) inst.save() + self._salvar_coautores(inst) return inst + def _salvar_coautores(self, inst): + """Sincroniza os co-autores selecionados no formulário com AutoriaProposicao.""" + coautores = self.cleaned_data.get('coautores', []) + # Remove co-autores não mais selecionados + inst.coautores.exclude(autor__in=coautores).delete() + # Adiciona novos co-autores + autores_existentes = set( + inst.coautores.values_list('autor_id', flat=True) + ) + for autor in coautores: + if autor.pk not in autores_existentes: + AutoriaProposicao.objects.create( + proposicao=inst, + autor=autor, + primeiro_autor=False + ) + class DevolverProposicaoForm(forms.ModelForm): @@ -2746,6 +2837,21 @@ class ConfirmarProposicaoForm(ProposicaoForm): 'Autoria registrada para (%s)' ) % str(autoria.autor)) + # Transferir co-autores da proposição para Autoria da matéria + for coautoria in proposicao.coautores.all(): + # Não duplicar se o co-autor for o mesmo que o autor principal + if coautoria.autor != proposicao.autor: + Autoria.objects.get_or_create( + autor=coautoria.autor, + materia=materia, + defaults={ + 'primeiro_autor': coautoria.primeiro_autor + } + ) + self.instance.results['messages']['success'].append(_( + 'Co-autoria registrada para (%s)' + ) % str(coautoria.autor)) + # Transferir anexos da proposição para DocumentoAcessorio from sapl.materia.models import AnexoProposicao anexos = AnexoProposicao.objects.filter( 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/migrations/0095_add_autoria_proposicao.py b/sapl/materia/migrations/0095_add_autoria_proposicao.py new file mode 100644 index 000000000..a125f3721 --- /dev/null +++ b/sapl/materia/migrations/0095_add_autoria_proposicao.py @@ -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')}, + }, + ), + ] diff --git a/sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py b/sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py new file mode 100644 index 000000000..248e8a29b --- /dev/null +++ b/sapl/materia/migrations/0096_add_permite_remover_assinatura_e_permissoes.py @@ -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'}, + ), + ] diff --git a/sapl/materia/models.py b/sapl/materia/models.py index 5ce5ddf52..0d490ded0 100644 --- a/sapl/materia/models.py +++ b/sapl/materia/models.py @@ -345,7 +345,10 @@ class MateriaLegislativa(models.Model): verbose_name_plural = _('Matérias Legislativas') unique_together = (("tipo", "numero", "ano"),) ordering = ['-ano', 'tipo', 'numero'] - permissions = (("can_access_impressos", "Can access impressos"),) + permissions = ( + ("can_access_impressos", "Can access impressos"), + ("can_remove_assinatura", "Pode remover assinatura digital"), + ) def __str__(self): return _('%(tipo)s nº %(numero)s de %(ano)s') % { @@ -634,6 +637,9 @@ class DocumentoAcessorio(models.Model): verbose_name = _('Documento Acessório') verbose_name_plural = _('Documentos Acessórios') ordering = ('data', 'id') + permissions = ( + ("can_remove_assinatura_doc", "Pode remover assinatura digital de documento acessório"), + ) def __str__(self): return _('%(tipo)s - %(nome)s de %(data)s por %(autor)s') % { @@ -1158,6 +1164,40 @@ class Proposicao(models.Model): update_fields=update_fields) +class AutoriaProposicao(models.Model): + """ + Modelo para co-autores de uma Proposição. + Permite que o autor da proposição indique múltiplos co-autores + que serão transferidos para a Autoria da matéria ao incorporar. + """ + proposicao = models.ForeignKey( + Proposicao, + on_delete=models.CASCADE, + verbose_name=_('Proposição'), + related_name='coautores' + ) + autor = models.ForeignKey( + Autor, + on_delete=models.PROTECT, + verbose_name=_('Co-autor') + ) + primeiro_autor = models.BooleanField( + verbose_name=_('Primeiro Autor'), + choices=YES_NO_CHOICES, + default=False + ) + + class Meta: + verbose_name = _('Co-autoria da Proposição') + verbose_name_plural = _('Co-autorias da Proposição') + unique_together = (('proposicao', 'autor'),) + ordering = ('-primeiro_autor', 'autor__nome') + + def __str__(self): + return _('Co-autoria: %(autor)s - %(proposicao)s') % { + 'autor': self.autor, 'proposicao': self.proposicao} + + class HistoricoProposicao(models.Model): STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')), ('R', 'RECEBIDA', _('Recebida')), diff --git a/sapl/materia/onlyoffice_materia_views.py b/sapl/materia/onlyoffice_materia_views.py index e7ebb663d..3da122b51 100644 --- a/sapl/materia/onlyoffice_materia_views.py +++ b/sapl/materia/onlyoffice_materia_views.py @@ -821,3 +821,210 @@ def materia_gerar_pdf_assinatura(request, pk): logger.error(f"Erro inesperado na geração de PDF: {e}") messages.error(request, 'Erro inesperado ao gerar o PDF.') return redirect('sapl.materia:materialegislativa_detail', pk=pk) + + +# ============================================================ +# Prévia de PDF para Documento Acessório +# ============================================================ + +# ============================================================ +# Prévia de PDF para Documento Acessório +# ============================================================ + +@login_required +@require_http_methods(["GET"]) +def materia_gerar_pdf_previa(request, pk): + """ + Gera o PDF da matéria para prévia antes da assinatura. + Diferente do materia_gerar_pdf_assinatura, não exige numero_protocolo. + Se já for PDF, retorna direto. Caso seja DOCX, converte via OnlyOffice. + """ + import requests as http_requests + import xml.etree.ElementTree as ET + + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + if not materia.texto_original: + return HttpResponse('Matéria não possui documento de texto original.', status=404) + + file_name = materia.texto_original.name.lower() + + if file_name.endswith('.pdf'): + try: + with open(materia.texto_original.path, 'rb') as f: + content = f.read() + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = ( + f'inline; filename="Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf"' + ) + return response + except Exception as e: + logger.error(f"Erro ao ler PDF da materia {pk}: {e}") + return HttpResponse('Erro ao ler o arquivo PDF.', status=500) + + download_url = build_onlyoffice_url( + request, + reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': pk}) + ) + + conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx' + + conversion_data = { + "async": False, + "filetype": "docx", + "key": generate_file_key("materia_previa", pk, request.user.pk), + "outputtype": "pdf", + "title": f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf", + "url": download_url, + } + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + conversion_data['token'] = token + + try: + headers = {'Content-Type': 'application/json'} + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + headers['Authorization'] = f'Bearer {header_token}' + + conversion_response = http_requests.post( + conversion_url, + json=conversion_data, + headers=headers, + timeout=60 + ) + + if conversion_response.status_code != 200: + return HttpResponse('Erro ao converter o documento.', status=500) + + try: + root = ET.fromstring(conversion_response.text) + except ET.ParseError as e: + logger.error(f"Erro ao parsear resposta XML (materia previa): {e}") + return HttpResponse('Erro ao processar resposta do serviço.', status=500) + + error_elem = root.find('Error') + if error_elem is not None: + return HttpResponse(f'Erro na conversão: {error_elem.text}', status=500) + + file_url_elem = root.find('FileUrl') + if file_url_elem is None or not file_url_elem.text: + return HttpResponse('URL do PDF não retornada.', status=500) + + pdf_response = http_requests.get(file_url_elem.text, timeout=60) + if pdf_response.status_code != 200: + return HttpResponse('Erro ao baixar PDF convertido.', status=500) + + filename = f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf" + filename = filename.replace(' ', '_').replace('/', '-') + response = HttpResponse(pdf_response.content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="{filename}"' + return response + + except http_requests.exceptions.Timeout: + logger.error(f"Timeout na conversão OnlyOffice (materia previa pk={pk})") + return HttpResponse('Tempo limite excedido ao converter o documento.', status=504) + except http_requests.exceptions.ConnectionError: + logger.error(f"Erro de conexão com OnlyOffice (materia previa pk={pk})") + return HttpResponse('Não foi possível conectar ao serviço de conversão.', status=502) + except Exception as e: + logger.error(f"Erro inesperado na previa PDF (materia pk={pk}): {e}") + return HttpResponse('Erro inesperado ao gerar o PDF.', status=500) + + +@login_required +@require_http_methods(["GET"]) +def docacessorio_gerar_pdf_previa(request, pk): + """ + Gera o PDF do documento acessório para prévia antes da assinatura. + Se já for PDF, retorna direto. Caso seja DOCX, converte via OnlyOffice. + """ + import requests as http_requests + import xml.etree.ElementTree as ET + + docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) + + if not docacessorio.arquivo: + return HttpResponse('Documento sem arquivo.', status=404) + + file_name = docacessorio.arquivo.name.lower() + + if file_name.endswith('.pdf'): + try: + with open(docacessorio.arquivo.path, 'rb') as f: + content = f.read() + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="DocAcessorio_{pk}.pdf"' + return response + except Exception as e: + logger.error(f"Erro ao ler arquivo PDF do doc acessorio: {e}") + return HttpResponse('Erro ao ler o arquivo PDF.', status=500) + + download_url = build_onlyoffice_url( + request, + reverse('sapl.materia:docacessorio_onlyoffice_download', kwargs={'pk': pk}) + ) + + conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx' + + conversion_data = { + "async": False, + "filetype": "docx", + "key": generate_file_key("docacessorio_pdf", pk, request.user.pk), + "outputtype": "pdf", + "title": f"DocAcessorio_{pk}.pdf", + "url": download_url, + } + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + conversion_data['token'] = token + + try: + headers = {'Content-Type': 'application/json'} + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + headers['Authorization'] = f'Bearer {header_token}' + + conversion_response = http_requests.post( + conversion_url, + json=conversion_data, + headers=headers, + timeout=60 + ) + + if conversion_response.status_code != 200: + return HttpResponse('Erro ao converter o documento.', status=500) + + try: + root = ET.fromstring(conversion_response.text) + except ET.ParseError as e: + logger.error(f"Erro ao parsear resposta XML: {e}") + return HttpResponse('Erro ao processar resposta do serviço.', status=500) + + error_elem = root.find('Error') + if error_elem is not None: + return HttpResponse(f'Erro na conversão: {error_elem.text}', status=500) + + file_url_elem = root.find('FileUrl') + if file_url_elem is None or not file_url_elem.text: + return HttpResponse('URL do PDF não retornada.', status=500) + + pdf_response = http_requests.get(file_url_elem.text, timeout=60) + if pdf_response.status_code != 200: + return HttpResponse('Erro ao baixar PDF convertido.', status=500) + + response = HttpResponse(pdf_response.content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="DocAcessorio_{pk}.pdf"' + return response + + except Exception as e: + logger.error(f"Erro inesperado na previa PDF (docacessorio): {e}") + return HttpResponse('Erro inesperado ao gerar o PDF.', status=500) diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index 4333063eb..f55a89329 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -16,6 +16,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView, LegislacaoCitadaCrud, MateriaAssuntoCrud, MateriaLegislativaCrud, MateriaLegislativaPesquisaView, MateriaTaView, + MateriasPendentesAssinaturaView, NumeracaoCrud, OrgaoCrud, OrigemCrud, PrimeiraTramitacaoEmLoteView, ProposicaoCrud, ProposicaoDevolvida, ProposicaoPendente, @@ -33,6 +34,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView, MateriaPesquisaSimplesView, DespachoInicialMultiCreateView, get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo, + get_pdf_multiplos, configEtiquetaMateriaLegislativaCrud, PesquisarStatusTramitacaoView, HistoricoProposicaoView) from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download, @@ -46,15 +48,17 @@ from sapl.materia.onlyoffice_materia_views import ( docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config, docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback, docacessorio_check_doc, docacessorio_forcesave, - materia_gerar_pdf_assinatura + materia_gerar_pdf_assinatura, materia_gerar_pdf_previa, docacessorio_gerar_pdf_previa ) 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, assinatura_api_status, materia_assinar_lote, docacessorio_assinar_a1, docacessorio_pdf_assinado, docacessorio_verificar_assinatura, docacessorio_remover_assinatura, - materia_verificar_documento, docacessorio_verificar_documento + materia_verificar_documento, docacessorio_verificar_documento, + docacessorio_assinar_lote, + materia_pagina_assinatura_png, docacessorio_pagina_assinatura_png, ) from sapl.norma.views import NormaPesquisaSimplesView from sapl.protocoloadm.views import ( @@ -122,6 +126,10 @@ urlpatterns_materia = [ url(r'^materia/pesquisar-materia$', MateriaLegislativaPesquisaView.as_view(), name='pesquisar_materia'), + + url(r'^materia/pendentes-assinatura$', + MateriasPendentesAssinaturaView.as_view(), name='materias_pendentes_assinatura'), + url(r'^materia/(?P\d+)/acompanhar-materia/$', AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'), url(r'^materia/(?P\d+)/acompanhar-confirmar$', @@ -158,6 +166,8 @@ urlpatterns_materia = [ name='zip_completo_materia'), url(r'^materia/pdf-completo/(?P\d+)$', get_pdf_completo, name='pdf_completo_materia'), + url(r'^materia/pdf-multiplos/$', get_pdf_multiplos, + name='pdf_multiplos_materias'), # OnlyOffice endpoints para Matéria Legislativa url(r'^materia/(?P\d+)/onlyoffice/editor$', materia_onlyoffice_editor, @@ -175,6 +185,10 @@ urlpatterns_materia = [ url(r'^materia/(?P\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura, name='materia_pdf_assinatura'), + # Prévia de PDF da Matéria antes da assinatura (sem restrição de protocolo) + url(r'^materia/(?P\d+)/pdf-previa$', materia_gerar_pdf_previa, + name='materia_pdf_previa'), + # Assinatura Digital de Matéria Legislativa url(r'^materia/(?P\d+)/assinar/a1/$', materia_assinar_a1, name='materia_assinar_a1'), @@ -182,6 +196,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, @@ -190,6 +206,12 @@ urlpatterns_materia = [ name='materia_remover_assinatura'), url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3, name='detectar_aplicacao_a3'), + url(r'^materia/assinatura/api-status/$', assinatura_api_status, + name='assinatura_api_status'), + url(r'^materia/(?P\d+)/assinatura/pagina-preview/$', materia_pagina_assinatura_png, + name='materia_pagina_assinatura_png'), + url(r'^materia/documentoacessorio/(?P\d+)/assinatura/pagina-preview/$', docacessorio_pagina_assinatura_png, + name='docacessorio_pagina_assinatura_png'), # Verificação pública de autenticidade (sem login) url(r'^materia/(?P\d+)/verificar/$', materia_verificar_documento, @@ -209,6 +231,10 @@ urlpatterns_materia = [ url(r'^materia/documentoacessorio/(?P\d+)/forcesave$', docacessorio_forcesave, name='docacessorio_forcesave'), + # Prévia de PDF do Documento Acessório antes da assinatura + url(r'^materia/documentoacessorio/(?P\d+)/pdf-previa$', docacessorio_gerar_pdf_previa, + name='docacessorio_pdf_previa'), + # Assinatura Digital de Documento Acessório url(r'^materia/documentoacessorio/(?P\d+)/assinar/a1/$', docacessorio_assinar_a1, name='docacessorio_assinar_a1'), @@ -219,6 +245,10 @@ urlpatterns_materia = [ url(r'^materia/documentoacessorio/(?P\d+)/remover-assinatura/$', docacessorio_remover_assinatura, name='docacessorio_remover_assinatura'), + # Assinatura em Lote de Documentos Acessórios + url(r'^materia/documentoacessorio/assinar-em-lote/$', docacessorio_assinar_lote, + name='docacessorio_assinar_lote'), + # Verificação pública de autenticidade de Documento Acessório (sem login) url(r'^materia/documentoacessorio/(?P\d+)/verificar/$', docacessorio_verificar_documento, name='docacessorio_verificar_documento'), diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 9ea94385a..cd7ada455 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -15,7 +15,7 @@ from crispy_forms.layout import Div, HTML, Submit from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError from django.db.models import Max, Q @@ -38,7 +38,7 @@ from django.utils.decorators import method_decorator import sapl from sapl.base.email_utils import do_envia_email_confirmacao -from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig +from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig, OperadorAutor from sapl.comissoes.models import Participacao from sapl.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE from sapl.compilacao.views import IntegracaoTaView @@ -641,6 +641,53 @@ class ProposicaoPendenteSetor(PermissionRequiredMixin, ListView): return context +class MateriasPendentesAssinaturaView(LoginRequiredMixin, ListView): + template_name = 'materia/materias_pendentes_assinatura_list.html' + model = MateriaLegislativa + paginate_by = 20 + login_url = '/login/' + + def get_autor(self): + try: + return OperadorAutor.objects.get(user=self.request.user).autor + except OperadorAutor.DoesNotExist: + return None + + def get_queryset(self): + autor = self.get_autor() + qs = MateriaLegislativa.objects.filter( + texto_original__isnull=False + ).exclude( + texto_original='' + ).filter( + Q(pdf_assinado__isnull=True) | Q(pdf_assinado='') + ) + if autor: + qs = qs.filter(autoria__autor=autor) + return qs.order_by('-data_apresentacao', '-id').distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + autor = self.get_autor() + context['autor'] = autor + paginator = context['paginator'] + page_obj = context['page_obj'] + context['page_range'] = make_pagination(page_obj.number, paginator.num_pages) + context['total'] = paginator.count + # URL para pesquisar todas filtrando por autor + pendente + if autor: + context['url_pesquisa_completa'] = ( + reverse('sapl.materia:pesquisar_materia') + + f'?autoria__autor={autor.pk}&status_assinatura=pendente' + ) + else: + context['url_pesquisa_completa'] = ( + reverse('sapl.materia:pesquisar_materia') + + '?status_assinatura=pendente' + ) + return context + + class RevisarProposicaoSetor(PermissionRequiredMixin, UpdateView): template_name = "materia/revisar_proposicao_setor.html" model = Proposicao @@ -1949,13 +1996,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud): ) # Normaliza assinatura_info para lista (backward-compatible) - from sapl.materia.views_assinatura import _normalizar_assinatura_info + from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura assinaturas = _normalizar_assinatura_info(self.object.assinatura_info) context['assinaturas'] = assinaturas context['ja_assinou'] = any( a.get('signed_by') == u.username for a in assinaturas ) if u.is_authenticated else False + context['pode_remover_assinatura'] = ( + u.is_authenticated and _pode_remover_assinatura(u) + ) return context @@ -1969,6 +2019,29 @@ class DocumentoAcessorioCrud(MasterDetailCrud): u.has_perm('materia.add_documentoacessorio') ) context['tipos_documento'] = TipoDocumento.objects.all() + + # Documentos acessórios pendentes de assinatura para o lote + pode_assinar_lote = u.is_authenticated and ( + u.is_superuser or + u.has_perm('materia.change_documentoacessorio') + ) + if not pode_assinar_lote: + pode_assinar_lote = u.is_authenticated and OperadorAutor.objects.filter(user=u).exists() + + if pode_assinar_lote: + materia_pk = self.kwargs.get('pk') or self.kwargs.get('root_pk') + qs_pendentes = DocumentoAcessorio.objects.filter( + materia__pk=materia_pk, + pdf_assinado='', + ).order_by('data', 'nome') + docs_lote = [ + {'id': d.pk, 'descricao': f'{d.nome} ({d.tipo}) — {d.data}'} + for d in qs_pendentes + ] + context['docs_pendentes_lote'] = docs_lote + else: + context['docs_pendentes_lote'] = [] + return context def hook_arquivo(self, obj, default, url): @@ -2482,13 +2555,17 @@ class MateriaLegislativaCrud(Crud): ) # Normaliza assinatura_info para lista (backward-compatible) - from sapl.materia.views_assinatura import _normalizar_assinatura_info + from sapl.materia.views_assinatura import _normalizar_assinatura_info, _pode_remover_assinatura assinaturas = _normalizar_assinatura_info(self.object.assinatura_info) context['assinaturas'] = assinaturas context['ja_assinou'] = any( a.get('signed_by') == self.request.user.username for a in assinaturas ) if self.request.user.is_authenticated else False + context['pode_remover_assinatura'] = ( + self.request.user.is_authenticated and + _pode_remover_assinatura(self.request.user) + ) return context @@ -2742,6 +2819,33 @@ 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 + # object_list já foi filtrado pelo filter_status_assinatura — + # precisamos obter os IDs primeiro para evitar problemas com + # querysets compostos por union (|) que não suportam .filter() extra + try: + ids_lote = list(self.object_list.values_list('id', flat=True)[:200]) + from .models import MateriaLegislativa + qs_lote = MateriaLegislativa.objects.filter( + pk__in=ids_lote, + texto_original__isnull=False, + ).exclude(texto_original='').filter( + _Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='') + ).select_related('tipo').values_list( + 'id', 'tipo__sigla', 'numero', 'ano' + ) + context['materias_pendentes_lote'] = [ + {'id': pk, 'descricao': f'{sigla} {numero}/{ano}'} + for pk, sigla, numero, ano in qs_lote + ] + except Exception: + context['materias_pendentes_lote'] = [] + else: + context['materias_pendentes_lote'] = [] + return context @@ -3810,3 +3914,77 @@ def configEtiquetaMateriaLegislativaCrud(request): else: form = ConfigEtiquetaMateriaLegislativaForms(instance=config) return render(request, 'materia/config_etiqueta_materia.html', {'form': form}) + + +def get_pdf_multiplos(request): + """ + Gera PDF unificado com os documentos das matérias informadas via + GET ?ids=1,2,3 ou POST body JSON {"ids": [1,2,3]}. + Retorna o PDF inline para impressão direta no browser. + Limite: 50 matérias por chamada. + """ + logger_local = logging.getLogger(__name__) + username = 'Usuário anônimo' if request.user.is_anonymous else request.user.username + + if request.method == 'POST': + import json as _json + try: + body = _json.loads(request.body) + ids_raw = body.get('ids', '') + except Exception: + ids_raw = request.POST.get('ids', '') + else: + ids_raw = request.GET.get('ids', '') + + try: + import json as _json + if isinstance(ids_raw, list): + ids = [int(i) for i in ids_raw] + elif isinstance(ids_raw, str) and ids_raw.startswith('['): + ids = [int(i) for i in _json.loads(ids_raw)] + else: + ids = [int(i.strip()) for i in str(ids_raw).split(',') if i.strip().isdigit()] + except Exception: + return JsonResponse({'error': 'IDs invalidos'}, status=400) + + if not ids: + return JsonResponse({'error': 'Nenhum ID informado'}, status=400) + + ids = ids[:50] + + MEDIA_ROOT_local = settings.MEDIA_ROOT + materias = MateriaLegislativa.objects.filter(pk__in=ids).select_related('tipo') + + pdf_files = [] + for materia in materias: + if materia.pdf_assinado: + f = os.path.join(MEDIA_ROOT_local, str(materia.pdf_assinado)) + if os.path.exists(f) and f.lower().endswith('.pdf'): + pdf_files.append(f) + continue + if materia.texto_original: + f = os.path.join(MEDIA_ROOT_local, str(materia.texto_original)) + if os.path.exists(f) and f.lower().endswith('.pdf'): + pdf_files.append(f) + + if not pdf_files: + return JsonResponse({'error': 'Nenhum PDF disponivel para as materias selecionadas.'}, status=404) + + try: + merger = PdfFileMerger(strict=False) + for f in pdf_files: + merger.append(fileobj=f) + data = BytesIO() + merger.write(data) + merger.close() + pdf_bytes = data.getvalue() + except Exception as e: + logger_local.error("user={}. Erro ao gerar PDF multiplos: {}".format(username, str(e))) + return JsonResponse({'error': 'Erro ao gerar PDF: ' + str(e)}, status=500) + + logger_local.info("user={}. Gerou PDF multiplos ({} materias, {} PDFs)".format( + username, len(materias), len(pdf_files))) + + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = 'inline; filename="materias_selecionadas.pdf"' + return response diff --git a/sapl/materia/views_assinatura.py b/sapl/materia/views_assinatura.py index 4ed6641ba..b0ea55c7c 100644 --- a/sapl/materia/views_assinatura.py +++ b/sapl/materia/views_assinatura.py @@ -21,13 +21,30 @@ 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 logger = logging.getLogger(__name__) +def _pode_remover_assinatura(user): + """ + Verifica se o usuário tem permissão para remover assinaturas digitais. + Regras (qualquer uma é suficiente): + 1. É superusuário, OU + 2. A funcionalidade está habilitada em AppConfig E o usuário possui a + permissão granular 'materia.can_remove_assinatura'. + """ + if user.is_superuser: + return True + from sapl.base.models import AppConfig + permite = AppConfig.attr('permite_remover_assinatura') + if permite and user.has_perm('materia.can_remove_assinatura'): + return True + return False + + def _normalizar_assinatura_info(info): """Converte assinatura_info legado (dict) para lista de dicts.""" if info is None: @@ -37,6 +54,362 @@ def _normalizar_assinatura_info(info): return info +# ============================================================================= +# Roteamento de backend de assinatura: API externa OU pyhanko local +# ============================================================================= + +def _usar_api_externa(): + """Retorna True se a API externa de assinatura está configurada.""" + from sapl.materia.assinatura_api_client import _api_configurada + return _api_configurada() + + +def _extrair_posicao_custom(post_data): + """ + Extrai coordenadas de posicionamento customizado do POST. + Retorna dict com sig_left, sig_bottom, sig_width, sig_height, sig_page + ou None se não foram enviadas / inválidas. + """ + try: + sig_left = post_data.get('sig_left', '').strip() + sig_bottom = post_data.get('sig_bottom', '').strip() + sig_width = post_data.get('sig_width', '').strip() + sig_height = post_data.get('sig_height', '').strip() + sig_page = post_data.get('sig_page', '').strip() + if not all([sig_left, sig_bottom, sig_width, sig_height, sig_page]): + return None + return { + 'sig_left': float(sig_left), + 'sig_bottom': float(sig_bottom), + 'sig_width': float(sig_width), + 'sig_height': float(sig_height), + 'sig_page': int(sig_page), + } + except (ValueError, TypeError): + return None + + +def _assinar_pdf_com_pagina_auth(pdf_bytes, *, request, tipo_doc, pk_doc, + assinaturas_existentes, + certificado_bytes=None, senha=None, + assinatura_a3_bytes=None, cert_chain_bytes=None, + tipo_cert_input='a1', + posicao_custom=None): + """ + Orquestra a assinatura digital completa de um PDF: + - Monta a página de autenticação (1ª assinatura) ou assina incrementalmente + (assinaturas subsequentes). + - Usa a API externa quando ASSINATURA_API_URL estiver configurado; + caso contrário usa pyhanko localmente. + + posicao_custom : dict opcional com chaves sig_left, sig_bottom, sig_width, + sig_height (em pontos PDF) e sig_page (1-based). + Quando fornecido, substitui o cálculo automático de grid. + + Retorna (signed_pdf_bytes, nova_assinatura_dict, codigo_autenticacao_ou_None). + - codigo_autenticacao_ou_None é não-None apenas na 1ª assinatura. + + Lança Exception em caso de erro. + """ + from PyPDF4 import PdfFileReader, PdfFileWriter + import base64 as _base64 + + ja_tem_pdf_assinado = bool(assinaturas_existentes) + + # ── Informações do assinante ────────────────────────────────────────────── + # Para API externa: não há signer local; tentamos extrair info do certificado + # de forma leve (apenas para metadados visuais). + if _usar_api_externa(): + from sapl.materia.assinatura_api_client import assinar_pdf_via_api, AssinaturaAPIError + + # Obter nome/cargo via relação User → Autor (sem pyhanko) + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + tipo_cert_display = 'Certificado Digital' + + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + tipo_desc = autor.tipo.descricao if autor.tipo else '' + if tipo_desc == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_desc: + cargo = tipo_desc + if isinstance(autor.autor_related, Parlamentar): + parl = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar + except Exception: + pass + + 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_retorno = None + + if not ja_tem_pdf_assinado: + # 1ª assinatura: montar PDF com página de autenticação ANTES de enviar + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) + + nova_assinatura_info_visual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info_visual], codigo, url_verificacao, + page_width, page_height + ) + + auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for page_num in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(page_num)) + output_pdf.addPage(auth_page_pdf.getPage(0)) + + buf_combinado = io.BytesIO() + output_pdf.write(buf_combinado) + pdf_para_assinar = buf_combinado.getvalue() + codigo_retorno = codigo + else: + pdf_para_assinar = pdf_bytes # já inclui página de autenticação + + # Calcula a posição do campo de assinatura na última página (página de autenticação) + # para que a assinatura visual fique no bloco correto do grid. + # Se posicao_custom foi fornecido pelo usuário, usa ele diretamente. + if posicao_custom: + sig_page = posicao_custom.get('sig_page') + sig_left = posicao_custom.get('sig_left') + sig_bottom = posicao_custom.get('sig_bottom') + sig_width = posicao_custom.get('sig_width') + sig_height = posicao_custom.get('sig_height') + else: + n_assinatura = len(assinaturas_existentes) + try: + temp_pypdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) + auth_pg = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) + auth_w = float(auth_pg.mediaBox.getWidth()) + auth_h = float(auth_pg.mediaBox.getHeight()) + sig_x1, sig_y1, sig_x2, sig_y2 = _posicao_bloco_assinatura( + n_assinatura, auth_w, auth_h + ) + sig_page = temp_pypdf.getNumPages() # 1-based, última página + sig_left = sig_x1 + sig_bottom = sig_y1 + sig_width = sig_x2 - sig_x1 + sig_height = sig_y2 - sig_y1 + except Exception: + sig_page = sig_left = sig_bottom = sig_width = sig_height = None + + try: + pdf_assinado_bytes = assinar_pdf_via_api( + pdf_para_assinar, + certificado_bytes=certificado_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location='Câmara Municipal', + signature_page=sig_page, + signature_left=sig_left, + signature_bottom=sig_bottom, + signature_width=sig_width, + signature_height=sig_height, + ) + except AssinaturaAPIError as exc: + raise Exception(str(exc)) + + nova_assinatura = { + 'tipo_certificado': tipo_cert_input.upper(), + 'tipo_certificado_display': f'Certificado Digital – {tipo_cert_input.upper()}', + 'subject': '', + 'issuer': '', + 'serial': '', + 'valid_from': '', + 'valid_to': '', + 'signed_by': request.user.username, + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_formatada, + 'validade_juridica': 'Assinatura Eletrônica Qualificada', + 'backend': 'api_externa', + } + return pdf_assinado_bytes, nova_assinatura, codigo_retorno + + else: + # ── Backend local: pyhanko ───────────────────────────────────────── + from pyhanko.sign import signers, fields + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + from pyhanko.sign.fields import SigFieldSpec + from pyhanko.sign.signers.pdf_signer import PdfSigner + from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader + + # Carregar certificado A1 + import tempfile as tmp_module + with tmp_module.NamedTemporaryFile(delete=False, suffix='.pfx') as tmp_cert: + tmp_cert.write(certificado_bytes) + tmp_cert_path = tmp_cert.name + try: + signer = signers.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + except Exception as cert_error: + raise Exception(f'Erro ao carregar certificado: {cert_error}') + 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: + import json as _json + data = _json.loads(error_response.content) + raise Exception(data.get('error', 'Certificado inválido.')) + + # Criar objeto fake para _obter_info_assinante + class _CertWrapper: + def __init__(self, c): self._c = c + @property + def issuer(self): return self._c.issuer + @property + def subject(self): return self._c.subject + @property + def serial_number(self): return self._c.serial_number + @property + def not_valid_before(self): return self._c.not_valid_before + @property + def not_valid_after(self): return self._c.not_valid_after + + nome_assinante, cargo, tipo_cert_display = _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') + + n_assinatura = len(assinaturas_existentes) + sig_field_name = ( + f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 + else 'AssinaturaDigital' + ) + codigo_retorno = None + + if ja_tem_pdf_assinado: + # Assinatura subsequente (incremental) + temp_pypdf = PdfFileReader(io.BytesIO(pdf_bytes)) + auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) + auth_page_width = float(auth_page.mediaBox.getWidth()) + auth_page_height = float(auth_page.mediaBox.getHeight()) + x1, y1, x2, y2 = _posicao_bloco_assinatura( + n_assinatura, auth_page_width, auth_page_height + ) + + signed_buffer = io.BytesIO() + with io.BytesIO(pdf_bytes) as inf: + w = IncrementalPdfFileWriter(inf) + temp_reader = PyhankoReader(io.BytesIO(pdf_bytes)) + last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 + + sig_field = SigFieldSpec( + sig_field_name=sig_field_name, + on_page=last_page_idx, + box=(x1, y1, x2, y2) + ) + fields.append_signature_field(w, sig_field) + + meta = signers.PdfSignatureMetadata( + field_name=sig_field_name, + location='Câmara Municipal', + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + name=nome_assinante + ) + hash_doc = '' # será preenchido pelo chamador se necessário + stamp_style = _criar_stamp_style(nome_assinante, cargo, hash_doc) + pdf_signer = PdfSigner(meta, signer=signer, stamp_style=stamp_style) + pdf_signer.sign_pdf( + w, + existing_fields_only=True, + appearance_text_params={'signer': nome_assinante}, + output=signed_buffer, + ) + + signed_buffer.seek(0) + pdf_assinado_bytes = signed_buffer.read() + + else: + # Primeira assinatura: página de autenticação + pyhanko + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, tipo_doc, pk_doc, codigo) + nova_assinatura_info_visual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info_visual], codigo, url_verificacao, + page_width, page_height + ) + + auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + 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: + tmp_path = tmp_f.name + output_pdf.write(tmp_f) + + try: + with open(tmp_path, 'rb') as f: + stamped_bytes = f.read() + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + 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) + pdf_assinado_bytes = signed_buffer.read() + codigo_retorno = codigo + + nova_assinatura = { + 'tipo_certificado': 'A1', + 'tipo_certificado_display': f'{tipo_cert_display} – 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', + 'backend': 'pyhanko_local', + } + return pdf_assinado_bytes, nova_assinatura, codigo_retorno + + # ============================================================================= # Funções auxiliares para página de autenticação # ============================================================================= @@ -664,13 +1037,15 @@ def materia_assinar_a1(request, pk): """ Assina o PDF da matéria com certificado A1 (arquivo .pfx/.p12). + Usa API externa de assinatura quando ASSINATURA_API_URL estiver configurado; + caso contrário assina localmente com pyhanko. + Parâmetros POST: - certificado: arquivo .pfx ou .p12 - senha: senha do certificado """ materia = get_object_or_404(MateriaLegislativa, pk=pk) - # Verifica se o usuário atual já assinou assinaturas_existentes = _normalizar_assinatura_info(materia.assinatura_info) if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): return JsonResponse({ @@ -678,244 +1053,40 @@ def materia_assinar_a1(request, pk): 'error': 'Você já assinou esta matéria.' }, status=400) - ja_tem_pdf_assinado = bool(materia.pdf_assinado) - - # Obtém dados do formulário 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) - + 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) - - # Gera o PDF da matéria - pdf_bytes, error = _gerar_pdf_da_materia(materia, request) - if error: - return JsonResponse({ - 'success': False, - 'error': error - }, status=400) - - try: - from pyhanko.sign import signers, fields - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - - signer, error_response = _carregar_certificado(certificado_file, senha) - if error_response: - return error_response - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - return error_response - - # Cria arquivo temporário para o PDF com página de autenticação - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped: - temp_stamped_path = temp_stamped.name - - try: - from pyhanko.sign.fields import SigFieldSpec - from PyPDF4 import PdfFileReader, PdfFileWriter - from reportlab.lib.units import mm - - # Informações do assinante - 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') - - # Número da assinatura (0-indexed) - n_assinatura = len(assinaturas_existentes) - sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital' - - if ja_tem_pdf_assinado: - # ===== ASSINATURA SUBSEQUENTE: Incremental sobre PDF já assinado ===== - # A página de autenticação já existe (criada na 1ª assinatura). - # Posicionar campo de assinatura na última página (autenticação). - - with open(materia.pdf_assinado.path, 'rb') as f: - existing_pdf_bytes = f.read() + return JsonResponse({'success': False, 'error': 'Senha do certificado não informada.'}, status=400) - # Calcular posição do campo de assinatura na página de autenticação - # Usa o mesmo grid da página gerada na primeira assinatura - temp_pypdf = PdfFileReader(io.BytesIO(existing_pdf_bytes)) - auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) - auth_page_width = float(auth_page.mediaBox.getWidth()) - auth_page_height = float(auth_page.mediaBox.getHeight()) - - x1, y1, x2, y2 = _posicao_bloco_assinatura( - n_assinatura, auth_page_width, auth_page_height - ) - - signed_buffer = io.BytesIO() - with io.BytesIO(existing_pdf_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - - # Determina a última página - from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader - from pyhanko.sign.signers.pdf_signer import PdfSigner - temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes)) - last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 - - # Adiciona campo de assinatura com posição visual - sig_field = SigFieldSpec( - sig_field_name=sig_field_name, - on_page=last_page_idx, - box=(x1, y1, x2, y2) - ) - fields.append_signature_field(w, sig_field) - - meta = signers.PdfSignatureMetadata( - field_name=sig_field_name, - location='Câmara Municipal', - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - name=nome_assinante - ) - - # Usa PdfSigner com stamp_style customizado para - # manter visual consistente com a página de autenticação - hash_doc = materia.codigo_autenticacao or '' - stamp_style = _criar_stamp_style( - nome_assinante, cargo, hash_doc - ) - pdf_signer = PdfSigner( - meta, - signer=signer, - stamp_style=stamp_style, - ) - pdf_signer.sign_pdf( - w, - existing_fields_only=True, - appearance_text_params={'signer': nome_assinante}, - output=signed_buffer, - ) - - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() - - else: - # ===== PRIMEIRA ASSINATURA: Página de autenticação + pyhanko sign ===== - - # Lê o PDF original para obter as dimensões - 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()) - - # Gerar código de autenticação - codigo = _gerar_codigo_autenticacao(pdf_bytes) - - # Construir URL de verificação - url_verificacao = _construir_url_verificacao( - request, 'materia', pk, codigo - ) - - # Nova assinatura info (para incluir na página de autenticação) - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - - # Gerar a página de autenticação - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], - codigo, url_verificacao, - page_width, page_height - ) - - # Montar PDF: original + página de autenticação - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() - - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - - # Anexar página de autenticação - output_pdf.addPage(auth_page_pdf.getPage(0)) - - # Salvar em arquivo temporário - with open(temp_stamped_path, 'wb') as f: - output_pdf.write(f) - - # Assinar o PDF combinado - with open(temp_stamped_path, 'rb') as stamped_file: - stamped_bytes = stamped_file.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() - - # Salvar código de autenticação - materia.codigo_autenticacao = codigo - - # Salva o PDF assinado no modelo - filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - materia.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) - - # Salva informações da assinatura (lista de dicts) - nova_assinatura = { - '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) - materia.assinatura_info = assinaturas_existentes - materia.assinado_em = timezone.now() - materia.assinado_por = request.user - materia.save() + # PDF a assinar: já assinado (subsequente) ou original (primeira assinatura) + if materia.pdf_assinado: + with open(materia.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + else: + pdf_bytes, error = _gerar_pdf_da_materia(materia, request) + if error: + return JsonResponse({'success': False, 'error': error}, status=400) - logger.info(f"Matéria {materia.pk} assinada por {request.user.username}") + certificado_bytes = certificado_file.read() - return JsonResponse({ - 'success': True, - 'message': 'PDF assinado com sucesso!', - 'certificado': { - 'nome': str(cert_info.subject), - 'validade': cert_info.not_valid_after.strftime('%d/%m/%Y') - } - }) - - finally: - # Remove arquivo temporário - if os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) + # Posição personalizada enviada pelo usuário (opcional) + posicao_custom = _extrair_posicao_custom(request.POST) + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='materia', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=certificado_bytes, + senha=senha, + tipo_cert_input='a1', + posicao_custom=posicao_custom, + ) except ImportError: logger.error("pyhanko não está instalado") return JsonResponse({ @@ -923,11 +1094,38 @@ def materia_assinar_a1(request, pk): 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' }, status=500) except Exception as e: - logger.error(f"Erro ao assinar PDF: {e}") - return JsonResponse({ - 'success': False, - 'error': f'Erro ao assinar o PDF: {str(e)}' - }, status=500) + logger.error(f"Erro ao assinar PDF da matéria {pk}: {e}") + return JsonResponse({'success': False, 'error': str(e)}, status=500) + + filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + + if codigo: + materia.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + materia.assinatura_info = assinaturas_existentes + materia.assinado_em = timezone.now() + materia.assinado_por = request.user + materia.save() + + 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} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + + return JsonResponse({ + 'success': True, + 'message': 'PDF assinado com sucesso!', + 'backend': nova_assinatura.get('backend', ''), + 'certificado': { + 'nome': nova_assinatura.get('subject') or nova_assinatura.get('nome_assinante', ''), + 'validade': nova_assinatura.get('valid_to', ''), + } + }) @login_required @@ -1211,12 +1409,13 @@ def materia_verificar_assinatura(request, pk): def materia_remover_assinatura(request, pk): """ Remove a assinatura digital da matéria. - Apenas superusuários podem executar esta ação. + Requer: superusuário OU funcionalidade habilitada em AppConfig + E permissão 'materia.can_remove_assinatura'. """ - if not request.user.is_superuser: + if not _pode_remover_assinatura(request.user): return JsonResponse({ 'success': False, - 'error': 'Apenas administradores podem remover assinaturas.' + 'error': 'Você não tem permissão para remover assinaturas digitais.' }, status=403) materia = get_object_or_404(MateriaLegislativa, pk=pk) @@ -1276,249 +1475,219 @@ def detectar_aplicacao_a3(request): # ============================================================================= -# Views de Assinatura Digital para Documento Acessório +# Preview de página para posicionamento de assinatura # ============================================================================= @login_required -@csrf_exempt -@require_http_methods(["POST"]) -def docacessorio_assinar_a1(request, pk): +@require_http_methods(["GET"]) +def materia_pagina_assinatura_png(request, pk): """ - Assina o PDF do documento acessório com certificado A1 (arquivo .pfx/.p12). + Renderiza a última página (página de autenticação) do PDF da matéria como + PNG e retorna JSON com: + - png_base64 : imagem da página em base64 + - page_width : largura em pontos PDF + - page_height : altura em pontos PDF + - page_number : número 1-based da página + - grid_slots : lista de slots do grid automático [{left,bottom,width,height}] """ - docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) - - # Verifica se o usuário atual já assinou - assinaturas_existentes = _normalizar_assinatura_info(docacessorio.assinatura_info) - if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): - return JsonResponse({ - 'success': False, - 'error': 'Você já assinou este documento.' - }, status=400) + return _pagina_assinatura_png_response(request, 'materia', pk) - ja_tem_pdf_assinado = bool(docacessorio.pdf_assinado) - - 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) +@login_required +@require_http_methods(["GET"]) +def docacessorio_pagina_assinatura_png(request, pk): + """Mesmo que materia_pagina_assinatura_png, mas para DocumentoAcessório.""" + return _pagina_assinatura_png_response(request, 'docacessorio', pk) - pdf_bytes, error = _gerar_pdf_do_docacessorio(docacessorio, request) - if error: - return JsonResponse({ - 'success': False, - 'error': error - }, status=400) +def _pagina_assinatura_png_response(request, tipo_doc, pk): + """Lógica comum para gerar PNG da página de autenticação.""" try: - from pyhanko.sign import signers, fields - from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter - - signer, error_response = _carregar_certificado(certificado_file, senha) - if error_response: - return error_response - - cert_info = signer.signing_cert - error_response = _validar_certificado(cert_info) - if error_response: - return error_response - - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_stamped: - temp_stamped_path = temp_stamped.name + import fitz # PyMuPDF + except ImportError: + return JsonResponse({'error': 'PyMuPDF não instalado. Instale com: pip install pymupdf'}, status=500) + + from PyPDF4 import PdfFileReader, PdfFileWriter + + # Obter PDF + if tipo_doc == 'materia': + obj = get_object_or_404(MateriaLegislativa, pk=pk) + if obj.pdf_assinado: + with open(obj.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + # PDF já tem página de autenticação — renderizar última + build_auth = False + else: + pdf_bytes, error = _gerar_pdf_da_materia(obj, request) + if error: + return JsonResponse({'error': error}, status=400) + build_auth = True + else: + obj = get_object_or_404(DocumentoAcessorio, pk=pk) + if obj.pdf_assinado: + with open(obj.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + build_auth = False + else: + pdf_bytes, error = _gerar_pdf_do_docacessorio(obj, request) + if error: + return JsonResponse({'error': error}, status=400) + build_auth = True + + # Se ainda não tem página de autenticação, criar uma temporária para preview + if build_auth: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + # Gerar página de autenticação placeholder (sem código real — só visual) + codigo_fake = 'PREVIEW-000000' + url_fake = request.build_absolute_uri('/') + assinaturas_existentes = _normalizar_assinatura_info( + obj.assinatura_info if hasattr(obj, 'assinatura_info') else None + ) + nome = request.user.get_full_name() or request.user.username + auth_page_bytes = _gerar_pagina_autenticacao( + [{'nome_assinante': nome, 'cargo': '—', 'data_assinatura': ''}], + codigo_fake, url_fake, page_width, page_height + ) + auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output = PdfFileWriter() + for i in range(original_pdf.getNumPages()): + output.addPage(original_pdf.getPage(i)) + output.addPage(auth_pdf.getPage(0)) + buf = io.BytesIO() + output.write(buf) + pdf_bytes = buf.getvalue() + else: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + assinaturas_existentes = _normalizar_assinatura_info( + obj.assinatura_info if hasattr(obj, 'assinatura_info') else None + ) + # Renderizar última página como PNG via PyMuPDF + import base64 + doc_fitz = fitz.open(stream=pdf_bytes, filetype='pdf') + page_idx = len(doc_fitz) - 1 # última página + page_fitz = doc_fitz[page_idx] + mat = fitz.Matrix(2.0, 2.0) # 2x zoom → melhor qualidade + pix = page_fitz.get_pixmap(matrix=mat, alpha=False) + png_bytes = pix.tobytes('png') + doc_fitz.close() + + png_b64 = base64.b64encode(png_bytes).decode('ascii') + + # Calcular slots do grid automático para mostrar no preview + n_existentes = len(assinaturas_existentes) + grid_slots = [] + for i in range(n_existentes + 4): # mostra slots já usados + 4 próximos try: - from pyhanko.sign.fields import SigFieldSpec - from PyPDF4 import PdfFileReader, PdfFileWriter - from reportlab.lib.units import mm - - 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') - - # Número da assinatura (0-indexed) - n_assinatura = len(assinaturas_existentes) - sig_field_name = f'AssinaturaDigital_{n_assinatura + 1}' if n_assinatura > 0 else 'AssinaturaDigital' - - if ja_tem_pdf_assinado: - # ===== ASSINATURA SUBSEQUENTE ===== - - with open(docacessorio.pdf_assinado.path, 'rb') as f: - existing_pdf_bytes = f.read() - - # Calcular posição na página de autenticação - # Usa o mesmo grid da página gerada na primeira assinatura - temp_pypdf = PdfFileReader(io.BytesIO(existing_pdf_bytes)) - auth_page = temp_pypdf.getPage(temp_pypdf.getNumPages() - 1) - auth_page_width = float(auth_page.mediaBox.getWidth()) - auth_page_height = float(auth_page.mediaBox.getHeight()) - - x1, y1, x2, y2 = _posicao_bloco_assinatura( - n_assinatura, auth_page_width, auth_page_height - ) - - signed_buffer = io.BytesIO() - with io.BytesIO(existing_pdf_bytes) as inf: - w = IncrementalPdfFileWriter(inf) - - from pyhanko.pdf_utils.reader import PdfFileReader as PyhankoReader - from pyhanko.sign.signers.pdf_signer import PdfSigner - temp_reader = PyhankoReader(io.BytesIO(existing_pdf_bytes)) - last_page_idx = temp_reader.root['/Pages']['/Count'] - 1 - - sig_field = SigFieldSpec( - sig_field_name=sig_field_name, - on_page=last_page_idx, - box=(x1, y1, x2, y2) - ) - fields.append_signature_field(w, sig_field) - - meta = signers.PdfSignatureMetadata( - field_name=sig_field_name, - location='Câmara Municipal', - reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', - name=nome_assinante - ) - - # Usa PdfSigner com stamp_style customizado para - # manter visual consistente com a página de autenticação - hash_doc = docacessorio.codigo_autenticacao or '' - stamp_style = _criar_stamp_style( - nome_assinante, cargo, hash_doc - ) - pdf_signer = PdfSigner( - meta, - signer=signer, - stamp_style=stamp_style, - ) - pdf_signer.sign_pdf( - w, - existing_fields_only=True, - appearance_text_params={'signer': nome_assinante}, - output=signed_buffer, - ) - - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() - - else: - # ===== PRIMEIRA ASSINATURA: Página de autenticação + pyhanko sign ===== - - 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()) - - # Gerar código de autenticação - codigo = _gerar_codigo_autenticacao(pdf_bytes) - - # Construir URL de verificação - url_verificacao = _construir_url_verificacao( - request, 'docacessorio', pk, codigo - ) - - # Nova assinatura info - nova_assinatura_info = { - 'nome_assinante': nome_assinante, - 'cargo': cargo, - 'data_assinatura': data_simples, - } - - # Gerar a página de autenticação - auth_page_bytes = _gerar_pagina_autenticacao( - [nova_assinatura_info], - codigo, url_verificacao, - page_width, page_height - ) + x1, y1, x2, y2 = _posicao_bloco_assinatura(i, page_width, page_height) + grid_slots.append({ + 'index': i, + 'left': round(x1, 2), + 'bottom': round(y1, 2), + 'width': round(x2 - x1, 2), + 'height': round(y2 - y1, 2), + 'usado': i < n_existentes, + 'sugerido': i == n_existentes, + }) + except Exception: + break - # Montar PDF: original + página de autenticação - auth_page_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) - output_pdf = PdfFileWriter() + return JsonResponse({ + 'png_base64': png_b64, + 'page_width': round(page_width, 2), + 'page_height': round(page_height, 2), + 'page_number': page_idx + 1, + 'render_scale': 2.0, + 'grid_slots': grid_slots, + }) - for page_num in range(original_pdf.getNumPages()): - output_pdf.addPage(original_pdf.getPage(page_num)) - output_pdf.addPage(auth_page_pdf.getPage(0)) +@login_required +@require_http_methods(["GET"]) +def assinatura_api_status(request): + """ + Retorna o status do microserviço de assinatura e qual backend está ativo. + Útil para diagnóstico e para o frontend exibir informações ao usuário. + """ + from sapl.materia.assinatura_api_client import verificar_health, _api_configurada - with open(temp_stamped_path, 'wb') as f: - output_pdf.write(f) + usa_api = _api_configurada() - with open(temp_stamped_path, 'rb') as stamped_file: - stamped_bytes = stamped_file.read() + if usa_api: + ok, mensagem = verificar_health() + return JsonResponse({ + 'backend': 'api_externa', + 'api_url': settings.ASSINATURA_API_URL, + 'disponivel': ok, + 'mensagem': mensagem, + }) + else: + return JsonResponse({ + 'backend': 'pyhanko_local', + 'disponivel': True, + 'mensagem': 'Usando pyhanko local (ASSINATURA_API_URL não configurado).', + }) - 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 - ) +# ============================================================================= +# Views de Assinatura Digital para Documento Acessório +# ============================================================================= - signers.sign_pdf( - w, - meta, - signer=signer, - output=signed_buffer - ) +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def docacessorio_assinar_a1(request, pk): + """ + Assina o PDF do documento acessório com certificado A1 (arquivo .pfx/.p12). - signed_buffer.seek(0) - signed_pdf_content = signed_buffer.read() + Usa API externa de assinatura quando ASSINATURA_API_URL estiver configurado; + caso contrário assina localmente com pyhanko. + """ + docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) - # Salvar código de autenticação - docacessorio.codigo_autenticacao = codigo + assinaturas_existentes = _normalizar_assinatura_info(docacessorio.assinatura_info) + if any(a.get('signed_by') == request.user.username for a in assinaturas_existentes): + return JsonResponse({'success': False, 'error': 'Você já assinou este documento.'}, status=400) - filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf" - docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_content), save=False) + certificado_file = request.FILES.get('certificado') + senha = request.POST.get('senha', '') - nova_assinatura = { - '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) - docacessorio.assinatura_info = assinaturas_existentes - docacessorio.assinado_em = timezone.now() - docacessorio.assinado_por = request.user - docacessorio.save() + 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) - logger.info(f"Documento acessório {docacessorio.pk} assinado por {request.user.username}") + if docacessorio.pdf_assinado: + with open(docacessorio.pdf_assinado.path, 'rb') as f: + pdf_bytes = f.read() + else: + pdf_bytes, error = _gerar_pdf_do_docacessorio(docacessorio, request) + if error: + return JsonResponse({'success': False, 'error': error}, status=400) - return JsonResponse({ - 'success': True, - 'message': 'PDF assinado com sucesso!', - 'certificado': { - 'nome': str(cert_info.subject), - 'validade': cert_info.not_valid_after.strftime('%d/%m/%Y') - } - }) + certificado_bytes = certificado_file.read() - finally: - if os.path.exists(temp_stamped_path): - os.unlink(temp_stamped_path) + # Posição personalizada enviada pelo usuário (opcional) + posicao_custom = _extrair_posicao_custom(request.POST) + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='docacessorio', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=certificado_bytes, + senha=senha, + tipo_cert_input='a1', + posicao_custom=posicao_custom, + ) except ImportError: logger.error("pyhanko não está instalado") return JsonResponse({ @@ -1526,11 +1695,35 @@ def docacessorio_assinar_a1(request, pk): 'error': 'Biblioteca de assinatura não instalada. Contate o administrador.' }, status=500) except Exception as e: - logger.error(f"Erro ao assinar PDF do doc acessório: {e}") - return JsonResponse({ - 'success': False, - 'error': f'Erro ao assinar o PDF: {str(e)}' - }, status=500) + logger.error(f"Erro ao assinar PDF do doc acessório {pk}: {e}") + return JsonResponse({'success': False, 'error': str(e)}, status=500) + + filename = f"docacessorio_{docacessorio.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + docacessorio.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + + if codigo: + docacessorio.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + docacessorio.assinatura_info = assinaturas_existentes + docacessorio.assinado_em = timezone.now() + docacessorio.assinado_por = request.user + docacessorio.save() + + logger.info( + f"Documento acessório {docacessorio.pk} assinado por {request.user.username} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + + return JsonResponse({ + 'success': True, + 'message': 'PDF assinado com sucesso!', + 'backend': nova_assinatura.get('backend', ''), + 'certificado': { + 'nome': nova_assinatura.get('subject') or nova_assinatura.get('nome_assinante', ''), + 'validade': nova_assinatura.get('valid_to', ''), + } + }) @login_required @@ -1637,12 +1830,16 @@ def docacessorio_verificar_assinatura(request, pk): def docacessorio_remover_assinatura(request, pk): """ Remove a assinatura digital do documento acessório. - Apenas superusuários podem executar esta ação. + Requer: superusuário OU funcionalidade habilitada em AppConfig + E permissão 'materia.can_remove_assinatura_doc'. """ - if not request.user.is_superuser: + if not (request.user.is_superuser or ( + AppConfig.attr('permite_remover_assinatura') and + request.user.has_perm('materia.can_remove_assinatura_doc') + )): return JsonResponse({ 'success': False, - 'error': 'Apenas administradores podem remover assinaturas.' + 'error': 'Você não tem permissão para remover assinaturas digitais.' }, status=403) docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) @@ -1762,3 +1959,685 @@ 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() + + # Pré-validação do certificado apenas quando pyhanko é o backend local. + # Na API externa a validação é feita pelo microserviço. + if not _usar_api_externa(): + 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 as _signers_lote + _signer_test = _signers_lote.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + _cert_test = _signer_test.signing_cert + _err = _validar_certificado(_cert_test) + if _err: + _d = json.loads(_err.content) + return JsonResponse({'success': False, 'error': _d.get('error', 'Certificado inválido.')}, status=400) + 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) + + # ── 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 + + # ── Pré-processar: gerar PDFs, filtrar inválidos ────────────────────────── + # itens_para_assinar: apenas matérias que passaram na pré-validação + itens_para_assinar = [] # list of dict com metadados + pdf_bytes + meta_map = {} # pk → {descricao, assinaturas_existentes} + + 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 + + meta_map[pk] = {'descricao': descricao, 'assinaturas_existentes': assinaturas_existentes} + itens_para_assinar.append({'pk': pk, 'pdf_bytes': pdf_bytes}) + + # ── Assinatura: paralela (API externa) ou sequencial (pyhanko) ──────────── + if _usar_api_externa() and itens_para_assinar: + from sapl.materia.assinatura_api_client import assinar_pdf_lote_via_api, AssinaturaAPIError as _APIError + + # Preparar itens com página de autenticação e coordenadas + from PyPDF4 import PdfFileReader, PdfFileWriter + import base64 as _base64 + + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + tipo_desc = autor.tipo.descricao if autor.tipo else '' + if tipo_desc == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_desc: + cargo = tipo_desc + if isinstance(autor.autor_related, Parlamentar): + parl = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar + except Exception: + pass + + data_assinatura = timezone.localtime(timezone.now()) + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') + + itens_api = [] + codigos_map = {} # pk → codigo_autenticacao + + for item in itens_para_assinar: + pk = item['pk'] + pdf_bytes = item['pdf_bytes'] + assinaturas_existentes = meta_map[pk]['assinaturas_existentes'] + ja_tem = bool(assinaturas_existentes) + + if not ja_tem: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, 'materia', pk, codigo) + codigos_map[pk] = codigo + + nova_assinatura_info_visual = { + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_simples, + } + auth_page_bytes = _gerar_pagina_autenticacao( + [nova_assinatura_info_visual], codigo, url_verificacao, + page_width, page_height + ) + + auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for i in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(i)) + output_pdf.addPage(auth_pdf.getPage(0)) + buf = io.BytesIO() + output_pdf.write(buf) + pdf_para_assinar = buf.getvalue() + else: + pdf_para_assinar = pdf_bytes + + n_assinatura = len(assinaturas_existentes) + try: + temp_pdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) + auth_pg = temp_pdf.getPage(temp_pdf.getNumPages() - 1) + auth_w = float(auth_pg.mediaBox.getWidth()) + auth_h = float(auth_pg.mediaBox.getHeight()) + x1, y1, x2, y2 = _posicao_bloco_assinatura(n_assinatura, auth_w, auth_h) + sig_page = temp_pdf.getNumPages() + sig_left, sig_bottom = x1, y1 + sig_width, sig_height = x2 - x1, y2 - y1 + except Exception: + sig_page = sig_left = sig_bottom = sig_width = sig_height = None + + itens_api.append({ + 'id': pk, + 'pdf_bytes': pdf_para_assinar, + 'signature_page': sig_page, + 'signature_left': sig_left, + 'signature_bottom': sig_bottom, + 'signature_width': sig_width, + 'signature_height': sig_height, + }) + + logger.info(f'[lote] Enviando {len(itens_api)} PDFs em paralelo para API externa...') + resultados_api = assinar_pdf_lote_via_api( + itens_api, + certificado_bytes=cert_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location='Câmara Municipal', + ) + + for res_api in resultados_api: + pk = res_api['id'] + materia = materias_map[pk] + descricao = meta_map[pk]['descricao'] + assinaturas_existentes = meta_map[pk]['assinaturas_existentes'] + + if not res_api['ok']: + logger.error(f'[lote] Matéria {pk} falhou: {res_api["error"]}') + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': res_api['error']}) + erro_count += 1 + continue + + nova_assinatura = { + 'tipo_certificado': 'A1', + 'tipo_certificado_display': 'Certificado Digital – A1', + 'subject': '', 'issuer': '', 'serial': '', + 'valid_from': '', 'valid_to': '', + 'signed_by': request.user.username, + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_formatada, + 'validade_juridica': 'Assinatura Eletrônica Qualificada', + 'backend': 'api_externa', + } + + filename = f"materia_{pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(res_api['pdf_bytes']), save=False) + if pk in codigos_map: + materia.codigo_autenticacao = codigos_map[pk] + assinaturas_existentes.append(nova_assinatura) + 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} (api_externa/paralelo)') + resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) + sucesso_count += 1 + + else: + # Backend local: pyhanko — sequencial + for item in itens_para_assinar: + pk = item['pk'] + materia = materias_map[pk] + descricao = meta_map[pk]['descricao'] + assinaturas_existentes = meta_map[pk]['assinaturas_existentes'] + pdf_bytes = item['pdf_bytes'] + + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='materia', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=cert_bytes, + senha=senha, + tipo_cert_input='a1', + ) + + filename = f"materia_{materia.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + materia.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + if codigo: + materia.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + 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} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + 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 + + # 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, + }) + + +# ============================================================================= +# Assinatura em Lote de Documentos Acessórios +# ============================================================================= + +@login_required +@csrf_exempt +@require_http_methods(["POST"]) +def docacessorio_assinar_lote(request): + """ + Assina em lote documentos acessórios pendentes com certificado A1. + + POST multipart: + - certificado: arquivo .pfx / .p12 + - senha: senha do certificado + - ids: JSON array com os PKs dos documentos acessórios ex: "[1,2,3]" + + Retorna JSON: + { + "total": 3, "sucesso": 2, "erros": 1, + "resultados": [ + {"pk": 1, "success": true, "descricao": "Despacho - PL 1/2025"}, + {"pk": 2, "success": false, "descricao": "...", "error": "..."} + ] + } + """ + _tem_perm_django = request.user.has_perm('materia.change_documentoacessorio') + try: + _autor_lote = OperadorAutor.objects.get(user=request.user).autor + except OperadorAutor.DoesNotExist: + _autor_lote = None + + if not (_tem_perm_django or _autor_lote or request.user.is_superuser): + return JsonResponse( + {'success': False, 'error': 'Sem permissao para assinar documentos acessorios.'}, + status=403 + ) + + # -- IDs dos documentos -- + 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': 'Parametro "ids" invalido. Envie um array JSON.'}, + status=400 + ) + else: + return JsonResponse({'success': False, 'error': 'Nenhum documento selecionado.'}, 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 maximo de 200 documentos 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 nao informado.'}, status=400) + if not senha: + return JsonResponse({'success': False, 'error': 'Senha do certificado nao informada.'}, status=400) + + cert_bytes = certificado_file.read() + + # Pré-validação do certificado apenas no backend local (pyhanko). + if not _usar_api_externa(): + 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 as _signers_lote + _signer_test = _signers_lote.SimpleSigner.load_pkcs12( + pfx_file=tmp_cert_path, + passphrase=senha.encode('utf-8') + ) + _cert_test = _signer_test.signing_cert + _err = _validar_certificado(_cert_test) + if _err: + _d = json.loads(_err.content) + return JsonResponse({'success': False, 'error': _d.get('error', 'Certificado inválido.')}, status=400) + except Exception as cert_error: + logger.error(f"[lote-doc] 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 invalido.' + elif 'decode' in err_msg.lower() or 'parse' in err_msg.lower(): + detail = 'Arquivo nao e um certificado valido (.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) + + # -- Pré-processar: gerar PDFs, filtrar inválidos -- + docs = DocumentoAcessorio.objects.filter(pk__in=pks).select_related('materia', 'tipo', 'materia__tipo') + docs_map = {d.pk: d for d in docs} + + resultados = [] + sucesso_count = 0 + erro_count = 0 + + itens_para_assinar = [] + meta_map_doc = {} # pk → {descricao, assinaturas_existentes} + + for pk in pks: + doc = docs_map.get(pk) + if not doc: + resultados.append({'pk': pk, 'success': False, 'descricao': f'ID {pk}', 'error': 'Documento nao encontrado.'}) + erro_count += 1 + continue + + descricao = f'{doc.nome} - {doc.materia.tipo.sigla} {doc.materia.numero}/{doc.materia.ano}' + + if doc.pdf_assinado: + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': 'Ja possui PDF assinado. Ignorado.'}) + erro_count += 1 + continue + + assinaturas_existentes = _normalizar_assinatura_info(doc.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': 'Voce ja assinou este documento.'}) + erro_count += 1 + continue + + pdf_bytes, error = _gerar_pdf_do_docacessorio(doc, request) + if error: + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': error}) + erro_count += 1 + continue + + meta_map_doc[pk] = {'descricao': descricao, 'assinaturas_existentes': assinaturas_existentes} + itens_para_assinar.append({'pk': pk, 'pdf_bytes': pdf_bytes}) + + # -- Assinatura: /sign/batch (API externa) ou sequencial (pyhanko) -- + if _usar_api_externa() and itens_para_assinar: + from sapl.materia.assinatura_api_client import assinar_pdf_lote_via_api, AssinaturaAPIError as _APIError + from PyPDF4 import PdfFileReader, PdfFileWriter + + nome_assinante = request.user.get_full_name() or request.user.username + cargo = 'Usuário do Sistema' + try: + from sapl.base.models import Autor + from sapl.parlamentares.models import Parlamentar + autor = Autor.objects.filter(operadores=request.user).first() + if autor: + tipo_desc = autor.tipo.descricao if autor.tipo else '' + if tipo_desc == 'Parlamentar': + cargo = 'Vereador(a)' + elif tipo_desc: + cargo = tipo_desc + if isinstance(autor.autor_related, Parlamentar): + parl = autor.autor_related + tipo_nome = AppConfig.attr('assinatura_nome') + nome_assinante = parl.nome_completo if tipo_nome == 'C' else parl.nome_parlamentar + except Exception: + pass + + data_assinatura = timezone.localtime(timezone.now()) + data_simples = data_assinatura.strftime('%d/%m/%Y %H:%M') + data_formatada = data_assinatura.strftime('%d/%m/%Y %H:%M:%S') + + itens_api = [] + codigos_map = {} + + for item in itens_para_assinar: + pk = item['pk'] + pdf_bytes = item['pdf_bytes'] + assinaturas_existentes = meta_map_doc[pk]['assinaturas_existentes'] + ja_tem = bool(assinaturas_existentes) + + if not ja_tem: + original_pdf = PdfFileReader(io.BytesIO(pdf_bytes)) + last_page = original_pdf.getPage(original_pdf.getNumPages() - 1) + page_width = float(last_page.mediaBox.getWidth()) + page_height = float(last_page.mediaBox.getHeight()) + codigo = _gerar_codigo_autenticacao(pdf_bytes) + url_verificacao = _construir_url_verificacao(request, 'docacessorio', pk, codigo) + codigos_map[pk] = codigo + nova_assinatura_info_visual = {'nome_assinante': nome_assinante, 'cargo': cargo, 'data_assinatura': data_simples} + auth_page_bytes = _gerar_pagina_autenticacao([nova_assinatura_info_visual], codigo, url_verificacao, page_width, page_height) + auth_pdf = PdfFileReader(io.BytesIO(auth_page_bytes)) + output_pdf = PdfFileWriter() + for i in range(original_pdf.getNumPages()): + output_pdf.addPage(original_pdf.getPage(i)) + output_pdf.addPage(auth_pdf.getPage(0)) + buf = io.BytesIO() + output_pdf.write(buf) + pdf_para_assinar = buf.getvalue() + else: + pdf_para_assinar = pdf_bytes + + n_assinatura = len(assinaturas_existentes) + try: + temp_pdf = PdfFileReader(io.BytesIO(pdf_para_assinar)) + auth_pg = temp_pdf.getPage(temp_pdf.getNumPages() - 1) + auth_w = float(auth_pg.mediaBox.getWidth()) + auth_h = float(auth_pg.mediaBox.getHeight()) + x1, y1, x2, y2 = _posicao_bloco_assinatura(n_assinatura, auth_w, auth_h) + sig_page = temp_pdf.getNumPages() + sig_left, sig_bottom = x1, y1 + sig_width, sig_height = x2 - x1, y2 - y1 + except Exception: + sig_page = sig_left = sig_bottom = sig_width = sig_height = None + + itens_api.append({ + 'id': pk, + 'pdf_bytes': pdf_para_assinar, + 'signature_page': sig_page, + 'signature_left': sig_left, + 'signature_bottom': sig_bottom, + 'signature_width': sig_width, + 'signature_height': sig_height, + }) + + logger.info(f'[lote-doc] Enviando {len(itens_api)} PDFs em batch para API externa...') + resultados_api = assinar_pdf_lote_via_api( + itens_api, + certificado_bytes=cert_bytes, + senha=senha, + reason='Documento assinado digitalmente nos termos da MP 2.200-2/2001', + location='Câmara Municipal', + ) + + for res_api in resultados_api: + pk = res_api['id'] + doc = docs_map[pk] + descricao = meta_map_doc[pk]['descricao'] + assinaturas_existentes = meta_map_doc[pk]['assinaturas_existentes'] + + if not res_api['ok']: + logger.error(f'[lote-doc] Doc {pk} falhou: {res_api["error"]}') + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': res_api['error']}) + erro_count += 1 + continue + + nova_assinatura = { + 'tipo_certificado': 'A1', + 'tipo_certificado_display': 'Certificado Digital – A1', + 'subject': '', 'issuer': '', 'serial': '', + 'valid_from': '', 'valid_to': '', + 'signed_by': request.user.username, + 'nome_assinante': nome_assinante, + 'cargo': cargo, + 'data_assinatura': data_formatada, + 'validade_juridica': 'Assinatura Eletrônica Qualificada', + 'backend': 'api_externa', + } + filename = f"docacessorio_{pk}_assinado_{int(timezone.now().timestamp())}.pdf" + doc.pdf_assinado.save(filename, ContentFile(res_api['pdf_bytes']), save=False) + if pk in codigos_map: + doc.codigo_autenticacao = codigos_map[pk] + assinaturas_existentes.append(nova_assinatura) + doc.assinatura_info = assinaturas_existentes + doc.assinado_em = timezone.now() + doc.assinado_por = request.user + doc.save() + + logger.info(f'[lote-doc] DocAcessorio {pk} assinado por {request.user.username} (api_externa/batch)') + resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) + sucesso_count += 1 + + else: + # Backend local: pyhanko — sequencial + for item in itens_para_assinar: + pk = item['pk'] + doc = docs_map[pk] + descricao = meta_map_doc[pk]['descricao'] + assinaturas_existentes = meta_map_doc[pk]['assinaturas_existentes'] + pdf_bytes = item['pdf_bytes'] + + try: + signed_pdf_bytes, nova_assinatura, codigo = _assinar_pdf_com_pagina_auth( + pdf_bytes, + request=request, + tipo_doc='docacessorio', + pk_doc=pk, + assinaturas_existentes=assinaturas_existentes, + certificado_bytes=cert_bytes, + senha=senha, + tipo_cert_input='a1', + ) + + filename = f"docacessorio_{doc.pk}_assinado_{int(timezone.now().timestamp())}.pdf" + doc.pdf_assinado.save(filename, ContentFile(signed_pdf_bytes), save=False) + if codigo: + doc.codigo_autenticacao = codigo + + assinaturas_existentes.append(nova_assinatura) + doc.assinatura_info = assinaturas_existentes + doc.assinado_em = timezone.now() + doc.assinado_por = request.user + doc.save() + + logger.info( + f"[lote-doc] DocAcessorio {pk} assinado por {request.user.username} " + f"(backend: {nova_assinatura.get('backend', '?')})" + ) + resultados.append({'pk': pk, 'success': True, 'descricao': descricao}) + sucesso_count += 1 + + except Exception as e: + logger.error(f"[lote-doc] Erro ao assinar docacessorio {pk}: {e}") + resultados.append({'pk': pk, 'success': False, 'descricao': descricao, 'error': str(e)}) + erro_count += 1 + + return JsonResponse({ + 'success': True, + 'total': len(pks), + 'sucesso': sucesso_count, + 'erros': erro_count, + 'resultados': resultados, + }) + + +# (função docacessorio_assinar_lote definida acima) + diff --git a/sapl/settings.py b/sapl/settings.py index 82f00cfb6..3db706fa4 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 }, @@ -317,6 +318,13 @@ ONLYOFFICE_JWT_ENABLED = config('ONLYOFFICE_JWT_ENABLED', cast=bool, default=Fal # consegue acessar o SAPL pela mesma URL que o navegador) SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='') +# Microserviço de Assinatura Digital +# Quando ASSINATURA_API_URL estiver configurado, o SAPL delega a assinatura +# dos PDFs ao microserviço em vez de usar pyhanko localmente. +ASSINATURA_API_URL = config('ASSINATURA_API_URL', default='') +ASSINATURA_API_KEY = config('ASSINATURA_API_KEY', default='') +ASSINATURA_API_TIMEOUT = config('ASSINATURA_API_TIMEOUT', cast=int, default=120) + # Integração externa de matérias da sessão SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='') SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='') diff --git a/sapl/static/js/materia_impressao_em_massa.js b/sapl/static/js/materia_impressao_em_massa.js new file mode 100644 index 000000000..71dacc058 --- /dev/null +++ b/sapl/static/js/materia_impressao_em_massa.js @@ -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 = ' ' + escHtml(msg) + + ''; + 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 = ' ' + 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, '"'); + } + + // ── Arranque ────────────────────────────────────────────────────────────── + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/sapl/static/js/materia_pesquisa_download_pdfs.js b/sapl/static/js/materia_pesquisa_download_pdfs.js index 389c7065f..c213e3e23 100644 --- a/sapl/static/js/materia_pesquisa_download_pdfs.js +++ b/sapl/static/js/materia_pesquisa_download_pdfs.js @@ -208,5 +208,27 @@ downloadAllPDFs(); }); } + + // ── Filtro rápido de assinatura ────────────────────────────────── + const selectAssinatura = document.getElementById('select-status-assinatura'); + if (selectAssinatura) { + selectAssinatura.addEventListener('change', function () { + const status = this.value; + const url = new URL(window.location.href); + const params = url.searchParams; + + // Volta sempre para a página 1 + params.delete('page'); + + if (status === '') { + params.delete('status_assinatura'); + } else { + params.set('status_assinatura', status); + } + + window.location.href = url.pathname + '?' + params.toString(); + }); + } + // ───────────────────────────────────────────────────────────────── }); })(); 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 %}