Browse Source

Paginação matéria legislativa

pull/3858/head
rangelbruno 8 months ago
parent
commit
764928a346
  1. 4
      .gitignore
  2. 20
      docker/Dockerfile.dev
  3. 29
      docker/docker-compose-dev-db.yml
  4. 277
      docs/fluxo-proposicao-vereador.md
  5. 10
      sapl/materia/views.py
  6. 327
      sapl/static/css/assinatura.css
  7. 320
      sapl/static/js/assinatura-a3.js
  8. 287
      sapl/static/js/assinatura-materia.js
  9. 12
      sapl/templates/materia/historico_proposicao.html
  10. 12
      sapl/templates/materia/prop_devolvidas_list.html
  11. 12
      sapl/templates/materia/prop_recebidas_list.html

4
.gitignore

@ -120,3 +120,7 @@ docker-compose.yaml
# Claude Code
.claude/context/*.md
NOTAS.txt
# Database dumps
*.sql
dump_*.sql

20
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

29
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
driver: bridge
volumes:
onlyoffice_data:
onlyoffice_log:

277
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/<id>` → 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<hash_md5>/<proposicao_id>
Exemplo: P8f14e45fceea167a5a36dedd4bea2543/26
```
### 3. Geração do Recibo
**Acesso:** `/proposicao/recibo/<id>`
**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/<hash>/<id>`
**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/<id>` → 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

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

327
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;
}

320
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<boolean>}
*/
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);

287
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);

12
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 %}
<fieldset>
<legend>Histórico de Proposições</legend>
<div class="proposicao-content">
<div class="proposicao-header">
<h2>Historico de Proposicoes</h2>
<p class="text-muted">Registro de todas as movimentacoes de proposicoes</p>
</div>
{% if not object_list %}
<p>{{ NO_ENTRIES_MSG }}</p>
{% else %}
@ -38,7 +41,8 @@
</tbody>
</table>
{% endif %}
</fieldset>
</div>
{% include 'paginacao.html'%}
{% endblock %}

12
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 %}
<fieldset>
<legend>Proposições Não Incorporadas</legend>
<div class="proposicao-content">
<div class="proposicao-header">
<h2>Proposicoes Devolvidas</h2>
<p class="text-muted">Proposicoes nao incorporadas e devolvidas ao autor</p>
</div>
{% if not object_list %}
<p>{{ NO_ENTRIES_MSG }}</p>
{% else %}
@ -31,6 +34,7 @@
</tbody>
</table>
{% endif %}
</fieldset>
</div>
{% include 'paginacao.html'%}
{% endblock %}

12
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 %}
<fieldset>
<legend>Proposições Incorporadas</legend>
<div class="proposicao-content">
<div class="proposicao-header">
<h2>Proposicoes Incorporadas</h2>
<p class="text-muted">Proposicoes recebidas e vinculadas a materias legislativas</p>
</div>
{% if not object_list %}
<p>{{ NO_ENTRIES_MSG }}</p>
{% else %}
@ -47,6 +50,7 @@
</tbody>
</table>
{% endif %}
</fieldset>
</div>
{% include 'paginacao.html'%}
{% endblock %}

Loading…
Cancel
Save