diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index 28d186297..ec2afb73e 100644 --- a/sapl/materia/forms.py +++ b/sapl/materia/forms.py @@ -2746,6 +2746,26 @@ class ConfirmarProposicaoForm(ProposicaoForm): 'Autoria registrada para (%s)' ) % 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 if proposicao.materia_de_vinculo: anexada = Anexada() diff --git a/sapl/materia/migrations/0093_allow_image_uploads_documentoacessorio.py b/sapl/materia/migrations/0093_allow_image_uploads_documentoacessorio.py new file mode 100644 index 000000000..ffc6ea8fd --- /dev/null +++ b/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'), + ), + ] diff --git a/sapl/materia/migrations/0094_add_anexoproposicao.py b/sapl/materia/migrations/0094_add_anexoproposicao.py new file mode 100644 index 000000000..174811af3 --- /dev/null +++ b/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'), + }, + ), + ] diff --git a/sapl/materia/models.py b/sapl/materia/models.py index 27e467927..5ce5ddf52 100644 --- a/sapl/materia/models.py +++ b/sapl/materia/models.py @@ -17,7 +17,8 @@ from sapl.compilacao.models import (PerfilEstruturalTextoArticulado, TextoArticulado) from sapl.parlamentares.models import Parlamentar 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, OverwriteStorage) @@ -590,7 +591,7 @@ class DocumentoAcessorio(models.Model): upload_to=anexo_upload_path, verbose_name=_('Texto Integral'), storage=OverwriteStorage(), - validators=[restringe_tipos_de_arquivo_txt]) + validators=[restringe_tipos_de_arquivo_doc_img]) proposicao = GenericRelation('Proposicao', related_query_name='proposicao') data_ultima_atualizacao = models.DateTimeField( 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)}' +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): INDICADOR_CHOICES = Choices(('F', 'fim', _('Fim')), ('R', 'retorno', _('Retorno'))) diff --git a/sapl/materia/onlyoffice_views.py b/sapl/materia/onlyoffice_views.py index c02d995dd..93cb55352 100644 --- a/sapl/materia/onlyoffice_views.py +++ b/sapl/materia/onlyoffice_views.py @@ -397,10 +397,13 @@ def onlyoffice_editor(request, pk): # URL do OnlyOffice acessível pelo navegador do usuário onlyoffice_url = get_onlyoffice_browser_url(request) + from sapl.materia.models import TipoDocumento context = { 'proposicao': proposicao, 'onlyoffice_url': onlyoffice_url, '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) diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index f6896278e..4333063eb 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -8,6 +8,8 @@ from sapl.materia.views import (AcompanhamentoConfirmarView, CriarProtocoloMateriaView, DespachoInicialCrud, DocumentoAcessorioCrud, DocumentoAcessorioEmLoteView, + DocumentoAcessorioUploadView, + AnexoProposicaoUploadView, MateriaAnexadaEmLoteView, EtiquetaPesquisaView, FichaPesquisaView, FichaSelecionaView, ImpressosView, @@ -134,6 +136,9 @@ urlpatterns_materia = [ name='autoria_multicreate'), + url(r'^materia/(?P\d+)/upload-anexos/$', + DocumentoAcessorioUploadView.as_view(), + name='upload_anexos_materia'), url(r'^materia/acessorio-em-lote', DocumentoAcessorioEmLoteView.as_view(), name='acessorio_em_lote'), url(r'^materia/(?P\d+)/anexada-em-lote', MateriaAnexadaEmLoteView.as_view(), @@ -258,6 +263,10 @@ urlpatterns_proposicao = [ url(r'^proposicao/historico', HistoricoProposicaoView.as_view(), name='historico-proposicao'), + url(r'^proposicao/(?P\d+)/upload-anexos/$', + AnexoProposicaoUploadView.as_view(), + name='upload_anexos_proposicao'), + # OnlyOffice endpoints url(r'^proposicao/(?P\d+)/onlyoffice/editor$', onlyoffice_editor, name='onlyoffice_editor'), diff --git a/sapl/materia/views.py b/sapl/materia/views.py index de3dfccaa..9ea94385a 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -75,7 +75,8 @@ from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, ReceberProposicaoForm, RelatoriaForm, TramitacaoEmLoteFilterSet, TramitacaoEmLoteForm, UnidadeTramitacaoForm, StatusTramitacaoFilterSet) -from .models import (AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial, +from .models import (AcompanhamentoMateria, Anexada, AnexoProposicao, AssuntoMateria, + Autoria, DespachoInicial, DocumentoAcessorio, MateriaAssunto, MateriaLegislativa, Numeracao, Orgao, Origem, Proposicao, RegimeTramitacao, Relatoria, StatusTramitacao, TipoDocumento, TipoFimRelatoria, TipoMateriaLegislativa, TipoProposicao, @@ -1960,6 +1961,16 @@ class DocumentoAcessorioCrud(MasterDetailCrud): 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): u = self.request.user can_edit = u.is_authenticated and ( @@ -1980,6 +1991,155 @@ class DocumentoAcessorioCrud(MasterDetailCrud): 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): model = Autoria parent_field = 'materia' diff --git a/sapl/templates/materia/documentoacessorio_list.html b/sapl/templates/materia/documentoacessorio_list.html index 08bae0819..04766b043 100644 --- a/sapl/templates/materia/documentoacessorio_list.html +++ b/sapl/templates/materia/documentoacessorio_list.html @@ -1,5 +1,54 @@ {% extends "crud/list.html" %} {% load i18n %} + +{% block extra_content %} +{% if pode_upload %} +
+
+ {% trans 'Upload Rápido de Anexos' %} + {% trans 'clique para expandir' %} +
+
+
+ +
+ +
+
+ +
+ +

{% trans 'Arraste arquivos aqui' %}

+

{% trans 'ou clique para selecionar (fotos, PDFs, documentos)' %}

+ +
+ +
+
+
+
+ +
+ +
+
+
+{% endif %} +{% endblock %} + {% block base_content %} {{ block.super }}
@@ -10,4 +59,114 @@ {% trans 'Baixar documentos compactados' %}
-{% endblock %} \ No newline at end of file + +{% if pode_upload %} + +{% endif %} +{% endblock %} diff --git a/sapl/templates/materia/onlyoffice_editor.html b/sapl/templates/materia/onlyoffice_editor.html index d4a170413..8e1e9145e 100644 --- a/sapl/templates/materia/onlyoffice_editor.html +++ b/sapl/templates/materia/onlyoffice_editor.html @@ -45,6 +45,70 @@ + + {# ===== SEÇÃO DE ANEXOS ===== #} + {% if upload_anexos_url %} +
+
+
+
+ {% trans "Documentos Anexos" %} + {% trans "Fotos, mapas, plantas e outros arquivos que acompanham esta proposição" %} +
+
+
+
+ + +
+
+ +
+ +

{% trans "Arraste arquivos aqui" %}

+

{% trans "ou clique para selecionar (fotos, PDFs, documentos)" %}

+ +
+ +
+
+
+
+ +
+ +
+ + {# Lista de anexos existentes #} +
+ + + + + + + + + +
{% trans "Nome" %}{% trans "Tipo" %}{% trans "Ações" %}
+
+
+
+
+
+ {% endif %} @@ -199,4 +263,172 @@ } }); + +{% if upload_anexos_url %} + +{% endif %} {% endblock %} diff --git a/sapl/utils.py b/sapl/utils.py index 6dc123b53..1e1d2a64f 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -688,8 +688,9 @@ def fabrica_validador_de_tipos_de_arquivo(lista, nome): except FileNotFoundError: 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.__qualname__ = nome 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( 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): maior_inicio = max(a_inicio, b_inicio)