mirror of https://github.com/interlegis/sapl.git
Browse Source
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.mdpull/3858/head
16 changed files with 1346 additions and 11 deletions
@ -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. |
||||
@ -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'], |
||||
|
}, |
||||
|
), |
||||
|
] |
||||
@ -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) |
||||
@ -0,0 +1,18 @@ |
|||||
|
{% extends "crud/detail.html" %} |
||||
|
{% load i18n %} |
||||
|
|
||||
|
{% block editions %} |
||||
|
<div class="actions btn-group" role="group"> |
||||
|
{% if onlyoffice_edit_url %} |
||||
|
<a href="{{ onlyoffice_edit_url }}" class="btn btn-primary"> |
||||
|
<i class="fa fa-file-word-o"></i> {% trans 'Editar no OnlyOffice' %} |
||||
|
</a> |
||||
|
{% endif %} |
||||
|
{% if view.update_url %} |
||||
|
<a href="{{ view.update_url }}" class="btn btn-outline-primary">{% trans 'Editar Dados' %}</a> |
||||
|
{% endif %} |
||||
|
{% if view.delete_url %} |
||||
|
<a href="{{ view.delete_url }}" class="btn btn-outline-primary btn-outline-danger">{% trans 'Excluir' %}</a> |
||||
|
{% endif %} |
||||
|
</div> |
||||
|
{% endblock %} |
||||
@ -0,0 +1,22 @@ |
|||||
|
{% extends "base.html" %} |
||||
|
{% load i18n crispy_forms_tags %} |
||||
|
|
||||
|
{% block base_content %} |
||||
|
{% if onlyoffice_edit_url %} |
||||
|
<div class="alert alert-info"> |
||||
|
<strong><i class="fa fa-info-circle"></i> {% trans 'Dica:' %}</strong> |
||||
|
{% trans 'Para editar o cabeçalho, rodapé e formatação do template, use o editor OnlyOffice.' %} |
||||
|
<a href="{{ onlyoffice_edit_url }}" class="btn btn-primary btn-sm ml-2"> |
||||
|
<i class="fa fa-file-word-o"></i> {% trans 'Abrir no OnlyOffice' %} |
||||
|
</a> |
||||
|
</div> |
||||
|
{% else %} |
||||
|
<div class="alert alert-success"> |
||||
|
<strong><i class="fa fa-file-word-o"></i> {% trans 'Novo Template' %}</strong><br> |
||||
|
{% trans 'Após salvar, você será redirecionado automaticamente para o editor OnlyOffice onde poderá configurar o cabeçalho, rodapé e formatação do template.' %} |
||||
|
<br><small class="text-muted">{% trans 'O campo "Arquivo" é opcional. Se não enviar um arquivo, um documento em branco será criado automaticamente.' %}</small> |
||||
|
</div> |
||||
|
{% endif %} |
||||
|
|
||||
|
{% crispy form %} |
||||
|
{% endblock base_content %} |
||||
@ -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 |
||||
Loading…
Reference in new issue