Sistema de Apoio ao Processo Legislativo
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

532 lines
19 KiB

"""
Views e utilitários para integração com OnlyOffice Document Server
"""
import hashlib
import json
import logging
import os
import time
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.http import JsonResponse, HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from sapl.materia.models import Proposicao
from sapl.utils import get_base_url, build_onlyoffice_url, get_onlyoffice_browser_url
logger = logging.getLogger(__name__)
def generate_file_key(proposicao_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"proposicao_{proposicao_id}_user_{user_id}_{timestamp}"
return hashlib.md5(string_to_hash.encode()).hexdigest()
@login_required
@require_http_methods(["GET"])
def onlyoffice_config(request, pk):
"""
Retorna a configuração JSON para inicializar o editor OnlyOffice
"""
proposicao = get_object_or_404(Proposicao, pk=pk)
# Verifica se o usuário tem permissão para editar
is_autor_operador = proposicao.autor.operadores.filter(id=request.user.id).exists()
# Setor Legislativo pode editar quando a proposição está com o setor
from sapl.rules import SGVP_GROUP_SETOR_LEGISLATIVO
is_setor = request.user.groups.filter(name=SGVP_GROUP_SETOR_LEGISLATIVO).exists()
setor_can_edit = (
is_setor and
proposicao.data_envio_setor is not None and
proposicao.data_retorno_setor is None and
proposicao.data_envio is None
)
can_edit = (
not proposicao.data_envio and
(is_autor_operador or setor_can_edit)
)
# URLs para o OnlyOffice acessar (dentro da rede Docker)
# Usa SAPL_INTERNAL_URL quando configurada para comunicação container-a-container
download_url = build_onlyoffice_url(
request,
reverse('sapl.materia:onlyoffice_download', kwargs={'pk': pk})
)
callback_url = build_onlyoffice_url(
request,
reverse('sapl.materia:onlyoffice_callback', kwargs={'pk': pk})
)
# Configuração do documento
document_config = {
"fileType": "docx",
"key": generate_file_key(proposicao.pk, request.user.pk),
"title": f"Proposicao_{proposicao.pk}.docx",
"url": download_url,
}
# Configuração do editor
editor_config = {
"mode": "edit" if can_edit else "view",
"lang": "pt-BR",
"callbackUrl": callback_url,
"user": {
"id": str(request.user.pk),
"name": request.user.get_full_name() or request.user.username,
},
"customization": {
"autosave": True,
"forcesave": True,
"comments": True,
"chat": False,
},
}
config = {
"documentType": "word",
"document": document_config,
"editorConfig": editor_config,
"height": "600px",
"width": "100%",
}
# Adiciona JWT se estiver habilitado
if settings.ONLYOFFICE_JWT_ENABLED and settings.ONLYOFFICE_JWT_SECRET:
import jwt
token = jwt.encode(config, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
config['token'] = token
return JsonResponse(config)
@require_http_methods(["GET"])
def onlyoffice_download(request, pk):
"""
Endpoint para o OnlyOffice baixar o documento
Se não existe arquivo, retorna um documento em branco
NOTA: Sem @login_required pois o OnlyOffice não tem sessão do Django
"""
proposicao = get_object_or_404(Proposicao, pk=pk)
# Verificação de permissão apenas se houver usuário autenticado
# Permite: operadores do autor OU usuários com permissão de protocolo
if request.user.is_authenticated:
is_operator = proposicao.autor.operadores.filter(id=request.user.id).exists()
has_protocol_perm = request.user.has_perm('protocoloadm.add_documentoadministrativo')
if not is_operator and not has_protocol_perm:
return HttpResponse("Sem permissão", status=403)
# Se já tem arquivo, retorna ele
if proposicao.texto_original:
try:
with open(proposicao.texto_original.path, 'rb') as f:
content = f.read()
response = HttpResponse(content, content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document')
response['Content-Disposition'] = f'attachment; filename="Proposicao_{pk}.docx"'
return response
except Exception as e:
logger.error(f"Erro ao ler arquivo: {e}")
# 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 sapl.utils_template import criar_documento_padrao
from io import BytesIO
doc = criar_documento_padrao()
doc.add_heading(f'Proposição {proposicao.tipo}', 0)
doc.add_paragraph(f'Ementa: {proposicao.descricao}')
doc.add_paragraph('')
doc.add_paragraph('Digite o texto da proposição abaixo:')
doc.add_paragraph('')
file_stream = BytesIO()
doc.save(file_stream)
file_stream.seek(0)
response = HttpResponse(
file_stream.getvalue(),
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
response['Content-Disposition'] = f'attachment; filename="Proposicao_{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 onlyoffice_callback(request, pk):
"""
Callback chamado pelo OnlyOffice quando o documento é salvo
Status codes:
0 - Nenhum documento com a chave identificada foi encontrado
1 - Documento está sendo editado
2 - Documento está pronto para salvar
3 - Ocorreu erro ao salvar o documento
4 - Documento está fechado sem alterações
6 - Documento está sendo editado, mas a versão atual do documento foi salva
7 - Ocorreu um erro de salvamento forçado ou salvamento automático
"""
try:
body = json.loads(request.body.decode('utf-8'))
status = body.get('status')
download_url = body.get('url')
logger.info(f"OnlyOffice callback para proposição {pk}: status={status}, url={download_url}, body={body}")
# Status 2 ou 6 significa que o documento foi salvo
if status in [2, 6] and download_url:
logger.info(f"URL original recebida: {download_url}")
# Substitui o host da URL pelo ONLYOFFICE_URL configurado
# O OnlyOffice retorna URLs com seu próprio hostname que pode não ser acessível
import re
from urllib.parse import urlparse
onlyoffice_parsed = urlparse(settings.ONLYOFFICE_URL)
onlyoffice_base = f"{onlyoffice_parsed.scheme}://{onlyoffice_parsed.netloc}"
download_url = re.sub(
r'https?://[^/]+',
onlyoffice_base,
download_url
)
logger.info(f"URL substituída para: {download_url}")
proposicao = get_object_or_404(Proposicao, pk=pk)
# Baixa o documento do OnlyOffice
import requests
try:
response = requests.get(download_url, timeout=30)
logger.info(f"Download response: status={response.status_code}, size={len(response.content)}")
except Exception as e:
logger.error(f"Erro ao fazer requisição de download: {e}")
return JsonResponse({"error": 1})
if response.status_code == 200:
# Salva o arquivo
from django.core.files.base import ContentFile
filename = f"proposicao_{pk}_{int(time.time())}.docx"
# Remove arquivo antigo se existir
if proposicao.texto_original:
old_file = proposicao.texto_original.name
proposicao.texto_original.delete(save=False)
logger.info(f"Arquivo antigo removido: {old_file}")
try:
proposicao.texto_original.save(
filename,
ContentFile(response.content),
save=False
)
# Regenera o hash_code se a proposição está em confirmação
if proposicao.data_envio and not proposicao.data_recebimento:
from sapl.utils import gerar_hash_arquivo
proposicao.hash_code = gerar_hash_arquivo(
proposicao.texto_original.path, str(proposicao.pk))
proposicao.save()
logger.info(f"Documento salvo com sucesso: {filename}")
cache.set(f'proposicao_saved_{pk}', True, timeout=120)
cache.delete(f'proposicao_forcesave_{pk}')
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})
# Para outros status, apenas retorna sucesso
return JsonResponse({"error": 0})
except Exception as e:
logger.error(f"Erro no callback OnlyOffice: {e}")
return JsonResponse({"error": 1})
@login_required
@require_http_methods(["GET"])
def proposicao_check_doc(request, pk):
"""
Verifica se a proposição já possui documento salvo.
Usado pelo frontend para polling após forcesave do OnlyOffice.
Usa cache para rastrear o ciclo forcesave → callback → check.
"""
if cache.get(f'proposicao_saved_{pk}'):
cache.delete(f'proposicao_saved_{pk}')
cache.delete(f'proposicao_forcesave_{pk}')
return JsonResponse({"has_document": True})
if cache.get(f'proposicao_forcesave_{pk}'):
return JsonResponse({"has_document": False})
proposicao = get_object_or_404(Proposicao, pk=pk)
has_document = bool(proposicao.texto_original)
return JsonResponse({"has_document": has_document})
@login_required
@require_http_methods(["POST"])
def proposicao_forcesave(request, pk):
"""
Força o salvamento do documento via OnlyOffice Command Service.
Chama o endpoint /coauthoring/CommandService.ashx com o comando forcesave.
"""
proposicao = get_object_or_404(Proposicao, pk=pk)
body = json.loads(request.body.decode('utf-8'))
doc_key = body.get('key')
if not doc_key:
return JsonResponse({"error": "key é obrigatório"}, status=400)
# Monta a URL do Command Service do OnlyOffice
command_url = f"{settings.ONLYOFFICE_URL}/coauthoring/CommandService.ashx"
payload = {
"c": "forcesave",
"key": doc_key
}
# Adiciona JWT se habilitado
if settings.ONLYOFFICE_JWT_ENABLED and settings.ONLYOFFICE_JWT_SECRET:
import jwt
token = jwt.encode(payload, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256')
payload['token'] = token
try:
import requests as http_requests
logger.info(f"Forcesave para proposição {pk}: key={doc_key}")
resp = http_requests.post(command_url, json=payload, timeout=10)
result = resp.json()
logger.info(f"Forcesave response para proposição {pk}: {result}")
cache.set(f'proposicao_forcesave_{pk}', True, timeout=120)
return JsonResponse(result)
except Exception as e:
logger.error(f"Erro ao chamar forcesave para proposição {pk}: {e}")
return JsonResponse({"error": str(e)}, status=500)
@login_required
def onlyoffice_editor(request, pk):
"""
Renderiza a página com o editor OnlyOffice integrado
"""
from django.shortcuts import render
proposicao = get_object_or_404(Proposicao, pk=pk)
# Verifica se o usuário tem permissão
is_autor_operador = proposicao.autor.operadores.filter(id=request.user.id).exists()
from sapl.rules import SGVP_GROUP_SETOR_LEGISLATIVO
is_setor = request.user.groups.filter(name=SGVP_GROUP_SETOR_LEGISLATIVO).exists()
setor_can_edit = (
is_setor and
proposicao.data_envio_setor is not None and
proposicao.data_retorno_setor is None and
proposicao.data_envio is None
)
if not is_autor_operador and not setor_can_edit:
from django.contrib import messages
from django.shortcuts import redirect
messages.error(request, 'Você não tem permissão para editar esta proposição.')
return redirect('sapl.materia:proposicao_detail', pk=pk)
# Verifica se já foi enviada
if proposicao.data_envio:
from django.contrib import messages
from django.shortcuts import redirect
messages.warning(request, 'Esta proposição já foi enviada e não pode mais ser editada.')
return redirect('sapl.materia:proposicao_detail', pk=pk)
# URL do OnlyOffice acessível pelo navegador do usuário
onlyoffice_url = get_onlyoffice_browser_url(request)
context = {
'proposicao': proposicao,
'onlyoffice_url': onlyoffice_url,
'config_url': reverse('sapl.materia:onlyoffice_config', kwargs={'pk': pk}),
}
return render(request, 'materia/onlyoffice_editor.html', context)
@login_required
@require_http_methods(["GET"])
def onlyoffice_confirmar_config(request, pk):
"""
Retorna a configuração JSON para o editor OnlyOffice na página de confirmação
"""
proposicao = get_object_or_404(Proposicao, pk=pk)
# Verifica se o usuário tem permissão de protocolo
if not request.user.has_perm('protocoloadm.add_documentoadministrativo'):
return JsonResponse({"error": "Sem permissão"}, status=403)
# Verifica se a proposição está em estado de confirmação
# (enviada mas não recebida)
if not proposicao.data_envio or proposicao.data_recebimento:
return JsonResponse({"error": "Proposição não está em confirmação"}, status=400)
# URLs para o OnlyOffice acessar o documento
download_url = build_onlyoffice_url(
request,
reverse('sapl.materia:onlyoffice_download', kwargs={'pk': pk})
)
callback_url = build_onlyoffice_url(
request,
reverse('sapl.materia:onlyoffice_callback', kwargs={'pk': pk})
)
# Configuração do documento
document_config = {
"fileType": "docx",
"key": generate_file_key(proposicao.pk, request.user.pk),
"title": f"Proposicao_{proposicao.pk}.docx",
"url": download_url,
"permissions": {
"edit": True,
"download": True,
"print": True,
"review": True,
"comment": True,
},
}
# Configuração do editor - permite edição na confirmação
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)
@login_required
def onlyoffice_confirmar_editor(request, pk, hash):
"""
Renderiza a página com o editor OnlyOffice para proposição em confirmação
"""
from django.shortcuts import render, redirect
from django.contrib import messages
from sapl.materia.forms import SEPARADOR_HASH_PROPOSICAO
from sapl.utils import gerar_hash_arquivo
proposicao = get_object_or_404(Proposicao, pk=pk)
# Verifica se o usuário tem permissão de protocolo
if not request.user.has_perm('protocoloadm.add_documentoadministrativo'):
messages.error(request, 'Você não tem permissão para editar esta proposição.')
return redirect('sapl.materia:proposicao-confirmar', hash=hash, pk=pk)
# Verifica se a proposição está em estado de confirmação
if not proposicao.data_envio or proposicao.data_recebimento:
messages.warning(request, 'Esta proposição não está em estado de confirmação.')
return redirect('sapl.materia:proposicao-confirmar', hash=hash, pk=pk)
# Verifica o hash (mesma lógica de ConfirmarProposicao)
if proposicao.texto_articulado.exists():
ta = proposicao.texto_articulado.first()
hasher = 'P' + ta.hash() + SEPARADOR_HASH_PROPOSICAO + str(proposicao.pk)
else:
hasher = gerar_hash_arquivo(
proposicao.texto_original.path,
str(proposicao.pk)) if proposicao.texto_original else None
expected_hash = 'P%s%s%s' % (hash, SEPARADOR_HASH_PROPOSICAO, proposicao.pk)
if hasher != expected_hash:
messages.error(request, 'Link de confirmação inválido.')
return redirect('sapl.materia:proposicao-confirmar', hash=hash, pk=pk)
# URL do OnlyOffice acessível pelo navegador do usuário
onlyoffice_url = get_onlyoffice_browser_url(request)
context = {
'proposicao': proposicao,
'hash': hash,
'onlyoffice_url': onlyoffice_url,
'config_url': reverse('sapl.materia:onlyoffice_confirmar_config', kwargs={'pk': pk}),
'voltar_url': reverse('sapl.materia:proposicao-confirmar', kwargs={'hash': hash, 'pk': pk}),
}
return render(request, 'materia/onlyoffice_confirmar_editor.html', context)