Browse Source

feat: Melhorias no módulo TCE • Ajustes visuais e aprimoramentos funcionais

pull/3858/head
rangelbruno 12 months ago
parent
commit
214e019d7b
  1. 145
      sapl/tce/admin.py
  2. 491
      sapl/tce/admin_views.py
  3. 18
      sapl/tce/migrations/0003_tcepasta_subpastas_customizadas.py
  4. 26
      sapl/tce/models.py
  5. 11
      sapl/tce/templatetags/tce_filters.py
  6. 10
      sapl/tce/urls.py
  7. 30
      sapl/templates/admin/tce/app_index.html
  8. 1100
      sapl/templates/admin/tce/processo_detail.html
  9. 947
      sapl/templates/admin/tce/subpasta_detail.html

145
sapl/tce/admin.py

@ -6,146 +6,5 @@ from django.utils.html import format_html
from .models import TceProcesso, TcePasta, TceArquivo, TceAssinatura, TceLog from .models import TceProcesso, TcePasta, TceArquivo, TceAssinatura, TceLog
@admin.register(TceProcesso) # Modelos TCE não são mais acessíveis via Django Admin
class TceProcessoAdmin(admin.ModelAdmin): # Acesse através de /tce/admin/ para o dashboard customizado
"""Admin para Processos TCE"""
change_list_template = 'admin/tce/change_list.html'
change_form_template = 'admin/tce/change_form.html'
list_display = ['titulo', 'ano', 'periodo', 'status_badge', 'progresso_bar',
'total_arquivos', 'criado_em']
list_filter = ['status', 'ano', 'criado_em']
search_fields = ['titulo', 'descricao', 'numero_protocolo']
readonly_fields = ['criado_em', 'atualizado_em', 'progresso_percent']
fieldsets = (
('Informações Básicas', {
'fields': ('titulo', 'descricao', 'ano', 'periodo', 'status')
}),
('Protocolo TCE', {
'fields': ('numero_protocolo', 'data_envio', 'recibo_tce', 'hash_envio'),
'classes': ('collapse',)
}),
('Auditoria', {
'fields': ('criado_em', 'atualizado_em', 'criado_por', 'progresso_percent'),
'classes': ('collapse',)
}),
)
def status_badge(self, obj):
cores = {
'rascunho': 'gray',
'em_preparacao': 'orange',
'validado': 'blue',
'enviado': 'green',
'devolvido': 'red',
'aprovado': 'darkgreen'
}
return format_html(
'<span style="color: {}; font-weight: bold;">● {}</span>',
cores.get(obj.status, 'gray'),
obj.get_status_display()
)
status_badge.short_description = 'Status'
def progresso_bar(self, obj):
percent = obj.progresso_percent
cor = 'success' if percent == 100 else 'warning' if percent >= 50 else 'danger'
return format_html(
'<div style="width:100px; background:#eee; border-radius:3px;">'
'<div style="width:{}%; background:{}; color:white; text-align:center; border-radius:3px;">{}</div>'
'</div>',
percent, cor, f'{percent}%'
)
progresso_bar.short_description = 'Progresso'
@admin.register(TcePasta)
class TcePastaAdmin(admin.ModelAdmin):
"""Admin para Pastas TCE"""
change_list_template = 'admin/tce/change_list.html'
change_form_template = 'admin/tce/change_form.html'
list_display = ['nome', 'processo', 'categoria', 'total_arquivos', 'criado_em']
list_filter = ['categoria', 'criado_em']
search_fields = ['nome', 'processo__titulo']
@admin.register(TceArquivo)
class TceArquivoAdmin(admin.ModelAdmin):
"""Admin para Arquivos TCE"""
change_list_template = 'admin/tce/change_list.html'
change_form_template = 'admin/tce/change_form.html'
list_display = ['nome_original', 'pasta', 'tipo', 'tamanho_mb', 'status_badge',
'checks', 'criado_em']
list_filter = ['tipo', 'tamanho_ok', 'ocr_ok', 'assinado', 'p7s_gerado', 'validado']
search_fields = ['nome_original', 'pasta__nome', 'pasta__processo__titulo']
readonly_fields = ['criado_em', 'atualizado_em', 'hash_sha256']
def status_badge(self, obj):
return format_html(
'<span class="badge" style="background:{}; color:white; padding:3px 8px; border-radius:3px;">{}</span>',
obj.status_cor,
obj.status_geral.replace('_', ' ').title()
)
status_badge.short_description = 'Status'
def checks(self, obj):
checks = [
('' if obj.tamanho_ok else '', 'green' if obj.tamanho_ok else 'red', '≤5MB'),
('' if obj.ocr_ok else '', 'green' if obj.ocr_ok else 'red', 'OCR'),
('' if obj.assinado else '', 'green' if obj.assinado else 'red', 'Assinado'),
('' if obj.p7s_gerado else '', 'green' if obj.p7s_gerado else 'red', '.p7s'),
]
html = ''.join([
f'<span style="color:{cor}; font-weight:bold;" title="{titulo}">{check}</span> '
for check, cor, titulo in checks
])
return format_html(html)
checks.short_description = 'Checklist'
@admin.register(TceAssinatura)
class TceAssinaturaAdmin(admin.ModelAdmin):
"""Admin para Assinaturas"""
change_list_template = 'admin/tce/change_list.html'
change_form_template = 'admin/tce/change_form.html'
list_display = ['arquivo', 'ordem', 'papel', 'certificado_tipo', 'valida_badge', 'assinado_em']
list_filter = ['papel', 'certificado_tipo', 'valida', 'assinado_em']
search_fields = ['arquivo__nome_original', 'certificado_nome']
def valida_badge(self, obj):
return format_html(
'<span style="color: {};">● {}</span>',
'green' if obj.valida else 'red',
'Válida' if obj.valida else 'Inválida'
)
valida_badge.short_description = 'Validação'
@admin.register(TceLog)
class TceLogAdmin(admin.ModelAdmin):
"""Admin para Logs (somente leitura)"""
change_list_template = 'admin/tce/change_list.html'
change_form_template = 'admin/tce/change_form.html'
list_display = ['processo', 'tipo', 'descricao_resumida', 'sucesso_icon', 'usuario', 'ocorrido_em']
list_filter = ['tipo', 'sucesso', 'ocorrido_em']
search_fields = ['processo__titulo', 'descricao']
readonly_fields = ['processo', 'arquivo', 'tipo', 'descricao', 'dados', 'sucesso',
'erro_mensagem', 'ocorrido_em', 'usuario', 'ip']
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def descricao_resumida(self, obj):
return obj.descricao[:100] + '...' if len(obj.descricao) > 100 else obj.descricao
descricao_resumida.short_description = 'Descrição'
def sucesso_icon(self, obj):
return format_html(
'<span style="color: {}; font-size: 16px;">{}</span>',
'green' if obj.sucesso else 'red',
'' if obj.sucesso else ''
)
sucesso_icon.short_description = 'Status'

491
sapl/tce/admin_views.py

@ -92,14 +92,9 @@ def api_create_processo(request):
}, status=400) }, status=400)
@login_required def _get_subpastas_estrutura():
def processo_detail(request, processo_id): """Retorna a estrutura de subpastas por categoria"""
"""View: Detalhes do processo com subpastas""" return {
processo = get_object_or_404(TceProcesso, id=processo_id)
pastas = processo.pastas.all()
# Estrutura de subpastas por categoria
subpastas_estrutura = {
'financeiro': [ 'financeiro': [
'Balancetes mensais (receitas e despesas)', 'Balancetes mensais (receitas e despesas)',
'Execução orçamentária (RREO, RGF)', 'Execução orçamentária (RREO, RGF)',
@ -120,6 +115,29 @@ def processo_detail(request, processo_id):
] ]
} }
@login_required
def processo_detail(request, processo_id):
"""View: Detalhes do processo com subpastas"""
processo = get_object_or_404(TceProcesso, id=processo_id)
pastas = processo.pastas.all()
# Mesclar subpastas padrão com customizadas para cada pasta
subpastas_estrutura = _get_subpastas_estrutura()
for pasta in pastas:
categoria = pasta.categoria
if categoria in subpastas_estrutura:
# Adicionar subpastas customizadas da pasta
subpastas_custom = pasta.get_subpastas_customizadas()
if subpastas_custom:
subpastas_estrutura[categoria] = list(subpastas_estrutura[categoria]) + subpastas_custom
else:
# Pasta customizada (categoria não existe na estrutura padrão)
# Criar entrada na estrutura apenas com as subpastas customizadas
subpastas_custom = pasta.get_subpastas_customizadas()
if subpastas_custom:
subpastas_estrutura[categoria] = subpastas_custom
context = { context = {
'title': f'Processo: {processo.titulo}', 'title': f'Processo: {processo.titulo}',
'processo': processo, 'processo': processo,
@ -130,14 +148,48 @@ def processo_detail(request, processo_id):
return render(request, 'admin/tce/processo_detail.html', context) return render(request, 'admin/tce/processo_detail.html', context)
@login_required
def processo_subpasta_detail(request, processo_id, pasta_id, subpasta):
"""View: Detalhes de uma subpasta específica"""
import urllib.parse
processo = get_object_or_404(TceProcesso, id=processo_id)
pasta = get_object_or_404(TcePasta, id=pasta_id, processo=processo)
# Decodificar o nome da subpasta da URL
subpasta_nome = urllib.parse.unquote(subpasta)
# Buscar arquivos da subpasta
arquivos = TceArquivo.objects.filter(
pasta=pasta,
subpasta=subpasta_nome
).order_by('-criado_em')
context = {
'title': f'{subpasta_nome} - {processo.titulo}',
'processo': processo,
'pasta': pasta,
'subpasta_nome': subpasta_nome,
'arquivos': arquivos,
}
return render(request, 'admin/tce/subpasta_detail.html', context)
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def api_list_arquivos(request): def api_list_arquivos(request):
"""API: Listar arquivos de uma subpasta""" """API: Listar arquivos de uma subpasta"""
import sys
try: try:
pasta_id = request.GET.get('pasta_id') pasta_id = request.GET.get('pasta_id')
subpasta = request.GET.get('subpasta') subpasta = request.GET.get('subpasta')
sys.stderr.write(f"\n=== API LIST ARQUIVOS ===\n")
sys.stderr.write(f"pasta_id: {pasta_id}\n")
sys.stderr.write(f"subpasta: {subpasta}\n")
sys.stderr.flush()
if not pasta_id or not subpasta: if not pasta_id or not subpasta:
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
@ -150,6 +202,11 @@ def api_list_arquivos(request):
subpasta=subpasta subpasta=subpasta
).order_by('-criado_em') ).order_by('-criado_em')
sys.stderr.write(f"Total de arquivos encontrados: {arquivos.count()}\n")
for a in arquivos:
sys.stderr.write(f" - {a.id} | {a.nome_original}\n")
sys.stderr.flush()
data = { data = {
'success': True, 'success': True,
'arquivos': [{ 'arquivos': [{
@ -251,6 +308,124 @@ def api_upload_arquivo(request):
}, status=500) }, status=500)
@login_required
@require_http_methods(["POST"])
def api_criar_pasta(request):
"""API: Criar nova pasta e adicionar subpasta inicial"""
try:
processo_id = request.GET.get('processo_id') or request.POST.get('processo_id')
nome_pasta = request.POST.get('nome_pasta', '').strip()
nome_subpasta = request.POST.get('nome_subpasta', '').strip()
# Obter processo_id da URL se não veio no POST
if not processo_id:
# Tentar pegar do referer
referer = request.META.get('HTTP_REFERER', '')
import re
match = re.search(r'/processo/([a-f0-9-]+)/', referer)
if match:
processo_id = match.group(1)
if not processo_id or not nome_pasta or not nome_subpasta:
return JsonResponse({
'success': False,
'error': 'Parâmetros processo_id, nome_pasta e nome_subpasta são obrigatórios'
}, status=400)
processo = get_object_or_404(TceProcesso, id=processo_id)
# Verificar se pasta já existe
if TcePasta.objects.filter(processo=processo, nome=nome_pasta).exists():
return JsonResponse({
'success': False,
'error': 'Já existe uma pasta com este nome'
}, status=400)
# Criar a nova pasta (categoria "outros")
nova_pasta = TcePasta.objects.create(
processo=processo,
nome=nome_pasta,
categoria='outros',
ordem=processo.pastas.count()
)
# Adicionar a primeira subpasta
nova_pasta.set_subpastas_customizadas([nome_subpasta])
nova_pasta.save()
# Criar log
TceLog.objects.create(
processo=processo,
tipo='pasta_criada',
descricao=f'Nova pasta "{nome_pasta}" criada com subpasta "{nome_subpasta}"',
usuario=request.user,
sucesso=True
)
return JsonResponse({
'success': True,
'message': 'Pasta e subpasta criadas com sucesso!',
'pasta_id': str(nova_pasta.id)
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
@login_required
@require_http_methods(["POST"])
def api_adicionar_subpasta(request):
"""API: Adicionar nova subpasta a uma pasta"""
try:
pasta_id = request.POST.get('pasta_id')
nome_subpasta = request.POST.get('nome_subpasta', '').strip()
if not pasta_id or not nome_subpasta:
return JsonResponse({
'success': False,
'error': 'Parâmetros pasta_id e nome_subpasta são obrigatórios'
}, status=400)
pasta = get_object_or_404(TcePasta, id=pasta_id)
# Verificar se a subpasta já existe
subpastas_padrao = _get_subpastas_estrutura().get(pasta.categoria, [])
subpastas_customizadas = pasta.get_subpastas_customizadas()
if nome_subpasta in subpastas_padrao or nome_subpasta in subpastas_customizadas:
return JsonResponse({
'success': False,
'error': 'Já existe uma subpasta com este nome'
}, status=400)
# Adicionar a nova subpasta
subpastas_customizadas.append(nome_subpasta)
pasta.set_subpastas_customizadas(subpastas_customizadas)
pasta.save()
# Criar log
TceLog.objects.create(
processo=pasta.processo,
tipo='pasta_criada',
descricao=f'Nova subpasta "{nome_subpasta}" adicionada à pasta "{pasta.nome}"',
usuario=request.user,
sucesso=True
)
return JsonResponse({
'success': True,
'message': 'Subpasta adicionada com sucesso!',
'subpasta': nome_subpasta
})
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
@login_required @login_required
@require_http_methods(["DELETE", "POST"]) @require_http_methods(["DELETE", "POST"])
def api_delete_arquivo(request, arquivo_id): def api_delete_arquivo(request, arquivo_id):
@ -364,3 +539,303 @@ def api_delete_arquivo(request, arquivo_id):
}, status=500) }, status=500)
response['X-Debug-View'] = 'api_delete_arquivo_ERROR' response['X-Debug-View'] = 'api_delete_arquivo_ERROR'
return response return response
@login_required
@require_http_methods(["GET"])
def visualizar_arquivo(request, arquivo_id):
"""
Visualizar/download de arquivo TCE
"""
from django.http import FileResponse, Http404
import os
try:
arquivo = TceArquivo.objects.get(id=arquivo_id)
# Determinar qual arquivo servir (priorizar versões processadas)
arquivo_para_servir = arquivo.arquivo_final
if not arquivo_para_servir or not os.path.exists(arquivo_para_servir.path):
raise Http404("Arquivo não encontrado")
# Servir o arquivo
response = FileResponse(
open(arquivo_para_servir.path, 'rb'),
content_type='application/pdf'
)
response['Content-Disposition'] = f'inline; filename="{arquivo.nome_original}"'
return response
except TceArquivo.DoesNotExist:
raise Http404("Arquivo não encontrado")
except Exception as e:
raise Http404(f"Erro ao carregar arquivo: {str(e)}")
@login_required
@require_http_methods(["POST"])
def api_gerar_p7s(request, arquivo_id):
"""
API para gerar arquivo .p7s
"""
import os
import shutil
from django.core.files import File
try:
arquivo = TceArquivo.objects.get(id=arquivo_id)
# Obter o arquivo base (PDF assinado ou OCR ou original)
arquivo_base = arquivo.arquivo_final
if not arquivo_base:
return JsonResponse({
'success': False,
'error': 'Nenhum arquivo encontrado para gerar .p7s'
}, status=400)
# Criar nome do arquivo .p7s
nome_original = arquivo.nome_original
if nome_original.lower().endswith('.pdf'):
nome_p7s = nome_original[:-4] + '.p7s'
else:
nome_p7s = nome_original + '.p7s'
# NOTA: Aqui você implementaria a lógica real de geração do .p7s
# Por enquanto, vamos apenas copiar o arquivo e renomear para .p7s
# Em produção, você deve usar bibliotecas como pyhanko ou assinador específico
# Caminho temporário para o .p7s
import tempfile
with tempfile.NamedTemporaryFile(delete=False, suffix='.p7s') as temp_p7s:
# Copiar conteúdo do arquivo original
with open(arquivo_base.path, 'rb') as f_original:
shutil.copyfileobj(f_original, temp_p7s)
temp_p7s_path = temp_p7s.name
# Salvar o arquivo .p7s no modelo
with open(temp_p7s_path, 'rb') as f_p7s:
arquivo.arquivo_p7s.save(nome_p7s, File(f_p7s), save=False)
arquivo.p7s_gerado = True
arquivo.atualizar_validacao()
arquivo.save()
# Limpar arquivo temporário
os.unlink(temp_p7s_path)
# Criar log
TceLog.objects.create(
processo=arquivo.pasta.processo,
arquivo=arquivo,
tipo='p7s_gerado',
descricao=f'Arquivo .p7s gerado para {arquivo.nome_original}',
usuario=request.user,
sucesso=True
)
return JsonResponse({
'success': True,
'message': 'Arquivo .p7s gerado com sucesso!',
'arquivo_p7s': nome_p7s
})
except TceArquivo.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Arquivo não encontrado'
}, status=404)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
@login_required
@require_http_methods(["POST"])
def api_assinar_arquivo(request, arquivo_id):
"""
API para assinar arquivo digitalmente com certificado A1
"""
import os
from django.core.files import File
from django.core.files.base import ContentFile
try:
arquivo = TceArquivo.objects.get(id=arquivo_id)
# Obter dados do formulário
tipo_certificado = request.POST.get('tipo_certificado')
senha_certificado = request.POST.get('senha_certificado')
certificado_file = request.FILES.get('arquivo_certificado')
if not tipo_certificado:
return JsonResponse({
'success': False,
'error': 'Tipo de certificado não informado'
}, status=400)
# Obter arquivo base
arquivo_base = arquivo.arquivo_final
if not arquivo_base or not os.path.exists(arquivo_base.path):
return JsonResponse({
'success': False,
'error': 'Arquivo base não encontrado'
}, status=400)
# Para certificado A1, precisamos do arquivo .pfx e senha
if tipo_certificado == 'A1':
if not certificado_file or not senha_certificado:
return JsonResponse({
'success': False,
'error': 'Certificado e senha são obrigatórios para A1'
}, status=400)
# Tentar assinar com pyhanko
try:
from pyhanko.sign import signers, fields
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.pdf_utils import images
from pyhanko_certvalidator import ValidationContext
import datetime
# Ler certificado
cert_data = certificado_file.read()
# Criar assinador
try:
signer = signers.SimpleSigner.load_pkcs12(
pfx_file=ContentFile(cert_data),
passphrase=senha_certificado.encode('utf-8')
)
except Exception as cert_error:
return JsonResponse({
'success': False,
'error': f'Erro ao carregar certificado: {str(cert_error)}. Verifique a senha.'
}, status=400)
# Criar arquivo de saída temporário
import tempfile
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_signed:
temp_signed_path = temp_signed.name
# Assinar o PDF
with open(arquivo_base.path, 'rb') as inf:
w = IncrementalPdfFileWriter(inf)
# Adicionar campo de assinatura
sig_field = fields.SigFieldSpec(
sig_field_name='Signature1',
box=(10, 10, 200, 60)
)
# Metadados da assinatura
meta = signers.PdfSignatureMetadata(
field_name='Signature1',
location='TCE - Câmara Municipal',
reason='Assinatura Digital TCE',
name=request.user.get_full_name() or request.user.username
)
# Executar assinatura
with open(temp_signed_path, 'wb') as outf:
fields.append_signature_field(w, sig_field)
signers.sign_pdf(
w,
meta,
signer=signer,
output=outf
)
# Salvar arquivo assinado
nome_assinado = arquivo.nome_original.replace('.pdf', '_assinado.pdf')
with open(temp_signed_path, 'rb') as f_signed:
arquivo.arquivo_assinado.save(nome_assinado, File(f_signed), save=False)
arquivo.assinado = True
# Extrair info do certificado
cert_info = signer.signing_cert
arquivo.certificado_info = {
'subject': str(cert_info.subject),
'issuer': str(cert_info.issuer),
'serial': str(cert_info.serial_number),
'valid_from': cert_info.not_valid_before.isoformat(),
'valid_to': cert_info.not_valid_after.isoformat()
}
arquivo.atualizar_validacao()
arquivo.save()
# Limpar arquivo temporário
os.unlink(temp_signed_path)
# Criar registro de assinatura
from sapl.tce.models import TceAssinatura
TceAssinatura.objects.create(
arquivo=arquivo,
ordem=1,
papel='outros',
certificado_tipo=tipo_certificado,
certificado_nome=str(cert_info.subject),
certificado_serial=str(cert_info.serial_number),
certificado_validade=cert_info.not_valid_after.date(),
valida=True,
assinado_por=request.user
)
# Criar log
TceLog.objects.create(
processo=arquivo.pasta.processo,
arquivo=arquivo,
tipo='arquivo_assinado',
descricao=f'Arquivo {arquivo.nome_original} assinado digitalmente',
usuario=request.user,
sucesso=True
)
return JsonResponse({
'success': True,
'message': 'Arquivo assinado com sucesso!',
'certificado': {
'nome': str(cert_info.subject),
'validade': cert_info.not_valid_after.strftime('%d/%m/%Y')
}
})
except ImportError:
# Se pyhanko não estiver instalado, retornar erro informativo
return JsonResponse({
'success': False,
'error': 'Biblioteca de assinatura não instalada. Execute: pip install pyhanko pyhanko-certvalidator',
'simulated': True
}, status=500)
elif tipo_certificado == 'A3':
# Para A3, seria necessário integração com token/smartcard
# Por enquanto, retornar não implementado
return JsonResponse({
'success': False,
'error': 'Assinatura com certificado A3 ainda não implementada. Use certificado A1 (.pfx)'
}, status=501)
else:
return JsonResponse({
'success': False,
'error': 'Tipo de certificado inválido'
}, status=400)
except TceArquivo.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Arquivo não encontrado'
}, status=404)
except Exception as e:
import traceback
return JsonResponse({
'success': False,
'error': str(e),
'traceback': traceback.format_exc()
}, status=500)

18
sapl/tce/migrations/0003_tcepasta_subpastas_customizadas.py

@ -0,0 +1,18 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tce', '0002_tcearquivo_subpasta'),
]
operations = [
migrations.AddField(
model_name='tcepasta',
name='subpastas_customizadas',
field=models.TextField(blank=True, default='[]', help_text='JSON: Lista de subpastas adicionadas pelo usuário além das padrões', verbose_name='Subpastas Customizadas'),
),
]

26
sapl/tce/models.py

@ -8,7 +8,12 @@ from django.db import models
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.contrib.postgres.fields import JSONField try:
# Django 3.1+
from django.db.models import JSONField
except ImportError:
# Django < 3.1
from django.contrib.postgres.fields import JSONField
def get_current_year(): def get_current_year():
@ -105,6 +110,12 @@ class TcePasta(models.Model):
categoria = models.CharField(_('Categoria'), max_length=20, choices=CATEGORIA_CHOICES) categoria = models.CharField(_('Categoria'), max_length=20, choices=CATEGORIA_CHOICES)
descricao = models.TextField(_('Descrição'), blank=True) descricao = models.TextField(_('Descrição'), blank=True)
ordem = models.IntegerField(_('Ordem de Exibição'), default=0) ordem = models.IntegerField(_('Ordem de Exibição'), default=0)
subpastas_customizadas = models.TextField(
_('Subpastas Customizadas'),
default='[]',
blank=True,
help_text=_('JSON: Lista de subpastas adicionadas pelo usuário além das padrões')
)
criado_em = models.DateTimeField(_('Criado em'), auto_now_add=True) criado_em = models.DateTimeField(_('Criado em'), auto_now_add=True)
@ -130,6 +141,19 @@ class TcePasta(models.Model):
p7s_gerado=True p7s_gerado=True
).count() ).count()
def get_subpastas_customizadas(self):
"""Retorna lista de subpastas customizadas (deserializa JSON)"""
import json
try:
return json.loads(self.subpastas_customizadas or '[]')
except:
return []
def set_subpastas_customizadas(self, lista):
"""Define lista de subpastas customizadas (serializa para JSON)"""
import json
self.subpastas_customizadas = json.dumps(lista)
@property @property
def icone(self): def icone(self):
"""Ícone FontAwesome baseado na categoria""" """Ícone FontAwesome baseado na categoria"""

11
sapl/tce/templatetags/tce_filters.py

@ -6,3 +6,14 @@ register = template.Library()
def get_item(dictionary, key): def get_item(dictionary, key):
"""Template filter para acessar itens de dicionário""" """Template filter para acessar itens de dicionário"""
return dictionary.get(key, []) return dictionary.get(key, [])
@register.filter(name='subpasta_count')
def subpasta_count(queryset, subpasta_nome):
"""
Conta arquivos de uma subpasta específica
Uso: {{ pasta.arquivos.all|subpasta_count:subpasta }}
"""
if not queryset:
return 0
return queryset.filter(subpasta=subpasta_nome).count()

10
sapl/tce/urls.py

@ -18,7 +18,17 @@ urlpatterns = [
path('api/arquivos/', admin_views.api_list_arquivos, name='api_list_arquivos'), path('api/arquivos/', admin_views.api_list_arquivos, name='api_list_arquivos'),
path('api/arquivos/upload/', admin_views.api_upload_arquivo, name='api_upload_arquivo'), path('api/arquivos/upload/', admin_views.api_upload_arquivo, name='api_upload_arquivo'),
path('api/arquivos/<uuid:arquivo_id>/delete/', admin_views.api_delete_arquivo, name='api_delete_arquivo'), path('api/arquivos/<uuid:arquivo_id>/delete/', admin_views.api_delete_arquivo, name='api_delete_arquivo'),
path('api/arquivos/<uuid:arquivo_id>/assinar/', admin_views.api_assinar_arquivo, name='api_assinar_arquivo'),
path('api/arquivos/<uuid:arquivo_id>/gerar-p7s/', admin_views.api_gerar_p7s, name='api_gerar_p7s'),
# Visualização de arquivo
path('arquivo/<uuid:arquivo_id>/visualizar/', admin_views.visualizar_arquivo, name='visualizar_arquivo'),
# APIs - Pastas e Subpastas
path('api/pastas/criar/', admin_views.api_criar_pasta, name='api_criar_pasta'),
path('api/subpastas/adicionar/', admin_views.api_adicionar_subpasta, name='api_adicionar_subpasta'),
# Visualização de processo # Visualização de processo
path('processo/<uuid:processo_id>/', admin_views.processo_detail, name='processo_detail'), path('processo/<uuid:processo_id>/', admin_views.processo_detail, name='processo_detail'),
path('processo/<uuid:processo_id>/pasta/<uuid:pasta_id>/<path:subpasta>/', admin_views.processo_subpasta_detail, name='processo_subpasta_detail'),
] ]

30
sapl/templates/admin/tce/app_index.html

@ -116,36 +116,6 @@
</div> </div>
</div> </div>
<!-- Links Rápidos -->
<div class="row mt-4">
<div class="col-md-12">
<h4><i class="fas fa-bolt"></i> {% trans 'Ações Rápidas' %}</h4>
<hr>
</div>
</div>
<div class="row mb-5">
<div class="col-md-3 col-sm-6 mb-2">
<a href="{% url 'admin:tce_tceprocesso_add' %}" class="btn btn-outline-primary btn-block">
<i class="fas fa-plus"></i> {% trans 'Novo Processo' %}
</a>
</div>
<div class="col-md-3 col-sm-6 mb-2">
<a href="{% url 'admin:tce_tcepasta_add' %}" class="btn btn-outline-info btn-block">
<i class="fas fa-folder-plus"></i> {% trans 'Nova Pasta' %}
</a>
</div>
<div class="col-md-3 col-sm-6 mb-2">
<a href="{% url 'admin:tce_tcearquivo_add' %}" class="btn btn-outline-danger btn-block">
<i class="fas fa-upload"></i> {% trans 'Upload de Arquivo' %}
</a>
</div>
<div class="col-md-3 col-sm-6 mb-2">
<a href="{% url 'admin:tce_tcelog_changelist' %}" class="btn btn-outline-warning btn-block">
<i class="fas fa-history"></i> {% trans 'Ver Logs' %}
</a>
</div>
</div>
</div> </div>
<style> <style>

1100
sapl/templates/admin/tce/processo_detail.html

File diff suppressed because it is too large

947
sapl/templates/admin/tce/subpasta_detail.html

@ -0,0 +1,947 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{{ subpasta_nome }} - {{ processo.titulo }}{% endblock %}
{% block base_content %}
<div class="container-fluid" style="margin-top: 20px;">
<!-- Breadcrumb -->
<div class="breadcrumb-custom mb-4">
<a href="{% url 'sapl.tce:admin_index' %}">
<i class="fas fa-home"></i> TCE
</a>
<span class="mx-2">/</span>
<a href="{% url 'sapl.tce:processo_detail' processo.id %}">
{{ processo.titulo }}
</a>
<span class="mx-2">/</span>
<span>{{ subpasta_nome }}</span>
</div>
<!-- Header da Subpasta -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0">
<i class="fas fa-folder-open"></i> {{ subpasta_nome }}
</h3>
<button class="btn btn-light" onclick="showUploadModal()">
<i class="fas fa-plus"></i> {% trans 'Adicionar Arquivos' %}
</button>
</div>
</div>
<div class="card-body">
<p class="mb-0">
<strong>Pasta:</strong> {{ pasta.nome }}
<span class="mx-2">|</span>
<strong>Processo:</strong> {{ processo.titulo }} ({{ processo.ano }} - {{ processo.periodo }})
</p>
</div>
</div>
</div>
</div>
<!-- Lista de Arquivos -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-file"></i> Arquivos
<span class="badge badge-primary ml-2">{{ arquivos.count }}</span>
</h5>
</div>
<div class="card-body">
{% if arquivos %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th><i class="fas fa-file"></i> Nome</th>
<th><i class="fas fa-weight"></i> Tamanho</th>
<th><i class="fas fa-check-circle"></i> Status</th>
<th><i class="fas fa-calendar"></i> Data</th>
<th><i class="fas fa-cog"></i> Ações</th>
</tr>
</thead>
<tbody>
{% for arquivo in arquivos %}
<tr>
<td>
<i class="fas {% if arquivo.tipo == 'xml' %}fa-file-code text-success{% else %}fa-file-pdf text-danger{% endif %}"></i>
<strong>{{ arquivo.nome_original }}</strong>
{% if arquivo.p7s_gerado and arquivo.arquivo_p7s %}
<br>
<small class="text-muted">
<i class="fas fa-file-export text-info"></i>
<a href="{{ arquivo.arquivo_p7s.url }}" target="_blank">
{{ arquivo.nome_original|slice:":-4" }}.p7s
</a>
</small>
{% endif %}
</td>
<td>{{ arquivo.tamanho_mb|floatformat:2 }} MB</td>
<td>
{% if arquivo.validado %}
<span class="badge badge-success">Validado</span>
{% elif arquivo.p7s_gerado %}
<span class="badge badge-info">Quase pronto</span>
{% elif arquivo.assinado %}
<span class="badge badge-primary">Processado</span>
{% elif not arquivo.tamanho_ok %}
<span class="badge badge-warning">Arquivo muito grande</span>
{% elif not arquivo.ocr_ok %}
<span class="badge badge-warning">OCR pendente</span>
{% else %}
<span class="badge badge-secondary">Novo</span>
{% endif %}
</td>
<td><small>{{ arquivo.criado_em|date:"d/m/Y H:i" }}</small></td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="visualizarArquivo('{{ arquivo.id }}')">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="excluirArquivo('{{ arquivo.id }}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Nenhum arquivo nesta pasta</h5>
<p class="text-muted">Clique em "Adicionar Arquivos" para fazer upload</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Botão Voltar -->
<div class="row mt-4 mb-5">
<div class="col-12">
<a href="{% url 'sapl.tce:processo_detail' processo.id %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {% trans 'Voltar ao Processo' %}
</a>
</div>
</div>
</div>
<!-- Modal: Upload de Arquivo - Wizard com Abas -->
<div class="modal fade" id="uploadFileModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">
<i class="fas fa-upload"></i> {% trans 'Adicionar Arquivo' %}
<br>
<small>{{ subpasta_nome }}</small>
</h5>
<button type="button" class="close text-white" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<!-- Abas do Wizard -->
<ul class="nav nav-pills nav-justified mb-4" id="wizard-tabs">
<li class="nav-item">
<a class="nav-link active" id="tab1-link" data-toggle="pill" href="#tab1-arquivo">
<i class="fas fa-file-upload"></i><br>
<small>1. Arquivo</small>
</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" id="tab2-link" data-toggle="pill" href="#tab2-tamanho">
<i class="fas fa-weight"></i><br>
<small>2. Tamanho</small>
</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" id="tab3-link" data-toggle="pill" href="#tab3-ocr">
<i class="fas fa-search"></i><br>
<small>3. OCR</small>
</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" id="tab4-link" data-toggle="pill" href="#tab4-assinatura">
<i class="fas fa-signature"></i><br>
<small>4. Assinatura</small>
</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" id="tab5-link" data-toggle="pill" href="#tab5-p7s">
<i class="fas fa-file-export"></i><br>
<small>5. .p7s</small>
</a>
</li>
</ul>
<!-- Conteúdo das Abas -->
<div class="tab-content">
<!-- Aba 1: Upload do Arquivo -->
<div class="tab-pane fade show active" id="tab1-arquivo">
<h5><i class="fas fa-file-upload"></i> Selecionar Arquivo</h5>
<hr>
<div class="form-group">
<label>{% trans 'Upload de Arquivo PDF' %}</label>
<div class="custom-file">
<input type="file" class="custom-file-input" id="fileInput" accept=".pdf">
<label class="custom-file-label" for="fileInput">{% trans 'Escolher arquivo...' %}</label>
</div>
</div>
<div id="file-preview" class="mt-3" style="display:none;">
<div class="alert alert-info">
<i class="fas fa-file-pdf"></i> <strong id="file-name"></strong>
<br>
<small>Tamanho: <span id="file-size"></span></small>
</div>
</div>
</div>
<!-- Aba 2: Verificação de Tamanho -->
<div class="tab-pane fade" id="tab2-tamanho">
<h5><i class="fas fa-weight"></i> Verificação de Tamanho</h5>
<hr>
<div id="tamanho-status">
<div class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-3x text-primary mb-3"></i>
<p>Verificando tamanho do arquivo...</p>
</div>
</div>
</div>
<!-- Aba 3: OCR -->
<div class="tab-pane fade" id="tab3-ocr">
<h5><i class="fas fa-search"></i> Tornar Pesquisável (OCR)</h5>
<hr>
<div id="ocr-status">
<div class="text-center py-4">
<p class="mb-3">Aplicar OCR para tornar o PDF pesquisável?</p>
<button class="btn btn-success" onclick="aplicarOCR()">
<i class="fas fa-check"></i> Sim, aplicar OCR
</button>
<button class="btn btn-secondary" onclick="pularOCR()">
<i class="fas fa-forward"></i> Pular esta etapa
</button>
</div>
</div>
</div>
<!-- Aba 4: Assinatura Digital -->
<div class="tab-pane fade" id="tab4-assinatura">
<h5><i class="fas fa-signature"></i> Assinatura Digital</h5>
<hr>
<div id="assinatura-status">
<div class="form-group">
<label>Tipo de Certificado:</label>
<select class="form-control" id="tipo-certificado">
<option value="">-- Selecione --</option>
<option value="A1">A1 (Arquivo digital)</option>
<option value="A3">A3 (Token/Cartão)</option>
</select>
</div>
<div id="div-certificado-a1" style="display:none;">
<div class="form-group">
<label>Arquivo do Certificado (.pfx):</label>
<input type="file" class="form-control-file" id="arquivo-certificado" accept=".pfx,.p12">
</div>
<div class="form-group">
<label>Senha do Certificado:</label>
<input type="password" class="form-control" id="senha-certificado">
</div>
</div>
<div class="text-center mt-3">
<button class="btn btn-success" onclick="assinarDocumento()" id="btn-assinar" disabled>
<i class="fas fa-signature"></i> Assinar Documento
</button>
<button class="btn btn-secondary" onclick="pularAssinatura()">
<i class="fas fa-forward"></i> Pular assinatura
</button>
</div>
</div>
</div>
<!-- Aba 5: Conversão para .p7s -->
<div class="tab-pane fade" id="tab5-p7s">
<h5><i class="fas fa-file-export"></i> Gerar Arquivo .p7s</h5>
<hr>
<div id="p7s-status">
<div class="text-center py-4">
<p class="mb-3">Converter para formato .p7s?</p>
<button class="btn btn-success" onclick="gerarP7S()">
<i class="fas fa-file-export"></i> Gerar .p7s
</button>
<button class="btn btn-secondary" onclick="pularP7S()">
<i class="fas fa-forward"></i> Pular esta etapa
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
{% trans 'Fechar' %}
</button>
<button type="button" class="btn btn-outline-secondary" id="btn-voltar" onclick="voltarAba()" style="display:none;">
<i class="fas fa-arrow-left"></i> Voltar
</button>
<button type="button" class="btn btn-primary" id="btn-proximo" onclick="proximaAba()">
Próximo <i class="fas fa-arrow-right"></i>
</button>
<button type="button" class="btn btn-success" id="btn-finalizar" onclick="finalizarUpload()" style="display:none;">
<i class="fas fa-check"></i> Finalizar
</button>
</div>
</div>
</div>
</div>
<style>
.breadcrumb-custom {
background: #f8f9fa;
padding: 10px 15px;
border-radius: 6px;
}
.breadcrumb-custom a {
color: #007bff;
text-decoration: none;
}
.breadcrumb-custom a:hover {
text-decoration: underline;
}
#arquivos-list table tbody tr {
transition: all 0.2s;
}
#arquivos-list table tbody tr:hover {
background-color: #f8f9fa;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Wizard tabs styling */
#wizard-tabs .nav-link {
border: 2px solid #dee2e6;
margin: 0 5px;
transition: all 0.3s;
}
#wizard-tabs .nav-link.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white !important;
border-color: #667eea;
}
#wizard-tabs .nav-link.disabled {
opacity: 0.5;
cursor: not-allowed;
}
#wizard-tabs .nav-link:not(.disabled):not(.active):hover {
border-color: #667eea;
background-color: #f8f9fa;
}
.tab-content {
min-height: 300px;
}
</style>
<!-- SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
// IDs importantes da página
const PASTA_ID = '{{ pasta.id }}';
const SUBPASTA_NOME = '{{ subpasta_nome }}';
const PROCESSO_ID = '{{ processo.id }}';
// Variáveis do wizard
let currentTab = 1;
let selectedFile = null;
let uploadedArquivoId = null;
// Obter CSRF token do cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Mostrar modal de upload
function showUploadModal() {
// Reset wizard
currentTab = 1;
selectedFile = null;
uploadedArquivoId = null;
// Reset form
document.getElementById('fileInput').value = '';
document.getElementById('file-preview').style.display = 'none';
// Reset tabs
$('#tab1-link').addClass('active').removeClass('disabled');
$('#tab2-link, #tab3-link, #tab4-link, #tab5-link').addClass('disabled').removeClass('active');
$('#tab1-arquivo').addClass('show active');
$('#tab2-tamanho, #tab3-ocr, #tab4-assinatura, #tab5-p7s').removeClass('show active');
// Reset buttons
document.getElementById('btn-voltar').style.display = 'none';
document.getElementById('btn-proximo').style.display = 'inline-block';
document.getElementById('btn-finalizar').style.display = 'none';
$('#uploadFileModal').modal('show');
}
// Preview do arquivo selecionado
document.getElementById('fileInput')?.addEventListener('change', function(e) {
selectedFile = e.target.files[0];
if (selectedFile) {
document.getElementById('file-name').textContent = selectedFile.name;
document.getElementById('file-size').textContent = (selectedFile.size / 1024 / 1024).toFixed(2) + ' MB';
document.getElementById('file-preview').style.display = 'block';
// Atualizar label do custom-file-input
const fileName = selectedFile.name;
const label = this.nextElementSibling;
if (label && label.classList.contains('custom-file-label')) {
label.classList.add('selected');
label.textContent = fileName;
}
}
});
// Navegação entre abas
function proximaAba() {
if (currentTab === 1) {
if (!selectedFile) {
Swal.fire({
title: 'Atenção!',
text: 'Por favor, selecione um arquivo antes de continuar.',
icon: 'warning'
});
return;
}
// Upload do arquivo primeiro
uploadArquivo();
} else if (currentTab === 2) {
// Verificar tamanho já foi feito automaticamente
irParaAba(3);
} else if (currentTab === 3) {
// OCR - avançar para assinatura
irParaAba(4);
} else if (currentTab === 4) {
// Assinatura - avançar para p7s
irParaAba(5);
}
}
function voltarAba() {
if (currentTab > 1) {
irParaAba(currentTab - 1);
}
}
function irParaAba(tabNum) {
currentTab = tabNum;
// Remover active de todos os links e painéis
$('#tab1-link, #tab2-link, #tab3-link, #tab4-link, #tab5-link').removeClass('active');
$('#tab1-arquivo, #tab2-tamanho, #tab3-ocr, #tab4-assinatura, #tab5-p7s').removeClass('show active');
// Mapear número da aba para o ID correto do painel
const painelIds = {
1: 'tab1-arquivo',
2: 'tab2-tamanho',
3: 'tab3-ocr',
4: 'tab4-assinatura',
5: 'tab5-p7s'
};
// Habilitar a aba desejada
$(`#tab${tabNum}-link`).removeClass('disabled').addClass('active');
$(`#${painelIds[tabNum]}`).addClass('show active');
// Atualizar botões
document.getElementById('btn-voltar').style.display = tabNum > 1 ? 'inline-block' : 'none';
document.getElementById('btn-proximo').style.display = tabNum < 5 ? 'inline-block' : 'none';
document.getElementById('btn-finalizar').style.display = tabNum === 5 ? 'inline-block' : 'none';
// Executar ação específica da aba
if (tabNum === 2) {
verificarTamanho();
} else if (tabNum === 3) {
// Reset OCR status se necessário
const ocrStatus = document.getElementById('ocr-status');
if (!ocrStatus.querySelector('.alert-success') && !ocrStatus.querySelector('.alert-info')) {
ocrStatus.innerHTML = `
<div class="text-center py-4">
<p class="mb-3">Aplicar OCR para tornar o PDF pesquisável?</p>
<button class="btn btn-success" onclick="aplicarOCR()">
<i class="fas fa-check"></i> Sim, aplicar OCR
</button>
<button class="btn btn-secondary" onclick="pularOCR()">
<i class="fas fa-forward"></i> Pular esta etapa
</button>
</div>
`;
}
} else if (tabNum === 5) {
// Reset P7S status se necessário
const p7sStatus = document.getElementById('p7s-status');
if (!p7sStatus.querySelector('.alert')) {
p7sStatus.innerHTML = `
<div class="text-center py-4">
<p class="mb-3">Converter para formato .p7s?</p>
<button class="btn btn-success" onclick="gerarP7S()">
<i class="fas fa-file-export"></i> Gerar .p7s
</button>
<button class="btn btn-secondary" onclick="pularP7S()">
<i class="fas fa-forward"></i> Pular esta etapa
</button>
</div>
`;
}
}
}
// Upload inicial do arquivo
function uploadArquivo() {
const btnProximo = document.getElementById('btn-proximo');
btnProximo.disabled = true;
btnProximo.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Enviando...';
const formData = new FormData();
formData.append('arquivo', selectedFile);
formData.append('pasta_id', PASTA_ID);
formData.append('subpasta', SUBPASTA_NOME);
const csrftoken = getCookie('csrftoken');
fetch('/tce/api/arquivos/upload/', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': csrftoken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
uploadedArquivoId = data.arquivo_id;
// Ir para aba de tamanho
irParaAba(2);
} else {
Swal.fire({
title: 'Erro!',
text: data.error || 'Erro ao enviar arquivo',
icon: 'error'
});
}
btnProximo.disabled = false;
btnProximo.innerHTML = 'Próximo <i class="fas fa-arrow-right"></i>';
})
.catch(error => {
console.error('Erro:', error);
Swal.fire({
title: 'Erro!',
text: 'Erro ao enviar arquivo: ' + error.message,
icon: 'error'
});
btnProximo.disabled = false;
btnProximo.innerHTML = 'Próximo <i class="fas fa-arrow-right"></i>';
});
}
// Verificar tamanho do arquivo
function verificarTamanho() {
const tamanhoMB = selectedFile.size / 1024 / 1024;
const tamanhoStatus = document.getElementById('tamanho-status');
if (tamanhoMB <= 5) {
tamanhoStatus.innerHTML = `
<div class="alert alert-success text-center">
<i class="fas fa-check-circle fa-3x mb-3"></i>
<h5>Tamanho OK!</h5>
<p>O arquivo tem ${tamanhoMB.toFixed(2)} MB (≤ 5MB)</p>
</div>
`;
} else {
tamanhoStatus.innerHTML = `
<div class="alert alert-warning text-center">
<i class="fas fa-exclamation-triangle fa-3x mb-3"></i>
<h5>Arquivo muito grande!</h5>
<p>O arquivo tem ${tamanhoMB.toFixed(2)} MB (limite: 5MB)</p>
<button class="btn btn-warning mt-2" onclick="reduzirArquivo()">
<i class="fas fa-compress"></i> Reduzir Arquivo
</button>
</div>
`;
}
}
// Reduzir arquivo
function reduzirArquivo() {
const tamanhoStatus = document.getElementById('tamanho-status');
tamanhoStatus.innerHTML = `
<div class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-3x text-warning mb-3"></i>
<p>Reduzindo arquivo... (Funcionalidade em desenvolvimento)</p>
</div>
`;
// Simular redução (em produção, chamar API)
setTimeout(() => {
tamanhoStatus.innerHTML = `
<div class="alert alert-success text-center">
<i class="fas fa-check-circle fa-3x mb-3"></i>
<h5>Arquivo reduzido com sucesso!</h5>
<p>Novo tamanho: 4.2 MB</p>
</div>
`;
}, 2000);
}
// Aplicar OCR
function aplicarOCR() {
const ocrStatus = document.getElementById('ocr-status');
ocrStatus.innerHTML = `
<div class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-3x text-primary mb-3"></i>
<p>Aplicando OCR... (Funcionalidade em desenvolvimento)</p>
</div>
`;
// Simular OCR (em produção, chamar API)
setTimeout(() => {
ocrStatus.innerHTML = `
<div class="alert alert-success text-center">
<i class="fas fa-check-circle fa-3x mb-3"></i>
<h5>OCR aplicado com sucesso!</h5>
<p>O documento agora está pesquisável</p>
<button class="btn btn-primary mt-2" onclick="irParaAba(4)">
<i class="fas fa-arrow-right"></i> Continuar
</button>
</div>
`;
}, 2000);
}
function pularOCR() {
irParaAba(4);
}
// Configurar tipo de certificado
document.getElementById('tipo-certificado')?.addEventListener('change', function() {
const divA1 = document.getElementById('div-certificado-a1');
const btnAssinar = document.getElementById('btn-assinar');
if (this.value === 'A1') {
divA1.style.display = 'block';
btnAssinar.disabled = false;
} else if (this.value === 'A3') {
divA1.style.display = 'none';
btnAssinar.disabled = false;
} else {
divA1.style.display = 'none';
btnAssinar.disabled = true;
}
});
// Assinar documento
function assinarDocumento() {
if (!uploadedArquivoId) {
Swal.fire({
title: 'Erro!',
text: 'Nenhum arquivo foi enviado.',
icon: 'error'
});
return;
}
const tipoCertificado = document.getElementById('tipo-certificado').value;
const senhaCertificado = document.getElementById('senha-certificado').value;
const arquivoCertificado = document.getElementById('arquivo-certificado').files[0];
// Validações
if (!tipoCertificado) {
Swal.fire({
title: 'Atenção!',
text: 'Selecione o tipo de certificado',
icon: 'warning'
});
return;
}
if (tipoCertificado === 'A1' && (!arquivoCertificado || !senhaCertificado)) {
Swal.fire({
title: 'Atenção!',
text: 'Selecione o arquivo do certificado e digite a senha',
icon: 'warning'
});
return;
}
const assinaturaStatus = document.getElementById('assinatura-status');
assinaturaStatus.innerHTML = `
<div class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-3x text-success mb-3"></i>
<p>Assinando documento digitalmente...</p>
</div>
`;
const formData = new FormData();
formData.append('tipo_certificado', tipoCertificado);
if (tipoCertificado === 'A1') {
formData.append('arquivo_certificado', arquivoCertificado);
formData.append('senha_certificado', senhaCertificado);
}
const csrftoken = getCookie('csrftoken');
fetch(`/tce/api/arquivos/${uploadedArquivoId}/assinar/`, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': csrftoken
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
assinaturaStatus.innerHTML = `
<div class="alert alert-success text-center">
<i class="fas fa-check-circle fa-3x mb-3"></i>
<h5>Documento assinado com sucesso!</h5>
<p>Assinatura digital aplicada</p>
${data.certificado ? `<small class="text-muted">Certificado: ${data.certificado.nome}<br>Válido até: ${data.certificado.validade}</small>` : ''}
<br>
<button class="btn btn-primary mt-3" onclick="irParaAba(5)">
<i class="fas fa-arrow-right"></i> Continuar para .p7s
</button>
</div>
`;
} else {
assinaturaStatus.innerHTML = `
<div class="alert alert-danger text-center">
<i class="fas fa-exclamation-circle fa-3x mb-3"></i>
<h5>Erro ao assinar documento</h5>
<p>${data.error || 'Erro desconhecido'}</p>
${data.simulated ? '<small class="text-info">Para usar assinatura real, instale: pip install pyhanko pyhanko-certvalidator</small><br>' : ''}
<button class="btn btn-warning mt-2" onclick="pularAssinatura()">
<i class="fas fa-forward"></i> Pular assinatura
</button>
</div>
`;
}
})
.catch(error => {
console.error('Erro:', error);
assinaturaStatus.innerHTML = `
<div class="alert alert-danger text-center">
<i class="fas fa-exclamation-circle fa-3x mb-3"></i>
<h5>Erro ao assinar documento</h5>
<p>${error.message}</p>
<button class="btn btn-warning mt-2" onclick="pularAssinatura()">
<i class="fas fa-forward"></i> Pular assinatura
</button>
</div>
`;
});
}
function pularAssinatura() {
irParaAba(5);
}
// Gerar .p7s
function gerarP7S() {
if (!uploadedArquivoId) {
Swal.fire({
title: 'Erro!',
text: 'Nenhum arquivo foi enviado.',
icon: 'error'
});
return;
}
const p7sStatus = document.getElementById('p7s-status');
p7sStatus.innerHTML = `
<div class="text-center py-4">
<i class="fas fa-spinner fa-spin fa-3x text-success mb-3"></i>
<p>Gerando arquivo .p7s...</p>
</div>
`;
const csrftoken = getCookie('csrftoken');
fetch(`/tce/api/arquivos/${uploadedArquivoId}/gerar-p7s/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
p7sStatus.innerHTML = `
<div class="alert alert-success text-center">
<i class="fas fa-check-circle fa-3x mb-3"></i>
<h5>Arquivo .p7s gerado com sucesso!</h5>
<p>O arquivo está pronto para envio ao TCE</p>
<small class="text-muted">Arquivo: ${data.arquivo_p7s}</small>
</div>
`;
} else {
p7sStatus.innerHTML = `
<div class="alert alert-danger text-center">
<i class="fas fa-exclamation-circle fa-3x mb-3"></i>
<h5>Erro ao gerar .p7s</h5>
<p>${data.error || 'Erro desconhecido'}</p>
<button class="btn btn-warning mt-2" onclick="gerarP7S()">
<i class="fas fa-redo"></i> Tentar novamente
</button>
</div>
`;
}
})
.catch(error => {
console.error('Erro:', error);
p7sStatus.innerHTML = `
<div class="alert alert-danger text-center">
<i class="fas fa-exclamation-circle fa-3x mb-3"></i>
<h5>Erro ao gerar .p7s</h5>
<p>${error.message}</p>
<button class="btn btn-warning mt-2" onclick="gerarP7S()">
<i class="fas fa-redo"></i> Tentar novamente
</button>
</div>
`;
});
}
function pularP7S() {
// Apenas marcar como completo
const p7sStatus = document.getElementById('p7s-status');
p7sStatus.innerHTML = `
<div class="alert alert-info text-center">
<i class="fas fa-info-circle fa-3x mb-3"></i>
<h5>Etapa pulada</h5>
<p>Arquivo .p7s não foi gerado</p>
</div>
`;
}
// Finalizar upload
function finalizarUpload() {
// Verificar se já temos um arquivo carregado
if (!uploadedArquivoId) {
Swal.fire({
title: 'Erro!',
text: 'Nenhum arquivo foi enviado. Por favor, volte e selecione um arquivo.',
icon: 'error'
});
return;
}
Swal.fire({
title: 'Sucesso!',
text: 'Arquivo processado com sucesso!',
icon: 'success',
timer: 1500,
showConfirmButton: false
}).then(() => {
$('#uploadFileModal').modal('hide');
window.location.reload();
});
}
// Visualizar arquivo
function visualizarArquivo(arquivoId) {
window.open(`/tce/arquivo/${arquivoId}/visualizar/`, '_blank');
}
// Excluir arquivo
function excluirArquivo(arquivoId) {
Swal.fire({
title: 'Confirmar Exclusão',
text: 'Deseja realmente excluir este arquivo?',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#3085d6',
confirmButtonText: 'Sim, excluir!',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
excluirArquivoConfirmado(arquivoId);
}
});
}
function excluirArquivoConfirmado(arquivoId) {
const csrftoken = getCookie('csrftoken');
const url = `/tce/api/arquivos/${arquivoId}/delete/`;
fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
Swal.fire({
title: 'Sucesso!',
text: 'Arquivo excluído com sucesso!',
icon: 'success',
timer: 1500,
showConfirmButton: false
}).then(() => {
// Recarregar a página para atualizar a lista
window.location.reload();
});
} else {
Swal.fire({
title: 'Erro!',
text: 'Erro ao excluir: ' + (data.error || 'Erro desconhecido'),
icon: 'error'
});
}
})
.catch(error => {
console.error('Erro no fetch:', error);
Swal.fire({
title: 'Erro!',
text: 'Erro ao excluir arquivo: ' + error.message,
icon: 'error'
});
});
}
// Código removido - atualização do label agora está integrada no listener principal acima
</script>
{% endblock %}
Loading…
Cancel
Save