mirror of https://github.com/interlegis/sapl.git
Browse Source
- Adiciona suporte OnlyOffice para DocumentoAdministrativo, NormaJuridica, MateriaLegislativa e DocumentoAcessorio - Cria views específicas por módulo (protocoloadm, norma, materia) - Adiciona botão "Editar com OnlyOffice" nos templates de detail - Melhora layout do formulário de Proposição com CSS aprimorado - Corrige exibição da opção OnlyOffice no formulário de criação - Remove botão OpenAPI não utilizado dos templates crud - Adiciona documentação completa em docs/ONLYOFFICE_INTEGRACAO.mdpull/3858/head
18 changed files with 1937 additions and 98 deletions
@ -0,0 +1,485 @@ |
|||||
|
## Configuração do Docker |
||||
|
|
||||
|
### docker-compose-dev.yml |
||||
|
|
||||
|
O OnlyOffice é executado como um container Docker separado. Adicione ao arquivo `docker/docker-compose-dev.yml`: |
||||
|
|
||||
|
```yaml |
||||
|
version: '3.7' |
||||
|
|
||||
|
services: |
||||
|
sapl-dev: |
||||
|
container_name: sapl-dev |
||||
|
image: sapl:dev |
||||
|
build: |
||||
|
context: ../ |
||||
|
dockerfile: ./docker/Dockerfile.dev |
||||
|
command: python3 manage.py runserver 0:8000 |
||||
|
volumes: |
||||
|
- ..:/sapl-dev |
||||
|
ports: |
||||
|
- "8000:8000" |
||||
|
environment: |
||||
|
SECRET_KEY: '$dkhxm-$zvxdox$g2-&w^1i!_z1juq0xwox6e3#gy6w_88!3t^' |
||||
|
DEBUG: 'True' |
||||
|
DATABASE_URL: postgresql://sapl:sapl@host.docker.internal:5432/sapl |
||||
|
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 |
||||
|
|
||||
|
volumes: |
||||
|
onlyoffice_data: |
||||
|
onlyoffice_log: |
||||
|
onlyoffice_fonts: |
||||
|
``` |
||||
|
|
||||
|
### Portas Utilizadas |
||||
|
|
||||
|
| Serviço | Porta Interna | Porta Externa | Descrição | |
||||
|
|---------|---------------|---------------|-----------| |
||||
|
| SAPL | 8000 | 8000 | Aplicação Django | |
||||
|
| OnlyOffice | 80 | 8001 | Document Server | |
||||
|
|
||||
|
### Comunicação entre Containers |
||||
|
|
||||
|
- **Navegador → OnlyOffice**: `http://localhost:8001` (porta externa) |
||||
|
- **OnlyOffice → SAPL**: `http://sapl-dev:8000` (nome do container na rede Docker) |
||||
|
- **SAPL → OnlyOffice**: `http://onlyoffice:80` (nome do container na rede Docker) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Configuração do Django |
||||
|
|
||||
|
### settings.py |
||||
|
|
||||
|
Adicione as seguintes configurações ao `sapl/settings.py`: |
||||
|
|
||||
|
```python |
||||
|
# OnlyOffice Document Server Configuration |
||||
|
ONLYOFFICE_URL = config('ONLYOFFICE_URL', default='http://localhost:8001') |
||||
|
ONLYOFFICE_JWT_SECRET = config('ONLYOFFICE_JWT_SECRET', default='') |
||||
|
ONLYOFFICE_JWT_ENABLED = config('ONLYOFFICE_JWT_ENABLED', cast=bool, default=False) |
||||
|
``` |
||||
|
|
||||
|
### Variáveis de Ambiente |
||||
|
|
||||
|
| Variável | Descrição | Valor Padrão | |
||||
|
|----------|-----------|--------------| |
||||
|
| `ONLYOFFICE_URL` | URL do OnlyOffice Document Server | `http://localhost:8001` | |
||||
|
| `ONLYOFFICE_JWT_SECRET` | Chave secreta para autenticação JWT | (vazio) | |
||||
|
| `ONLYOFFICE_JWT_ENABLED` | Habilitar autenticação JWT | `False` | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Tipos de Documentos Suportados |
||||
|
|
||||
|
A integração OnlyOffice está disponível para os seguintes tipos de documentos: |
||||
|
|
||||
|
| Tipo de Documento | Modelo Django | Campo de Arquivo | App | |
||||
|
|-------------------|---------------|------------------|-----| |
||||
|
| Proposição | `Proposicao` | `texto_original` | materia | |
||||
|
| Matéria Legislativa | `MateriaLegislativa` | `texto_original` | materia | |
||||
|
| Documento Acessório | `DocumentoAcessorio` | `arquivo` | materia | |
||||
|
| Documento Administrativo | `DocumentoAdministrativo` | `texto_integral` | protocoloadm | |
||||
|
| Norma Jurídica | `NormaJuridica` | `texto_integral` | norma | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Arquitetura da Implementação |
||||
|
|
||||
|
### Estrutura de Views |
||||
|
|
||||
|
Cada módulo possui 4 endpoints para integração com OnlyOffice: |
||||
|
|
||||
|
1. **editor**: Renderiza a página com o editor OnlyOffice |
||||
|
2. **config**: Retorna configuração JSON para inicializar o editor |
||||
|
3. **download**: Permite que o OnlyOffice baixe o documento |
||||
|
4. **callback**: Recebe notificações de salvamento do OnlyOffice |
||||
|
|
||||
|
### URLs Implementadas |
||||
|
|
||||
|
#### Proposição (já existia) |
||||
|
``` |
||||
|
/proposicao/<pk>/onlyoffice/editor |
||||
|
/proposicao/<pk>/onlyoffice/config |
||||
|
/proposicao/<pk>/onlyoffice/download |
||||
|
/proposicao/<pk>/onlyoffice/callback |
||||
|
``` |
||||
|
|
||||
|
#### Matéria Legislativa |
||||
|
``` |
||||
|
/materia/<pk>/onlyoffice/editor |
||||
|
/materia/<pk>/onlyoffice/config |
||||
|
/materia/<pk>/onlyoffice/download |
||||
|
/materia/<pk>/onlyoffice/callback |
||||
|
``` |
||||
|
|
||||
|
#### Documento Acessório |
||||
|
``` |
||||
|
/materia/documentoacessorio/<pk>/onlyoffice/editor |
||||
|
/materia/documentoacessorio/<pk>/onlyoffice/config |
||||
|
/materia/documentoacessorio/<pk>/onlyoffice/download |
||||
|
/materia/documentoacessorio/<pk>/onlyoffice/callback |
||||
|
``` |
||||
|
|
||||
|
#### Documento Administrativo |
||||
|
``` |
||||
|
/docadm/<pk>/onlyoffice/editor |
||||
|
/docadm/<pk>/onlyoffice/config |
||||
|
/docadm/<pk>/onlyoffice/download |
||||
|
/docadm/<pk>/onlyoffice/callback |
||||
|
``` |
||||
|
|
||||
|
#### Norma Jurídica |
||||
|
``` |
||||
|
/norma/<pk>/onlyoffice/editor |
||||
|
/norma/<pk>/onlyoffice/config |
||||
|
/norma/<pk>/onlyoffice/download |
||||
|
/norma/<pk>/onlyoffice/callback |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Arquivos Criados/Modificados |
||||
|
|
||||
|
### Arquivos Criados |
||||
|
|
||||
|
#### Views OnlyOffice |
||||
|
|
||||
|
| Arquivo | Descrição | |
||||
|
|---------|-----------| |
||||
|
| `sapl/protocoloadm/onlyoffice_views.py` | Views para Documento Administrativo | |
||||
|
| `sapl/norma/onlyoffice_views.py` | Views para Norma Jurídica | |
||||
|
| `sapl/materia/onlyoffice_materia_views.py` | Views para Matéria Legislativa e Documento Acessório | |
||||
|
|
||||
|
#### Templates |
||||
|
|
||||
|
| Arquivo | Descrição | |
||||
|
|---------|-----------| |
||||
|
| `sapl/templates/onlyoffice/onlyoffice_editor.html` | Template genérico do editor | |
||||
|
| `sapl/templates/materia/documentoacessorio_detail.html` | Detail com botão OnlyOffice | |
||||
|
|
||||
|
### Arquivos Modificados |
||||
|
|
||||
|
#### URLs |
||||
|
|
||||
|
| Arquivo | Modificação | |
||||
|
|---------|-------------| |
||||
|
| `sapl/protocoloadm/urls.py` | Adicionados 4 endpoints OnlyOffice | |
||||
|
| `sapl/norma/urls.py` | Adicionados 4 endpoints OnlyOffice | |
||||
|
| `sapl/materia/urls.py` | Adicionados 8 endpoints OnlyOffice (matéria + doc acessório) | |
||||
|
|
||||
|
#### Templates (botão OnlyOffice) |
||||
|
|
||||
|
| Arquivo | Modificação | |
||||
|
|---------|-------------| |
||||
|
| `sapl/templates/protocoloadm/documentoadministrativo_detail.html` | Botão "Editar com OnlyOffice" | |
||||
|
| `sapl/templates/norma/normajuridica_detail.html` | Botão "Editar com OnlyOffice" | |
||||
|
| `sapl/templates/materia/materialegislativa_detail.html` | Botão "Editar com OnlyOffice" | |
||||
|
|
||||
|
#### Formulário de Proposição |
||||
|
|
||||
|
| Arquivo | Modificação | |
||||
|
|---------|-------------| |
||||
|
| `sapl/materia/forms.py` | Opção "Criar com OnlyOffice" sempre visível | |
||||
|
| `sapl/materia/views.py` | Redirecionamento para OnlyOffice após salvar | |
||||
|
| `sapl/templates/materia/proposicao_form.html` | Layout melhorado | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Fluxo de Funcionamento |
||||
|
|
||||
|
### 1. Abertura do Editor |
||||
|
|
||||
|
``` |
||||
|
Usuário clica em "Editar com OnlyOffice" |
||||
|
↓ |
||||
|
GET /documento/<pk>/onlyoffice/editor |
||||
|
↓ |
||||
|
Renderiza template com iframe do OnlyOffice |
||||
|
↓ |
||||
|
JavaScript busca configuração via AJAX |
||||
|
↓ |
||||
|
GET /documento/<pk>/onlyoffice/config |
||||
|
↓ |
||||
|
Retorna JSON com URLs e configurações |
||||
|
↓ |
||||
|
OnlyOffice inicializa e busca documento |
||||
|
↓ |
||||
|
GET /documento/<pk>/onlyoffice/download |
||||
|
↓ |
||||
|
Retorna arquivo .docx (ou documento em branco) |
||||
|
``` |
||||
|
|
||||
|
### 2. Salvamento do Documento |
||||
|
|
||||
|
``` |
||||
|
Usuário edita documento no OnlyOffice |
||||
|
↓ |
||||
|
OnlyOffice faz autosave/forcesave |
||||
|
↓ |
||||
|
POST /documento/<pk>/onlyoffice/callback |
||||
|
↓ |
||||
|
Body: { "status": 2, "url": "http://..." } |
||||
|
↓ |
||||
|
SAPL baixa documento da URL fornecida |
||||
|
↓ |
||||
|
Salva no campo de arquivo do modelo |
||||
|
↓ |
||||
|
Retorna { "error": 0 } |
||||
|
``` |
||||
|
|
||||
|
### 3. Status do Callback |
||||
|
|
||||
|
| Status | Significado | Ação | |
||||
|
|--------|-------------|------| |
||||
|
| 1 | Documento sendo editado | Nenhuma | |
||||
|
| 2 | Documento pronto para salvar | Baixar e salvar | |
||||
|
| 4 | Documento fechado sem alterações | Nenhuma | |
||||
|
| 6 | Documento salvo (forcesave) | Baixar e salvar | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Problemas Conhecidos e Soluções |
||||
|
|
||||
|
### 1. OnlyOffice não consegue baixar o documento |
||||
|
|
||||
|
**Sintoma**: Editor carrega mas mostra erro ao abrir documento. |
||||
|
|
||||
|
**Causa**: OnlyOffice não consegue acessar a URL de download do SAPL. |
||||
|
|
||||
|
**Solução**: As URLs precisam ser convertidas para usar o nome do container Docker: |
||||
|
|
||||
|
```python |
||||
|
# No código das views: |
||||
|
host = request.get_host() |
||||
|
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
``` |
||||
|
|
||||
|
### 2. Callback não salva o documento |
||||
|
|
||||
|
**Sintoma**: Edições são perdidas ao fechar o editor. |
||||
|
|
||||
|
**Causa**: SAPL não consegue baixar o documento da URL fornecida pelo OnlyOffice. |
||||
|
|
||||
|
**Solução**: Converter a URL do callback para acessar via rede Docker: |
||||
|
|
||||
|
```python |
||||
|
# Na função de callback: |
||||
|
if 'localhost:8001' in download_url: |
||||
|
download_url = download_url.replace('localhost:8001', 'onlyoffice:80') |
||||
|
``` |
||||
|
|
||||
|
### 3. Erro de JWT |
||||
|
|
||||
|
**Sintoma**: OnlyOffice rejeita requisições com erro de token. |
||||
|
|
||||
|
**Causa**: JWT está habilitado no OnlyOffice mas não configurado no SAPL. |
||||
|
|
||||
|
**Solução**: |
||||
|
- Opção 1: Desabilitar JWT no OnlyOffice (`JWT_ENABLED=false`) |
||||
|
- Opção 2: Configurar mesmo segredo em ambos: |
||||
|
```yaml |
||||
|
# docker-compose.yml |
||||
|
environment: |
||||
|
- JWT_ENABLED=true |
||||
|
- JWT_SECRET=sua-chave-secreta |
||||
|
``` |
||||
|
```python |
||||
|
# settings.py ou .env |
||||
|
ONLYOFFICE_JWT_ENABLED=True |
||||
|
ONLYOFFICE_JWT_SECRET=sua-chave-secreta |
||||
|
``` |
||||
|
|
||||
|
### 4. Opção OnlyOffice não aparece no formulário de Proposição |
||||
|
|
||||
|
**Sintoma**: Ao criar proposição, não aparece a opção "Criar com OnlyOffice". |
||||
|
|
||||
|
**Causa**: A opção estava condicionada à configuração `texto_articulado_proposicao`. |
||||
|
|
||||
|
**Solução**: Modificado `sapl/materia/forms.py` para sempre mostrar as opções de tipo de texto: |
||||
|
|
||||
|
```python |
||||
|
# Sempre incluir tipo_texto para permitir escolher entre Arquivo Digital e OnlyOffice |
||||
|
if 'tipo_texto' not in self._meta.fields: |
||||
|
self._meta.fields.append('tipo_texto') |
||||
|
|
||||
|
# Ajustar choices baseado na configuração |
||||
|
if not self.texto_articulado_proposicao: |
||||
|
self.fields['tipo_texto'].choices = [ |
||||
|
('D', _('Arquivo Digital')), |
||||
|
('O', _('Criar com OnlyOffice')) |
||||
|
] |
||||
|
``` |
||||
|
|
||||
|
### 5. Container OnlyOffice não inicia |
||||
|
|
||||
|
**Sintoma**: Container fica reiniciando ou não responde. |
||||
|
|
||||
|
**Causa**: OnlyOffice requer recursos significativos (mínimo 2GB RAM). |
||||
|
|
||||
|
**Solução**: |
||||
|
- Verificar logs: `docker logs onlyoffice-documentserver` |
||||
|
- Aumentar recursos do Docker Desktop |
||||
|
- Aguardar inicialização completa (~2-3 minutos na primeira vez) |
||||
|
|
||||
|
### 6. Documento em branco não é criado |
||||
|
|
||||
|
**Sintoma**: Erro ao abrir editor para documento sem arquivo. |
||||
|
|
||||
|
**Causa**: Biblioteca `python-docx` não instalada. |
||||
|
|
||||
|
**Solução**: Adicionar ao `requirements.txt`: |
||||
|
``` |
||||
|
python-docx |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Melhorias no Formulário de Proposição |
||||
|
|
||||
|
### Alterações Visuais |
||||
|
|
||||
|
O template `sapl/templates/materia/proposicao_form.html` foi completamente reformulado para melhorar a experiência do usuário. |
||||
|
|
||||
|
#### 1. Botão "Novo Tipo" |
||||
|
|
||||
|
**Antes**: Colado ao select, sem espaçamento adequado. |
||||
|
|
||||
|
**Depois**: Posicionado ao lado do label com flexbox: |
||||
|
|
||||
|
```css |
||||
|
#div_id_tipo .tipo-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 2. Opções de Tipo de Texto |
||||
|
|
||||
|
**Antes**: Radio buttons simples, difíceis de clicar. |
||||
|
|
||||
|
**Depois**: Cards visuais com hover e seleção destacada: |
||||
|
|
||||
|
```css |
||||
|
#div_id_tipo_texto .form-check label { |
||||
|
padding: 14px 20px; |
||||
|
background: white; |
||||
|
border: 2px solid #dee2e6; |
||||
|
border-radius: 8px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.2s ease; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto .form-check label.checked { |
||||
|
border-color: #007bff; |
||||
|
background: #e7f1ff; |
||||
|
box-shadow: 0 0 0 1px #007bff; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 3. Layout dos Campos |
||||
|
|
||||
|
**Antes**: Campos divididos em colunas (5 + 7). |
||||
|
|
||||
|
**Depois**: Campos ocupam largura total (12 colunas). |
||||
|
|
||||
|
```python |
||||
|
# forms.py |
||||
|
fields.append(to_column((InlineRadios('tipo_texto'), 12))) |
||||
|
fields.append(to_column(('texto_original', 12))) |
||||
|
``` |
||||
|
|
||||
|
#### 4. Botões do Formulário |
||||
|
|
||||
|
**Antes**: Desalinhados. |
||||
|
|
||||
|
**Depois**: Alinhados com flexbox e separados visualmente: |
||||
|
|
||||
|
```css |
||||
|
.form-group.row.justify-content-between { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding: 20px 0; |
||||
|
margin-top: 20px; |
||||
|
border-top: 1px solid #e9ecef; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 5. Feedback de Busca de Matéria |
||||
|
|
||||
|
**Antes**: Apenas texto. |
||||
|
|
||||
|
**Depois**: Alertas coloridos com ícones: |
||||
|
|
||||
|
```javascript |
||||
|
if (data.pagination.total_entries === 1) { |
||||
|
$(".ementa_materia") |
||||
|
.html('<strong><i class="fa fa-check-circle text-success"></i> Matéria encontrada:</strong> ' + data.results[0].ementa) |
||||
|
.addClass('alert-info'); |
||||
|
} else { |
||||
|
$(".ementa_materia") |
||||
|
.html('<i class="fa fa-exclamation-triangle"></i> <em>Matéria não encontrada</em>') |
||||
|
.addClass('alert-warning'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Testando a Integração |
||||
|
|
||||
|
### 1. Iniciar os containers |
||||
|
|
||||
|
```bash |
||||
|
cd docker |
||||
|
docker-compose -f docker-compose-dev.yml up -d |
||||
|
``` |
||||
|
|
||||
|
### 2. Verificar status |
||||
|
|
||||
|
```bash |
||||
|
# SAPL |
||||
|
curl http://localhost:8000 |
||||
|
|
||||
|
# OnlyOffice (aguardar ~2 min na primeira vez) |
||||
|
curl http://localhost:8001/healthcheck |
||||
|
``` |
||||
|
|
||||
|
### 3. Testar no navegador |
||||
|
|
||||
|
1. Acessar `http://localhost:8000` |
||||
|
2. Fazer login |
||||
|
3. Navegar até um documento (Proposição, Matéria, Norma, etc.) |
||||
|
4. Clicar em "Editar com OnlyOffice" |
||||
|
5. Editar o documento |
||||
|
6. Fechar e verificar se foi salvo |
||||
|
|
||||
|
### 4. Verificar logs |
||||
|
|
||||
|
```bash |
||||
|
# Logs do SAPL |
||||
|
docker logs -f sapl-dev |
||||
|
|
||||
|
# Logs do OnlyOffice |
||||
|
docker logs -f onlyoffice-documentserver |
||||
|
``` |
||||
|
|
||||
@ -0,0 +1,454 @@ |
|||||
|
""" |
||||
|
Views e utilitários para integração com OnlyOffice Document Server |
||||
|
para Matéria Legislativa e Documento Acessório |
||||
|
""" |
||||
|
import hashlib |
||||
|
import json |
||||
|
import logging |
||||
|
import time |
||||
|
from django.conf import settings |
||||
|
from django.contrib.auth.decorators import login_required |
||||
|
from django.http import JsonResponse, HttpResponse |
||||
|
from django.shortcuts import get_object_or_404, render, redirect |
||||
|
from django.urls import reverse |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from django.views.decorators.http import require_http_methods |
||||
|
from django.contrib import messages |
||||
|
|
||||
|
from sapl.materia.models import MateriaLegislativa, DocumentoAcessorio |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
def generate_file_key(prefix, doc_id, user_id): |
||||
|
""" |
||||
|
Gera uma chave única para o documento no OnlyOffice |
||||
|
A chave muda a cada edição para forçar o OnlyOffice a recarregar |
||||
|
""" |
||||
|
timestamp = str(int(time.time())) |
||||
|
string_to_hash = f"{prefix}_{doc_id}_user_{user_id}_{timestamp}" |
||||
|
return hashlib.md5(string_to_hash.encode()).hexdigest() |
||||
|
|
||||
|
|
||||
|
# ============================================================ |
||||
|
# Views para Matéria Legislativa |
||||
|
# ============================================================ |
||||
|
|
||||
|
@login_required |
||||
|
@require_http_methods(["GET"]) |
||||
|
def materia_onlyoffice_config(request, pk): |
||||
|
""" |
||||
|
Retorna a configuração JSON para inicializar o editor OnlyOffice |
||||
|
""" |
||||
|
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
||||
|
|
||||
|
# Verifica permissão de edição |
||||
|
can_edit = request.user.has_perm('materia.change_materialegislativa') |
||||
|
|
||||
|
# URLs para o OnlyOffice acessar (dentro da rede Docker) |
||||
|
download_url = request.build_absolute_uri( |
||||
|
reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': pk}) |
||||
|
) |
||||
|
callback_url = request.build_absolute_uri( |
||||
|
reverse('sapl.materia:materia_onlyoffice_callback', kwargs={'pk': pk}) |
||||
|
) |
||||
|
|
||||
|
# Substituir localhost/host externo pelo nome do container na rede Docker |
||||
|
host = request.get_host() |
||||
|
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
|
||||
|
# Configuração do documento |
||||
|
document_config = { |
||||
|
"fileType": "docx", |
||||
|
"key": generate_file_key("materia", materia.pk, request.user.pk), |
||||
|
"title": f"Materia_{materia.pk}.docx", |
||||
|
"url": download_url, |
||||
|
} |
||||
|
|
||||
|
# Configuração do editor |
||||
|
editor_config = { |
||||
|
"mode": "edit" if can_edit else "view", |
||||
|
"lang": "pt-BR", |
||||
|
"callbackUrl": callback_url, |
||||
|
"user": { |
||||
|
"id": str(request.user.pk), |
||||
|
"name": request.user.get_full_name() or request.user.username, |
||||
|
}, |
||||
|
"customization": { |
||||
|
"autosave": True, |
||||
|
"forcesave": True, |
||||
|
"comments": True, |
||||
|
"chat": False, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
config = { |
||||
|
"documentType": "word", |
||||
|
"document": document_config, |
||||
|
"editorConfig": editor_config, |
||||
|
"height": "600px", |
||||
|
"width": "100%", |
||||
|
} |
||||
|
|
||||
|
# Adiciona JWT se estiver habilitado |
||||
|
if settings.ONLYOFFICE_JWT_ENABLED and settings.ONLYOFFICE_JWT_SECRET: |
||||
|
import jwt |
||||
|
token = jwt.encode(config, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') |
||||
|
config['token'] = token |
||||
|
|
||||
|
return JsonResponse(config) |
||||
|
|
||||
|
|
||||
|
@require_http_methods(["GET"]) |
||||
|
def materia_onlyoffice_download(request, pk): |
||||
|
""" |
||||
|
Endpoint para o OnlyOffice baixar o documento |
||||
|
Se não existe arquivo, retorna um documento em branco |
||||
|
""" |
||||
|
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
||||
|
|
||||
|
# Se já tem arquivo, retorna ele |
||||
|
if materia.texto_original: |
||||
|
try: |
||||
|
with open(materia.texto_original.path, 'rb') as f: |
||||
|
content = f.read() |
||||
|
response = HttpResponse(content, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document') |
||||
|
response['Content-Disposition'] = f'attachment; filename="Materia_{pk}.docx"' |
||||
|
return response |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao ler arquivo: {e}") |
||||
|
|
||||
|
# Se não tem arquivo, cria um documento em branco usando python-docx |
||||
|
try: |
||||
|
from docx import Document |
||||
|
from io import BytesIO |
||||
|
|
||||
|
doc = Document() |
||||
|
doc.add_heading(f'{materia.tipo} {materia.numero}/{materia.ano}', 0) |
||||
|
doc.add_paragraph(f'Ementa: {materia.ementa}') |
||||
|
doc.add_paragraph('') |
||||
|
doc.add_paragraph('Digite o texto da matéria abaixo:') |
||||
|
doc.add_paragraph('') |
||||
|
|
||||
|
file_stream = BytesIO() |
||||
|
doc.save(file_stream) |
||||
|
file_stream.seek(0) |
||||
|
|
||||
|
response = HttpResponse( |
||||
|
file_stream.getvalue(), |
||||
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
||||
|
) |
||||
|
response['Content-Disposition'] = f'attachment; filename="Materia_{pk}.docx"' |
||||
|
return response |
||||
|
|
||||
|
except ImportError: |
||||
|
logger.error("python-docx não está instalado") |
||||
|
return HttpResponse("Erro: python-docx não instalado", status=500) |
||||
|
|
||||
|
|
||||
|
@csrf_exempt |
||||
|
@require_http_methods(["POST"]) |
||||
|
def materia_onlyoffice_callback(request, pk): |
||||
|
""" |
||||
|
Callback chamado pelo OnlyOffice quando o documento é salvo |
||||
|
""" |
||||
|
try: |
||||
|
body = json.loads(request.body.decode('utf-8')) |
||||
|
status = body.get('status') |
||||
|
download_url = body.get('url') |
||||
|
|
||||
|
logger.info(f"OnlyOffice callback para matéria legislativa {pk}: status={status}, url={download_url}") |
||||
|
|
||||
|
# Status 2 ou 6 significa que o documento foi salvo |
||||
|
if status in [2, 6] and download_url: |
||||
|
# Substitui localhost:8001 por onlyoffice:80 para acesso interno Docker |
||||
|
if 'localhost:8001' in download_url: |
||||
|
download_url = download_url.replace('localhost:8001', 'onlyoffice:80') |
||||
|
|
||||
|
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
||||
|
|
||||
|
# Baixa o documento do OnlyOffice |
||||
|
import requests |
||||
|
try: |
||||
|
response = requests.get(download_url, timeout=30) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao fazer requisição de download: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
if response.status_code == 200: |
||||
|
from django.core.files.base import ContentFile |
||||
|
|
||||
|
filename = f"materia_{pk}_{int(time.time())}.docx" |
||||
|
|
||||
|
# Remove arquivo antigo se existir |
||||
|
if materia.texto_original: |
||||
|
materia.texto_original.delete(save=False) |
||||
|
|
||||
|
try: |
||||
|
materia.texto_original.save( |
||||
|
filename, |
||||
|
ContentFile(response.content), |
||||
|
save=True |
||||
|
) |
||||
|
logger.info(f"Documento salvo com sucesso: {filename}") |
||||
|
return JsonResponse({"error": 0}) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao salvar arquivo: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
else: |
||||
|
logger.error(f"Erro ao baixar documento: status={response.status_code}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
return JsonResponse({"error": 0}) |
||||
|
|
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro no callback OnlyOffice: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
|
||||
|
@login_required |
||||
|
def materia_onlyoffice_editor(request, pk): |
||||
|
""" |
||||
|
Renderiza a página com o editor OnlyOffice integrado |
||||
|
""" |
||||
|
materia = get_object_or_404(MateriaLegislativa, pk=pk) |
||||
|
|
||||
|
# Verifica se o usuário tem permissão |
||||
|
if not request.user.has_perm('materia.change_materialegislativa'): |
||||
|
messages.error(request, 'Você não tem permissão para editar esta matéria.') |
||||
|
return redirect('sapl.materia:materialegislativa_detail', pk=pk) |
||||
|
|
||||
|
# URL do OnlyOffice acessível pelo navegador do usuário |
||||
|
onlyoffice_url = settings.ONLYOFFICE_URL |
||||
|
if 'onlyoffice:' in onlyoffice_url or 'onlyoffice/' in onlyoffice_url: |
||||
|
protocol = 'https' if request.is_secure() else 'http' |
||||
|
host = request.get_host().split(':')[0] |
||||
|
onlyoffice_url = f"{protocol}://{host}:8001" |
||||
|
|
||||
|
context = { |
||||
|
'documento': materia, |
||||
|
'documento_tipo': 'Matéria Legislativa', |
||||
|
'documento_titulo': f'{materia.tipo} {materia.numero}/{materia.ano}', |
||||
|
'documento_descricao': materia.ementa, |
||||
|
'onlyoffice_url': onlyoffice_url, |
||||
|
'config_url': reverse('sapl.materia:materia_onlyoffice_config', kwargs={'pk': pk}), |
||||
|
'voltar_url': reverse('sapl.materia:materialegislativa_detail', kwargs={'pk': pk}), |
||||
|
} |
||||
|
|
||||
|
return render(request, 'onlyoffice/onlyoffice_editor.html', context) |
||||
|
|
||||
|
|
||||
|
# ============================================================ |
||||
|
# Views para Documento Acessório |
||||
|
# ============================================================ |
||||
|
|
||||
|
@login_required |
||||
|
@require_http_methods(["GET"]) |
||||
|
def docacessorio_onlyoffice_config(request, pk): |
||||
|
""" |
||||
|
Retorna a configuração JSON para inicializar o editor OnlyOffice |
||||
|
""" |
||||
|
documento = get_object_or_404(DocumentoAcessorio, pk=pk) |
||||
|
|
||||
|
# Verifica permissão de edição |
||||
|
can_edit = request.user.has_perm('materia.change_documentoacessorio') |
||||
|
|
||||
|
# URLs para o OnlyOffice acessar (dentro da rede Docker) |
||||
|
download_url = request.build_absolute_uri( |
||||
|
reverse('sapl.materia:docacessorio_onlyoffice_download', kwargs={'pk': pk}) |
||||
|
) |
||||
|
callback_url = request.build_absolute_uri( |
||||
|
reverse('sapl.materia:docacessorio_onlyoffice_callback', kwargs={'pk': pk}) |
||||
|
) |
||||
|
|
||||
|
# Substituir localhost/host externo pelo nome do container na rede Docker |
||||
|
host = request.get_host() |
||||
|
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
|
||||
|
# Configuração do documento |
||||
|
document_config = { |
||||
|
"fileType": "docx", |
||||
|
"key": generate_file_key("docacessorio", documento.pk, request.user.pk), |
||||
|
"title": f"DocAcessorio_{documento.pk}.docx", |
||||
|
"url": download_url, |
||||
|
} |
||||
|
|
||||
|
# Configuração do editor |
||||
|
editor_config = { |
||||
|
"mode": "edit" if can_edit else "view", |
||||
|
"lang": "pt-BR", |
||||
|
"callbackUrl": callback_url, |
||||
|
"user": { |
||||
|
"id": str(request.user.pk), |
||||
|
"name": request.user.get_full_name() or request.user.username, |
||||
|
}, |
||||
|
"customization": { |
||||
|
"autosave": True, |
||||
|
"forcesave": True, |
||||
|
"comments": True, |
||||
|
"chat": False, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
config = { |
||||
|
"documentType": "word", |
||||
|
"document": document_config, |
||||
|
"editorConfig": editor_config, |
||||
|
"height": "600px", |
||||
|
"width": "100%", |
||||
|
} |
||||
|
|
||||
|
# Adiciona JWT se estiver habilitado |
||||
|
if settings.ONLYOFFICE_JWT_ENABLED and settings.ONLYOFFICE_JWT_SECRET: |
||||
|
import jwt |
||||
|
token = jwt.encode(config, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') |
||||
|
config['token'] = token |
||||
|
|
||||
|
return JsonResponse(config) |
||||
|
|
||||
|
|
||||
|
@require_http_methods(["GET"]) |
||||
|
def docacessorio_onlyoffice_download(request, pk): |
||||
|
""" |
||||
|
Endpoint para o OnlyOffice baixar o documento |
||||
|
Se não existe arquivo, retorna um documento em branco |
||||
|
""" |
||||
|
documento = get_object_or_404(DocumentoAcessorio, pk=pk) |
||||
|
|
||||
|
# Se já tem arquivo, retorna ele |
||||
|
if documento.arquivo: |
||||
|
try: |
||||
|
with open(documento.arquivo.path, 'rb') as f: |
||||
|
content = f.read() |
||||
|
response = HttpResponse(content, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document') |
||||
|
response['Content-Disposition'] = f'attachment; filename="DocAcessorio_{pk}.docx"' |
||||
|
return response |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao ler arquivo: {e}") |
||||
|
|
||||
|
# Se não tem arquivo, cria um documento em branco usando python-docx |
||||
|
try: |
||||
|
from docx import Document |
||||
|
from io import BytesIO |
||||
|
|
||||
|
doc = Document() |
||||
|
doc.add_heading(f'{documento.tipo} - {documento.nome}', 0) |
||||
|
if documento.ementa: |
||||
|
doc.add_paragraph(f'Ementa: {documento.ementa}') |
||||
|
doc.add_paragraph(f'Matéria: {documento.materia}') |
||||
|
doc.add_paragraph('') |
||||
|
doc.add_paragraph('Digite o texto do documento abaixo:') |
||||
|
doc.add_paragraph('') |
||||
|
|
||||
|
file_stream = BytesIO() |
||||
|
doc.save(file_stream) |
||||
|
file_stream.seek(0) |
||||
|
|
||||
|
response = HttpResponse( |
||||
|
file_stream.getvalue(), |
||||
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
||||
|
) |
||||
|
response['Content-Disposition'] = f'attachment; filename="DocAcessorio_{pk}.docx"' |
||||
|
return response |
||||
|
|
||||
|
except ImportError: |
||||
|
logger.error("python-docx não está instalado") |
||||
|
return HttpResponse("Erro: python-docx não instalado", status=500) |
||||
|
|
||||
|
|
||||
|
@csrf_exempt |
||||
|
@require_http_methods(["POST"]) |
||||
|
def docacessorio_onlyoffice_callback(request, pk): |
||||
|
""" |
||||
|
Callback chamado pelo OnlyOffice quando o documento é salvo |
||||
|
""" |
||||
|
try: |
||||
|
body = json.loads(request.body.decode('utf-8')) |
||||
|
status = body.get('status') |
||||
|
download_url = body.get('url') |
||||
|
|
||||
|
logger.info(f"OnlyOffice callback para documento acessório {pk}: status={status}, url={download_url}") |
||||
|
|
||||
|
# Status 2 ou 6 significa que o documento foi salvo |
||||
|
if status in [2, 6] and download_url: |
||||
|
# Substitui localhost:8001 por onlyoffice:80 para acesso interno Docker |
||||
|
if 'localhost:8001' in download_url: |
||||
|
download_url = download_url.replace('localhost:8001', 'onlyoffice:80') |
||||
|
|
||||
|
documento = get_object_or_404(DocumentoAcessorio, pk=pk) |
||||
|
|
||||
|
# Baixa o documento do OnlyOffice |
||||
|
import requests |
||||
|
try: |
||||
|
response = requests.get(download_url, timeout=30) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao fazer requisição de download: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
if response.status_code == 200: |
||||
|
from django.core.files.base import ContentFile |
||||
|
|
||||
|
filename = f"docacessorio_{pk}_{int(time.time())}.docx" |
||||
|
|
||||
|
# Remove arquivo antigo se existir |
||||
|
if documento.arquivo: |
||||
|
documento.arquivo.delete(save=False) |
||||
|
|
||||
|
try: |
||||
|
documento.arquivo.save( |
||||
|
filename, |
||||
|
ContentFile(response.content), |
||||
|
save=True |
||||
|
) |
||||
|
logger.info(f"Documento salvo com sucesso: {filename}") |
||||
|
return JsonResponse({"error": 0}) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao salvar arquivo: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
else: |
||||
|
logger.error(f"Erro ao baixar documento: status={response.status_code}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
return JsonResponse({"error": 0}) |
||||
|
|
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro no callback OnlyOffice: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
|
||||
|
@login_required |
||||
|
def docacessorio_onlyoffice_editor(request, pk): |
||||
|
""" |
||||
|
Renderiza a página com o editor OnlyOffice integrado |
||||
|
""" |
||||
|
documento = get_object_or_404(DocumentoAcessorio, pk=pk) |
||||
|
|
||||
|
# Verifica se o usuário tem permissão |
||||
|
if not request.user.has_perm('materia.change_documentoacessorio'): |
||||
|
messages.error(request, 'Você não tem permissão para editar este documento.') |
||||
|
return redirect('sapl.materia:documentoacessorio_detail', pk=documento.materia.pk, zpk=pk) |
||||
|
|
||||
|
# URL do OnlyOffice acessível pelo navegador do usuário |
||||
|
onlyoffice_url = settings.ONLYOFFICE_URL |
||||
|
if 'onlyoffice:' in onlyoffice_url or 'onlyoffice/' in onlyoffice_url: |
||||
|
protocol = 'https' if request.is_secure() else 'http' |
||||
|
host = request.get_host().split(':')[0] |
||||
|
onlyoffice_url = f"{protocol}://{host}:8001" |
||||
|
|
||||
|
context = { |
||||
|
'documento': documento, |
||||
|
'documento_tipo': 'Documento Acessório', |
||||
|
'documento_titulo': f'{documento.tipo} - {documento.nome}', |
||||
|
'documento_descricao': documento.ementa or f'Matéria: {documento.materia}', |
||||
|
'onlyoffice_url': onlyoffice_url, |
||||
|
'config_url': reverse('sapl.materia:docacessorio_onlyoffice_config', kwargs={'pk': pk}), |
||||
|
'voltar_url': reverse('sapl.materia:documentoacessorio_detail', kwargs={'pk': documento.materia.pk, 'zpk': pk}), |
||||
|
} |
||||
|
|
||||
|
return render(request, 'onlyoffice/onlyoffice_editor.html', context) |
||||
@ -0,0 +1,237 @@ |
|||||
|
""" |
||||
|
Views e utilitários para integração com OnlyOffice Document Server |
||||
|
para Norma Jurídica |
||||
|
""" |
||||
|
import hashlib |
||||
|
import json |
||||
|
import logging |
||||
|
import time |
||||
|
from django.conf import settings |
||||
|
from django.contrib.auth.decorators import login_required |
||||
|
from django.http import JsonResponse, HttpResponse |
||||
|
from django.shortcuts import get_object_or_404, render, redirect |
||||
|
from django.urls import reverse |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from django.views.decorators.http import require_http_methods |
||||
|
from django.contrib import messages |
||||
|
|
||||
|
from sapl.norma.models import NormaJuridica |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
def generate_file_key(norma_id, user_id): |
||||
|
""" |
||||
|
Gera uma chave única para o documento no OnlyOffice |
||||
|
A chave muda a cada edição para forçar o OnlyOffice a recarregar |
||||
|
""" |
||||
|
timestamp = str(int(time.time())) |
||||
|
string_to_hash = f"norma_{norma_id}_user_{user_id}_{timestamp}" |
||||
|
return hashlib.md5(string_to_hash.encode()).hexdigest() |
||||
|
|
||||
|
|
||||
|
@login_required |
||||
|
@require_http_methods(["GET"]) |
||||
|
def norma_onlyoffice_config(request, pk): |
||||
|
""" |
||||
|
Retorna a configuração JSON para inicializar o editor OnlyOffice |
||||
|
""" |
||||
|
norma = get_object_or_404(NormaJuridica, pk=pk) |
||||
|
|
||||
|
# Verifica permissão de edição |
||||
|
can_edit = request.user.has_perm('norma.change_normajuridica') |
||||
|
|
||||
|
# URLs para o OnlyOffice acessar (dentro da rede Docker) |
||||
|
download_url = request.build_absolute_uri( |
||||
|
reverse('sapl.norma:norma_onlyoffice_download', kwargs={'pk': pk}) |
||||
|
) |
||||
|
callback_url = request.build_absolute_uri( |
||||
|
reverse('sapl.norma:norma_onlyoffice_callback', kwargs={'pk': pk}) |
||||
|
) |
||||
|
|
||||
|
# Substituir localhost/host externo pelo nome do container na rede Docker |
||||
|
host = request.get_host() |
||||
|
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
|
||||
|
# Configuração do documento |
||||
|
document_config = { |
||||
|
"fileType": "docx", |
||||
|
"key": generate_file_key(norma.pk, request.user.pk), |
||||
|
"title": f"Norma_{norma.pk}.docx", |
||||
|
"url": download_url, |
||||
|
} |
||||
|
|
||||
|
# Configuração do editor |
||||
|
editor_config = { |
||||
|
"mode": "edit" if can_edit else "view", |
||||
|
"lang": "pt-BR", |
||||
|
"callbackUrl": callback_url, |
||||
|
"user": { |
||||
|
"id": str(request.user.pk), |
||||
|
"name": request.user.get_full_name() or request.user.username, |
||||
|
}, |
||||
|
"customization": { |
||||
|
"autosave": True, |
||||
|
"forcesave": True, |
||||
|
"comments": True, |
||||
|
"chat": False, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
config = { |
||||
|
"documentType": "word", |
||||
|
"document": document_config, |
||||
|
"editorConfig": editor_config, |
||||
|
"height": "600px", |
||||
|
"width": "100%", |
||||
|
} |
||||
|
|
||||
|
# Adiciona JWT se estiver habilitado |
||||
|
if settings.ONLYOFFICE_JWT_ENABLED and settings.ONLYOFFICE_JWT_SECRET: |
||||
|
import jwt |
||||
|
token = jwt.encode(config, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') |
||||
|
config['token'] = token |
||||
|
|
||||
|
return JsonResponse(config) |
||||
|
|
||||
|
|
||||
|
@require_http_methods(["GET"]) |
||||
|
def norma_onlyoffice_download(request, pk): |
||||
|
""" |
||||
|
Endpoint para o OnlyOffice baixar o documento |
||||
|
Se não existe arquivo, retorna um documento em branco |
||||
|
""" |
||||
|
norma = get_object_or_404(NormaJuridica, pk=pk) |
||||
|
|
||||
|
# Se já tem arquivo, retorna ele |
||||
|
if norma.texto_integral: |
||||
|
try: |
||||
|
with open(norma.texto_integral.path, 'rb') as f: |
||||
|
content = f.read() |
||||
|
response = HttpResponse(content, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document') |
||||
|
response['Content-Disposition'] = f'attachment; filename="Norma_{pk}.docx"' |
||||
|
return response |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao ler arquivo: {e}") |
||||
|
|
||||
|
# Se não tem arquivo, cria um documento em branco usando python-docx |
||||
|
try: |
||||
|
from docx import Document |
||||
|
from io import BytesIO |
||||
|
|
||||
|
doc = Document() |
||||
|
doc.add_heading(f'{norma.tipo} {norma.numero}/{norma.ano}', 0) |
||||
|
doc.add_paragraph(f'Ementa: {norma.ementa}') |
||||
|
doc.add_paragraph('') |
||||
|
doc.add_paragraph('Digite o texto da norma abaixo:') |
||||
|
doc.add_paragraph('') |
||||
|
|
||||
|
file_stream = BytesIO() |
||||
|
doc.save(file_stream) |
||||
|
file_stream.seek(0) |
||||
|
|
||||
|
response = HttpResponse( |
||||
|
file_stream.getvalue(), |
||||
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
||||
|
) |
||||
|
response['Content-Disposition'] = f'attachment; filename="Norma_{pk}.docx"' |
||||
|
return response |
||||
|
|
||||
|
except ImportError: |
||||
|
logger.error("python-docx não está instalado") |
||||
|
return HttpResponse("Erro: python-docx não instalado", status=500) |
||||
|
|
||||
|
|
||||
|
@csrf_exempt |
||||
|
@require_http_methods(["POST"]) |
||||
|
def norma_onlyoffice_callback(request, pk): |
||||
|
""" |
||||
|
Callback chamado pelo OnlyOffice quando o documento é salvo |
||||
|
""" |
||||
|
try: |
||||
|
body = json.loads(request.body.decode('utf-8')) |
||||
|
status = body.get('status') |
||||
|
download_url = body.get('url') |
||||
|
|
||||
|
logger.info(f"OnlyOffice callback para norma jurídica {pk}: status={status}, url={download_url}") |
||||
|
|
||||
|
# Status 2 ou 6 significa que o documento foi salvo |
||||
|
if status in [2, 6] and download_url: |
||||
|
# Substitui localhost:8001 por onlyoffice:80 para acesso interno Docker |
||||
|
if 'localhost:8001' in download_url: |
||||
|
download_url = download_url.replace('localhost:8001', 'onlyoffice:80') |
||||
|
|
||||
|
norma = get_object_or_404(NormaJuridica, pk=pk) |
||||
|
|
||||
|
# Baixa o documento do OnlyOffice |
||||
|
import requests |
||||
|
try: |
||||
|
response = requests.get(download_url, timeout=30) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao fazer requisição de download: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
if response.status_code == 200: |
||||
|
from django.core.files.base import ContentFile |
||||
|
|
||||
|
filename = f"norma_{pk}_{int(time.time())}.docx" |
||||
|
|
||||
|
# Remove arquivo antigo se existir |
||||
|
if norma.texto_integral: |
||||
|
norma.texto_integral.delete(save=False) |
||||
|
|
||||
|
try: |
||||
|
norma.texto_integral.save( |
||||
|
filename, |
||||
|
ContentFile(response.content), |
||||
|
save=True |
||||
|
) |
||||
|
logger.info(f"Documento salvo com sucesso: {filename}") |
||||
|
return JsonResponse({"error": 0}) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao salvar arquivo: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
else: |
||||
|
logger.error(f"Erro ao baixar documento: status={response.status_code}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
return JsonResponse({"error": 0}) |
||||
|
|
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro no callback OnlyOffice: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
|
||||
|
@login_required |
||||
|
def norma_onlyoffice_editor(request, pk): |
||||
|
""" |
||||
|
Renderiza a página com o editor OnlyOffice integrado |
||||
|
""" |
||||
|
norma = get_object_or_404(NormaJuridica, pk=pk) |
||||
|
|
||||
|
# Verifica se o usuário tem permissão |
||||
|
if not request.user.has_perm('norma.change_normajuridica'): |
||||
|
messages.error(request, 'Você não tem permissão para editar esta norma.') |
||||
|
return redirect('sapl.norma:normajuridica_detail', pk=pk) |
||||
|
|
||||
|
# URL do OnlyOffice acessível pelo navegador do usuário |
||||
|
onlyoffice_url = settings.ONLYOFFICE_URL |
||||
|
if 'onlyoffice:' in onlyoffice_url or 'onlyoffice/' in onlyoffice_url: |
||||
|
protocol = 'https' if request.is_secure() else 'http' |
||||
|
host = request.get_host().split(':')[0] |
||||
|
onlyoffice_url = f"{protocol}://{host}:8001" |
||||
|
|
||||
|
context = { |
||||
|
'documento': norma, |
||||
|
'documento_tipo': 'Norma Jurídica', |
||||
|
'documento_titulo': f'{norma.tipo} {norma.numero}/{norma.ano}', |
||||
|
'documento_descricao': norma.ementa, |
||||
|
'onlyoffice_url': onlyoffice_url, |
||||
|
'config_url': reverse('sapl.norma:norma_onlyoffice_config', kwargs={'pk': pk}), |
||||
|
'voltar_url': reverse('sapl.norma:normajuridica_detail', kwargs={'pk': pk}), |
||||
|
} |
||||
|
|
||||
|
return render(request, 'onlyoffice/onlyoffice_editor.html', context) |
||||
@ -0,0 +1,238 @@ |
|||||
|
""" |
||||
|
Views e utilitários para integração com OnlyOffice Document Server |
||||
|
para Documento Administrativo |
||||
|
""" |
||||
|
import hashlib |
||||
|
import json |
||||
|
import logging |
||||
|
import time |
||||
|
from django.conf import settings |
||||
|
from django.contrib.auth.decorators import login_required |
||||
|
from django.http import JsonResponse, HttpResponse |
||||
|
from django.shortcuts import get_object_or_404, render, redirect |
||||
|
from django.urls import reverse |
||||
|
from django.views.decorators.csrf import csrf_exempt |
||||
|
from django.views.decorators.http import require_http_methods |
||||
|
from django.contrib import messages |
||||
|
|
||||
|
from sapl.protocoloadm.models import DocumentoAdministrativo |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
def generate_file_key(doc_id, user_id): |
||||
|
""" |
||||
|
Gera uma chave única para o documento no OnlyOffice |
||||
|
A chave muda a cada edição para forçar o OnlyOffice a recarregar |
||||
|
""" |
||||
|
timestamp = str(int(time.time())) |
||||
|
string_to_hash = f"docadm_{doc_id}_user_{user_id}_{timestamp}" |
||||
|
return hashlib.md5(string_to_hash.encode()).hexdigest() |
||||
|
|
||||
|
|
||||
|
@login_required |
||||
|
@require_http_methods(["GET"]) |
||||
|
def docadm_onlyoffice_config(request, pk): |
||||
|
""" |
||||
|
Retorna a configuração JSON para inicializar o editor OnlyOffice |
||||
|
""" |
||||
|
documento = get_object_or_404(DocumentoAdministrativo, pk=pk) |
||||
|
|
||||
|
# Verifica permissão de edição |
||||
|
can_edit = request.user.has_perm('protocoloadm.change_documentoadministrativo') |
||||
|
|
||||
|
# URLs para o OnlyOffice acessar (dentro da rede Docker) |
||||
|
download_url = request.build_absolute_uri( |
||||
|
reverse('sapl.protocoloadm:docadm_onlyoffice_download', kwargs={'pk': pk}) |
||||
|
) |
||||
|
callback_url = request.build_absolute_uri( |
||||
|
reverse('sapl.protocoloadm:docadm_onlyoffice_callback', kwargs={'pk': pk}) |
||||
|
) |
||||
|
|
||||
|
# Substituir localhost/host externo pelo nome do container na rede Docker |
||||
|
host = request.get_host() |
||||
|
download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'http://{host}', 'http://sapl-dev:8000') |
||||
|
callback_url = callback_url.replace(f'https://{host}', 'http://sapl-dev:8000') |
||||
|
|
||||
|
# Configuração do documento |
||||
|
document_config = { |
||||
|
"fileType": "docx", |
||||
|
"key": generate_file_key(documento.pk, request.user.pk), |
||||
|
"title": f"DocAdm_{documento.pk}.docx", |
||||
|
"url": download_url, |
||||
|
} |
||||
|
|
||||
|
# Configuração do editor |
||||
|
editor_config = { |
||||
|
"mode": "edit" if can_edit else "view", |
||||
|
"lang": "pt-BR", |
||||
|
"callbackUrl": callback_url, |
||||
|
"user": { |
||||
|
"id": str(request.user.pk), |
||||
|
"name": request.user.get_full_name() or request.user.username, |
||||
|
}, |
||||
|
"customization": { |
||||
|
"autosave": True, |
||||
|
"forcesave": True, |
||||
|
"comments": True, |
||||
|
"chat": False, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
config = { |
||||
|
"documentType": "word", |
||||
|
"document": document_config, |
||||
|
"editorConfig": editor_config, |
||||
|
"height": "600px", |
||||
|
"width": "100%", |
||||
|
} |
||||
|
|
||||
|
# Adiciona JWT se estiver habilitado |
||||
|
if settings.ONLYOFFICE_JWT_ENABLED and settings.ONLYOFFICE_JWT_SECRET: |
||||
|
import jwt |
||||
|
token = jwt.encode(config, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') |
||||
|
config['token'] = token |
||||
|
|
||||
|
return JsonResponse(config) |
||||
|
|
||||
|
|
||||
|
@require_http_methods(["GET"]) |
||||
|
def docadm_onlyoffice_download(request, pk): |
||||
|
""" |
||||
|
Endpoint para o OnlyOffice baixar o documento |
||||
|
Se não existe arquivo, retorna um documento em branco |
||||
|
""" |
||||
|
documento = get_object_or_404(DocumentoAdministrativo, pk=pk) |
||||
|
|
||||
|
# Se já tem arquivo, retorna ele |
||||
|
if documento.texto_integral: |
||||
|
try: |
||||
|
with open(documento.texto_integral.path, 'rb') as f: |
||||
|
content = f.read() |
||||
|
response = HttpResponse(content, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document') |
||||
|
response['Content-Disposition'] = f'attachment; filename="DocAdm_{pk}.docx"' |
||||
|
return response |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao ler arquivo: {e}") |
||||
|
|
||||
|
# Se não tem arquivo, cria um documento em branco usando python-docx |
||||
|
try: |
||||
|
from docx import Document |
||||
|
from io import BytesIO |
||||
|
|
||||
|
doc = Document() |
||||
|
doc.add_heading(f'Documento Administrativo {documento.tipo}', 0) |
||||
|
doc.add_paragraph(f'Número: {documento.numero}/{documento.ano}') |
||||
|
doc.add_paragraph(f'Assunto: {documento.assunto}') |
||||
|
doc.add_paragraph('') |
||||
|
doc.add_paragraph('Digite o texto do documento abaixo:') |
||||
|
doc.add_paragraph('') |
||||
|
|
||||
|
file_stream = BytesIO() |
||||
|
doc.save(file_stream) |
||||
|
file_stream.seek(0) |
||||
|
|
||||
|
response = HttpResponse( |
||||
|
file_stream.getvalue(), |
||||
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
||||
|
) |
||||
|
response['Content-Disposition'] = f'attachment; filename="DocAdm_{pk}.docx"' |
||||
|
return response |
||||
|
|
||||
|
except ImportError: |
||||
|
logger.error("python-docx não está instalado") |
||||
|
return HttpResponse("Erro: python-docx não instalado", status=500) |
||||
|
|
||||
|
|
||||
|
@csrf_exempt |
||||
|
@require_http_methods(["POST"]) |
||||
|
def docadm_onlyoffice_callback(request, pk): |
||||
|
""" |
||||
|
Callback chamado pelo OnlyOffice quando o documento é salvo |
||||
|
""" |
||||
|
try: |
||||
|
body = json.loads(request.body.decode('utf-8')) |
||||
|
status = body.get('status') |
||||
|
download_url = body.get('url') |
||||
|
|
||||
|
logger.info(f"OnlyOffice callback para documento administrativo {pk}: status={status}, url={download_url}") |
||||
|
|
||||
|
# Status 2 ou 6 significa que o documento foi salvo |
||||
|
if status in [2, 6] and download_url: |
||||
|
# Substitui localhost:8001 por onlyoffice:80 para acesso interno Docker |
||||
|
if 'localhost:8001' in download_url: |
||||
|
download_url = download_url.replace('localhost:8001', 'onlyoffice:80') |
||||
|
|
||||
|
documento = get_object_or_404(DocumentoAdministrativo, pk=pk) |
||||
|
|
||||
|
# Baixa o documento do OnlyOffice |
||||
|
import requests |
||||
|
try: |
||||
|
response = requests.get(download_url, timeout=30) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao fazer requisição de download: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
if response.status_code == 200: |
||||
|
from django.core.files.base import ContentFile |
||||
|
|
||||
|
filename = f"docadm_{pk}_{int(time.time())}.docx" |
||||
|
|
||||
|
# Remove arquivo antigo se existir |
||||
|
if documento.texto_integral: |
||||
|
documento.texto_integral.delete(save=False) |
||||
|
|
||||
|
try: |
||||
|
documento.texto_integral.save( |
||||
|
filename, |
||||
|
ContentFile(response.content), |
||||
|
save=True |
||||
|
) |
||||
|
logger.info(f"Documento salvo com sucesso: {filename}") |
||||
|
return JsonResponse({"error": 0}) |
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro ao salvar arquivo: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
else: |
||||
|
logger.error(f"Erro ao baixar documento: status={response.status_code}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
return JsonResponse({"error": 0}) |
||||
|
|
||||
|
except Exception as e: |
||||
|
logger.error(f"Erro no callback OnlyOffice: {e}") |
||||
|
return JsonResponse({"error": 1}) |
||||
|
|
||||
|
|
||||
|
@login_required |
||||
|
def docadm_onlyoffice_editor(request, pk): |
||||
|
""" |
||||
|
Renderiza a página com o editor OnlyOffice integrado |
||||
|
""" |
||||
|
documento = get_object_or_404(DocumentoAdministrativo, pk=pk) |
||||
|
|
||||
|
# Verifica se o usuário tem permissão |
||||
|
if not request.user.has_perm('protocoloadm.change_documentoadministrativo'): |
||||
|
messages.error(request, 'Você não tem permissão para editar este documento.') |
||||
|
return redirect('sapl.protocoloadm:documentoadministrativo_detail', pk=pk) |
||||
|
|
||||
|
# URL do OnlyOffice acessível pelo navegador do usuário |
||||
|
onlyoffice_url = settings.ONLYOFFICE_URL |
||||
|
if 'onlyoffice:' in onlyoffice_url or 'onlyoffice/' in onlyoffice_url: |
||||
|
protocol = 'https' if request.is_secure() else 'http' |
||||
|
host = request.get_host().split(':')[0] |
||||
|
onlyoffice_url = f"{protocol}://{host}:8001" |
||||
|
|
||||
|
context = { |
||||
|
'documento': documento, |
||||
|
'documento_tipo': 'Documento Administrativo', |
||||
|
'documento_titulo': f'{documento.tipo} {documento.numero}/{documento.ano}', |
||||
|
'documento_descricao': documento.assunto, |
||||
|
'onlyoffice_url': onlyoffice_url, |
||||
|
'config_url': reverse('sapl.protocoloadm:docadm_onlyoffice_config', kwargs={'pk': pk}), |
||||
|
'voltar_url': reverse('sapl.protocoloadm:documentoadministrativo_detail', kwargs={'pk': pk}), |
||||
|
} |
||||
|
|
||||
|
return render(request, 'onlyoffice/onlyoffice_editor.html', context) |
||||
@ -0,0 +1,13 @@ |
|||||
|
{% extends "crud/detail.html" %} |
||||
|
{% load i18n %} |
||||
|
|
||||
|
{% block sub_actions %} |
||||
|
{{ block.super }} |
||||
|
{% if perms.materia.change_documentoacessorio %} |
||||
|
<div class="actions btn-group btn-group-sm" role="group"> |
||||
|
<a class="btn btn-primary" href="{% url 'sapl.materia:docacessorio_onlyoffice_editor' object.pk %}"> |
||||
|
<i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %} |
||||
|
</a> |
||||
|
</div> |
||||
|
{% endif %} |
||||
|
{% endblock sub_actions %} |
||||
@ -1,68 +1,334 @@ |
|||||
{% extends "crud/form.html" %} |
{% extends "crud/form.html" %} |
||||
{% load i18n %} |
{% load i18n %} |
||||
{% load crispy_forms_tags %} |
{% load crispy_forms_tags %} |
||||
|
|
||||
{% block extra_js %} |
{% block extra_js %} |
||||
<script type="text/javascript"> |
<style> |
||||
|
/* ===================================================== |
||||
|
Estilo para o container do tipo de proposição |
||||
|
===================================================== */ |
||||
|
#div_id_tipo { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
$(document).ready(function(){ |
#div_id_tipo .tipo-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
$("input[name=tipo_texto]").change(function(event) { |
#div_id_tipo .btn-novo-tipo { |
||||
if (this.value == 'D' && this.checked) { |
white-space: nowrap; |
||||
$("#div_id_texto_original").removeClass('hidden'); |
padding: 6px 12px; |
||||
$("#onlyoffice-info").addClass('hidden'); |
font-size: 13px; |
||||
} |
} |
||||
else if (this.value == 'T' && this.checked) { |
|
||||
$("#div_id_texto_original").addClass('hidden'); |
/* ===================================================== |
||||
$("#onlyoffice-info").addClass('hidden'); |
Estilo para os radio buttons de tipo de texto |
||||
} |
===================================================== */ |
||||
else if (this.value == 'O' && this.checked) { |
#div_id_tipo_texto { |
||||
$("#div_id_texto_original").addClass('hidden'); |
background: #f8f9fa; |
||||
$("#onlyoffice-info").removeClass('hidden'); |
padding: 20px; |
||||
|
border-radius: 8px; |
||||
|
border: 1px solid #e9ecef; |
||||
|
margin-bottom: 15px; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto > label.col-form-label, |
||||
|
#div_id_tipo_texto > label:first-child { |
||||
|
font-weight: 600; |
||||
|
color: #495057; |
||||
|
margin-bottom: 15px; |
||||
|
display: block; |
||||
|
font-size: 1em; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto .form-check { |
||||
|
display: flex; |
||||
|
gap: 15px; |
||||
|
flex-wrap: wrap; |
||||
|
padding: 0; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto .form-check label, |
||||
|
#div_id_tipo_texto .form-check-inline { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
padding: 14px 20px; |
||||
|
background: white; |
||||
|
border: 2px solid #dee2e6; |
||||
|
border-radius: 8px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.2s ease; |
||||
|
margin: 0 !important; |
||||
|
font-weight: 500; |
||||
|
color: #495057; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto .form-check label:hover, |
||||
|
#div_id_tipo_texto .form-check-inline:hover { |
||||
|
border-color: #007bff; |
||||
|
background: #f0f7ff; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto .form-check label.checked, |
||||
|
#div_id_tipo_texto .form-check-inline.checked { |
||||
|
border-color: #007bff; |
||||
|
background: #e7f1ff; |
||||
|
box-shadow: 0 0 0 1px #007bff; |
||||
|
color: #0056b3; |
||||
|
} |
||||
|
|
||||
|
#div_id_tipo_texto input[type="radio"] { |
||||
|
margin-right: 10px; |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
accent-color: #007bff; |
||||
|
} |
||||
|
|
||||
|
/* ===================================================== |
||||
|
Estilo para o alerta do OnlyOffice |
||||
|
===================================================== */ |
||||
|
#onlyoffice-info { |
||||
|
border-left: 4px solid #17a2b8; |
||||
|
background: linear-gradient(135deg, #e8f4f8 0%, #f8f9fa 100%); |
||||
|
border-radius: 8px; |
||||
|
padding: 20px; |
||||
|
margin-top: 15px; |
||||
|
} |
||||
|
|
||||
|
#onlyoffice-info h5 { |
||||
|
color: #0c5460; |
||||
|
margin-bottom: 12px; |
||||
|
font-size: 1.1em; |
||||
|
} |
||||
|
|
||||
|
#onlyoffice-info .fa-file-word-o { |
||||
|
margin-right: 8px; |
||||
|
color: #2b579a; |
||||
|
} |
||||
|
|
||||
|
#onlyoffice-info p { |
||||
|
color: #495057; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
/* ===================================================== |
||||
|
Estilo para o campo de upload |
||||
|
===================================================== */ |
||||
|
#div_id_texto_original { |
||||
|
background: #fff; |
||||
|
padding: 20px; |
||||
|
border-radius: 8px; |
||||
|
border: 2px dashed #dee2e6; |
||||
|
transition: all 0.2s ease; |
||||
|
} |
||||
|
|
||||
|
#div_id_texto_original:hover { |
||||
|
border-color: #007bff; |
||||
|
background: #f8f9ff; |
||||
|
} |
||||
|
|
||||
|
#div_id_texto_original label { |
||||
|
font-weight: 600; |
||||
|
color: #495057; |
||||
|
} |
||||
|
|
||||
|
/* ===================================================== |
||||
|
Fieldset de vinculação |
||||
|
===================================================== */ |
||||
|
fieldset.card { |
||||
|
margin-top: 25px; |
||||
|
border: 1px solid #e9ecef; |
||||
|
border-radius: 8px; |
||||
|
} |
||||
|
|
||||
|
fieldset.card legend { |
||||
|
padding: 0 10px; |
||||
|
font-size: 1em; |
||||
|
} |
||||
|
|
||||
|
/* ===================================================== |
||||
|
Alerta de ementa da matéria |
||||
|
===================================================== */ |
||||
|
.ementa_materia { |
||||
|
margin-top: 15px; |
||||
|
font-size: 0.95em; |
||||
|
border-radius: 6px; |
||||
|
} |
||||
|
|
||||
|
/* ===================================================== |
||||
|
Botões do formulário (Cancelar / Salvar) |
||||
|
===================================================== */ |
||||
|
.form-group.row.justify-content-between { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding: 20px 0; |
||||
|
margin-top: 20px; |
||||
|
border-top: 1px solid #e9ecef; |
||||
|
} |
||||
|
|
||||
|
.form-group.row.justify-content-between .btn { |
||||
|
min-width: 120px; |
||||
|
} |
||||
|
|
||||
|
.form-group.row.justify-content-between .btn-dark { |
||||
|
background-color: #6c757d; |
||||
|
border-color: #6c757d; |
||||
|
} |
||||
|
|
||||
|
.form-group.row.justify-content-between .btn-dark:hover { |
||||
|
background-color: #5a6268; |
||||
|
border-color: #545b62; |
||||
|
} |
||||
|
|
||||
|
.form-group.row.justify-content-between .btn-primary { |
||||
|
padding: 8px 25px; |
||||
|
} |
||||
|
</style> |
||||
|
|
||||
|
<script type="text/javascript"> |
||||
|
$(document).ready(function(){ |
||||
|
|
||||
|
// ===================================================== |
||||
|
// Configuração do botão "Novo Tipo" |
||||
|
// ===================================================== |
||||
|
var $tipoDiv = $('#div_id_tipo'); |
||||
|
var $tipoLabel = $tipoDiv.find('label').first(); |
||||
|
var $tipoSelect = $("select[name=tipo]"); |
||||
|
|
||||
|
if ($tipoLabel.length > 0 && $tipoSelect.length > 0) { |
||||
|
// Criar header com label e botão |
||||
|
var $header = $('<div class="tipo-header"></div>'); |
||||
|
var $btnNovoTipo = $(` |
||||
|
<a href="/sistema/proposicao/tipo/create" |
||||
|
class="btn btn-success btn-sm btn-novo-tipo" |
||||
|
title="Cadastrar novo tipo de proposição" |
||||
|
target="_blank"> |
||||
|
<i class="fa fa-plus"></i> Novo Tipo |
||||
|
</a> |
||||
|
`); |
||||
|
|
||||
|
// Mover label para o header e adicionar botão |
||||
|
$tipoLabel.wrap($header); |
||||
|
$tipoLabel.after($btnNovoTipo); |
||||
|
} |
||||
|
|
||||
|
// ===================================================== |
||||
|
// Gerenciamento do tipo de texto |
||||
|
// ===================================================== |
||||
|
function updateTipoTextoDisplay() { |
||||
|
var tipoTextoValue = $("input[name=tipo_texto]:checked").val(); |
||||
|
|
||||
|
if (tipoTextoValue === 'D') { |
||||
|
// Arquivo Digital: mostrar campo de upload |
||||
|
$("#div_id_texto_original").slideDown(200); |
||||
|
$("#onlyoffice-info").slideUp(200); |
||||
|
} else if (tipoTextoValue === 'T') { |
||||
|
// Texto Articulado: esconder ambos |
||||
|
$("#div_id_texto_original").slideUp(200); |
||||
|
$("#onlyoffice-info").slideUp(200); |
||||
|
} else if (tipoTextoValue === 'O') { |
||||
|
// OnlyOffice: mostrar mensagem informativa |
||||
|
$("#div_id_texto_original").slideUp(200); |
||||
|
$("#onlyoffice-info").slideDown(200); |
||||
} |
} |
||||
}); |
|
||||
|
|
||||
// Adicionar botão ao lado do select de tipo |
// Atualizar visual dos labels |
||||
var $tipoSelect = $("select[name=tipo]"); |
$("#div_id_tipo_texto .form-check label, #div_id_tipo_texto .form-check-inline").removeClass('checked'); |
||||
if ($tipoSelect.length > 0) { |
$("input[name=tipo_texto]:checked").closest('label').addClass('checked'); |
||||
var $btnNovoTipo = $('<a href="/sistema/proposicao/tipo/create" class="btn btn-sm btn-success" style="margin-left: 10px;" title="Cadastrar novo tipo de proposição" target="_blank"><i class="fa fa-plus"></i> Novo Tipo</a>'); |
} |
||||
$tipoSelect.parent().append($btnNovoTipo); |
|
||||
} |
|
||||
|
|
||||
$("select[name=tipo]").change(function(event) { |
// Event listener para mudança de tipo de texto |
||||
|
$("input[name=tipo_texto]").change(function() { |
||||
|
updateTipoTextoDisplay(); |
||||
|
}); |
||||
|
|
||||
|
// ===================================================== |
||||
|
// Mostrar opções de tipo de texto |
||||
|
// ===================================================== |
||||
|
function showTipoTextoOptions() { |
||||
|
var $tipoTextoContainer = $("input[name=tipo_texto]").closest('.form-group').parent(); |
||||
|
$tipoTextoContainer.removeClass('hidden'); |
||||
|
|
||||
// Sempre mostrar as opções de tipo_texto quando texto_articulado_proposicao está habilitado |
|
||||
// Inclui suporte para OnlyOffice além de Arquivo Digital e Texto Articulado |
|
||||
$("input[name=tipo_texto]").closest('label').removeClass('disabled'); |
|
||||
$("input[name=tipo_texto]").closest('.form-group').parent().removeClass('hidden'); |
|
||||
$("input[name=tipo_texto]").prop('disabled', false); |
$("input[name=tipo_texto]").prop('disabled', false); |
||||
|
$("input[name=tipo_texto]").closest('label').removeClass('disabled'); |
||||
|
|
||||
if ($("input[name=tipo_texto]:checked").length == 0) { |
// Selecionar primeira opção se nenhuma estiver selecionada |
||||
$("input[name=tipo_texto]").first().prop('checked', true); |
if ($("input[name=tipo_texto]:checked").length === 0) { |
||||
$("input[name=tipo_texto]").first().closest('label').addClass('checked'); |
$("input[name=tipo_texto]").first().prop('checked', true); |
||||
} |
} |
||||
|
|
||||
}); |
updateTipoTextoDisplay(); |
||||
|
} |
||||
|
|
||||
$("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]").change(function(event) { |
// Mostrar opções ao carregar a página |
||||
var url = '{% url 'sapl.api:materialegislativa-list'%}' |
showTipoTextoOptions(); |
||||
|
|
||||
|
// Atualizar quando tipo de proposição mudar |
||||
|
$("select[name=tipo]").change(function() { |
||||
|
showTipoTextoOptions(); |
||||
|
}); |
||||
|
|
||||
|
// ===================================================== |
||||
|
// Busca de matéria legislativa para vinculação |
||||
|
// ===================================================== |
||||
|
function buscarMateria() { |
||||
var formData = { |
var formData = { |
||||
'tipo' : $("select[name=tipo_materia]").val(), |
'tipo': $("select[name=tipo_materia]").val(), |
||||
'ano' : $("input[name=ano_materia]").val(), |
'ano': $("input[name=ano_materia]").val(), |
||||
'numero' : $("input[name=numero_materia]").val(), |
'numero': $("input[name=numero_materia]").val(), |
||||
|
}; |
||||
|
|
||||
|
// Validar se todos os campos estão preenchidos |
||||
|
if (!formData.tipo || !formData.ano || !formData.numero) { |
||||
|
$(".ementa_materia").html('').addClass('hidden'); |
||||
|
return; |
||||
} |
} |
||||
if (formData.tipo == '' || formData.ano == '' || formData.numero == '') |
|
||||
return; |
var url = '{% url "sapl.api:materialegislativa-list" %}'; |
||||
|
|
||||
$.get(url, formData).done(function(data) { |
$.get(url, formData).done(function(data) { |
||||
if (data.pagination.total_entries == 1) |
if (data.pagination.total_entries === 1) { |
||||
$(".ementa_materia").html(data.results[0].ementa).removeClass('hidden'); |
$(".ementa_materia") |
||||
else |
.html('<strong><i class="fa fa-check-circle text-success"></i> Matéria encontrada:</strong> ' + data.results[0].ementa) |
||||
|
.removeClass('hidden alert-warning') |
||||
|
.addClass('alert-info'); |
||||
|
} else if (data.pagination.total_entries === 0) { |
||||
|
$(".ementa_materia") |
||||
|
.html('<i class="fa fa-exclamation-triangle"></i> <em>Matéria não encontrada</em>') |
||||
|
.removeClass('hidden alert-info') |
||||
|
.addClass('alert-warning'); |
||||
|
} else { |
||||
|
$(".ementa_materia").html('').addClass('hidden'); |
||||
|
} |
||||
|
}).fail(function() { |
||||
$(".ementa_materia").html('').addClass('hidden'); |
$(".ementa_materia").html('').addClass('hidden'); |
||||
}); |
}); |
||||
}); |
} |
||||
|
|
||||
$("input[name=tipo_texto], select[name=tipo_materia], select[name=tipo]").trigger('change'); |
// Event listeners para busca de matéria |
||||
|
$("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]") |
||||
|
.on('change keyup', function() { |
||||
|
buscarMateria(); |
||||
|
}); |
||||
|
|
||||
}); |
// ===================================================== |
||||
</script> |
// Inicialização |
||||
|
// ===================================================== |
||||
|
|
||||
|
// Disparar eventos iniciais |
||||
|
$("select[name=tipo]").trigger('change'); |
||||
|
buscarMateria(); |
||||
|
|
||||
|
// Garantir que o onlyoffice-info comece escondido |
||||
|
$("#onlyoffice-info").hide(); |
||||
|
|
||||
|
// Atualizar display inicial |
||||
|
setTimeout(updateTipoTextoDisplay, 100); |
||||
|
|
||||
|
}); |
||||
|
</script> |
||||
{% endblock %} |
{% endblock %} |
||||
|
|||||
@ -0,0 +1,82 @@ |
|||||
|
{% extends "base.html" %} |
||||
|
{% load i18n %} |
||||
|
{% load static %} |
||||
|
|
||||
|
{% block base_content %} |
||||
|
<div class="container-fluid"> |
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<h2>{% trans "Editor de Texto" %} - {{ documento_tipo }}</h2> |
||||
|
<hr> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<div class="alert alert-info"> |
||||
|
<strong>{% trans "Documento:" %}</strong> {{ documento_titulo }}<br> |
||||
|
<strong>{% trans "Descrição:" %}</strong> {{ documento_descricao|truncatewords:50 }} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row"> |
||||
|
<div class="col-12"> |
||||
|
<div id="onlyoffice-placeholder" style="height: 600px; border: 1px solid #ccc;"> |
||||
|
<div class="text-center" style="padding-top: 250px;"> |
||||
|
<i class="fa fa-spinner fa-spin fa-3x"></i> |
||||
|
<p>{% trans "Carregando editor..." %}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="row mt-3"> |
||||
|
<div class="col-12"> |
||||
|
<a href="{{ voltar_url }}" class="btn btn-secondary"> |
||||
|
<i class="fa fa-arrow-left"></i> {% trans "Voltar" %} |
||||
|
</a> |
||||
|
<div class="alert alert-warning mt-3"> |
||||
|
<i class="fa fa-info-circle"></i> |
||||
|
{% trans "O documento é salvo automaticamente enquanto você edita. Ao terminar, feche o editor e volte para a página do documento." %} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<script type="text/javascript" src="{{ onlyoffice_url }}/web-apps/apps/api/documents/api.js"></script> |
||||
|
|
||||
|
<script type="text/javascript"> |
||||
|
// Carrega a configuração do OnlyOffice |
||||
|
fetch('{{ config_url }}') |
||||
|
.then(response => response.json()) |
||||
|
.then(config => { |
||||
|
console.log('OnlyOffice Config:', config); |
||||
|
|
||||
|
// Inicializa o editor |
||||
|
var docEditor = new DocsAPI.DocEditor("onlyoffice-placeholder", config); |
||||
|
|
||||
|
// Eventos do editor |
||||
|
docEditor.events = { |
||||
|
'onDocumentReady': function() { |
||||
|
console.log('Documento pronto para edição'); |
||||
|
}, |
||||
|
'onError': function(event) { |
||||
|
console.error('Erro no OnlyOffice:', event); |
||||
|
alert('Ocorreu um erro ao carregar o editor. Por favor, tente novamente.'); |
||||
|
}, |
||||
|
'onWarning': function(event) { |
||||
|
console.warn('Aviso do OnlyOffice:', event); |
||||
|
} |
||||
|
}; |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error('Erro ao carregar configuração:', error); |
||||
|
document.getElementById('onlyoffice-placeholder').innerHTML = |
||||
|
'<div class="alert alert-danger m-5">' + |
||||
|
'<strong>Erro:</strong> Não foi possível carregar o editor. ' + |
||||
|
'Verifique se o servidor OnlyOffice está rodando.' + |
||||
|
'</div>'; |
||||
|
}); |
||||
|
</script> |
||||
|
{% endblock %} |
||||
Loading…
Reference in new issue