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" %} |
|||
{% load i18n %} |
|||
{% load crispy_forms_tags %} |
|||
|
|||
{% 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) { |
|||
if (this.value == 'D' && this.checked) { |
|||
$("#div_id_texto_original").removeClass('hidden'); |
|||
$("#onlyoffice-info").addClass('hidden'); |
|||
} |
|||
else if (this.value == 'T' && this.checked) { |
|||
$("#div_id_texto_original").addClass('hidden'); |
|||
$("#onlyoffice-info").addClass('hidden'); |
|||
} |
|||
else if (this.value == 'O' && this.checked) { |
|||
$("#div_id_texto_original").addClass('hidden'); |
|||
$("#onlyoffice-info").removeClass('hidden'); |
|||
#div_id_tipo .btn-novo-tipo { |
|||
white-space: nowrap; |
|||
padding: 6px 12px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
/* ===================================================== |
|||
Estilo para os radio buttons de tipo de texto |
|||
===================================================== */ |
|||
#div_id_tipo_texto { |
|||
background: #f8f9fa; |
|||
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 |
|||
var $tipoSelect = $("select[name=tipo]"); |
|||
if ($tipoSelect.length > 0) { |
|||
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); |
|||
} |
|||
// Atualizar visual dos labels |
|||
$("#div_id_tipo_texto .form-check label, #div_id_tipo_texto .form-check-inline").removeClass('checked'); |
|||
$("input[name=tipo_texto]:checked").closest('label').addClass('checked'); |
|||
} |
|||
|
|||
$("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]").closest('label').removeClass('disabled'); |
|||
|
|||
if ($("input[name=tipo_texto]:checked").length == 0) { |
|||
$("input[name=tipo_texto]").first().prop('checked', true); |
|||
$("input[name=tipo_texto]").first().closest('label').addClass('checked'); |
|||
// Selecionar primeira opção se nenhuma estiver selecionada |
|||
if ($("input[name=tipo_texto]:checked").length === 0) { |
|||
$("input[name=tipo_texto]").first().prop('checked', true); |
|||
} |
|||
|
|||
}); |
|||
updateTipoTextoDisplay(); |
|||
} |
|||
|
|||
$("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]").change(function(event) { |
|||
var url = '{% url 'sapl.api:materialegislativa-list'%}' |
|||
// Mostrar opções ao carregar a página |
|||
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 = { |
|||
'tipo' : $("select[name=tipo_materia]").val(), |
|||
'ano' : $("input[name=ano_materia]").val(), |
|||
'numero' : $("input[name=numero_materia]").val(), |
|||
'tipo': $("select[name=tipo_materia]").val(), |
|||
'ano': $("input[name=ano_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) { |
|||
if (data.pagination.total_entries == 1) |
|||
$(".ementa_materia").html(data.results[0].ementa).removeClass('hidden'); |
|||
else |
|||
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) |
|||
.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'); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
$("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 %} |
|||
|
|||
@ -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