Browse Source

Feat(Templates): Sistema de templates de documentos para OnlyOffice

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
pull/3858/head
rangelbruno 8 months ago
parent
commit
2bd536b40b
  1. 214
      docs/templates-documentos.md
  2. 97
      sapl/base/forms.py
  3. 62
      sapl/base/migrations/0061_documenttemplate.py
  4. 139
      sapl/base/models.py
  5. 254
      sapl/base/onlyoffice_template_views.py
  6. 19
      sapl/base/urls.py
  7. 113
      sapl/base/views.py
  8. 78
      sapl/materia/onlyoffice_materia_views.py
  9. 40
      sapl/materia/onlyoffice_views.py
  10. 38
      sapl/norma/onlyoffice_views.py
  11. 38
      sapl/protocoloadm/onlyoffice_views.py
  12. 18
      sapl/templates/base/documenttemplate_detail.html
  13. 22
      sapl/templates/base/documenttemplate_form.html
  14. 7
      sapl/templates/base/layouts.yaml
  15. 3
      sapl/templates/menu_tabelas_auxiliares.yaml
  16. 215
      sapl/utils_template.py

214
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.

97
sapl/base/forms.py

@ -1293,6 +1293,103 @@ class PartidoForm(FileFieldCheckMixin, ModelForm):
return cleaned_data 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): class SaplSearchForm(ModelSearchForm):
def search(self): def search(self):

62
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'],
},
),
]

139
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.cache import cache
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models 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.models.signals import post_migrate
from django.db.utils import DEFAULT_DB_ALIAS from django.db.utils import DEFAULT_DB_ALIAS
from django.utils.translation import ugettext_lazy as _ 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) 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_OSTENSIVO = 'O'
DOC_ADM_RESTRITIVO = 'R' DOC_ADM_RESTRITIVO = 'R'
@ -482,3 +490,132 @@ class Metadata(models.Model):
def __str__(self): def __str__(self):
return f'Metadata de {self.content_object}' 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

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

19
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, from sapl.base.views import (AutorCrud, ConfirmarEmailView, TipoAutorCrud, get_estatistica,
RecuperarSenhaEmailView, RecuperarSenhaFinalizadoView, RecuperarSenhaEmailView, RecuperarSenhaFinalizadoView,
RecuperarSenhaConfirmaView, RecuperarSenhaCompletoView, IndexView, UserCrud, 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 sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL
from .apps import AppConfig from .apps import AppConfig
from .views import (LoginSapl, AlterarSenha, AppConfigCrud, CasaLegislativaCrud, 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/criar-ajax/$', CriarAutorAjaxView.as_view(), name='criar_autor_ajax'),
url(r'^sistema/autor/', include(AutorCrud.get_urls())), 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<pk>\d+)/onlyoffice/editor$',
template_onlyoffice_editor, name='template_onlyoffice_editor'),
url(r'^sistema/template-documento/(?P<pk>\d+)/onlyoffice/config$',
template_onlyoffice_config, name='template_onlyoffice_config'),
url(r'^sistema/template-documento/(?P<pk>\d+)/onlyoffice/download$',
template_onlyoffice_download, name='template_onlyoffice_download'),
url(r'^sistema/template-documento/(?P<pk>\d+)/onlyoffice/callback$',
template_onlyoffice_callback, name='template_onlyoffice_callback'),
url(r'^sistema/guia-projeto/$', GuiaProjetoView.as_view(), url(r'^sistema/guia-projeto/$', GuiaProjetoView.as_view(),
name='guia_projeto'), name='guia_projeto'),
url(r'^sistema/fluxo-proposicoes/$', FluxoProposicoesView.as_view(), url(r'^sistema/fluxo-proposicoes/$', FluxoProposicoesView.as_view(),

113
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, from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured,
SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured,
get_client_ip, sapn_is_enabled, is_weak_password, ratelimit_ip) get_client_ip, sapn_is_enabled, is_weak_password, ratelimit_ip)
from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm,
from .models import AppConfig, CasaLegislativa EstatisticasAcessoNormasForm, DocumentTemplateForm)
from .models import AppConfig, CasaLegislativa, DocumentTemplate
def get_casalegislativa(): def get_casalegislativa():
@ -1747,3 +1748,111 @@ class CriarAutorAjaxView(PermissionRequiredMixin, FormView):
'success': False, 'success': False,
'error': f'Erro ao criar autor: {str(e)}' 'error': f'Erro ao criar autor: {str(e)}'
}, status=500) }, 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

78
sapl/materia/onlyoffice_materia_views.py

@ -121,7 +121,43 @@ def materia_onlyoffice_download(request, pk):
except Exception as e: except Exception as e:
logger.error(f"Erro ao ler arquivo: {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: try:
from docx import Document from docx import Document
from io import BytesIO from io import BytesIO
@ -332,7 +368,45 @@ def docacessorio_onlyoffice_download(request, pk):
except Exception as e: except Exception as e:
logger.error(f"Erro ao ler arquivo: {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: try:
from docx import Document from docx import Document
from io import BytesIO from io import BytesIO

40
sapl/materia/onlyoffice_views.py

@ -130,7 +130,43 @@ def onlyoffice_download(request, pk):
except Exception as e: except Exception as e:
logger.error(f"Erro ao ler arquivo: {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: try:
from docx import Document from docx import Document
from io import BytesIO 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('Digite o texto da proposição abaixo:')
doc.add_paragraph('') doc.add_paragraph('')
# Salva em memória
file_stream = BytesIO() file_stream = BytesIO()
doc.save(file_stream) doc.save(file_stream)
file_stream.seek(0) file_stream.seek(0)
@ -155,7 +190,6 @@ def onlyoffice_download(request, pk):
return response return response
except ImportError: except ImportError:
# Se python-docx não está instalado, retorna erro
logger.error("python-docx não está instalado") logger.error("python-docx não está instalado")
return HttpResponse("Erro: python-docx não instalado", status=500) return HttpResponse("Erro: python-docx não instalado", status=500)

38
sapl/norma/onlyoffice_views.py

@ -117,7 +117,43 @@ def norma_onlyoffice_download(request, pk):
except Exception as e: except Exception as e:
logger.error(f"Erro ao ler arquivo: {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: try:
from docx import Document from docx import Document
from io import BytesIO from io import BytesIO

38
sapl/protocoloadm/onlyoffice_views.py

@ -117,7 +117,43 @@ def docadm_onlyoffice_download(request, pk):
except Exception as e: except Exception as e:
logger.error(f"Erro ao ler arquivo: {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: try:
from docx import Document from docx import Document
from io import BytesIO from io import BytesIO

18
sapl/templates/base/documenttemplate_detail.html

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

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

7
sapl/templates/base/layouts.yaml

@ -76,3 +76,10 @@ AutorCreate:
OperadorAutor: OperadorAutor:
{% trans 'Operador de Autor' %}: {% trans 'Operador de Autor' %}:
- user - user
DocumentTemplate:
{% trans 'Template de Documento' %}:
- nome:6 tipo_conteudo:6
- content_type:6 object_id:6
- descricao
- arquivo:8 ativo:2 padrao:2

3
sapl/templates/menu_tabelas_auxiliares.yaml

@ -14,6 +14,9 @@
- title: {% trans 'Autores' %} - title: {% trans 'Autores' %}
url: sapl.base:autor_list url: sapl.base:autor_list
css_class: btn btn-link 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%} {% if not sapl_as_sapn%}
- title: {% trans 'Módulo Parlamentares' %} - title: {% trans 'Módulo Parlamentares' %}
css_class: head_title css_class: head_title

215
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 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 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…
Cancel
Save