From a212ae6c739755d251cc2163bb656716d0fb9013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moraes?= Date: Wed, 14 Oct 2026 17:53:39 -0300 Subject: [PATCH] =?UTF-8?q?Permite=20os=20relat=C3=B3rios=20por=20autor=20?= =?UTF-8?q?sem=20autor,=20limitados=20por=20per=C3=ADodo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desde o 3.1.165-RC3, Matérias por Autor e Normas por Autor devolviam "Não foram encontradas..." quando nenhum autor era selecionado, sem explicar o motivo (OSTicket #885134, Mamborê/PR). - Sem autor, a pesquisa exige o período (data inicial e final); sem nenhum dos dois, o formulário mostra o erro. - Matérias sem autor: lista as matérias do período, uma vez cada, com todos os autores. - Normas sem autor: agrupa as normas do período pelo primeiro autor, como antes do RC3. - Com o formulário inválido e "Gerar relatório PDF" marcado, os relatórios voltam à tela com os erros em vez de gerar um PDF vazio. - Prefetch das autorias e dos autores nas duas listagens. --- sapl/relatorios/forms.py | 53 +++++- sapl/relatorios/tests.py | 155 +++++++++++++++++- sapl/relatorios/views.py | 8 +- .../RelatorioMateriasPorAutor_filter.html | 4 +- .../RelatorioNormasPorAutor_filter.html | 4 +- .../relatorio_materias_por_autor.html | 2 +- .../relatorio_normas_por_autor.html | 2 +- 7 files changed, 215 insertions(+), 13 deletions(-) diff --git a/sapl/relatorios/forms.py b/sapl/relatorios/forms.py index 726501524..130596d75 100644 --- a/sapl/relatorios/forms.py +++ b/sapl/relatorios/forms.py @@ -625,19 +625,52 @@ class RelatorioMateriasPorAnoAutorTipoFilterSet(django_filters.FilterSet): ) +class AutorOuPeriodoForm(forms.Form): + """ + Relatórios por autor aceitam pesquisa sem autor, desde que informado um + período fechado, para não listar todo o acervo da Casa de uma só vez. + """ + campo_autor = None + campo_periodo = None + + def clean(self): + cleaned_data = super().clean() + + if cleaned_data.get(self.campo_autor) or \ + self.campo_periodo in self.errors: + return cleaned_data + + periodo = cleaned_data.get(self.campo_periodo) + if not periodo or periodo.start is None or periodo.stop is None: + raise forms.ValidationError( + _('Selecione um autor ou informe o período ' + '(data inicial e final).')) + + return cleaned_data + + +class RelatorioMateriasPorAutorForm(AutorOuPeriodoForm): + campo_autor = 'autoria__autor' + campo_periodo = 'data_apresentacao' + + class RelatorioMateriasPorAutorFilterSet(django_filters.FilterSet): autoria__autor = django_filters.CharFilter(widget=forms.HiddenInput()) @property def qs(self): parent = super().qs - if not self.data.get('autoria__autor'): + if not self.is_valid(): return parent.none() - return parent.distinct().order_by('-ano', '-numero', 'tipo', 'autoria__autor', '-autoria__primeiro_autor') + if self.form.cleaned_data.get('autoria__autor'): + return parent.distinct().order_by('-ano', '-numero', 'tipo', 'autoria__autor', '-autoria__primeiro_autor') + # Sem autor, o período exigido pelo form limita a consulta. + return parent.order_by('-ano', '-numero', 'tipo') class Meta(FilterOverridesMetaMixin): model = MateriaLegislativa fields = ['tipo', 'data_apresentacao'] + form = RelatorioMateriasPorAutorForm def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -738,20 +771,30 @@ class RelatorioHistoricoTramitacaoAdmFilterSet(django_filters.FilterSet): ) +class RelatorioNormasPorAutorForm(AutorOuPeriodoForm): + campo_autor = 'autorianorma__autor' + campo_periodo = 'data' + + class RelatorioNormasPorAutorFilterSet(django_filters.FilterSet): autorianorma__autor = django_filters.CharFilter(widget=forms.HiddenInput()) @property def qs(self): parent = super().qs - if not self.data.get('autorianorma__autor'): + if not self.is_valid(): return parent.none() - return parent.distinct() \ - .order_by('autorianorma__autor', '-autorianorma__primeiro_autor', 'tipo', '-ano', '-numero') + if self.form.cleaned_data.get('autorianorma__autor'): + return parent.distinct() \ + .order_by('autorianorma__autor', '-autorianorma__primeiro_autor', 'tipo', '-ano', '-numero') + # Sem autor, agrupa as normas do período pelo primeiro autor. + return parent.filter(autorianorma__primeiro_autor=True) \ + .order_by('autorianorma__autor', 'tipo', '-ano', '-numero') class Meta(FilterOverridesMetaMixin): model = NormaJuridica fields = ['tipo', 'data'] + form = RelatorioNormasPorAutorForm def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/sapl/relatorios/tests.py b/sapl/relatorios/tests.py index fe46e86c4..075c0d39d 100644 --- a/sapl/relatorios/tests.py +++ b/sapl/relatorios/tests.py @@ -1,7 +1,13 @@ +from datetime import date + import pytest +from django.urls import reverse from model_bakery import baker -from sapl.base.models import CasaLegislativa +from sapl.base.models import Autor, CasaLegislativa +from sapl.materia.models import Autoria, MateriaLegislativa +from sapl.norma.models import AutoriaNorma, NormaJuridica +from sapl.relatorios import views as relatorios_views from sapl.relatorios.views import get_sessao_plenaria from sapl.sessao.models import ExpedienteSessao, SessaoPlenaria, TipoExpediente from sapl.sessao.views import get_expedientes @@ -50,3 +56,150 @@ def test_relatorio_sessao_tem_mesma_ordem_de_expedientes_do_resumo(): da_tela = [e['tipo'].nome for e in get_expedientes(sessao)['expedientes']] assert do_pdf == da_tela + + +MSG_AUTOR_OU_PERIODO = 'Selecione um autor ou informe o período' + + +def params_materias_por_autor(autor='', inicio='', fim='', **extra): + return dict(tipo='', data_apresentacao_0=inicio, + data_apresentacao_1=fim, autoria__autor=autor, + pesquisar='Pesquisar', **extra) + + +def params_normas_por_autor(autor='', inicio='', fim='', **extra): + return dict(tipo='', data_0=inicio, data_1=fim, + autorianorma__autor=autor, pesquisar='Pesquisar', **extra) + + +def cria_materia(numero, data_apresentacao, autores): + materia = baker.make(MateriaLegislativa, numero=numero, + ano=data_apresentacao.year, + data_apresentacao=data_apresentacao, + ementa='Ementa da matéria {}'.format(numero)) + for i, autor in enumerate(autores): + baker.make(Autoria, materia=materia, autor=autor, + primeiro_autor=(i == 0)) + return materia + + +def cria_norma(numero, data_norma, autores): + norma = baker.make(NormaJuridica, numero=numero, ano=data_norma.year, + data=data_norma, + ementa='Ementa da norma {}'.format(numero)) + for i, autor in enumerate(autores): + baker.make(AutoriaNorma, norma=norma, autor=autor, + primeiro_autor=(i == 0)) + return norma + + +@pytest.fixture +def pdf_html(monkeypatch): + """Troca o WeasyPrint por uma captura do HTML que iria para o PDF.""" + baker.make(CasaLegislativa, cep='') + capturado = {} + + def make_pdf(base_url, main_template, header_template, **kwargs): + capturado['html'] = main_template + return b'%PDF-fake' + + monkeypatch.setattr(relatorios_views, 'make_pdf', make_pdf) + return capturado + + +@pytest.mark.django_db(transaction=False) +def test_materias_por_autor_sem_autor_e_sem_periodo_pede_filtro(client): + response = client.get(reverse('sapl.relatorios:materia_por_autor'), + params_materias_por_autor()) + + assert MSG_AUTOR_OU_PERIODO in response.content.decode() + assert not response.context.get('show_results') + + +@pytest.mark.django_db(transaction=False) +def test_materias_por_autor_sem_autor_lista_materias_do_periodo(client): + """OSTicket #885134: sem autor, a pesquisa por período deve listar as + matérias do período, uma vez cada, mesmo as de mais de um autor.""" + ana = baker.make(Autor, nome='Ana') + bruno = baker.make(Autor, nome='Bruno') + m1 = cria_materia(1, date(2025, 3, 1), [ana, bruno]) + m2 = cria_materia(2, date(2025, 6, 1), [bruno]) + cria_materia(3, date(2024, 1, 1), [ana]) + + response = client.get( + reverse('sapl.relatorios:materia_por_autor'), + params_materias_por_autor(inicio='01/01/2025', fim='31/12/2025')) + + assert list(response.context['object_list']) == [m2, m1] + content = response.content.decode() + assert 'Autor: Todos' in content + assert 'Autor(es)' in content + + +@pytest.mark.django_db(transaction=False) +def test_materias_por_autor_com_autor_dispensa_periodo(client): + ana = baker.make(Autor, nome='Ana') + bruno = baker.make(Autor, nome='Bruno') + m1 = cria_materia(1, date(2025, 3, 1), [ana, bruno]) + cria_materia(2, date(2025, 6, 1), [bruno]) + m3 = cria_materia(3, date(2024, 1, 1), [ana]) + + response = client.get(reverse('sapl.relatorios:materia_por_autor'), + params_materias_por_autor(autor=ana.pk)) + + assert list(response.context['object_list']) == [m1, m3] + + +@pytest.mark.django_db(transaction=False) +def test_materias_por_autor_pdf_lista_materias(client, pdf_html): + """OSTicket #885134: o PDF saía "Nenhum documento encontrado" mesmo com + a tela listando as matérias do autor.""" + ana = baker.make(Autor, nome='Ana') + cria_materia(1, date(2025, 3, 1), [ana]) + + response = client.get( + reverse('sapl.relatorios:materia_por_autor'), + params_materias_por_autor(autor=ana.pk, relatorio='on')) + + assert response['Content-Type'].startswith('application/pdf') + assert 'Ementa da matéria 1' in pdf_html['html'] + assert 'Nenhum documento encontrado' not in pdf_html['html'] + + +@pytest.mark.django_db(transaction=False) +def test_materias_por_autor_pdf_sem_filtro_volta_ao_form(client, pdf_html): + response = client.get( + reverse('sapl.relatorios:materia_por_autor'), + params_materias_por_autor(relatorio='on')) + + assert response['Content-Type'].startswith('text/html') + assert MSG_AUTOR_OU_PERIODO in response.content.decode() + assert 'html' not in pdf_html + + +@pytest.mark.django_db(transaction=False) +def test_normas_por_autor_sem_autor_e_sem_periodo_pede_filtro(client): + response = client.get(reverse('sapl.relatorios:normas_por_autor'), + params_normas_por_autor()) + + assert MSG_AUTOR_OU_PERIODO in response.content.decode() + assert not response.context.get('show_results') + + +@pytest.mark.django_db(transaction=False) +def test_normas_por_autor_sem_autor_agrupa_pelo_primeiro_autor(client): + ana = baker.make(Autor, nome='Ana') + bruno = baker.make(Autor, nome='Bruno') + n1 = cria_norma('1', date(2025, 3, 1), [ana, bruno]) + n2 = cria_norma('2', date(2025, 6, 1), [bruno]) + cria_norma('3', date(2024, 1, 1), [ana]) + + response = client.get( + reverse('sapl.relatorios:normas_por_autor'), + params_normas_por_autor(inicio='01/01/2025', fim='31/12/2025')) + + assert list(response.context['object_list']) == [n1, n2] + content = response.content.decode() + assert 'Autor: Todos' in content + assert 'Autor: Ana' in content + assert 'Autor: Bruno' in content diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 3fbb889b4..d8e654f42 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -1882,7 +1882,8 @@ class RelatorioMixin: is_relatorio = request.GET.get('relatorio') context = self.get_context_data(filter=self.filterset) - if is_relatorio: + # Com o form inválido, volta à tela com os erros em vez de um PDF vazio + if is_relatorio and self.filterset.form.is_valid(): return self.relatorio(request, context) else: return self.render_to_response(context) @@ -2710,6 +2711,8 @@ class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView): qs = context['object_list'] context['qtdes'] = num_materias_por_tipo(qs) + context['object_list'] = qs.select_related('tipo').prefetch_related( + 'autoria_set__autor__autor_related') qr = self.request.GET.copy() context['filter_url'] = ('&' + qr.urlencode()) if len(qr) > 0 else '' @@ -2955,6 +2958,9 @@ class RelatorioNormasPorAutorView(RelatorioMixin, FilterView): if qtde > 0: qtdes[tipo] = qtde context['qtdes'] = qtdes + context['object_list'] = context['object_list'] \ + .select_related('tipo') \ + .prefetch_related('autorianorma_set__autor__autor_related') qr = self.request.GET.copy() context['filter_url'] = ('&' + qr.urlencode()) if len(qr) > 0 else '' diff --git a/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html b/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html index 03a68dae9..a3cc2f002 100644 --- a/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html +++ b/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html @@ -13,7 +13,7 @@



PARÂMETROS DE PESQUISA:
-  Autor: {{ autor }}
+  Autor: {{ autor|default_if_none:"Todos" }}
 Tipo de matéria: {{ tipo }}
 Data de apresentação: {{ periodo }}


@@ -43,7 +43,7 @@ Matéria Ementa - Coautor(es) + {% if autor %}Coautor(es){% else %}Autor(es){% endif %} {% for materia in object_list %} diff --git a/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html b/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html index 2c16e1ec1..a1b0f6a80 100644 --- a/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html +++ b/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html @@ -13,7 +13,7 @@



PARÂMETROS DE PESQUISA:
-  Autor: {{ autor }}
+  Autor: {{ autor|default_if_none:"Todos" }}
 Tipo de Norma: {{ tipo }}
 Data: {{periodo}}


{% if object_list %} @@ -55,7 +55,7 @@ {{norma.ementa}}
{{norma.observacao}} {% for autoria in norma.autorianorma_set.all %} - {% if autoria.autor != autor %} + {% if autor and autoria.autor != autor or not autor and not autoria.primeiro_autor %} {{ autoria.autor }}
{% endif %} {% endfor %} diff --git a/sapl/templates/relatorios/relatorio_materias_por_autor.html b/sapl/templates/relatorios/relatorio_materias_por_autor.html index 3325059d3..bc7b74b95 100644 --- a/sapl/templates/relatorios/relatorio_materias_por_autor.html +++ b/sapl/templates/relatorios/relatorio_materias_por_autor.html @@ -6,7 +6,7 @@ {% block content %}

Matérias por Autor

PARÂMETROS DE PESQUISA:
-  Autor: {{ autor }}
+  Autor: {{ autor|default_if_none:"Todos" }}
 Tipo de matéria: {{ tipo }}
 Data de apresentação: {{periodo}}


diff --git a/sapl/templates/relatorios/relatorio_normas_por_autor.html b/sapl/templates/relatorios/relatorio_normas_por_autor.html index 716dcb7e8..74aa23624 100644 --- a/sapl/templates/relatorios/relatorio_normas_por_autor.html +++ b/sapl/templates/relatorios/relatorio_normas_por_autor.html @@ -8,7 +8,7 @@

Norma por Autor

PARÂMETROS DE PESQUISA:
-  Autor: {{ autor }}
+  Autor: {{ autor|default_if_none:"Todos" }}
 Tipo de Norma: {{ tipo }}
 Data: {{periodo}}


{% if object_list %}