Browse Source

Feat(Materia): Upload múltiplo de anexos (fotos, imagens) em proposições e matérias

- Adicionar validador combinado (documentos + imagens) para DocumentoAcessorio
- Criar modelo AnexoProposicao para anexos durante edição de proposição
- Adicionar upload drag-and-drop na lista de Documentos Acessórios da matéria
- Adicionar seção de anexos na tela do editor OnlyOffice de proposição
- Transferir anexos automaticamente para DocumentoAcessorio ao incorporar proposição
pull/3858/head
rangelbruno 6 months ago
parent
commit
a2acc8c4ab
  1. 20
      sapl/materia/forms.py
  2. 20
      sapl/materia/migrations/0093_allow_image_uploads_documentoacessorio.py
  3. 33
      sapl/materia/migrations/0094_add_anexoproposicao.py
  4. 61
      sapl/materia/models.py
  5. 3
      sapl/materia/onlyoffice_views.py
  6. 9
      sapl/materia/urls.py
  7. 162
      sapl/materia/views.py
  8. 159
      sapl/templates/materia/documentoacessorio_list.html
  9. 232
      sapl/templates/materia/onlyoffice_editor.html
  10. 7
      sapl/utils.py

20
sapl/materia/forms.py

@ -2746,6 +2746,26 @@ class ConfirmarProposicaoForm(ProposicaoForm):
'Autoria registrada para (%s)' 'Autoria registrada para (%s)'
) % str(autoria.autor)) ) % str(autoria.autor))
# Transferir anexos da proposição para DocumentoAcessorio
from sapl.materia.models import AnexoProposicao
anexos = AnexoProposicao.objects.filter(
proposicao=proposicao)
if anexos.exists():
for anexo in anexos:
doc_acessorio = DocumentoAcessorio()
doc_acessorio.materia = materia
doc_acessorio.tipo = anexo.tipo
doc_acessorio.nome = anexo.nome
doc_acessorio.data = anexo.data
doc_acessorio.arquivo = File(
anexo.arquivo,
os.path.basename(anexo.arquivo.name))
doc_acessorio.save()
self.instance.results['messages']['success'].append(_(
'%d anexo(s) transferido(s) como Documento(s) Acessório(s)'
) % anexos.count())
anexos.delete()
# Matéria de vinlculo # Matéria de vinlculo
if proposicao.materia_de_vinculo: if proposicao.materia_de_vinculo:
anexada = Anexada() anexada = Anexada()

20
sapl/materia/migrations/0093_allow_image_uploads_documentoacessorio.py

@ -0,0 +1,20 @@
# Generated by Django 2.2.28 on 2026-03-23 19:49
from django.db import migrations, models
import sapl.materia.models
import sapl.utils
class Migration(migrations.Migration):
dependencies = [
('materia', '0092_auto_20260223_1102'),
]
operations = [
migrations.AlterField(
model_name='documentoacessorio',
name='arquivo',
field=models.FileField(blank=True, max_length=300, null=True, storage=sapl.utils.OverwriteStorage(), upload_to=sapl.materia.models.anexo_upload_path, validators=[sapl.utils.restringe_tipos_de_arquivo_doc_img], verbose_name='Texto Integral'),
),
]

33
sapl/materia/migrations/0094_add_anexoproposicao.py

@ -0,0 +1,33 @@
# Generated by Django 2.2.28 on 2026-03-23 20:14
from django.db import migrations, models
import django.db.models.deletion
import sapl.materia.models
import sapl.utils
class Migration(migrations.Migration):
dependencies = [
('materia', '0093_allow_image_uploads_documentoacessorio'),
]
operations = [
migrations.CreateModel(
name='AnexoProposicao',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('arquivo', models.FileField(max_length=300, storage=sapl.utils.OverwriteStorage(), upload_to=sapl.materia.models.anexo_proposicao_upload_path, validators=[sapl.utils.restringe_tipos_de_arquivo_doc_img], verbose_name='Arquivo')),
('nome', models.CharField(max_length=50, verbose_name='Nome')),
('data', models.DateField(verbose_name='Data')),
('data_ultima_atualizacao', models.DateTimeField(auto_now=True, null=True)),
('proposicao', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='anexos', to='materia.Proposicao', verbose_name='Proposição')),
('tipo', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='materia.TipoDocumento', verbose_name='Tipo')),
],
options={
'verbose_name': 'Anexo de Proposição',
'verbose_name_plural': 'Anexos de Proposição',
'ordering': ('data', 'id'),
},
),
]

61
sapl/materia/models.py

@ -17,7 +17,8 @@ from sapl.compilacao.models import (PerfilEstruturalTextoArticulado,
TextoArticulado) TextoArticulado)
from sapl.parlamentares.models import Parlamentar from sapl.parlamentares.models import Parlamentar
from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericForeignKey, from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericForeignKey,
SaplGenericRelation, restringe_tipos_de_arquivo_txt, SaplGenericRelation, restringe_tipos_de_arquivo_doc_img,
restringe_tipos_de_arquivo_txt,
texto_upload_path, get_settings_auth_user_model, texto_upload_path, get_settings_auth_user_model,
OverwriteStorage) OverwriteStorage)
@ -590,7 +591,7 @@ class DocumentoAcessorio(models.Model):
upload_to=anexo_upload_path, upload_to=anexo_upload_path,
verbose_name=_('Texto Integral'), verbose_name=_('Texto Integral'),
storage=OverwriteStorage(), storage=OverwriteStorage(),
validators=[restringe_tipos_de_arquivo_txt]) validators=[restringe_tipos_de_arquivo_doc_img])
proposicao = GenericRelation('Proposicao', related_query_name='proposicao') proposicao = GenericRelation('Proposicao', related_query_name='proposicao')
data_ultima_atualizacao = models.DateTimeField( data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True, verbose_name=_('Data')) blank=True, null=True, auto_now=True, verbose_name=_('Data'))
@ -1204,6 +1205,62 @@ class HistoricoProposicao(models.Model):
return f'{self.data_hora} - {self.STATUS_PROPOSICAO[self.status]} - {str(self.proposicao)}' return f'{self.data_hora} - {self.STATUS_PROPOSICAO[self.status]} - {str(self.proposicao)}'
def anexo_proposicao_upload_path(instance, filename):
return texto_upload_path(instance, filename,
subpath=instance.proposicao.ano)
class AnexoProposicao(models.Model):
proposicao = models.ForeignKey(
Proposicao, on_delete=models.CASCADE,
related_name='anexos',
verbose_name=_('Proposição'))
arquivo = models.FileField(
max_length=300,
upload_to=anexo_proposicao_upload_path,
storage=OverwriteStorage(),
validators=[restringe_tipos_de_arquivo_doc_img],
verbose_name=_('Arquivo'))
nome = models.CharField(
max_length=50, verbose_name=_('Nome'))
tipo = models.ForeignKey(
TipoDocumento, on_delete=models.PROTECT,
verbose_name=_('Tipo'))
data = models.DateField(verbose_name=_('Data'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True)
class Meta:
verbose_name = _('Anexo de Proposição')
verbose_name_plural = _('Anexos de Proposição')
ordering = ('data', 'id')
def __str__(self):
return f'{self.nome} ({self.proposicao})'
def delete(self, using=None, keep_parents=False):
arquivo = self.arquivo
result = super().delete(using=using, keep_parents=keep_parents)
if arquivo:
arquivo.delete(save=False)
return result
def save(self, force_insert=False, force_update=False, using=None,
update_fields=None):
if not self.pk and self.arquivo:
arquivo = self.arquivo
self.arquivo = None
models.Model.save(self, force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields)
self.arquivo = arquivo
return models.Model.save(self, force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields)
class StatusTramitacao(models.Model): class StatusTramitacao(models.Model):
INDICADOR_CHOICES = Choices(('F', 'fim', _('Fim')), INDICADOR_CHOICES = Choices(('F', 'fim', _('Fim')),
('R', 'retorno', _('Retorno'))) ('R', 'retorno', _('Retorno')))

3
sapl/materia/onlyoffice_views.py

@ -397,10 +397,13 @@ def onlyoffice_editor(request, pk):
# URL do OnlyOffice acessível pelo navegador do usuário # URL do OnlyOffice acessível pelo navegador do usuário
onlyoffice_url = get_onlyoffice_browser_url(request) onlyoffice_url = get_onlyoffice_browser_url(request)
from sapl.materia.models import TipoDocumento
context = { context = {
'proposicao': proposicao, 'proposicao': proposicao,
'onlyoffice_url': onlyoffice_url, 'onlyoffice_url': onlyoffice_url,
'config_url': reverse('sapl.materia:onlyoffice_config', kwargs={'pk': pk}), 'config_url': reverse('sapl.materia:onlyoffice_config', kwargs={'pk': pk}),
'upload_anexos_url': reverse('sapl.materia:upload_anexos_proposicao', kwargs={'pk': pk}),
'tipos_documento': TipoDocumento.objects.all(),
} }
return render(request, 'materia/onlyoffice_editor.html', context) return render(request, 'materia/onlyoffice_editor.html', context)

9
sapl/materia/urls.py

@ -8,6 +8,8 @@ from sapl.materia.views import (AcompanhamentoConfirmarView,
CriarProtocoloMateriaView, DespachoInicialCrud, CriarProtocoloMateriaView, DespachoInicialCrud,
DocumentoAcessorioCrud, DocumentoAcessorioCrud,
DocumentoAcessorioEmLoteView, DocumentoAcessorioEmLoteView,
DocumentoAcessorioUploadView,
AnexoProposicaoUploadView,
MateriaAnexadaEmLoteView, MateriaAnexadaEmLoteView,
EtiquetaPesquisaView, FichaPesquisaView, EtiquetaPesquisaView, FichaPesquisaView,
FichaSelecionaView, ImpressosView, FichaSelecionaView, ImpressosView,
@ -134,6 +136,9 @@ urlpatterns_materia = [
name='autoria_multicreate'), name='autoria_multicreate'),
url(r'^materia/(?P<pk>\d+)/upload-anexos/$',
DocumentoAcessorioUploadView.as_view(),
name='upload_anexos_materia'),
url(r'^materia/acessorio-em-lote', DocumentoAcessorioEmLoteView.as_view(), url(r'^materia/acessorio-em-lote', DocumentoAcessorioEmLoteView.as_view(),
name='acessorio_em_lote'), name='acessorio_em_lote'),
url(r'^materia/(?P<pk>\d+)/anexada-em-lote', MateriaAnexadaEmLoteView.as_view(), url(r'^materia/(?P<pk>\d+)/anexada-em-lote', MateriaAnexadaEmLoteView.as_view(),
@ -258,6 +263,10 @@ urlpatterns_proposicao = [
url(r'^proposicao/historico', HistoricoProposicaoView.as_view(), url(r'^proposicao/historico', HistoricoProposicaoView.as_view(),
name='historico-proposicao'), name='historico-proposicao'),
url(r'^proposicao/(?P<pk>\d+)/upload-anexos/$',
AnexoProposicaoUploadView.as_view(),
name='upload_anexos_proposicao'),
# OnlyOffice endpoints # OnlyOffice endpoints
url(r'^proposicao/(?P<pk>\d+)/onlyoffice/editor$', onlyoffice_editor, url(r'^proposicao/(?P<pk>\d+)/onlyoffice/editor$', onlyoffice_editor,
name='onlyoffice_editor'), name='onlyoffice_editor'),

162
sapl/materia/views.py

@ -75,7 +75,8 @@ from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm,
ReceberProposicaoForm, RelatoriaForm, ReceberProposicaoForm, RelatoriaForm,
TramitacaoEmLoteFilterSet, TramitacaoEmLoteForm, TramitacaoEmLoteFilterSet, TramitacaoEmLoteForm,
UnidadeTramitacaoForm, StatusTramitacaoFilterSet) UnidadeTramitacaoForm, StatusTramitacaoFilterSet)
from .models import (AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial, from .models import (AcompanhamentoMateria, Anexada, AnexoProposicao, AssuntoMateria,
Autoria, DespachoInicial,
DocumentoAcessorio, MateriaAssunto, MateriaLegislativa, Numeracao, Orgao, DocumentoAcessorio, MateriaAssunto, MateriaLegislativa, Numeracao, Orgao,
Origem, Proposicao, RegimeTramitacao, Relatoria, StatusTramitacao, Origem, Proposicao, RegimeTramitacao, Relatoria, StatusTramitacao,
TipoDocumento, TipoFimRelatoria, TipoMateriaLegislativa, TipoProposicao, TipoDocumento, TipoFimRelatoria, TipoMateriaLegislativa, TipoProposicao,
@ -1960,6 +1961,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
class ListView(MasterDetailCrud.ListView): class ListView(MasterDetailCrud.ListView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
u = self.request.user
context['pode_upload'] = u.is_authenticated and (
u.is_superuser or
u.has_perm('materia.add_documentoacessorio')
)
context['tipos_documento'] = TipoDocumento.objects.all()
return context
def hook_arquivo(self, obj, default, url): def hook_arquivo(self, obj, default, url):
u = self.request.user u = self.request.user
can_edit = u.is_authenticated and ( can_edit = u.is_authenticated and (
@ -1980,6 +1991,155 @@ class DocumentoAcessorioCrud(MasterDetailCrud):
return html, url return html, url
class DocumentoAcessorioUploadView(PermissionRequiredMixin, TemplateView):
"""Upload múltiplo de arquivos como Documentos Acessórios."""
permission_required = ('materia.add_documentoacessorio',)
def post(self, request, *args, **kwargs):
materia = get_object_or_404(
MateriaLegislativa, pk=kwargs['pk'])
arquivos = request.FILES.getlist('arquivos')
tipo_id = request.POST.get('tipo')
if not arquivos:
return JsonResponse(
{'error': 'Nenhum arquivo enviado.'}, status=400)
tipo = None
if tipo_id:
tipo = TipoDocumento.objects.filter(pk=tipo_id).first()
if not tipo:
tipo, _ = TipoDocumento.objects.get_or_create(
descricao='Anexo')
criados = []
erros = []
for arq in arquivos:
if arq.size > MAX_DOC_UPLOAD_SIZE:
erros.append(f'{arq.name}: arquivo excede o tamanho máximo.')
continue
nome = os.path.splitext(arq.name)[0][:50]
doc = DocumentoAcessorio(
materia=materia,
tipo=tipo,
nome=nome,
data=timezone.now().date(),
arquivo=arq
)
try:
doc.save()
criados.append({
'id': doc.id,
'nome': doc.nome,
'arquivo': arq.name
})
except Exception as e:
erros.append(f'{arq.name}: {str(e)}')
return JsonResponse({
'criados': criados,
'erros': erros,
'total': len(criados)
})
class AnexoProposicaoUploadView(PermissionRequiredMixin, TemplateView):
"""Upload múltiplo de anexos para uma Proposição."""
permission_required = ('materia.add_proposicao',)
def post(self, request, *args, **kwargs):
proposicao = get_object_or_404(Proposicao, pk=kwargs['pk'])
# Verifica se o usuário é operador do autor
if not proposicao.autor.operadores.filter(
id=request.user.id).exists():
return JsonResponse(
{'error': 'Sem permissão.'}, status=403)
# Não permite upload se já foi enviada
if proposicao.data_envio:
return JsonResponse(
{'error': 'Proposição já enviada.'}, status=400)
arquivos = request.FILES.getlist('arquivos')
tipo_id = request.POST.get('tipo')
if not arquivos:
return JsonResponse(
{'error': 'Nenhum arquivo enviado.'}, status=400)
tipo = None
if tipo_id:
tipo = TipoDocumento.objects.filter(pk=tipo_id).first()
if not tipo:
tipo, _ = TipoDocumento.objects.get_or_create(
descricao='Anexo')
criados = []
erros = []
for arq in arquivos:
if arq.size > MAX_DOC_UPLOAD_SIZE:
erros.append(f'{arq.name}: arquivo excede o tamanho máximo.')
continue
nome = os.path.splitext(arq.name)[0][:50]
anexo = AnexoProposicao(
proposicao=proposicao,
tipo=tipo,
nome=nome,
data=timezone.now().date(),
arquivo=arq
)
try:
anexo.save()
criados.append({
'id': anexo.id,
'nome': anexo.nome,
'arquivo': arq.name
})
except Exception as e:
erros.append(f'{arq.name}: {str(e)}')
return JsonResponse({
'criados': criados,
'erros': erros,
'total': len(criados)
})
def delete(self, request, *args, **kwargs):
import json
data = json.loads(request.body)
anexo_id = data.get('id')
proposicao = get_object_or_404(Proposicao, pk=kwargs['pk'])
if not proposicao.autor.operadores.filter(
id=request.user.id).exists():
return JsonResponse(
{'error': 'Sem permissão.'}, status=403)
anexo = get_object_or_404(
AnexoProposicao, pk=anexo_id, proposicao=proposicao)
anexo.delete()
return JsonResponse({'ok': True})
def get(self, request, *args, **kwargs):
proposicao = get_object_or_404(Proposicao, pk=kwargs['pk'])
anexos = proposicao.anexos.all()
return JsonResponse({
'anexos': [
{
'id': a.id,
'nome': a.nome,
'tipo': str(a.tipo),
'arquivo': a.arquivo.name,
'arquivo_url': a.arquivo.url if a.arquivo else '',
}
for a in anexos
]
})
class AutoriaCrud(MasterDetailCrud): class AutoriaCrud(MasterDetailCrud):
model = Autoria model = Autoria
parent_field = 'materia' parent_field = 'materia'

159
sapl/templates/materia/documentoacessorio_list.html

@ -1,5 +1,54 @@
{% extends "crud/list.html" %} {% extends "crud/list.html" %}
{% load i18n %} {% load i18n %}
{% block extra_content %}
{% if pode_upload %}
<div class="card mb-3" id="upload-card">
<div class="card-header d-flex justify-content-between align-items-center"
style="cursor:pointer" onclick="document.getElementById('upload-body').classList.toggle('d-none')">
<strong><i class="fa fa-cloud-upload"></i> {% trans 'Upload Rápido de Anexos' %}</strong>
<small class="text-muted">{% trans 'clique para expandir' %}</small>
</div>
<div class="card-body d-none" id="upload-body">
<div class="form-group row mb-3">
<label class="col-sm-2 col-form-label">{% trans 'Tipo' %}</label>
<div class="col-sm-4">
<select id="upload-tipo" class="form-control">
{% for tipo in tipos_documento %}
<option value="{{ tipo.pk }}">{{ tipo.descricao }}</option>
{% empty %}
<option value="">{% trans 'Anexo (será criado automaticamente)' %}</option>
{% endfor %}
</select>
</div>
</div>
<div id="dropzone"
style="border: 2px dashed #007bff; border-radius: 8px; padding: 40px;
text-align: center; cursor: pointer; transition: all 0.2s;
background: #f8f9fa;">
<i class="fa fa-cloud-upload fa-3x text-primary mb-2" style="display:block"></i>
<p class="mb-1"><strong>{% trans 'Arraste arquivos aqui' %}</strong></p>
<p class="text-muted mb-0">{% trans 'ou clique para selecionar (fotos, PDFs, documentos)' %}</p>
<input type="file" id="upload-input" multiple
accept="image/*,.pdf,.doc,.docx,.odt,.txt,.xml"
style="display:none">
</div>
<div id="upload-progress" class="mt-3 d-none">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%" id="progress-bar"></div>
</div>
<small id="upload-status" class="text-muted mt-1 d-block"></small>
</div>
<div id="upload-results" class="mt-3"></div>
</div>
</div>
{% endif %}
{% endblock %}
{% block base_content %} {% block base_content %}
{{ block.super }} {{ block.super }}
<div style="display:flex;padding-left: 600px;padding-top: 10px;"> <div style="display:flex;padding-left: 600px;padding-top: 10px;">
@ -10,4 +59,114 @@
<a href="{% url 'sapl.materia:compress_docacessorios' root_pk %}" class="btn btn-outline-primary">{% trans 'Baixar documentos compactados' %}</a> <a href="{% url 'sapl.materia:compress_docacessorios' root_pk %}" class="btn btn-outline-primary">{% trans 'Baixar documentos compactados' %}</a>
</div> </div>
</div> </div>
{% if pode_upload %}
<script>
(function() {
var dropzone = document.getElementById('dropzone');
var input = document.getElementById('upload-input');
var progressDiv = document.getElementById('upload-progress');
var progressBar = document.getElementById('progress-bar');
var statusEl = document.getElementById('upload-status');
var resultsDiv = document.getElementById('upload-results');
var uploadUrl = '{% url "sapl.materia:upload_anexos_materia" root_pk %}';
var csrfToken = '{{ csrf_token }}';
dropzone.addEventListener('click', function() { input.click(); });
dropzone.addEventListener('dragover', function(e) {
e.preventDefault();
dropzone.style.borderColor = '#28a745';
dropzone.style.background = '#e8f5e9';
});
dropzone.addEventListener('dragleave', function(e) {
e.preventDefault();
dropzone.style.borderColor = '#007bff';
dropzone.style.background = '#f8f9fa';
});
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.style.borderColor = '#007bff';
dropzone.style.background = '#f8f9fa';
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
input.addEventListener('change', function() {
if (input.files.length > 0) {
uploadFiles(input.files);
}
});
function uploadFiles(files) {
var formData = new FormData();
var tipo = document.getElementById('upload-tipo').value;
if (tipo) formData.append('tipo', tipo);
for (var i = 0; i < files.length; i++) {
formData.append('arquivos', files[i]);
}
progressDiv.classList.remove('d-none');
progressBar.style.width = '0%';
statusEl.textContent = 'Enviando ' + files.length + ' arquivo(s)...';
resultsDiv.innerHTML = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl, true);
xhr.setRequestHeader('X-CSRFToken', csrfToken);
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
statusEl.textContent = 'Enviando... ' + pct + '%';
}
});
xhr.onload = function() {
progressBar.style.width = '100%';
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
var html = '<div class="alert alert-success">' +
'<strong>' + data.total + ' arquivo(s) enviado(s) com sucesso!</strong></div>';
if (data.erros && data.erros.length > 0) {
html += '<div class="alert alert-warning"><strong>Erros:</strong><ul>';
for (var i = 0; i < data.erros.length; i++) {
html += '<li>' + data.erros[i] + '</li>';
}
html += '</ul></div>';
}
resultsDiv.innerHTML = html;
if (data.total > 0) {
setTimeout(function() { location.reload(); }, 1500);
}
} else {
progressBar.classList.add('bg-danger');
var errMsg = 'Erro ao enviar arquivos.';
try { errMsg = JSON.parse(xhr.responseText).error || errMsg; } catch(e) {}
resultsDiv.innerHTML = '<div class="alert alert-danger">' + errMsg + '</div>';
}
input.value = '';
};
xhr.onerror = function() {
progressBar.classList.add('bg-danger');
resultsDiv.innerHTML = '<div class="alert alert-danger">Erro de conexão.</div>';
input.value = '';
};
xhr.send(formData);
}
})();
</script>
{% endif %}
{% endblock %} {% endblock %}

232
sapl/templates/materia/onlyoffice_editor.html

@ -45,6 +45,70 @@
</div> </div>
</div> </div>
</div> </div>
{# ===== SEÇÃO DE ANEXOS ===== #}
{% if upload_anexos_url %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="fa fa-paperclip"></i> {% trans "Documentos Anexos" %}</strong>
<small class="text-muted">{% trans "Fotos, mapas, plantas e outros arquivos que acompanham esta proposição" %}</small>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<label class="col-form-label"><strong>{% trans "Tipo do Documento" %}</strong></label>
<select id="anexo-tipo" class="form-control">
{% for tipo in tipos_documento %}
<option value="{{ tipo.pk }}">{{ tipo.descricao }}</option>
{% empty %}
<option value="">{% trans "Anexo" %}</option>
{% endfor %}
</select>
</div>
</div>
<div id="anexo-dropzone"
style="border: 2px dashed #007bff; border-radius: 8px; padding: 30px;
text-align: center; cursor: pointer; transition: all 0.2s;
background: #f8f9fa;">
<i class="fa fa-cloud-upload fa-2x text-primary mb-2" style="display:block"></i>
<p class="mb-1"><strong>{% trans "Arraste arquivos aqui" %}</strong></p>
<p class="text-muted mb-0">{% trans "ou clique para selecionar (fotos, PDFs, documentos)" %}</p>
<input type="file" id="anexo-input" multiple
accept="image/*,.pdf,.doc,.docx,.odt,.txt,.xml"
style="display:none">
</div>
<div id="anexo-progress" class="mt-3 d-none">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%" id="anexo-progress-bar"></div>
</div>
<small id="anexo-status" class="text-muted mt-1 d-block"></small>
</div>
<div id="anexo-messages" class="mt-2"></div>
{# Lista de anexos existentes #}
<div id="anexos-lista" class="mt-3">
<table class="table table-sm table-striped d-none" id="anexos-table">
<thead>
<tr>
<th>{% trans "Nome" %}</th>
<th>{% trans "Tipo" %}</th>
<th style="width:80px">{% trans "Ações" %}</th>
</tr>
</thead>
<tbody id="anexos-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
@ -199,4 +263,172 @@
} }
}); });
</script> </script>
{% if upload_anexos_url %}
<script type="text/javascript">
(function() {
var dropzone = document.getElementById('anexo-dropzone');
var input = document.getElementById('anexo-input');
var progressDiv = document.getElementById('anexo-progress');
var progressBar = document.getElementById('anexo-progress-bar');
var statusEl = document.getElementById('anexo-status');
var messagesDiv = document.getElementById('anexo-messages');
var uploadUrl = '{{ upload_anexos_url }}';
// Carregar anexos existentes ao abrir a página
carregarAnexos();
dropzone.addEventListener('click', function() { input.click(); });
dropzone.addEventListener('dragover', function(e) {
e.preventDefault();
dropzone.style.borderColor = '#28a745';
dropzone.style.background = '#e8f5e9';
});
dropzone.addEventListener('dragleave', function(e) {
e.preventDefault();
dropzone.style.borderColor = '#007bff';
dropzone.style.background = '#f8f9fa';
});
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.style.borderColor = '#007bff';
dropzone.style.background = '#f8f9fa';
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
input.addEventListener('change', function() {
if (input.files.length > 0) {
uploadFiles(input.files);
}
});
function uploadFiles(files) {
var formData = new FormData();
var tipo = document.getElementById('anexo-tipo').value;
if (tipo) formData.append('tipo', tipo);
for (var i = 0; i < files.length; i++) {
formData.append('arquivos', files[i]);
}
progressDiv.classList.remove('d-none');
progressBar.style.width = '0%';
progressBar.className = 'progress-bar progress-bar-striped progress-bar-animated';
statusEl.textContent = 'Enviando ' + files.length + ' arquivo(s)...';
messagesDiv.innerHTML = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl, true);
xhr.setRequestHeader('X-CSRFToken', csrfToken);
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + '%';
statusEl.textContent = 'Enviando... ' + pct + '%';
}
});
xhr.onload = function() {
progressBar.style.width = '100%';
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
var html = '<div class="alert alert-success alert-dismissible fade show">' +
'<strong>' + data.total + ' arquivo(s) anexado(s)!</strong>' +
'<button type="button" class="close" data-dismiss="alert">&times;</button></div>';
if (data.erros && data.erros.length > 0) {
html += '<div class="alert alert-warning"><ul class="mb-0">';
for (var i = 0; i < data.erros.length; i++) {
html += '<li>' + data.erros[i] + '</li>';
}
html += '</ul></div>';
}
messagesDiv.innerHTML = html;
carregarAnexos();
} else {
progressBar.classList.add('bg-danger');
var errMsg = 'Erro ao enviar.';
try { errMsg = JSON.parse(xhr.responseText).error || errMsg; } catch(e) {}
messagesDiv.innerHTML = '<div class="alert alert-danger">' + errMsg + '</div>';
}
input.value = '';
setTimeout(function() {
progressDiv.classList.add('d-none');
}, 3000);
};
xhr.onerror = function() {
progressBar.classList.add('bg-danger');
messagesDiv.innerHTML = '<div class="alert alert-danger">Erro de conexão.</div>';
input.value = '';
};
xhr.send(formData);
}
function carregarAnexos() {
fetch(uploadUrl)
.then(function(r) { return r.json(); })
.then(function(data) {
var tbody = document.getElementById('anexos-tbody');
var table = document.getElementById('anexos-table');
tbody.innerHTML = '';
if (data.anexos && data.anexos.length > 0) {
table.classList.remove('d-none');
for (var i = 0; i < data.anexos.length; i++) {
var a = data.anexos[i];
var tr = document.createElement('tr');
var nomeLink = a.arquivo_url
? '<a href="' + a.arquivo_url + '" target="_blank">' + a.nome + '</a>'
: a.nome;
tr.innerHTML =
'<td>' + nomeLink + '</td>' +
'<td><span class="badge badge-secondary">' + a.tipo + '</span></td>' +
'<td><button class="btn btn-sm btn-outline-danger" ' +
'onclick="removerAnexo(' + a.id + ', this)" title="Remover">' +
'<i class="fa fa-trash"></i></button></td>';
tbody.appendChild(tr);
}
} else {
table.classList.add('d-none');
}
});
}
window.removerAnexo = function(id, btn) {
if (!confirm('Remover este anexo?')) return;
btn.disabled = true;
fetch(uploadUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({id: id})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
carregarAnexos();
} else {
alert(data.error || 'Erro ao remover.');
btn.disabled = false;
}
})
.catch(function() {
alert('Erro de conexão.');
btn.disabled = false;
});
};
})();
</script>
{% endif %}
{% endblock %} {% endblock %}

7
sapl/utils.py

@ -688,8 +688,9 @@ def fabrica_validador_de_tipos_de_arquivo(lista, nome):
except FileNotFoundError: except FileNotFoundError:
raise ValidationError(_('Arquivo não encontrado')) raise ValidationError(_('Arquivo não encontrado'))
# o nome é importante para as migrations # o nome e qualname são importantes para as migrations
restringe_tipos_de_arquivo.__name__ = nome restringe_tipos_de_arquivo.__name__ = nome
restringe_tipos_de_arquivo.__qualname__ = nome
return restringe_tipos_de_arquivo return restringe_tipos_de_arquivo
@ -699,6 +700,10 @@ restringe_tipos_de_arquivo_txt = fabrica_validador_de_tipos_de_arquivo(
restringe_tipos_de_arquivo_img = fabrica_validador_de_tipos_de_arquivo( restringe_tipos_de_arquivo_img = fabrica_validador_de_tipos_de_arquivo(
TIPOS_IMG_PERMITIDOS, 'restringe_tipos_de_arquivo_img') TIPOS_IMG_PERMITIDOS, 'restringe_tipos_de_arquivo_img')
restringe_tipos_de_arquivo_doc_img = fabrica_validador_de_tipos_de_arquivo(
TIPOS_TEXTO_PERMITIDOS + TIPOS_IMG_PERMITIDOS,
'restringe_tipos_de_arquivo_doc_img')
def intervalos_tem_intersecao(a_inicio, a_fim, b_inicio, b_fim): def intervalos_tem_intersecao(a_inicio, a_fim, b_inicio, b_fim):
maior_inicio = max(a_inicio, b_inicio) maior_inicio = max(a_inicio, b_inicio)

Loading…
Cancel
Save