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