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 @@