From f042d86f639e545efbd6ada787d718c45e284e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Alves?= Date: Mon, 26 Jan 2026 08:40:13 -0300 Subject: [PATCH 1/9] Rename requirements.txt to requirements-ubuntu-18-04.txt --- requirements/{requirements.txt => requirements-ubuntu-18-04.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename requirements/{requirements.txt => requirements-ubuntu-18-04.txt} (100%) diff --git a/requirements/requirements.txt b/requirements/requirements-ubuntu-18-04.txt similarity index 100% rename from requirements/requirements.txt rename to requirements/requirements-ubuntu-18-04.txt From 9a9b5a0f0d46333452a8ae7c38816ad324bbc21e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Alves?= Date: Mon, 26 Jan 2026 08:40:39 -0300 Subject: [PATCH 2/9] Rename requirements-original-sapl.txt to requirements.txt --- requirements/{requirements-original-sapl.txt => requirements.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename requirements/{requirements-original-sapl.txt => requirements.txt} (100%) diff --git a/requirements/requirements-original-sapl.txt b/requirements/requirements.txt similarity index 100% rename from requirements/requirements-original-sapl.txt rename to requirements/requirements.txt From 8023a07834bc52f60030baa61d19ea91c774736b Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Fri, 30 Jan 2026 12:35:03 -0300 Subject: [PATCH 3/9] =?UTF-8?q?Feat(OnlyOffice):=20Integra=C3=A7=C3=A3o=20?= =?UTF-8?q?completa=20do=20editor=20em=20todos=20os=20tipos=20de=20documen?= =?UTF-8?q?tos?= 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 }} From 37e48b732f7a19640da1c93369a7cf9ef1c91421 Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Fri, 30 Jan 2026 13:56:14 -0300 Subject: [PATCH 4/9] Feat(Templates): Sistema de templates de documentos para OnlyOffice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa funcionalidade para gerenciar templates de documentos com cabeçalho, rodapé e formatação pré-definidos. Novos documentos criados via OnlyOffice usam esses templates automaticamente. Funcionalidades: - CRUD completo para gerenciar templates (/sistema/template-documento/) - Edição de templates via OnlyOffice com cabeçalho/rodapé - Busca hierárquica de templates (específico > genérico) - Templates vinculáveis a tipos específicos (TipoMateriaLegislativa, etc.) - Criação automática de documento em branco se não enviar arquivo - Integração com todas as views de download OnlyOffice Arquivos novos: - sapl/base/onlyoffice_template_views.py - sapl/base/migrations/0061_documenttemplate.py - sapl/utils_template.py - sapl/templates/base/documenttemplate_*.html - docs/templates-documentos.md --- docs/templates-documentos.md | 214 +++++++++++++++ sapl/base/forms.py | 97 +++++++ sapl/base/migrations/0061_documenttemplate.py | 62 +++++ sapl/base/models.py | 139 +++++++++- sapl/base/onlyoffice_template_views.py | 254 ++++++++++++++++++ sapl/base/urls.py | 19 +- sapl/base/views.py | 113 +++++++- sapl/materia/onlyoffice_materia_views.py | 78 +++++- sapl/materia/onlyoffice_views.py | 40 ++- sapl/norma/onlyoffice_views.py | 38 ++- sapl/protocoloadm/onlyoffice_views.py | 38 ++- .../base/documenttemplate_detail.html | 18 ++ .../templates/base/documenttemplate_form.html | 22 ++ sapl/templates/base/layouts.yaml | 7 + sapl/templates/menu_tabelas_auxiliares.yaml | 3 + sapl/utils_template.py | 215 +++++++++++++++ 16 files changed, 1346 insertions(+), 11 deletions(-) create mode 100644 docs/templates-documentos.md create mode 100644 sapl/base/migrations/0061_documenttemplate.py create mode 100644 sapl/base/onlyoffice_template_views.py create mode 100644 sapl/templates/base/documenttemplate_detail.html create mode 100644 sapl/templates/base/documenttemplate_form.html create mode 100644 sapl/utils_template.py diff --git a/docs/templates-documentos.md b/docs/templates-documentos.md new file mode 100644 index 000000000..161b7d0bf --- /dev/null +++ b/docs/templates-documentos.md @@ -0,0 +1,214 @@ +# Templates de Documentos - SAPL + +## Visão Geral + +O sistema de Templates de Documentos permite que administradores configurem modelos padrão com cabeçalho, rodapé e formatação pré-definidos. Quando um usuário cria um novo documento via OnlyOffice (proposição, matéria legislativa, norma, etc.), o sistema automaticamente aplica o template correspondente. + +## Arquitetura + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Admin SAPL │────▶│ CRUD Templates │────▶│ OnlyOffice │ +│ │ │ │ │ (Edita .docx) │ +└─────────────────┘ └──────────────────┘ └────────┬────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Usuário Comum │────▶│ Criar Documento │────▶│ Aplica Template │ +│ │ │ (Matéria, etc) │ │ (cabeç/rodapé) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +## Como Acessar + +### Menu Principal +1. Acesse o SAPL como administrador +2. Vá em **Sistema** → **Tabelas Auxiliares** +3. Na seção **Configurações Gerais**, clique em **Templates de Documentos** + +### URL Direta +``` +/sistema/template-documento/ +``` + +## Criando um Template + +### Passo 1: Acessar a Criação +1. Acesse `/sistema/template-documento/` +2. Clique em **Adicionar Template de Documento** + +### Passo 2: Preencher o Formulário + +| Campo | Descrição | Obrigatório | Exemplo | +|-------|-----------|-------------|---------| +| **Nome** | Nome identificador do template | Sim | "Template Oficial Câmara" | +| **Tipo de Conteúdo** | Tipo de documento que usará este template | Sim | Matéria Legislativa | +| **Tipo Específico** | Vincular a um tipo específico | Não | TipoMateriaLegislativa | +| **ID do Tipo** | ID do tipo específico | Não | 1 (Projeto de Lei) | +| **Descrição** | Descrição do template | Não | "Template com brasão e rodapé padrão" | +| **Arquivo** | Arquivo .docx inicial | **Não** | template.docx | +| **Ativo** | Se o template está disponível para uso | Sim | ✓ | +| **Padrão** | Se é o template padrão para este tipo | Não | ✓ | + +> **Nota:** O campo **Arquivo** é opcional. Se não enviar um arquivo, o sistema criará automaticamente um documento em branco com cabeçalho e rodapé de exemplo. + +### Passo 3: Editar no OnlyOffice +Após salvar, você será **automaticamente redirecionado** para o editor OnlyOffice onde poderá: +- Configurar o **cabeçalho** (logo, nome da casa legislativa) +- Configurar o **rodapé** (numeração de página, data) +- Definir **estilos** de fonte e formatação padrão +- Adicionar **elementos fixos** que aparecerão em todos os documentos + +### Passo 4: Salvar +O OnlyOffice salva automaticamente. Quando terminar, clique em **Voltar** para retornar ao SAPL. + +### Editando um Template Existente +Na tela de **Detalhes** do template, você encontrará dois botões: +- **Editar no OnlyOffice** - Abre o editor para modificar cabeçalho, rodapé e formatação +- **Editar Dados** - Modifica os campos do formulário (nome, tipo, etc.) + +Na tela de **Edição de Dados**, há um alerta no topo com link direto para o OnlyOffice. + +## Tipos de Conteúdo Suportados + +| Tipo | Descrição | Usado em | +|------|-----------|----------| +| `proposicao` | Proposições | Módulo Matéria → Proposições | +| `materia` | Matérias Legislativas | Módulo Matéria → Matérias | +| `docacessorio` | Documentos Acessórios | Anexos de Matérias | +| `docadm` | Documentos Administrativos | Módulo Protocolo Administrativo | +| `norma` | Normas Jurídicas | Módulo Normas | + +## Hierarquia de Templates + +O sistema busca templates na seguinte ordem de prioridade: + +1. **Template Específico**: Vinculado ao tipo exato (ex: "Projeto de Lei") +2. **Template Genérico**: Apenas com tipo de conteúdo (ex: "Matéria Legislativa") +3. **Documento em Branco**: Se nenhum template for encontrado + +### Exemplo Prático + +Se você criar: +- Template A: Matéria Legislativa (genérico) +- Template B: Matéria Legislativa → Projeto de Lei (específico) + +Ao criar um **Projeto de Lei**, o sistema usará o **Template B**. +Ao criar um **Requerimento**, o sistema usará o **Template A**. + +## Configurando Templates Específicos + +Para vincular um template a um tipo específico: + +### 1. Identificar o Tipo +Primeiro, descubra o ID do tipo desejado. Por exemplo, para TipoMateriaLegislativa: +- Acesse `/materia/tipo-materia-legislativa/` +- Anote o ID do tipo (visível na URL ao editar) + +### 2. Configurar no Template +No formulário de criação/edição do template: +- **Tipo Específico**: Selecione o ContentType correspondente + - `materia | tipomaterialegislativa` para tipos de matéria + - `materia | tipoproposicao` para tipos de proposição + - `protocoloadm | tipodocumentoadministrativo` para tipos de doc. administrativo + - `norma | tiponormajuridica` para tipos de norma +- **ID do Tipo Específico**: Digite o ID anotado + +## Estrutura Técnica + +### Arquivos Criados + +| Arquivo | Descrição | +|---------|-----------| +| `sapl/base/models.py` | Modelo `DocumentTemplate` | +| `sapl/base/forms.py` | Formulário `DocumentTemplateForm` | +| `sapl/base/views.py` | CRUD `DocumentTemplateCrud` | +| `sapl/base/urls.py` | URLs do CRUD e OnlyOffice | +| `sapl/base/onlyoffice_template_views.py` | Views OnlyOffice para templates | +| `sapl/utils_template.py` | Funções utilitárias | +| `sapl/base/migrations/0061_documenttemplate.py` | Migration do banco | +| `sapl/templates/base/layouts.yaml` | Layout do formulário | +| `sapl/templates/menu_tabelas_auxiliares.yaml` | Link no menu | +| `sapl/templates/base/documenttemplate_detail.html` | Template da tela de detalhes | +| `sapl/templates/base/documenttemplate_form.html` | Template da tela de edição | + +### URLs Disponíveis + +| URL | Descrição | +|-----|-----------| +| `/sistema/template-documento/` | Lista de templates | +| `/sistema/template-documento/create` | Criar template | +| `/sistema/template-documento/{pk}` | Detalhes do template | +| `/sistema/template-documento/{pk}/edit` | Editar template | +| `/sistema/template-documento/{pk}/delete` | Excluir template | +| `/sistema/template-documento/{pk}/onlyoffice/editor` | Editor OnlyOffice | + +### Modelo de Dados + +```python +class DocumentTemplate(models.Model): + nome = CharField(max_length=100) + descricao = TextField(blank=True) + tipo_conteudo = CharField(choices=TIPO_CONTEUDO_TEMPLATE) + + # GenericFK para tipo específico (opcional) + content_type = ForeignKey(ContentType, null=True, blank=True) + object_id = PositiveIntegerField(null=True, blank=True) + tipo_especifico = GenericForeignKey() + + arquivo = FileField(upload_to='templates/') + ativo = BooleanField(default=True) + padrao = BooleanField(default=False) + data_criacao = DateTimeField(auto_now_add=True) + data_modificacao = DateTimeField(auto_now=True) +``` + +## Boas Práticas + +### 1. Nomenclatura +Use nomes descritivos que identifiquem claramente o propósito: +- ✅ "Template Oficial - Projetos de Lei" +- ✅ "Modelo Padrão Câmara - Requerimentos" +- ❌ "Template 1" + +### 2. Organização +- Crie um template **genérico** para cada tipo de conteúdo +- Crie templates **específicos** apenas quando necessário +- Mantenha apenas **um template padrão** por tipo + +### 3. Cabeçalho e Rodapé +No OnlyOffice, configure: +- **Cabeçalho**: Brasão, nome da casa legislativa, endereço +- **Rodapé**: Número da página, data, informações de contato + +### 4. Manutenção +- Revise os templates periodicamente +- Desative templates obsoletos em vez de excluí-los +- Mantenha backup dos arquivos .docx + +## Solução de Problemas + +### Template não está sendo aplicado +1. Verifique se o template está **Ativo** +2. Verifique se o **Tipo de Conteúdo** está correto +3. Se é específico, verifique se o **ID do Tipo** está correto + +### Cabeçalho/rodapé não aparecem +1. Edite o template no OnlyOffice +2. Verifique se o cabeçalho/rodapé foram salvos corretamente +3. O OnlyOffice salva automaticamente, mas aguarde alguns segundos + +### Erro ao criar template +1. Verifique se o arquivo é um **.docx** válido +2. Verifique se você tem permissão de administrador +3. Consulte os logs do sistema para mais detalhes + +## Permissões Necessárias + +Para gerenciar templates, o usuário precisa das permissões: +- `base.add_documenttemplate` - Criar templates +- `base.change_documenttemplate` - Editar templates +- `base.delete_documenttemplate` - Excluir templates +- `base.view_documenttemplate` - Visualizar templates + +Essas permissões são automaticamente concedidas ao grupo **Operador Geral** e administradores. diff --git a/sapl/base/forms.py b/sapl/base/forms.py index cee8739cc..dc89bf996 100644 --- a/sapl/base/forms.py +++ b/sapl/base/forms.py @@ -1293,6 +1293,103 @@ class PartidoForm(FileFieldCheckMixin, ModelForm): return cleaned_data +class DocumentTemplateForm(FileFieldCheckMixin, ModelForm): + class Meta: + from .models import DocumentTemplate + model = DocumentTemplate + fields = ['nome', 'descricao', 'tipo_conteudo', 'content_type', + 'object_id', 'arquivo', 'ativo', 'padrao'] + + def __init__(self, *args, **kwargs): + super(DocumentTemplateForm, self).__init__(*args, **kwargs) + + # Limita os content_types às opções válidas de tipos específicos + from django.contrib.contenttypes.models import ContentType + from sapl.materia.models import TipoMateriaLegislativa, TipoProposicao, TipoDocumento + from sapl.protocoloadm.models import TipoDocumentoAdministrativo + from sapl.norma.models import TipoNormaJuridica + + tipos_validos = [ + ContentType.objects.get_for_model(TipoMateriaLegislativa), + ContentType.objects.get_for_model(TipoProposicao), + ContentType.objects.get_for_model(TipoDocumento), + ContentType.objects.get_for_model(TipoDocumentoAdministrativo), + ContentType.objects.get_for_model(TipoNormaJuridica), + ] + self.fields['content_type'].queryset = ContentType.objects.filter( + pk__in=[ct.pk for ct in tipos_validos] + ) + self.fields['content_type'].required = False + self.fields['object_id'].required = False + + # Arquivo é opcional - pode criar em branco e editar no OnlyOffice + self.fields['arquivo'].required = False + self.fields['arquivo'].help_text = _( + 'Opcional. Se não enviar arquivo, um documento em branco será criado. ' + 'Você poderá editar o template no OnlyOffice após salvar.' + ) + + row1 = to_row([ + ('nome', 6), + ('tipo_conteudo', 6), + ]) + row2 = to_row([ + ('content_type', 6), + ('object_id', 6), + ]) + row3 = to_row([ + ('descricao', 12), + ]) + row4 = to_row([ + ('arquivo', 8), + ('ativo', 2), + ('padrao', 2), + ]) + + self.helper = SaplFormHelper() + self.helper.layout = Layout( + Fieldset( + _('Template de Documento'), + row1, row2, row3, row4, + ), + form_actions(label='Salvar')) + + def clean(self): + cleaned_data = super().clean() + + if not self.is_valid(): + return cleaned_data + + content_type = cleaned_data.get('content_type') + object_id = cleaned_data.get('object_id') + + # Se informou content_type, deve informar object_id + if content_type and not object_id: + raise ValidationError( + _('Se informar o tipo específico, deve informar o ID do tipo.')) + + # Se informou object_id, deve informar content_type + if object_id and not content_type: + raise ValidationError( + _('Se informar o ID do tipo, deve informar o tipo específico.')) + + # Valida se o object_id existe para o content_type + if content_type and object_id: + model_class = content_type.model_class() + if not model_class.objects.filter(pk=object_id).exists(): + raise ValidationError( + _('O tipo específico informado não existe.')) + + # Valida extensão do arquivo + arquivo = cleaned_data.get('arquivo') + if arquivo and hasattr(arquivo, 'name'): + if not arquivo.name.lower().endswith('.docx'): + raise ValidationError( + _('O arquivo deve ser um documento .docx')) + + return cleaned_data + + class SaplSearchForm(ModelSearchForm): def search(self): diff --git a/sapl/base/migrations/0061_documenttemplate.py b/sapl/base/migrations/0061_documenttemplate.py new file mode 100644 index 000000000..6cf924838 --- /dev/null +++ b/sapl/base/migrations/0061_documenttemplate.py @@ -0,0 +1,62 @@ +# Generated by Django 2.2 on 2026-01-30 00:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('base', '0060_auto_20240812_1628'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nome', models.CharField(max_length=100, verbose_name='Nome do Template')), + ('descricao', models.TextField(blank=True, verbose_name='Descrição')), + ('tipo_conteudo', models.CharField( + choices=[ + ('proposicao', 'Proposição'), + ('materia', 'Matéria Legislativa'), + ('docacessorio', 'Documento Acessório'), + ('docadm', 'Documento Administrativo'), + ('norma', 'Norma Jurídica'), + ], + max_length=20, + verbose_name='Tipo de Conteúdo', + help_text='Tipo de documento ao qual este template se aplica' + )), + ('object_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='ID do Tipo Específico')), + ('arquivo', models.FileField( + upload_to='templates/', + verbose_name='Arquivo do Template', + help_text='Arquivo .docx com o template do documento' + )), + ('ativo', models.BooleanField(default=True, verbose_name='Ativo')), + ('padrao', models.BooleanField( + default=False, + verbose_name='Padrão', + help_text='Se marcado, será o template padrão para este tipo de conteúdo' + )), + ('data_criacao', models.DateTimeField(auto_now_add=True, verbose_name='Data de Criação')), + ('data_modificacao', models.DateTimeField(auto_now=True, verbose_name='Data de Modificação')), + ('content_type', models.ForeignKey( + blank=True, + help_text='Tipo específico de documento (ex: Projeto de Lei, Requerimento)', + null=True, + on_delete=django.db.models.deletion.PROTECT, + to='contenttypes.ContentType', + verbose_name='Tipo Específico' + )), + ], + options={ + 'verbose_name': 'Template de Documento', + 'verbose_name_plural': 'Templates de Documentos', + 'ordering': ['tipo_conteudo', 'nome'], + }, + ), + ] diff --git a/sapl/base/models.py b/sapl/base/models.py index f93161f61..84079b8f7 100644 --- a/sapl/base/models.py +++ b/sapl/base/models.py @@ -4,7 +4,7 @@ from django.contrib.postgres.fields.jsonb import JSONField from django.core.cache import cache from django.core.serializers.json import DjangoJSONEncoder from django.db import models -from django.db.models.deletion import CASCADE +from django.db.models.deletion import CASCADE, PROTECT from django.db.models.signals import post_migrate from django.db.utils import DEFAULT_DB_ALIAS from django.utils.translation import ugettext_lazy as _ @@ -13,6 +13,14 @@ from sapl.utils import (LISTA_DE_UFS, YES_NO_CHOICES, get_settings_auth_user_model, models_with_gr_for_model) +TIPO_CONTEUDO_TEMPLATE = ( + ('proposicao', _('Proposição')), + ('materia', _('Matéria Legislativa')), + ('docacessorio', _('Documento Acessório')), + ('docadm', _('Documento Administrativo')), + ('norma', _('Norma Jurídica')), +) + DOC_ADM_OSTENSIVO = 'O' DOC_ADM_RESTRITIVO = 'R' @@ -482,3 +490,132 @@ class Metadata(models.Model): def __str__(self): return f'Metadata de {self.content_object}' + + +class DocumentTemplate(models.Model): + """ + Template de documento para ser usado na criação de novos documentos + via OnlyOffice. Permite definir cabeçalho, rodapé e formatação padrão. + """ + nome = models.CharField( + max_length=100, + verbose_name=_('Nome do Template')) + + descricao = models.TextField( + blank=True, + verbose_name=_('Descrição')) + + tipo_conteudo = models.CharField( + max_length=20, + choices=TIPO_CONTEUDO_TEMPLATE, + verbose_name=_('Tipo de Conteúdo'), + help_text=_('Tipo de documento ao qual este template se aplica')) + + # GenericFK para tipo específico (opcional) + # Ex: TipoMateriaLegislativa, TipoProposicao, etc. + content_type = models.ForeignKey( + ContentType, + null=True, + blank=True, + on_delete=PROTECT, + verbose_name=_('Tipo Específico'), + help_text=_('Tipo específico de documento (ex: Projeto de Lei, Requerimento)')) + + object_id = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_('ID do Tipo Específico')) + + tipo_especifico = GenericForeignKey('content_type', 'object_id') + + arquivo = models.FileField( + upload_to='templates/', + verbose_name=_('Arquivo do Template'), + help_text=_('Arquivo .docx com o template do documento')) + + ativo = models.BooleanField( + default=True, + verbose_name=_('Ativo')) + + padrao = models.BooleanField( + default=False, + verbose_name=_('Padrão'), + help_text=_('Se marcado, será o template padrão para este tipo de conteúdo')) + + data_criacao = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Data de Criação')) + + data_modificacao = models.DateTimeField( + auto_now=True, + verbose_name=_('Data de Modificação')) + + class Meta: + verbose_name = _('Template de Documento') + verbose_name_plural = _('Templates de Documentos') + ordering = ['tipo_conteudo', 'nome'] + + def __str__(self): + if self.tipo_especifico: + return f'{self.nome} ({self.get_tipo_conteudo_display()} - {self.tipo_especifico})' + return f'{self.nome} ({self.get_tipo_conteudo_display()})' + + def save(self, *args, **kwargs): + # Se este template está sendo marcado como padrão, + # desmarcar outros templates padrão do mesmo tipo + if self.padrao: + qs = DocumentTemplate.objects.filter( + tipo_conteudo=self.tipo_conteudo, + padrao=True + ) + if self.pk: + qs = qs.exclude(pk=self.pk) + + # Se tem tipo específico, filtrar apenas os do mesmo tipo + if self.content_type and self.object_id: + qs = qs.filter( + content_type=self.content_type, + object_id=self.object_id + ) + else: + # Se não tem tipo específico, desmarcar apenas os genéricos + qs = qs.filter(content_type__isnull=True) + + qs.update(padrao=False) + + super().save(*args, **kwargs) + + @classmethod + def get_template_for(cls, tipo_conteudo, tipo_especifico=None): + """ + Busca o template mais adequado para o tipo de conteúdo. + Prioridade: específico > genérico > None + + Args: + tipo_conteudo: string com o tipo (proposicao, materia, etc.) + tipo_especifico: objeto do tipo específico (TipoMateriaLegislativa, etc.) + + Returns: + DocumentTemplate ou None + """ + # Primeiro, tenta encontrar template específico + if tipo_especifico: + ct = ContentType.objects.get_for_model(tipo_especifico) + template = cls.objects.filter( + tipo_conteudo=tipo_conteudo, + content_type=ct, + object_id=tipo_especifico.pk, + ativo=True + ).order_by('-padrao', '-data_modificacao').first() + + if template: + return template + + # Se não encontrou específico, busca genérico + template = cls.objects.filter( + tipo_conteudo=tipo_conteudo, + content_type__isnull=True, + ativo=True + ).order_by('-padrao', '-data_modificacao').first() + + return template diff --git a/sapl/base/onlyoffice_template_views.py b/sapl/base/onlyoffice_template_views.py new file mode 100644 index 000000000..a9bb84523 --- /dev/null +++ b/sapl/base/onlyoffice_template_views.py @@ -0,0 +1,254 @@ +""" +Views e utilitários para integração com OnlyOffice Document Server +para edição de Templates de Documentos +""" +import hashlib +import json +import logging +import time +from django.conf import settings +from django.contrib.auth.decorators import login_required, permission_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.base.models import DocumentTemplate + +logger = logging.getLogger(__name__) + + +def generate_file_key(template_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"template_{template_id}_user_{user_id}_{timestamp}" + return hashlib.md5(string_to_hash.encode()).hexdigest() + + +@login_required +@permission_required('base.change_documenttemplate', raise_exception=True) +@require_http_methods(["GET"]) +def template_onlyoffice_config(request, pk): + """ + Retorna a configuração JSON para inicializar o editor OnlyOffice + """ + template = get_object_or_404(DocumentTemplate, pk=pk) + + # URLs para o OnlyOffice acessar (dentro da rede Docker) + download_url = request.build_absolute_uri( + reverse('sapl.base:template_onlyoffice_download', kwargs={'pk': pk}) + ) + callback_url = request.build_absolute_uri( + reverse('sapl.base:template_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(template.pk, request.user.pk), + "title": f"Template_{template.pk}.docx", + "url": download_url, + } + + # Configuração do editor + editor_config = { + "mode": "edit", + "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 template_onlyoffice_download(request, pk): + """ + Endpoint para o OnlyOffice baixar o documento do template + Se não existe arquivo, retorna um documento em branco + """ + template = get_object_or_404(DocumentTemplate, pk=pk) + + # Se já tem arquivo, retorna ele + if template.arquivo: + try: + with open(template.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="Template_{pk}.docx"' + return response + except Exception as e: + logger.error(f"Erro ao ler arquivo do template: {e}") + + # Se não tem arquivo, cria um documento em branco usando python-docx + try: + from docx import Document + from docx.shared import Inches, Pt + from docx.enum.text import WD_ALIGN_PARAGRAPH + from io import BytesIO + + doc = Document() + + # Adiciona cabeçalho padrão + section = doc.sections[0] + header = section.header + header_para = header.paragraphs[0] + header_para.text = f"[CABEÇALHO - {template.nome}]" + header_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Adiciona rodapé padrão + footer = section.footer + footer_para = footer.paragraphs[0] + footer_para.text = "[RODAPÉ]" + footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Adiciona conteúdo inicial + doc.add_heading(f'Template: {template.nome}', 0) + doc.add_paragraph(f'Tipo: {template.get_tipo_conteudo_display()}') + if template.descricao: + doc.add_paragraph(f'Descrição: {template.descricao}') + doc.add_paragraph('') + doc.add_paragraph('Edite este documento para configurar o template.') + doc.add_paragraph('O cabeçalho e rodapé definidos aqui serão aplicados aos novos documentos.') + 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="Template_{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 template_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 template {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') + + template = get_object_or_404(DocumentTemplate, 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"template_{pk}_{int(time.time())}.docx" + + # Remove arquivo antigo se existir + if template.arquivo: + template.arquivo.delete(save=False) + + try: + template.arquivo.save( + filename, + ContentFile(response.content), + save=True + ) + logger.info(f"Template 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 +@permission_required('base.change_documenttemplate', raise_exception=True) +def template_onlyoffice_editor(request, pk): + """ + Renderiza a página com o editor OnlyOffice integrado para editar template + """ + template = get_object_or_404(DocumentTemplate, 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': template, + 'documento_tipo': 'Template de Documento', + 'documento_titulo': template.nome, + 'documento_descricao': template.descricao or f'Template para {template.get_tipo_conteudo_display()}', + 'onlyoffice_url': onlyoffice_url, + 'config_url': reverse('sapl.base:template_onlyoffice_config', kwargs={'pk': pk}), + 'voltar_url': reverse('sapl.base:documenttemplate_detail', kwargs={'pk': pk}), + } + + return render(request, 'onlyoffice/onlyoffice_editor.html', context) diff --git a/sapl/base/urls.py b/sapl/base/urls.py index 787ac44e3..392c0119b 100644 --- a/sapl/base/urls.py +++ b/sapl/base/urls.py @@ -8,7 +8,11 @@ from django.views.generic.base import RedirectView, TemplateView from sapl.base.views import (AutorCrud, ConfirmarEmailView, TipoAutorCrud, get_estatistica, RecuperarSenhaEmailView, RecuperarSenhaFinalizadoView, RecuperarSenhaConfirmaView, RecuperarSenhaCompletoView, IndexView, UserCrud, - GuiaProjetoView, FluxoProposicoesView, CriarAutorAjaxView) + GuiaProjetoView, FluxoProposicoesView, CriarAutorAjaxView, + DocumentTemplateCrud) +from sapl.base.onlyoffice_template_views import ( + template_onlyoffice_editor, template_onlyoffice_config, + template_onlyoffice_download, template_onlyoffice_callback) from sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL from .apps import AppConfig from .views import (LoginSapl, AlterarSenha, AppConfigCrud, CasaLegislativaCrud, @@ -53,6 +57,19 @@ urlpatterns = [ url(r'^sistema/autor/criar-ajax/$', CriarAutorAjaxView.as_view(), name='criar_autor_ajax'), url(r'^sistema/autor/', include(AutorCrud.get_urls())), + # Template de Documento - CRUD + url(r'^sistema/template-documento/', include(DocumentTemplateCrud.get_urls())), + + # Template de Documento - OnlyOffice + url(r'^sistema/template-documento/(?P\d+)/onlyoffice/editor$', + template_onlyoffice_editor, name='template_onlyoffice_editor'), + url(r'^sistema/template-documento/(?P\d+)/onlyoffice/config$', + template_onlyoffice_config, name='template_onlyoffice_config'), + url(r'^sistema/template-documento/(?P\d+)/onlyoffice/download$', + template_onlyoffice_download, name='template_onlyoffice_download'), + url(r'^sistema/template-documento/(?P\d+)/onlyoffice/callback$', + template_onlyoffice_callback, name='template_onlyoffice_callback'), + url(r'^sistema/guia-projeto/$', GuiaProjetoView.as_view(), name='guia_projeto'), url(r'^sistema/fluxo-proposicoes/$', FluxoProposicoesView.as_view(), diff --git a/sapl/base/views.py b/sapl/base/views.py index 662fd5f2f..c5fad7f13 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -52,8 +52,9 @@ from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, get_client_ip, sapn_is_enabled, is_weak_password, ratelimit_ip) -from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) -from .models import AppConfig, CasaLegislativa +from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, + EstatisticasAcessoNormasForm, DocumentTemplateForm) +from .models import AppConfig, CasaLegislativa, DocumentTemplate def get_casalegislativa(): @@ -1747,3 +1748,111 @@ class CriarAutorAjaxView(PermissionRequiredMixin, FormView): 'success': False, 'error': f'Erro ao criar autor: {str(e)}' }, status=500) + + +class DocumentTemplateCrud(CrudAux): + model = DocumentTemplate + help_topic = 'template-documento' + + class BaseMixin(CrudAux.BaseMixin): + list_field_names = ['nome', 'tipo_conteudo', 'ativo', 'padrao'] + form_class = DocumentTemplateForm + + class ListView(CrudAux.ListView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['title'] = _('Templates de Documentos') + return context + + class DetailView(CrudAux.DetailView): + template_name = 'base/documenttemplate_detail.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Sempre adiciona link para editar via OnlyOffice + context['onlyoffice_edit_url'] = reverse( + 'sapl.base:template_onlyoffice_editor', + kwargs={'pk': self.object.pk} + ) + return context + + def hook_tipo_especifico(self, obj): + if obj.tipo_especifico: + return 'Tipo Específico', str(obj.tipo_especifico) + return 'Tipo Específico', _('Nenhum (template genérico)') + + class CreateView(CrudAux.CreateView): + template_name = 'base/documenttemplate_form.html' + + def form_valid(self, form): + # Salva o objeto primeiro + response = super().form_valid(form) + + # Se não foi enviado arquivo, cria um documento em branco + if not self.object.arquivo: + self._criar_arquivo_branco() + + return response + + def _criar_arquivo_branco(self): + """Cria um arquivo .docx em branco com cabeçalho e rodapé padrão""" + try: + from docx import Document + from docx.enum.text import WD_ALIGN_PARAGRAPH + from django.core.files.base import ContentFile + from io import BytesIO + + doc = Document() + + # Configura cabeçalho padrão + section = doc.sections[0] + header = section.header + header_para = header.paragraphs[0] + header_para.text = f"[CABEÇALHO - {self.object.nome}]" + header_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Configura rodapé padrão + footer = section.footer + footer_para = footer.paragraphs[0] + footer_para.text = "[RODAPÉ]" + footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Adiciona conteúdo inicial + doc.add_heading(f'Template: {self.object.nome}', 0) + doc.add_paragraph(f'Tipo: {self.object.get_tipo_conteudo_display()}') + if self.object.descricao: + doc.add_paragraph(f'Descrição: {self.object.descricao}') + doc.add_paragraph('') + doc.add_paragraph('Edite este documento para configurar o template.') + doc.add_paragraph('O cabeçalho e rodapé definidos aqui serão aplicados aos novos documentos.') + + # Salva em memória + file_stream = BytesIO() + doc.save(file_stream) + file_stream.seek(0) + + # Salva no modelo + filename = f"template_{self.object.pk}.docx" + self.object.arquivo.save(filename, ContentFile(file_stream.getvalue()), save=True) + + except ImportError: + pass # python-docx não instalado, ignora + + def get_success_url(self): + # Sempre redireciona para o editor OnlyOffice após criação + return reverse( + 'sapl.base:template_onlyoffice_editor', + kwargs={'pk': self.object.pk} + ) + + class UpdateView(CrudAux.UpdateView): + template_name = 'base/documenttemplate_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Sempre adiciona link para editar via OnlyOffice + context['onlyoffice_edit_url'] = reverse( + 'sapl.base:template_onlyoffice_editor', + kwargs={'pk': self.object.pk} + ) + return context diff --git a/sapl/materia/onlyoffice_materia_views.py b/sapl/materia/onlyoffice_materia_views.py index 9f7e9874d..2a1257781 100644 --- a/sapl/materia/onlyoffice_materia_views.py +++ b/sapl/materia/onlyoffice_materia_views.py @@ -121,7 +121,43 @@ def materia_onlyoffice_download(request, pk): 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 + # Tenta criar documento usando template + try: + from sapl.utils_template import criar_documento_com_template, criar_documento_em_branco + + # Busca tipo específico da matéria (TipoMateriaLegislativa) + tipo_especifico = materia.tipo if materia.tipo else None + + file_stream = criar_documento_com_template( + 'materia', + tipo_especifico, + { + 'titulo': f'{materia.tipo} {materia.numero}/{materia.ano}', + 'descricao': materia.ementa, + 'tipo_display': 'Matéria Legislativa' + } + ) + + # Se não encontrou template, cria documento em branco + if not file_stream: + file_stream = criar_documento_em_branco( + f'{materia.tipo} {materia.numero}/{materia.ano}', + materia.ementa, + 'Matéria Legislativa' + ) + + if file_stream: + 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 Exception as e: + logger.error(f"Erro ao criar documento: {e}") + + # Fallback: Se python-docx não está instalado ou houve erro try: from docx import Document from io import BytesIO @@ -332,7 +368,45 @@ def docacessorio_onlyoffice_download(request, pk): 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 + # Tenta criar documento usando template + try: + from sapl.utils_template import criar_documento_com_template, criar_documento_em_branco + + # Busca tipo específico do documento acessório (TipoDocumento) + tipo_especifico = documento.tipo if documento.tipo else None + + descricao = documento.ementa if documento.ementa else f'Matéria: {documento.materia}' + + file_stream = criar_documento_com_template( + 'docacessorio', + tipo_especifico, + { + 'titulo': f'{documento.tipo} - {documento.nome}', + 'descricao': descricao, + 'tipo_display': 'Documento Acessório' + } + ) + + # Se não encontrou template, cria documento em branco + if not file_stream: + file_stream = criar_documento_em_branco( + f'{documento.tipo} - {documento.nome}', + descricao, + 'Documento Acessório' + ) + + if file_stream: + 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 Exception as e: + logger.error(f"Erro ao criar documento: {e}") + + # Fallback: Se python-docx não está instalado ou houve erro try: from docx import Document from io import BytesIO diff --git a/sapl/materia/onlyoffice_views.py b/sapl/materia/onlyoffice_views.py index 17dd5e97d..caa8b6c37 100644 --- a/sapl/materia/onlyoffice_views.py +++ b/sapl/materia/onlyoffice_views.py @@ -130,7 +130,43 @@ def onlyoffice_download(request, pk): 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 + # Tenta criar documento usando template + try: + from sapl.utils_template import criar_documento_com_template, criar_documento_em_branco + + # Busca tipo específico da proposição (TipoProposicao) + tipo_especifico = proposicao.tipo if proposicao.tipo else None + + file_stream = criar_documento_com_template( + 'proposicao', + tipo_especifico, + { + 'titulo': f'Proposição {proposicao.tipo}', + 'descricao': proposicao.descricao, + 'tipo_display': 'Proposição' + } + ) + + # Se não encontrou template, cria documento em branco + if not file_stream: + file_stream = criar_documento_em_branco( + f'Proposição {proposicao.tipo}', + proposicao.descricao, + 'Proposição' + ) + + if file_stream: + response = HttpResponse( + file_stream.getvalue(), + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + response['Content-Disposition'] = f'attachment; filename="Proposicao_{pk}.docx"' + return response + + except Exception as e: + logger.error(f"Erro ao criar documento: {e}") + + # Fallback: Se python-docx não está instalado ou houve erro try: from docx import Document from io import BytesIO @@ -142,7 +178,6 @@ def onlyoffice_download(request, pk): doc.add_paragraph('Digite o texto da proposição abaixo:') doc.add_paragraph('') - # Salva em memória file_stream = BytesIO() doc.save(file_stream) file_stream.seek(0) @@ -155,7 +190,6 @@ def onlyoffice_download(request, pk): return response except ImportError: - # Se python-docx não está instalado, retorna erro logger.error("python-docx não está instalado") return HttpResponse("Erro: python-docx não instalado", status=500) diff --git a/sapl/norma/onlyoffice_views.py b/sapl/norma/onlyoffice_views.py index b78b64d1d..f99a52cfb 100644 --- a/sapl/norma/onlyoffice_views.py +++ b/sapl/norma/onlyoffice_views.py @@ -117,7 +117,43 @@ def norma_onlyoffice_download(request, pk): 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 + # Tenta criar documento usando template + try: + from sapl.utils_template import criar_documento_com_template, criar_documento_em_branco + + # Busca tipo específico da norma (TipoNormaJuridica) + tipo_especifico = norma.tipo if norma.tipo else None + + file_stream = criar_documento_com_template( + 'norma', + tipo_especifico, + { + 'titulo': f'{norma.tipo} {norma.numero}/{norma.ano}', + 'descricao': norma.ementa, + 'tipo_display': 'Norma Jurídica' + } + ) + + # Se não encontrou template, cria documento em branco + if not file_stream: + file_stream = criar_documento_em_branco( + f'{norma.tipo} {norma.numero}/{norma.ano}', + norma.ementa, + 'Norma Jurídica' + ) + + if file_stream: + 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 Exception as e: + logger.error(f"Erro ao criar documento: {e}") + + # Fallback: Se python-docx não está instalado ou houve erro try: from docx import Document from io import BytesIO diff --git a/sapl/protocoloadm/onlyoffice_views.py b/sapl/protocoloadm/onlyoffice_views.py index 301106c66..68475e596 100644 --- a/sapl/protocoloadm/onlyoffice_views.py +++ b/sapl/protocoloadm/onlyoffice_views.py @@ -117,7 +117,43 @@ def docadm_onlyoffice_download(request, pk): 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 + # Tenta criar documento usando template + try: + from sapl.utils_template import criar_documento_com_template, criar_documento_em_branco + + # Busca tipo específico do documento administrativo (TipoDocumentoAdministrativo) + tipo_especifico = documento.tipo if documento.tipo else None + + file_stream = criar_documento_com_template( + 'docadm', + tipo_especifico, + { + 'titulo': f'{documento.tipo} {documento.numero}/{documento.ano}', + 'descricao': documento.assunto, + 'tipo_display': 'Documento Administrativo' + } + ) + + # Se não encontrou template, cria documento em branco + if not file_stream: + file_stream = criar_documento_em_branco( + f'{documento.tipo} {documento.numero}/{documento.ano}', + documento.assunto, + 'Documento Administrativo' + ) + + if file_stream: + 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 Exception as e: + logger.error(f"Erro ao criar documento: {e}") + + # Fallback: Se python-docx não está instalado ou houve erro try: from docx import Document from io import BytesIO diff --git a/sapl/templates/base/documenttemplate_detail.html b/sapl/templates/base/documenttemplate_detail.html new file mode 100644 index 000000000..c534d34c1 --- /dev/null +++ b/sapl/templates/base/documenttemplate_detail.html @@ -0,0 +1,18 @@ +{% extends "crud/detail.html" %} +{% load i18n %} + +{% block editions %} +
+ {% if onlyoffice_edit_url %} + + {% trans 'Editar no OnlyOffice' %} + + {% endif %} + {% if view.update_url %} + {% trans 'Editar Dados' %} + {% endif %} + {% if view.delete_url %} + {% trans 'Excluir' %} + {% endif %} +
+{% endblock %} diff --git a/sapl/templates/base/documenttemplate_form.html b/sapl/templates/base/documenttemplate_form.html new file mode 100644 index 000000000..ecbb8be88 --- /dev/null +++ b/sapl/templates/base/documenttemplate_form.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% load i18n crispy_forms_tags %} + +{% block base_content %} + {% if onlyoffice_edit_url %} +
+ {% trans 'Dica:' %} + {% trans 'Para editar o cabeçalho, rodapé e formatação do template, use o editor OnlyOffice.' %} + + {% trans 'Abrir no OnlyOffice' %} + +
+ {% else %} +
+ {% trans 'Novo Template' %}
+ {% trans 'Após salvar, você será redirecionado automaticamente para o editor OnlyOffice onde poderá configurar o cabeçalho, rodapé e formatação do template.' %} +
{% trans 'O campo "Arquivo" é opcional. Se não enviar um arquivo, um documento em branco será criado automaticamente.' %} +
+ {% endif %} + + {% crispy form %} +{% endblock base_content %} diff --git a/sapl/templates/base/layouts.yaml b/sapl/templates/base/layouts.yaml index 34c4f111d..395ef2698 100644 --- a/sapl/templates/base/layouts.yaml +++ b/sapl/templates/base/layouts.yaml @@ -76,3 +76,10 @@ AutorCreate: OperadorAutor: {% trans 'Operador de Autor' %}: - user + +DocumentTemplate: + {% trans 'Template de Documento' %}: + - nome:6 tipo_conteudo:6 + - content_type:6 object_id:6 + - descricao + - arquivo:8 ativo:2 padrao:2 diff --git a/sapl/templates/menu_tabelas_auxiliares.yaml b/sapl/templates/menu_tabelas_auxiliares.yaml index cf64082a1..2d4a988a2 100644 --- a/sapl/templates/menu_tabelas_auxiliares.yaml +++ b/sapl/templates/menu_tabelas_auxiliares.yaml @@ -14,6 +14,9 @@ - title: {% trans 'Autores' %} url: sapl.base:autor_list css_class: btn btn-link + - title: {% trans 'Templates de Documentos' %} + url: sapl.base:documenttemplate_list + css_class: btn btn-link {% if not sapl_as_sapn%} - title: {% trans 'Módulo Parlamentares' %} css_class: head_title diff --git a/sapl/utils_template.py b/sapl/utils_template.py new file mode 100644 index 000000000..37e63b3fd --- /dev/null +++ b/sapl/utils_template.py @@ -0,0 +1,215 @@ +""" +Utilitários para aplicar templates de documentos aos novos documentos +criados via OnlyOffice. +""" +import logging +from io import BytesIO + +logger = logging.getLogger(__name__) + + +def criar_documento_com_template(tipo_conteudo, tipo_especifico, dados): + """ + Cria um novo documento usando um template existente. + + 1. Busca template adequado via DocumentTemplate.get_template_for() + 2. Carrega .docx do template com python-docx + 3. Mantém cabeçalho/rodapé, limpa ou adapta o corpo + 4. Adiciona dados iniciais do documento + 5. Retorna BytesIO com documento pronto + + Args: + tipo_conteudo: string com o tipo (proposicao, materia, docacessorio, docadm, norma) + tipo_especifico: objeto do tipo específico (TipoMateriaLegislativa, TipoProposicao, etc.) + ou None para usar template genérico + dados: dicionário com dados do documento: + - titulo: título ou identificação do documento + - descricao: descrição, ementa ou assunto + - tipo_display: nome do tipo para exibição (opcional) + + Returns: + BytesIO com o documento pronto ou None se não houver template + """ + try: + from docx import Document + from docx.shared import Pt + from sapl.base.models import DocumentTemplate + + # Busca o template mais adequado + template = DocumentTemplate.get_template_for(tipo_conteudo, tipo_especifico) + + if not template or not template.arquivo: + logger.debug(f"Nenhum template encontrado para {tipo_conteudo}") + return None + + logger.info(f"Usando template '{template.nome}' para {tipo_conteudo}") + + # Carrega o documento do template + try: + doc = Document(template.arquivo.path) + except Exception as e: + logger.error(f"Erro ao carregar template {template.pk}: {e}") + return None + + # Limpa o corpo do documento mantendo cabeçalho e rodapé + # Remove todos os parágrafos existentes + for paragraph in doc.paragraphs: + p = paragraph._element + p.getparent().remove(p) + + # Remove todas as tabelas existentes + for table in doc.tables: + t = table._element + t.getparent().remove(t) + + # Adiciona o conteúdo inicial do novo documento + titulo = dados.get('titulo', 'Documento') + descricao = dados.get('descricao', '') + tipo_display = dados.get('tipo_display', tipo_conteudo.title()) + + # Adiciona título + doc.add_heading(titulo, level=0) + + # Adiciona informações do documento + if descricao: + if tipo_conteudo in ['proposicao', 'materia', 'norma']: + doc.add_paragraph(f'Ementa: {descricao}') + else: + doc.add_paragraph(f'Assunto: {descricao}') + + doc.add_paragraph('') + + # Texto orientativo + doc.add_paragraph(f'Digite o texto do documento abaixo:') + doc.add_paragraph('') + doc.add_paragraph('') + + # Salva em memória + file_stream = BytesIO() + doc.save(file_stream) + file_stream.seek(0) + + return file_stream + + except ImportError: + logger.error("python-docx não está instalado") + return None + except Exception as e: + logger.error(f"Erro ao criar documento com template: {e}") + return None + + +def criar_documento_em_branco(titulo, descricao, tipo_documento='Documento'): + """ + Cria um documento em branco básico quando não há template disponível. + Fallback para manter compatibilidade com o comportamento anterior. + + Args: + titulo: título ou identificação do documento + descricao: descrição, ementa ou assunto + tipo_documento: tipo para exibição (ex: 'Proposição', 'Matéria Legislativa') + + Returns: + BytesIO com o documento ou None em caso de erro + """ + try: + from docx import Document + from io import BytesIO + + doc = Document() + doc.add_heading(titulo, 0) + + if descricao: + doc.add_paragraph(f'Ementa: {descricao}') + + doc.add_paragraph('') + doc.add_paragraph(f'Digite o texto do documento abaixo:') + doc.add_paragraph('') + + file_stream = BytesIO() + doc.save(file_stream) + file_stream.seek(0) + + return file_stream + + except ImportError: + logger.error("python-docx não está instalado") + return None + except Exception as e: + logger.error(f"Erro ao criar documento em branco: {e}") + return None + + +def aplicar_template_existente(documento_path, tipo_conteudo, tipo_especifico=None): + """ + Aplica cabeçalho e rodapé de um template a um documento existente. + + Útil para documentos que já foram criados mas precisam ter o + cabeçalho/rodapé atualizado. + + Args: + documento_path: caminho para o documento .docx existente + tipo_conteudo: string com o tipo (proposicao, materia, etc.) + tipo_especifico: objeto do tipo específico ou None + + Returns: + BytesIO com o documento atualizado ou None se não houver template + """ + try: + from docx import Document + from sapl.base.models import DocumentTemplate + from copy import deepcopy + + # Busca o template + template = DocumentTemplate.get_template_for(tipo_conteudo, tipo_especifico) + + if not template or not template.arquivo: + logger.debug(f"Nenhum template encontrado para aplicar") + return None + + # Carrega o template e o documento existente + template_doc = Document(template.arquivo.path) + doc = Document(documento_path) + + # Copia cabeçalho do template para o documento + if template_doc.sections: + template_section = template_doc.sections[0] + + for section in doc.sections: + # Copia cabeçalho + if template_section.header: + for para in section.header.paragraphs: + p = para._element + p.getparent().remove(p) + + for para in template_section.header.paragraphs: + new_para = section.header.add_paragraph() + new_para.text = para.text + new_para.style = para.style + new_para.alignment = para.alignment + + # Copia rodapé + if template_section.footer: + for para in section.footer.paragraphs: + p = para._element + p.getparent().remove(p) + + for para in template_section.footer.paragraphs: + new_para = section.footer.add_paragraph() + new_para.text = para.text + new_para.style = para.style + new_para.alignment = para.alignment + + # Salva em memória + file_stream = BytesIO() + doc.save(file_stream) + file_stream.seek(0) + + return file_stream + + except ImportError: + logger.error("python-docx não está instalado") + return None + except Exception as e: + logger.error(f"Erro ao aplicar template a documento existente: {e}") + return None From cb8ab86b084fa845a28438c0739874a3f4892cb0 Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Sat, 31 Jan 2026 21:58:46 -0300 Subject: [PATCH 5/9] =?UTF-8?q?Fix(Proposi=C3=A7=C3=B5es):=20Corre=C3=A7?= =?UTF-8?q?=C3=A3o=20da=20gera=C3=A7=C3=A3o=20de=20hash=5Fcode=20para=20do?= =?UTF-8?q?cumentos=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Corrige geração de hash_code ao enviar proposições com texto_original (PDF) - Adiciona verificação de arquivo existente antes de gerar hash no recibo - Melhora UI/UX: filtros clicáveis na lista de proposições - Aumenta largura máxima dos containers para 1200px - Permite acesso de administradores ao detalhe da proposição - Adiciona botão Receber na lista de proposições pendentes - Corrige cor do título/cabeçalho das tabelas para preto - Remove títulos duplicados nos templates --- sapl/materia/views.py | 61 ++++-- .../materia/prop_pendentes_list.html | 21 +- sapl/templates/materia/proposicao_base.html | 204 ++++++++++++++++++ sapl/templates/materia/proposicao_detail.html | 6 +- sapl/templates/materia/proposicao_list.html | 48 ++++- .../templates/materia/receber_proposicao.html | 15 ++ 6 files changed, 321 insertions(+), 34 deletions(-) create mode 100644 sapl/templates/materia/proposicao_base.html create mode 100644 sapl/templates/materia/receber_proposicao.html diff --git a/sapl/materia/views.py b/sapl/materia/views.py index de6db87bb..a114f9b29 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -553,7 +553,6 @@ class ProposicaoDevolvida(PermissionRequiredMixin, ListView): context['page_range'] = make_pagination( page_obj.number, paginator.num_pages) context['NO_ENTRIES_MSG'] = 'Nenhuma proposição devolvida.' - context['subnav_template_name'] = 'materia/subnav_prop.yaml' return context @@ -582,8 +581,6 @@ class ProposicaoPendente(PermissionRequiredMixin, ListView): context['page_range'] = make_pagination( page_obj.number, paginator.num_pages) context['NO_ENTRIES_MSG'] = 'Nenhuma proposição pendente.' - - context['subnav_template_name'] = 'materia/subnav_prop.yaml' qr = self.request.GET.copy() context['filter_url'] = ('&o=' + qr['o']) if 'o' in qr.keys() else '' return context @@ -610,13 +607,12 @@ class ProposicaoRecebida(PermissionRequiredMixin, ListView): context['page_range'] = make_pagination( page_obj.number, paginator.num_pages) context['NO_ENTRIES_MSG'] = 'Nenhuma proposição recebida.' - context['subnav_template_name'] = 'materia/subnav_prop.yaml' return context class ReceberProposicao(PermissionRequiredForAppCrudMixin, FormView): app_label = sapl.protocoloadm.apps.AppConfig.label - template_name = "crud/form.html" + template_name = "materia/receber_proposicao.html" form_class = ReceberProposicaoForm def post(self, request, *args, **kwargs): @@ -662,7 +658,6 @@ class ReceberProposicao(PermissionRequiredForAppCrudMixin, FormView): def get_context_data(self, **kwargs): context = super(ReceberProposicao, self).get_context_data(**kwargs) - context['subnav_template_name'] = 'materia/subnav_prop.yaml' return context @@ -918,18 +913,22 @@ class ProposicaoCrud(Crud): 'Proposição não possui texto. Use o botão "Editar com OnlyOffice" para criar o documento, ' 'e depois clique em "Salvar e Voltar" para garantir que o texto seja salvo antes de enviar.') else: + receber_recibo = BaseAppConfig.attr( + 'receber_recibo_proposicao') + if p.texto_articulado.exists(): ta = p.texto_articulado.first() ta.privacidade = STATUS_TA_IMMUTABLE_RESTRICT ta.editing_locked = True ta.save() - receber_recibo = BaseAppConfig.attr( - 'receber_recibo_proposicao') - if not receber_recibo: - ta = p.texto_articulado.first() p.hash_code = 'P' + ta.hash() + SEPARADOR_HASH_PROPOSICAO + str(p.pk) + elif p.texto_original and not receber_recibo: + # Gerar hash para arquivos PDF/documentos + p.hash_code = gerar_hash_arquivo( + p.texto_original.path, + str(p.pk)) p.data_devolucao = None p.data_envio = timezone.now() @@ -1204,16 +1203,32 @@ class ProposicaoCrud(Crud): def hook_header_status_proposicao(self): return 'Status' + def get_queryset(self): + qs = super().get_queryset() + status_filter = self.request.GET.get('status', '') + + if status_filter == 'elaboracao': + qs = qs.filter(data_envio__isnull=True, cancelado=False) + elif status_filter == 'aguardando': + qs = qs.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False) + elif status_filter == 'incorporada': + qs = qs.filter(data_recebimento__isnull=False, cancelado=False) + elif status_filter == 'devolvida': + qs = qs.filter(data_devolucao__isnull=False, cancelado=False) + + return qs + def get_context_data(self, **kwargs): # Primeiro, buscar os dados antes de chamar super() # pois get_rows modifica os objetos - qs = self.get_queryset() + # Usar queryset base (sem filtro) para as estatísticas + qs_base = super().get_queryset() stats = { - 'total': qs.count(), - 'elaboracao': qs.filter(data_envio__isnull=True, cancelado=False).count(), - 'aguardando': qs.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False).count(), - 'incorporada': qs.filter(data_recebimento__isnull=False, cancelado=False).count(), - 'devolvida': qs.filter(data_devolucao__isnull=False, cancelado=False).count(), + 'total': qs_base.count(), + 'elaboracao': qs_base.filter(data_envio__isnull=True, cancelado=False).count(), + 'aguardando': qs_base.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False).count(), + 'incorporada': qs_base.filter(data_recebimento__isnull=False, cancelado=False).count(), + 'devolvida': qs_base.filter(data_devolucao__isnull=False, cancelado=False).count(), } # Processar proposições ANTES de chamar super() que chama get_rows @@ -1274,6 +1289,7 @@ class ProposicaoCrud(Crud): context = super().get_context_data(**kwargs) context['stats'] = stats context['proposicoes'] = proposicoes + context['status_filter'] = self.request.GET.get('status', '') # Paginação context['is_paginated'] = paginator.num_pages > 1 @@ -1328,10 +1344,15 @@ class ReciboProposicaoView(TemplateView): **kwargs) proposicao = Proposicao.objects.get(pk=self.kwargs['pk']) + _hash = None if proposicao.texto_original: - _hash = gerar_hash_arquivo( - proposicao.texto_original.path, - self.kwargs['pk']) + import os + if os.path.exists(proposicao.texto_original.path): + _hash = gerar_hash_arquivo( + proposicao.texto_original.path, + self.kwargs['pk']) + else: + _hash = proposicao.hash_code if proposicao.hash_code else 'ARQUIVO_NAO_ENCONTRADO' elif proposicao.texto_articulado.exists(): ta = proposicao.texto_articulado.first() # FIXME hash para textos articulados @@ -1404,8 +1425,6 @@ class HistoricoProposicaoView(PermissionRequiredMixin, ListView): context['page_range'] = make_pagination( page_obj.number, paginator.num_pages) context['NO_ENTRIES_MSG'] = 'Nenhuma proposição' - if self.request.user.is_superuser: - context['subnav_template_name'] = 'materia/subnav_prop.yaml' return context diff --git a/sapl/templates/materia/prop_pendentes_list.html b/sapl/templates/materia/prop_pendentes_list.html index b940333d2..209319ece 100644 --- a/sapl/templates/materia/prop_pendentes_list.html +++ b/sapl/templates/materia/prop_pendentes_list.html @@ -1,10 +1,13 @@ -{% extends "base.html" %} +{% extends "materia/proposicao_base.html" %} {% load i18n %} {% load tz %} {% load common_tags %} {% block base_content %} -
- Proposições Não Recebidas +
+
+

Proposicoes Pendentes

+

Proposicoes enviadas aguardando recebimento

+
{% if not object_list %}

{{ NO_ENTRIES_MSG }}

{% else %} @@ -29,6 +32,7 @@ {% if not AppConfig.receber_recibo_proposicao %} Código do Documento {% endif %} + Ações {% endwith %} @@ -64,13 +68,22 @@ {% endif %} {% endif %} + + {% if prop.hash_code %} + + Receber + + {% else %} + Sem hash + {% endif %} + {% endif %} {% endfor %} {% endif %} -
+ {% include 'paginacao.html'%} {% endblock %} diff --git a/sapl/templates/materia/proposicao_base.html b/sapl/templates/materia/proposicao_base.html new file mode 100644 index 000000000..99733a5e2 --- /dev/null +++ b/sapl/templates/materia/proposicao_base.html @@ -0,0 +1,204 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block sections_nav %}{% endblock sections_nav %} + +{% block content_subnav %} + + + +{% endblock content_subnav %} + +{% block title %}{% endblock title %} diff --git a/sapl/templates/materia/proposicao_detail.html b/sapl/templates/materia/proposicao_detail.html index 6437d3232..efa563687 100644 --- a/sapl/templates/materia/proposicao_detail.html +++ b/sapl/templates/materia/proposicao_detail.html @@ -2,11 +2,13 @@ {% load i18n common_tags %} {% load tz %} +{% block title %}{% endblock title %} + {% block webpack_loader_chunks_css %} {{ block.super }}