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 %} |
||||
@ -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