From e9dd39d664d7daffeb4743bc5decaa7c21517e6d Mon Sep 17 00:00:00 2001 From: KemuelAlves Date: Thu, 7 May 2026 14:32:50 -0300 Subject: [PATCH 1/8] =?UTF-8?q?Como=20cliente,=20quero=20que=20documentos?= =?UTF-8?q?=20n=C3=A3o=20assinados=20tenham=20filtro=20na=20tela=20de=20Mi?= =?UTF-8?q?nhas=20Mat=C3=A9rias=20[AB#1322]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/materia/forms.py | 50 ++++++++ sapl/materia/urls.py | 5 + sapl/materia/views.py | 51 +++++++- .../js/materia_pesquisa_download_pdfs.js | 22 ++++ .../materia/materialegislativa_filter.html | 35 +++++- .../materias_pendentes_assinatura_list.html | 119 ++++++++++++++++++ sapl/templates/navbar.yaml | 2 + 7 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 sapl/templates/materia/materias_pendentes_assinatura_list.html diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index ec2afb73e..eb6de00a4 100644 --- a/sapl/materia/forms.py +++ b/sapl/materia/forms.py @@ -979,12 +979,26 @@ class AnexadaForm(ModelForm): fields = ['tipo', 'numero', 'ano', 'data_anexacao', 'data_desanexacao'] +CHOICE_STATUS_ASSINATURA = [ + ('', _('Todas')), + ('pendente', _('⚠ Pendente de Assinatura')), + ('assinada', _('✔ Assinada')), +] + + class MateriaLegislativaFilterSet(django_filters.FilterSet): ano = django_filters.ChoiceFilter(required=False, label='Ano da Matéria', choices=choice_anos_com_materias) + status_assinatura = django_filters.ChoiceFilter( + required=False, + label=_('Status de Assinatura'), + choices=CHOICE_STATUS_ASSINATURA, + method='filter_status_assinatura' + ) + autoria__autor = django_filters.CharFilter(widget=forms.HiddenInput()) autoria__primeiro_autor = django_filters.BooleanFilter( @@ -1074,6 +1088,17 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet): self.filters['o'].label = _('Ordenação') self.form.fields['tipo_listagem'] = self.tipo_listagem + row_assinatura = to_row([ + (HTML(''' +
+ ✍️ + Assinatura Digital: +
+ '''), 3), + ('status_assinatura', 4), + ]) + row1 = to_row( [('tipo', 5), ('ementa', 7)]) row2 = to_row( @@ -1131,6 +1156,7 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet): HTML(autor_label), HTML(autor_modal), row4, + row_assinatura, ), Button('btn_pesquisa_avancada', 'Pesquisa Avançada >>>', css_id='btn_pesquisa_avancada_id', @@ -1158,6 +1184,30 @@ class MateriaLegislativaFilterSet(django_filters.FilterSet): ) ) + def filter_status_assinatura(self, queryset, name, value): + if value == 'pendente': + # Tem texto original mas não tem PDF assinado + return queryset.filter( + texto_original__isnull=False + ).exclude( + texto_original='' + ).filter( + pdf_assinado__isnull=True + ) | queryset.filter( + texto_original__isnull=False + ).exclude( + texto_original='' + ).filter( + pdf_assinado='' + ) + elif value == 'assinada': + return queryset.exclude( + pdf_assinado__isnull=True + ).exclude( + pdf_assinado='' + ) + return queryset + @property def qs(self): qs = qs_override_django_filter(self) diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index 4333063eb..c0939bc67 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -16,6 +16,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView, LegislacaoCitadaCrud, MateriaAssuntoCrud, MateriaLegislativaCrud, MateriaLegislativaPesquisaView, MateriaTaView, + MateriasPendentesAssinaturaView, NumeracaoCrud, OrgaoCrud, OrigemCrud, PrimeiraTramitacaoEmLoteView, ProposicaoCrud, ProposicaoDevolvida, ProposicaoPendente, @@ -122,6 +123,10 @@ urlpatterns_materia = [ url(r'^materia/pesquisar-materia$', MateriaLegislativaPesquisaView.as_view(), name='pesquisar_materia'), + + url(r'^materia/pendentes-assinatura$', + MateriasPendentesAssinaturaView.as_view(), name='materias_pendentes_assinatura'), + url(r'^materia/(?P\d+)/acompanhar-materia/$', AcompanhamentoMateriaView.as_view(), name='acompanhar_materia'), url(r'^materia/(?P\d+)/acompanhar-confirmar$', diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 9ea94385a..1e888c5c9 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -15,7 +15,7 @@ from crispy_forms.layout import Div, HTML, Submit from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError from django.db.models import Max, Q @@ -38,7 +38,7 @@ from django.utils.decorators import method_decorator import sapl from sapl.base.email_utils import do_envia_email_confirmacao -from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig +from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig, OperadorAutor from sapl.comissoes.models import Participacao from sapl.compilacao.models import STATUS_TA_IMMUTABLE_RESTRICT, STATUS_TA_PRIVATE from sapl.compilacao.views import IntegracaoTaView @@ -641,6 +641,53 @@ class ProposicaoPendenteSetor(PermissionRequiredMixin, ListView): return context +class MateriasPendentesAssinaturaView(LoginRequiredMixin, ListView): + template_name = 'materia/materias_pendentes_assinatura_list.html' + model = MateriaLegislativa + paginate_by = 20 + login_url = '/login/' + + def get_autor(self): + try: + return OperadorAutor.objects.get(user=self.request.user).autor + except OperadorAutor.DoesNotExist: + return None + + def get_queryset(self): + autor = self.get_autor() + qs = MateriaLegislativa.objects.filter( + texto_original__isnull=False + ).exclude( + texto_original='' + ).filter( + Q(pdf_assinado__isnull=True) | Q(pdf_assinado='') + ) + if autor: + qs = qs.filter(autoria__autor=autor) + return qs.order_by('-data_apresentacao', '-id').distinct() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + autor = self.get_autor() + context['autor'] = autor + paginator = context['paginator'] + page_obj = context['page_obj'] + context['page_range'] = make_pagination(page_obj.number, paginator.num_pages) + context['total'] = paginator.count + # URL para pesquisar todas filtrando por autor + pendente + if autor: + context['url_pesquisa_completa'] = ( + reverse('sapl.materia:pesquisar_materia') + + f'?autoria__autor={autor.pk}&status_assinatura=pendente' + ) + else: + context['url_pesquisa_completa'] = ( + reverse('sapl.materia:pesquisar_materia') + + '?status_assinatura=pendente' + ) + return context + + class RevisarProposicaoSetor(PermissionRequiredMixin, UpdateView): template_name = "materia/revisar_proposicao_setor.html" model = Proposicao diff --git a/sapl/static/js/materia_pesquisa_download_pdfs.js b/sapl/static/js/materia_pesquisa_download_pdfs.js index 389c7065f..c213e3e23 100644 --- a/sapl/static/js/materia_pesquisa_download_pdfs.js +++ b/sapl/static/js/materia_pesquisa_download_pdfs.js @@ -208,5 +208,27 @@ downloadAllPDFs(); }); } + + // ── Filtro rápido de assinatura ────────────────────────────────── + const selectAssinatura = document.getElementById('select-status-assinatura'); + if (selectAssinatura) { + selectAssinatura.addEventListener('change', function () { + const status = this.value; + const url = new URL(window.location.href); + const params = url.searchParams; + + // Volta sempre para a página 1 + params.delete('page'); + + if (status === '') { + params.delete('status_assinatura'); + } else { + params.set('status_assinatura', status); + } + + window.location.href = url.pathname + '?' + params.toString(); + }); + } + // ───────────────────────────────────────────────────────────────── }); })(); diff --git a/sapl/templates/materia/materialegislativa_filter.html b/sapl/templates/materia/materialegislativa_filter.html index 885ab5d38..11bf3cb12 100644 --- a/sapl/templates/materia/materialegislativa_filter.html +++ b/sapl/templates/materia/materialegislativa_filter.html @@ -19,6 +19,23 @@ {% endif %} + {# ── Filtro rápido de assinatura ─────────────────────────────────── #} +
+ + {% with status_atual=request.GET.status_assinatura %} + + {% endwith %} +
+ {# ─────────────────────────────────────────────────────────────────── #} +
{% switch "SOLR_SWITCH" %} @@ -37,6 +54,7 @@ {% endblock %} {% block detail_content %} + {% if not show_results %} {% crispy filter.form %} {% endif %} @@ -215,6 +233,19 @@ Texto Original +   + {% if m.pdf_assinado %} + + Assinado{% if m.assinado_em %} em {{ m.assinado_em|date:"d/m/Y" }}{% endif %} + + {% elif m.texto_original %} + + Pendente de Assinatura + + {% endif %}
{% endif %} {% if m.texto_articulado.exists %}Texto Articulado
{% endif %} @@ -256,7 +287,7 @@ {% block extra_js %} - + {% if materias_pendentes_lote %} + {# ── Modal de Assinatura em Lote ─────────────────────────────────────── #} + + + + {% endif %} + {% endif %} +{% if pode_remover_assinatura and object.pdf_assinado %} + {% include "materia/remover_assinatura_modal.html" %} +{% endif %} {% endblock extra_js %} diff --git a/sapl/templates/materia/materialegislativa_detail.html b/sapl/templates/materia/materialegislativa_detail.html index 210bd583a..e9555dbd7 100644 --- a/sapl/templates/materia/materialegislativa_detail.html +++ b/sapl/templates/materia/materialegislativa_detail.html @@ -230,6 +230,13 @@ {% trans "Verificar Autenticidade" %} {% endif %} + {% if pode_remover_assinatura %} + + {% endif %}
@@ -250,4 +257,7 @@ }); {% endif %} +{% if pode_remover_assinatura and object.pdf_assinado %} + {% include "materia/remover_assinatura_modal.html" %} +{% endif %} {% endblock extra_js %} diff --git a/sapl/templates/materia/remover_assinatura_modal.html b/sapl/templates/materia/remover_assinatura_modal.html new file mode 100644 index 000000000..d60608781 --- /dev/null +++ b/sapl/templates/materia/remover_assinatura_modal.html @@ -0,0 +1,135 @@ +{% load i18n %} + + + + + From 425686ce0d9313ec39eba2a16f596756ae61499a Mon Sep 17 00:00:00 2001 From: KemuelAlves Date: Thu, 14 May 2026 09:16:16 -0300 Subject: [PATCH 5/8] =?UTF-8?q?Feat=20(Assinatura=20digital):=20Como=20cli?= =?UTF-8?q?ente,=20quero=20poder=20imprimir=20diversos=20documentos=20de?= =?UTF-8?q?=20uma=20=C3=BAnica=20vez=20[AB#1325]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/materia/onlyoffice_materia_views.py | 207 ++++++++++++++++++ sapl/materia/urls.py | 10 +- sapl/templates/materia/assinatura_modal.html | 166 +++++++++++++- .../materia/materialegislativa_detail.html | 10 +- .../materia/materialegislativa_filter.html | 114 +++++++++- 5 files changed, 491 insertions(+), 16 deletions(-) diff --git a/sapl/materia/onlyoffice_materia_views.py b/sapl/materia/onlyoffice_materia_views.py index e7ebb663d..3da122b51 100644 --- a/sapl/materia/onlyoffice_materia_views.py +++ b/sapl/materia/onlyoffice_materia_views.py @@ -821,3 +821,210 @@ def materia_gerar_pdf_assinatura(request, pk): logger.error(f"Erro inesperado na geração de PDF: {e}") messages.error(request, 'Erro inesperado ao gerar o PDF.') return redirect('sapl.materia:materialegislativa_detail', pk=pk) + + +# ============================================================ +# Prévia de PDF para Documento Acessório +# ============================================================ + +# ============================================================ +# Prévia de PDF para Documento Acessório +# ============================================================ + +@login_required +@require_http_methods(["GET"]) +def materia_gerar_pdf_previa(request, pk): + """ + Gera o PDF da matéria para prévia antes da assinatura. + Diferente do materia_gerar_pdf_assinatura, não exige numero_protocolo. + Se já for PDF, retorna direto. Caso seja DOCX, converte via OnlyOffice. + """ + import requests as http_requests + import xml.etree.ElementTree as ET + + materia = get_object_or_404(MateriaLegislativa, pk=pk) + + if not materia.texto_original: + return HttpResponse('Matéria não possui documento de texto original.', status=404) + + file_name = materia.texto_original.name.lower() + + if file_name.endswith('.pdf'): + try: + with open(materia.texto_original.path, 'rb') as f: + content = f.read() + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = ( + f'inline; filename="Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf"' + ) + return response + except Exception as e: + logger.error(f"Erro ao ler PDF da materia {pk}: {e}") + return HttpResponse('Erro ao ler o arquivo PDF.', status=500) + + download_url = build_onlyoffice_url( + request, + reverse('sapl.materia:materia_onlyoffice_download', kwargs={'pk': pk}) + ) + + conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx' + + conversion_data = { + "async": False, + "filetype": "docx", + "key": generate_file_key("materia_previa", pk, request.user.pk), + "outputtype": "pdf", + "title": f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf", + "url": download_url, + } + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + conversion_data['token'] = token + + try: + headers = {'Content-Type': 'application/json'} + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + headers['Authorization'] = f'Bearer {header_token}' + + conversion_response = http_requests.post( + conversion_url, + json=conversion_data, + headers=headers, + timeout=60 + ) + + if conversion_response.status_code != 200: + return HttpResponse('Erro ao converter o documento.', status=500) + + try: + root = ET.fromstring(conversion_response.text) + except ET.ParseError as e: + logger.error(f"Erro ao parsear resposta XML (materia previa): {e}") + return HttpResponse('Erro ao processar resposta do serviço.', status=500) + + error_elem = root.find('Error') + if error_elem is not None: + return HttpResponse(f'Erro na conversão: {error_elem.text}', status=500) + + file_url_elem = root.find('FileUrl') + if file_url_elem is None or not file_url_elem.text: + return HttpResponse('URL do PDF não retornada.', status=500) + + pdf_response = http_requests.get(file_url_elem.text, timeout=60) + if pdf_response.status_code != 200: + return HttpResponse('Erro ao baixar PDF convertido.', status=500) + + filename = f"Materia_{materia.tipo}_{materia.numero}_{materia.ano}.pdf" + filename = filename.replace(' ', '_').replace('/', '-') + response = HttpResponse(pdf_response.content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="{filename}"' + return response + + except http_requests.exceptions.Timeout: + logger.error(f"Timeout na conversão OnlyOffice (materia previa pk={pk})") + return HttpResponse('Tempo limite excedido ao converter o documento.', status=504) + except http_requests.exceptions.ConnectionError: + logger.error(f"Erro de conexão com OnlyOffice (materia previa pk={pk})") + return HttpResponse('Não foi possível conectar ao serviço de conversão.', status=502) + except Exception as e: + logger.error(f"Erro inesperado na previa PDF (materia pk={pk}): {e}") + return HttpResponse('Erro inesperado ao gerar o PDF.', status=500) + + +@login_required +@require_http_methods(["GET"]) +def docacessorio_gerar_pdf_previa(request, pk): + """ + Gera o PDF do documento acessório para prévia antes da assinatura. + Se já for PDF, retorna direto. Caso seja DOCX, converte via OnlyOffice. + """ + import requests as http_requests + import xml.etree.ElementTree as ET + + docacessorio = get_object_or_404(DocumentoAcessorio, pk=pk) + + if not docacessorio.arquivo: + return HttpResponse('Documento sem arquivo.', status=404) + + file_name = docacessorio.arquivo.name.lower() + + if file_name.endswith('.pdf'): + try: + with open(docacessorio.arquivo.path, 'rb') as f: + content = f.read() + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="DocAcessorio_{pk}.pdf"' + return response + except Exception as e: + logger.error(f"Erro ao ler arquivo PDF do doc acessorio: {e}") + return HttpResponse('Erro ao ler o arquivo PDF.', status=500) + + download_url = build_onlyoffice_url( + request, + reverse('sapl.materia:docacessorio_onlyoffice_download', kwargs={'pk': pk}) + ) + + conversion_url = f'{settings.ONLYOFFICE_URL}/ConvertService.ashx' + + conversion_data = { + "async": False, + "filetype": "docx", + "key": generate_file_key("docacessorio_pdf", pk, request.user.pk), + "outputtype": "pdf", + "title": f"DocAcessorio_{pk}.pdf", + "url": download_url, + } + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + token = jwt.encode(conversion_data, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + conversion_data['token'] = token + + try: + headers = {'Content-Type': 'application/json'} + + if getattr(settings, 'ONLYOFFICE_JWT_ENABLED', False) and getattr(settings, 'ONLYOFFICE_JWT_SECRET', None): + import jwt + header_token = jwt.encode({"payload": conversion_data}, settings.ONLYOFFICE_JWT_SECRET, algorithm='HS256') + headers['Authorization'] = f'Bearer {header_token}' + + conversion_response = http_requests.post( + conversion_url, + json=conversion_data, + headers=headers, + timeout=60 + ) + + if conversion_response.status_code != 200: + return HttpResponse('Erro ao converter o documento.', status=500) + + try: + root = ET.fromstring(conversion_response.text) + except ET.ParseError as e: + logger.error(f"Erro ao parsear resposta XML: {e}") + return HttpResponse('Erro ao processar resposta do serviço.', status=500) + + error_elem = root.find('Error') + if error_elem is not None: + return HttpResponse(f'Erro na conversão: {error_elem.text}', status=500) + + file_url_elem = root.find('FileUrl') + if file_url_elem is None or not file_url_elem.text: + return HttpResponse('URL do PDF não retornada.', status=500) + + pdf_response = http_requests.get(file_url_elem.text, timeout=60) + if pdf_response.status_code != 200: + return HttpResponse('Erro ao baixar PDF convertido.', status=500) + + response = HttpResponse(pdf_response.content, content_type='application/pdf') + response['Content-Disposition'] = f'inline; filename="DocAcessorio_{pk}.pdf"' + return response + + except Exception as e: + logger.error(f"Erro inesperado na previa PDF (docacessorio): {e}") + return HttpResponse('Erro inesperado ao gerar o PDF.', status=500) diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index a3e67b8f2..007a7b7b6 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -47,7 +47,7 @@ from sapl.materia.onlyoffice_materia_views import ( docacessorio_onlyoffice_editor, docacessorio_onlyoffice_config, docacessorio_onlyoffice_download, docacessorio_onlyoffice_callback, docacessorio_check_doc, docacessorio_forcesave, - materia_gerar_pdf_assinatura + materia_gerar_pdf_assinatura, materia_gerar_pdf_previa, docacessorio_gerar_pdf_previa ) from sapl.materia.views_assinatura import ( materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, @@ -180,6 +180,10 @@ urlpatterns_materia = [ url(r'^materia/(?P\d+)/pdf-assinatura$', materia_gerar_pdf_assinatura, name='materia_pdf_assinatura'), + # Prévia de PDF da Matéria antes da assinatura (sem restrição de protocolo) + url(r'^materia/(?P\d+)/pdf-previa$', materia_gerar_pdf_previa, + name='materia_pdf_previa'), + # Assinatura Digital de Matéria Legislativa url(r'^materia/(?P\d+)/assinar/a1/$', materia_assinar_a1, name='materia_assinar_a1'), @@ -216,6 +220,10 @@ urlpatterns_materia = [ url(r'^materia/documentoacessorio/(?P\d+)/forcesave$', docacessorio_forcesave, name='docacessorio_forcesave'), + # Prévia de PDF do Documento Acessório antes da assinatura + url(r'^materia/documentoacessorio/(?P\d+)/pdf-previa$', docacessorio_gerar_pdf_previa, + name='docacessorio_pdf_previa'), + # Assinatura Digital de Documento Acessório url(r'^materia/documentoacessorio/(?P\d+)/assinar/a1/$', docacessorio_assinar_a1, name='docacessorio_assinar_a1'), diff --git a/sapl/templates/materia/assinatura_modal.html b/sapl/templates/materia/assinatura_modal.html index 25c15ba62..d7c27d438 100644 --- a/sapl/templates/materia/assinatura_modal.html +++ b/sapl/templates/materia/assinatura_modal.html @@ -1,5 +1,52 @@ {% load i18n %} + + +