From 1b20f87053e9c44f6e0a723ed4dab8ff3768a330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Alves?= Date: Mon, 26 Jan 2026 08:39:14 -0300 Subject: [PATCH 1/5] 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 ca8476dd6844f18758a9816b4ecf20ca07797eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Alves?= Date: Mon, 26 Jan 2026 08:39:37 -0300 Subject: [PATCH 2/5] 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 6b236be92fd6546c18ceb17dbf6e63c5481e4519 Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Fri, 30 Jan 2026 12:35:03 -0300 Subject: [PATCH 3/5] =?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 2bd536b40bfbdcef65972e00d606577044669694 Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Fri, 30 Jan 2026 13:56:14 -0300 Subject: [PATCH 4/5] 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 46eabe298cd05530167faed21588c9e7f0d953eb Mon Sep 17 00:00:00 2001 From: rangelbruno Date: Sat, 31 Jan 2026 19:13:16 -0300 Subject: [PATCH 5/5] =?UTF-8?q?Feat(Proposi=C3=A7=C3=B5es):=20Melhorias=20?= =?UTF-8?q?na=20UI/UX=20do=20m=C3=B3dulo=20de=20proposi=C3=A7=C3=B5es=20do?= =?UTF-8?q?=20vereador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: Adicionado card "Matérias Legislativas" e "Minhas Matérias" para vereadores - OnlyOffice: Corrigido callback para salvar documentos corretamente na rede Docker - OnlyOffice: Adicionado botão "Salvar e Voltar" no editor - Listagem: Nova interface minimalista com cards, status visual e paginação - Detalhes: Layout moderno com alertas contextuais e ações dinâmicas - Documentação: Adicionado docs/melhorias-proposicoes-vereador.md --- docs/melhorias-proposicoes-vereador.md | 262 ++++++++ sapl/base/views.py | 5 +- sapl/materia/onlyoffice_views.py | 14 +- sapl/materia/views.py | 116 +++- sapl/templates/index.html | 27 +- sapl/templates/materia/onlyoffice_editor.html | 56 +- sapl/templates/materia/proposicao_detail.html | 636 ++++++++++++------ sapl/templates/materia/proposicao_list.html | 308 ++++++++- 8 files changed, 1179 insertions(+), 245 deletions(-) create mode 100644 docs/melhorias-proposicoes-vereador.md diff --git a/docs/melhorias-proposicoes-vereador.md b/docs/melhorias-proposicoes-vereador.md new file mode 100644 index 000000000..a9367fca7 --- /dev/null +++ b/docs/melhorias-proposicoes-vereador.md @@ -0,0 +1,262 @@ +## Resumo das Alterações + +- **Home do Vereador**: Novos cards de acesso rápido +- **OnlyOffice**: Correções no salvamento de documentos +- **Listagem de Proposições**: Nova UI minimalista com status visual +- **Detalhes da Proposição**: Layout moderno e intuitivo + +--- + +## 1. Tela Home do Vereador + +### Arquivos Alterados +- `sapl/templates/index.html` +- `sapl/base/views.py` + +### Mudanças + +**Novo card "Matérias Legislativas"** +- Adicionado na seção do vereador (`is_parlamentar`) +- Permite pesquisar projetos de lei e proposições +- Link para `/materia/pesquisar-materia` + +**Novo card "Minhas Matérias"** +- Substitui o antigo card "Fluxo de Proposições" +- Filtra automaticamente as matérias pelo autor logado +- Permite ao vereador visualizar e assinar documentos de sua autoria + +**Alteração na View** +- `IndexView` agora passa `autor_id` no contexto +- Usado para filtrar matérias por autor na pesquisa + +--- + +## 2. Correções no OnlyOffice + +### Arquivos Alterados +- `sapl/materia/onlyoffice_views.py` +- `sapl/templates/materia/onlyoffice_editor.html` +- `sapl/materia/views.py` + +### Problema Resolvido +Ao tentar enviar uma proposição, aparecia o erro: +> "Proposição não possui nenhum tipo de Texto associado" + +Isso ocorria porque o callback do OnlyOffice não conseguia salvar o documento corretamente. + +### Soluções Implementadas + +**1. Correção no Callback** +```python +# Antes: só substituía localhost:8001 +if 'localhost:8001' in download_url: + download_url = download_url.replace('localhost:8001', 'onlyoffice:80') + +# Depois: substitui qualquer URL externa +download_url = re.sub(r'https?://[^/]+', 'http://onlyoffice:80', download_url) +``` + +**2. Botão "Salvar e Voltar" no Editor** +- Força o fechamento do editor (triggering callback) +- Aguarda 3 segundos para o callback processar +- Redireciona para a página de detalhes + +**3. Mensagem de Erro Melhorada** +- Orienta o usuário a usar o OnlyOffice +- Explica como salvar o documento antes de enviar + +**4. Aviso no Template de Detalhes** +- Alerta quando a proposição não tem texto +- Instrui o usuário a criar o documento + +--- + +## 3. Listagem de Proposições (`/proposicao/`) + +### Arquivos Alterados +- `sapl/templates/materia/proposicao_list.html` +- `sapl/materia/views.py` + +### Nova Interface + +**Design Minimalista** +- Layout limpo com max-width de 900px +- Cards com bordas sutis ao invés de sombras pesadas +- Tipografia clara e espaçamento consistente + +**Header com Estatísticas** +``` +Proposições 5 total 2 em elaboração 1 enviadas +``` + +**Cards de Proposição** +Cada card mostra: +- Tipo (ex: Requerimento) +- Status com badge colorido +- Data de envio +- Ementa (truncada em 40 palavras) +- Alerta de devolução (se aplicável) +- Links: Abrir / Editar + +**Status com Cores** +| Status | Cor | Descrição | +|--------|-----|-----------| +| Em Elaboração | Cinza | Proposição ainda não enviada | +| Aguardando | Azul | Enviada, aguardando recebimento | +| Incorporada | Verde | Recebida pela Mesa | +| Devolvida | Laranja | Devolvida com justificativa | +| Cancelada | Cinza escuro | Proposição cancelada | + +**Paginação** +- 10 itens por página +- Navegação: Anterior / Números / Próxima +- Indicador "Página X de Y" + +### Código da View +```python +class ListView(Crud.ListView): + paginate_by = 10 + + def get_context_data(self, **kwargs): + # Estatísticas + context['stats'] = { + 'total': qs.count(), + 'elaboracao': qs.filter(data_envio__isnull=True).count(), + # ... + } + + # Lista processada com status + for obj in page_obj.object_list: + if obj.cancelado: + status = 'cancelada' + elif obj.data_devolucao: + status = 'devolvida' + # ... + + # Paginação + context['is_paginated'] = paginator.num_pages > 1 + context['page_obj'] = page_obj + context['page_range'] = make_pagination(...) +``` + +--- + +## 4. Detalhes da Proposição (`/proposicao/{id}`) + +### Arquivos Alterados +- `sapl/templates/materia/proposicao_detail.html` + +### Nova Interface + +**Header** +- Link "Voltar para lista" +- Título: Tipo + Número/Ano ou "Rascunho" +- Badge de status colorido + +**Alertas Contextuais** +- **Vermelho**: Proposição devolvida (mostra justificativa) +- **Azul**: Aguardando recebimento +- **Amarelo**: Documento não criado + +**Barra de Ações** +Muda conforme o estado da proposição: + +*Em Elaboração:* +- Editar Documento (OnlyOffice) +- Enviar Proposição (se tem texto) +- Editar Dados +- Excluir + +*Enviada:* +- Ver Recibo +- Retornar Proposição (se não recebida) +- Baixar Documento + +**Seções de Conteúdo** + +1. **Ementa** - Texto descritivo em destaque + +2. **Informações** - Grid com: + - Tipo + - Número + - Data de Envio + - Data de Recebimento + - Data de Devolução + - Status do Documento (Criado/Não criado) + +3. **Observação** - Se houver + +4. **Vínculos** - Links para: + - Matéria gerada + - Matéria anexadora + +5. **Info do Sistema** (só admin) - Usuário, IP, Última edição, Hash + +**Página de Acesso Negado** +- Ícone de cadeado +- Mensagem clara +- Botão para voltar à lista + +--- + +## Fluxo do Vereador + +``` +1. Acessar Home + └── Card "Proposições" → Criar / Listar + +2. Criar Proposição + └── Preencher tipo e ementa + └── Redirecionado para OnlyOffice + +3. Editar Documento (OnlyOffice) + └── Escrever texto da proposição + └── Clicar "Salvar e Voltar" + +4. Enviar Proposição + └── Verificar dados + └── Clicar "Enviar Proposição" + └── Aguardar recebimento + +5. Acompanhar Status + └── Listagem mostra status atual + └── Ver detalhes para mais informações + └── Se devolvida, ver justificativa e corrigir +``` + +--- + +## Considerações Técnicas + +### Compatibilidade Docker +O OnlyOffice roda em container separado. As URLs são convertidas: +- Browser → OnlyOffice: `localhost:8001` +- SAPL → OnlyOffice: `onlyoffice:80` +- OnlyOffice → SAPL: `sapl-dev:8000` + +### Paginação +Usa a função `make_pagination` do CRUD base para gerar o range de páginas com reticências. + +### CSS +Estilos inline no template usando o bloco `webpack_loader_chunks_css` para evitar conflitos com o CSS global. + +--- + +## Arquivos Modificados (Resumo) + +``` +sapl/ +├── base/ +│ └── views.py # IndexView com autor_id +├── materia/ +│ ├── views.py # ListView e mensagens de erro +│ └── onlyoffice_views.py # Correção do callback +└── templates/ + ├── index.html # Cards do vereador + └── materia/ + ├── proposicao_list.html # Nova listagem + ├── proposicao_detail.html # Novos detalhes + └── onlyoffice_editor.html # Botão salvar e voltar +``` + +--- diff --git a/sapl/base/views.py b/sapl/base/views.py index c5fad7f13..34ccab936 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -71,9 +71,12 @@ class IndexView(TemplateView): context = super().get_context_data(**kwargs) # Verifica se o usuário é operador de algum Autor (Parlamentar) if self.request.user.is_authenticated: - context['is_parlamentar'] = self.request.user.autor_set.exists() + autor = self.request.user.autor_set.first() + context['is_parlamentar'] = autor is not None + context['autor_id'] = autor.id if autor else None else: context['is_parlamentar'] = False + context['autor_id'] = None return context diff --git a/sapl/materia/onlyoffice_views.py b/sapl/materia/onlyoffice_views.py index caa8b6c37..35d679b9d 100644 --- a/sapl/materia/onlyoffice_views.py +++ b/sapl/materia/onlyoffice_views.py @@ -220,10 +220,16 @@ def onlyoffice_callback(request, pk): if status in [2, 6] and download_url: logger.info(f"URL original recebida: {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') - logger.info(f"URL substituída para rede Docker: {download_url}") + # Substitui URLs externas por URLs internas da rede Docker + # O OnlyOffice pode retornar localhost:8001 ou o host externo + import re + # Padrão para capturar qualquer host:porta antes do path + download_url = re.sub( + r'https?://[^/]+', + 'http://onlyoffice:80', + download_url + ) + logger.info(f"URL substituída para rede Docker: {download_url}") logger.info(f"Iniciando download do documento de: {download_url}") proposicao = get_object_or_404(Proposicao, pk=pk) diff --git a/sapl/materia/views.py b/sapl/materia/views.py index de995c563..de6db87bb 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -843,12 +843,11 @@ class ProposicaoCrud(Crud): class BaseMixin(Crud.BaseMixin): list_field_names = [ - 'data_envio', - 'data_recebimento', - 'descricao', + 'status_proposicao', 'tipo', + 'descricao', + 'data_envio', 'conteudo_gerado_related', - 'cancelado' ] class BaseLocalMixin: @@ -916,7 +915,8 @@ class ProposicaoCrud(Crud): msg_error = _('Proposição já foi enviada.') elif not p.texto_original and not p.texto_articulado.exists(): msg_error = _( - 'Proposição não possui nenhum tipo de Texto associado.') + '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: if p.texto_articulado.exists(): ta = p.texto_articulado.first() @@ -1199,24 +1199,112 @@ class ProposicaoCrud(Crud): '-data_envio', 'descricao' ] + paginate_by = 10 + + def hook_header_status_proposicao(self): + return 'Status' + + def get_context_data(self, **kwargs): + # Primeiro, buscar os dados antes de chamar super() + # pois get_rows modifica os objetos + qs = self.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(), + } + + # Processar proposições ANTES de chamar super() que chama get_rows + # Usar uma nova query para não conflitar + page_qs = self.get_queryset() + paginator = self.get_paginator(page_qs, self.paginate_by) + page = self.request.GET.get('page', 1) + try: + page_obj = paginator.page(page) + except: + page_obj = paginator.page(1) + + proposicoes = [] + for obj in page_obj.object_list: + # Determina o status + if obj.cancelado: + status = 'cancelada' + status_label = 'Cancelada' + status_icon = 'fa-ban' + elif obj.data_devolucao: + status = 'devolvida' + status_label = 'Devolvida' + status_icon = 'fa-undo' + elif obj.data_recebimento: + status = 'incorporada' + status_label = 'Incorporada' + status_icon = 'fa-check' + elif obj.data_envio: + status = 'aguardando' + status_label = 'Aguardando Recebimento' + status_icon = 'fa-clock-o' + else: + status = 'elaboracao' + status_label = 'Em Elaboração' + status_icon = 'fa-edit' + + data_envio_fmt = None + if obj.data_envio: + data_envio_fmt = formats.date_format( + timezone.localtime(obj.data_envio), "SHORT_DATETIME_FORMAT" + ) + + data_devolucao_fmt = None + if obj.data_devolucao: + data_devolucao_fmt = formats.date_format( + timezone.localtime(obj.data_devolucao), "SHORT_DATETIME_FORMAT" + ) + + proposicoes.append({ + 'obj': obj, + 'status': status, + 'status_label': status_label, + 'status_icon': status_icon, + 'data_envio_fmt': data_envio_fmt, + 'data_devolucao_fmt': data_devolucao_fmt, + }) + + context = super().get_context_data(**kwargs) + context['stats'] = stats + context['proposicoes'] = proposicoes + + # Paginação + context['is_paginated'] = paginator.num_pages > 1 + context['page_obj'] = page_obj + context['paginator'] = paginator + context['page_range'] = make_pagination(page_obj.number, paginator.num_pages) + + return context def get_rows(self, object_list): for obj in object_list: - if obj.data_recebimento is None: - obj.data_recebimento = 'Não recebida' if obj.data_envio else 'Não enviada' + # Determina o status da proposição + if obj.cancelado: + obj.status_proposicao = ' Cancelada' + elif obj.data_devolucao: + obj.status_proposicao = ' Devolvida' + elif obj.data_recebimento: + obj.status_proposicao = ' Incorporada' + elif obj.data_envio: + obj.status_proposicao = ' Aguardando' else: - obj.data_recebimento = timezone.localtime( - obj.data_recebimento) - obj.data_recebimento = formats.date_format( - obj.data_recebimento, "DATETIME_FORMAT") + obj.status_proposicao = ' Em Elaboração' + + # Formata a data de envio if obj.data_envio is None: - obj.data_envio = 'Em elaboração...' + obj.data_envio = '-' else: - obj.data_envio = timezone.localtime(obj.data_envio) obj.data_envio = formats.date_format( - obj.data_envio, "DATETIME_FORMAT") + obj.data_envio, "SHORT_DATETIME_FORMAT") return [self._as_row(obj) for obj in object_list] diff --git a/sapl/templates/index.html b/sapl/templates/index.html index 086950973..2d5a9f745 100644 --- a/sapl/templates/index.html +++ b/sapl/templates/index.html @@ -23,6 +23,19 @@ + +
+
+ +
+

Matérias Legislativas

+

Pesquise projetos de lei e proposições

+ +
{% endif %} @@ -153,17 +166,17 @@ {% endif %} - - {% if is_parlamentar %} + + {% if is_parlamentar and autor_id %}
- +
-

Fluxo de Proposições

-

Como parlamentares criam e enviam proposições

+

Minhas Matérias

+

Visualize e assine seus documentos de autoria

diff --git a/sapl/templates/materia/onlyoffice_editor.html b/sapl/templates/materia/onlyoffice_editor.html index 220e4b3e9..72206a41e 100644 --- a/sapl/templates/materia/onlyoffice_editor.html +++ b/sapl/templates/materia/onlyoffice_editor.html @@ -33,12 +33,15 @@
+ - {% trans "Voltar" %} + {% trans "Voltar sem Salvar" %} -
+
- {% trans "O documento é salvo automaticamente enquanto você edita. Ao terminar, feche o editor e volte para a página da proposição." %} + {% trans "Use o botão 'Salvar e Voltar' para garantir que o documento seja salvo antes de sair. Se usar Ctrl+S, aguarde a confirmação de salvamento antes de sair." %}
@@ -47,17 +50,34 @@ {% endblock %} diff --git a/sapl/templates/materia/proposicao_detail.html b/sapl/templates/materia/proposicao_detail.html index 252afe7ac..6437d3232 100644 --- a/sapl/templates/materia/proposicao_detail.html +++ b/sapl/templates/materia/proposicao_detail.html @@ -1,239 +1,471 @@ -{% extends "crud/detail.html" %} +{% extends "base.html" %} {% load i18n common_tags %} {% load tz %} -{% block sub_actions %} - {{block.super}} - {% if user in object.autor.operadores.all %} -
- {% if object.texto_articulado.exists %} - {% trans "Texto Eletrônico" %} + +{% block webpack_loader_chunks_css %} +{{ block.super }} + +{% endblock webpack_loader_chunks_css %} + +{% block base_content %} +
+ {% if user in object.autor.operadores.all %} + +
+ + {% trans "Voltar para lista" %} + + +
+

+ {{ object.tipo }} — + {% if object.numero_proposicao %}#{{ object.numero_proposicao }}/{{ object.ano }}{% else %}{% trans "Rascunho" %}{% endif %} +

+ + {% if object.cancelado %} + Cancelada + {% elif object.data_devolucao %} + Devolvida + {% elif object.data_recebimento %} + Incorporada + {% elif object.data_envio %} + Aguardando Recebimento + {% else %} + Em Elaboração + {% endif %} +
+
+ + + {% if object.data_devolucao %} +
+ {% trans "Proposição Devolvida" %} + {{ object.justificativa_devolucao }} +
+ {% elif object.data_envio and not object.data_recebimento %} +
+ {% trans "Proposição enviada e aguardando recebimento pela Mesa Diretora." %} +
+ {% elif not object.data_envio and not object.texto_original and not object.texto_articulado.exists %} +
+ {% trans "Documento não criado" %} + {% trans "Use o botão 'Editar Documento' abaixo para criar o texto da proposição." %} +
+ {% endif %} + + +
+ {% if not object.data_envio %} + + {% trans "Editar Documento" %} + + {% if object.texto_original or object.texto_articulado.exists %} + + {% trans "Enviar Proposição" %} + + {% endif %} + + {% trans "Editar Dados" %} + + + {% trans "Excluir" %} + + {% else %} + + {% trans "Ver Recibo" %} + + {% if not object.data_recebimento %} + + {% trans "Retornar Proposição" %} + + {% endif %} {% endif %} + {% if object.texto_original %} - {% trans "Texto Original" %} - {% endif %} - {% if not object.data_envio %} - - {% trans "Editar com OnlyOffice" %} + + {% trans "Baixar Documento" %} {% endif %}
- {% endif %} -{% endblock sub_actions%} -{% block editions %} - {% if user in object.autor.operadores.all %} - {% if object.data_envio %} - {% block editions_actions_return %} -
- {% trans "Recibo de Envio" %} - {% if not object.data_recebimento %} - {% trans 'Retornar Proposição Enviada' %} - {% endif %} -
- {% endblock %} - {% else %} - {% block editions_actions_send %} - - - {% endblock %} - {% endif %} - {% endif %} -{% endblock editions %} -{% block detail_content %} - {% if user in object.autor.operadores.all %} -

{% model_verbose_name 'sapl.materia.models.Proposicao' %}

-
-
-
-

{%field_verbose_name object 'tipo'%}

-
-
{{object.tipo}}
-
+ + +
+
{% trans "Ementa" %}
+
{{ object.descricao }}
+
+ + +
+
{% trans "Informações" %}
+
+
+ + {{ object.tipo }}
-
- {% if object.data_devolucao %} -
- + {% if object.numero_proposicao %} +
+ + {{ object.numero_proposicao }}/{{ object.ano }}
- {% else %} + {% endif %} {% if object.data_envio %} -
-
-

{%field_verbose_name object 'data_envio' %}

-
-
{{object.data_envio}}
-
-
-
+
+ + {{ object.data_envio|date:"d/m/Y H:i" }} +
{% endif %} {% if object.data_recebimento %} -
-
-

{%field_verbose_name object 'data_recebimento'%}

-
-
{{object.data_recebimento}}
-
-
-
- {% elif object.data_envio %} -
- -
+
+ + {{ object.data_recebimento|date:"d/m/Y H:i" }} +
{% endif %} - {% endif %} -
-
-
-
-

{%field_verbose_name object 'descricao'%}

-
-
{{object.descricao}}
-
+ {% if object.data_devolucao %} +
+ + {{ object.data_devolucao|date:"d/m/Y H:i" }} +
+ {% endif %} +
+ + + {% if object.texto_original %} + {% trans "Criado" %} + {% elif object.texto_articulado.exists %} + {% trans "Texto Eletrônico" %} + {% else %} + {% trans "Não criado" %} + {% endif %} +
+ + {% if object.observacao %} -
-
-
-

{%field_verbose_name object 'observacao'%}

-
-
{{object.observacao}}
-
-
-
-
- {% endif %} - {% if object.data_envio %} -
-
-
-

Enviada por

-
-
- {# TODO remover condicional apos migrar as acoes para os devidos campos#} - {% if proposicao.usuario_envio %} - {{ proposicao.usuario_envio|format_user }} - {% else %} - {{ proposicao.user|format_user }} - {% endif %} -
-
-
-
-
+
+
{% trans "Observação" %}
+
{{ object.observacao }}
+
{% endif %} -
+ + + {% if object.conteudo_gerado_related or object.materia_de_vinculo %} +
+
{% trans "Vínculos" %}
{% if object.conteudo_gerado_related %} -
-

{% trans "Conteúdo Gerado" %}

- +
+ + + {{ object.conteudo_gerado_related }} +
{% endif %} {% if object.materia_de_vinculo %} -
-

{% trans "Vínculo com a Matéria Legislativa" %}

-
-
- Matéria
-      - - {{object.materia_de_vinculo}} - -
- {% if object.materia_de_vinculo.autoria_set.all %} - Autores - {% for a in object.materia_de_vinculo.autoria_set.all %} -
    {{a.autor}} - {% endfor %} - {% endif %} -
- Texto Original -
- {% if object.materia_de_vinculo.texto_original %} - - {{object.materia_de_vinculo.texto_original| to_str | split:"/" | get_last_item_from_list:-1}} - - {% endif %} -
-
-
+
+ + + {{ object.materia_de_vinculo }} +
{% endif %} - {% if not AppConfig.receber_recibo_proposicao %} - {% if object.hash_code %} -
-
-

{%field_verbose_name object 'hash_code'%}

-
-
{{object.hash_code}}
-
-
-
- {% endif %} - {% endif %}
+ {% endif %} + + {% if user.is_superuser %} -
+
+
{% trans "Informações do Sistema" %}
+
{% if proposicao.user %} -
-
-

Usuário

-
- -
-
-
+
+ + {{ proposicao.user }} +
{% endif %} {% if proposicao.ip %} -
-
-

IP

-
-
-
{{ proposicao.ip }}
-
-
-
-
+
+ + {{ proposicao.ip }} +
{% endif %} {% if proposicao.ultima_edicao %} -
-
-

Data e Hora da Edição

-
-
-
{{ proposicao.ultima_edicao }}
-
-
-
-
+
+ + {{ proposicao.ultima_edicao }} +
+ {% endif %} + {% if object.hash_code %} +
+ + {{ object.hash_code }} +
{% endif %}
+
{% endif %} + {% else %} -

- +{% endblock base_content %} diff --git a/sapl/templates/materia/proposicao_list.html b/sapl/templates/materia/proposicao_list.html index afd407763..85e599a5b 100644 --- a/sapl/templates/materia/proposicao_list.html +++ b/sapl/templates/materia/proposicao_list.html @@ -1,12 +1,302 @@ -{% extends "crud/list.html" %} -{% load i18n %} +{% extends "base.html" %} +{% load i18n common_tags crispy_forms_tags %} + +{% block webpack_loader_chunks_css %} +{{ block.super }} + +{% endblock webpack_loader_chunks_css %} + {% block base_content %} -{% block sub_actions %} -
- - {% blocktrans with verbose_name=view.verbose_name %} Histórico de {{ verbose_name }} {% endblocktrans %} +
+
+

{% trans "Proposições" %}

+
+ {{ stats.total|default:"0" }} total + {{ stats.elaboracao|default:"0" }} em elaboração + {{ stats.aguardando|default:"0" }} enviadas +
+
+ +
-{% endblock sub_actions %} - {{ block.super }} -{% endblock %} \ No newline at end of file + + {% if not proposicoes %} +
+ +

{% trans "Nenhuma proposição encontrada" %}

+ {% if view.create_url %} + + {% trans "Criar primeira proposição" %} + + {% endif %} +
+ {% else %} + {% for prop in proposicoes %} +
+
+
+ {{ prop.obj.tipo }} + {{ prop.status_label }} +
+ + {% if prop.data_envio_fmt %} + {{ prop.data_envio_fmt }} + {% else %} + — + {% endif %} + +
+ +
+ {{ prop.obj.descricao|truncatewords:40 }} +
+ + {% if prop.status == 'devolvida' and prop.obj.justificativa_devolucao %} +
+ {% trans "Devolvida:" %} {{ prop.obj.justificativa_devolucao }} +
+ {% endif %} + + +
+ {% endfor %} + {% endif %} +
+ +{% if is_paginated %} +
+ +

+ Página {{ page_obj.number }} de {{ paginator.num_pages }} +

+
+{% endif %} + +{% endblock %}