From 8023a07834bc52f60030baa61d19ea91c774736b Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Fri, 30 Jan 2026 12:35:03 -0300 Subject: [PATCH] =?UTF-8?q?Feat(OnlyOffice):=20Integra=C3=A7=C3=A3o=20comp?= =?UTF-8?q?leta=20do=20editor=20em=20todos=20os=20tipos=20de=20documentos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/ONLYOFFICE_INTEGRACAO.md | 485 ++++++++++++++++++ sapl/materia/forms.py | 29 +- sapl/materia/onlyoffice_materia_views.py | 454 ++++++++++++++++ sapl/materia/onlyoffice_views.py | 25 +- sapl/materia/urls.py | 28 +- sapl/materia/views.py | 6 + sapl/norma/onlyoffice_views.py | 237 +++++++++ sapl/norma/urls.py | 14 + sapl/protocoloadm/onlyoffice_views.py | 238 +++++++++ sapl/protocoloadm/urls.py | 16 +- sapl/templates/crud/detail.html | 13 - sapl/templates/crud/detail_detail.html | 12 - .../materia/documentoacessorio_detail.html | 13 + .../materia/materialegislativa_detail.html | 7 + sapl/templates/materia/proposicao_form.html | 354 +++++++++++-- .../templates/norma/normajuridica_detail.html | 11 + .../onlyoffice/onlyoffice_editor.html | 82 +++ .../documentoadministrativo_detail.html | 11 + 18 files changed, 1937 insertions(+), 98 deletions(-) create mode 100644 docs/ONLYOFFICE_INTEGRACAO.md create mode 100644 sapl/materia/onlyoffice_materia_views.py create mode 100644 sapl/norma/onlyoffice_views.py create mode 100644 sapl/protocoloadm/onlyoffice_views.py create mode 100644 sapl/templates/materia/documentoacessorio_detail.html create mode 100644 sapl/templates/onlyoffice/onlyoffice_editor.html diff --git a/docs/ONLYOFFICE_INTEGRACAO.md b/docs/ONLYOFFICE_INTEGRACAO.md new file mode 100644 index 000000000..75d29469b --- /dev/null +++ b/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//onlyoffice/editor +/proposicao//onlyoffice/config +/proposicao//onlyoffice/download +/proposicao//onlyoffice/callback +``` + +#### Matéria Legislativa +``` +/materia//onlyoffice/editor +/materia//onlyoffice/config +/materia//onlyoffice/download +/materia//onlyoffice/callback +``` + +#### Documento Acessório +``` +/materia/documentoacessorio//onlyoffice/editor +/materia/documentoacessorio//onlyoffice/config +/materia/documentoacessorio//onlyoffice/download +/materia/documentoacessorio//onlyoffice/callback +``` + +#### Documento Administrativo +``` +/docadm//onlyoffice/editor +/docadm//onlyoffice/config +/docadm//onlyoffice/download +/docadm//onlyoffice/callback +``` + +#### Norma Jurídica +``` +/norma//onlyoffice/editor +/norma//onlyoffice/config +/norma//onlyoffice/download +/norma//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//onlyoffice/editor + ↓ +Renderiza template com iframe do OnlyOffice + ↓ +JavaScript busca configuração via AJAX + ↓ +GET /documento//onlyoffice/config + ↓ +Retorna JSON com URLs e configurações + ↓ +OnlyOffice inicializa e busca documento + ↓ +GET /documento//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//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(' Matéria encontrada: ' + data.results[0].ementa) + .addClass('alert-info'); +} else { + $(".ementa_materia") + .html(' Matéria não encontrada') + .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 +``` + diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index af27e0397..a102c9e4f 100644 --- a/sapl/materia/forms.py +++ b/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.receber_recibo = app_config.receber_recibo_proposicao if app_config else False - if not self.texto_articulado_proposicao: - if 'tipo_texto' in self._meta.fields: - self._meta.fields.remove('tipo_texto') - else: - if 'tipo_texto' not in self._meta.fields: - self._meta.fields.append('tipo_texto') + # 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') fields = [ - to_column((Fieldset( - TipoProposicao._meta.verbose_name, Field('tipo')), 12)), + to_column(('tipo', 12)), to_column( (Alert('teste', css_class="ementa_materia hidden alert-info", @@ -2003,12 +1999,13 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): if 'numero_materia_futuro' in self._meta.fields: self._meta.fields.remove('numero_materia_futuro') - if self.texto_articulado_proposicao: - fields.append( - to_column((InlineRadios('tipo_texto'), 5)),) + # 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( + to_column((InlineRadios('tipo_texto'), 12)),) fields.append(to_column(( - 'texto_original', 7 if self.texto_articulado_proposicao else 12))) + 'texto_original', 12))) fields.append( to_column( @@ -2103,6 +2100,14 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): 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: self.fields['tipo_texto'].initial = '' if self.instance.texto_original: diff --git a/sapl/materia/onlyoffice_materia_views.py b/sapl/materia/onlyoffice_materia_views.py new file mode 100644 index 000000000..9f7e9874d --- /dev/null +++ b/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) diff --git a/sapl/materia/onlyoffice_views.py b/sapl/materia/onlyoffice_views.py index 74ec01ebb..17dd5e97d 100644 --- a/sapl/materia/onlyoffice_views.py +++ b/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 # 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() - download_url = download_url.replace(f'http://{host}', 'http://sapl:80') - download_url = download_url.replace(f'https://{host}', 'http://sapl:80') - callback_url = callback_url.replace(f'http://{host}', 'http://sapl:80') - callback_url = callback_url.replace(f'https://{host}', 'http://sapl:80') + host = request.get_host() + # sapl-dev:8000 é o nome do container e porta interna do SAPL + 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 = { @@ -191,9 +186,9 @@ def onlyoffice_callback(request, pk): if status in [2, 6] and download_url: logger.info(f"URL original recebida: {download_url}") - # Substitui localhost:8002 por onlyoffice:80 para acesso interno Docker - if 'localhost:8002' in download_url: - download_url = download_url.replace('localhost:8002', 'onlyoffice:80') + # 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') logger.info(f"URL substituída para rede Docker: {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 protocol = 'https' if request.is_secure() else 'http' host = request.get_host().split(':')[0] # Remove porta se existir - onlyoffice_url = f"{protocol}://{host}:8002" + onlyoffice_url = f"{protocol}://{host}:8001" context = { 'proposicao': proposicao, diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index e30d889a2..f7ff5a892 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -34,6 +34,12 @@ from sapl.materia.views import (AcompanhamentoConfirmarView, PesquisarStatusTramitacaoView, HistoricoProposicaoView) from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download, 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.protocoloadm.views import ( FichaPesquisaAdmView, FichaSelecionaAdmView @@ -128,7 +134,27 @@ urlpatterns_materia = [ url(r'^materia/docacessorio/zip/(?P\d+)$', get_zip_docacessorios, name='compress_docacessorios'), url(r'^materia/docacessorio/pdf/(?P\d+)$', get_pdf_docacessorios, - name='merge_docacessorios') + name='merge_docacessorios'), + + # OnlyOffice endpoints para Matéria Legislativa + url(r'^materia/(?P\d+)/onlyoffice/editor$', materia_onlyoffice_editor, + name='materia_onlyoffice_editor'), + url(r'^materia/(?P\d+)/onlyoffice/config$', materia_onlyoffice_config, + name='materia_onlyoffice_config'), + url(r'^materia/(?P\d+)/onlyoffice/download$', materia_onlyoffice_download, + name='materia_onlyoffice_download'), + url(r'^materia/(?P\d+)/onlyoffice/callback$', materia_onlyoffice_callback, + name='materia_onlyoffice_callback'), + + # OnlyOffice endpoints para Documento Acessório + url(r'^materia/documentoacessorio/(?P\d+)/onlyoffice/editor$', docacessorio_onlyoffice_editor, + name='docacessorio_onlyoffice_editor'), + url(r'^materia/documentoacessorio/(?P\d+)/onlyoffice/config$', docacessorio_onlyoffice_config, + name='docacessorio_onlyoffice_config'), + url(r'^materia/documentoacessorio/(?P\d+)/onlyoffice/download$', docacessorio_onlyoffice_download, + name='docacessorio_onlyoffice_download'), + url(r'^materia/documentoacessorio/(?P\d+)/onlyoffice/callback$', docacessorio_onlyoffice_callback, + name='docacessorio_onlyoffice_callback'), ] diff --git a/sapl/materia/views.py b/sapl/materia/views.py index eea10fd4c..de995c563 100644 --- a/sapl/materia/views.py +++ b/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, \ você será redirecionado para a edição do Texto Eletrônico.""".format(username)) 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: return Crud.UpdateView.get_success_url(self) diff --git a/sapl/norma/onlyoffice_views.py b/sapl/norma/onlyoffice_views.py new file mode 100644 index 000000000..b78b64d1d --- /dev/null +++ b/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) diff --git a/sapl/norma/urls.py b/sapl/norma/urls.py index 951479ea4..cc9428a49 100644 --- a/sapl/norma/urls.py +++ b/sapl/norma/urls.py @@ -7,6 +7,10 @@ from sapl.norma.views import (AnexoNormaJuridicaCrud, AssuntoNormaCrud, TipoVinculoNormaJuridicaCrud, recuperar_norma, recuperar_numero_norma, AutoriaNormaCrud, PesquisarAssuntoNormaView) +from sapl.norma.onlyoffice_views import ( + norma_onlyoffice_editor, norma_onlyoffice_config, + norma_onlyoffice_download, norma_onlyoffice_callback +) from .apps import AppConfig @@ -40,4 +44,14 @@ urlpatterns = [ url(r'^norma/recuperar-norma$', recuperar_norma, name="recuperar_norma"), url(r'^norma/recuperar-numero-norma$', recuperar_numero_norma, name="recuperar_numero_norma"), + + # OnlyOffice endpoints para Norma Jurídica + url(r'^norma/(?P\d+)/onlyoffice/editor$', norma_onlyoffice_editor, + name='norma_onlyoffice_editor'), + url(r'^norma/(?P\d+)/onlyoffice/config$', norma_onlyoffice_config, + name='norma_onlyoffice_config'), + url(r'^norma/(?P\d+)/onlyoffice/download$', norma_onlyoffice_download, + name='norma_onlyoffice_download'), + url(r'^norma/(?P\d+)/onlyoffice/callback$', norma_onlyoffice_callback, + name='norma_onlyoffice_callback'), ] diff --git a/sapl/protocoloadm/onlyoffice_views.py b/sapl/protocoloadm/onlyoffice_views.py new file mode 100644 index 000000000..301106c66 --- /dev/null +++ b/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) diff --git a/sapl/protocoloadm/urls.py b/sapl/protocoloadm/urls.py index 05c787897..f8237656b 100644 --- a/sapl/protocoloadm/urls.py +++ b/sapl/protocoloadm/urls.py @@ -29,6 +29,10 @@ from sapl.protocoloadm.views import (AcompanhamentoDocumentoView, VinculoDocAdminMateriaCrud, VinculoDocAdminMateriaEmLoteView, 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 @@ -53,7 +57,17 @@ urlpatterns_documento_administrativo = [ url(r'^docadm/(?P\d+)/vinculo-em-lote', VinculoDocAdminMateriaEmLoteView.as_view(), name='vinculodocadminmateria_em_lote'), url(r'^docadm/documentoacessorioadministrativo/pdf/(?P\d+)$', get_pdf_docacessorios, - name='merge_docacessorios') + name='merge_docacessorios'), + + # OnlyOffice endpoints para Documento Administrativo + url(r'^docadm/(?P\d+)/onlyoffice/editor$', docadm_onlyoffice_editor, + name='docadm_onlyoffice_editor'), + url(r'^docadm/(?P\d+)/onlyoffice/config$', docadm_onlyoffice_config, + name='docadm_onlyoffice_config'), + url(r'^docadm/(?P\d+)/onlyoffice/download$', docadm_onlyoffice_download, + name='docadm_onlyoffice_download'), + url(r'^docadm/(?P\d+)/onlyoffice/callback$', docadm_onlyoffice_callback, + name='docadm_onlyoffice_callback'), ] urlpatterns_protocolo = [ diff --git a/sapl/templates/crud/detail.html b/sapl/templates/crud/detail.html index 2f9a2c6c0..3dabebd0f 100644 --- a/sapl/templates/crud/detail.html +++ b/sapl/templates/crud/detail.html @@ -101,19 +101,6 @@ {% endfor %} {% endblock detail_content %} - {% if view.openapi_url %} - - {% endif %} - - {% block table_content %} diff --git a/sapl/templates/crud/detail_detail.html b/sapl/templates/crud/detail_detail.html index 5f4b59d72..21733f637 100644 --- a/sapl/templates/crud/detail_detail.html +++ b/sapl/templates/crud/detail_detail.html @@ -64,18 +64,6 @@ {% endfor %} {% endblock detail_content %} - {% if view.openapi_url %} - -{% endif %} -
{% if view.detail_set_create_url %} diff --git a/sapl/templates/materia/documentoacessorio_detail.html b/sapl/templates/materia/documentoacessorio_detail.html new file mode 100644 index 000000000..d3ab57225 --- /dev/null +++ b/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 %} + + {% endif %} +{% endblock sub_actions %} diff --git a/sapl/templates/materia/materialegislativa_detail.html b/sapl/templates/materia/materialegislativa_detail.html index 032ccb3c2..10e5043c5 100644 --- a/sapl/templates/materia/materialegislativa_detail.html +++ b/sapl/templates/materia/materialegislativa_detail.html @@ -8,6 +8,13 @@ Acompanhar Matéria
{% endif %} + {% if perms.materia.change_materialegislativa %} + + {% endif %} {% endblock sub_actions %} {% block editions %} diff --git a/sapl/templates/materia/proposicao_form.html b/sapl/templates/materia/proposicao_form.html index 86fc39a7d..b947e8364 100644 --- a/sapl/templates/materia/proposicao_form.html +++ b/sapl/templates/materia/proposicao_form.html @@ -1,68 +1,334 @@ {% extends "crud/form.html" %} {% load i18n %} {% load crispy_forms_tags %} + {% block extra_js %} - + // ===================================================== + // 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); + +}); + {% endblock %} diff --git a/sapl/templates/norma/normajuridica_detail.html b/sapl/templates/norma/normajuridica_detail.html index 03636266c..21cff27fd 100644 --- a/sapl/templates/norma/normajuridica_detail.html +++ b/sapl/templates/norma/normajuridica_detail.html @@ -3,6 +3,17 @@ {% load waffle_tags %} {% load render_bundle from webpack_loader %} +{% block sub_actions %} + {{ block.super }} + {% if perms.norma.change_normajuridica %} + + {% endif %} +{% endblock sub_actions %} + {% block webpack_loader_css %} {{ block.super }} {% if object.texto_articulado.exists and object.texto_articulado.first.has_view_permission %} diff --git a/sapl/templates/onlyoffice/onlyoffice_editor.html b/sapl/templates/onlyoffice/onlyoffice_editor.html new file mode 100644 index 000000000..28d711aa1 --- /dev/null +++ b/sapl/templates/onlyoffice/onlyoffice_editor.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block base_content %} +
+
+
+

{% trans "Editor de Texto" %} - {{ documento_tipo }}

+
+
+
+ +
+
+
+ {% trans "Documento:" %} {{ documento_titulo }}
+ {% trans "Descrição:" %} {{ documento_descricao|truncatewords:50 }} +
+
+
+ +
+
+
+
+ +

{% trans "Carregando editor..." %}

+
+
+
+
+ +
+
+ + {% trans "Voltar" %} + +
+ + {% trans "O documento é salvo automaticamente enquanto você edita. Ao terminar, feche o editor e volte para a página do documento." %} +
+
+
+
+ + + + +{% endblock %} diff --git a/sapl/templates/protocoloadm/documentoadministrativo_detail.html b/sapl/templates/protocoloadm/documentoadministrativo_detail.html index 2ab80b885..d0b329931 100644 --- a/sapl/templates/protocoloadm/documentoadministrativo_detail.html +++ b/sapl/templates/protocoloadm/documentoadministrativo_detail.html @@ -1,6 +1,17 @@ {% extends "crud/detail.html" %} {% load i18n %} +{% block sub_actions %} + {{ block.super }} + {% if perms.protocoloadm.change_documentoadministrativo %} + + {% endif %} +{% endblock sub_actions %} + {% block detail_content %} {{ block.super }}