diff --git a/.gitignore b/.gitignore index e36c2e160..501ce48c9 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,7 @@ docker-compose.yaml # Claude Code .claude/context/*.md NOTAS.txt + +# Database dumps +*.sql +dump_*.sql diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 6b8514437..174e59d3b 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,9 +1,19 @@ -FROM python:3.6 -ENV PYTHONUNBUFFERED 1 +FROM python:3.12-slim-bookworm + +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 \ + DEBIAN_FRONTEND=noninteractive + WORKDIR /sapl-dev + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential git pkg-config \ + libpq-dev libxml2-dev libjpeg-dev libssl-dev libffi-dev libxslt1-dev \ + libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libharfbuzz-dev \ + libfreetype6-dev zlib1g-dev graphviz libmagic1 \ + && rm -rf /var/lib/apt/lists/* + COPY ./requirements ./requirements/ -RUN apt update && \ - apt -y install graphviz-dev && \ - pip install --upgrade pip && \ +RUN pip install --upgrade pip && \ pip install -r ./requirements/dev-requirements.txt + EXPOSE 8000 diff --git a/docker/docker-compose-dev-db.yml b/docker/docker-compose-dev-db.yml index 15eb735fe..a009a92c2 100644 --- a/docker/docker-compose-dev-db.yml +++ b/docker/docker-compose-dev-db.yml @@ -9,7 +9,7 @@ services: POSTGRES_USER: sapl POSTGRES_DB: sapl ports: - - "5433:5432" + - "5435:5432" networks: - sapl-net-dev @@ -23,16 +23,39 @@ services: volumes: - ..:/sapl-dev ports: - - "8000:8000" + - "8002:8000" environment: SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^' DEBUG: 'True' DATABASE_URL: postgresql://sapl:sapl@sapldb-dev:5432/sapl TZ: America/Sao_Paulo + ONLYOFFICE_URL: 'http://onlyoffice:80' + depends_on: + - onlyoffice + networks: + - sapl-net-dev + + onlyoffice: + container_name: onlyoffice-documentserver + image: onlyoffice/documentserver:latest + ports: + - "8001:80" + environment: + - JWT_ENABLED=false + - ALLOW_PRIVATE_IP_ADDRESS=true + - ALLOW_META_IP_ADDRESS=true + volumes: + - onlyoffice_data:/var/www/onlyoffice/Data + - onlyoffice_log:/var/log/onlyoffice + restart: unless-stopped networks: - sapl-net-dev networks: sapl-net-dev: name: sapl-net-dev - driver: bridge \ No newline at end of file + driver: bridge + +volumes: + onlyoffice_data: + onlyoffice_log: \ No newline at end of file diff --git a/docs/fluxo-proposicao-vereador.md b/docs/fluxo-proposicao-vereador.md new file mode 100644 index 000000000..2b24ee7e0 --- /dev/null +++ b/docs/fluxo-proposicao-vereador.md @@ -0,0 +1,277 @@ +# Fluxo de Proposição do Vereador no SAPL + +Este documento descreve o processo completo de criação, envio e tramitação de uma proposição por um Vereador no Sistema de Apoio ao Processo Legislativo (SAPL). + +## Visão Geral + +O fluxo de proposição envolve três atores principais: + +| Ator | Papel | +|------|-------| +| **Vereador/Autor** | Cria e envia a proposição | +| **Protocolo/Mesa** | Recebe, valida e incorpora a proposição | +| **Sistema** | Gerencia estados, gera hash e registra histórico | + +## Diagrama de Estados + +``` +┌─────────────────┐ +│ ELABORAÇÃO │ ← Vereador cria/edita proposição +└────────┬────────┘ + │ clica "Enviar" + ▼ +┌─────────────────┐ +│ ENVIADA │ ← Aguardando Protocolo +└────────┬────────┘ + │ Protocolo digita código hash + ▼ +┌─────────────────┐ +│ CONFIRMAÇÃO │ ← Protocolo revisa +└────────┬────────┘ + │ + ┌────┴───────┐ + ▼ ▼ +┌────────┐ ┌──────────┐ +│DEVOLVER│ │INCORPORAR│ +└───┬────┘ └────┬─────┘ + │ │ + ▼ ▼ +┌──────────┐ ┌───────────────────┐ +│ELABORAÇÃO│ │MATÉRIA LEGISLATIVA│ +│(corrigir)│ │ ou DOCUMENTO │ +└──────────┘ └───────────────────┘ +``` + +## Etapas Detalhadas + +### 1. Criação da Proposição (Vereador) + +**Acesso:** `/proposicao/create` + +O Vereador cria uma nova proposição preenchendo: + +| Campo | Descrição | +|-------|-----------| +| Tipo | Tipo de proposição (Projeto de Lei, Moção, Requerimento, etc.) | +| Descrição/Ementa | Texto descritivo da proposição | +| Texto | Pode ser: arquivo digital (upload), Texto Articulado ou OnlyOffice | + +**Opções de criação de texto:** + +- **Arquivo Digital (D):** Upload de arquivo DOCX/DOC/PDF +- **Texto Articulado (T):** Editor estruturado de normas (XML/LexML) +- **OnlyOffice (O):** Editor online de documentos + +### 2. Envio da Proposição (Vereador) + +**Acesso:** `/proposicao/` → botão "Enviar" + +**Código fonte:** `sapl/materia/views.py:912-970` + +#### O que acontece ao clicar "Enviar": + +1. **Validações:** + - Proposição não pode ter sido enviada anteriormente + - Deve possuir pelo menos um tipo de texto associado + +2. **Processamento:** + - Se houver Texto Articulado, ele é **bloqueado para edição** + - Data de envio é registrada (`data_envio = timestamp atual`) + - Hash MD5 é gerado para verificação de integridade + +3. **Registro:** + - Histórico é criado com status `E` (Enviada) + - IP e usuário são registrados + +4. **Resposta:** + - Mensagem de sucesso com número provável da futura matéria + - Recibo disponibilizado com código de barras/QR + +#### Código do hash gerado: + +``` +Formato: P/ +Exemplo: P8f14e45fceea167a5a36dedd4bea2543/26 +``` + +### 3. Geração do Recibo + +**Acesso:** `/proposicao/recibo/` + +**Código fonte:** `sapl/materia/views.py:1224-1279` + +O sistema gera um recibo contendo: + +- Código de barras com o hash +- QR Code para leitura rápida +- Informações da proposição (tipo, autor, data) + +> **Importante:** Este recibo é usado pelo Protocolo para recuperar a proposição. Não constitui assinatura digital. + +### 4. Recebimento pelo Protocolo + +**Acesso:** `/proposicao/receber/` + +**Código fonte:** `sapl/materia/views.py:617-667` + +**Permissão necessária:** `materia.detail_proposicao_enviada` + +#### Processo: + +1. Operador do Protocolo acessa a área de recebimento +2. Digita ou escaneia o código hash do recibo +3. Sistema valida: + - Proposição existe e está enviada + - Hash confere (documento não foi alterado) +4. Redireciona para tela de confirmação + +### 5. Confirmação/Incorporação (Protocolo) + +**Acesso:** `/proposicao/confirmar//` + +**Código fonte:** `sapl/materia/views.py:701-800` + +O Protocolo visualiza os dados da proposição e tem duas opções: + +#### Opção A: Incorporar + +Campos do formulário: + +| Campo | Obrigatório | Descrição | +|-------|-------------|-----------| +| Número de Matéria | Não | Número sugerido ou automático | +| Regime de Tramitação | Sim* | Como a matéria tramitará | +| Matéria de Vínculo | Condicional | Obrigatório para documentos | +| Gerar Protocolo | Configurável | Se deve criar protocolo administrativo | +| Número de Páginas | Não | Para o protocolo | + +*Obrigatório apenas para Matérias Legislativas + +**Resultado da incorporação:** + +1. Proposição marcada como recebida (`data_recebimento = timestamp`) +2. Criação de **MateriaLegislativa** ou **DocumentoAcessorio** +3. Registro de **Autoria** vinculando o autor +4. Opcionalmente, criação de **Protocolo** administrativo +5. Histórico atualizado com status `R` (Recebida) + +#### Opção B: Devolver + +**Código fonte:** `sapl/materia/forms.py:2244-2330` + +Campos: + +| Campo | Obrigatório | Descrição | +|-------|-------------|-----------| +| Justificativa | Sim | Motivo da devolução | +| Observação | Não | Informações adicionais | + +**Resultado da devolução:** + +1. Proposição marcada como devolvida (`data_devolucao = timestamp`) +2. Datas de envio e recebimento são limpas +3. Texto Articulado é **desbloqueado para edição** +4. Histórico atualizado com status `D` (Devolvida) +5. Vereador pode corrigir e reenviar + +### 6. Retorno pelo Autor (Opcional) + +**Acesso:** `/proposicao/` → botão "Retornar" + +**Código fonte:** `sapl/materia/views.py:971-998` + +Se a proposição foi enviada mas **ainda não foi recebida** pelo Protocolo, o Vereador pode retorná-la: + +- Data de envio é limpa +- Texto Articulado é desbloqueado +- Histórico registra status `T` (Retornada) + +## Modelo de Dados + +### Proposição (`sapl/materia/models.py:754-1050`) + +```python +class Proposicao(models.Model): + autor # Quem propôs + tipo # Tipo de proposição + descricao # Ementa/descrição + texto_original # Arquivo digital (se houver) + texto_articulado # Texto estruturado (se houver) + + # Controle de fluxo + data_envio # Quando foi enviada + data_recebimento # Quando foi recebida/incorporada + data_devolucao # Quando foi devolvida + + # Usuários envolvidos + usuario_envio # Quem enviou + usuario_recebimento # Quem recebeu + usuario_devolucao # Quem devolveu + + # Resultado + conteudo_gerado_related # Matéria ou Documento criado + materia_de_vinculo # Matéria relacionada + + # Segurança + hash_code # MD5 para verificação + cancelado # Se foi cancelada +``` + +### Histórico (`sapl/materia/models.py`) + +```python +class HistoricoProposicao(models.Model): + proposicao # Referência à proposição + status # E=Enviada, R=Recebida, T=Retornada, D=Devolvida + data_hora # Timestamp da ação + user # Usuário que executou + ip # IP do usuário + observacao # Justificativa (em devoluções) +``` + +## Permissões + +| Permissão | Descrição | +|-----------|-----------| +| `detail_proposicao_enviada` | Acessar proposições enviadas (Protocolo) | +| `detail_proposicao_devolvida` | Acessar proposições devolvidas | +| `detail_proposicao_incorporada` | Acessar proposições incorporadas | + +## Sobre Assinatura Digital + +> **Importante:** O SAPL atualmente **não possui integração com assinatura digital** (PKI/ICP-Brasil). + +O que existe para segurança: + +| Mecanismo | Função | +|-----------|--------| +| Hash MD5 | Verificar integridade do documento | +| Registro de IP | Rastreabilidade de ações | +| Registro de Usuário | Identificação do responsável | +| Histórico completo | Auditoria de mudanças de estado | +| Bloqueio de edição | Impedir alterações após envio | + +O hash gerado serve para verificar que o documento não foi alterado entre o envio e o recebimento, mas **não constitui assinatura eletrônica** no sentido jurídico. + +## Arquivos do Código-Fonte + +| Arquivo | Responsabilidade | +|---------|------------------| +| `sapl/materia/models.py` | Modelos Proposicao, HistoricoProposicao, TipoProposicao | +| `sapl/materia/views.py` | Views de envio, recebimento, confirmação | +| `sapl/materia/forms.py` | Formulários de proposição | +| `sapl/materia/onlyoffice_views.py` | Integração com editor OnlyOffice | +| `sapl/materia/urls.py` | Rotas e URLs | +| `sapl/templates/materia/proposicao_*.html` | Templates de apresentação | + +## Resumo do Fluxo + +1. **Vereador cria** proposição com texto (upload, Texto Articulado ou OnlyOffice) +2. **Vereador envia** → Sistema gera hash e bloqueia edição +3. **Sistema gera recibo** com código de barras +4. **Protocolo recebe** digitando/escaneando o código hash +5. **Protocolo valida** integridade (hash confere?) +6. **Protocolo decide:** + - **Incorporar** → Cria Matéria Legislativa ou Documento + - **Devolver** → Volta para Vereador corrigir +7. **Se incorporado** → Matéria entra em tramitação normal diff --git a/sapl/materia/views.py b/sapl/materia/views.py index a114f9b29..31cb6c7de 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -2260,11 +2260,11 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): if materia_assunto_null: qs = qs.filter(materiaassunto__isnull=True) - if 'o' in self.request.GET and not self.request.GET['o']: - args = ['-ano', 'tipo__sequencia_regimental', '-numero'] if BaseAppConfig.attr( - 'ordenacao_pesquisa_materia') == 'R' else ['-ano', 'tipo__sigla', '-numero'] - - qs = qs.order_by(*args) + # Ordenação: do mais novo para o mais antigo (por data de apresentação ou ID) + # Só aplica ordenação padrão se não houver ordenação explícita na URL + if 'o' not in self.request.GET or not self.request.GET['o']: + # Ordena por data de apresentação (mais recente primeiro), depois por ID + qs = qs.order_by('-data_apresentacao', '-id') kwargs.update({ 'queryset': qs, diff --git a/sapl/static/css/assinatura.css b/sapl/static/css/assinatura.css new file mode 100644 index 000000000..90cddc934 --- /dev/null +++ b/sapl/static/css/assinatura.css @@ -0,0 +1,327 @@ +/** + * Estilos para o Modal de Assinatura Digital + * SAPL - Sistema de Apoio ao Processo Legislativo + */ + +/* Modal de Assinatura */ +#assinaturaModal .modal-header { + background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); + border-bottom: none; + border-radius: 0.3rem 0.3rem 0 0; +} + +#assinaturaModal .modal-header .modal-title { + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +#assinaturaModal .modal-header .close { + color: white; + opacity: 0.8; + text-shadow: none; +} + +#assinaturaModal .modal-header .close:hover { + opacity: 1; +} + +#assinaturaModal .modal-footer { + border-top: 1px solid #e9ecef; + background-color: #f8f9fa; +} + +/* Cards de Tipo de Certificado */ +.tipo-certificado-card { + cursor: pointer; + transition: all 0.3s ease; + border: 2px solid #dee2e6; + border-radius: 0.5rem; +} + +.tipo-certificado-card:hover { + border-color: #007bff; + box-shadow: 0 4px 15px rgba(0, 123, 255, 0.2); + transform: translateY(-2px); +} + +.tipo-certificado-card.selected { + border-color: #28a745; + background-color: #f8fff8; +} + +.tipo-certificado-card .card-body { + padding: 2rem 1.5rem; +} + +.tipo-certificado-card .fa { + transition: transform 0.3s ease; +} + +.tipo-certificado-card:hover .fa { + transform: scale(1.1); +} + +.tipo-certificado-card .card-title { + font-weight: 600; + margin-bottom: 0.75rem; +} + +.tipo-certificado-card .card-text { + font-size: 0.9rem; + min-height: 2.5rem; +} + +/* Formulário A1 */ +#formulario-a1 .form-group label { + font-weight: 600; + color: #495057; + display: flex; + align-items: center; + gap: 0.5rem; +} + +#formulario-a1 .custom-file-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#formulario-a1 .custom-file-label::after { + content: "Procurar"; + background-color: #007bff; + color: white; + border-left: 1px solid #007bff; +} + +#formulario-a1 .custom-file-input:focus ~ .custom-file-label { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +/* Formulário A3 */ +#a3-detectando { + padding: 2rem; +} + +#a3-detectando .fa-spinner { + color: #007bff; +} + +#a3-nao-detectado ul { + margin-top: 1rem; + padding-left: 1.5rem; +} + +#a3-nao-detectado ul li { + margin-bottom: 0.5rem; +} + +#a3-nao-detectado ul a { + font-weight: 600; +} + +#a3-cert-info { + background-color: #f8f9fa; + border: 1px solid #e9ecef; +} + +#a3-cert-info .card-title { + font-size: 0.9rem; + color: #6c757d; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Status da Assinatura */ +#status-assinatura { + min-height: 200px; +} + +#status-processando { + padding: 2rem; +} + +#status-processando .fa-spinner { + animation: fa-spin 1s infinite linear; +} + +#status-processando .progress { + height: 8px; + border-radius: 4px; + overflow: hidden; + background-color: #e9ecef; +} + +#status-processando .progress-bar { + background: linear-gradient(90deg, #007bff 0%, #00d4ff 100%); + transition: width 0.5s ease; +} + +#status-sucesso { + padding: 2rem; +} + +#status-sucesso .fa-check-circle { + color: #28a745; +} + +#status-sucesso h5 { + color: #28a745; + font-weight: 600; +} + +#status-erro { + padding: 1rem; +} + +#status-erro .alert { + margin-bottom: 0; +} + +/* Alertas customizados */ +#assinaturaModal .alert-info { + background-color: #e7f3ff; + border-color: #b8daff; + color: #004085; +} + +#assinaturaModal .alert-warning { + background-color: #fff3cd; + border-color: #ffeeba; + color: #856404; +} + +#assinaturaModal .alert-success { + background-color: #d4edda; + border-color: #c3e6cb; + color: #155724; +} + +#assinaturaModal .alert-danger { + background-color: #f8d7da; + border-color: #f5c6cb; + color: #721c24; +} + +/* Botões */ +#assinaturaModal .btn-voltar-selecao { + padding: 0; + font-size: 0.9rem; + color: #6c757d; +} + +#assinaturaModal .btn-voltar-selecao:hover { + color: #007bff; + text-decoration: none; +} + +#assinaturaModal .btn-assinar { + min-width: 160px; +} + +/* Card de Assinatura no Detail */ +.card.border-success .card-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.card.border-success .badge-success { + background-color: #28a745; + font-size: 0.85rem; + padding: 0.35em 0.65em; +} + +/* Responsividade */ +@media (max-width: 768px) { + .tipo-certificado-card .card-body { + padding: 1.5rem 1rem; + } + + .tipo-certificado-card .fa { + font-size: 2rem !important; + } + + #assinaturaModal .modal-body { + padding: 1rem; + } + + #assinaturaModal .row.mb-4 > .col-md-6 { + margin-bottom: 1rem; + } +} + +/* Animação de entrada */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +#assinaturaModal.show .modal-content { + animation: fadeInUp 0.3s ease; +} + +/* Estados de loading */ +.btn-assinar.loading { + pointer-events: none; + opacity: 0.7; +} + +.btn-assinar.loading::after { + content: ''; + display: inline-block; + width: 1rem; + height: 1rem; + margin-left: 0.5rem; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: fa-spin 0.75s linear infinite; +} + +/* Tooltip para informações adicionais */ +.info-tooltip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: #6c757d; + color: white; + font-size: 0.7rem; + cursor: help; + margin-left: 0.25rem; +} + +.info-tooltip:hover { + background-color: #007bff; +} + +/* Estilo para certificados expirados */ +.certificado-expirado { + color: #dc3545; + font-weight: 600; +} + +.certificado-valido { + color: #28a745; + font-weight: 600; +} + +/* Estilo para lista de certificados A3 */ +#certificado-a3-select { + font-size: 0.95rem; +} + +#certificado-a3-select option { + padding: 0.5rem; +} diff --git a/sapl/static/js/assinatura-a3.js b/sapl/static/js/assinatura-a3.js new file mode 100644 index 000000000..28e63437e --- /dev/null +++ b/sapl/static/js/assinatura-a3.js @@ -0,0 +1,320 @@ +/** + * Módulo JavaScript para Assinatura Digital com Certificado A3 + * Gerencia a comunicação com aplicações de assinatura local (token USB/smartcard) + */ + +(function(global) { + 'use strict'; + + var AssinaturaA3 = { + /** + * Configurações das aplicações suportadas + */ + aplicacoes: { + serpro: { + nome: 'Assinador SERPRO', + porta: 10443, + protocolo: 'https', + endpoints: { + listarCertificados: '/certificados', + assinar: '/assinar', + verificar: '/verificar' + } + }, + webpki: { + nome: 'Lacuna Web PKI', + porta: 5000, + protocolo: 'http', + endpoints: { + listarCertificados: '/api/certificates', + assinar: '/api/sign', + verificar: '/api/verify' + } + } + }, + + /** + * Aplicação detectada + */ + appDetectada: null, + + /** + * Detecta qual aplicação de assinatura está rodando + * @returns {Promise} + */ + detectar: function() { + var self = this; + var apps = Object.keys(this.aplicacoes); + + return new Promise(function(resolve, reject) { + var tentativas = 0; + + apps.forEach(function(appKey) { + var app = self.aplicacoes[appKey]; + var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.listarCertificados; + + // Usando timeout com AbortController + var controller = new AbortController(); + var timeoutId = setTimeout(function() { + controller.abort(); + }, 3000); + + fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + signal: controller.signal + }) + .then(function(response) { + clearTimeout(timeoutId); + if (response.ok) { + self.appDetectada = { + key: appKey, + config: app + }; + resolve({ + success: true, + app: app.nome, + key: appKey + }); + } else { + throw new Error('Resposta não OK'); + } + }) + .catch(function(error) { + clearTimeout(timeoutId); + tentativas++; + if (tentativas >= apps.length) { + reject({ + success: false, + error: 'Nenhuma aplicação de assinatura A3 detectada. Certifique-se de que a aplicação está em execução.' + }); + } + }); + }); + }); + }, + + /** + * Testa se uma aplicação específica está respondendo + * @param {string} appKey - Chave da aplicação (serpro, webpki) + * @returns {Promise} + */ + testarConexao: function(appKey) { + var app = this.aplicacoes[appKey]; + if (!app) { + return Promise.resolve(false); + } + + var url = app.protocolo + '://localhost:' + app.porta + '/'; + + return fetch(url, { + method: 'GET', + mode: 'no-cors' + }) + .then(function() { + return true; + }) + .catch(function() { + return false; + }); + }, + + /** + * Lista os certificados disponíveis no token/smartcard + * @returns {Promise} + */ + listarCertificados: function() { + var self = this; + + if (!this.appDetectada) { + return Promise.reject({ + error: 'Nenhuma aplicação detectada. Execute detectar() primeiro.' + }); + } + + var app = this.appDetectada.config; + var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.listarCertificados; + + return fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + headers: { + 'Accept': 'application/json' + } + }) + .then(function(response) { + if (!response.ok) { + throw new Error('Erro ao listar certificados'); + } + return response.json(); + }) + .then(function(data) { + // Normaliza o formato dos certificados + return self._normalizarCertificados(data); + }); + }, + + /** + * Normaliza o formato dos certificados para um formato comum + * @param {Object} data - Dados retornados pela API + * @returns {Array} + */ + _normalizarCertificados: function(data) { + var certificados = []; + + if (Array.isArray(data)) { + certificados = data; + } else if (data.certificados) { + certificados = data.certificados; + } else if (data.certificates) { + certificados = data.certificates; + } + + return certificados.map(function(cert) { + return { + id: cert.id || cert.thumbprint || cert.serial, + subject: cert.subject || cert.subjectName || cert.nome, + issuer: cert.issuer || cert.issuerName || cert.emissor, + validFrom: cert.validFrom || cert.notBefore || cert.validade_inicio, + validTo: cert.validTo || cert.notAfter || cert.validade_fim, + thumbprint: cert.thumbprint || cert.hash, + raw: cert + }; + }); + }, + + /** + * Assina dados com o certificado selecionado + * @param {string} certificadoId - ID/thumbprint do certificado + * @param {string} dados - Dados a serem assinados (base64 ou hash) + * @param {string} algoritmo - Algoritmo de hash (sha256, sha384, sha512) + * @returns {Promise} + */ + assinar: function(certificadoId, dados, algoritmo) { + var self = this; + + if (!this.appDetectada) { + return Promise.reject({ + error: 'Nenhuma aplicação detectada.' + }); + } + + var app = this.appDetectada.config; + var url = app.protocolo + '://localhost:' + app.porta + app.endpoints.assinar; + + var payload = { + certificateId: certificadoId, + data: dados, + algorithm: algoritmo || 'sha256' + }; + + // Adapta payload para formato específico de cada aplicação + if (this.appDetectada.key === 'serpro') { + payload = { + certificado: certificadoId, + dados: dados, + algoritmo: algoritmo || 'SHA256' + }; + } + + return fetch(url, { + method: 'POST', + mode: 'cors', + credentials: 'omit', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(function(response) { + if (!response.ok) { + throw new Error('Erro ao assinar'); + } + return response.json(); + }) + .then(function(data) { + return { + success: true, + signature: data.signature || data.assinatura, + certificate: data.certificate || data.certificado + }; + }); + }, + + /** + * Retorna informações sobre a aplicação detectada + * @returns {Object|null} + */ + getAplicacaoDetectada: function() { + return this.appDetectada; + }, + + /** + * Retorna lista de aplicações suportadas + * @returns {Array} + */ + getAplicacoesSuportadas: function() { + var self = this; + return Object.keys(this.aplicacoes).map(function(key) { + return { + key: key, + nome: self.aplicacoes[key].nome, + porta: self.aplicacoes[key].porta + }; + }); + }, + + /** + * Fluxo completo de assinatura A3 + * @param {string} hashPdf - Hash SHA256 do PDF em hexadecimal + * @param {function} onProgress - Callback de progresso + * @returns {Promise} + */ + assinarPdf: function(hashPdf, onProgress) { + var self = this; + + return new Promise(function(resolve, reject) { + // Etapa 1: Detectar aplicação + if (onProgress) onProgress(10, 'Detectando aplicação de assinatura...'); + + self.detectar() + .then(function(deteccao) { + if (onProgress) onProgress(30, 'Aplicação detectada: ' + deteccao.app); + + // Etapa 2: Listar certificados + return self.listarCertificados(); + }) + .then(function(certificados) { + if (!certificados || certificados.length === 0) { + throw { error: 'Nenhum certificado encontrado no token' }; + } + + if (onProgress) onProgress(50, 'Certificados encontrados: ' + certificados.length); + + // Por enquanto, usa o primeiro certificado + // Em produção, deveria mostrar seletor para o usuário + var certificado = certificados[0]; + + if (onProgress) onProgress(70, 'Assinando com: ' + certificado.subject); + + // Etapa 3: Assinar + return self.assinar(certificado.id, hashPdf, 'sha256'); + }) + .then(function(resultado) { + if (onProgress) onProgress(100, 'Assinatura concluída!'); + resolve(resultado); + }) + .catch(function(error) { + reject(error); + }); + }); + } + }; + + // Expor globalmente + global.AssinaturaA3 = AssinaturaA3; + +})(typeof window !== 'undefined' ? window : this); diff --git a/sapl/static/js/assinatura-materia.js b/sapl/static/js/assinatura-materia.js new file mode 100644 index 000000000..160a44b17 --- /dev/null +++ b/sapl/static/js/assinatura-materia.js @@ -0,0 +1,287 @@ +/** + * Módulo JavaScript para Assinatura Digital de Matérias Legislativas + * Gerencia o fluxo de assinatura com certificados A1 e A3 + */ + +(function(global) { + 'use strict'; + + var AssinaturaMateria = { + /** + * Configurações + */ + config: { + materiaId: null, + csrfToken: null, + urls: { + assinarA1: '/materia/{pk}/assinar/a1/', + assinarA3Preparar: '/materia/{pk}/assinar/a3/preparar/', + assinarA3Finalizar: '/materia/{pk}/assinar/a3/finalizar/', + pdfAssinado: '/materia/{pk}/pdf-assinado/', + verificarAssinatura: '/materia/{pk}/verificar-assinatura/', + detectarA3: '/materia/assinatura/detectar-a3/' + }, + portasA3: [ + { nome: 'Assinador SERPRO', porta: 10443, protocolo: 'https' }, + { nome: 'Web PKI Local', porta: 5000, protocolo: 'http' } + ] + }, + + /** + * Inicializa o módulo + * @param {number} materiaId - ID da matéria legislativa + * @param {string} csrfToken - Token CSRF para requisições POST + */ + init: function(materiaId, csrfToken) { + this.config.materiaId = materiaId; + this.config.csrfToken = csrfToken || this._getCSRFToken(); + return this; + }, + + /** + * Obtém o token CSRF do cookie + */ + _getCSRFToken: function() { + var name = 'csrftoken'; + var cookieValue = null; + if (document.cookie && document.cookie !== '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + }, + + /** + * Gera URL substituindo {pk} pelo ID da matéria + */ + _getUrl: function(urlKey) { + return this.config.urls[urlKey].replace('{pk}', this.config.materiaId); + }, + + /** + * Assina com certificado A1 + * @param {File} certificadoFile - Arquivo .pfx ou .p12 + * @param {string} senha - Senha do certificado + * @param {function} onProgress - Callback de progresso (opcional) + * @returns {Promise} + */ + assinarComA1: function(certificadoFile, senha, onProgress) { + var self = this; + + return new Promise(function(resolve, reject) { + if (!certificadoFile) { + reject({ error: 'Arquivo do certificado não informado' }); + return; + } + + if (!senha) { + reject({ error: 'Senha do certificado não informada' }); + return; + } + + var formData = new FormData(); + formData.append('certificado', certificadoFile); + formData.append('senha', senha); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', self._getUrl('assinarA1'), true); + xhr.setRequestHeader('X-CSRFToken', self.config.csrfToken); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) { + var percentComplete = (e.loaded / e.total) * 50; + onProgress(percentComplete, 'Enviando certificado...'); + } + }; + + xhr.onload = function() { + if (xhr.status >= 200 && xhr.status < 300) { + try { + var response = JSON.parse(xhr.responseText); + if (response.success) { + resolve(response); + } else { + reject(response); + } + } catch (e) { + reject({ error: 'Erro ao processar resposta do servidor' }); + } + } else { + try { + var errorResponse = JSON.parse(xhr.responseText); + reject(errorResponse); + } catch (e) { + reject({ error: 'Erro na comunicação com o servidor' }); + } + } + }; + + xhr.onerror = function() { + reject({ error: 'Erro de rede ao comunicar com o servidor' }); + }; + + xhr.send(formData); + }); + }, + + /** + * Detecta aplicações de assinatura A3 rodando localmente + * @returns {Promise} - Resolve com informações da aplicação encontrada + */ + detectarAplicacaoA3: function() { + var self = this; + var portas = this.config.portasA3; + + return new Promise(function(resolve, reject) { + var tentativas = 0; + var encontrado = false; + + portas.forEach(function(app) { + var url = app.protocolo + '://localhost:' + app.porta + '/'; + + // Tenta conectar usando fetch com timeout + var controller = new AbortController(); + var timeoutId = setTimeout(function() { + controller.abort(); + }, 3000); + + fetch(url, { + method: 'GET', + mode: 'no-cors', + signal: controller.signal + }) + .then(function() { + clearTimeout(timeoutId); + if (!encontrado) { + encontrado = true; + resolve({ + success: true, + app: app, + message: 'Aplicação ' + app.nome + ' detectada na porta ' + app.porta + }); + } + }) + .catch(function() { + clearTimeout(timeoutId); + tentativas++; + if (tentativas >= portas.length && !encontrado) { + reject({ + success: false, + error: 'Nenhuma aplicação de assinatura A3 detectada' + }); + } + }); + }); + + // Timeout global + setTimeout(function() { + if (!encontrado) { + reject({ + success: false, + error: 'Timeout ao detectar aplicação A3' + }); + } + }, 5000); + }); + }, + + /** + * Prepara a assinatura A3 obtendo o hash do PDF + * @returns {Promise} + */ + prepararAssinaturaA3: function() { + var self = this; + + return fetch(this._getUrl('assinarA3Preparar'), { + method: 'POST', + headers: { + 'X-CSRFToken': this.config.csrfToken, + 'Content-Type': 'application/json' + } + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + return data; + } else { + throw data; + } + }); + }, + + /** + * Finaliza a assinatura A3 enviando a assinatura do token + * @param {string} signatureBase64 - Assinatura em base64 + * @param {string} certificateBase64 - Certificado em base64 + * @returns {Promise} + */ + finalizarAssinaturaA3: function(signatureBase64, certificateBase64) { + var self = this; + + return fetch(this._getUrl('assinarA3Finalizar'), { + method: 'POST', + headers: { + 'X-CSRFToken': this.config.csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + signature: signatureBase64, + certificate: certificateBase64 + }) + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data.success) { + return data; + } else { + throw data; + } + }); + }, + + /** + * Verifica a assinatura do PDF da matéria + * @returns {Promise} + */ + verificarAssinatura: function() { + return fetch(this._getUrl('verificarAssinatura'), { + method: 'GET', + headers: { + 'X-CSRFToken': this.config.csrfToken + } + }) + .then(function(response) { + return response.json(); + }); + }, + + /** + * Retorna a URL do PDF assinado + * @returns {string} + */ + getUrlPdfAssinado: function() { + return this._getUrl('pdfAssinado'); + }, + + /** + * Baixa o PDF assinado + */ + baixarPdfAssinado: function() { + window.open(this.getUrlPdfAssinado(), '_blank'); + } + }; + + // Expor globalmente + global.AssinaturaMateria = AssinaturaMateria; + +})(typeof window !== 'undefined' ? window : this); diff --git a/sapl/templates/materia/historico_proposicao.html b/sapl/templates/materia/historico_proposicao.html index 410d286c0..bca37c4c2 100644 --- a/sapl/templates/materia/historico_proposicao.html +++ b/sapl/templates/materia/historico_proposicao.html @@ -1,9 +1,12 @@ -{% extends "base.html" %} +{% extends "materia/proposicao_base.html" %} {% load i18n %} {% load tz %} {% block base_content %} -
- Histórico de Proposições +
+
+

Historico de Proposicoes

+

Registro de todas as movimentacoes de proposicoes

+
{% if not object_list %}

{{ NO_ENTRIES_MSG }}

{% else %} @@ -38,7 +41,8 @@ {% endif %} -
+ + {% include 'paginacao.html'%} {% endblock %} diff --git a/sapl/templates/materia/prop_devolvidas_list.html b/sapl/templates/materia/prop_devolvidas_list.html index 8ec29f177..ba2797cbe 100644 --- a/sapl/templates/materia/prop_devolvidas_list.html +++ b/sapl/templates/materia/prop_devolvidas_list.html @@ -1,9 +1,12 @@ -{% extends "base.html" %} +{% extends "materia/proposicao_base.html" %} {% load i18n %} {% load tz %} {% block base_content %} -
- Proposições Não Incorporadas +
+
+

Proposicoes Devolvidas

+

Proposicoes nao incorporadas e devolvidas ao autor

+
{% if not object_list %}

{{ NO_ENTRIES_MSG }}

{% else %} @@ -31,6 +34,7 @@ {% endif %} -
+ + {% include 'paginacao.html'%} {% endblock %} diff --git a/sapl/templates/materia/prop_recebidas_list.html b/sapl/templates/materia/prop_recebidas_list.html index ed4b555fa..c50f71e73 100644 --- a/sapl/templates/materia/prop_recebidas_list.html +++ b/sapl/templates/materia/prop_recebidas_list.html @@ -1,9 +1,12 @@ -{% extends "base.html" %} +{% extends "materia/proposicao_base.html" %} {% load i18n %} {% load tz %} {% block base_content %} -
- Proposições Incorporadas +
+
+

Proposicoes Incorporadas

+

Proposicoes recebidas e vinculadas a materias legislativas

+
{% if not object_list %}

{{ NO_ENTRIES_MSG }}

{% else %} @@ -47,6 +50,7 @@ {% endif %} -
+ + {% include 'paginacao.html'%} {% endblock %}