Browse Source

Feat(OnlyOffice): Integração completa do editor em todos os tipos de documentos

- 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.md
pull/3858/head
rangelbruno 8 months ago
parent
commit
8023a07834
  1. 485
      docs/ONLYOFFICE_INTEGRACAO.md
  2. 23
      sapl/materia/forms.py
  3. 454
      sapl/materia/onlyoffice_materia_views.py
  4. 23
      sapl/materia/onlyoffice_views.py
  5. 28
      sapl/materia/urls.py
  6. 6
      sapl/materia/views.py
  7. 237
      sapl/norma/onlyoffice_views.py
  8. 14
      sapl/norma/urls.py
  9. 238
      sapl/protocoloadm/onlyoffice_views.py
  10. 16
      sapl/protocoloadm/urls.py
  11. 13
      sapl/templates/crud/detail.html
  12. 12
      sapl/templates/crud/detail_detail.html
  13. 13
      sapl/templates/materia/documentoacessorio_detail.html
  14. 7
      sapl/templates/materia/materialegislativa_detail.html
  15. 332
      sapl/templates/materia/proposicao_form.html
  16. 11
      sapl/templates/norma/normajuridica_detail.html
  17. 82
      sapl/templates/onlyoffice/onlyoffice_editor.html
  18. 11
      sapl/templates/protocoloadm/documentoadministrativo_detail.html

485
docs/ONLYOFFICE_INTEGRACAO.md

@ -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
```

23
sapl/materia/forms.py

@ -1978,16 +1978,12 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
self.texto_articulado_proposicao = app_config.texto_articulado_proposicao if app_config else False self.texto_articulado_proposicao = app_config.texto_articulado_proposicao if app_config else False
self.receber_recibo = app_config.receber_recibo_proposicao if app_config else False self.receber_recibo = app_config.receber_recibo_proposicao if app_config else False
if not self.texto_articulado_proposicao: # Sempre incluir tipo_texto para permitir escolher entre Arquivo Digital e OnlyOffice
if 'tipo_texto' in self._meta.fields:
self._meta.fields.remove('tipo_texto')
else:
if 'tipo_texto' not in self._meta.fields: if 'tipo_texto' not in self._meta.fields:
self._meta.fields.append('tipo_texto') self._meta.fields.append('tipo_texto')
fields = [ fields = [
to_column((Fieldset( to_column(('tipo', 12)),
TipoProposicao._meta.verbose_name, Field('tipo')), 12)),
to_column( to_column(
(Alert('teste', (Alert('teste',
css_class="ementa_materia hidden alert-info", css_class="ementa_materia hidden alert-info",
@ -2003,12 +1999,13 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
if 'numero_materia_futuro' in self._meta.fields: if 'numero_materia_futuro' in self._meta.fields:
self._meta.fields.remove('numero_materia_futuro') self._meta.fields.remove('numero_materia_futuro')
if self.texto_articulado_proposicao: # Sempre mostrar opção de tipo de texto (Arquivo Digital, OnlyOffice, e opcionalmente Texto Articulado)
# Usar coluna inteira (12) para melhor visualização
fields.append( fields.append(
to_column((InlineRadios('tipo_texto'), 5)),) to_column((InlineRadios('tipo_texto'), 12)),)
fields.append(to_column(( fields.append(to_column((
'texto_original', 7 if self.texto_articulado_proposicao else 12))) 'texto_original', 12)))
fields.append( fields.append(
to_column( to_column(
@ -2103,6 +2100,14 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
super(ProposicaoForm, self).__init__(*args, **kwargs) super(ProposicaoForm, self).__init__(*args, **kwargs)
# Ajustar choices do tipo_texto baseado na configuração
# Se texto_articulado_proposicao não está habilitado, remover opção 'T' (Texto Articulado)
if not self.texto_articulado_proposicao:
self.fields['tipo_texto'].choices = [
('D', _('Arquivo Digital')),
('O', _('Criar com OnlyOffice'))
]
if self.instance.pk: if self.instance.pk:
self.fields['tipo_texto'].initial = '' self.fields['tipo_texto'].initial = ''
if self.instance.texto_original: if self.instance.texto_original:

454
sapl/materia/onlyoffice_materia_views.py

@ -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)

23
sapl/materia/onlyoffice_views.py

@ -57,17 +57,12 @@ def onlyoffice_config(request, pk):
# Substituir localhost/host externo pelo nome do container na rede Docker # Substituir localhost/host externo pelo nome do container na rede Docker
# para que o OnlyOffice consiga acessar # para que o OnlyOffice consiga acessar
if 'localhost' in download_url or 'localhost' in callback_url:
# Substituir localhost:8000 por sapl:80 (nome do container e porta interna)
download_url = download_url.replace('localhost:8000', 'sapl:80')
callback_url = callback_url.replace('localhost:8000', 'sapl:80')
else:
# Se não for localhost, substituir o host pelo nome do container
host = request.get_host() host = request.get_host()
download_url = download_url.replace(f'http://{host}', 'http://sapl:80') # sapl-dev:8000 é o nome do container e porta interna do SAPL
download_url = download_url.replace(f'https://{host}', 'http://sapl:80') download_url = download_url.replace(f'http://{host}', 'http://sapl-dev:8000')
callback_url = callback_url.replace(f'http://{host}', 'http://sapl:80') download_url = download_url.replace(f'https://{host}', 'http://sapl-dev:8000')
callback_url = callback_url.replace(f'https://{host}', 'http://sapl:80') 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 # Configuração do documento
document_config = { document_config = {
@ -191,9 +186,9 @@ def onlyoffice_callback(request, pk):
if status in [2, 6] and download_url: if status in [2, 6] and download_url:
logger.info(f"URL original recebida: {download_url}") logger.info(f"URL original recebida: {download_url}")
# Substitui localhost:8002 por onlyoffice:80 para acesso interno Docker # Substitui localhost:8001 por onlyoffice:80 para acesso interno Docker
if 'localhost:8002' in download_url: if 'localhost:8001' in download_url:
download_url = download_url.replace('localhost:8002', 'onlyoffice:80') download_url = download_url.replace('localhost:8001', 'onlyoffice:80')
logger.info(f"URL substituída para rede Docker: {download_url}") logger.info(f"URL substituída para rede Docker: {download_url}")
logger.info(f"Iniciando download do documento de: {download_url}") logger.info(f"Iniciando download do documento de: {download_url}")
@ -274,7 +269,7 @@ def onlyoffice_editor(request, pk):
# É a URL interna do Docker, precisa usar a URL externa # É a URL interna do Docker, precisa usar a URL externa
protocol = 'https' if request.is_secure() else 'http' protocol = 'https' if request.is_secure() else 'http'
host = request.get_host().split(':')[0] # Remove porta se existir host = request.get_host().split(':')[0] # Remove porta se existir
onlyoffice_url = f"{protocol}://{host}:8002" onlyoffice_url = f"{protocol}://{host}:8001"
context = { context = {
'proposicao': proposicao, 'proposicao': proposicao,

28
sapl/materia/urls.py

@ -34,6 +34,12 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
PesquisarStatusTramitacaoView, HistoricoProposicaoView) PesquisarStatusTramitacaoView, HistoricoProposicaoView)
from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download, from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download,
onlyoffice_callback, onlyoffice_editor) onlyoffice_callback, onlyoffice_editor)
from sapl.materia.onlyoffice_materia_views import (
materia_onlyoffice_editor, materia_onlyoffice_config,
materia_onlyoffice_download, materia_onlyoffice_callback,
docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config,
docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback
)
from sapl.norma.views import NormaPesquisaSimplesView from sapl.norma.views import NormaPesquisaSimplesView
from sapl.protocoloadm.views import ( from sapl.protocoloadm.views import (
FichaPesquisaAdmView, FichaSelecionaAdmView FichaPesquisaAdmView, FichaSelecionaAdmView
@ -128,7 +134,27 @@ urlpatterns_materia = [
url(r'^materia/docacessorio/zip/(?P<pk>\d+)$', get_zip_docacessorios, url(r'^materia/docacessorio/zip/(?P<pk>\d+)$', get_zip_docacessorios,
name='compress_docacessorios'), name='compress_docacessorios'),
url(r'^materia/docacessorio/pdf/(?P<pk>\d+)$', get_pdf_docacessorios, url(r'^materia/docacessorio/pdf/(?P<pk>\d+)$', get_pdf_docacessorios,
name='merge_docacessorios') name='merge_docacessorios'),
# OnlyOffice endpoints para Matéria Legislativa
url(r'^materia/(?P<pk>\d+)/onlyoffice/editor$', materia_onlyoffice_editor,
name='materia_onlyoffice_editor'),
url(r'^materia/(?P<pk>\d+)/onlyoffice/config$', materia_onlyoffice_config,
name='materia_onlyoffice_config'),
url(r'^materia/(?P<pk>\d+)/onlyoffice/download$', materia_onlyoffice_download,
name='materia_onlyoffice_download'),
url(r'^materia/(?P<pk>\d+)/onlyoffice/callback$', materia_onlyoffice_callback,
name='materia_onlyoffice_callback'),
# OnlyOffice endpoints para Documento Acessório
url(r'^materia/documentoacessorio/(?P<pk>\d+)/onlyoffice/editor$', docacessorio_onlyoffice_editor,
name='docacessorio_onlyoffice_editor'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/onlyoffice/config$', docacessorio_onlyoffice_config,
name='docacessorio_onlyoffice_config'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/onlyoffice/download$', docacessorio_onlyoffice_download,
name='docacessorio_onlyoffice_download'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/onlyoffice/callback$', docacessorio_onlyoffice_callback,
name='docacessorio_onlyoffice_callback'),
] ]

6
sapl/materia/views.py

@ -1138,6 +1138,12 @@ class ProposicaoCrud(Crud):
User={}. Sempre que uma Proposição é inclusa ou alterada e a opção "Texto Articulado" for marcada, \ User={}. Sempre que uma Proposição é inclusa ou alterada e a opção "Texto Articulado" for marcada, \
você será redirecionado para a edição do Texto Eletrônico.""".format(username)) você será redirecionado para a edição do Texto Eletrônico.""".format(username))
return reverse('sapl.materia:proposicao_ta', kwargs={'pk': self.object.pk}) return reverse('sapl.materia:proposicao_ta', kwargs={'pk': self.object.pk})
elif tipo_texto == 'O':
messages.info(self.request, _("""\
Você será redirecionado para o Editor OnlyOffice para editar o documento."""))
self.logger.debug("""\
User={}. Redirecionando para o editor OnlyOffice.""".format(username))
return reverse('sapl.materia:onlyoffice_editor', kwargs={'pk': self.object.pk})
else: else:
return Crud.UpdateView.get_success_url(self) return Crud.UpdateView.get_success_url(self)

237
sapl/norma/onlyoffice_views.py

@ -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)

14
sapl/norma/urls.py

@ -7,6 +7,10 @@ from sapl.norma.views import (AnexoNormaJuridicaCrud, AssuntoNormaCrud,
TipoVinculoNormaJuridicaCrud, recuperar_norma, TipoVinculoNormaJuridicaCrud, recuperar_norma,
recuperar_numero_norma, AutoriaNormaCrud, recuperar_numero_norma, AutoriaNormaCrud,
PesquisarAssuntoNormaView) PesquisarAssuntoNormaView)
from sapl.norma.onlyoffice_views import (
norma_onlyoffice_editor, norma_onlyoffice_config,
norma_onlyoffice_download, norma_onlyoffice_callback
)
from .apps import AppConfig from .apps import AppConfig
@ -40,4 +44,14 @@ urlpatterns = [
url(r'^norma/recuperar-norma$', recuperar_norma, name="recuperar_norma"), url(r'^norma/recuperar-norma$', recuperar_norma, name="recuperar_norma"),
url(r'^norma/recuperar-numero-norma$', recuperar_numero_norma, url(r'^norma/recuperar-numero-norma$', recuperar_numero_norma,
name="recuperar_numero_norma"), name="recuperar_numero_norma"),
# OnlyOffice endpoints para Norma Jurídica
url(r'^norma/(?P<pk>\d+)/onlyoffice/editor$', norma_onlyoffice_editor,
name='norma_onlyoffice_editor'),
url(r'^norma/(?P<pk>\d+)/onlyoffice/config$', norma_onlyoffice_config,
name='norma_onlyoffice_config'),
url(r'^norma/(?P<pk>\d+)/onlyoffice/download$', norma_onlyoffice_download,
name='norma_onlyoffice_download'),
url(r'^norma/(?P<pk>\d+)/onlyoffice/callback$', norma_onlyoffice_callback,
name='norma_onlyoffice_callback'),
] ]

238
sapl/protocoloadm/onlyoffice_views.py

@ -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)

16
sapl/protocoloadm/urls.py

@ -29,6 +29,10 @@ from sapl.protocoloadm.views import (AcompanhamentoDocumentoView,
VinculoDocAdminMateriaCrud, VinculoDocAdminMateriaCrud,
VinculoDocAdminMateriaEmLoteView, VinculoDocAdminMateriaEmLoteView,
get_pdf_docacessorios) get_pdf_docacessorios)
from sapl.protocoloadm.onlyoffice_views import (
docadm_onlyoffice_editor, docadm_onlyoffice_config,
docadm_onlyoffice_download, docadm_onlyoffice_callback
)
from .apps import AppConfig from .apps import AppConfig
@ -53,7 +57,17 @@ urlpatterns_documento_administrativo = [
url(r'^docadm/(?P<pk>\d+)/vinculo-em-lote', VinculoDocAdminMateriaEmLoteView.as_view(), url(r'^docadm/(?P<pk>\d+)/vinculo-em-lote', VinculoDocAdminMateriaEmLoteView.as_view(),
name='vinculodocadminmateria_em_lote'), name='vinculodocadminmateria_em_lote'),
url(r'^docadm/documentoacessorioadministrativo/pdf/(?P<pk>\d+)$', get_pdf_docacessorios, url(r'^docadm/documentoacessorioadministrativo/pdf/(?P<pk>\d+)$', get_pdf_docacessorios,
name='merge_docacessorios') name='merge_docacessorios'),
# OnlyOffice endpoints para Documento Administrativo
url(r'^docadm/(?P<pk>\d+)/onlyoffice/editor$', docadm_onlyoffice_editor,
name='docadm_onlyoffice_editor'),
url(r'^docadm/(?P<pk>\d+)/onlyoffice/config$', docadm_onlyoffice_config,
name='docadm_onlyoffice_config'),
url(r'^docadm/(?P<pk>\d+)/onlyoffice/download$', docadm_onlyoffice_download,
name='docadm_onlyoffice_download'),
url(r'^docadm/(?P<pk>\d+)/onlyoffice/callback$', docadm_onlyoffice_callback,
name='docadm_onlyoffice_callback'),
] ]
urlpatterns_protocolo = [ urlpatterns_protocolo = [

13
sapl/templates/crud/detail.html

@ -101,19 +101,6 @@
{% endfor %} {% endfor %}
{% endblock detail_content %} {% endblock detail_content %}
{% if view.openapi_url %}
<div class="container justify-content-end d-flex">
<div class="actions btn-group" role="group">
<a href="{{ view.openapi_url }}" class="btn btn-sm btn-info py-0">
<small class="text-white">
{% trans 'OpenAPI' %}
</small>
</a>
</div>
</div>
{% endif %}
</div> </div>
{% block table_content %} {% block table_content %}

12
sapl/templates/crud/detail_detail.html

@ -64,18 +64,6 @@
{% endfor %} {% endfor %}
{% endblock detail_content %} {% endblock detail_content %}
{% if view.openapi_url %}
<div class="container justify-content-end d-flex">
<div class="actions btn-group" role="group">
<a href="{{ view.openapi_url }}" class="btn btn-sm btn-info py-0">
<small class="text-white">
{% trans 'OpenAPI' %}
</small>
</a>
</div>
</div>
{% endif %}
<div class="actions btn-group mb-3" role="group"> <div class="actions btn-group mb-3" role="group">
{% if view.detail_set_create_url %} {% if view.detail_set_create_url %}
<a href="{{ view.detail_set_create_url }}" class="btn btn-outline-primary"> <a href="{{ view.detail_set_create_url }}" class="btn btn-outline-primary">

13
sapl/templates/materia/documentoacessorio_detail.html

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

7
sapl/templates/materia/materialegislativa_detail.html

@ -8,6 +8,13 @@
<a href="{% url 'sapl.materia:acompanhar_materia' object.id %}" class="btn btn-outline-primary">Acompanhar Matéria</a> <a href="{% url 'sapl.materia:acompanhar_materia' object.id %}" class="btn btn-outline-primary">Acompanhar Matéria</a>
</div> </div>
{% endif %} {% endif %}
{% if perms.materia.change_materialegislativa %}
<div class="actions btn-group btn-group-sm" role="group">
<a class="btn btn-primary" href="{% url 'sapl.materia:materia_onlyoffice_editor' object.pk %}">
<i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %}
</a>
</div>
{% endif %}
{% endblock sub_actions %} {% endblock sub_actions %}
{% block editions %} {% block editions %}

332
sapl/templates/materia/proposicao_form.html

@ -1,67 +1,333 @@
{% extends "crud/form.html" %} {% extends "crud/form.html" %}
{% load i18n %} {% load i18n %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block extra_js %} {% block extra_js %}
<script type="text/javascript"> <style>
/* =====================================================
Estilo para o container do tipo de proposição
===================================================== */
#div_id_tipo {
position: relative;
}
$(document).ready(function(){ #div_id_tipo .tipo-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
$("input[name=tipo_texto]").change(function(event) { #div_id_tipo .btn-novo-tipo {
if (this.value == 'D' && this.checked) { white-space: nowrap;
$("#div_id_texto_original").removeClass('hidden'); padding: 6px 12px;
$("#onlyoffice-info").addClass('hidden'); font-size: 13px;
} }
else if (this.value == 'T' && this.checked) {
$("#div_id_texto_original").addClass('hidden'); /* =====================================================
$("#onlyoffice-info").addClass('hidden'); Estilo para os radio buttons de tipo de texto
===================================================== */
#div_id_tipo_texto {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border: 1px solid #e9ecef;
margin-bottom: 15px;
} }
else if (this.value == 'O' && this.checked) {
$("#div_id_texto_original").addClass('hidden'); #div_id_tipo_texto > label.col-form-label,
$("#onlyoffice-info").removeClass('hidden'); #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;
} }
});
// Adicionar botão ao lado do select de tipo .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]"); 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>'); if ($tipoLabel.length > 0 && $tipoSelect.length > 0) {
$tipoSelect.parent().append($btnNovoTipo); // 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);
} }
$("select[name=tipo]").change(function(event) { // =====================================================
// 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);
}
// 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');
}
// Event listener para mudança de tipo de texto
$("input[name=tipo_texto]").change(function() {
updateTipoTextoDisplay();
});
// =====================================================
// Mostrar opções de tipo de texto
// =====================================================
function showTipoTextoOptions() {
var $tipoTextoContainer = $("input[name=tipo_texto]").closest('.form-group').parent();
$tipoTextoContainer.removeClass('hidden');
// Sempre mostrar as opções de tipo_texto quando texto_articulado_proposicao está habilitado
// Inclui suporte para OnlyOffice além de Arquivo Digital e Texto Articulado
$("input[name=tipo_texto]").closest('label').removeClass('disabled');
$("input[name=tipo_texto]").closest('.form-group').parent().removeClass('hidden');
$("input[name=tipo_texto]").prop('disabled', false); $("input[name=tipo_texto]").prop('disabled', false);
$("input[name=tipo_texto]").closest('label').removeClass('disabled');
if ($("input[name=tipo_texto]:checked").length == 0) { // Selecionar primeira opção se nenhuma estiver selecionada
if ($("input[name=tipo_texto]:checked").length === 0) {
$("input[name=tipo_texto]").first().prop('checked', true); $("input[name=tipo_texto]").first().prop('checked', true);
$("input[name=tipo_texto]").first().closest('label').addClass('checked');
} }
}); updateTipoTextoDisplay();
}
// Mostrar opções ao carregar a página
showTipoTextoOptions();
$("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]").change(function(event) { // Atualizar quando tipo de proposição mudar
var url = '{% url 'sapl.api:materialegislativa-list'%}' $("select[name=tipo]").change(function() {
showTipoTextoOptions();
});
// =====================================================
// Busca de matéria legislativa para vinculação
// =====================================================
function buscarMateria() {
var formData = { var formData = {
'tipo': $("select[name=tipo_materia]").val(), 'tipo': $("select[name=tipo_materia]").val(),
'ano': $("input[name=ano_materia]").val(), 'ano': $("input[name=ano_materia]").val(),
'numero': $("input[name=numero_materia]").val(), 'numero': $("input[name=numero_materia]").val(),
} };
if (formData.tipo == '' || formData.ano == '' || formData.numero == '')
// Validar se todos os campos estão preenchidos
if (!formData.tipo || !formData.ano || !formData.numero) {
$(".ementa_materia").html('').addClass('hidden');
return; return;
}
var url = '{% url "sapl.api:materialegislativa-list" %}';
$.get(url, formData).done(function(data) { $.get(url, formData).done(function(data) {
if (data.pagination.total_entries == 1) if (data.pagination.total_entries === 1) {
$(".ementa_materia").html(data.results[0].ementa).removeClass('hidden'); $(".ementa_materia")
else .html('<strong><i class="fa fa-check-circle text-success"></i> Matéria encontrada:</strong> ' + data.results[0].ementa)
.removeClass('hidden alert-warning')
.addClass('alert-info');
} else if (data.pagination.total_entries === 0) {
$(".ementa_materia")
.html('<i class="fa fa-exclamation-triangle"></i> <em>Matéria não encontrada</em>')
.removeClass('hidden alert-info')
.addClass('alert-warning');
} else {
$(".ementa_materia").html('').addClass('hidden');
}
}).fail(function() {
$(".ementa_materia").html('').addClass('hidden'); $(".ementa_materia").html('').addClass('hidden');
}); });
}
// Event listeners para busca de matéria
$("select[name=tipo_materia], input[name=numero_materia], input[name=ano_materia]")
.on('change keyup', function() {
buscarMateria();
}); });
$("input[name=tipo_texto], select[name=tipo_materia], select[name=tipo]").trigger('change'); // =====================================================
// 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> </script>

11
sapl/templates/norma/normajuridica_detail.html

@ -3,6 +3,17 @@
{% load waffle_tags %} {% load waffle_tags %}
{% load render_bundle from webpack_loader %} {% load render_bundle from webpack_loader %}
{% block sub_actions %}
{{ block.super }}
{% if perms.norma.change_normajuridica %}
<div class="actions btn-group btn-group-sm" role="group">
<a class="btn btn-primary" href="{% url 'sapl.norma:norma_onlyoffice_editor' object.pk %}">
<i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %}
</a>
</div>
{% endif %}
{% endblock sub_actions %}
{% block webpack_loader_css %} {% block webpack_loader_css %}
{{ block.super }} {{ block.super }}
{% if object.texto_articulado.exists and object.texto_articulado.first.has_view_permission %} {% if object.texto_articulado.exists and object.texto_articulado.first.has_view_permission %}

82
sapl/templates/onlyoffice/onlyoffice_editor.html

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

11
sapl/templates/protocoloadm/documentoadministrativo_detail.html

@ -1,6 +1,17 @@
{% extends "crud/detail.html" %} {% extends "crud/detail.html" %}
{% load i18n %} {% load i18n %}
{% block sub_actions %}
{{ block.super }}
{% if perms.protocoloadm.change_documentoadministrativo %}
<div class="actions btn-group btn-group-sm" role="group">
<a class="btn btn-primary" href="{% url 'sapl.protocoloadm:docadm_onlyoffice_editor' documentoadministrativo.pk %}">
<i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %}
</a>
</div>
{% endif %}
{% endblock sub_actions %}
{% block detail_content %} {% block detail_content %}
{{ block.super }} {{ block.super }}

Loading…
Cancel
Save