From 9da03c1f083e7920530a0bc864757e7728ed3ff5 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 3 Aug 2026 14:19:15 -0300 Subject: [PATCH 01/17] hot-fix: Remove alerta errado --- sapl/materia/forms.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index 3615fee47..9a0d5007c 100644 --- a/sapl/materia/forms.py +++ b/sapl/materia/forms.py @@ -1944,10 +1944,6 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): fields = [ to_column((Fieldset( TipoProposicao._meta.verbose_name, Field('tipo')), 12)), - to_column( - (Alert('teste', - css_class="ementa_materia hidden alert-info", - dismiss=False), 12)), to_column(('descricao', 12)), to_column(('observacao', 12)), From a258c0a232013e6663865a22578fbef454086a17 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Mon, 3 Aug 2026 15:48:02 -0300 Subject: [PATCH 02/17] hot-fix: Adiciona MIME Media Types opcionais para CSV. --- sapl/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sapl/utils.py b/sapl/utils.py index 42d4b2c6e..9bd6231dc 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -611,6 +611,9 @@ TIPOS_TEXTO_PERMITIDOS = ( 'text/pdf', 'text/x-pdf', 'text/plain', + 'text/csv', + 'application/vnd.ms-excel', + 'application/csv', 'application/txt', 'browser/internal', 'text/anytext', From 85079b492d691ed6ae256c7d383a996be02ca3e2 Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:04:19 -0300 Subject: [PATCH 03/17] =?UTF-8?q?Oculta=20sess=C3=B5es=20plen=C3=A1rias=20?= =?UTF-8?q?n=C3=A3o=20iniciadas=20da=20consulta=20p=C3=BAblica=20(#3845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Oculta sessões plenárias não iniciadas da consulta pública Sessões cadastradas com antecedência ficavam visíveis no portal mesmo com "Publicar pauta?", "Sessão iniciada?" e "Sessão finalizada?" em Não: nenhum desses campos filtrava queryset público. Só publicar_pauta filtrava, e apenas nos dois pontos de entrada da pauta. Passa a esconder do usuário anônimo as sessões marcadas explicitamente como não iniciadas e sem pauta publicada. Usuário autenticado continua vendo todas. A regra fica centralizada em SessaoPlenaria.objects.visiveis_para(user) e é aplicada na pesquisa de sessões, no detalhe, no resumo e no resumo/ata. iniciada nulo conta como visível: o campo nasceu sem default e o default=True só chegou na migração 0027, que não preencheu as linhas existentes, então bases anteriores a out/2018 têm sessões realizadas com o campo em NULL. Também restringe PautaSessaoDetailView, que não filtrava publicar_pauta e expunha por URL a pauta de sessões não publicadas. * Troca custom manager por função utilitária de visibilidade Atende à revisão do PR: substitui a subclasse SessaoPlenariaQuerySet e o método .visiveis_para() por uma função restringe_sessoes_visiveis(qs, user), chamada como wrapper nos pontos de acesso público. Mantém a mesma regra de filtragem e o comportamento de fundir as cadeias de filter em um único SQL. --------- Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- sapl/sessao/models.py | 20 ++++++ sapl/sessao/tests/test_sessao_view.py | 92 ++++++++++++++++++++++++++- sapl/sessao/views.py | 21 +++++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py index 613068afe..ad71be072 100644 --- a/sapl/sessao/models.py +++ b/sapl/sessao/models.py @@ -153,6 +153,26 @@ def anexo_upload_path(instance, filename): # return get_sessao_media_path(instance, 'anexo', filename) +def restringe_sessoes_visiveis(qs, user): + """Restringe o queryset às sessões visíveis na consulta pública. + + Usuário autenticado enxerga todas. Para o público, ficam ocultas as + sessões cadastradas com antecedência, ou seja, as marcadas como não + iniciadas e sem pauta publicada. + + `iniciada` nulo conta como visível: o default do campo só surgiu na + migração 0027, que não preencheu as linhas já existentes, então bases + antigas têm sessões realizadas com o campo em NULL. + """ + if user.is_authenticated: + return qs + + return qs.filter( + Q(publicar_pauta=True) | + Q(iniciada=True) | + Q(iniciada__isnull=True)) + + class SessaoPlenaria(models.Model): # TODO trash??? Seems to have been a FK in the past. Would be: # andamento_sessao = models.ForeignKey( diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py index 00284465b..70bb920a9 100644 --- a/sapl/sessao/tests/test_sessao_view.py +++ b/sapl/sessao/tests/test_sessao_view.py @@ -1,4 +1,7 @@ import pytest +from datetime import date +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from model_bakery import baker @@ -8,7 +11,8 @@ from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria, IntegranteMesa, SessaoPlenariaPresenca, JustificativaAusencia, ExpedienteSessao, TipoExpediente, ExpedienteMateria, - Orador, OcorrenciaSessao) + Orador, OcorrenciaSessao, + restringe_sessoes_visiveis) from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao @@ -146,4 +150,88 @@ class TestResumoView(): ocorrencia = baker.make(OcorrenciaSessao, sessao_plenaria=self.sessao_plenaria) resultado_get_ocorrencia = get_ocorrencias_da_sessao(self.sessao_plenaria) - assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia \ No newline at end of file + assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia + + +@pytest.mark.django_db(transaction=False) +def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa(): + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True) + iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False) + # Sessões anteriores à migração 0027 ficaram com `iniciada` em NULL. + legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False) + + visiveis = restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), AnonymousUser()) + + assert previa not in visiveis + assert com_pauta in visiveis + assert iniciada in visiveis + assert legada in visiveis + + +@pytest.mark.django_db(transaction=False) +def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado(): + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + + operador = baker.make(get_user_model()) + + assert previa in restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), operador) + + +@pytest.mark.django_db(transaction=False) +def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client): + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=date(2025, 11, 5)) + iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False, + data_inicio=date(2025, 11, 5)) + + response = client.get(reverse('sapl.sessao:pesquisar_sessao'), + {'data_inicio__year': '2025'}) + + assert response.status_code == 200 + assert previa not in response.context['object_list'] + assert iniciada in response.context['object_list'] + + +@pytest.mark.django_db(transaction=False) +def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client): + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=date(2025, 11, 5)) + + response = admin_client.get(reverse('sapl.sessao:pesquisar_sessao'), + {'data_inicio__year': '2025'}) + + assert response.status_code == 200 + assert previa in response.context['object_list'] + + +@pytest.mark.django_db(transaction=False) +def test_detail_sessao_previa_indisponivel_para_anonimo(client): + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + + response = client.get(reverse('sapl.sessao:sessaoplenaria_detail', + kwargs={'pk': previa.pk})) + + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_resumo_de_sessao_previa_indisponivel_para_anonimo(client): + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + + response = client.get(reverse('sapl.sessao:resumo', + kwargs={'pk': previa.pk})) + + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_pauta_nao_publicada_indisponivel_para_anonimo(client): + sem_pauta = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False) + + response = client.get(reverse('sapl.sessao:pauta_sessao_detail', + kwargs={'pk': sem_pauta.pk})) + + assert response.status_code == 404 diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index ab30c2c6a..64f7ce7c1 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -64,7 +64,7 @@ from .models import (Bancada, CargoBancada, CargoMesa, SessaoPlenaria, SessaoPlenariaPresenca, TipoExpediente, TipoResultadoVotacao, TipoSessaoPlenaria, VotoParlamentar, TipoRetiradaPauta, RetiradaPauta, TipoJustificativa, JustificativaAusencia, OradorOrdemDia, - ORDENACAO_RESUMO, RegistroLeitura) + ORDENACAO_RESUMO, RegistroLeitura, restringe_sessoes_visiveis) TipoSessaoCrud = CrudAux.build(TipoSessaoPlenaria, 'tipo_sessao_plenaria') TipoJustificativaCrud = CrudAux.build(TipoJustificativa, 'tipo_justificativa') @@ -1348,6 +1348,13 @@ class SessaoCrud(Crud): class DetailView(Crud.DetailView): + def get(self, request, *args, **kwargs): + if not restringe_sessoes_visiveis( + SessaoPlenaria.objects.filter(pk=kwargs.get('pk')), + request.user).exists(): + raise Http404() + return super().get(request, *args, **kwargs) + @property def layout_key(self): sessao = self.object @@ -2327,6 +2334,10 @@ class ResumoView(DetailView): model = SessaoPlenaria logger = logging.getLogger(__name__) + def get_queryset(self): + return restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), self.request.user) + def get_context(self, *args, **kwargs): self.object = self.get_object() context = self.get_context_data(object=self.object) @@ -3833,6 +3844,12 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): ('situacao', 'Situação') ) + def get_queryset(self): + qs = SessaoPlenaria.objects.all() + if not self.request.user.is_authenticated: + qs = qs.filter(publicar_pauta=True) + return qs + def hook_autor(self, obj): return ','.join(obj['autor']) @@ -4028,6 +4045,8 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): qs = self.get_queryset().select_related( 'tipo', 'sessao_legislativa', 'legislatura') + qs = restringe_sessoes_visiveis(qs, self.request.user) + qs = qs.distinct().order_by( '-legislatura__numero', '-data_inicio', '-hora_inicio') From 4e58d8ae9719cea4f203bdac49afcf56a3989f82 Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:33:41 -0300 Subject: [PATCH 04/17] =?UTF-8?q?Remove=20limite=20de=20caracteres=20do=20?= =?UTF-8?q?campo=20Informa=C3=A7=C3=A3o=20Geral=20da=20Casa=20(#3848)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O campo `CasaLegislativa.informacao_geral` é editado por editor de texto rico: `CasaLegislativaForm` aplica `attrs={'id': 'texto-rico'}` no widget e `frontend/src/__global/main.js` instancia o TinyMCE sobre esse id. O valor submetido, portanto, é HTML, e não texto puro. Com `max_length=100` no model, o MaxLengthValidator derivado pelo Django contava os caracteres do HTML, tags inclusive. Um endereço de três linhas com 87 caracteres visíveis vira 106 caracteres depois dos `

`, e o salvamento era recusado com "no máximo 100 caracteres (ele possui 106)" sem que o usuário tivesse digitado 100 caracteres. Quanto mais formatação o texto tem, mais cedo o erro aparece — e não há como o operador descobrir sozinho que o limite está sendo consumido por tags que ele não vê. O limite nunca teve efeito no banco: `TextField` gera coluna `text` no PostgreSQL, sem restrição de tamanho. A migration é um no-op em SQL e serve apenas para manter o state do Django coerente com o model. Adiciona teste que trava o comportamento e comentário no model registrando por que o campo não deve receber `max_length` de volta. OSTicket #824090 --- .../0061_casalegislativa_informacao_geral.py | 19 +++++++++++++ sapl/base/models.py | 4 ++- sapl/base/tests/test_form.py | 27 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 sapl/base/migrations/0061_casalegislativa_informacao_geral.py diff --git a/sapl/base/migrations/0061_casalegislativa_informacao_geral.py b/sapl/base/migrations/0061_casalegislativa_informacao_geral.py new file mode 100644 index 000000000..5fec68acd --- /dev/null +++ b/sapl/base/migrations/0061_casalegislativa_informacao_geral.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.28 on 2026-08-05 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0060_auto_20240812_1628'), + ] + + operations = [ + migrations.AlterField( + model_name='casalegislativa', + name='informacao_geral', + field=models.TextField(blank=True, + verbose_name='Informação Geral'), + ), + ] diff --git a/sapl/base/models.py b/sapl/base/models.py index 692c55138..b31c532ab 100644 --- a/sapl/base/models.py +++ b/sapl/base/models.py @@ -73,8 +73,10 @@ class CasaLegislativa(models.Model): max_length=100, blank=True, verbose_name=_('HomePage')) email = models.EmailField( max_length=100, blank=True, verbose_name=_('E-mail')) + # Campo editado via editor de texto rico (TinyMCE), portanto o valor + # armazenado é HTML. Não deve ter max_length: as tags consomem o limite + # e impedem o salvamento de textos curtos. informacao_geral = models.TextField( - max_length=100, blank=True, verbose_name=_('Informação Geral')) diff --git a/sapl/base/tests/test_form.py b/sapl/base/tests/test_form.py index 35f1c82bf..9464fe39e 100644 --- a/sapl/base/tests/test_form.py +++ b/sapl/base/tests/test_form.py @@ -39,3 +39,30 @@ def test_casa_legislativa_form_invalido(): }) assert not form.is_valid() + + +@pytest.mark.django_db(transaction=False) +def test_casa_legislativa_form_informacao_geral_sem_limite(): + # O campo é editado por editor de texto rico, então o valor submetido é + # HTML: as tags não podem consumir um limite de caracteres. + html = ''.join('

%s

' % linha for linha in + ['Câmara Municipal', 'Rua Osvaldo Cruz, 555 - Centro', + 'Expediente das 8h às 12h e das 13h30 às 17h30']) + assert len(html) > 100 + + form = CasaLegislativaForm(data={'codigo': 'codigo', + 'nome': 'nome', + 'sigla': 'sg', + 'endereco': 'endereco', + 'cep': '70000-000', + 'municipio': 'municipio', + 'uf': 'DF', + 'telefone': '33333333', + 'fax': '33333333', + 'endereco_web': '', + 'email': '', + 'informacao_geral': html + }) + + assert form.is_valid(), form.errors + assert form.cleaned_data['informacao_geral'] == html From c82d626540e58b3613e8a06c9a08867a0290af48 Mon Sep 17 00:00:00 2001 From: Edward <9326037+edwardoliveira@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:34:56 -0300 Subject: [PATCH 05/17] Corrige SQL injection em DispositivoSearchFragmentFormView (#3856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os parâmetros texto, rotulo, num_ta e ano_ta da busca de dispositivos eram interpolados diretamente na SQL bruta executada via Dispositivo.objects.raw(), sem nenhum escape (ano_ta nem sequer era citado entre aspas). Troca as interpolações por placeholders %s com params, deixando o driver do banco responsável pela escapagem. Além disso: - Renomeia a lista de palavras pesquisadas para texto_valores e monta os fragmentos '%s' repetidos via ['d.texto ~* %s'] * len(texto_valores), em vez de reatribuir a mesma variável texto ou usar uma comprehension com variável de loop não utilizada. - Adiciona sapl.compilacao.utils.valid_int e aplica a tipo_model, tipo_ta e ano_ta (colunas/PKs inteiras), rejeitando valores não numéricos com um erro claro em vez de deixar o filtro inválido seguir adiante. num_ta não é validado dessa forma porque ta.numero é CharField pesquisado via regex (~*), então aceitar valores não numéricos ali é esperado. Claude-Session: https://claude.ai/code/session_01RZAufy4JQKWAaDgfcQByyt Co-authored-by: Claude Sonnet 5 --- sapl/compilacao/utils.py | 13 ++++++++++++ sapl/compilacao/views.py | 44 ++++++++++++++++++++++++---------------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/sapl/compilacao/utils.py b/sapl/compilacao/utils.py index aa1793484..c56f618b8 100644 --- a/sapl/compilacao/utils.py +++ b/sapl/compilacao/utils.py @@ -1,5 +1,7 @@ import sys +from django.utils.translation import ugettext_lazy as _ + DISPOSITIVO_SELECT_RELATED = ( 'tipo_dispositivo', 'ta_publicado', @@ -66,3 +68,14 @@ def get_integrations_view_names(): if 'IntegracaoTaView' in str(base): result.append(v) return result + + +def valid_int(value): + if value: + try: + int(value) + except (TypeError, ValueError): + raise ValueError( + _('Valor "%(value)s" não é um número inteiro válido.') % { + 'value': value}) + return value diff --git a/sapl/compilacao/views.py b/sapl/compilacao/views.py index 9afbe3201..93a066b3f 100644 --- a/sapl/compilacao/views.py +++ b/sapl/compilacao/views.py @@ -49,7 +49,7 @@ from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE, VeiculoPublicacao, Vide) from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED, DISPOSITIVO_SELECT_RELATED_EDIT, - get_integrations_view_names) + get_integrations_view_names, valid_int) from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, CrudAux, CrudListView,\ make_pagination from sapl.settings import BASE_DIR @@ -3177,14 +3177,14 @@ class DispositivoSearchFragmentFormView(ListView): result = [] try: - tipo_model = self.request.GET.get('tipo_model', '') + tipo_model = valid_int(self.request.GET.get('tipo_model', '')) limit = int(self.request.GET.get('max_results', 100)) - tipo_ta = self.request.GET.get('tipo_ta', '') + tipo_ta = valid_int(self.request.GET.get('tipo_ta', '')) num_ta = self.request.GET.get('num_ta', '') - ano_ta = self.request.GET.get('ano_ta', '') + ano_ta = valid_int(self.request.GET.get('ano_ta', '')) rotulo = self.request.GET.get('rotulo', '') str_texto = self.request.GET.get('texto', '') - texto = str_texto.split(' ') + texto_valores = str_texto.split(' ') tipo_resultado = self.request.GET.get('tipo_resultado', '') tipo_resultado = '' if tipo_resultado == 'False' else tipo_resultado @@ -3228,20 +3228,24 @@ class DispositivoSearchFragmentFormView(ListView): AND_CONTROLS = '''AND td.dispositivo_de_alteracao = true AND td.dispositivo_de_articulacao = true''' - texto = list(map("d.texto ~* '{}'".format, texto)) + texto = ['d.texto ~* %s'] * len(texto_valores) AND_TEXTO_ROTULO = '' + texto_rotulo_params = [] if str_texto and rotulo: - AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* '{BUSCA_ROTULO}') OR - ({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* '{BUSCA_ROTULO}') + busca_texto = ' AND '.join(texto) + AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* %s) OR + ({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* %s) )'''.format( - BUSCA_TEXTO=' AND '.join(texto), - BUSCA_ROTULO=rotulo + BUSCA_TEXTO=busca_texto, ) + texto_rotulo_params = ( + texto_valores + [rotulo] + texto_valores + [rotulo]) elif str_texto: - AND_TEXTO_ROTULO = ' AND %s' % ' AND '.join(texto) + AND_TEXTO_ROTULO = ' AND ' + ' AND '.join(texto) + texto_rotulo_params = texto_valores elif rotulo: - AND_TEXTO_ROTULO = "AND d.rotulo ~* '{BUSCA_ROTULO}'".format( - BUSCA_ROTULO=rotulo) + AND_TEXTO_ROTULO = "AND d.rotulo ~* %s" + texto_rotulo_params = [rotulo] else: AND_TEXTO_ROTULO = '' @@ -3288,17 +3292,21 @@ class DispositivoSearchFragmentFormView(ListView): AND3_TIPO_TA="AND ta.tipo_ta_id = {}".format( tipo_ta.id) if tipo_ta else '', - AND2_ANO="AND ta.ano = {}".format( - ano_ta) if ano_ta else '', + AND2_ANO="AND ta.ano = %s" if ano_ta else '', - AND1_NUMERO="AND ta.numero ~* '{}'".format( - num_ta) if num_ta else '', + AND1_NUMERO="AND ta.numero ~* %s" if num_ta else '', AND_TEXTO_ROTULO=AND_TEXTO_ROTULO if AND_TEXTO_ROTULO else '', AND_CONTROLS=AND_CONTROLS if AND_CONTROLS else '' ) - result = Dispositivo.objects.raw(sql) + params = texto_rotulo_params.copy() + if num_ta: + params.append(num_ta) + if ano_ta: + params.append(ano_ta) + + result = Dispositivo.objects.raw(sql, params) r = [] ids = set() From 55f02aa6397bc613ad5b471b799519e02e53dde5 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Tue, 1 Sep 2026 18:43:34 -0300 Subject: [PATCH 06/17] Release: 3.1.165-RC3 --- CHANGES.md | 24 ++++++++++++++++++++++++ docker/docker-compose.yaml | 2 +- sapl/settings.py | 2 +- sapl/templates/base.html | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e7b78bbd7..efac508cd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,28 @@ +3.1.165-RC3 / 2026-09-01 +======================== + + * Corrige SQL injection em DispositivoSearchFragmentFormView (#3856) + * Remove limite de caracteres do campo Informação Geral da Casa (#3848) + * Oculta sessões plenárias não iniciadas da consulta pública (#3845) + * hot-fix: Adiciona MIME Media Types opcionais para CSV. + * hot-fix: Remove alerta errado + * Corrige ordem dos expedientes no PDF do Resumo da Sessão (#3844) + * hot-fix: imprime somente contéudo da ocorrência + * Fix duplicate normas/matérias in author reports and prevent unfiltered queries + * Fix ValueError in audiencia form and AttributeError on HEAD requests + * Fix panel clock source and prevent overlapping polling + * hot-fix: recover epoch from server always + * Corrige polling excessivo e bloqueia não-votantes em /voto-individual/ (#3831) + * Hot-fix: corrige cópia de request.data + * Release: 3.1.165-RC2 + * #3821 centralizar geração próximo número para create de matérias legislativas (#3822) + +3.1.166-RC0 / 2026-09-01 +======================== + + + 3.1.165-RC2 / 2026-03-26 ======================== diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index dc8559812..46b5bd627 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -33,7 +33,7 @@ services: networks: - sapl-net sapl: - image: interlegis/sapl:3.1.165-RC2 + image: interlegis/sapl:3.1.165-RC3 # build: # context: ../ # dockerfile: ./docker/Dockerfile diff --git a/sapl/settings.py b/sapl/settings.py index 4be58ab0c..9c9aa99c7 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -43,7 +43,7 @@ ALLOWED_HOSTS = ['*'] LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/login/?next=' -SAPL_VERSION = '3.1.165-RC2' +SAPL_VERSION = '3.1.165-RC3' if DEBUG: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' diff --git a/sapl/templates/base.html b/sapl/templates/base.html index 79b285a3a..a11841704 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -200,7 +200,7 @@ Desenvolvido pelo Interlegis em software livre e aberto. - Release: 3.1.165-RC2 + Release: 3.1.165-RC3

From 2e6acfa24d11c6346daa172041fe4f38c5cf3156 Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:14:58 -0300 Subject: [PATCH 07/17] =?UTF-8?q?Impede=20presen=C3=A7as=20duplicadas=20de?= =?UTF-8?q?=20parlamentar=20na=20mesma=20sess=C3=A3o=20(#3850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Impede presenças duplicadas de parlamentar na mesma sessão O relatório de presença exibia percentuais acima de 100% — em Capanema/PR, um vereador aparecia com 46 sessões em um período de 45. A contagem não estava errada: existiam mesmo duas linhas de presença dele na sessão nº 22 de 30/06/2025, e o relatório contava linhas com Count('id'). A origem é PresencaView/PresencaOrdemDiaView. `presentes_banco` vem do banco como inteiros e `marcados` vem do POST como strings, então `set(presentes_banco) - set(marcados)` nunca casava e resultava em apagar todas as presenças da sessão a cada salvamento, recriando-as em seguida. Sequencialmente o resultado é correto, mas duas submissões concorrentes do formulário — um duplo clique em Salvar — passam ambas pela janela entre o apagar e o recriar e gravam duas linhas por parlamentar. Os ids gravados em Capanema confirmam: pares adjacentes intercalados na ordem alfabética da tela, assinatura de dois laços concorrentes. Nada no banco impedia isso. Não é um caso isolado: Capanema tinha 625 linhas excedentes em 313 sessões, e a base de Agudo/RS, 9 linhas em 1 sessão. Corrige em quatro frentes: - compara os ids com o mesmo tipo e passa a criar apenas quem ainda não tem presença, sem apagar e recriar quem permanece marcado; - usa bulk_create(ignore_conflicts=True), para que a submissão concorrente descarte a inserção repetida em vez de estourar IntegrityError; - adiciona unique_together (sessao_plenaria, parlamentar) nos dois modelos, que é a proteção efetiva contra concorrência, com migração que remove as duplicatas existentes antes de criar a restrição; - conta sessões distintas no relatório, em vez de linhas, para que bases ainda não migradas não exibam percentuais impossíveis. OSTicket #212222 * Remove presenças duplicadas com RunSQL na migração 0070 Substitui o RunPython por um DELETE ... USING por tabela, conforme sugestão da revisão: mantém a presença de menor id de cada par (sessão, parlamentar), agora também em sessao_presencaordemdia. A preferência por data_sessao preenchida foi abandonada, já que o campo não é lido nem escrito em nenhum ponto do código. --- sapl/relatorios/views.py | 11 +- .../0070_presenca_unica_por_sessao.py | 40 +++++++ sapl/sessao/models.py | 6 ++ sapl/sessao/tests/test_sessao_view.py | 102 ++++++++++++++++++ sapl/sessao/views.py | 56 +++++----- 5 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 sapl/sessao/migrations/0070_presenca_unica_por_sessao.py diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 8b89342fd..073fa8ba6 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -2080,17 +2080,22 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): parlamentares_id = parlamentares_qs.values_list('id', flat=True) # Presenças de cada Parlamentar em Sessões + # Conta sessões distintas, e não linhas de presença: bases com + # presenças repetidas para o mesmo parlamentar na mesma sessão + # produziam percentuais acima de 100%. presenca_sessao = SessaoPlenariaPresenca.objects.filter( - **param0).values_list('parlamentar_id').annotate(sessao_count=Count('id')) + **param0).values_list('parlamentar_id').annotate( + sessao_count=Count('sessao_plenaria_id', distinct=True)) # Presenças de cada Ordem do Dia presenca_ordem = PresencaOrdemDia.objects.filter( - **param0).values_list('parlamentar_id').annotate(sessao_count=Count('id')) + **param0).values_list('parlamentar_id').annotate( + sessao_count=Count('sessao_plenaria_id', distinct=True)) # Ausencias justificadas ausencia_justificadas = JustificativaAusencia.objects.filter( **param0, ausencia=2).values_list('parlamentar_id')\ - .annotate(sessao_count=Count('id')) + .annotate(sessao_count=Count('sessao_plenaria_id', distinct=True)) total_ordemdia = PresencaOrdemDia.objects.filter( **param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count() diff --git a/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py b/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py new file mode 100644 index 000000000..ff71601b1 --- /dev/null +++ b/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.28 on 2026-08-05 16:00 + +from django.db import migrations + + +# Mantém uma única presença por (sessão, parlamentar). Presença é um sim/não, +# então as linhas repetidas não carregam informação adicional: preserva-se a +# de menor id e descartam-se as demais. +REMOVE_DUPLICADAS_SQL = """ + DELETE FROM {tabela} a + USING {tabela} b + WHERE a.sessao_plenaria_id = b.sessao_plenaria_id + AND a.parlamentar_id = b.parlamentar_id + AND a.id > b.id; +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('sessao', '0069_auto_20220919_1705'), + ] + + operations = [ + migrations.RunSQL( + REMOVE_DUPLICADAS_SQL.format( + tabela='sessao_sessaoplenariapresenca'), + migrations.RunSQL.noop), + migrations.RunSQL( + REMOVE_DUPLICADAS_SQL.format(tabela='sessao_presencaordemdia'), + migrations.RunSQL.noop), + migrations.AlterUniqueTogether( + name='sessaoplenariapresenca', + unique_together={('sessao_plenaria', 'parlamentar')}, + ), + migrations.AlterUniqueTogether( + name='presencaordemdia', + unique_together={('sessao_plenaria', 'parlamentar')}, + ), + ] diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py index ad71be072..fc418b08f 100644 --- a/sapl/sessao/models.py +++ b/sapl/sessao/models.py @@ -594,6 +594,9 @@ class PresencaOrdemDia(models.Model): # OrdemDiaPresenca verbose_name = _('Presença da Ordem do Dia') verbose_name_plural = _('Presenças da Ordem do Dia') ordering = ['parlamentar__nome_parlamentar'] + # Presença é um sim/não: mais de uma linha para o mesmo parlamentar + # na mesma sessão infla a contagem dos relatórios. + unique_together = ('sessao_plenaria', 'parlamentar') def __str__(self): # FIXME ambigous @@ -736,6 +739,9 @@ class SessaoPlenariaPresenca(models.Model): verbose_name = _('Presença em Sessão Plenária') verbose_name_plural = _('Presenças em Sessões Plenárias') ordering = ['parlamentar__nome_parlamentar'] + # Presença é um sim/não: mais de uma linha para o mesmo parlamentar + # na mesma sessão infla a contagem dos relatórios. + unique_together = ('sessao_plenaria', 'parlamentar') ORDENACAO_RESUMO = [ diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py index 70bb920a9..aef74c2ae 100644 --- a/sapl/sessao/tests/test_sessao_view.py +++ b/sapl/sessao/tests/test_sessao_view.py @@ -2,6 +2,7 @@ import pytest from datetime import date from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser +from django.db import IntegrityError, transaction from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from model_bakery import baker @@ -9,6 +10,7 @@ from model_bakery import baker from sapl.parlamentares.models import Legislatura, SessaoLegislativa from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria, IntegranteMesa, SessaoPlenariaPresenca, + PresencaOrdemDia, JustificativaAusencia, ExpedienteSessao, TipoExpediente, ExpedienteMateria, Orador, OcorrenciaSessao, @@ -235,3 +237,103 @@ def test_pauta_nao_publicada_indisponivel_para_anonimo(client): kwargs={'pk': sem_pauta.pk})) assert response.status_code == 404 + + +def cria_sessao_com_parlamentares(): + legislatura = baker.make(Legislatura) + sessao_legislativa = baker.make(SessaoLegislativa) + tipo = baker.make(TipoSessaoPlenaria) + sessao = baker.make(SessaoPlenaria, + legislatura=legislatura, + sessao_legislativa=sessao_legislativa, + tipo=tipo) + parlamentares = [baker.make(Parlamentar, ativo=True) for _i in range(3)] + return sessao, parlamentares + + +@pytest.mark.django_db(transaction=False) +def test_presenca_sessao_preserva_registros_ao_salvar_novamente(admin_client): + # Cada salvamento apagava todas as presenças da sessão e as recriava, + # trocando os ids. Além do desperdício, é essa janela entre apagar e + # recriar que permite a duplicação quando o formulário é submetido duas + # vezes em paralelo. Salvar de novo deve ser inócuo. + sessao, parlamentares = cria_sessao_com_parlamentares() + url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk}) + dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]} + + admin_client.post(url, dados, follow=True) + presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao) + ids_iniciais = sorted(presencas.values_list('id', flat=True)) + + admin_client.post(url, dados, follow=True) + admin_client.post(url, dados, follow=True) + + assert presencas.count() == len(parlamentares) + assert sorted(presencas.values_list('parlamentar_id', flat=True)) == \ + sorted(p.pk for p in parlamentares) + assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais + + +@pytest.mark.django_db(transaction=False) +def test_presenca_sessao_ignora_registro_ja_existente(admin_client): + # Reproduz o efeito da submissão concorrente: a linha já existe quando a + # view vai gravar. Deve terminar com uma única presença, sem erro. + sessao, parlamentares = cria_sessao_com_parlamentares() + parlamentar = parlamentares[0] + SessaoPlenariaPresenca.objects.create(sessao_plenaria=sessao, + parlamentar=parlamentar) + + url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk}) + response = admin_client.post( + url, {'presenca_ativos': [str(parlamentar.pk)]}, follow=True) + + assert response.status_code == 200 + assert SessaoPlenariaPresenca.objects.filter( + sessao_plenaria=sessao, parlamentar=parlamentar).count() == 1 + + +@pytest.mark.django_db(transaction=False) +def test_presenca_sessao_remove_desmarcados(admin_client): + sessao, parlamentares = cria_sessao_com_parlamentares() + url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk}) + + admin_client.post(url, {'presenca_ativos': [str(p.pk) + for p in parlamentares]}, + follow=True) + admin_client.post(url, {'presenca_ativos': [str(parlamentares[0].pk)]}, + follow=True) + + presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao) + assert list(presencas.values_list('parlamentar_id', flat=True)) == \ + [parlamentares[0].pk] + + +@pytest.mark.django_db(transaction=False) +def test_presenca_ordem_dia_preserva_registros_ao_resalvar(admin_client): + sessao, parlamentares = cria_sessao_com_parlamentares() + url = reverse('sapl.sessao:presencaordemdia', kwargs={'pk': sessao.pk}) + dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]} + + admin_client.post(url, dados, follow=True) + presencas = PresencaOrdemDia.objects.filter(sessao_plenaria=sessao) + ids_iniciais = sorted(presencas.values_list('id', flat=True)) + + admin_client.post(url, dados, follow=True) + + assert presencas.count() == len(parlamentares) + assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais + + +@pytest.mark.django_db(transaction=False) +def test_presenca_unica_por_sessao_e_parlamentar(): + # A restrição de unicidade é a proteção contra submissões concorrentes, + # que a lógica da view sozinha não cobre. + sessao, parlamentares = cria_sessao_com_parlamentares() + parlamentar = parlamentares[0] + + for model in (SessaoPlenariaPresenca, PresencaOrdemDia): + model.objects.create(sessao_plenaria=sessao, parlamentar=parlamentar) + with pytest.raises(IntegrityError): + with transaction.atomic(): + model.objects.create(sessao_plenaria=sessao, + parlamentar=parlamentar) diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 64f7ce7c1..4298f8f44 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -1420,26 +1420,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView): if form.is_valid(): # Pegar os presentes salvos no banco - presentes_banco = SessaoPlenariaPresenca.objects.filter( + presentes_banco = set(SessaoPlenariaPresenca.objects.filter( sessao_plenaria_id=self.object.id).values_list( - 'parlamentar_id', flat=True).distinct() + 'parlamentar_id', flat=True)) # Id dos parlamentares presentes - marcados = request.POST.getlist('presenca_ativos') \ - + request.POST.getlist('presenca_inativos') + marcados = set(int(p) for p in + request.POST.getlist('presenca_ativos') + + request.POST.getlist('presenca_inativos')) # Deletar os que foram desmarcados - deletar = set(presentes_banco) - set(marcados) SessaoPlenariaPresenca.objects.filter( - parlamentar_id__in=deletar, + parlamentar_id__in=presentes_banco - marcados, sessao_plenaria_id=self.object.id).delete() - for p in marcados: - sessao = SessaoPlenariaPresenca() - sessao.sessao_plenaria = self.object - sessao.parlamentar = Parlamentar.objects.get(id=p) - sessao.save() - username = request.user.username + # Criar apenas quem ainda não tem presença registrada. O + # ignore_conflicts descarta a inserção duplicada quando o + # formulário é submetido duas vezes em paralelo, em vez de + # gravar uma segunda linha para o mesmo parlamentar. + username = request.user.username + novos = marcados - presentes_banco + SessaoPlenariaPresenca.objects.bulk_create( + [SessaoPlenariaPresenca(sessao_plenaria=self.object, + parlamentar_id=p) for p in novos], + ignore_conflicts=True) + for p in novos: self.logger.info( "user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p)) msg = _('Presença em Sessão salva com sucesso!') @@ -1535,26 +1540,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView): if form.is_valid(): # Pegar os presentes salvos no banco - presentes_banco = PresencaOrdemDia.objects.filter( + presentes_banco = set(PresencaOrdemDia.objects.filter( sessao_plenaria_id=self.object.id).values_list( - 'parlamentar_id', flat=True).distinct() + 'parlamentar_id', flat=True)) # Id dos parlamentares presentes - marcados = request.POST.getlist('presenca_ativos') \ - + request.POST.getlist('presenca_inativos') + marcados = set(int(p) for p in + request.POST.getlist('presenca_ativos') + + request.POST.getlist('presenca_inativos')) # Deletar os que foram desmarcados - deletar = set(presentes_banco) - set(marcados) PresencaOrdemDia.objects.filter( - parlamentar_id__in=deletar, + parlamentar_id__in=presentes_banco - marcados, sessao_plenaria_id=self.object.id).delete() - for p in marcados: - ordem = PresencaOrdemDia() - ordem.sessao_plenaria = self.object - ordem.parlamentar = Parlamentar.objects.get(id=p) - ordem.save() - username = request.user.username + # Criar apenas quem ainda não tem presença registrada. Ver + # comentário equivalente em PresencaView.post. + username = request.user.username + novos = marcados - presentes_banco + PresencaOrdemDia.objects.bulk_create( + [PresencaOrdemDia(sessao_plenaria=self.object, + parlamentar_id=p) for p in novos], + ignore_conflicts=True) + for p in novos: self.logger.info( 'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p)) From 2e7c3aa56d23cc615835d704cc0f15ffb6ae6250 Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:28:30 -0300 Subject: [PATCH 08/17] =?UTF-8?q?Melhorias=20de=20transpar=C3=AAncia=20(PN?= =?UTF-8?q?TP):=20corre=C3=A7=C3=B5es=20de=20relat=C3=B3rios=20e=20novas?= =?UTF-8?q?=20funcionalidades=20(#3842)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sessao): ordena votações nominais do resumo por numero_ordem O resumo da Sessão Plenária (HTML e PDF) ordenava as votações nominais por order_by('-materia'), divergindo da ordem das Matérias da Ordem do Dia, que seguem numero_ordem. Passa a ordenar por numero_ordem nas listas de Expediente e de Ordem do Dia, tanto no resumo HTML quanto no gerador de PDF. * fix(relatorios): lista todos os autores no relatório de tramitação O relatório "Imprimir Tramitação" exibia apenas o primeiro autor (autores.first()). Passa a exibir todos os autores/co-autores da matéria, ajustando a view (autores.all()) e o template (rótulo Autor/Autores e junção dos nomes). * feat(uploads): aceita planilhas e CSV em documentos de texto Amplia TIPOS_TEXTO_PERMITIDOS (validador restringe_tipos_de_arquivo_txt), usado nos campos de Pauta/Ata/Anexo, para aceitar formatos editáveis de planilha e CSV: .csv, .xls, .xlsx e .ods (e variantes de MIME). Não requer migração, pois o validador é referenciado nas migrations apenas pelo nome. * feat(comissoes): filtro de pesquisa e coluna de pauta na lista de reuniões Adiciona ReuniaoFilterSet (número, nome, tema e período de data) à listagem de Reuniões de Comissão, permitindo localizar reuniões sem navegar página a página. Inclui a coluna 'Pauta da Reunião' ao lado de 'Ata da Reunião'. * feat(sessao): renomeia anexo para 'Votações Nominais' e exibe anexos na busca Renomeia o verbose_name do campo upload_anexo de SessaoPlenaria de 'Anexo da Sessão' para 'Votações Nominais' (com migração) e passa a exibir os links de Pauta da Sessão e Votações Nominais ao lado da Ata na pesquisa pública de sessões plenárias. * feat(base): exibe data de atualização no topo das páginas Adiciona uma faixa no topo do layout com 'Informações atualizadas em DD/MM/AAAA' (data corrente, sem horário), oculta na impressão e em iframes. * feat(audiencia): permite autor de qualquer tipo na audiência pública Substitui o campo parlamentar_autor (FK direta a Parlamentar) pelo Autor polimórfico do SAPL em AudienciaPublica, permitindo Parlamentar, Comissão, Bancada e demais tipos. Atualiza o formulário, o layout e a validação de autoria do requerimento. A migração preserva os autores já cadastrados. * feat(base): faixa de atualidade usa a data de última atualização da entidade Em telas de detalhe (CrudDetailView), a faixa do topo passa a exibir a data de última atualização do registro (campo data_ultima_atualizacao); nas demais telas (pesquisa/listagem) exibe a data atual. Adiciona data_ultima_atualizacao (auto_now) a Parlamentar, Comissao, DocumentoAdministrativo e AudienciaPublica. * fix(sessao): reverte rótulo do anexo e adiciona link de Votações Nominais Desfaz o rename de upload_anexo (volta a 'Anexo da Sessão' e remove a migração de verbose_name) e adiciona, na pesquisa pública de sessões, um link 'Votações Nominais' que aponta para o resumo da sessão. --------- Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- sapl/audiencia/forms.py | 20 +++---- .../migrations/0021_auto_20260623_1938.py | 59 +++++++++++++++++++ ...udienciapublica_data_ultima_atualizacao.py | 18 ++++++ sapl/audiencia/models.py | 11 ++-- sapl/comissoes/forms.py | 25 ++++++++ .../0031_comissao_data_ultima_atualizacao.py | 18 ++++++ sapl/comissoes/models.py | 3 + sapl/comissoes/views.py | 11 +++- sapl/crud/base.py | 5 ++ ...046_parlamentar_data_ultima_atualizacao.py | 18 ++++++ sapl/parlamentares/models.py | 4 ++ .../migrations/0046_auto_20260626_1220.py | 28 +++++++++ sapl/protocoloadm/models.py | 3 + sapl/relatorios/views.py | 6 +- sapl/sessao/views.py | 4 +- sapl/templates/audiencia/layouts.yaml | 4 +- sapl/templates/base.html | 7 +++ .../relatorio_materia_tramitacao.html | 2 +- .../sessao/sessaoplenaria_filter.html | 7 +++ sapl/utils.py | 9 +++ 20 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 sapl/audiencia/migrations/0021_auto_20260623_1938.py create mode 100644 sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py create mode 100644 sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py create mode 100644 sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py create mode 100644 sapl/protocoloadm/migrations/0046_auto_20260626_1220.py diff --git a/sapl/audiencia/forms.py b/sapl/audiencia/forms.py index f1978032f..d2c7dd45b 100755 --- a/sapl/audiencia/forms.py +++ b/sapl/audiencia/forms.py @@ -10,9 +10,9 @@ from django.utils.translation import ugettext_lazy as _ from crispy_forms.layout import Button, Column, Fieldset, HTML, Layout from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica +from sapl.base.models import Autor from sapl.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa -from sapl.parlamentares.models import Parlamentar from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo @@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): widget=forms.HiddenInput(), queryset=MateriaLegislativa.objects.all()) - parlamentar_autor = forms.ModelChoiceField( - label=_("Parlamentar Autor"), + autor = forms.ModelChoiceField( + label=_("Autor"), required=False, - queryset=Parlamentar.objects.all()) + queryset=Autor.objects.all().order_by('nome')) requerimento = forms.ModelChoiceField( label=_("Requerimento"), @@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): model = AudienciaPublica fields = ['tipo', 'numero', 'ano', 'nome', 'tema', 'data', 'hora_inicio', 'hora_fim', - 'observacao', 'audiencia_cancelada', 'parlamentar_autor', 'requerimento', 'url_audio', + 'observacao', 'audiencia_cancelada', 'autor', 'requerimento', 'url_audio', 'url_video', 'upload_pauta', 'upload_ata', 'upload_anexo', 'tipo_materia', 'numero_materia', 'ano_materia', 'materia'] @@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): materia = cleaned_data['numero_materia'] ano_materia = cleaned_data['ano_materia'] tipo_materia = cleaned_data['tipo_materia'] - parlamentar_autor = cleaned_data["parlamentar_autor"] + autor = cleaned_data["autor"] requerimento = cleaned_data["requerimento"] if cleaned_data["ano"] != cleaned_data["data"].year: @@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): raise ValidationError(msg) # requerimento é optativo - if parlamentar_autor and requerimento: - if parlamentar_autor.autor.first() not in requerimento.autores.all(): - raise ValidationError("Parlamentar Autor selecionado não faz" + if autor and requerimento: + if autor not in requerimento.autores.all(): + raise ValidationError("Autor selecionado não faz" " parte da autoria do Requerimento " "selecionado.") - elif parlamentar_autor: + elif autor: raise ValidationError("Para informar um autor deve-se informar um requerimento.") elif requerimento: raise ValidationError("Para informar um requerimento deve-se informar um autor.") diff --git a/sapl/audiencia/migrations/0021_auto_20260623_1938.py b/sapl/audiencia/migrations/0021_auto_20260623_1938.py new file mode 100644 index 000000000..a2e101c64 --- /dev/null +++ b/sapl/audiencia/migrations/0021_auto_20260623_1938.py @@ -0,0 +1,59 @@ +# Generated by Django 2.2.28 on 2026-06-23 22:38 + +from django.db import migrations, models +import django.db.models.deletion + + +def copia_parlamentar_para_autor(apps, schema_editor): + """Converte o antigo parlamentar_autor no Autor genérico correspondente.""" + AudienciaPublica = apps.get_model('audiencia', 'AudienciaPublica') + Autor = apps.get_model('base', 'Autor') + ContentType = apps.get_model('contenttypes', 'ContentType') + try: + ct = ContentType.objects.get( + app_label='parlamentares', model='parlamentar') + except ContentType.DoesNotExist: + return + for ap in AudienciaPublica.objects.filter(parlamentar_autor__isnull=False): + autor = Autor.objects.filter( + content_type=ct, object_id=ap.parlamentar_autor_id).first() + if autor: + ap.autor = autor + ap.save(update_fields=['autor']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0060_auto_20240812_1628'), + ('audiencia', '0020_auto_20251201_1450'), + ] + + operations = [ + migrations.AddField( + model_name='audienciapublica', + name='autor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Autor'), + ), + migrations.RunPython( + copia_parlamentar_para_autor, migrations.RunPython.noop), + # Mantém a coluna parlamentar_autor no banco (removida apenas do estado + # do Django). Assim uma casa pode dar rollback da imagem sem perder o + # vínculo, e o código novo simplesmente ignora a coluna. O DROP + # definitivo fica para uma migração posterior, quando toda a frota + # estiver estável nesta versão. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name='audienciapublica', + name='parlamentar_autor', + ), + ], + database_operations=[], + ), + migrations.AlterField( + model_name='audienciapublica', + name='ano', + field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), + ), + ] diff --git a/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py new file mode 100644 index 000000000..15f49d3fa --- /dev/null +++ b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2026-06-26 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audiencia', '0021_auto_20260623_1938'), + ] + + operations = [ + migrations.AddField( + model_name='audienciapublica', + name='data_ultima_atualizacao', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'), + ), + ] diff --git a/sapl/audiencia/models.py b/sapl/audiencia/models.py index 6b74cffd0..8ba14f29f 100755 --- a/sapl/audiencia/models.py +++ b/sapl/audiencia/models.py @@ -3,7 +3,7 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from model_utils import Choices from sapl.materia.models import MateriaLegislativa -from sapl.parlamentares.models import (CargoMesa, Parlamentar) +from sapl.parlamentares.models import CargoMesa from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation, restringe_tipos_de_arquivo_txt, texto_upload_path, @@ -79,12 +79,15 @@ class AudienciaPublica(models.Model): default=False, choices=YES_NO_CHOICES, verbose_name=_('Audiência Cancelada?')) - parlamentar_autor = models.ForeignKey( - Parlamentar, + autor = models.ForeignKey( + 'base.Autor', on_delete=models.PROTECT, null=True, blank=True, - verbose_name=_('Parlamentar Autor')) + verbose_name=_('Autor')) + data_ultima_atualizacao = models.DateTimeField( + blank=True, null=True, auto_now=True, + verbose_name=_('Data da Última Atualização')) requerimento = models.ForeignKey( MateriaLegislativa, null=True, diff --git a/sapl/comissoes/forms.py b/sapl/comissoes/forms.py index 76f511ed2..599b9a5b3 100644 --- a/sapl/comissoes/forms.py +++ b/sapl/comissoes/forms.py @@ -558,3 +558,28 @@ class DocumentoAcessorioEditForm(FileFieldCheckMixin, forms.ModelForm): # raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}') return self.cleaned_data + + +class ReuniaoFilterSet(django_filters.FilterSet): + + nome = django_filters.CharFilter( + lookup_expr='icontains', label=_('Nome da Reunião')) + tema = django_filters.CharFilter( + lookup_expr='icontains', label=_('Tema da Reunião')) + + class Meta(FilterOverridesMetaMixin): + model = Reuniao + fields = ['numero', 'nome', 'tema', 'data'] + + def __init__(self, *args, **kwargs): + super(ReuniaoFilterSet, self).__init__(*args, **kwargs) + + row1 = to_row([('numero', 3), ('nome', 5), ('tema', 4)]) + row2 = to_row([('data', 6)]) + + self.form.helper = SaplFormHelper() + self.form.helper.form_method = 'GET' + self.form.helper.layout = Layout( + Fieldset(_('Pesquisar Reunião'), + row1, row2, + form_actions(label=_('Pesquisar')))) diff --git a/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py new file mode 100644 index 000000000..e35f98e0a --- /dev/null +++ b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2026-06-26 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('comissoes', '0030_auto_20231007_2149'), + ] + + operations = [ + migrations.AddField( + model_name='comissao', + name='data_ultima_atualizacao', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'), + ), + ] diff --git a/sapl/comissoes/models.py b/sapl/comissoes/models.py index 2d9a323d3..694fdceaf 100644 --- a/sapl/comissoes/models.py +++ b/sapl/comissoes/models.py @@ -34,6 +34,9 @@ class Comissao(models.Model): tipo = models.ForeignKey(TipoComissao, on_delete=models.PROTECT, verbose_name=_('Tipo')) + data_ultima_atualizacao = models.DateTimeField( + blank=True, null=True, auto_now=True, + verbose_name=_('Data da Última Atualização')) nome = models.CharField(max_length=100, verbose_name=_('Nome')) sigla = models.CharField(max_length=10, verbose_name=_('Sigla')) data_criacao = models.DateField(verbose_name=_('Data de Criação')) diff --git a/sapl/comissoes/views.py b/sapl/comissoes/views.py index 6017ae2b3..92cde447f 100644 --- a/sapl/comissoes/views.py +++ b/sapl/comissoes/views.py @@ -22,7 +22,7 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm, ParticipacaoCreateForm, ParticipacaoEditForm, PautaReuniaoFilterSet, PautaReuniaoForm, - PeriodoForm, ReuniaoForm) + PeriodoForm, ReuniaoFilterSet, ReuniaoForm) from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, PermissionRequiredForAppCrudMixin, RP_DETAIL, RP_LIST) @@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud): public = [RP_LIST, RP_DETAIL, ] class BaseMixin(MasterDetailCrud.BaseMixin): - list_field_names = ['data', 'nome', 'tema', 'upload_ata'] + list_field_names = ['data', 'nome', 'tema', 'upload_pauta', 'upload_ata'] class DetailView(MasterDetailCrud.DetailView): template_name = "comissoes/reuniao_detail.html" @@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud): logger = logging.getLogger(__name__) paginate_by = 10 + def get_queryset(self): + qs = super().get_queryset() + self.filterset = ReuniaoFilterSet(self.request.GET, queryset=qs) + return self.filterset.qs + def take_reuniao_pk(self): username = self.request.user.username @@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud): context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter( reuniao__pk=context['reuniao_pk'] ).order_by('id') + + context['form'] = self.filterset.form return context class UpdateView(MasterDetailCrud.UpdateView): diff --git a/sapl/crud/base.py b/sapl/crud/base.py index fd091ec52..0966c4bc4 100644 --- a/sapl/crud/base.py +++ b/sapl/crud/base.py @@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin, context[context_object_name] = self.object context.update(kwargs) + # Mensagem de atualidade (PNTP): data de última atualização da entidade, + # quando o modelo possuir o campo. Consumido pelo banner em base.html. + context['data_atualizacao_conteudo'] = getattr( + self.object, 'data_ultima_atualizacao', None) + return context @property diff --git a/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py b/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py new file mode 100644 index 000000000..c4ae53dee --- /dev/null +++ b/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2026-06-26 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parlamentares', '0045_auto_20251201_1531'), + ] + + operations = [ + migrations.AddField( + model_name='parlamentar', + name='data_ultima_atualizacao', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'), + ), + ] diff --git a/sapl/parlamentares/models.py b/sapl/parlamentares/models.py index eecc2f314..6098835c4 100644 --- a/sapl/parlamentares/models.py +++ b/sapl/parlamentares/models.py @@ -193,6 +193,10 @@ class Parlamentar(models.Model): SEXO_CHOICE = ((FEMININO, _('Feminino')), (MASCULINO, _('Masculino'))) + data_ultima_atualizacao = models.DateTimeField( + blank=True, null=True, auto_now=True, + verbose_name=_('Data da Última Atualização')) + nivel_instrucao = models.ForeignKey( NivelInstrucao, blank=True, diff --git a/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py b/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py new file mode 100644 index 000000000..313bcd98a --- /dev/null +++ b/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.28 on 2026-06-26 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('protocoloadm', '0045_auto_20240711_1405'), + ] + + operations = [ + migrations.AddField( + model_name='documentoadministrativo', + name='data_ultima_atualizacao', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'), + ), + migrations.AlterField( + model_name='documentoadministrativo', + name='ano', + field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), + ), + migrations.AlterField( + model_name='protocolo', + name='ano', + field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano do Protocolo'), + ), + ] diff --git a/sapl/protocoloadm/models.py b/sapl/protocoloadm/models.py index 3cdf32735..1a169733a 100644 --- a/sapl/protocoloadm/models.py +++ b/sapl/protocoloadm/models.py @@ -158,6 +158,9 @@ class DocumentoAdministrativo(models.Model): tipo = models.ForeignKey( TipoDocumentoAdministrativo, on_delete=models.PROTECT, verbose_name=_('Tipo Documento')) + data_ultima_atualizacao = models.DateTimeField( + blank=True, null=True, auto_now=True, + verbose_name=_('Data da Última Atualização')) numero = models.PositiveIntegerField(verbose_name=_('Número')) complemento = models.CharField(max_length=10, blank=True, diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 073fa8ba6..1c1d2299f 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -711,7 +711,7 @@ def get_sessao_plenaria(sessao, casa, user): lst_expediente_materia_vot_nom = [] materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ - .order_by('-materia') + .order_by('numero_ordem') for mevn in materias_expediente_votacao_nominal: votos_materia = [] @@ -824,7 +824,7 @@ def get_sessao_plenaria(sessao, casa, user): lst_votacao_vot_nom = [] materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ - .order_by('-materia') + .order_by('numero_ordem') for modvn in materias_ordem_dia_votacao_nominal: votos_materia_od = [] @@ -1792,7 +1792,7 @@ def relatorio_materia_tramitacao(request, pk): 'materia': materia_legislativa, 'ano': materia_legislativa.ano, 'numero': materia_legislativa.numero, - 'autor': materia_legislativa.autores.first(), + 'autores': materia_legislativa.autores.all(), 'tipo': materia_legislativa.tipo.descricao, 'rodape': rodape, 'data': dt.today().strftime('%d/%m/%Y'), diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 4298f8f44..2c1df1442 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -2353,7 +2353,7 @@ class ResumoView(DetailView): # Votos de Votação Nominal de Matérias Expediente votacoes = [] for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \ - .order_by('-materia'): + .order_by('numero_ordem'): votos_materia = [] titulo_materia = mevn.materia registro = RegistroVotacao.objects.filter(expediente=mevn) @@ -2402,7 +2402,7 @@ class ResumoView(DetailView): # Matérias Ordem do Dia # Votos de Votação Nominal de Matérias Ordem do Dia votacoes_od = [] - for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('-materia'): + for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('numero_ordem'): votos_materia_od = [] t_materia = modvn.materia registro_od = RegistroVotacao.objects.filter(ordem=modvn) diff --git a/sapl/templates/audiencia/layouts.yaml b/sapl/templates/audiencia/layouts.yaml index 916bfa287..18bc46e46 100644 --- a/sapl/templates/audiencia/layouts.yaml +++ b/sapl/templates/audiencia/layouts.yaml @@ -6,7 +6,7 @@ AudienciaPublica: {% trans 'Dados' %}: - tipo_materia numero_materia ano_materia - tipo:4 data:2 hora_inicio:3 hora_fim:3 - - parlamentar_autor requerimento + - autor requerimento - url_audio url_video - upload_pauta upload_ata upload_anexo - observacao @@ -19,7 +19,7 @@ AudienciaPublicaDetail: {% trans 'Dados' %}: - materia tipo - data hora_inicio hora_fim - - parlamentar_autor requerimento + - autor requerimento - url_audio url_video - upload_pauta upload_ata upload_anexo - observacao diff --git a/sapl/templates/base.html b/sapl/templates/base.html index a11841704..e3582e2ef 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -40,6 +40,13 @@
+ {% if not request|has_iframe %} +
+ {% trans 'Informações foram atualizadas em' %} + {% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %} +
+ {% endif %} + {% if not sapl_as_sapn or user.is_authenticated%} {% if not request|has_iframe %} {% block navigation %} diff --git a/sapl/templates/relatorios/relatorio_materia_tramitacao.html b/sapl/templates/relatorios/relatorio_materia_tramitacao.html index ddfe6e38b..a75a7344b 100644 --- a/sapl/templates/relatorios/relatorio_materia_tramitacao.html +++ b/sapl/templates/relatorios/relatorio_materia_tramitacao.html @@ -9,7 +9,7 @@

Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}

Tipo de matéria: {{ tipo }}
- Autor: {{ autor }}
+ {% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}
{% for tramitacao in object%} diff --git a/sapl/templates/sessao/sessaoplenaria_filter.html b/sapl/templates/sessao/sessaoplenaria_filter.html index 51c2fdb17..7ea3f8b65 100644 --- a/sapl/templates/sessao/sessaoplenaria_filter.html +++ b/sapl/templates/sessao/sessaoplenaria_filter.html @@ -53,6 +53,13 @@ {% if s.upload_ata %} Ata da Sessão
{% endif %} + {% if s.upload_pauta %} + Pauta da Sessão
+ {% endif %} + {% if s.upload_anexo %} + Anexo da Sessão
+ {% endif %} + Votações Nominais
{% endfor %} diff --git a/sapl/utils.py b/sapl/utils.py index 9bd6231dc..588494bd5 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -623,6 +623,15 @@ TIPOS_TEXTO_PERMITIDOS = ( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/xml', 'text/xml', + # Formatos editáveis de planilha/CSV (atende à gravação de relatórios do PNTP). + 'text/csv', + 'application/csv', + 'text/comma-separated-values', + 'application/vnd.ms-excel', # .xls + 'application/x-ole-storage', # .xls (detecção OLE2 legada do libmagic) + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx + 'application/vnd.oasis.opendocument.spreadsheet', # .ods / calc + 'application/x-vnd.oasis.opendocument.spreadsheet', ) TIPOS_IMG_PERMITIDOS = ( From c1d542ae349bfe6580a3b1e4ebdd295bdafb3d7c Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:02:14 -0300 Subject: [PATCH 09/17] =?UTF-8?q?Mant=C3=A9m=20vis=C3=ADveis=20ao=20p?= =?UTF-8?q?=C3=BAblico=20as=20sess=C3=B5es=20de=20hoje=20e=20de=20datas=20?= =?UTF-8?q?passadas=20(#3864)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regra do #3845 escondia do usuário anônimo toda sessão com "Sessão iniciada?" e "Publicar pauta?" em Não. Há Casas que não marcam o campo nas sessões já realizadas, e essas sessões, com presença e áudio, passaram a dar 404 para o público. O que caracteriza o cadastro antecipado é a data futura. Passa a ocultar só as sessões não iniciadas, sem pauta publicada e com data posterior a hoje; as de hoje e de datas passadas ficam sempre visíveis. --- sapl/sessao/models.py | 11 ++++-- sapl/sessao/tests/test_sessao_view.py | 54 ++++++++++++++++++++------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py index fc418b08f..f86f2f414 100644 --- a/sapl/sessao/models.py +++ b/sapl/sessao/models.py @@ -157,8 +157,12 @@ def restringe_sessoes_visiveis(qs, user): """Restringe o queryset às sessões visíveis na consulta pública. Usuário autenticado enxerga todas. Para o público, ficam ocultas as - sessões cadastradas com antecedência, ou seja, as marcadas como não - iniciadas e sem pauta publicada. + sessões cadastradas com antecedência, ou seja, as de data futura + marcadas como não iniciadas e sem pauta publicada. + + A data é o que distingue o cadastro antecipado: sessões de hoje ou de + datas passadas ficam sempre visíveis, mesmo com `iniciada` em Não, porque + há Casas que não marcam o campo nas sessões já realizadas. `iniciada` nulo conta como visível: o default do campo só surgiu na migração 0027, que não preencheu as linhas já existentes, então bases @@ -170,7 +174,8 @@ def restringe_sessoes_visiveis(qs, user): return qs.filter( Q(publicar_pauta=True) | Q(iniciada=True) | - Q(iniciada__isnull=True)) + Q(iniciada__isnull=True) | + Q(data_inicio__lte=timezone.localdate())) class SessaoPlenaria(models.Model): diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py index aef74c2ae..9e68953a6 100644 --- a/sapl/sessao/tests/test_sessao_view.py +++ b/sapl/sessao/tests/test_sessao_view.py @@ -1,9 +1,10 @@ import pytest -from datetime import date +from datetime import timedelta from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, transaction from django.urls import reverse +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from model_bakery import baker @@ -155,13 +156,22 @@ class TestResumoView(): assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia +# Sessão cadastrada com antecedência: data futura, sem pauta e não iniciada. +def data_futura(): + return timezone.localdate() + timedelta(days=30) + + @pytest.mark.django_db(transaction=False) def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa(): - previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) - com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True) - iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False) + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=data_futura()) + com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True, + data_inicio=data_futura()) + iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False, + data_inicio=data_futura()) # Sessões anteriores à migração 0027 ficaram com `iniciada` em NULL. - legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False) + legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False, + data_inicio=data_futura()) visiveis = restringe_sessoes_visiveis( SessaoPlenaria.objects.all(), AnonymousUser()) @@ -172,9 +182,25 @@ def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa(): assert legada in visiveis +@pytest.mark.django_db(transaction=False) +def test_visiveis_para_mostra_ao_anonimo_sessao_realizada_nao_marcada(): + # Casas que não marcam "Sessão iniciada?" nas sessões já realizadas. + passada = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=timezone.localdate() - timedelta(days=1)) + de_hoje = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=timezone.localdate()) + + visiveis = restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), AnonymousUser()) + + assert passada in visiveis + assert de_hoje in visiveis + + @pytest.mark.django_db(transaction=False) def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado(): - previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=data_futura()) operador = baker.make(get_user_model()) @@ -185,12 +211,12 @@ def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado(): @pytest.mark.django_db(transaction=False) def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client): previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, - data_inicio=date(2025, 11, 5)) + data_inicio=data_futura()) iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False, - data_inicio=date(2025, 11, 5)) + data_inicio=data_futura()) response = client.get(reverse('sapl.sessao:pesquisar_sessao'), - {'data_inicio__year': '2025'}) + {'data_inicio__year': data_futura().year}) assert response.status_code == 200 assert previa not in response.context['object_list'] @@ -200,10 +226,10 @@ def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client): @pytest.mark.django_db(transaction=False) def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client): previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, - data_inicio=date(2025, 11, 5)) + data_inicio=data_futura()) response = admin_client.get(reverse('sapl.sessao:pesquisar_sessao'), - {'data_inicio__year': '2025'}) + {'data_inicio__year': data_futura().year}) assert response.status_code == 200 assert previa in response.context['object_list'] @@ -211,7 +237,8 @@ def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client): @pytest.mark.django_db(transaction=False) def test_detail_sessao_previa_indisponivel_para_anonimo(client): - previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=data_futura()) response = client.get(reverse('sapl.sessao:sessaoplenaria_detail', kwargs={'pk': previa.pk})) @@ -221,7 +248,8 @@ def test_detail_sessao_previa_indisponivel_para_anonimo(client): @pytest.mark.django_db(transaction=False) def test_resumo_de_sessao_previa_indisponivel_para_anonimo(client): - previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False) + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=data_futura()) response = client.get(reverse('sapl.sessao:resumo', kwargs={'pk': previa.pk})) From 0e031959d9bdf369cbf5aa1dc816d32a3e746b0b Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:12:24 -0300 Subject: [PATCH 10/17] Permite ajustar e persistir o tamanho da letra no editor de textos articulados (#3862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Permite ajustar e persistir o tamanho da letra no editor de textos articulados Os botões a/A da leitura alteravam o font-size de cada dispositivo via jQuery, sem persistir, e não existiam na tela de edição. Com isso, a única forma de aumentar a letra era formatar o texto no TinyMCE, o que gravava o tamanho no conteúdo do dispositivo e valia apenas para ele. - Escala de leitura aplicada via variável CSS (--cp-font-scale) no container .cp, salva no localStorage (80% a 250%, passo de 10%) - Botões a/A também na tela de edição (text_edit) - Textarea do editor simples e corpo do TinyMCE acompanham a escala - Linha de vigências não é afetada pela escala - initTextRichEditor passa a retornar a promise do tinymce.init * Ajusta a escala de fonte conforme a revisão do PR - Agrupa as constantes no objeto congelado EscalaFonte, com o valor padrão em EscalaFonte.PADRAO. - Extrai o helper clamp() para limitar a escala ao intervalo permitido. - Separa, no log, a falha de acesso ao localStorage do valor salvo inválido, e registra também a falha ao salvar. - Renomeia aplicaEscalaFonte/salvaEscalaFonte para o infinitivo. --------- Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- .../__apps/compilacao/js/old/compilacao.js | 70 ++++++++++++++++++- .../compilacao/js/old/compilacao_edit.js | 4 +- .../__apps/compilacao/scss/compilacao.scss | 9 +++ frontend/src/__global/js/tinymce/index.js | 2 +- sapl/templates/compilacao/text_edit.html | 10 ++- 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/frontend/src/__apps/compilacao/js/old/compilacao.js b/frontend/src/__apps/compilacao/js/old/compilacao.js index f7176c473..8853d6425 100644 --- a/frontend/src/__apps/compilacao/js/old/compilacao.js +++ b/frontend/src/__apps/compilacao/js/old/compilacao.js @@ -279,6 +279,69 @@ function DispositivoSearch (opts) { }) } +// Escala de leitura do texto articulado, preferência de cada navegador. +// Aplicada via variável CSS (--cp-font-scale) para não alterar o conteúdo +// dos dispositivos e para valer também nos que são recarregados via ajax. +const EscalaFonte = Object.freeze({ + CHAVE: 'compilacao_escala_fonte', + PADRAO: 1, + PASSO: 0.1, + MIN: 0.8, + MAX: 2.5 +}) +let escalaFonteAtual = null + +// Limita num ao intervalo [lower, upper]. +function clamp (num, lower, upper) { + return Math.max(lower, Math.min(num, upper)) +} + +function lerEscalaFonte () { + if (escalaFonteAtual !== null) { + return escalaFonteAtual + } + let valor = null + try { + valor = window.localStorage.getItem(EscalaFonte.CHAVE) + } catch (e) { + // localStorage indisponível (ex.: navegação privada bloqueada) + console.warn('Escala de fonte: não foi possível ler o localStorage.', e) + return EscalaFonte.PADRAO + } + if (valor === null) { + return EscalaFonte.PADRAO + } + const escala = parseFloat(valor) + if (!Number.isFinite(escala)) { + console.warn('Escala de fonte: valor inválido no localStorage, usando o padrão.', valor) + return EscalaFonte.PADRAO + } + return clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) +} + +function aplicarEscalaFonte (escala) { + document.documentElement.style.setProperty('--cp-font-scale', escala) + if (window.tinymce) { + window.tinymce.get().forEach(function (editor) { + if (editor.getBody()) { + editor.getBody().style.fontSize = (escala * 100) + '%' + } + }) + } +} + +function salvarEscalaFonte (escala) { + escala = Math.round(clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) * 10) / 10 + try { + window.localStorage.setItem(EscalaFonte.CHAVE, escala) + } catch (e) { + // localStorage indisponível: a escala vale apenas até recarregar a página + console.warn('Escala de fonte: não foi possível salvar no localStorage.', escala, e) + } + escalaFonteAtual = escala + aplicarEscalaFonte(escala) +} + function InitViewTAs () { setTimeout(function () { var href = location.href.split('#') @@ -297,11 +360,12 @@ function InitViewTAs () { } }, 100) + aplicarEscalaFonte(lerEscalaFonte()) $('#btn_font_menos').click(function () { - $('.dpt').css('font-size', '-=1') + salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO) }) $('#btn_font_mais').click(function () { - $('.dpt').css('font-size', '+=1') + salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO) }) $('.dpt.bloco_alteracao .dpt').each(function () { @@ -327,5 +391,7 @@ export default { ReadCookie, insertWaitAjax, InitViewTAs, + lerEscalaFonte, + aplicarEscalaFonte, DispositivoSearch } diff --git a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js index ba651188b..27060135b 100644 --- a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js +++ b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js @@ -244,7 +244,9 @@ window.DispositivoEdit = function () { if (editortype !== 'construct') { dpt_form.html(data) if (editortype === 'tinymce') { - window.initTextRichEditor(null, false, false) + window.initTextRichEditor(null, false, false).then(function () { + window.aplicarEscalaFonte(window.lerEscalaFonte()) + }) } // OptionalCustomFrontEnd().init() } diff --git a/frontend/src/__apps/compilacao/scss/compilacao.scss b/frontend/src/__apps/compilacao/scss/compilacao.scss index 82c52f7f6..842436b24 100644 --- a/frontend/src/__apps/compilacao/scss/compilacao.scss +++ b/frontend/src/__apps/compilacao/scss/compilacao.scss @@ -164,12 +164,21 @@ a:link:after, a:visited:after { } .cp { + /* --cp-font-scale: escala de leitura escolhida pelo usuário (a/A) */ + font-size: calc(100% * var(--cp-font-scale, 1)); + + .dpt-form textarea.form-control { + font-size: calc(1rem * var(--cp-font-scale, 1)); + } + .vigencia-active { margin-top: 30px; display: block; } .cp-linha-vigencias { + /* navegação entre vigências não acompanha a escala de leitura */ + font-size: calc(100% / var(--cp-font-scale, 1)); display: flex; flex-direction: row; list-style: none; diff --git a/frontend/src/__global/js/tinymce/index.js b/frontend/src/__global/js/tinymce/index.js index 51eed1efa..3f3ca312e 100644 --- a/frontend/src/__global/js/tinymce/index.js +++ b/frontend/src/__global/js/tinymce/index.js @@ -31,5 +31,5 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text configTinymce.menubar = false configTinymce.toolbar = false } - window.tinymce.init(configTinymce) + return window.tinymce.init(configTinymce) } diff --git a/sapl/templates/compilacao/text_edit.html b/sapl/templates/compilacao/text_edit.html index ada437934..ed7055e62 100644 --- a/sapl/templates/compilacao/text_edit.html +++ b/sapl/templates/compilacao/text_edit.html @@ -17,8 +17,14 @@ {% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %} {% endif %} -
- {% include 'compilacao/textoarticulado_menu_config.html' %} +
+
+ a + A +
+
+ {% include 'compilacao/textoarticulado_menu_config.html' %} +
{% if object.dispositivos_set.count == 1 %} From ecfd65a8b353be5368bce5ff624d19f4b95d3491 Mon Sep 17 00:00:00 2001 From: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:15:50 -0300 Subject: [PATCH 11/17] =?UTF-8?q?Converte=20t=C3=ADtulos=20colados=20em=20?= =?UTF-8?q?par=C3=A1grafos=20no=20editor=20de=20dispositivos=20(#3863)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texto colado de estilos de título do Word ou de páginas web chega ao TinyMCE como h1-h6: fica em negrito e não pode ser desfeito, porque o botão de negrito apenas acrescenta um strong e "remover formatação" mantém o título. A barra também não oferecia como trocar o bloco, pois "styleselect" não existe mais no TinyMCE 7 e era ignorado. - initTextRichEditor ganha o parâmetro paste_titulos_como_paragrafo, que converte h1-h6 colados em p, mantendo o alinhamento - Parâmetro ativado nos editores de dispositivos da compilação - "styleselect" substituído por "blocks" na barra de ferramentas Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- frontend/src/__global/js/tinymce/index.js | 23 +++++++++++++++++-- .../dispositivo_form_alteracao.html | 2 +- .../dispositivo_form_edicao_basica.html | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/__global/js/tinymce/index.js b/frontend/src/__global/js/tinymce/index.js index 3f3ca312e..ee20a7089 100644 --- a/frontend/src/__global/js/tinymce/index.js +++ b/frontend/src/__global/js/tinymce/index.js @@ -14,7 +14,23 @@ import 'tinymce/plugins/table' import './langs/pt_BR.js' window.tinymce = tinymce -window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false) { +// Títulos (h1-h6) vindos de estilos de título do Word ou de páginas web +// chegam em negrito e não podem ser desfeitos pelo botão de negrito nem +// por "remover formatação". Converte-os em parágrafos, mantendo o alinhamento. +function colarTitulosComoParagrafo (editor, args) { + args.node.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (titulo) { + const paragrafo = document.createElement('p') + if (titulo.style.textAlign) { + paragrafo.style.textAlign = titulo.style.textAlign + } + while (titulo.firstChild) { + paragrafo.appendChild(titulo.firstChild) + } + titulo.replaceWith(paragrafo) + }) +} + +window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false, paste_titulos_como_paragrafo = false) { const configTinymce = { selector: elements === null || elements === undefined ? 'textarea' : elements, language: 'pt_BR', @@ -22,10 +38,13 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text forced_root_block: 'p', paste_as_text, plugins: 'table lists advlist link code', - toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', + toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', menubar: 'file edit view insert format table', license_key: 'gpl' } + if (paste_titulos_como_paragrafo) { + configTinymce.paste_postprocess = colarTitulosComoParagrafo + } if (readonly) { configTinymce.readonly = 1 configTinymce.menubar = false diff --git a/sapl/templates/compilacao/dispositivo_form_alteracao.html b/sapl/templates/compilacao/dispositivo_form_alteracao.html index 711472d01..c7d8562d8 100644 --- a/sapl/templates/compilacao/dispositivo_form_alteracao.html +++ b/sapl/templates/compilacao/dispositivo_form_alteracao.html @@ -7,6 +7,6 @@ 'url_form': '{% url 'sapl.compilacao:dispositivo_search_form'%}', 'text_button': '{% trans 'Definir Dispositivo'%}' }); - initTextRichEditor(); + initTextRichEditor(null, false, false, true); {% endblock %} diff --git a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html index 5eca7a09d..c4c5d8e2b 100644 --- a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html +++ b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html @@ -31,6 +31,6 @@ }); } - initTextRichEditor(); + initTextRichEditor(null, false, false, true); {% endblock %} From 9c94aa398d74ec0835de41d50a1cf2b9165e6bf9 Mon Sep 17 00:00:00 2001 From: Edward <9326037+edwardoliveira@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:44:25 -0300 Subject: [PATCH 12/17] =?UTF-8?q?Restringe=20acesso=20a=20arquivos=20de=20?= =?UTF-8?q?m=C3=ADdia=20(#3865)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Verifica permissão de acesso em documentos administrativos Co-Authored-By: Claude Opus 5.5 * Serve arquivos de mídia via Django com X-Accel-Redirect Todo /media/ passa pelo Django, que verifica a permissão dos arquivos de documentos administrativos e proposições e define Content-Type e Content-Disposition. O nginx entrega o arquivo por uma location interna. Co-Authored-By: Claude Opus 5.5 * Recusa upload de arquivos com conteúdo executável pelo navegador Co-Authored-By: Claude Opus 5.5 * Oculta sessões não iniciadas também na API e nos arquivos de mídia Co-Authored-By: Claude Opus 5.5 * Valida arquivo antes de gravar no cadastro de acessórios em lote Co-Authored-By: Claude Opus 5.5 * Identifica conteúdo ativo também pelo tipo MIME da extensão Co-Authored-By: Claude Opus 5.5 * Não redireciona arquivos de mídia para troca de senha Co-Authored-By: Claude Opus 5.5 * Ajusta regras de acesso a arquivos de documentos e proposições Documentos administrativos e acessórios ficam ocultos ao público quando a configuração é restritiva ou o documento é restrito. O texto original da proposição nunca é público. Arquivos de sessões plenárias são públicos. Co-Authored-By: Claude Opus 5.5 * Aplica regras da página de detalhe e de sessões ocultas aos arquivos No modo restritivo, os arquivos de documentos administrativos exigem a mesma permissão da página de detalhe. Arquivos de sessões ocultas e de seus oradores e justificativas não são servidos ao público. Co-Authored-By: Claude Opus 5.5 * Usa permissões por estado no acesso ao texto original da proposição Co-Authored-By: Claude Opus 5.5 * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Centraliza validação de caminho dos arquivos de mídia Co-Authored-By: Claude Opus 5.5 * Ajusta teste de sessão oculta à regra de data da sessão Co-Authored-By: Claude Opus 5.5 * Update sapl/materia/views.py Co-authored-by: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> * Update sapl/base/media.py Co-authored-by: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> * Update sapl/base/media.py Co-authored-by: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> * Exige permissão de proposição para receber e confirmar proposições Co-Authored-By: Claude Opus 5.5 * Corrige redirecionamento do PDF de acessórios de documento administrativo Co-Authored-By: Claude Opus 5.5 * Usa a mesma configuração na página e nos arquivos de documento administrativo Co-Authored-By: Claude Opus 5.5 * Remove XML da lista de tipos de texto aceitos no upload O upload de XML já era recusado pela checagem de conteúdo ativo. Co-Authored-By: Claude Opus 5.5 * Envia ao Django só os arquivos de mídia que dependem de autorização Os demais arquivos de /media/ voltam a ser servidos direto pelo nginx, com nosniff e download forçado para extensões ativas. Co-Authored-By: Claude Opus 5.5 * Detecta extensões ativas sem depender de /etc/mime.types A imagem python:3.12-slim não tem /etc/mime.types, então mimetypes não reconhecia .xht, .xslt, .kml etc. e o upload/entrega deixavam passar. EXTENSOES_ATIVAS passa a cobrir as mesmas extensões do nginx (mais .kml e .xspf), com teste que mantém as duas listas iguais. media-types entra na imagem para o tipo_ativo reconhecer os demais +xml. Co-Authored-By: Claude Opus 5.5 * Serve arquivos públicos só com tipos conhecidos Em vez de depender do mime.types global, que entrega tipos +xml (.kml, .xspf) inline, a location /media/ lista os tipos que podem abrir no navegador e manda o resto como application/octet-stream. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: joaohortsenado <100957576+joaohortsenado@users.noreply.github.com> --- docker/Dockerfile | 2 +- docker/config/nginx/sapl.conf | 64 ++- sapl/api/views_sessao.py | 28 +- sapl/base/media.py | 124 ++++++ sapl/base/tests/test_media.py | 384 ++++++++++++++++++ sapl/materia/models.py | 19 + sapl/materia/tests/test_acessorio_em_lote.py | 64 +++ .../tests/test_proposicao_confirmar.py | 98 +++++ sapl/materia/views.py | 126 +++--- sapl/middleware/check_password.py | 4 +- sapl/protocoloadm/models.py | 10 +- sapl/protocoloadm/tests/test_docadm_acesso.py | 163 ++++++++ sapl/protocoloadm/views.py | 38 +- sapl/sessao/tests/test_sessao_oculta_api.py | 94 +++++ sapl/test_utils.py | 94 ++++- sapl/urls.py | 10 +- sapl/utils.py | 44 +- 17 files changed, 1274 insertions(+), 92 deletions(-) create mode 100644 sapl/base/media.py create mode 100644 sapl/base/tests/test_media.py create mode 100644 sapl/materia/tests/test_acessorio_em_lote.py create mode 100644 sapl/materia/tests/test_proposicao_confirmar.py create mode 100644 sapl/protocoloadm/tests/test_docadm_acesso.py create mode 100644 sapl/sessao/tests/test_sessao_oculta_api.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 831627ec8..a1dc565f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -51,7 +51,7 @@ ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ - curl jq bash tzdata fontconfig tini libmagic1 \ + curl jq bash tzdata fontconfig tini libmagic1 media-types \ libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 \ libharfbuzz0b libfreetype6 libjpeg62-turbo zlib1g fonts-dejavu-core; \ if [ "$WITH_GRAPHVIZ" = "1" ]; then apt-get install -y --no-install-recommends graphviz; fi; \ diff --git a/docker/config/nginx/sapl.conf b/docker/config/nginx/sapl.conf index 015538c96..ce5171a00 100644 --- a/docker/config/nginx/sapl.conf +++ b/docker/config/nginx/sapl.conf @@ -49,8 +49,70 @@ server { alias /var/interlegis/sapl/collected_static/; } + # Arquivos que dependem de autorização vão ao Django (sapl.base.media), + # que devolve X-Accel-Redirect para /media-interna/. Manter a lista em + # sincronia com PROTEGIDO_RE e MODELOS_DE_SESSAO (teste em + # sapl/base/tests/test_media.py). Os dois regex ficam no nível do server + # e antes de location /media/: um regex aninhado em /media/ teria + # precedência sobre este. + location ~* ^/media/sapl/(private/|[^/]+/(documentoadministrativo|documentoacessorioadministrativo|proposicao|sessaoplenaria|orador|oradorexpediente|oradorordemdia|justificativaausencia)/) { + proxy_set_header X-Request-ID $req_id; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_pass http://sapl_server; + } + + # Demais arquivos são públicos e servidos direto do disco; conteúdo que o + # navegador executaria como página é forçado a download. + location ~* ^/media/.+\.(html?|xhtml|xht|shtml|svgz?|xml|xsl|xslt|rdf|js|mjs|mht|mhtml|rss|atom|mml|kml|xspf)$ { + root /var/interlegis/sapl; + types { } + default_type application/octet-stream; + add_header Content-Disposition attachment always; + add_header X-Content-Type-Options nosniff always; + } + + # Só os tipos abaixo saem com Content-Type próprio; o resto (inclusive + # +xml que o mime.types global entregaria inline) vai como octet-stream. location /media/ { - alias /var/interlegis/sapl/media/; + root /var/interlegis/sapl; + types { + application/pdf pdf; + image/jpeg jpg jpeg jpe; + image/png png; + image/gif gif; + image/webp webp; + image/bmp bmp; + image/tiff tif tiff; + image/x-icon ico; + text/plain txt; + text/csv csv; + audio/mpeg mp3; + audio/ogg ogg; + video/mp4 mp4; + video/webm webm; + application/rtf rtf; + application/zip zip; + application/msword doc; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; + application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; + application/vnd.oasis.opendocument.text odt; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.presentation odp; + } + default_type application/octet-stream; + add_header X-Content-Type-Options nosniff always; + } + + location /media-interna/ { + internal; + alias /var/interlegis/sapl/media/; + add_header X-Content-Type-Options nosniff always; } location / { diff --git a/sapl/api/views_sessao.py b/sapl/api/views_sessao.py index 75807f8cd..29c31bf29 100644 --- a/sapl/api/views_sessao.py +++ b/sapl/api/views_sessao.py @@ -7,7 +7,8 @@ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ customize, wrapper_queryset_response_for_drf_action from sapl.api.serializers import ChoiceSerializer,\ SessaoPlenariaECidadaniaSerializer -from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao +from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao,\ + restringe_sessoes_visiveis from sapl.utils import choice_anos_com_sessaoplenaria @@ -21,6 +22,13 @@ ApiViewSetConstrutor.build_class( @customize(SessaoPlenaria) class _SessaoPlenariaViewSet: + def get_queryset(self): + qs = super().get_queryset() + # actions com wrapper_queryset_response_for_drf_action trocam o model + if qs.model is SessaoPlenaria: + qs = restringe_sessoes_visiveis(qs, self.request.user) + return qs + @action(detail=False) def years(self, request, *args, **kwargs): years = choice_anos_com_sessaoplenaria() @@ -30,6 +38,7 @@ class _SessaoPlenariaViewSet: @action(detail=True) def expedientes(self, request, *args, **kwargs): + self.get_object() return self.get_expedientes() @wrapper_queryset_response_for_drf_action(model=ExpedienteSessao) @@ -45,3 +54,20 @@ class _SessaoPlenariaViewSet: def ecidadania_list(self, request, *args, **kwargs): self.serializer_class = SessaoPlenariaECidadaniaSerializer return self.list(request, *args, **kwargs) + + +class _RestringeSessoesVisiveisMixin: + + def get_queryset(self): + qs = super().get_queryset() + if self.request.user.is_authenticated: + return qs + return qs.filter(sessao_plenaria__in=restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), self.request.user)) + + +for _model in apps.get_app_config('sessao').get_models(): + if _model is not SessaoPlenaria and any( + f.name == 'sessao_plenaria' for f in _model._meta.fields): + customize(_model)(type('_%sViewSet' % _model.__name__, + (_RestringeSessoesVisiveisMixin,), {})) diff --git a/sapl/base/media.py b/sapl/base/media.py new file mode 100644 index 000000000..9b91027c2 --- /dev/null +++ b/sapl/base/media.py @@ -0,0 +1,124 @@ +import mimetypes +import os +import re +from urllib.parse import quote + +from django.conf import settings +from django.http import FileResponse, Http404, HttpResponse + +from sapl.materia.models import Proposicao, pode_ver_proposicao +from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, + DocumentoAdministrativo, + pode_ver_documento_adm) +from sapl.sessao.models import (JustificativaAusencia, Orador, + OradorExpediente, OradorOrdemDia, + SessaoPlenaria, restringe_sessoes_visiveis) +from sapl.utils import EXTENSOES_ATIVAS, tipo_ativo + +X_ACCEL_PREFIX = '/media-interna/' + +# O nginx do container só envia para cá os caminhos de PROTEGIDO_RE e de +# MODELOS_DE_SESSAO; o resto de /media/ sai direto do disco. Ao incluir um +# modelo aqui, incluí-lo também em docker/config/nginx/sapl.conf. + +# Arquivos que dependem de autorização, em private/ ou em qualquer outra +# pasta (public/ guarda os enviados antes da criação de private/). +PROTEGIDO_RE = re.compile( + r'^sapl/[^/]+/(?:documentoadministrativo|' + r'documentoacessorioadministrativo|proposicao)/') + +RESOLVEDORES = { + 'documentoadministrativo': ( + DocumentoAdministrativo, 'texto_integral', pode_ver_documento_adm), + 'documentoacessorioadministrativo': ( + DocumentoAcessorioAdministrativo, 'arquivo', + lambda user, obj: pode_ver_documento_adm(user, obj.documento)), + 'proposicao': ( + Proposicao, 'texto_original', pode_ver_proposicao), +} + + +# Arquivos gravados em sapl/public///..., que seguem a +# visibilidade da sessão plenária a que pertencem. +MODELOS_DE_SESSAO = {m._meta.model_name: m for m in ( + SessaoPlenaria, Orador, OradorExpediente, OradorOrdemDia, + JustificativaAusencia)} + + +def _de_sessao_oculta(request, partes): + model = MODELOS_DE_SESSAO.get(partes[2]) + if request.user.is_authenticated or not model or len(partes) < 5 or \ + partes[1] != 'public' or not re.fullmatch(r'[0-9]+', partes[3]): + return False + if model is SessaoPlenaria: + sessoes = SessaoPlenaria.objects.filter(pk=partes[3]) + else: + sessoes = SessaoPlenaria.objects.filter( + pk__in=model.objects.filter(pk=partes[3]).values( + 'sessao_plenaria_id')) + return sessoes.exists() and not restringe_sessoes_visiveis( + sessoes, request.user).exists() + + +def _nome_protegido(request, path): + """Devolve o nome do arquivo gravado no objeto dono, se o usuário puder + vê-lo. O caminho servido vem do banco, nunca da URL.""" + resolvedor = RESOLVEDORES.get(path.split('/')[2]) + if not resolvedor: + raise Http404 + model, campo, pode_ver = resolvedor + obj = model.objects.filter( + **{campo + '__in': [path, './' + path]}).first() + if not obj: + raise Http404 + arquivo = getattr(obj, campo) + if not arquivo or not pode_ver(request.user, obj): + raise Http404 + return arquivo.name[2:] if arquivo.name.startswith('./') else arquivo.name + + +def _caminho_em_media(nome): + raiz = os.path.realpath(settings.MEDIA_ROOT) + caminho = os.path.realpath(os.path.join(raiz, nome)) + if not caminho.startswith(raiz + os.sep): + raise Http404 + return caminho + + +def media(request, path): + partes = path.split('/') + if any(p in ('', '.', '..') for p in partes) or '\x00' in path: + raise Http404 + + protegido = path.startswith('sapl/private/') or bool( + PROTEGIDO_RE.match(path)) + nome = _nome_protegido(request, path) if protegido else path + + if len(partes) > 2 and _de_sessao_oculta(request, partes): + raise Http404 + nome_arquivo = os.path.basename(nome) + + content_type = (mimetypes.guess_type(nome_arquivo)[0] or + 'application/octet-stream') + disposicao = 'inline' + if os.path.splitext(nome_arquivo)[1].lower() in EXTENSOES_ATIVAS or \ + tipo_ativo(content_type): + content_type, disposicao = 'application/octet-stream', 'attachment' + + caminho = _caminho_em_media(nome) + if settings.DEBUG: + try: + response = FileResponse(open(caminho, 'rb'), + content_type=content_type) + except (FileNotFoundError, IsADirectoryError): + raise Http404 + else: + response = HttpResponse(content_type=content_type) + response['X-Accel-Redirect'] = X_ACCEL_PREFIX + quote(nome) + + response['Content-Disposition'] = "%s; filename*=UTF-8''%s" % ( + disposicao, quote(nome_arquivo)) + response['X-Content-Type-Options'] = 'nosniff' + if protegido or (len(partes) > 2 and partes[2] in MODELOS_DE_SESSAO): + response['Cache-Control'] = 'private' + return response diff --git a/sapl/base/tests/test_media.py b/sapl/base/tests/test_media.py new file mode 100644 index 000000000..de4ac7c94 --- /dev/null +++ b/sapl/base/tests/test_media.py @@ -0,0 +1,384 @@ +import os +import re + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.http import Http404 +from django.utils import timezone +from model_bakery import baker +import pytest + +from sapl.base.media import MODELOS_DE_SESSAO, RESOLVEDORES, media +from sapl.base.models import AppConfig, Autor +from sapl.materia.models import Proposicao +from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, + DocumentoAdministrativo) +from sapl.utils import EXTENSOES_ATIVAS + +DOC_PATH = 'sapl/private/documentoadministrativo/2026/1/texto.pdf' +ACESSORIO_PATH = 'sapl/public/documentoacessorioadministrativo/2026/1/a.pdf' +PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf' + + +@pytest.fixture(autouse=True) +def limpa_cache_appconfig(): + # o cache em arquivo sobrevive ao rollback do teste + cache.clear() + yield + cache.clear() + + +@pytest.fixture(autouse=True) +def sem_debug(settings): + settings.DEBUG = False + + +def _config(comportamento): + cache.clear() + AppConfig.objects.all().delete() + baker.make(AppConfig, documentos_administrativos=comportamento) + + +def _login(client, username, *perms): + user = baker.make(get_user_model(), username=username) + for perm in perms: + app_label, codename = perm.split('.') + perm_obj = Permission.objects.filter( + content_type__app_label=app_label, codename=codename).first() + if not perm_obj: + perm_obj = Permission.objects.create( + codename=codename, name=codename, + content_type=ContentType.objects.get_for_model( + DocumentoAdministrativo)) + user.user_permissions.add(perm_obj) + client.force_login(user) + return user + + +def _get(client, path): + return client.get('/media/' + path) + + +def _assert_servido(response, path): + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == '/media-interna/' + path + assert response['X-Content-Type-Options'] == 'nosniff' + assert response['Cache-Control'] == 'private' + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', [ + ('O', False, None, True), + ('O', True, None, False), + ('O', True, 'logado', True), + ('R', False, None, False), + ('R', False, 'logado', False), + ('R', True, None, False), + ('R', True, 'logado', False), + ('R', True, 'com_permissao', True), +]) +def test_documento_administrativo(client, comportamento, restrito, perfil, + pode_ver): + _config(comportamento) + baker.make(DocumentoAdministrativo, restrito=restrito, + texto_integral=DOC_PATH) + if perfil == 'logado': + _login(client, 'logado') + elif perfil == 'com_permissao': + _login(client, 'perm', + 'protocoloadm.detail_documentoadministrativo') + + response = _get(client, DOC_PATH) + + if pode_ver: + _assert_servido(response, DOC_PATH) + assert response['Content-Type'] == 'application/pdf' + else: + assert response.status_code == 404 + assert not response.has_header('X-Accel-Redirect') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('restrito,pode_ver', [(False, True), (True, False)]) +def test_documento_acessorio_segue_documento(client, restrito, pode_ver): + _config('O') + documento = baker.make(DocumentoAdministrativo, restrito=restrito) + baker.make(DocumentoAcessorioAdministrativo, documento=documento, + arquivo=ACESSORIO_PATH) + + response = _get(client, ACESSORIO_PATH) + + assert response.status_code == (200 if pode_ver else 404) + + +@pytest.mark.django_db(transaction=False) +def test_caminho_legado_e_prefixo_ponto_barra(client): + _config('O') + legado = 'sapl/public/documentoadministrativo/2010/7/antigo.pdf' + baker.make(DocumentoAdministrativo, texto_integral=legado) + baker.make(DocumentoAdministrativo, texto_integral='./' + DOC_PATH) + + _assert_servido(_get(client, legado), legado) + _assert_servido(_get(client, DOC_PATH), DOC_PATH) + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('path', [ + 'sapl/private/documentoadministrativo/2026/1/inexistente.pdf', + 'sapl/private/outromodelo/2026/1/texto.pdf', + 'sapl/private/documentoadministrativo/../../public/x.pdf', + 'sapl/private/documentoadministrativo/%2e%2e/x.pdf', +]) +def test_caminho_invalido(client, path): + _config('O') + baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH) + + assert _get(client, path).status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_extensao_ativa_e_baixada(client): + _config('O') + path = 'sapl/private/documentoadministrativo/2026/1/x.html' + baker.make(DocumentoAdministrativo, texto_integral=path) + + response = _get(client, path) + + _assert_servido(response, path) + assert response['Content-Type'] == 'application/octet-stream' + assert response['Content-Disposition'].startswith('attachment;') + + +@pytest.mark.django_db(transaction=False) +def test_nome_nao_ascii_codificado(client): + _config('O') + path = 'sapl/private/documentoadministrativo/2026/1/ofício_nº1.pdf' + baker.make(DocumentoAdministrativo, texto_integral=path) + + response = _get(client, path) + + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == ( + '/media-interna/sapl/private/documentoadministrativo/2026/1/' + 'of%C3%ADcio_n%C2%BA1.pdf') + + +ESTADOS_PROPOSICAO = ('rascunho', 'enviada', 'devolvida', 'incorporada') +PERMS_DOCADM = ['protocoloadm.add_documentoadministrativo', + 'protocoloadm.change_documentoadministrativo', + 'protocoloadm.detail_documentoadministrativo'] + + +def _proposicao(estado): + autor = baker.make(Autor) + operador = baker.make(get_user_model(), username='operador') + autor.operadores.add(operador) + agora = timezone.now() + # mesmos campos gravados pelo envio, devolução e incorporação + baker.make(Proposicao, autor=autor, texto_original=PROPOSICAO_PATH, + data_envio=agora if estado in ('enviada', 'incorporada') + else None, + data_recebimento=agora if estado == 'incorporada' else None, + data_devolucao=agora if estado == 'devolvida' else None) + return operador + + +def _login_proposicao(client, perfil, operador): + if perfil == 'operador': + client.force_login(operador) + elif perfil == 'docadm': + user = _login(client, 'docadm', *PERMS_DOCADM) + assert user.has_module_perms('protocoloadm') + elif perfil == 'outro': + _login(client, 'outro') + elif perfil: + _login(client, perfil, 'materia.detail_proposicao_' + perfil) + + +def _pode_ver_proposicao(estado, perfil): + return perfil == 'operador' or perfil == estado != 'rascunho' + + +GRADE_PROPOSICAO = [ + (estado, perfil, _pode_ver_proposicao(estado, perfil)) + for estado in ESTADOS_PROPOSICAO + for perfil in (None, 'operador', 'outro', 'docadm', 'enviada', + 'devolvida', 'incorporada')] + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO) +def test_proposicao(client, estado, perfil, pode_ver): + _login_proposicao(client, perfil, _proposicao(estado)) + + response = _get(client, PROPOSICAO_PATH) + + if pode_ver: + _assert_servido(response, PROPOSICAO_PATH) + else: + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_arquivo_publico_passa_por_x_accel(client): + path = 'sapl/public/materialegislativa/2026/1/materia.pdf' + + response = _get(client, path) + + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == '/media-interna/' + path + assert response['Content-Type'] == 'application/pdf' + assert response['X-Content-Type-Options'] == 'nosniff' + assert not response.has_header('Cache-Control') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('nome', ['x.svg', 'x.html', 'x.XML', 'x.js', + 'x.xht', 'x.rdf', 'x.xslt', 'x.kml']) +def test_arquivo_publico_ativo_e_baixado(client, nome): + response = _get(client, 'sapl/public/normajuridica/2026/1/' + nome) + + assert response['Content-Type'] == 'application/octet-stream' + assert response['Content-Disposition'].startswith('attachment;') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('path', [ + 'sapl/public/../private/documentoadministrativo/2026/1/texto.pdf', + 'sapl//private/documentoadministrativo/2026/1/texto.pdf', + 'sapl/./private/documentoadministrativo/2026/1/texto.pdf', + 'sapl/public/normajuridica/', +]) +def test_caminho_nao_normalizado(client, path): + assert _get(client, path).status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_debug_serve_arquivo(rf, settings, tmp_path): + settings.DEBUG = True + settings.MEDIA_ROOT = str(tmp_path) + _config('R') + publico = tmp_path / 'sapl/public/normajuridica/1/n.pdf' + publico.parent.mkdir(parents=True) + publico.write_bytes(b'%PDF-1.4 norma') + baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH) + + def chama(path): + request = rf.get('/media/' + path) + request.user = AnonymousUser() + return media(request, path) + + response = chama('sapl/public/normajuridica/1/n.pdf') + assert b''.join(response.streaming_content) == b'%PDF-1.4 norma' + assert not response.has_header('X-Accel-Redirect') + + for path in (DOC_PATH, 'sapl/public/normajuridica/1/nao.pdf'): + with pytest.raises(Http404): + chama(path) + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('debug', [False, True]) +def test_link_simbolico_para_fora_de_media(rf, settings, tmp_path, debug): + settings.DEBUG = debug + media_root = tmp_path / 'media' + fora = tmp_path / 'fora' + (media_root / 'sapl/public').mkdir(parents=True) + fora.mkdir() + (fora / 'segredo.txt').write_bytes(b'segredo') + (media_root / 'sapl/public/link').symlink_to(fora) + settings.MEDIA_ROOT = str(media_root) + request = rf.get('/media/sapl/public/link/segredo.txt') + request.user = AnonymousUser() + + with pytest.raises(Http404): + media(request, 'sapl/public/link/segredo.txt') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('path', ['/etc/passwd', 'sapl/public//etc/passwd']) +def test_caminho_absoluto(rf, path): + request = rf.get('/media/' + path) + request.user = AnonymousUser() + + with pytest.raises(Http404): + media(request, path) + + +@pytest.mark.django_db(transaction=False) +def test_modelo_protegido_fora_de_private_e_public(client): + _config('O') + path = 'sapl/outra/documentoadministrativo/2026/1/texto.pdf' + baker.make(DocumentoAdministrativo, restrito=True, texto_integral=path) + + assert _get(client, path).status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_senha_fraca_nao_redireciona_media(client): + _login(client, 'fraca') + session = client.session + session['weak_password'] = True + session.save() + + response = _get(client, 'sapl/public/casalegislativa/logotipo.png') + + assert response.status_code == 200 + assert response.has_header('X-Accel-Redirect') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO) +def test_proposicao_texto(client, settings, tmp_path, estado, perfil, + pode_ver): + settings.MEDIA_ROOT = str(tmp_path) + arquivo = tmp_path / PROPOSICAO_PATH + arquivo.parent.mkdir(parents=True) + arquivo.write_bytes(b'%PDF-1.4 proposicao') + _login_proposicao(client, perfil, _proposicao(estado)) + pk = Proposicao.objects.get(texto_original=PROPOSICAO_PATH).pk + + response = client.get('/proposicao/texto/%s' % pk) + + if pode_ver: + assert response.status_code == 200 + assert response.content == b'%PDF-1.4 proposicao' + else: + assert response.status_code == 302 + assert response['Location'] == '/proposicao/%s' % pk + + +def test_nginx_envia_ao_django_todos_os_caminhos_protegidos(): + caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config', + 'nginx', 'sapl.conf') + with open(caminho) as f: + conf = f.read() + regex = re.search(r'location ~\*? \^/media/sapl/\(private/\|' + r'\[\^/\]\+/\(([a-z|]+)\)/\)', conf) + + assert regex, 'location de arquivos protegidos não encontrada' + no_nginx = set(regex.group(1).split('|')) + assert set(RESOLVEDORES) | set(MODELOS_DE_SESSAO) <= no_nginx + assert conf.index(regex.group(0)) < conf.index('location /media/ {') + + +def test_nginx_forca_download_das_extensoes_ativas(): + caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config', + 'nginx', 'sapl.conf') + with open(caminho) as f: + conf = f.read() + regex = re.search(r'location ~\*? \^/media/\.\+\\\.\(([a-z?|]+)\)\$', + conf) + + assert regex, 'location de extensões ativas não encontrada' + no_nginx = set() + for ext in regex.group(1).split('|'): + if ext.endswith('?'): + no_nginx |= {ext[:-2], ext[:-1]} + else: + no_nginx.add(ext) + assert {'.' + e for e in no_nginx} == EXTENSOES_ATIVAS diff --git a/sapl/materia/models.py b/sapl/materia/models.py index bdeb402e3..1c61ba0db 100644 --- a/sapl/materia/models.py +++ b/sapl/materia/models.py @@ -1154,6 +1154,25 @@ class Proposicao(models.Model): update_fields=update_fields) +def pode_ver_proposicao(user, proposicao): + """Texto original da proposição: nunca público. Operadores do autor veem + sempre; demais usuários seguem as permissões por estado da página de + detalhe (ProposicaoCrud.DetailView.dispatch).""" + if not user.is_authenticated: + return False + if proposicao.autor and proposicao.autor.operadores.filter( + id=user.id).exists(): + return True + # devolução limpa data_envio: checar antes de tratar como rascunho + if proposicao.data_devolucao: + return user.has_perm('materia.detail_proposicao_devolvida') + if not proposicao.data_envio: + return False + if not proposicao.data_recebimento: + return user.has_perm('materia.detail_proposicao_enviada') + return user.has_perm('materia.detail_proposicao_incorporada') + + class HistoricoProposicao(models.Model): STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')), ('R', 'RECEBIDA', _('Recebida')), diff --git a/sapl/materia/tests/test_acessorio_em_lote.py b/sapl/materia/tests/test_acessorio_em_lote.py new file mode 100644 index 000000000..a56e8bcc6 --- /dev/null +++ b/sapl/materia/tests/test_acessorio_em_lote.py @@ -0,0 +1,64 @@ +import os + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from model_bakery import baker +import pytest + +from sapl.materia.models import (DocumentoAcessorio, MateriaLegislativa, + TipoDocumento) + + +@pytest.fixture +def dirs(settings, tmp_path, monkeypatch): + media = tmp_path / 'media' + temp = tmp_path / 'temp' + media.mkdir() + temp.mkdir() + settings.MEDIA_ROOT = str(media) + monkeypatch.setattr('sapl.materia.views.MEDIA_ROOT', str(media)) + monkeypatch.setattr('sapl.materia.views.get_tempfile_dir', + lambda: str(temp)) + return media, temp + + +def _post(client, nome, conteudo): + materias = baker.make(MateriaLegislativa, _quantity=2) + tipo = baker.make(TipoDocumento, descricao='Parecer') + client.post(reverse('sapl.materia:acessorio_em_lote'), { + 'materia_id': [m.pk for m in materias], + 'tipo': tipo.descricao, + 'nome': 'Parecer', + 'autor': 'Comissão', + 'data': '12/10/2026', + 'ementa': 'Ementa', + 'arquivo': SimpleUploadedFile(nome, conteudo), + }) + return materias + + +@pytest.mark.django_db(transaction=False) +def test_arquivo_invalido_nao_grava_nada(admin_client, dirs): + media, temp = dirs + + _post(admin_client, 'x.html', b'%PDF-1.4\n') + + assert not DocumentoAcessorio.objects.exists() + assert os.listdir(media) == [] + assert os.listdir(temp) == [] + + +@pytest.mark.django_db(transaction=False) +def test_arquivo_valido_copiado_para_cada_materia(admin_client, dirs): + media, temp = dirs + + materias = _post(admin_client, 'parecer.pdf', b'%PDF-1.4\n%%EOF') + + docs = DocumentoAcessorio.objects.filter(materia__in=materias) + assert docs.count() == 2 + for doc in docs: + assert doc.arquivo.name == ( + 'sapl/public/documentoacessorio/2026/%s/parecer.pdf' % doc.pk) + assert (media / doc.arquivo.name).read_bytes() == b'%PDF-1.4\n%%EOF' + assert os.listdir(media) == ['sapl'] + assert os.listdir(temp) == [] diff --git a/sapl/materia/tests/test_proposicao_confirmar.py b/sapl/materia/tests/test_proposicao_confirmar.py new file mode 100644 index 000000000..327b65d84 --- /dev/null +++ b/sapl/materia/tests/test_proposicao_confirmar.py @@ -0,0 +1,98 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils import timezone +from model_bakery import baker +import pytest + +from sapl.base.models import AppConfig +from sapl.materia.models import (Proposicao, TipoMateriaLegislativa, + TipoProposicao) +from sapl.utils import SEPARADOR_HASH_PROPOSICAO, gerar_hash_arquivo + +PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf' +PERMS_DOCADM = ['add_documentoadministrativo', + 'change_documentoadministrativo', + 'detail_documentoadministrativo'] + + +def _login(client, app_label, *codenames): + user = baker.make(get_user_model(), username=app_label) + user.user_permissions.add(*Permission.objects.filter( + content_type__app_label=app_label, codename__in=codenames)) + client.force_login(user) + return user + + +def _login_docadm(client): + user = _login(client, 'protocoloadm', *PERMS_DOCADM) + assert user.has_module_perms('protocoloadm') + + +def _login_protocolo(client): + _login(client, 'materia', 'detail_proposicao_enviada') + + +def _proposicao_enviada(settings, tmp_path): + settings.MEDIA_ROOT = str(tmp_path) + baker.make(AppConfig) + arquivo = tmp_path / PROPOSICAO_PATH + arquivo.parent.mkdir(parents=True) + arquivo.write_bytes(b'%PDF-1.4 proposicao') + tipo = baker.make( + TipoProposicao, + content_type=ContentType.objects.get_for_model(TipoMateriaLegislativa), + object_id=baker.make(TipoMateriaLegislativa).pk) + proposicao = baker.make(Proposicao, tipo=tipo, + texto_original=PROPOSICAO_PATH, + data_envio=timezone.now()) + hasher = gerar_hash_arquivo(str(arquivo), str(proposicao.pk)) + return proposicao, hasher[1:].split(SEPARADOR_HASH_PROPOSICAO)[0] + + +def _url_confirmar(proposicao, hash_): + return reverse('sapl.materia:proposicao-confirmar', + kwargs={'hash': hash_, 'pk': proposicao.pk}) + + +@pytest.mark.django_db(transaction=False) +def test_receber_exige_permissao_de_proposicao(client): + _login_docadm(client) + url = reverse('sapl.materia:receber-proposicao') + + assert client.get(url).status_code == 403 + + +@pytest.mark.django_db(transaction=False) +def test_receber_operador_de_protocolo(client): + _login_protocolo(client) + url = reverse('sapl.materia:receber-proposicao') + + assert client.get(url).status_code == 200 + + +@pytest.mark.django_db(transaction=False) +def test_confirmar_exige_permissao_de_proposicao(client, settings, tmp_path): + proposicao, hash_ = _proposicao_enviada(settings, tmp_path) + _login_docadm(client) + + response = client.get(_url_confirmar(proposicao, hash_)) + + assert response.status_code == 403 + + +@pytest.mark.django_db(transaction=False) +def test_confirmar_operador_de_protocolo_ve_texto(client, settings, + tmp_path): + settings.DEBUG = False + proposicao, hash_ = _proposicao_enviada(settings, tmp_path) + _login_protocolo(client) + + response = client.get(_url_confirmar(proposicao, hash_)) + + assert response.status_code == 200 + assert proposicao.texto_original.url.encode() in response.content + texto = client.get(proposicao.texto_original.url) + assert texto.status_code == 200 + assert texto['X-Accel-Redirect'] == '/media-interna/' + PROPOSICAO_PATH diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 698ad6d33..4ac8a2432 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -5,6 +5,7 @@ import logging import os from random import choice import shutil +import tempfile from string import ascii_letters, digits import time import zipfile @@ -15,7 +16,8 @@ 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.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, \ + SuspiciousFileOperation, ValidationError from django.db import transaction from django.db.models import Max, Q from django.http import HttpResponse, JsonResponse @@ -52,11 +54,12 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm from sapl.norma.models import LegislacaoCitada from sapl.parlamentares.models import Legislatura from sapl.protocoloadm.models import Protocolo -from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT, RATE_LIMITER_RATE +from sapl.settings import MEDIA_ROOT, RATE_LIMITER_RATE from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url, get_client_ip, get_mime_type_from_file_extension, lista_anexados, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, get_tempfile_dir, + restringe_tipos_de_arquivo_txt, validar_arquivo, google_recaptcha_configured, MultiFormatOutputMixin, ratelimit_ip) from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, @@ -72,7 +75,7 @@ from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, ReceberProposicaoForm, RelatoriaForm, TramitacaoEmLoteFilterSet, TramitacaoEmLoteForm, UnidadeTramitacaoForm, StatusTramitacaoFilterSet) -from .models import (AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial, +from .models import (pode_ver_proposicao, AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial, DocumentoAcessorio, MateriaAssunto, MateriaLegislativa, Numeracao, Orgao, Origem, Proposicao, RegimeTramitacao, Relatoria, StatusTramitacao, TipoDocumento, TipoFimRelatoria, TipoMateriaLegislativa, TipoProposicao, @@ -104,14 +107,10 @@ def proposicao_texto(request, pk): username = request.user.username logger.debug('user=' + username + '. Tentando obter objeto Proposicao com pk = {}.'.format(pk)) - proposicao = Proposicao.objects.get(pk=pk) + proposicao = get_object_or_404(Proposicao, pk=pk) if proposicao.texto_original: - if (not proposicao.data_recebimento and - not proposicao.autor.operadores.filter( - id=request.user.id - ).exists() - ): + if not pode_ver_proposicao(request.user, proposicao): logger.error("user=" + username + ". Usuário ({}) não tem permissão para acessar o texto original." .format(request.user.id)) messages.error(request, _( @@ -577,8 +576,8 @@ class ProposicaoRecebida(PermissionRequiredMixin, ListView): return context -class ReceberProposicao(PermissionRequiredForAppCrudMixin, FormView): - app_label = sapl.protocoloadm.apps.AppConfig.label +class ReceberProposicao(PermissionRequiredMixin, FormView): + permission_required = ('materia.detail_proposicao_enviada', ) template_name = "crud/form.html" form_class = ReceberProposicaoForm @@ -661,8 +660,8 @@ class RetornarProposicao(UpdateView): request, *args, **kwargs) -class ConfirmarProposicao(PermissionRequiredForAppCrudMixin, UpdateView): - app_label = sapl.protocoloadm.apps.AppConfig.label +class ConfirmarProposicao(PermissionRequiredMixin, UpdateView): + permission_required = ('materia.detail_proposicao_enviada', ) template_name = "materia/confirmar_proposicao.html" model = Proposicao form_class = ConfirmarProposicaoForm, DevolverProposicaoForm @@ -2330,17 +2329,15 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): messages.add_message(request, messages.ERROR, msg) return self.get(request, self.kwargs) - if request.FILES['arquivo'].size > MAX_DOC_UPLOAD_SIZE: - msg = _("O arquivo Anexo de Texto Integral deve ser menor que {0:.1f} MB, \ - o tamanho atual desse arquivo é {1:.1f} MB" - .format((MAX_DOC_UPLOAD_SIZE / 1024) / 1024, (request.FILES['arquivo'].size / 1024) / 1024)) - messages.add_message(request, messages.ERROR, msg) + arquivo = request.FILES['arquivo'] + try: + validar_arquivo(arquivo, 'Arquivo') + restringe_tipos_de_arquivo_txt(arquivo) + except ValidationError as e: + for m in e.messages: + messages.add_message(request, messages.ERROR, m) return self.get(request, self.kwargs) - tmp_name = os.path.join(MEDIA_ROOT, request.FILES['arquivo'].name) - with open(tmp_name, 'wb') as destination: - for chunk in request.FILES['arquivo'].chunks(): - destination.write(chunk) try: doc_data = tz.localize(datetime.strptime( request.POST['data'], "%d/%m/%Y")) @@ -2350,44 +2347,57 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): messages.add_message(request, messages.ERROR, msg) self.logger.error("User={}. {}. Data inserida: {}".format( username, str(msg), request.POST['data'])) - os.remove(tmp_name) return self.get(request, self.kwargs) - for materia_id in marcadas: - doc = DocumentoAcessorio() - doc.materia_id = materia_id - doc.tipo = tipo - doc.nome = request.POST['nome'] - doc.data = doc_data - doc.autor = request.POST['autor'] - doc.ementa = request.POST['ementa'] - doc.arquivo.name = tmp_name - try: - doc.clean_fields() - except ValidationError as e: - for m in ['%s: %s' % (DocumentoAcessorio()._meta.get_field(k).verbose_name, '
'.join(v)) - for k, v in e.message_dict.items()]: - # Insere as mensagens de erro no formato: - # 'verbose_name do nome do campo': 'mensagem de erro' - messages.add_message(request, messages.ERROR, m) - self.logger.error("User={}. {}. Nome do arquivo: {}.".format( - username, str(msg), request.FILES['arquivo'].name)) - os.remove(tmp_name) - return self.get(request, self.kwargs) - doc.save() - diretorio = os.path.join(MEDIA_ROOT, - 'sapl/public/documentoacessorio', - str(doc_data.year), - str(doc.id)) - if not os.path.exists(diretorio): - os.makedirs(diretorio) - file_path = os.path.join(diretorio, - request.FILES['arquivo'].name) - shutil.copy2(tmp_name, file_path) - doc.arquivo.name = file_path.split( - MEDIA_ROOT + "/")[1] # Retira MEDIA_ROOT do nome - doc.save() - os.remove(tmp_name) + nome_arquivo = os.path.basename(arquivo.name) + if nome_arquivo in ('', '.', '..'): + messages.add_message(request, messages.ERROR, + _('Nome de arquivo inválido.')) + return self.get(request, self.kwargs) + + # Fora de MEDIA_ROOT, para não ficar acessível em /media/ + with tempfile.NamedTemporaryFile(dir=get_tempfile_dir(), + delete=False) as tmp: + for chunk in arquivo.chunks(): + tmp.write(chunk) + try: + for materia_id in marcadas: + doc = DocumentoAcessorio() + doc.materia_id = materia_id + doc.tipo = tipo + doc.nome = request.POST['nome'] + doc.data = doc_data + doc.autor = request.POST['autor'] + doc.ementa = request.POST['ementa'] + try: + doc.clean_fields(exclude=['arquivo']) + except ValidationError as e: + for m in ['%s: %s' % (DocumentoAcessorio()._meta.get_field(k).verbose_name, '
'.join(v)) + for k, v in e.message_dict.items()]: + # Insere as mensagens de erro no formato: + # 'verbose_name do nome do campo': 'mensagem de erro' + messages.add_message(request, messages.ERROR, m) + self.logger.error("User={}. {}. Nome do arquivo: {}.".format( + username, str(m), arquivo.name)) + return self.get(request, self.kwargs) + doc.save() + diretorio = os.path.join(MEDIA_ROOT, + 'sapl/public/documentoacessorio', + str(doc_data.year), + str(doc.id)) + if not os.path.exists(diretorio): + os.makedirs(diretorio) + diretorio = os.path.normpath(diretorio) + file_path = os.path.normpath( + os.path.join(diretorio, nome_arquivo)) + if not file_path.startswith(diretorio + os.sep): + raise SuspiciousFileOperation(nome_arquivo) + shutil.copyfile(tmp.name, file_path) + doc.arquivo.name = file_path.split( + MEDIA_ROOT + "/")[1] # Retira MEDIA_ROOT do nome + doc.save() + finally: + os.remove(tmp.name) msg = _('Documento(s) criado(s).') messages.add_message(request, messages.SUCCESS, msg) diff --git a/sapl/middleware/check_password.py b/sapl/middleware/check_password.py index 329e24909..90bf40635 100644 --- a/sapl/middleware/check_password.py +++ b/sapl/middleware/check_password.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.shortcuts import redirect from django.urls import reverse @@ -14,7 +15,8 @@ class CheckWeakPasswordMiddleware: if request.user.is_authenticated and \ request.session.get('weak_password', False) and \ request.path != reverse('sapl.base:alterar_senha') and \ - request.path != reverse('sapl.base:logout'): + request.path != reverse('sapl.base:logout') and \ + not request.path.startswith(settings.MEDIA_URL): logging.warning(f"Usuário {request.user.username} possui senha fraca.") return redirect('sapl.base:alterar_senha') diff --git a/sapl/protocoloadm/models.py b/sapl/protocoloadm/models.py index 1a169733a..29fd29644 100644 --- a/sapl/protocoloadm/models.py +++ b/sapl/protocoloadm/models.py @@ -6,7 +6,8 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from model_utils import Choices -from sapl.base.models import Autor, AppConfig as SaplAppConfig +from sapl.base.models import Autor, AppConfig as SaplAppConfig, \ + DOC_ADM_OSTENSIVO from sapl.materia.models import TipoMateriaLegislativa, UnidadeTramitacao,\ MateriaLegislativa from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, texto_upload_path, @@ -344,6 +345,13 @@ class DocumentoAdministrativo(models.Model): update_fields=update_fields) +def pode_ver_documento_adm(user, documento): + """Mesmas regras da página de detalhe do documento (CRUD).""" + if SaplAppConfig.attr('documentos_administrativos') == DOC_ADM_OSTENSIVO: + return not (documento.restrito and user.is_anonymous) + return user.has_perm('protocoloadm.detail_documentoadministrativo') + + class DocumentoAcessorioAdministrativo(models.Model): documento = models.ForeignKey(DocumentoAdministrativo, on_delete=models.PROTECT) diff --git a/sapl/protocoloadm/tests/test_docadm_acesso.py b/sapl/protocoloadm/tests/test_docadm_acesso.py new file mode 100644 index 000000000..5f5bf9d0a --- /dev/null +++ b/sapl/protocoloadm/tests/test_docadm_acesso.py @@ -0,0 +1,163 @@ +from io import BytesIO + +from PyPDF4 import PdfFileWriter +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from model_bakery import baker +import pytest + +from sapl.base.models import AppConfig +from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, + DocumentoAdministrativo) + + +@pytest.fixture(autouse=True) +def limpa_cache_appconfig(): + # o cache em arquivo sobrevive ao rollback do teste + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def media_tmp(settings, tmp_path): + settings.MEDIA_ROOT = str(tmp_path) + return tmp_path + + +def _config(comportamento): + cache.clear() + AppConfig.objects.all().delete() + baker.make(AppConfig, documentos_administrativos=comportamento) + + +def _documento(restrito): + documento = baker.make(DocumentoAdministrativo, restrito=restrito) + documento.texto_integral = SimpleUploadedFile( + 'texto.pdf', b'%PDF-1.4 conteudo', content_type='application/pdf') + documento.save() + return documento + + +def _client(client, perfil): + if perfil == 'anonimo': + return client + user = baker.make(get_user_model(), username=perfil) + if perfil == 'com_permissao': + ct = ContentType.objects.get_for_model(DocumentoAdministrativo) + perm, _ = Permission.objects.get_or_create( + codename='detail_documentoadministrativo', content_type=ct, + defaults={'name': 'detail'}) + user.user_permissions.add(perm) + client.force_login(user) + return client + + +ACESSO = [ + # (comportamento, restrito, perfil, pode_ver) + ('O', False, 'anonimo', True), + ('O', False, 'sem_permissao', True), + ('O', False, 'com_permissao', True), + ('O', True, 'anonimo', False), + ('O', True, 'sem_permissao', True), + ('O', True, 'com_permissao', True), + ('R', False, 'anonimo', False), + ('R', False, 'sem_permissao', False), + ('R', False, 'com_permissao', True), + ('R', True, 'anonimo', False), + ('R', True, 'sem_permissao', False), + ('R', True, 'com_permissao', True), +] + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', ACESSO) +def test_doc_texto_integral_acesso(client, media_tmp, comportamento, + restrito, perfil, pode_ver): + _config(comportamento) + documento = _documento(restrito) + client = _client(client, perfil) + + response = client.get(reverse('sapl.protocoloadm:doc_texto_integral', + kwargs={'pk': documento.pk})) + + assert response.status_code == (200 if pode_ver else 404) + + +def _pdf(): + writer = PdfFileWriter() + writer.addBlankPage(72, 72) + data = BytesIO() + writer.write(data) + return data.getvalue() + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', ACESSO) +def test_pdf_docacessorios_acesso(client, media_tmp, monkeypatch, + comportamento, restrito, perfil, pode_ver): + # create_pdf_docacessorios usa o MEDIA_ROOT importado de sapl.settings + monkeypatch.setattr('sapl.protocoloadm.views.MEDIA_ROOT', str(media_tmp)) + _config(comportamento) + documento = baker.make(DocumentoAdministrativo, restrito=restrito) + acessorio = baker.make(DocumentoAcessorioAdministrativo, + documento=documento) + acessorio.arquivo = SimpleUploadedFile( + 'acessorio.pdf', _pdf(), content_type='application/pdf') + acessorio.save() + client = _client(client, perfil) + + response = client.get(reverse('sapl.protocoloadm:merge_docacessorios', + kwargs={'pk': documento.pk})) + + if pode_ver: + assert response.status_code == 200 + assert response['Content-Type'] == 'application/pdf' + assert response.content.startswith(b'%PDF') + else: + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_pdf_docacessorios_sem_acessorios_volta_para_lista(client): + _config('O') + documento = baker.make(DocumentoAdministrativo, restrito=False) + + response = client.get(reverse('sapl.protocoloadm:merge_docacessorios', + kwargs={'pk': documento.pk})) + + assert response.status_code == 302 + assert response['Location'] == reverse( + 'sapl.protocoloadm:documentoacessorioadministrativo_list', + kwargs={'pk': documento.pk}) + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('url_name', ['doc_texto_integral', + 'merge_docacessorios']) +def test_docadm_pk_inexistente(client, url_name): + _config('O') + response = client.get(reverse('sapl.protocoloadm:' + url_name, + kwargs={'pk': 999999})) + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_detalhe_e_arquivo_usam_a_mesma_configuracao(client, media_tmp): + # com mais de uma linha em AppConfig, página e arquivo leem a mesma + _config('O') + baker.make(AppConfig, documentos_administrativos='R') + documento = _documento(False) + + detalhe = client.get(reverse( + 'sapl.protocoloadm:documentoadministrativo_detail', + kwargs={'pk': documento.pk})) + texto = client.get(reverse('sapl.protocoloadm:doc_texto_integral', + kwargs={'pk': documento.pk})) + + assert detalhe.status_code == 302 + assert texto.status_code == 404 diff --git a/sapl/protocoloadm/views.py b/sapl/protocoloadm/views.py index 345382438..575495f4e 100755 --- a/sapl/protocoloadm/views.py +++ b/sapl/protocoloadm/views.py @@ -32,7 +32,8 @@ from django_filters.views import FilterView import sapl from sapl.base.email_utils import do_envia_email_confirmacao -from sapl.base.models import Autor, CasaLegislativa, AppConfig +from sapl.base.models import (Autor, CasaLegislativa, AppConfig, + DOC_ADM_OSTENSIVO) from sapl.comissoes.models import Comissao from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, make_pagination, RP_LIST, RP_DETAIL) @@ -42,7 +43,7 @@ from sapl.parlamentares.models import Legislatura, Parlamentar from sapl.protocoloadm.forms import VinculoDocAdminMateriaForm,\ VinculoDocAdminMateriaEmLoteFilterSet from sapl.protocoloadm.models import Protocolo, DocumentoAdministrativo,\ - VinculoDocAdminMateria + VinculoDocAdminMateria, pode_ver_documento_adm from sapl.relatorios.views import relatorio_doc_administrativos from sapl.utils import (create_barcode, get_base_url, get_client_ip, get_mime_type_from_file_extension, lista_anexados, @@ -101,15 +102,9 @@ def recuperar_materia_protocolo(request): def doc_texto_integral(request, pk): - can_see = True + documento = get_object_or_404(DocumentoAdministrativo, pk=pk) - if not request.user.is_authenticated: - app_config = AppConfig.objects.last() - if app_config and app_config.documentos_administrativos == 'R': - can_see = False - - if can_see: - documento = DocumentoAdministrativo.objects.get(pk=pk) + if pode_ver_documento_adm(request.user, documento): if documento.texto_integral: arquivo = documento.texto_integral @@ -127,6 +122,8 @@ def doc_texto_integral(request, pk): def get_pdf_docacessorios(request, pk): documento_administrativo = get_object_or_404(DocumentoAdministrativo, pk=pk) + if not pode_ver_documento_adm(request.user, documento_administrativo): + raise Http404 logger = logging.getLogger(__name__) username = 'Usuário anônimo' if request.user.is_anonymous else request.user.username try: @@ -137,21 +134,24 @@ def get_pdf_docacessorios(request, pk): logger.error("user= {}.Não há arquivos cadastrados".format(username)) msg = _('Não há arquivos cadastrados nesses documentos acessórios.') messages.add_message(request, messages.ERROR, msg) - return redirect(reverse('sapl.materia:documentoacessorio_list', - kwargs={'pk': pk})) + return redirect(reverse( + 'sapl.protocoloadm:documentoacessorioadministrativo_list', + kwargs={'pk': pk})) except Exception as e: logger.error("user= {}.Um erro inesperado ocorreu na criação do pdf de documentos acessorios: {}" .format(username, str(e))) msg = _('Um erro inesperado ocorreu. Entre em contato com o suporte do SAPL.') messages.add_message(request, messages.ERROR, msg) - return redirect(reverse('sapl.materia:documentoacessorio_list', - kwargs={'pk': pk})) + return redirect(reverse( + 'sapl.protocoloadm:documentoacessorioadministrativo_list', + kwargs={'pk': pk})) if not data: msg = _('Não há nenhum documento acessório PDF cadastrado.') messages.add_message(request, messages.ERROR, msg) - return redirect(reverse('sapl.materia:documentoacessorio_list', - kwargs={'pk': pk})) + return redirect(reverse( + 'sapl.protocoloadm:documentoacessorioadministrativo_list', + kwargs={'pk': pk})) response = HttpResponse(data, content_type='application/pdf') response['Content-Disposition'] = ('attachment; filename="%s"' @@ -401,8 +401,8 @@ class AcompanhamentoDocumentoView(CreateView): class DocumentoAdministrativoMixin: def has_permission(self): - app_config = AppConfig.objects.last() - if app_config and app_config.documentos_administrativos == 'O': + # mesmo acessor de pode_ver_documento_adm, usado nos arquivos + if AppConfig.attr('documentos_administrativos') == DOC_ADM_OSTENSIVO: return True return super().has_permission() @@ -498,7 +498,7 @@ class DocumentoAdministrativoCrud(Crud): def get(self, *args, **kwargs): pk = self.kwargs['pk'] - documento = DocumentoAdministrativo.objects.get(id=pk) + documento = get_object_or_404(DocumentoAdministrativo, pk=pk) if documento.restrito and self.request.user.is_anonymous: return redirect('/') return super(Crud.DetailView, self).get(args, kwargs) diff --git a/sapl/sessao/tests/test_sessao_oculta_api.py b/sapl/sessao/tests/test_sessao_oculta_api.py new file mode 100644 index 000000000..efd66806e --- /dev/null +++ b/sapl/sessao/tests/test_sessao_oculta_api.py @@ -0,0 +1,94 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.utils import timezone +from model_bakery import baker +import pytest + +from sapl.sessao.models import Orador, SessaoPlenaria + + +@pytest.fixture(autouse=True) +def limpa_cache_appconfig(): + # o cache em arquivo sobrevive ao rollback do teste + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def sessoes(): + # só a sessão futura, não iniciada e sem pauta fica oculta do público + previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, + data_inicio=timezone.localdate() + timedelta(days=7)) + iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False) + baker.make(Orador, sessao_plenaria=previa) + baker.make(Orador, sessao_plenaria=iniciada) + return previa, iniciada + + +def _listados(client, endpoint, campo, sessoes): + """Sessões de `sessoes` referenciadas na listagem completa do endpoint.""" + valores, page = set(), 1 + while page: + response = client.get('/api/sessao/%s/?page_size=100&page=%s' % ( + endpoint, page)) + assert response.status_code == 200 + dados = response.json() + valores |= {r[campo] for r in dados['results']} + page = dados['pagination']['next_page'] + return valores & {s.pk for s in sessoes} + + +@pytest.mark.django_db(transaction=False) +def test_api_oculta_sessao_previa_do_anonimo(client, sessoes): + previa, iniciada = sessoes + + assert _listados(client, 'sessaoplenaria', 'id', sessoes) == { + iniciada.pk} + + assert client.get('/api/sessao/sessaoplenaria/%s/' % previa.pk + ).status_code == 404 + assert client.get('/api/sessao/sessaoplenaria/%s/ecidadania/' % previa.pk + ).status_code == 404 + assert client.get('/api/sessao/sessaoplenaria/%s/expedientes/' % previa.pk + ).status_code == 404 + assert client.get('/api/sessao/sessaoplenaria/%s/expedientes/' + % iniciada.pk).status_code == 200 + + assert _listados(client, 'orador', 'sessao_plenaria', sessoes) == { + iniciada.pk} + + +@pytest.mark.django_db(transaction=False) +def test_api_mostra_sessao_previa_ao_usuario_autenticado(client, sessoes): + previa, iniciada = sessoes + client.force_login(baker.make(get_user_model())) + + assert _listados(client, 'sessaoplenaria', 'id', sessoes) == { + previa.pk, iniciada.pk} + assert client.get('/api/sessao/sessaoplenaria/%s/' % previa.pk + ).status_code == 200 + assert _listados(client, 'orador', 'sessao_plenaria', sessoes) == { + previa.pk, iniciada.pk} + + +@pytest.mark.django_db(transaction=False) +def test_media_de_sessao_oculta_do_anonimo(client, settings, sessoes): + settings.DEBUG = False + previa, iniciada = sessoes + orador_previa = Orador.objects.get(sessao_plenaria=previa) + orador_iniciada = Orador.objects.get(sessao_plenaria=iniciada) + + def get(path): + return client.get('/media/sapl/public/' + path).status_code + + assert get('sessaoplenaria/%s/pauta/p.pdf' % previa.pk) == 404 + assert get('sessaoplenaria/%s/pauta/p.pdf' % iniciada.pk) == 200 + assert get('orador/%s/anexo/a.pdf' % orador_previa.pk) == 404 + assert get('orador/%s/anexo/a.pdf' % orador_iniciada.pk) == 200 + + client.force_login(baker.make(get_user_model())) + assert get('sessaoplenaria/%s/pauta/p.pdf' % previa.pk) == 200 + assert get('orador/%s/anexo/a.pdf' % orador_previa.pk) == 200 diff --git a/sapl/test_utils.py b/sapl/test_utils.py index 925b0ce8a..1bb724056 100644 --- a/sapl/test_utils.py +++ b/sapl/test_utils.py @@ -1,4 +1,9 @@ -from .utils import listify +from django.core.exceptions import ValidationError +import pytest + +from .utils import (listify, rejeita_conteudo_ativo, + restringe_tipos_de_arquivo_txt, tipo_ativo, + validar_arquivo) def test_listify(): @@ -8,3 +13,90 @@ def test_listify(): yield 1 yield 2 assert [1, 2] == gen() + + +def _upload(nome, conteudo): + import io + from django.core.files.uploadedfile import InMemoryUploadedFile + return InMemoryUploadedFile(io.BytesIO(conteudo), 'arquivo', nome, None, + len(conteudo), None) + + +def _odt(): + import io + import zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as z: + z.writestr(zipfile.ZipInfo('mimetype'), + 'application/vnd.oasis.opendocument.text') + z.writestr('content.xml', '') + return buf.getvalue() + + +PNG = (b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89') + +ATIVOS = [ + ('x.html', b'%PDF-1.4\n'), + ('x.html', b''), + ('x.svg', b''), + ('x.XHTML', b'%PDF-1.4'), + ('x.xht', b'%PDF-1.4\n'), + ('x.rdf', b'%PDF-1.4'), + ('x.kml', b''), + ('x.pdf', b''), +] + + +@pytest.mark.parametrize('nome,conteudo', ATIVOS) +def test_validar_arquivo_rejeita_conteudo_ativo(nome, conteudo): + with pytest.raises(ValidationError): + validar_arquivo(_upload(nome, conteudo), 'Arquivo') + + +@pytest.mark.parametrize('nome,conteudo', ATIVOS) +def test_restringe_tipos_rejeita_conteudo_ativo(nome, conteudo): + with pytest.raises(ValidationError): + restringe_tipos_de_arquivo_txt(_upload(nome, conteudo)) + + +@pytest.mark.parametrize('nome,conteudo', [ + ('a.pdf', b'%PDF-1.4\n%%EOF'), + ('a.csv', b'nome;valor\nx;1\n'), + ('a.odt', _odt()), + ('a.png', PNG), + ('a.xlsx', b'PK\x03\x04' + b'\x00' * 64), +]) +def test_validar_arquivo_aceita_tipos_comuns(nome, conteudo): + validar_arquivo(_upload(nome, conteudo), 'Arquivo') + + +@pytest.mark.parametrize('nome,conteudo', [ + ('a.pdf', b'%PDF-1.4\n%%EOF'), + ('a.csv', b'nome;valor\nx;1\n'), + ('a.odt', _odt()), +]) +def test_restringe_tipos_aceita_documentos(nome, conteudo): + restringe_tipos_de_arquivo_txt(_upload(nome, conteudo)) + + +def test_validar_arquivo_ignora_arquivo_ja_gravado(): + from django.db.models.fields.files import FieldFile + from sapl.protocoloadm.models import DocumentoAdministrativo + campo = DocumentoAdministrativo._meta.get_field('texto_integral') + antigo = FieldFile(None, campo, + 'sapl/private/documentoadministrativo/x.html') + rejeita_conteudo_ativo(antigo) + + +@pytest.mark.parametrize('mime,ativo', [ + ('text/html', True), ('application/xhtml+xml', True), + ('application/rdf+xml', True), ('text/xml', True), + ('application/javascript', True), ('image/svg+xml', True), + ('application/pdf', False), ('image/png', False), ('text/csv', False), + ('application/vnd.openxmlformats-officedocument.wordprocessingml.document', + False), + (None, False), +]) +def test_tipo_ativo(mime, ativo): + assert tipo_ativo(mime) is ativo diff --git a/sapl/urls.py b/sapl/urls.py index d2967e927..7c1acc3e5 100644 --- a/sapl/urls.py +++ b/sapl/urls.py @@ -19,7 +19,6 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import path from django.views.generic.base import RedirectView, TemplateView -from django.views.static import serve as view_static_server import sapl.api.urls import sapl.audiencia.urls @@ -36,11 +35,14 @@ import sapl.redireciona_urls.urls import sapl.relatorios.urls import sapl.sessao.urls +from sapl.base.media import media from sapl.api.views_health import AppzVersionView, HealthzView, ReadyzView urlpatterns = [] urlpatterns += [ + url(r'^media/(?P.+)$', media, name='media'), + url(r'^message$', TemplateView.as_view(template_name='base.html')), url(r'^admin/', admin.site.urls), @@ -95,12 +97,6 @@ if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - urlpatterns += [ - url(r'^media/(?P.*)$', view_static_server, { - 'document_root': settings.MEDIA_ROOT, - }), - ] - # Make the rate limiter return 429 (Too Many Requests) instead of 403 (Forbidden Access) def custom_permission_denied_view(request, exception=None): diff --git a/sapl/utils.py b/sapl/utils.py index 588494bd5..532594801 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -41,6 +41,7 @@ from django.utils.translation import ugettext_lazy as _ import django_filters from easy_thumbnails import source_generators import magic +import mimetypes import requests from unipath.path import Path from xlsxwriter.workbook import Workbook @@ -133,6 +134,7 @@ def num_materias_por_tipo(qs, attr_tipo='tipo'): def validar_arquivo(arquivo, nome_campo): + rejeita_conteudo_ativo(arquivo) if len(arquivo.name) > 200: raise ValidationError( "Certifique-se de que o nome do arquivo no " @@ -601,6 +603,45 @@ class FilterOverridesMetaMixin: } +# Extensões que o navegador pode executar como página na origem do SAPL. +# Não depender só de mimetypes: sem /etc/mime.types (imagem slim) várias +# destas não são reconhecidas. Manter em sincronia com sapl.conf. +EXTENSOES_ATIVAS = { + '.html', '.htm', '.xhtml', '.xht', '.shtml', '.svg', '.svgz', '.xml', + '.xsl', '.xslt', '.rdf', '.js', '.mjs', '.mht', '.mhtml', '.rss', '.atom', + '.mml', '.kml', '.xspf', +} + +MIMES_ATIVOS = { + 'text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', + 'application/xml', 'application/javascript', 'text/javascript', +} + + +def tipo_ativo(mime): + """MIME que o navegador renderiza como documento capaz de rodar script.""" + if not mime: + return False + mime = mime.lower() + return (mime in MIMES_ATIVOS or 'html' in mime or 'javascript' in mime + or mime.endswith('/xml') or mime.endswith('+xml')) + + +def rejeita_conteudo_ativo(arquivo): + """Recusa uploads que o navegador executaria como página, pela extensão + ou pelo conteúdo. Arquivos já gravados não são checados, para não impedir + a edição de registros antigos.""" + if not isinstance(arquivo, UploadedFile): + return + if os.path.splitext(arquivo.name)[1].lower() in EXTENSOES_ATIVAS or \ + tipo_ativo(mimetypes.guess_type(arquivo.name)[0]): + raise ValidationError(_('Tipo de arquivo não permitido.')) + arquivo.seek(0) + mime = magic.from_buffer(arquivo.read(2048), mime=True) + arquivo.seek(0) + if tipo_ativo(mime): + raise ValidationError(_('Tipo de arquivo não permitido.')) + TIPOS_TEXTO_PERMITIDOS = ( 'application/vnd.oasis.opendocument.text', 'application/x-vnd.oasis.opendocument.text', @@ -621,8 +662,6 @@ TIPOS_TEXTO_PERMITIDOS = ( 'widetext/paragraph', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/xml', - 'text/xml', # Formatos editáveis de planilha/CSV (atende à gravação de relatórios do PNTP). 'text/csv', 'application/csv', @@ -657,6 +696,7 @@ TIPOS_IMG_PERMITIDOS = ( def fabrica_validador_de_tipos_de_arquivo(lista, nome): def restringe_tipos_de_arquivo(value): + rejeita_conteudo_ativo(value) filename = value.name if type(value) in ( InMemoryUploadedFile, TemporaryUploadedFile From 9f70d2985fa59bc3d5eda71e53269d6f814bc634 Mon Sep 17 00:00:00 2001 From: Edward <9326037+edwardoliveira@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:14:13 -0300 Subject: [PATCH 13/17] =?UTF-8?q?Corrige=20travas=20e=20concorr=C3=AAncia?= =?UTF-8?q?=20na=20vota=C3=A7=C3=A3o=20nominal=20do=20painel=20(#3855)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: corrige fluxo de votação nominal no painel * fix: não exibe 'Não votou' no painel para matérias sem voto individual O painel só deve marcar "Não votou" quando a matéria tem voto nominal de fato. Para Leitura, Simbólica, Secreta ou quando não há matéria aberta (ex.: sessão solene), tipo_votacao chega como 'Leitura' ou fica ausente na resposta, e o parlamentar nunca recebe um voto — o que antes caía no fallback de show_voto() e rotulava incorretamente todos os presentes como "Não votou". Co-Authored-By: Claude Sonnet 5 * fix: aplica ajustes da revisão do #3855 na votação nominal - renumera as migrações para 0071/0072, após 0070_presenca_unica_por_sessao - encerramento conta só os presentes ativos e grava 'Não Votou' para quem não votou - vínculo dos votos ao registro e 'Não Votou' via save()/create(), para ficarem no AuditLog - fechar outras votações usa save(update_fields=...), zera registro_aberto e fica no AuditLog - abertura simultânea mostra erro em vez de 500; "foram fechadas" só após commit - poll do tablet não acumula mensagens; voto_individual.html exibe messages - poll do tablet recarrega em resposta não-JSON e não sobrepõe requisições - remove timers duplicados de conta_votos e o hidden obsoleto em nominal.html - painel só mostra "Não votou" após o registro da votação - "Registrar Votação" volta a ser GET - 0071 carrega só os grupos duplicados; 0072 zera registro_aberto legado - usa get_or_create diretamente e remove código morto Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- sapl/api/serializers.py | 26 +- sapl/api/tests/test_serializers_sessao.py | 19 + sapl/painel/tests/tests.py | 365 +++++++++- sapl/painel/urls.py | 5 +- sapl/painel/views.py | 260 ++++--- sapl/sessao/admin.py | 31 + sapl/sessao/forms.py | 4 - .../0071_votoparlamentar_unique_constraint.py | 54 ++ .../0072_votacao_aberta_unique_constraint.py | 45 ++ sapl/sessao/models.py | 32 + sapl/sessao/tests/test_votacao_nominal.py | 689 ++++++++++++++++++ sapl/sessao/views.py | 595 +++++++-------- sapl/templates/painel/index.html | 22 +- sapl/templates/painel/voto_individual.html | 71 +- sapl/templates/sessao/votacao/nominal.html | 76 +- 15 files changed, 1866 insertions(+), 428 deletions(-) create mode 100644 sapl/api/tests/test_serializers_sessao.py create mode 100644 sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py create mode 100644 sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py create mode 100644 sapl/sessao/tests/test_votacao_nominal.py diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index b8bb06b4d..f4a82db35 100644 --- a/sapl/api/serializers.py +++ b/sapl/api/serializers.py @@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField from sapl.base.models import Autor, CasaLegislativa, Metadata from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura -from sapl.sessao.models import OrdemDia, SessaoPlenaria +from sapl.sessao.models import ExpedienteMateria, OrdemDia, SessaoPlenaria class SaplSerializerMixin(DrfAutoApiSerializerMixin): @@ -317,5 +317,25 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer): return self.casa().nome def casa(self): - casa = CasaLegislativa.objects.first() - return casa + return CasaLegislativa.objects.first() + + +class OrdemDiaSerializer(SaplSerializerMixin): + """ + votacao_aberta/registro_aberto só podem ser alterados através do fluxo + de abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que + garante a invariante de no máximo uma matéria aberta por vez. Editáveis + aqui, a API contornaria essa garantia por completo. + """ + class Meta: + model = OrdemDia + fields = '__all__' + read_only_fields = ('votacao_aberta', 'registro_aberto') + + +class ExpedienteMateriaSerializer(SaplSerializerMixin): + """Ver OrdemDiaSerializer — mesmo motivo para os mesmos dois campos.""" + class Meta: + model = ExpedienteMateria + fields = '__all__' + read_only_fields = ('votacao_aberta', 'registro_aberto') diff --git a/sapl/api/tests/test_serializers_sessao.py b/sapl/api/tests/test_serializers_sessao.py new file mode 100644 index 000000000..69e8d7259 --- /dev/null +++ b/sapl/api/tests/test_serializers_sessao.py @@ -0,0 +1,19 @@ +import pytest +from model_bakery import baker + +from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer +from sapl.base.models import CasaLegislativa +from sapl.sessao.models import SessaoPlenaria + + +@pytest.mark.django_db(transaction=False) +def test_sessao_ecidadania_serializa_dados_da_casa(): + casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM', + endereco='Praça Central') + sessao = baker.make(SessaoPlenaria) + + data = SessaoPlenariaECidadaniaSerializer(sessao).data + + assert data['txtNomeOrgao'] == casa.nome + assert data['txtSiglaOrgao'] == casa.sigla + assert data['txtLocal'] == casa.endereco diff --git a/sapl/painel/tests/tests.py b/sapl/painel/tests/tests.py index a79ca8be5..dd7753d18 100644 --- a/sapl/painel/tests/tests.py +++ b/sapl/painel/tests/tests.py @@ -1,3 +1,364 @@ -# from django.test import TestCase +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import Client +from django.urls import reverse +from model_bakery import baker -# Create your tests here. +from sapl.base.models import AppConfig as ConfiguracoesAplicacao +from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa +from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar, + SessaoLegislativa, Votante) +from sapl.sessao.models import (ExpedienteMateria, OrdemDia, + PresencaOrdemDia, SessaoPlenaria, + TipoResultadoVotacao, TipoSessaoPlenaria, + VotoParlamentar) + +NOMINAL = 2 +LEITURA = 4 + + +def _sessao_plenaria(): + legislatura = baker.make(Legislatura) + sessao_legislativa = baker.make(SessaoLegislativa) + tipo = baker.make(TipoSessaoPlenaria) + return baker.make(SessaoPlenaria, legislatura=legislatura, + sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) + + +def _materia(): + tipo_materia = baker.make(TipoMateriaLegislativa) + return baker.make(MateriaLegislativa, tipo=tipo_materia) + + +def _ordem_nominal_aberta(registro_aberto=False): + sessao = _sessao_plenaria() + materia = _materia() + ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, + tipo_votacao=NOMINAL, votacao_aberta=True, + registro_aberto=registro_aberto) + return sessao, ordem + + +def _ordem_leitura_aberta(): + sessao = _sessao_plenaria() + materia = _materia() + ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, + tipo_votacao=LEITURA, votacao_aberta=True) + return sessao, ordem + + +def _votante(sessao, admin_user): + parlamentar = baker.make(Parlamentar, ativo=True) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) + baker.make(Votante, parlamentar=parlamentar, user=admin_user) + return parlamentar + + +def _votante_com_client(sessao): + """ + Como _votante(), mas cria seu próprio usuário (não-superuser, só com a + permissão parlamentares.can_vote) e devolve um Client logado separado — + necessário para testar a corrida entre o operador e o vereador, que + precisam ser duas sessões/usuários distintos. + """ + parlamentar = baker.make(Parlamentar, ativo=True) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) + user = get_user_model().objects.create_user( + username='votante-{}'.format(parlamentar.pk), password='x') + user.user_permissions.add(Permission.objects.get(codename='can_vote')) + baker.make(Votante, parlamentar=parlamentar, user=user) + client = Client() + client.force_login(user) + return parlamentar, client + + +def _registrar_url(sessao, ordem): + return reverse('sapl.sessao:votacaonominal', + kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_envia_headers_never_cache(admin_client, admin_user): + sessao, ordem = _ordem_nominal_aberta() + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'no-store' in response['Cache-Control'] + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user): + sessao, ordem = _ordem_nominal_aberta(registro_aberto=False) + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'error_message' not in response.context + assert response.context['materia'] == ordem.materia + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user): + """ + Regressão da causa raiz #1: quando a Mesa bloqueia novos votos + (registro_aberto=True), o vereador que ainda não votou precisa ver uma + mensagem explícita — não uma tela em branco sem explicação. + """ + sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message'] + + +@pytest.mark.django_db(transaction=False) +def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client): + """ + Invariante: o voto do próprio vereador sempre prevalece sobre qualquer + valor provisório já existente para ele (ex.: de uma tentativa anterior + do operador, ou de qualquer outra origem). Complementa + test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a + ordem inversa: o operador não pode sobrescrever um voto real já + registrado) — aqui é o vereador votando por cima de um valor existente + através da view de verdade, não apenas o estado inicial simulado. + """ + sessao, ordem = _ordem_nominal_aberta() + vereador, votante_client = _votante_com_client(sessao) + + # 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o + # , não um conflito — não pode gerar o aviso de não sobrescrito. + avisos = [str(m) for m in get_messages(response.wsgi_request)] + assert not any('não foram sobrescritos' in a for a in avisos) + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_avisa_quando_voto_do_tablet_diverge_do_formulario(admin_client): + sessao, ordem = _ordem_nominal_aberta() + ja_votou = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim') + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + response = admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Não:{}'.format(ja_votou.pk)], + }) + + assert response.status_code == 302 + assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou).voto == 'Sim' + avisos = [str(m) for m in get_messages(response.wsgi_request)] + assert any('não foram sobrescritos' in a for a in avisos) + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_registra_nao_votou_para_presentes_sem_voto(admin_client): + """ + Relatórios, transparência e o painel leem VotoParlamentar por votação: + quem estava presente e não votou precisa ficar registrado como + 'Não Votou', vinculado ao RegistroVotacao. + """ + sessao, ordem = _ordem_nominal_aberta() + votou = _presente(sessao) + nao_votou = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + response = admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': [ + 'Sim:{}'.format(votou.pk), + 'Não Votou:{}'.format(nao_votou.pk), + ], + }) + + assert response.status_code == 302 + registro = RegistroVotacao.objects.get(ordem=ordem) + assert registro.numero_votos_sim == 1 + voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou) + assert voto.voto == 'Não Votou' + assert voto.votacao_id == registro.id + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_registra_votos_no_auditlog(admin_client): + """ + O AuditLog é gravado por post_save; o vínculo dos votos ao registro e + as linhas 'Não Votou' precisam passar por save() para ficarem auditados. + """ + sessao, ordem = _ordem_nominal_aberta() + votou = _presente(sessao) + nao_votou = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': [ + 'Sim:{}'.format(votou.pk), + 'Não Votou:{}'.format(nao_votou.pk), + ], + }) + + registro = RegistroVotacao.objects.get(ordem=ordem) + voto_sim = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votou) + voto_nao_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou) + logs = AuditLog.objects.filter(model_name='VotoParlamentar') + vinculo = logs.filter(operation='U', object_id=voto_sim.pk).first() + assert vinculo is not None + assert vinculo.data['fields']['votacao'] == registro.pk + criacao = logs.get(operation='C', object_id=voto_nao_votou.pk) + assert criacao.data['fields']['voto'] == 'Não Votou' + assert criacao.data['fields']['votacao'] == registro.pk + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_ignora_parlamentar_inativo_presente(admin_client): + """ + O formulário da Mesa só lista parlamentares ativos; um inativo marcado + como presente não pode ganhar 'Não Votou' nem entrar no total. + """ + sessao, ordem = _ordem_nominal_aberta() + votou = _presente(sessao) + inativo = _presente(sessao) + inativo.ativo = False + inativo.save() + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + response = admin_client.get(_registrar_url(sessao, ordem)) + assert response.context['total'] == 1 + + admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Sim:{}'.format(votou.pk)], + }) + + assert RegistroVotacao.objects.filter(ordem=ordem).exists() + assert not VotoParlamentar.objects.filter( + ordem=ordem, parlamentar=inativo).exists() + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_ignora_voto_de_quem_teve_presenca_removida(admin_client): + """ + Parlamentar vota pelo tablet, sai e tem a presença removida antes do + encerramento: o voto não entra na contagem e é apagado. + """ + sessao, ordem = _ordem_nominal_aberta() + presente = _presente(sessao) + saiu = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim') + PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete() + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R') + + response = admin_client.post(_registrar_url(sessao, ordem), { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Não:{}'.format(presente.pk)], + }) + + assert response.status_code == 302 + registro = RegistroVotacao.objects.get(ordem=ordem) + assert registro.numero_votos_sim == 0 + assert registro.numero_votos_nao == 1 + assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists() + + +@pytest.mark.django_db(transaction=False) +def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client): + """ + Regressão: fechar a votação sem nenhum voto real corretamente mostra um + erro, mas antes disso o laço de salvamento em lote criava um + VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select + não foi tocado (o valor padrão do de qualquer parlamentar com um VotoParlamentar + existente — travando o operador para sempre sem conseguir registrar + nenhum voto para essa matéria. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') + + url = _registrar_url(sessao, ordem) + payload = { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)], + } + + response = admin_client.post(url, payload) + assert response.status_code == 302 + + assert not VotoParlamentar.objects.filter( + ordem=ordem, parlamentar=parlamentar).exists() + + ordem.refresh_from_db() + assert ordem.votacao_aberta is True + + # O operador consegue tentar de novo, agora com um voto real. + response2 = admin_client.post(url, { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], + }) + assert response2.status_code == 302 + ordem.refresh_from_db() + assert ordem.votacao_aberta is False + assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim' + + +@pytest.mark.django_db(transaction=False) +def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client): + """ + Regressão: a tela de registro (nominal.html) não atualizava a linha de + um parlamentar que trocou o voto durante a janela de votação — o poll + antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll + (?status=1) precisa sempre devolver o voto atual, não só se existe. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, + voto='Sim') + + url = _registrar_url(sessao, ordem) + '?status=1' + response = admin_client.get(url) + assert response.status_code == 200 + data = response.json() + assert data['votos'] == {str(parlamentar.pk): 'Sim'} + assert data['votacao_aberta'] is True + assert data['registro_aberto'] is False + assert data['ja_registrada'] is False + + voto.voto = 'Não' + voto.save() + + response2 = admin_client.get(url) + assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'} + + +@pytest.mark.django_db(transaction=False) +def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client): + """ + O poll da tela de registro é só para a Mesa, não para o público — ao + contrário de sapl.painel:dados_painel, ele não pode mascarar o valor + real do voto por trás de "Voto Informado" mesmo quando a Casa configura + mostrar_voto=False (essa config controla o telão público, não a tela de + registro da própria Mesa). Como o endpoint nem consulta essa + configuração, isso é garantido por construção — este teste só + documenta a expectativa. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, + voto='Abstenção') + + url = _registrar_url(sessao, ordem) + '?status=1' + data = admin_client.get(url).json() + assert data['votos'][str(parlamentar.pk)] == 'Abstenção' + + +@pytest.mark.django_db(transaction=False) +def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao( + admin_client): + """ + _get_materia_votacao (usado pelo GET normal) redireciona com uma + mensagem quando a matéria já foi votada — comportamento certo para + navegação, errado para um poll em background. O branch ?status=1 não + pode herdar esse redirect. + """ + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', + natureza='A') + + url = _registrar_url(sessao, ordem) + admin_client.post(url, { + 'salvar-votacao': '1', + 'resultado_votacao': str(tipo_resultado.pk), + 'observacao': '', + 'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], + }) + + response = admin_client.get(url + '?status=1') + assert response.status_code == 200 + data = response.json() + assert data['ja_registrada'] is True + assert data['votacao_aberta'] is False + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_ja_aberta_e_idempotente(admin_client): + """ + Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta + (ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão + por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata + a própria matéria como uma "outra" votação conflitante — mostra a + mensagem "existem votações abertas... foram fechadas" e fecha a matéria + (para reabri-la em seguida, já que o código sempre prossegue). O + resultado final até ficava certo (votacao_aberta=True), mas a mensagem + era enganosa. Reabrir a própria matéria já aberta precisa ser + silenciosamente idempotente. + """ + sessao, ordem = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + url += '?tipo_materia=ordem' + + response = admin_client.get(url, follow=True) + + assert response.status_code == 200 + ordem.refresh_from_db() + assert ordem.votacao_aberta is True + + mensagens = [str(m) for m in response.context['messages']] + assert not any('foram fechadas' in m for m in mensagens) + + +def _abrir_votacao_url(sessao, ordem): + return reverse('sapl.sessao:abrir_votacao', + kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem' + + +@pytest.mark.django_db(transaction=False) +def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client): + """ + Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets + bloqueados (registro_aberto=True) quando for reaberta. + """ + sessao, x = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + x.registro_aberto = True + x.save() + y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), + tipo_votacao=NOMINAL, votacao_aberta=False) + + admin_client.get(_abrir_votacao_url(sessao, y)) + + x.refresh_from_db() + y.refresh_from_db() + assert y.votacao_aberta is True + assert x.votacao_aberta is False + assert x.registro_aberto is False + + # Legado: matéria fechada que ficou com registro_aberto=True. + OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True) + admin_client.get(_abrir_votacao_url(sessao, x)) + + x.refresh_from_db() + assert x.votacao_aberta is True + assert x.registro_aberto is False + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client): + """ + O lock de abrir_votacao() é por sessão, mas a unicidade de + votacao_aberta é global: uma abertura simultânea em outra sessão faz o + save cair no índice parcial. O fechamento das outras votações é + desfeito junto, então a mensagem "foram fechadas" não pode aparecer. + """ + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + _, outra = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError): + response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) + + assert response.status_code == 200 + ordem.refresh_from_db() + outra.refresh_from_db() + assert ordem.votacao_aberta is False + assert outra.votacao_aberta is True + mensagens = [str(m) for m in response.context['messages']] + assert any('aberta simultaneamente' in m for m in mensagens) + assert not any('foram fechadas' in m for m in mensagens) + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client): + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + _, outra = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) + + outra.refresh_from_db() + assert outra.votacao_aberta is False + mensagens = [str(m) for m in response.context['messages']] + assert any('foram fechadas' in m for m in mensagens) + + +@pytest.mark.django_db(transaction=False) +def test_abrir_votacao_audita_fechamento_das_outras(admin_client): + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + _, outra = _ordem_nominal_aberta() + sessao.iniciada = True + sessao.finalizada = False + sessao.save() + _presente(sessao) + + admin_client.get(_abrir_votacao_url(sessao, ordem)) + + fechamento = AuditLog.objects.filter( + model_name='OrdemDia', operation='U', object_id=outra.pk).first() + assert fechamento is not None + assert fechamento.data['fields']['votacao_aberta'] is False + + +@pytest.mark.django_db(transaction=False) +def test_unique_constraint_impede_voto_duplicado(admin_client): + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim') + + with pytest.raises(IntegrityError): + VotoParlamentar.objects.create( + ordem=ordem, parlamentar=parlamentar, voto='Não') + + +@pytest.mark.django_db(transaction=False) +def test_unique_constraint_impede_duas_ordens_abertas(): + sessao, ordem = _ordem_nominal_aberta() + outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia, + tipo_votacao=NOMINAL, votacao_aberta=False) + + with pytest.raises(IntegrityError): + outra.votacao_aberta = True + outra.save() + + +@pytest.mark.django_db(transaction=False) +def test_migracao_0071_remove_votos_duplicados_antes_da_constraint(): + import importlib + + from django.apps import apps as real_apps + from django.db import connection + + migracao = importlib.import_module( + 'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint') + + with connection.cursor() as cursor: + cursor.execute( + 'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem') + + sessao, ordem = _ordem_nominal_aberta() + parlamentar = _presente(sessao) + antigo = baker.make(VotoParlamentar, ordem=ordem, + parlamentar=parlamentar, voto='Não') + recente = baker.make(VotoParlamentar, ordem=ordem, + parlamentar=parlamentar, voto='Sim') + + migracao.remove_votos_duplicados(real_apps, None) + + votos = VotoParlamentar.objects.filter( + ordem=ordem, parlamentar=parlamentar) + assert list(votos.values_list('id', flat=True)) == [recente.id] + assert not votos.filter(id=antigo.id).exists() + + +@pytest.mark.django_db(transaction=False) +def test_migracao_0072_fecha_duplicatas_antes_da_constraint(): + """ + A função de dados da migration 0072 precisa fechar duplicatas + pré-existentes antes do AddConstraint — senão a migration falharia ao + ser aplicada num banco com dado antigo (de antes desta invariante + existir). Testa a função isoladamente: como o teste roda dentro de uma + transação que é desfeita no final, é seguro derrubar o índice aqui + (DDL é transacional no Postgres). + """ + import importlib + + from django.apps import apps as real_apps + from django.db import connection + + migracao = importlib.import_module( + 'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint') + + with connection.cursor() as cursor: + cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') + + sessao, mais_antiga = _ordem_nominal_aberta() + mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao, + materia=mais_antiga.materia, tipo_votacao=NOMINAL, + votacao_aberta=True, registro_aberto=True) + assert mais_recente.pk > mais_antiga.pk + # Legado: o GET antigo da tela de registro deixava registro_aberto=True + # em matérias já encerradas. + encerrada = baker.make(OrdemDia, sessao_plenaria=sessao, + materia=mais_antiga.materia, tipo_votacao=NOMINAL, + votacao_aberta=False, registro_aberto=True) + + migracao.fecha_materias_abertas_duplicadas(real_apps, None) + + mais_antiga.refresh_from_db() + mais_recente.refresh_from_db() + encerrada.refresh_from_db() + assert mais_antiga.votacao_aberta is False + assert mais_recente.votacao_aberta is True + assert mais_recente.registro_aberto is False + assert encerrada.registro_aberto is False + + +@pytest.mark.django_db(transaction=False) +def test_api_nao_permite_abrir_votacao_via_patch(admin_client): + """ + Regressão do bypass encontrado na auditoria: a API auto-gerada + (drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto — + senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma + matéria via PATCH direto, pulando verifica_votacoes_abertas() e o + lock em abrir_votacao(). + """ + sessao, ordem = _ordem_nominal_aberta() + ordem.votacao_aberta = False + ordem.save() + + url = '/api/sessao/ordemdia/{}/'.format(ordem.pk) + response = admin_client.patch( + url, data={'votacao_aberta': True}, content_type='application/json') + + assert response.status_code in (200, 202) + ordem.refresh_from_db() + assert ordem.votacao_aberta is False diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 2c1df1442..261d9e4ae 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -9,6 +9,7 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError, transaction from django.db.models import Max, Q from django.http import JsonResponse from django.http.response import Http404, HttpResponseRedirect @@ -52,7 +53,7 @@ from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip, MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip from .forms import (AdicionarVariasMateriasFilterSet, AdicionarVariasMateriasForm, BancadaForm, - ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, ListMateriaForm, + ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, MesaForm, OradorExpedienteForm, OradorForm, PautaSessaoFilterSet, PresencaForm, ResumoOrdenacaoForm, SessaoPlenariaFilterSet, SessaoPlenariaForm, VotacaoEditForm, VotacaoForm, @@ -128,7 +129,12 @@ def verifica_presenca(request, model, spk, is_leitura=False): return True -def verifica_votacoes_abertas(request): +def verifica_votacoes_abertas(request, mensagens): + """ + Fecha as votações abertas. A mensagem para o usuário vai para + `mensagens`, e o chamador só a registra depois do commit: se a transação + for desfeita, as votações não foram fechadas. + """ votacoes_abertas = SessaoPlenaria.objects.filter( Q(ordemdia__votacao_aberta=True) | Q(expedientemateria__votacao_aberta=True)).distinct() @@ -147,18 +153,16 @@ def verifica_votacoes_abertas(request): ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') - messages.add_message(request, messages.INFO, msg) + mensagens.append(msg) - for sessao in votacoes_abertas: - ordens = sessao.ordemdia_set.filter(votacao_aberta=True) - expediente = sessao.expedientemateria_set.filter( - votacao_aberta=True) - for o in ordens: - o.votacao_aberta = False - o.save() - for e in expediente: - e.votacao_aberta = False - e.save() + # save(update_fields=...) só grava estes dois campos, sem regravar + # um resultado que um "Encerrar Votação" concorrente tenha acabado + # de salvar, e ainda dispara os sinais que gravam o AuditLog. + for model in (OrdemDia, ExpedienteMateria): + for materia in model.objects.filter(votacao_aberta=True): + materia.votacao_aberta = False + materia.registro_aberto = False + materia.save(update_fields=['votacao_aberta', 'registro_aberto']) return True @@ -199,26 +203,83 @@ def abrir_votacao(request, pk, spk): if not model: raise Http404() - query_params = "?" - materia_votacao = model.objects.get(id=pk) is_leitura = materia_votacao.tipo_votacao == 4 - if (verifica_presenca(request, presenca_model, spk, is_leitura) and - verifica_votacoes_abertas(request) and - verifica_sessao_iniciada(request, spk, is_leitura)): - materia_votacao.votacao_aberta = True - sessao = SessaoPlenaria.objects.get(id=spk) - sessao.painel_aberto = True - sessao.save() - materia_votacao.save() + is_expediente = model is ExpedienteMateria + opened = False + mensagens = [] + try: + with transaction.atomic(): + # select_for_update trava a linha da SessaoPlenaria durante toda a + # checagem+fechamento+abertura, para que dois "abrir votação" + # concorrentes (duplo clique, ou uma requisição lenta seguida de + # nova tentativa) não deixem duas matérias com votacao_aberta=True + # ao mesmo tempo — sapl/painel/views.py::votacao_aberta() trata + # esse caso redirecionando todos os tablets sem nenhuma mensagem + # clara de erro. + SessaoPlenaria.objects.select_for_update().get(id=spk) + # Reflete o estado mais atual sob o lock: outra requisição + # concorrente pode ter mudado votacao_aberta entre o SELECT inicial + # (antes do lock) e aqui. + materia_votacao.refresh_from_db() + ja_aberta = materia_votacao.votacao_aberta + + # Reabrir a própria matéria que já está aberta precisa ser + # idempotente: verifica_votacoes_abertas() existe para fechar + # OUTRAS matérias concorrentes, e sua mensagem ("já existem + # votações abertas... foram fechadas") não faz sentido quando a + # única "conflitante" é ela mesma. + if (verifica_presenca(request, presenca_model, spk, is_leitura) and + (ja_aberta or verifica_votacoes_abertas(request, mensagens)) and + verifica_sessao_iniciada(request, spk, is_leitura)): + materia_votacao.votacao_aberta = True + if not ja_aberta: + materia_votacao.registro_aberto = False + sessao = SessaoPlenaria.objects.get(id=spk) + sessao.painel_aberto = True + sessao.save() + materia_votacao.save() + opened = True + for msg in mensagens: + messages.add_message(request, messages.INFO, msg) + except IntegrityError: + # O lock acima é por sessão, mas a unicidade de votacao_aberta é + # global: uma abertura simultânea em outra sessão cai no índice + # parcial. + opened = False + messages.add_message(request, messages.ERROR, _( + 'Outra votação foi aberta simultaneamente. Tente novamente.')) + + if opened: + # Leva direto para a tela de registro do tipo de votação recém + # aberta, em vez de voltar para a lista — sem isso, o usuário + # precisa de um segundo clique em "Registrar Votação"/"Registrar + # Leitura" (que só aparece depois que a lista recarrega) para + # chegar aonde queria. + registro_view_names_ordem = { + SIMBOLICA: 'votacaosimbolica', NOMINAL: 'votacaonominal', + SECRETA: 'votacaosecreta', LEITURA: 'leituraod', + } + registro_view_names_expediente = { + SIMBOLICA: 'votacaosimbolicaexp', NOMINAL: 'votacaonominalexp', + SECRETA: 'votacaosecretaexp', LEITURA: 'leituraexp', + } + registro_view_names = (registro_view_names_expediente if is_expediente + else registro_view_names_ordem) + registro_view_name = registro_view_names[materia_votacao.tipo_votacao] + success_url = reverse('sapl.sessao:' + registro_view_name, kwargs={ + 'pk': spk, 'oid': materia_votacao.pk, + 'mid': materia_votacao.materia_id}) + if 'page' in request.GET: + success_url += '?page={}'.format(request.GET['page']) + else: + query_params = "?" if 'page' in request.GET: query_params += 'page={}&'.format(request.GET['page']) - query_params += "#id{}".format(materia_votacao.materia.id) - - success_url = reverse('sapl.sessao:' + redirect_url, kwargs={'pk': spk}) - success_url += query_params + success_url = reverse('sapl.sessao:' + redirect_url, + kwargs={'pk': spk}) + query_params return HttpResponseRedirect(success_url) @@ -1578,112 +1639,6 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView): return reverse('sapl.sessao:presencaordemdia', kwargs={'pk': pk}) -class ListMateriaOrdemDiaView(FormMixin, DetailView): - template_name = 'sessao/materia_ordemdia_list.html' - form_class = ListMateriaForm - model = SessaoPlenaria - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - context = self.get_context_data(object=self.object) - - pk = self.kwargs['pk'] - ordem = OrdemDia.objects.filter(sessao_plenaria_id=pk) - - materias_ordem = [] - for o in ordem: - ementa = o.materia.ementa - titulo = o.materia - numero = o.numero_ordem - - autoria = Autoria.objects.filter(materia_id=o.materia_id) - autor = [str(a.autor) for a in autoria] - - mat = {'pk': pk, - 'oid': o.id, - 'ordem_id': o.materia_id, - 'ementa': ementa, - 'titulo': titulo, - 'numero': numero, - 'resultado': o.resultado, - 'autor': autor, - 'votacao_aberta': o.votacao_aberta, - 'tipo_votacao': o.tipo_votacao - } - materias_ordem.append(mat) - - sorted(materias_ordem, key=lambda x: x['numero']) - - context.update({'materias_ordem': materias_ordem}) - - return self.render_to_response(context) - - @method_decorator(permission_required('sessao.change_ordemdia')) - def post(self, request, *args, **kwargs): - self.object = self.get_object() - context = self.get_context_data(object=self.object) - - pk = self.kwargs['pk'] - form = ListMateriaForm(request.POST) - - # TODO: Existe uma forma de atualizar em lote de acordo - # com a forma abaixo, mas como setar o primeiro para "1"? - # OrdemDia.objects.filter(sessao_plenaria_id=pk) - # .order_by('numero_ordem').update(numero_ordem=3) - - if 'materia_reorder' in request.POST: - ordens = OrdemDia.objects.filter(sessao_plenaria_id=pk) - ordem_num = 1 - for o in ordens: - o.numero_ordem = ordem_num - o.save() - ordem_num += 1 - elif 'abrir-votacao' in request.POST: - existe_votacao_aberta = OrdemDia.objects.filter( - sessao_plenaria_id=pk, votacao_aberta=True).exists() - if existe_votacao_aberta: - context = self.get_context_data(object=self.object) - - form._errors = {'error_message': 'error_message'} - context.update({'form': form}) - - pk = self.kwargs['pk'] - ordem = OrdemDia.objects.filter(sessao_plenaria_id=pk) - - materias_ordem = [] - for o in ordem: - ementa = o.materia.ementa - titulo = o.materia - numero = o.numero_ordem - - autoria = Autoria.objects.filter(materia_id=o.materia_id) - autor = [str(a.autor) for a in autoria] - - mat = {'pk': pk, - 'oid': o.id, - 'ordem_id': o.materia_id, - 'ementa': ementa, - 'titulo': titulo, - 'numero': numero, - 'resultado': o.resultado, - 'autor': autor, - 'votacao_aberta': o.votacao_aberta, - 'tipo_votacao': o.tipo_votacao - } - materias_ordem.append(mat) - - sorted(materias_ordem, key=lambda x: x['numero']) - context.update({'materias_ordem': materias_ordem}) - return self.render_to_response(context) - else: - ordem_id = request.POST['ordem_id'] - ordem = OrdemDia.objects.get(id=ordem_id) - ordem.votacao_aberta = True - ordem.registro_aberto = False - ordem.save() - return self.get(self, request, args, kwargs) - - class MesaView(FormMixin, DetailView): template_name = 'sessao/mesa.html' form_class = MesaForm @@ -2985,7 +2940,15 @@ class VotacaoNominalAbstract(SessaoPermissionMixin): logger = logging.getLogger(__name__) - def get(self, request, *args, **kwargs): + def _get_materia_votacao(self, request, kwargs): + """ + Resolve a OrdemDia/ExpedienteMateria sendo registrada, sem nenhum + efeito colateral (não altera registro_aberto nem qualquer outro + estado) — apenas consulta. Retorna + (materia_votacao, presentes, total, redirect); quando a matéria não + pode ser exibida (já votada ou com a votação fechada), os três + primeiros valores são None e `redirect` é a resposta a devolver. + """ username = request.user.username if self.ordem: ordem_id = kwargs['oid'] @@ -2994,33 +2957,28 @@ class VotacaoNominalAbstract(SessaoPermissionMixin): messages.add_message(request, messages.ERROR, msg) self.logger.info( 'user=' + username + '. Matéria (ordem_id={}) já votada!'.format(ordem_id)) - return HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']})) try: - ordem = OrdemDia.objects.get(id=ordem_id) + materia_votacao = OrdemDia.objects.get(id=ordem_id) except ObjectDoesNotExist: self.logger.error( 'user=' + username + '. Objeto OrdemDia (pk={}) não existe.'.format(ordem_id)) raise Http404() presentes = PresencaOrdemDia.objects.filter( - sessao_plenaria_id=ordem.sessao_plenaria_id) - total = presentes.count() + sessao_plenaria_id=materia_votacao.sessao_plenaria_id, + parlamentar__ativo=True) - materia_votacao = ordem - - if not ordem.votacao_aberta: + if not materia_votacao.votacao_aberta: self.logger.error( 'user=' + username + '. A votação para esta OrdemDia (id={}) encontra-se fechada!'.format(ordem_id)) msg = _('A votação para esta matéria encontra-se fechada!') messages.add_message(request, messages.ERROR, msg) - return HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:ordemdia_list', kwargs={'pk': kwargs['pk']})) - ordem.registro_aberto = True - ordem.save() - elif self.expediente: expediente_id = kwargs['oid'] if (RegistroVotacao.objects.filter( @@ -3029,194 +2987,263 @@ class VotacaoNominalAbstract(SessaoPermissionMixin): "user=" + username + ". RegistroVotacao (expediente_id={}) já existe.".format(expediente_id)) msg = _('Esta matéria já foi votada!') messages.add_message(request, messages.ERROR, msg) - return HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:expedientemateria_list', kwargs={'pk': kwargs['pk']})) try: self.logger.debug( "user=" + username + ". Tentando obter Objeto ExpedienteMateria com id={}.".format(expediente_id)) - expediente = ExpedienteMateria.objects.get(id=expediente_id) + materia_votacao = ExpedienteMateria.objects.get(id=expediente_id) except ObjectDoesNotExist: self.logger.error( 'user=' + username + '. Objeto ExpedienteMateria com id={} não existe.'.format(expediente_id)) raise Http404() presentes = SessaoPlenariaPresenca.objects.filter( - sessao_plenaria_id=expediente.sessao_plenaria_id) - total = presentes.count() + sessao_plenaria_id=materia_votacao.sessao_plenaria_id, + parlamentar__ativo=True) - materia_votacao = expediente - - if not expediente.votacao_aberta: + if not materia_votacao.votacao_aberta: msg = _( 'A votação para este ExpedienteMateria (id={}) encontra-se fechada!'.format(expediente_id)) messages.add_message(request, messages.ERROR, msg) - return HttpResponseRedirect(reverse( + return None, None, None, HttpResponseRedirect(reverse( 'sapl.sessao:expedientemateria_list', kwargs={'pk': kwargs['pk']})) - expediente.registro_aberto = True - expediente.save() + total = presentes.count() + return materia_votacao, presentes, total, None + def _build_registro_context(self, materia_votacao, presentes, total): materia = {'materia': materia_votacao.materia, 'ementa': sub( ' ', ' ', strip_tags( materia_votacao.materia.ementa))} - context = {'materia': materia, 'object': self.get_object(), - 'parlamentares': self.get_parlamentares(presentes), - 'form': self.get_form(), - 'total': total} + return {'materia': materia, 'object': self.get_object(), + 'parlamentares': self.get_parlamentares(presentes), + 'form': self.get_form(), + 'total': total, + 'registro_aberto': materia_votacao.registro_aberto} + + def _redirect_same_registro(self, kwargs, page): + view = ('sapl.sessao:votacaonominal' if self.ordem + else 'sapl.sessao:votacaonominalexp') + return HttpResponseRedirect(reverse(view, kwargs={ + 'pk': kwargs['pk'], 'oid': kwargs['oid'], 'mid': kwargs['mid']}) + page) + + def _redirect_lista(self, kwargs, page): + view = ('sapl.sessao:ordemdia_list' if self.ordem + else 'sapl.sessao:expedientemateria_list') + return HttpResponseRedirect( + reverse(view, kwargs={'pk': kwargs['pk']}) + page + + "#id{}".format(kwargs['mid'])) + def get(self, request, *args, **kwargs): + if request.GET.get('status') == '1': + return self._status_json(kwargs) + materia_votacao, presentes, total, redirect = self._get_materia_votacao( + request, kwargs) + if redirect: + return redirect + context = self._build_registro_context(materia_votacao, presentes, total) return self.render_to_response(context) + def _status_json(self, kwargs): + """ + Poll leve para a tela de registro (nominal.html) acompanhar, em tempo + real, os votos que chegam pelos tablets — sem os efeitos colaterais + de _get_materia_votacao (mensagens, redirect quando já votada) e sem + a máscara de mostrar_voto do sapl.painel:dados_painel (que é para o + telão público; aqui é a tela da própria Mesa, que precisa do valor + real para não sobrescrever por engano um voto que mudou). + """ + model = OrdemDia if self.ordem else ExpedienteMateria + lookup_field = 'ordem_id' if self.ordem else 'expediente_id' + try: + materia_votacao = model.objects.get(id=kwargs['oid']) + except ObjectDoesNotExist: + raise Http404() + + votos = dict(VotoParlamentar.objects.filter( + **{lookup_field: materia_votacao.id}).values_list( + 'parlamentar_id', 'voto')) + + return JsonResponse({ + 'votacao_aberta': materia_votacao.votacao_aberta, + 'registro_aberto': materia_votacao.registro_aberto, + 'ja_registrada': RegistroVotacao.objects.filter( + **{lookup_field: materia_votacao.id}).exists(), + 'votos': votos, + }) + + def _lock_materia_votacao(self, materia_votacao): + model = OrdemDia if self.ordem else ExpedienteMateria + return model.objects.select_for_update().get(pk=materia_votacao.pk) + def post(self, request, *args, **kwargs): self.object = self.get_object() - form = self.get_form() username = request.user.username page = '' if 'page' in self.request.GET: page = '?page={}'.format(self.request.GET['page']) - if self.ordem: - ordem_id = kwargs['oid'] - try: - self.logger.debug( - "user=" + username + ". Tentando obter objeto OrdemDia com id={}.".format(ordem_id)) - materia_votacao = OrdemDia.objects.get(id=ordem_id) - except ObjectDoesNotExist: - self.logger.error( - 'user=' + username + '. Objeto OrdemDia com id={} não existe.'.format(ordem_id)) - raise Http404() - elif self.expediente: - expediente_id = kwargs['oid'] - try: - self.logger.debug( - "user=" + username + ". Tentando obter ExpedienteMateria com id={}.".format(expediente_id)) - materia_votacao = ExpedienteMateria.objects.get( - id=expediente_id) - except ObjectDoesNotExist: - self.logger.error( - 'user=' + username + '. Objeto ExpedienteMateria com id={} não existe.'.format(expediente_id)) - raise Http404() + materia_votacao, presentes, total, redirect = self._get_materia_votacao( + request, kwargs) + if redirect: + return redirect - if form.is_valid(): - votos_sim = 0 - votos_nao = 0 - abstencoes = 0 - nao_votou = 0 + fase_sessao_field = 'ordem_id' if self.ordem else 'expediente_id' + fase_sessao_value = kwargs['oid'] + + if 'reabrir-votacao' in request.POST: + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) + materia_votacao.registro_aberto = False + materia_votacao.save(update_fields=['registro_aberto']) + self.logger.info( + 'user=' + username + '. Reabriu a matéria (id={}) para novos votos.'.format(fase_sessao_value)) + return self._redirect_same_registro(kwargs, page) + + if 'bloquear-registro-votacao' in request.POST: + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) + materia_votacao.registro_aberto = True + materia_votacao.save(update_fields=['registro_aberto']) + self.logger.info( + 'user=' + username + '. Bloqueou novos votos para a matéria (id={}).'.format(fase_sessao_value)) + return self._redirect_same_registro(kwargs, page) - if 'cancelar-votacao' in request.POST: + if 'cancelar-votacao' in request.POST: + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) fechar_votacao_materia(materia_votacao) - if self.ordem: - return HttpResponseRedirect( - reverse( - 'sapl.sessao:ordemdia_list', - kwargs={'pk': kwargs['pk']} - ) + page + "#id{}".format(self.kwargs['mid']) - ) - else: - return HttpResponseRedirect( - reverse( - 'sapl.sessao:expedientemateria_list', - kwargs={'pk': kwargs['pk']} - ) + page + "#id{}".format(self.kwargs['mid']) - ) - else: - if form.cleaned_data['resultado_votacao'] == None: - form.add_error(None, 'Não é possível finalizar a votação sem ' - 'nenhum resultado da votação') - return self.form_invalid(form) + return self._redirect_lista(kwargs, page) - for votos in request.POST.getlist('voto_parlamentar'): - v = votos.split(':') - voto = v[0] - parlamentar_id = v[1] - - if voto == 'Sim': - votos_sim += 1 - elif voto == 'Não': - votos_nao += 1 - elif voto == 'Abstenção': - abstencoes += 1 - elif voto == 'Não Votou': - nao_votou += 1 - - # Caso todas as opções sejam 'Não votou', fecha a votação - if nao_votou == len(request.POST.getlist('voto_parlamentar')): - self.logger.error('user=' + username + '. Não é possível finalizar a votação sem ' - 'nenhum voto') - form.add_error(None, 'Não é possível finalizar a votação sem ' - 'nenhum voto') + if 'salvar-votacao' in request.POST: + form = self.get_form() + if not form.is_valid(): return self.form_invalid(form) - # Remove todas as votação desta matéria, caso existam - if self.ordem: - RegistroVotacao.objects.filter(ordem_id=ordem_id).delete() - elif self.expediente: - RegistroVotacao.objects.filter( - expediente_id=expediente_id).delete() - - votacao = RegistroVotacao() - votacao.numero_votos_sim = votos_sim - votacao.numero_votos_nao = votos_nao - votacao.numero_abstencoes = abstencoes - votacao.observacao = request.POST.get('observacao', None) - votacao.user = request.user - votacao.ip = get_client_ip(request) - - votacao.materia_id = materia_votacao.materia.id - if self.ordem: - votacao.ordem_id = ordem_id - elif self.expediente: - votacao.expediente_id = expediente_id - - votacao.tipo_resultado_votacao = form.cleaned_data['resultado_votacao'] - votacao.save() - - for votos in request.POST.getlist('voto_parlamentar'): - v = votos.split(':') - voto = v[0] - parlamentar_id = v[1] - - if self.ordem: - voto_parlamentar = VotoParlamentar.objects.get_or_create( - parlamentar_id=parlamentar_id, - ordem_id=ordem_id)[0] - elif self.expediente: - voto_parlamentar = VotoParlamentar.objects.get_or_create( - parlamentar_id=parlamentar_id, - expediente_id=expediente_id)[0] - voto_parlamentar.voto = voto - voto_parlamentar.parlamentar_id = parlamentar_id - voto_parlamentar.votacao_id = votacao.id - voto_parlamentar.user = request.user - voto_parlamentar.ip = get_client_ip(request) - voto_parlamentar.save() + if form.cleaned_data['resultado_votacao'] is None: + form.add_error(None, _('Não é possível finalizar a votação sem ' + 'nenhum resultado da votação')) + return self.form_invalid(form) - resultado = form.cleaned_data['resultado_votacao'] + votos_submetidos = [] + for voto_codificado in request.POST.getlist('voto_parlamentar'): + try: + voto_submetido, parlamentar_id = voto_codificado.split(':', 1) + except ValueError: + form.add_error(None, _('Formato de voto inválido.')) + return self.form_invalid(form) + if voto_submetido not in ('Sim', 'Não', 'Abstenção', 'Não Votou'): + form.add_error(None, _('Voto inválido.')) + return self.form_invalid(form) + votos_submetidos.append((voto_submetido, parlamentar_id)) + + skipped_parlamentares = [] + with transaction.atomic(): + materia_votacao = self._lock_materia_votacao(materia_votacao) + if (not materia_votacao.votacao_aberta or + RegistroVotacao.objects.filter( + **{fase_sessao_field: fase_sessao_value}).exists()): + messages.error( + request, _('A votação já foi encerrada por outra operação.')) + return self._redirect_lista(kwargs, page) + + for voto_submetido, parlamentar_id in votos_submetidos: + if voto_submetido == 'Não Votou': + # "Não Votou" é só o valor padrão do + # sempre que existe um VotoParlamentar) mesmo quando + # o fechamento falha por falta de votos reais. Os + # presentes que continuarem sem voto recebem + # 'Não Votou' só depois que o RegistroVotacao existe. + continue + voto_parlamentar, created = VotoParlamentar.objects.select_for_update().get_or_create( + parlamentar_id=parlamentar_id, + **{fase_sessao_field: fase_sessao_value}) + if created: + voto_parlamentar.voto = voto_submetido + voto_parlamentar.user = request.user + voto_parlamentar.ip = get_client_ip(request) + voto_parlamentar.save() + elif voto_parlamentar.voto != voto_submetido: + # Alguém (tipicamente via tablet) já registrou um + # voto diferente do valor deste formulário desde que + # a tela de registro foi carregada. O voto já + # registrado prevalece — não sobrescrevemos com um + # valor obsoleto do formulário em lote. + skipped_parlamentares.append(voto_parlamentar.parlamentar) + + votos_atuais = VotoParlamentar.objects.filter( + **{fase_sessao_field: fase_sessao_value}, + parlamentar_id__in=presentes.values('parlamentar_id')) + votos_sim = votos_atuais.filter(voto='Sim').count() + votos_nao = votos_atuais.filter(voto='Não').count() + abstencoes = votos_atuais.filter(voto='Abstenção').count() + + if votos_sim + votos_nao + abstencoes == 0: + self.logger.error('user=' + username + '. Não é possível finalizar a votação sem ' + 'nenhum voto') + form.add_error(None, _('Não é possível finalizar a votação sem ' + 'nenhum voto')) + return self.form_invalid(form) - materia_votacao.resultado = resultado.nome + votacao = RegistroVotacao( + numero_votos_sim=votos_sim, + numero_votos_nao=votos_nao, + numero_abstencoes=abstencoes, + observacao=request.POST.get('observacao', None), + user=request.user, + ip=get_client_ip(request), + materia_id=materia_votacao.materia.id, + tipo_resultado_votacao=form.cleaned_data['resultado_votacao']) + setattr(votacao, fase_sessao_field, fase_sessao_value) + votacao.save() + + # save()/create() em vez de update()/bulk_create(): só + # eles disparam os sinais que gravam o AuditLog. + for voto in votos_atuais: + voto.votacao = votacao + voto.save(update_fields=['votacao']) + for presenca in presentes.exclude( + parlamentar_id__in=votos_atuais.values('parlamentar_id')): + VotoParlamentar.objects.create( + parlamentar_id=presenca.parlamentar_id, + voto='Não Votou', + votacao=votacao, + user=request.user, + ip=get_client_ip(request), + **{fase_sessao_field: fase_sessao_value}) + + materia_votacao.resultado = form.cleaned_data['resultado_votacao'].nome materia_votacao.votacao_aberta = False + materia_votacao.registro_aberto = False materia_votacao.save() - # Verifica se existe algum VotoParlamentar sem RegistroVotacao - # Por exemplo, se algum parlamentar votar e sua presença for - # removida da ordem do dia/expediente antes da conclusão da - # votação - if self.ordem: - VotoParlamentar.objects.filter( - ordem_id=ordem_id, - votacao__isnull=True).delete() - elif self.expediente: + # Verifica se existe algum VotoParlamentar sem RegistroVotacao + # Por exemplo, se algum parlamentar votar e sua presença for + # removida da ordem do dia/expediente antes da conclusão da + # votação VotoParlamentar.objects.filter( - expediente_id=expediente_id, - votacao__isnull=True).delete() + **{fase_sessao_field: fase_sessao_value}, votacao__isnull=True).delete() + + if skipped_parlamentares: + nomes = ', '.join(p.nome_parlamentar for p in skipped_parlamentares) + messages.add_message( + request, messages.WARNING, + _('O(s) voto(s) de %(nomes)s já haviam sido registrados ' + 'e não foram sobrescritos.') % {'nomes': nomes}) + return self.form_valid(form) - else: - return self.form_invalid(form) + # Nenhuma ação reconhecida: volta para a tela de registro (GET). + return self._redirect_same_registro(kwargs, page) def form_invalid(self, form): errors_tuple = [(form[e].label, form.errors[e]) diff --git a/sapl/templates/painel/index.html b/sapl/templates/painel/index.html index d5dc8dc4c..22ef48ecb 100644 --- a/sapl/templates/painel/index.html +++ b/sapl/templates/painel/index.html @@ -313,11 +313,17 @@ if (data["status_painel"] == true) { mostrar_voto = data["mostrar_voto"]; + var tem_voto_individual = data['tipo_votacao'] == 'Nominal'; presentes.append('
'); $.each(presentes_list, function (index, parlamentar) { - - - if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){ + if (!tem_voto_individual){ + $('#parlamentares_list').append('') + } + else if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){ $('#parlamentares_list').append('') } else{ - $('#parlamentares_list').append(show_voto(parlamentar)) + $('#parlamentares_list').append(show_voto(parlamentar, !!data['registro'])) } }); @@ -504,8 +510,13 @@ })(); }); - function show_voto(parlamentar) { + function show_voto(parlamentar, registrada) { color = 'white' + + // Com a votação ainda aberta, voto vazio só significa "ainda não votou". + if (!parlamentar.voto && registrada) { + parlamentar.voto = "Não votou"; + } if (parlamentar.voto == "Sim"){ color = 'green' } @@ -529,4 +540,3 @@ - diff --git a/sapl/templates/painel/voto_individual.html b/sapl/templates/painel/voto_individual.html index 1dde0990d..df71296c5 100644 --- a/sapl/templates/painel/voto_individual.html +++ b/sapl/templates/painel/voto_individual.html @@ -46,6 +46,10 @@ + {% for message in messages %} +

{{ message }}

+ {% endfor %} + {% if not error_message %}

{{sessao}}

@@ -77,6 +81,11 @@

+ {% if status_message %} +

+ {{status_message}} +

+ {% endif %}

@@ -111,7 +120,8 @@
{% else %}
-

{{error_message}}

+

⏸

+

{{error_message}}




@@ -184,11 +194,62 @@ $("#date").text(n); startTime(); - - setTimeout(function() { - document.location.reload(true); - }, 30000) }); {% endif %} + + diff --git a/sapl/templates/sessao/votacao/nominal.html b/sapl/templates/sessao/votacao/nominal.html index 540e0f3da..887803ee4 100644 --- a/sapl/templates/sessao/votacao/nominal.html +++ b/sapl/templates/sessao/votacao/nominal.html @@ -13,6 +13,15 @@ Ementa: {{materia.ementa|safe}}

+ {% if total == 0 %} \n' + for c in lst_correspondencias: + tmp += '' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\t\t\n' + tmp += '\t\t\t
\n' + tmp += '\t\t
\n' + return tmp + + def expedientes(lst_expedientes): tmp = '' if lst_expedientes: @@ -415,7 +444,7 @@ def consideracoes(lst_consideracoes): return tmp -def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes): +def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_correspondencias, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes): """ """ arquivoPdf = str(int(time.time() * 100)) + ".pdf" @@ -440,6 +469,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ ordenacao = ResumoOrdenacao.objects.first() dict_ord_template = { 'cont_mult': multimidia(cont_mult_dic), + 'correspondencia': correspondencias(lst_correspondencias), 'exp': expedientes(lst_expedientes), 'id_basica': inf_basicas(inf_basicas_dic), 'lista_p': presenca(lst_presenca_sessao, lst_ausencia_sessao), @@ -473,6 +503,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ tmp += dict_ord_template[ordenacao.decimo_terceiro] tmp += dict_ord_template[ordenacao.decimo_quarto] tmp += dict_ord_template[ordenacao.decimo_quinto] + tmp += dict_ord_template[ordenacao.decimo_sexto] except KeyError as e: logger.error("KeyError: " + str(e) + ". Erro ao tentar utilizar " "configuração de ordenação. Utilizando ordenação padrão.") @@ -480,6 +511,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ tmp += multimidia(cont_mult_dic) tmp += mesa(lst_mesa) tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao) + tmp += correspondencias(lst_correspondencias) tmp += expedientes(lst_expedientes) tmp += expediente_materia(lst_expediente_materia) tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom) @@ -497,6 +529,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ tmp += multimidia(cont_mult_dic) tmp += mesa(lst_mesa) tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao) + tmp += correspondencias(lst_correspondencias) tmp += expedientes(lst_expedientes) tmp += expediente_materia(lst_expediente_materia) tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom) diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 1c1d2299f..3fbb889b4 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -8,6 +8,7 @@ import unidecode from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Q, F from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string from django.utils import timezone from django.utils.translation import ugettext_lazy as _ @@ -23,7 +24,7 @@ from sapl.materia.models import (Autoria, MateriaLegislativa, Numeracao, TipoMateriaLegislativa, MateriaEmTramitacao, DocumentoAcessorio, TipoDocumento, StatusTramitacao) from sapl.norma.models import TipoNormaJuridica, NormaJuridica -from sapl.parlamentares.models import Filiacao, Parlamentar, SessaoLegislativa, Legislatura +from sapl.parlamentares.models import Parlamentar, SessaoLegislativa, Legislatura from sapl.protocoloadm.models import (DocumentoAdministrativo, Protocolo, TramitacaoAdministrativo, StatusTramitacaoAdministrativo, TipoDocumentoAdministrativo) @@ -39,7 +40,8 @@ from sapl.sessao.models import (ExpedienteMateria, ExpedienteSessao, OrdemDia, PresencaOrdemDia, SessaoPlenaria, SessaoPlenariaPresenca, OcorrenciaSessao, RegistroVotacao, VotoParlamentar, OradorOrdemDia, - ConsideracoesFinais, ResumoOrdenacao, TipoSessaoPlenaria) + ConsideracoesFinais, ResumoOrdenacao, TipoSessaoPlenaria, + restringe_sessoes_visiveis) from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora, get_presenca_sessao, get_expedientes, get_materias_expediente, get_oradores_expediente, @@ -92,7 +94,8 @@ def get_imagem(casa): def get_rodape(casa): if len(casa.cep) == 8: - cep = casa.cep[:4] + "-" + casa.cep[5:] + # CEP tem 8 dígitos e se formata como XXXXX-XXX. + cep = casa.cep[:5] + "-" + casa.cep[5:] else: cep = "" @@ -542,6 +545,8 @@ def get_sessao_plenaria(sessao, casa, user): if sessao.tipo.nome == 'Solene': inf_basicas_dic["tema_solene"] = sessao.tema_solene + data_sessao = sessao.data_fim if sessao.data_fim else sessao.data_inicio + # Conteudo multimidia cont_mult_dic = { "multimidia_audio": str(sessao.url_audio) if sessao.url_audio else "Indisponível", @@ -553,9 +558,11 @@ def get_sessao_plenaria(sessao, casa, user): for composicao in IntegranteMesa.objects.select_related('parlamentar', 'cargo') \ .filter(sessao_plenaria=sessao) \ .order_by('cargo_id'): - partido_sigla = Filiacao.objects.filter( - parlamentar=composicao.parlamentar).first() - sigla = '' if not partido_sigla else partido_sigla.partido.sigla + # Antes usava Filiacao...first() (filiação mais recente), enquanto a + # tela usava .last() (a mais antiga) — e as listas de presença deste + # mesmo PDF já usavam filiacao_data. Unifica no critério das listas + # de presença e do Resumo: o partido vigente em data_inicio. + sigla = filiacao_data(composicao.parlamentar, sessao.data_inicio) lst_mesa.append({ 'nom_parlamentar': composicao.parlamentar.nome_parlamentar, 'sgl_partido': sigla, @@ -652,7 +659,7 @@ def get_sessao_plenaria(sessao, casa, user): "num_ordem": expediente_materia.numero_ordem, "id_materia": f"{materia.tipo.sigla} {materia.tipo.descricao} {str(materia.numero)}/{str(materia.ano)}", "des_numeracao": ' ', - "des_turno": get_turno(materia)[0], + "des_turno": get_turno(materia, data_sessao)[0], "situacao": materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"), "txt_ementa": str(materia.ementa), "materia_observacao": materia.observacao, @@ -732,13 +739,11 @@ def get_sessao_plenaria(sessao, casa, user): for orador_expediente in OradorExpediente.objects.filter(sessao_plenaria=sessao).order_by('numero_ordem'): parlamentar = Parlamentar.objects.get( id=orador_expediente.parlamentar.id) - partido_sigla = Filiacao.objects.filter( - parlamentar=parlamentar).first() lst_oradores_expediente.append({ "num_ordem": orador_expediente.numero_ordem, "nom_parlamentar": parlamentar.nome_parlamentar, "observacao": orador_expediente.observacao, - "sgl_partido": "" if not partido_sigla else partido_sigla.partido.sigla + "sgl_partido": filiacao_data(parlamentar, sessao.data_inicio) }) # Lista presença na ordem do dia @@ -774,7 +779,7 @@ def get_sessao_plenaria(sessao, casa, user): materia_em_tramitacao = materia.materiaemtramitacao_set.first() dic_votacao.update({ - "des_turno": get_turno(materia)[0], + "des_turno": get_turno(materia, data_sessao)[0], # https://github.com/interlegis/sapl/issues/1009 "txt_ementa": html.unescape(materia.ementa), "materia_observacao": materia.observacao, @@ -849,27 +854,23 @@ def get_sessao_plenaria(sessao, casa, user): for orador_ordemdia in oradores_ordem_dia: parlamentar_orador = Parlamentar.objects.get( id=orador_ordemdia.parlamentar.id) - sigla_partido = Filiacao.objects.filter( - parlamentar=parlamentar_orador).first() lst_oradores_ordemdia.append({ "num_ordem": orador_ordemdia.numero_ordem, "nome_parlamentar": parlamentar_orador.nome_parlamentar, "observacao": orador_ordemdia.observacao, - "sigla": "" if not sigla_partido else sigla_partido.partido.sigla + "sigla": filiacao_data(parlamentar_orador, sessao.data_inicio) }) # Lista dos oradores nas Explicações Pessoais lst_oradores = [] for orador in Orador.objects.select_related('parlamentar').filter(sessao_plenaria=sessao).order_by('numero_ordem'): parlamentar = orador.parlamentar - partido_sigla = orador.parlamentar.filiacao_set.select_related( - 'partido', 'parlamentar').first() lst_oradores.append({ "num_ordem": orador.numero_ordem, "nom_parlamentar": parlamentar.nome_parlamentar, "observacao": orador.observacao, - "sgl_partido": "" if not partido_sigla else partido_sigla.partido.sigla + "sgl_partido": filiacao_data(parlamentar, sessao.data_inicio) }) # Ocorrências da Sessão @@ -930,11 +931,20 @@ def get_sessao_plenaria(sessao, casa, user): lst_consideracoes) -def get_turno(materia): +def get_turno(materia, data_sessao=None): + """Turno e situação de uma matéria. + + `data_sessao` corta as tramitações posteriores à sessão, como já fazem + `get_materias_expediente` / `get_materias_ordem_do_dia` em + sapl.sessao.views. Sem esse corte, o PDF do resumo exibia um turno + registrado depois da sessão que ele documenta. + """ descricao_turno = '' descricao_tramitacao = '' tramitacoes = materia.tramitacao_set.order_by( '-data_tramitacao', '-id').all() + if data_sessao: + tramitacoes = tramitacoes.filter(data_tramitacao__lte=data_sessao) tramitacoes_turno = tramitacoes.exclude(turno="") if tramitacoes: @@ -969,7 +979,8 @@ def relatorio_sessao_plenaria(request, pk): try: logger.debug("user=" + username + ". Tentando obter SessaoPlenaria com id={}.".format(pk)) - sessao = SessaoPlenaria.objects.get(id=pk) + sessao = restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), request.user).get(id=pk) except ObjectDoesNotExist as e: logger.error("user=" + username + ". Essa SessaoPlenaria não existe (pk={}). ".format(pk) + str(e)) @@ -1143,8 +1154,29 @@ def relatorio_etiqueta_protocolo(request, nro, ano): def get_etiqueta_protocolos(prots): + prot_list = list(prots) + if not prot_list: + return [] + + # Pre-fetch MateriaLegislativa for all protocols in one query. + materia_query = Q() + for p in prot_list: + materia_query |= Q(numero_protocolo=p.numero, ano=p.ano) + materias_map = { + (m.numero_protocolo, m.ano): m + for m in MateriaLegislativa.objects.filter( + materia_query).select_related('tipo') + } + + # Pre-fetch DocumentoAdministrativo for all protocols in one query. + documentos_map = { + doc.protocolo_id: doc + for doc in DocumentoAdministrativo.objects.filter( + protocolo__in=prot_list).select_related('tipo') + } + protocolos = [] - for p in prots: + for p in prot_list: dic = {} dic['titulo'] = str(p.numero) + '/' + str(p.ano) @@ -1161,11 +1193,11 @@ def get_etiqueta_protocolos(prots): dic['nom_autor'] = str(p.autor or ' ') - dic['num_materia'] = '' - for materia in MateriaLegislativa.objects.filter( - numero_protocolo=p.numero, ano=p.ano): - dic['num_materia'] = materia.tipo.sigla + ' ' + \ - str(materia.numero) + '/' + str(materia.ano) + materia = materias_map.get((p.numero, p.ano)) + dic['num_materia'] = ( + materia.tipo.sigla + ' ' + str(materia.numero) + '/' + str(materia.ano) + if materia else '' + ) dic['natureza'] = '' if p.tipo_processo == 0: @@ -1173,11 +1205,11 @@ def get_etiqueta_protocolos(prots): if p.tipo_processo == 1: dic['natureza'] = 'Legislativo' - dic['num_documento'] = '' - for documento in DocumentoAdministrativo.objects.filter( - protocolo=p): - dic['num_documento'] = documento.tipo.sigla + ' ' + \ - str(documento.numero) + '/' + str(documento.ano) + documento = documentos_map.get(p.pk) + dic['num_documento'] = ( + documento.tipo.sigla + ' ' + str(documento.numero) + '/' + str(documento.ano) + if documento else '' + ) dic['ident_processo'] = dic['num_materia'] or dic['num_documento'] @@ -1382,9 +1414,15 @@ def make_pdf(base_url, main_template, header_template, main_css='', header_css=' def resumo_ata_pdf(request, pk): base_url = request.build_absolute_uri() casa = CasaLegislativa.objects.first() + if not casa: + raise Http404 rodape = ' '.join(get_rodape(casa)) - sessao_plenaria = SessaoPlenaria.objects.get(pk=pk) + # A mesma restrição de visibilidade aplicada por ResumoAtaView: sem ela, + # uma sessão oculta na consulta pública era legível por aqui. + sessao_plenaria = get_object_or_404( + restringe_sessoes_visiveis(SessaoPlenaria.objects.all(), request.user), + pk=pk) dict_ord_template = { 'cont_mult': 'conteudo_multimidia.html', @@ -1420,7 +1458,10 @@ def resumo_ata_pdf(request, pk): context.update(get_ocorrencias_da_sessao(sessao_plenaria)) context.update(get_consideracoes_finais(sessao_plenaria)) context.update(get_oradores_explicacoes_pessoais(sessao_plenaria)) - context.update(get_assinaturas(sessao_plenaria)) + context.update(get_assinaturas( + sessao_plenaria, + mesa=context['mesa'], + presenca_ordem=context['presenca_ordem'])) context.update({'object': sessao_plenaria}) context.update({'data': dt.today().strftime('%d/%m/%Y')}) context.update({'rodape': rodape}) @@ -1594,7 +1635,8 @@ def relatorio_sessao_plenaria_pdf(request, pk): try: logger.debug("user=" + username + ". Tentando obter SessaoPlenaria com id={}.".format(pk)) - sessao = SessaoPlenaria.objects.get(id=pk) + sessao = restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), request.user).get(id=pk) except ObjectDoesNotExist as e: logger.error("user=" + username + ". Essa SessaoPlenaria não existe (pk={}). ".format(pk) + str(e)) diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 261d9e4ae..4c6b84a5a 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +from collections import OrderedDict, defaultdict from datetime import datetime import json import logging @@ -38,7 +38,7 @@ from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux, PermissionRequiredForAppCrudMixin, make_pagination) from sapl.materia.forms import filtra_tramitacao_status from sapl.materia.models import (Autoria, TipoMateriaLegislativa, - Tramitacao, MateriaEmTramitacao, Numeracao) + Tramitacao, MateriaEmTramitacao) from sapl.materia.views import MateriaLegislativaPesquisaView from sapl.parlamentares.models import (Filiacao, Legislatura, Mandato, Parlamentar, SessaoLegislativa) @@ -50,7 +50,8 @@ from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedien from sapl.sessao.models import Correspondencia from sapl.settings import TIME_ZONE, RATE_LIMITER_RATE from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip, \ - MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip + MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip, \ + filiacao_data from .forms import (AdicionarVariasMateriasFilterSet, AdicionarVariasMateriasForm, BancadaForm, ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, @@ -285,38 +286,41 @@ def abrir_votacao(request, pk, spk): def customize_link_materia(context, pk, has_permission, is_expediente): + # sessao_plenaria is the same for every row — resolve once + object_list = context['object_list'] + if object_list: + sessao_plenaria = object_list[0].sessao_plenaria + else: + sessao_plenaria = SessaoPlenaria.objects.get(id=pk) + data_sessao = sessao_plenaria.data_fim or sessao_plenaria.data_inicio + for i, row in enumerate(context['rows']): - materia = context['object_list'][i].materia - obj = context['object_list'][i] + obj = object_list[i] + materia = obj.materia # already select_related + url_materia = reverse( 'sapl.materia:materialegislativa_detail', kwargs={'pk': materia.id}) - numeracao = materia.numeracao_set.first() if materia.numeracao_set.first() else "-" - todos_autoria = materia.autoria_set.all() - autoria = todos_autoria.filter(primeiro_autor=True) + + numeracao = materia._numeracao_prefetch[0] if materia._numeracao_prefetch else "-" + + todos_autoria = materia._autoria_prefetch + autoria = [a for a in todos_autoria if a.primeiro_autor] autor = ', '.join([str(a.autor) for a in autoria]) if autoria else "-" + todos_autores = ', '.join([str(a.autor) for a in todos_autoria]) if autoria else "-" - todos_autores = ', '.join([str(a.autor) - for a in todos_autoria]) if autoria else "-" + num_protocolo = materia.numero_protocolo or "-" - num_protocolo = materia.numero_protocolo if materia.numero_protocolo else "-" - sessao_plenaria = SessaoPlenaria.objects.get(id=pk) - data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio - tramitacao = Tramitacao.objects \ - .select_related('materia', 'status', 'materia__tipo') \ - .filter(materia=materia, turno__isnull=False, data_tramitacao__lte=data_sessao) \ - .exclude(turno__exact='') \ - .order_by('-data_tramitacao', '-id') \ - .first() + tramitacao = next( + (t for t in materia._tramitacao_prefetch if t.data_tramitacao <= data_sessao), + None, + ) turno = '-' if tramitacao: for t in Tramitacao.TURNO_CHOICES: if t[0] == tramitacao.turno: turno = t[1] break - materia_em_tramitacao = MateriaEmTramitacao.objects \ - .select_related("materia", "tramitacao") \ - .filter(materia=materia) \ - .first() + # idUnica para cada materia idAutor = "autor" + str(i) idAutores = "autores" + str(i) @@ -342,12 +346,17 @@ def customize_link_materia(context, pk, has_permission, is_expediente): # url em toda a string de title_materia context['rows'][i][1] = (title_materia, None) - exist_resultado = obj.registrovotacao_set.filter( - materia=obj.materia).exists() - exist_retirada = obj.retiradapauta_set.filter( - materia=obj.materia).exists() - exist_leitura = obj.registroleitura_set.filter( - materia=obj.materia).exists() + # A matéria do item pode ter sido trocada depois de votada: só contam + # os registros da matéria atual. + votacoes = [r for r in obj._votacao_prefetch + if r.materia_id == obj.materia_id] + retiradas = [r for r in obj._retirada_prefetch + if r.materia_id == obj.materia_id] + leituras = [r for r in obj._leitura_prefetch + if r.materia_id == obj.materia_id] + exist_resultado = bool(votacoes) + exist_retirada = bool(retiradas) + exist_leitura = bool(leituras) if (obj.tipo_votacao != LEITURA and not exist_resultado and not exist_retirada) or \ (obj.tipo_votacao == LEITURA and not exist_leitura): @@ -469,8 +478,7 @@ def customize_link_materia(context, pk, has_permission, is_expediente): resultado = '''Não há resultado''' elif exist_retirada: - retirada = obj.retiradapauta_set.filter( - materia_id=obj.materia_id).last() + retirada = retiradas[-1] retirada_descricao = retirada.tipo_de_retirada.descricao retirada_observacao = retirada.observacao url = reverse('sapl.sessao:retiradapauta_detail', @@ -482,13 +490,11 @@ def customize_link_materia(context, pk, has_permission, is_expediente): else: if obj.tipo_votacao == LEITURA: - resultado = obj.registroleitura_set.filter( - materia_id=obj.materia_id).last() + resultado = leituras[-1] resultado_descricao = "Matéria lida" resultado_observacao = resultado.observacao else: - resultado = obj.registrovotacao_set.filter( - materia_id=obj.materia_id).last() + resultado = votacoes[-1] resultado_descricao = resultado.tipo_resultado_votacao.nome resultado_observacao = resultado.observacao @@ -898,16 +904,16 @@ class MateriaOrdemDiaCrud(MasterDetailCrud): ordering = ['numero_ordem', 'materia', 'resultado'] def get_context_data(self, **kwargs): - if self.get_queryset().count() > 500: - self.paginate_by = 50 - else: - self.paginate_by = None - + self.paginate_by = 50 if self.object_list.count() > 500 else None context = super().get_context_data(**kwargs) - has_permition = self.request.user.has_module_perms(AppConfig.label) return customize_link_materia(context, self.kwargs['pk'], has_permition, False) + def get_queryset(self): + return prefetch_materias_sessao( + super().get_queryset().select_related('sessao_plenaria'), + com_situacao=False) + def recuperar_materia(request): tipo = TipoMateriaLegislativa.objects.get(pk=request.GET['tipo_materia']) @@ -970,20 +976,18 @@ class ExpedienteMateriaCrud(MasterDetailCrud): ordering = ['numero_ordem', 'materia', 'resultado'] def get_context_data(self, **kwargs): - - if self.get_queryset().count() > 500: - self.paginate_by = 50 - else: - self.paginate_by = None - + self.paginate_by = 50 if self.object_list.count() > 500 else None context = super().get_context_data(**kwargs) - if self.request.GET.get('page'): context['page'] = self.request.GET.get('page') - has_permition = self.request.user.has_module_perms(AppConfig.label) return customize_link_materia(context, self.kwargs['pk'], has_permition, True) + def get_queryset(self): + return prefetch_materias_sessao( + super().get_queryset().select_related('sessao_plenaria'), + com_situacao=False) + class CreateView(MasterDetailCrud.CreateView): form_class = ExpedienteMateriaForm @@ -1945,9 +1949,24 @@ def get_conteudo_multimidia(sessao_plenaria): return context +def prefetch_filiacao(): + """Prefetch de `filiacao_set` para os Parlamentares exibidos no resumo. + + Permite que `filiacao_data` (usada pelo filtro `filiacao_data_filter` nos + blocos do resumo/ata) resolva a sigla do partido em memória, sem uma query + por parlamentar. A ordenação replica `Filiacao.Meta.ordering`. + """ + return Prefetch( + 'parlamentar__filiacao_set', + queryset=Filiacao.objects.select_related('partido').order_by( + '-data', '-data_desfiliacao')) + + def get_mesa_diretora(sessao_plenaria): mesa = IntegranteMesa.objects.filter( - sessao_plenaria=sessao_plenaria).order_by('cargo_id') + sessao_plenaria=sessao_plenaria).select_related( + 'parlamentar', 'cargo').prefetch_related( + prefetch_filiacao()).order_by('cargo_id') integrantes = [{'parlamentar': m.parlamentar, 'cargo': m.cargo} for m in mesa] return {'mesa': integrantes} @@ -1956,18 +1975,21 @@ def get_mesa_diretora(sessao_plenaria): def get_presenca_sessao(sessao_plenaria): parlamentares_sessao = [p.parlamentar for p in SessaoPlenariaPresenca.objects.filter( sessao_plenaria_id=sessao_plenaria.id - ).order_by('parlamentar__nome_parlamentar').distinct()] + ).select_related('parlamentar').prefetch_related( + prefetch_filiacao()).order_by('parlamentar__nome_parlamentar').distinct()] ausentes_sessao = JustificativaAusencia.objects.filter( sessao_plenaria_id=sessao_plenaria.id - ).distinct().order_by('parlamentar__nome_parlamentar') + ).select_related('parlamentar', 'tipo_ausencia').distinct().order_by( + 'parlamentar__nome_parlamentar') return ({'presenca_sessao': parlamentares_sessao, 'justificativa_ausencia': ausentes_sessao}) def get_correspondencias(sessao_plenaria, user): - qs = sessao_plenaria.correspondencia_set.all() + qs = sessao_plenaria.correspondencia_set.select_related( + 'documento', 'documento__tipo') is_anon = user.is_anonymous is_ostensivo = AppsAppConfig.attr( @@ -1999,30 +2021,99 @@ def get_correspondencias(sessao_plenaria, user): def get_expedientes(sessao_plenaria): expediente = ExpedienteSessao.objects.filter( - sessao_plenaria_id=sessao_plenaria.id).order_by('tipo__ordenacao', 'tipo__nome') + sessao_plenaria_id=sessao_plenaria.id).select_related( + 'tipo').order_by('tipo__ordenacao', 'tipo__nome') expedientes = [] for e in expediente: - tipo = TipoExpediente.objects.get(id=e.tipo_id) + tipo = e.tipo conteudo = e.conteudo ex = {'tipo': tipo, 'conteudo': conteudo} expedientes.append(ex) return ({'expedientes': expedientes}) +def prefetch_materias_sessao(qs, com_situacao=True): + """Prefetches compartilhados por `get_materias_expediente`, + `get_materias_ordem_do_dia` e pelas ListViews de OrdemDia e + ExpedienteMateria. + + Os querysets dos `Prefetch` não recebem `order_by` explícito justamente + para herdarem o `Meta.ordering` de cada model — é dele que dependem os + `.first()` / `.last()` que este código substitui por indexação de lista. + + `com_situacao=False` dispensa o prefetch de `MateriaEmTramitacao`, que + as ListViews não usam. + """ + prefetches = [ + Prefetch( + 'materia__tramitacao_set', + queryset=Tramitacao.objects.exclude(turno='').order_by( + '-data_tramitacao', '-id'), + to_attr='_tramitacao_prefetch'), + Prefetch('materia__numeracao_set', to_attr='_numeracao_prefetch'), + Prefetch( + 'materia__autoria_set', + queryset=Autoria.objects.select_related('autor'), + to_attr='_autoria_prefetch'), + Prefetch( + 'registrovotacao_set', + queryset=RegistroVotacao.objects.select_related( + 'tipo_resultado_votacao'), + to_attr='_votacao_prefetch'), + Prefetch( + 'retiradapauta_set', + queryset=RetiradaPauta.objects.select_related('tipo_de_retirada'), + to_attr='_retirada_prefetch'), + Prefetch('registroleitura_set', to_attr='_leitura_prefetch'), + ] + if com_situacao: + prefetches.append(Prefetch( + 'materia__materiaemtramitacao_set', + queryset=MateriaEmTramitacao.objects.select_related( + 'tramitacao', 'tramitacao__status'), + to_attr='_met_prefetch')) + return qs.select_related('materia', 'materia__tipo').prefetch_related( + *prefetches) + + +def agrupa_votos_por_materia(campo, ids): + """Busca todos os VotoParlamentar de uma seção em uma única query. + + `campo` é 'expediente' ou 'ordem' — as FKs desnormalizadas de + VotoParlamentar. O agrupamento preserva a ordem da query, que herda o + `Meta.ordering = ('id',)` do model. + """ + votos = defaultdict(list) + if not ids: + return votos + qs = VotoParlamentar.objects.filter( + **{'%s_id__in' % campo: ids}).select_related('parlamentar') + for voto in qs: + votos[getattr(voto, '%s_id' % campo)].append(voto) + return votos + + def get_materias_expediente(sessao_plenaria): materias_expediente = [] - for m in ExpedienteMateria.objects.select_related("materia").filter(sessao_plenaria_id=sessao_plenaria.id): - tramitacao = '' - data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio - for aux_tramitacao in Tramitacao.objects.filter(materia=m.materia, data_tramitacao__lte=data_sessao).order_by( - '-data_tramitacao', '-id'): - if aux_tramitacao.turno: - tramitacao = aux_tramitacao - break - - rv = m.registrovotacao_set.filter(materia=m.materia).first() - rp = m.retiradapauta_set.filter(materia=m.materia).first() - rl = m.registroleitura_set.filter(materia=m.materia).first() + data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio + + expedientes = list(prefetch_materias_sessao( + ExpedienteMateria.objects.filter( + sessao_plenaria_id=sessao_plenaria.id))) + votos_por_materia = agrupa_votos_por_materia( + 'expediente', [m.id for m in expedientes]) + + for m in expedientes: + tramitacao = next( + (t for t in m.materia._tramitacao_prefetch + if t.data_tramitacao <= data_sessao), '') + + rv = next((r for r in m._votacao_prefetch + if r.materia_id == m.materia_id), None) + rp = next((r for r in m._retirada_prefetch + if r.materia_id == m.materia_id), None) + rl = next((r for r in m._leitura_prefetch + if r.materia_id == m.materia_id), None) if rv: resultado = rv.tipo_resultado_votacao.nome resultado_observacao = rv.observacao @@ -2039,11 +2130,11 @@ def get_materias_expediente(sessao_plenaria): voto_nominal = [] if m.tipo_votacao == 2: - for voto in VotoParlamentar.objects.filter(expediente=m.id): + for voto in votos_por_materia[m.id]: voto_nominal.append( (voto.parlamentar.nome_completo, voto.voto)) - voto = RegistroVotacao.objects.filter(expediente=m.id).last() + voto = m._votacao_prefetch[-1] if m._votacao_prefetch else None if voto: voto_sim = voto.numero_votos_sim voto_nao = voto.numero_votos_nao @@ -2053,7 +2144,9 @@ def get_materias_expediente(sessao_plenaria): voto_nao = " Não Informado" voto_abstencoes = " Não Informado" - materia_em_tramitacao = m.materia.materiaemtramitacao_set.first() + met = m.materia._met_prefetch + materia_em_tramitacao = met[0] if met else None + numeracao = m.materia._numeracao_prefetch materias_expediente.append({ 'ementa': m.materia.ementa, 'titulo': m.materia, @@ -2062,9 +2155,9 @@ def get_materias_expediente(sessao_plenaria): 'situacao': materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"), 'resultado': resultado, 'resultado_observacao': resultado_observacao, - 'autor': [str(x.autor) for x in Autoria.objects.select_related("autor").filter(materia_id=m.materia_id)], + 'autor': [str(x.autor) for x in m.materia._autoria_prefetch], 'numero_protocolo': m.materia.numero_protocolo, - 'numero_processo': m.materia.numeracao_set.last(), + 'numero_processo': numeracao[-1] if numeracao else None, 'tipo_votacao': m.TIPO_VOTACAO_CHOICES[m.tipo_votacao], 'voto_sim': voto_sim, 'voto_nao': voto_nao, @@ -2080,12 +2173,13 @@ def get_materias_expediente(sessao_plenaria): def get_oradores_expediente(sessao_plenaria): oradores = [] for orador in OradorExpediente.objects.filter( - sessao_plenaria_id=sessao_plenaria.id).order_by('numero_ordem'): + sessao_plenaria_id=sessao_plenaria.id).select_related( + 'parlamentar').prefetch_related( + prefetch_filiacao()).order_by('numero_ordem'): numero_ordem = orador.numero_ordem url_discurso = orador.url_discurso observacao = orador.observacao - parlamentar = Parlamentar.objects.get( - id=orador.parlamentar_id) + parlamentar = orador.parlamentar ora = {'numero_ordem': numero_ordem, 'url_discurso': url_discurso, 'parlamentar': parlamentar, @@ -2098,21 +2192,32 @@ def get_oradores_expediente(sessao_plenaria): def get_presenca_ordem_do_dia(sessao_plenaria): parlamentares_ordem = [p.parlamentar for p in PresencaOrdemDia.objects.filter( sessao_plenaria_id=sessao_plenaria.id - ).distinct().order_by('parlamentar__nome_parlamentar')] + ).select_related('parlamentar').prefetch_related( + prefetch_filiacao()).distinct().order_by('parlamentar__nome_parlamentar')] return {'presenca_ordem': parlamentares_ordem} -def get_assinaturas(sessao_plenaria): - mesa_dia = get_mesa_diretora(sessao_plenaria)['mesa'] +def get_assinaturas(sessao_plenaria, mesa=None, presenca_ordem=None): + """`mesa` e `presenca_ordem` podem ser reaproveitados de + `get_mesa_diretora` / `get_presenca_ordem_do_dia` quando o chamador já os + calculou, evitando repetir as duas queries. O `.distinct()` que + `get_presenca_ordem_do_dia` aplica é um no-op (a projeção inclui a PK), + então as duas listas são equivalentes. + """ + mesa_dia = get_mesa_diretora( + sessao_plenaria)['mesa'] if mesa is None else mesa presidente_dia = [next(iter( [m['parlamentar'] for m in mesa_dia if m['cargo'].descricao == 'Presidente']), '')] - parlamentares_ordem = [p.parlamentar for p in PresencaOrdemDia.objects.filter( - sessao_plenaria_id=sessao_plenaria.id - ).order_by('parlamentar__nome_parlamentar')] + if presenca_ordem is None: + presenca_ordem = [p.parlamentar for p in PresencaOrdemDia.objects.filter( + sessao_plenaria_id=sessao_plenaria.id + ).select_related('parlamentar').prefetch_related( + prefetch_filiacao()).order_by('parlamentar__nome_parlamentar')] + parlamentares_ordem = presenca_ordem parlamentares_mesa = [m['parlamentar'] for m in mesa_dia] @@ -2158,19 +2263,25 @@ def get_assinaturas_presidente(sessao_plenaria): def get_materias_ordem_do_dia(sessao_plenaria): materias_ordem = [] - for o in OrdemDia.objects.filter(sessao_plenaria_id=sessao_plenaria.id): - tramitacao = '' - data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio - for aux_tramitacao in Tramitacao.objects.filter(materia=o.materia, data_tramitacao__lte=data_sessao).order_by( - '-data_tramitacao', '-id'): - if aux_tramitacao.turno: - tramitacao = aux_tramitacao - break + data_sessao = sessao_plenaria.data_fim if sessao_plenaria.data_fim else sessao_plenaria.data_inicio + + ordens = list(prefetch_materias_sessao( + OrdemDia.objects.filter(sessao_plenaria_id=sessao_plenaria.id))) + votos_por_materia = agrupa_votos_por_materia( + 'ordem', [o.id for o in ordens]) + + for o in ordens: + tramitacao = next( + (t for t in o.materia._tramitacao_prefetch + if t.data_tramitacao <= data_sessao), '') # Verificar resultado - rv = o.registrovotacao_set.filter(materia=o.materia).first() - rp = o.retiradapauta_set.filter(materia=o.materia).first() - rl = o.registroleitura_set.filter(materia=o.materia).first() + rv = next((r for r in o._votacao_prefetch + if r.materia_id == o.materia_id), None) + rp = next((r for r in o._retirada_prefetch + if r.materia_id == o.materia_id), None) + rl = next((r for r in o._leitura_prefetch + if r.materia_id == o.materia_id), None) if rv: resultado = rv.tipo_resultado_votacao.nome resultado_observacao = rv.observacao @@ -2187,11 +2298,11 @@ def get_materias_ordem_do_dia(sessao_plenaria): voto_nominal = [] if o.tipo_votacao == 2: - for voto in VotoParlamentar.objects.filter(ordem=o.id): + for voto in votos_por_materia[o.id]: voto_nominal.append( (voto.parlamentar.nome_parlamentar, voto.voto)) - voto = RegistroVotacao.objects.filter(ordem=o.id).last() + voto = o._votacao_prefetch[-1] if o._votacao_prefetch else None if voto: voto_sim = voto.numero_votos_sim voto_nao = voto.numero_votos_nao @@ -2201,7 +2312,9 @@ def get_materias_ordem_do_dia(sessao_plenaria): voto_nao = " Não Informado" voto_abstencoes = " Não Informado" - materia_em_tramitacao = o.materia.materiaemtramitacao_set.first() + met = o.materia._met_prefetch + materia_em_tramitacao = met[0] if met else None + numeracao = o.materia._numeracao_prefetch materias_ordem.append({ 'ementa': o.materia.ementa, 'ementa_observacao': o.observacao, @@ -2211,9 +2324,9 @@ def get_materias_ordem_do_dia(sessao_plenaria): 'situacao': materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"), 'resultado': resultado, 'resultado_observacao': resultado_observacao, - 'autor': [str(x.autor) for x in Autoria.objects.select_related("autor").filter(materia_id=o.materia_id)], + 'autor': [str(x.autor) for x in o.materia._autoria_prefetch], 'numero_protocolo': o.materia.numero_protocolo, - 'numero_processo': o.materia.numeracao_set.last(), + 'numero_processo': numeracao[-1] if numeracao else None, 'tipo_votacao': o.TIPO_VOTACAO_CHOICES[o.tipo_votacao], 'voto_sim': voto_sim, 'voto_nao': voto_nao, @@ -2230,15 +2343,14 @@ def get_oradores_ordemdia(sessao_plenaria): oradores_ordem_dia = OradorOrdemDia.objects.filter( sessao_plenaria_id=sessao_plenaria.id - ).order_by('numero_ordem') + ).select_related('parlamentar').prefetch_related( + prefetch_filiacao()).order_by('numero_ordem') for orador in oradores_ordem_dia: numero_ordem = orador.numero_ordem url_discurso = orador.url_discurso observacao = orador.observacao - parlamentar = Parlamentar.objects.get( - id=orador.parlamentar_id - ) + parlamentar = orador.parlamentar o = { 'numero_ordem': numero_ordem, 'url_discurso': url_discurso, @@ -2254,26 +2366,26 @@ def get_oradores_ordemdia(sessao_plenaria): def get_oradores_explicacoes_pessoais(sessao_plenaria): oradores_explicacoes = [] for orador in Orador.objects.filter( - sessao_plenaria_id=sessao_plenaria.id).order_by('numero_ordem'): - for parlamentar in Parlamentar.objects.filter( - id=orador.parlamentar.id): - partido_sigla = Filiacao.objects.filter( - parlamentar=parlamentar).last() - if not partido_sigla: - sigla = '' - else: - sigla = partido_sigla.partido.sigla - observacao = orador.observacao - url_discurso = orador.url_discurso - - oradores = { - 'numero_ordem': orador.numero_ordem, - 'parlamentar': parlamentar, - 'sgl_partido': sigla, - 'observacao': observacao, - 'url_discurso': url_discurso - } - oradores_explicacoes.append(oradores) + sessao_plenaria_id=sessao_plenaria.id).select_related( + 'parlamentar').prefetch_related( + prefetch_filiacao()).order_by('numero_ordem'): + parlamentar = orador.parlamentar + # Antes era Filiacao...last() (a filiação mais antiga, dado o + # ordering decrescente por data), enquanto o PDF usava .first(). + # Unifica no critério já usado pelas listas de presença: o partido + # vigente na data da sessão. + sigla = filiacao_data(parlamentar, sessao_plenaria.data_inicio) + observacao = orador.observacao + url_discurso = orador.url_discurso + + oradores = { + 'numero_ordem': orador.numero_ordem, + 'parlamentar': parlamentar, + 'sgl_partido': sigla, + 'observacao': observacao, + 'url_discurso': url_discurso + } + oradores_explicacoes.append(oradores) context = {'oradores_explicacoes': oradores_explicacoes} return context @@ -2292,6 +2404,34 @@ def get_consideracoes_finais(sessao_plenaria): return context +def get_votos_nominais(sessao_plenaria_id, model, campo): + """Blocos de votação nominal de uma seção, em 3 queries em vez de 2 por matéria. + + Replica `VotoParlamentar.objects.filter(votacao__in=registro).order_by('parlamentar')`: + a query dos votos é ordenada globalmente por parlamentar e o agrupamento + preserva essa ordem dentro de cada matéria. + """ + materias = list(model.objects.filter( + sessao_plenaria_id=sessao_plenaria_id, tipo_votacao=2 + ).select_related('materia').order_by('numero_ordem')) + + votos_por_materia = defaultdict(list) + if materias: + registro_para_materia = { + r.id: getattr(r, '%s_id' % campo) + for r in RegistroVotacao.objects.filter( + **{'%s_id__in' % campo: [m.id for m in materias]}) + } + if registro_para_materia: + for vp in VotoParlamentar.objects.filter( + votacao_id__in=registro_para_materia).select_related( + 'parlamentar').order_by('parlamentar'): + votos_por_materia[registro_para_materia[vp.votacao_id]].append(vp) + + return [{'titulo': m.materia, 'votos': votos_por_materia[m.id]} + for m in materias] + + class ResumoView(DetailView): template_name = 'sessao/resumo.html' model = SessaoPlenaria @@ -2306,22 +2446,8 @@ class ResumoView(DetailView): context = self.get_context_data(object=self.object) # Votos de Votação Nominal de Matérias Expediente - votacoes = [] - for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \ - .order_by('numero_ordem'): - votos_materia = [] - titulo_materia = mevn.materia - registro = RegistroVotacao.objects.filter(expediente=mevn) - if registro: - for vp in VotoParlamentar.objects.filter(votacao__in=registro).order_by('parlamentar'): - votos_materia.append(vp) - - votacoes.append({ - 'titulo': titulo_materia, - 'votos': votos_materia - }) - - context.update({'votos_nominais_materia_expediente': votacoes}) + context.update({'votos_nominais_materia_expediente': get_votos_nominais( + self.object.id, ExpedienteMateria, 'expediente')}) # ===================================================================== # Identificação Básica @@ -2352,25 +2478,15 @@ class ResumoView(DetailView): context.update(get_presenca_ordem_do_dia(self.object)) # ===================================================================== # Assinaturas - context.update(get_assinaturas(self.object)) + context.update(get_assinaturas( + self.object, + mesa=context['mesa'], + presenca_ordem=context['presenca_ordem'])) # ===================================================================== # Matérias Ordem do Dia # Votos de Votação Nominal de Matérias Ordem do Dia - votacoes_od = [] - for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('numero_ordem'): - votos_materia_od = [] - t_materia = modvn.materia - registro_od = RegistroVotacao.objects.filter(ordem=modvn) - if registro_od: - for vp_od in VotoParlamentar.objects.filter(votacao__in=registro_od).order_by('parlamentar'): - votos_materia_od.append(vp_od) - - votacoes_od.append({ - 'titulo': t_materia, - 'votos': votos_materia_od - }) - - context.update({'votos_nominais_materia_ordem_dia': votacoes_od}) + context.update({'votos_nominais_materia_ordem_dia': get_votos_nominais( + self.object.id, OrdemDia, 'ordem')}) context.update(get_materias_ordem_do_dia(self.object)) # ===================================================================== diff --git a/sapl/templates/painel/mensagem.html b/sapl/templates/painel/mensagem.html deleted file mode 100644 index da8e355d4..000000000 --- a/sapl/templates/painel/mensagem.html +++ /dev/null @@ -1,120 +0,0 @@ -{% load i18n %} -{% load common_tags %} -{% load render_bundle from webpack_loader %} -{% load webpack_static from webpack_loader %} - - - - - - - - - - - {% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %} - - - {% render_chunk_vendors 'css' %} - {% render_bundle 'global' 'css' %} - {% render_bundle 'painel' 'css' %} - - - - - - -

{{ context.title }}

- -

Ajax refresh counter:

-

-

-

-

-




-
- - - - - {% render_chunk_vendors 'js' %} - {% render_bundle 'global' 'js' %} - {% render_bundle 'painel' 'js' %} - - - diff --git a/sapl/templates/painel/parlamentares.html b/sapl/templates/painel/parlamentares.html deleted file mode 100644 index 3dbe6e740..000000000 --- a/sapl/templates/painel/parlamentares.html +++ /dev/null @@ -1,128 +0,0 @@ -{% load i18n %} -{% load common_tags %} - -{% load render_bundle from webpack_loader %} -{% load webpack_static from webpack_loader %} - - - - - - - - - - - {% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %} - - - {% render_chunk_vendors 'css' %} - {% render_bundle 'global' 'css' %} - {% render_bundle 'painel' 'css' %} - - - - - - - -

{{ context.title }}

- -

-

-

-

-

-
' + + parlamentar.nome + + ' ' + + parlamentar.partido + ' ' + + '
' + parlamentar.nome + ' ' + @@ -325,7 +331,7 @@ + '
TipoDocumentoInteressadoAssunto
' + \ + escape(str(c['tipo'])) + '' + escape(str(c['epigrafe'])) + \ + ' - ' + escape(str(c['data'])) + '' + \ + escape(str(c['interessado'] or '')) + '' + \ + escape(str(c['assunto'] or '')) + '
- - - -
-
    -
-
- - - - {% render_chunk_vendors 'js' %} - {% render_bundle 'global' 'js' %} - {% render_bundle 'painel' 'js' %} - - - diff --git a/sapl/templates/painel/votacao.html b/sapl/templates/painel/votacao.html deleted file mode 100644 index e73551160..000000000 --- a/sapl/templates/painel/votacao.html +++ /dev/null @@ -1,123 +0,0 @@ -{% load i18n %} -{% load render_bundle from webpack_loader %} -{% load webpack_static from webpack_loader %} - - - - - - - - - - - {% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %} - - - {% render_chunk_vendors 'css' %} - {% render_bundle 'global' 'css' %} - {% render_bundle 'painel' 'css' %} - - - - -

{{ context.title }}

- -

-

-

-

-

- - - - -
-
    -
-
-
- - - - {% render_chunk_vendors 'js' %} - {% render_bundle 'global' 'js' %} - {% render_bundle 'painel' 'js' %} - - - diff --git a/sapl/templates/relatorios/relatorio_materias_por_autor.html b/sapl/templates/relatorios/relatorio_materias_por_autor.html index 34499fc1b..3325059d3 100644 --- a/sapl/templates/relatorios/relatorio_materias_por_autor.html +++ b/sapl/templates/relatorios/relatorio_materias_por_autor.html @@ -10,7 +10,7 @@  Tipo de matéria: {{ tipo }}
 Data de apresentação: {{periodo}}


- {% if materias_resultado|length %} + {% if object_list|length %} @@ -31,8 +31,8 @@


- {% if materias_resultado|length %} - {% for materia in materias_resultado %} + {% if object_list|length %} + {% for materia in object_list %} Matéria: {{materia.tipo.descricao}} {{materia.numero}}/{{materia.ano}}
Ementa: diff --git a/sapl/utils.py b/sapl/utils.py index 532594801..45c3dbd7b 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -984,23 +984,59 @@ def qs_override_django_filter(self): def filiacao_data(parlamentar, data_inicio, data_fim=None): from sapl.parlamentares.models import Filiacao - filiacoes_parlamentar = Filiacao.objects.filter( - parlamentar=parlamentar) + filiacoes = _filiacoes_prefetchadas(parlamentar, data_inicio, data_fim) - filiacoes = filiacoes_parlamentar.filter(Q( - data__lte=data_inicio, - data_desfiliacao__isnull=True) | Q( - data__lte=data_inicio, - data_desfiliacao__gte=data_inicio)) + if filiacoes is None: + filiacoes_parlamentar = Filiacao.objects.filter( + parlamentar=parlamentar) - if data_fim: - filiacoes = filiacoes | filiacoes_parlamentar.filter( - data__gte=data_inicio, - data__lte=data_fim) + filiacoes = filiacoes_parlamentar.filter(Q( + data__lte=data_inicio, + data_desfiliacao__isnull=True) | Q( + data__lte=data_inicio, + data_desfiliacao__gte=data_inicio)) + + if data_fim: + filiacoes = filiacoes | filiacoes_parlamentar.filter( + data__gte=data_inicio, + data__lte=data_fim) return ' | '.join([f.partido.sigla for f in filiacoes]) +def _filiacoes_prefetchadas(parlamentar, data_inicio, data_fim=None): + """Aplica em memória o mesmo predicado de `filiacao_data`, quando + `filiacao_set` já veio prefetchado. + + O resumo/ata da sessão chama `filiacao_data_filter` duas vezes por + parlamentar exibido; sem isso cada chamada custa 1+N queries. Retorna + `None` quando não há prefetch, para que o chamador use o caminho SQL. + + O queryset do prefetch deve replicar `Filiacao.Meta.ordering` para que a + sequência de siglas saia idêntica à da consulta ao banco. + """ + if not data_inicio: + return None + + cache = getattr(parlamentar, '_prefetched_objects_cache', None) + if not cache: + return None + + cache_name = parlamentar.filiacao_set.field.remote_field.get_cache_name() + if cache_name not in cache: + return None + + def selecionada(f): + if f.data <= data_inicio and (f.data_desfiliacao is None + or f.data_desfiliacao >= data_inicio): + return True + if data_fim: + return data_inicio <= f.data <= data_fim + return False + + return [f for f in cache[cache_name] if selecionada(f)] + + def parlamentares_ativos(data_inicio, data_fim=None): from sapl.parlamentares.models import Mandato, Parlamentar ''' From 37a248c63d3e7d453d644c39ffe61fddbb5e3b9b Mon Sep 17 00:00:00 2001 From: Edward <9326037+edwardoliveira@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:04 -0300 Subject: [PATCH 15/17] Sanitiza HTML/JavaScript nos campos TextField (stored XSS) (#3846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sanitiza HTML/JavaScript nos campos TextField (stored XSS) Os ~71 campos models.TextField do SAPL guardavam texto livre sem nenhuma sanitização, e a renderização os tratava como HTML confiável em 142 pontos (|safe, {% autoescape off %}). Qualquer usuário com permissão de edição conseguia gravar Ciente') == 'Ciente' + assert sanitize_html('Encaminhado ao setor') == \ + 'Encaminhado ao setor' + assert sanitize_html('') == '' + assert sanitize_html('
a
b
') == 'ab' + + +def test_plain_guarda_texto_puro_sem_escape(): + """O escape é da renderização; gravado escapado, apareceria & na tela.""" + assert sanitize_html('Valor < 10 & prazo > 5') == 'Valor < 10 & prazo > 5' + assert sanitize_html('ALFA & BETA') == 'ALFA & BETA' + + +def test_plain_preserva_quebras_de_linha(): + # get_field_display converte \n em
depois de sanitizar + assert sanitize_html('linha1\nlinha2') == 'linha1\nlinha2' + + +def test_valores_vazios_atravessam(): + assert sanitize_html('') == '' + assert sanitize_html(None) is None + assert sanitize_html('', rich=True) == '' + + +@pytest.mark.parametrize('valor', [ + 'Ciente', + 'Valor < 10 & prazo > 5', + 'Encaminhado ao setor', + 'texto & cia', +]) +def test_plain_e_idempotente(valor): + """Salvar de novo um registro já sanitizado não altera o texto.""" + uma_vez = sanitize_html(valor) + assert sanitize_html(uma_vez) == uma_vez + + +@pytest.mark.parametrize('valor', [ + 'Portal', + '

centro

', + '
c
', + 'ok', +]) +def test_rich_e_idempotente(valor): + uma_vez = sanitize_html(valor, rich=True) + assert sanitize_html(uma_vez, rich=True) == uma_vez + + +def test_rich_preserva_links(): + saida = sanitize_html( + 'Portal', + rich=True) + assert 'href="https://camara.gov.br"' in saida + assert 'target="_blank"' in saida + assert 'rel="noopener noreferrer"' in saida + assert '>Portal' in saida + + assert 'href="/materia/123"' in sanitize_html( + 'Matéria', rich=True) + assert 'href="mailto:a@b.c"' in sanitize_html( + 'mail', rich=True) + + +def test_rich_remove_href_perigosa_mas_mantem_o_texto(): + saida = sanitize_html( + 'clique', rich=True) + assert 'javascript' not in saida + assert 'clique' in saida + + +def test_rich_remove_script_e_manipuladores_de_evento(): + saida = sanitize_html('ok', rich=True) + assert saida == 'ok' + + assert 'onclick' not in sanitize_html( + 'x', rich=True) + assert 'onerror' not in sanitize_html( + '', rich=True) + + +def test_rich_preserva_formatacao_do_tinymce(): + """Protege contra regressão visual no conteúdo já cadastrado.""" + assert 'style="text-align: center;"' in sanitize_html( + '

centro

', rich=True) + + saida = sanitize_html( + '
c
', rich=True) + assert '' in saida and 'colspan="2"' in saida + + assert sanitize_html('
  • a
  • b
', rich=True) == \ + '
  • a
  • b
' + + +def test_sanitize_scope(): + assert sanitize_scope(TramitacaoAdministrativo, 'texto') == 'plain' + assert sanitize_scope(ExpedienteSessao, 'conteudo') == 'rich' + assert sanitize_scope(LexmlProvedor, 'xml') == 'exempt' + assert sanitize_scope(TipoTextoArticulado, 'rodape_global') == 'exempt' + assert sanitize_scope(Parlamentar, 'biografia') == 'rich' + + +def test_sanitize_field_respeita_isencao(): + xml = 'y' + assert sanitize_field(LexmlProvedor, 'xml', xml) == xml + + +@pytest.mark.django_db +def test_pre_save_sanitiza_campo_simples(): + t = baker.make(TramitacaoAdministrativo, + texto='Ciente ok') + t.refresh_from_db() + assert t.texto == 'Ciente ok' + + +@pytest.mark.django_db +def test_pre_save_sanitiza_campo_rico_preservando_html(): + e = baker.make(ExpedienteSessao, + conteudo='x' + 'l') + e.refresh_from_db() + assert '' + p = baker.make(LexmlProvedor, xml=xml) + p.refresh_from_db() + assert p.xml == xml + + +@pytest.mark.django_db +def test_get_field_display_nao_devolve_script(): + t = TramitacaoAdministrativo(texto='Ciente') + __, display = get_field_display(t, 'texto') + assert 'legado') + t.refresh_from_db() + assert t.texto == 'legado' + + __, display = get_field_display(t, 'texto') + assert '') + __, display = get_field_display(p, 'xml') + assert ''})) + assert saida == 'Ofício 12 lido & arquivado' + + +@pytest.mark.parametrize('valor, esperado', [ + ('
', True), + ('
', True), + ('
Justificativa
', True), + ('Art. ', True), + ('', False), + ('', False), + ('x', False), +]) +def test_html_fragment_is_balanced(valor, esperado): + assert html_fragment_is_balanced(valor) is esperado + + +@pytest.mark.django_db +def test_tipo_dispositivo_form_rejeita_fragmento_desbalanceado(): + td = baker.make(TipoDispositivo) + dados = {f: getattr(td, f) or '' for f in TipoDispositivoForm.Meta.fields} + dados['rotulo_prefixo_html'] = '' + dados['rotulo_sufixo_html'] = '' + form = TipoDispositivoForm(data=dados, instance=td) + assert not form.is_valid() + assert 'rotulo_prefixo_html' in form.errors + assert 'rotulo_sufixo_html' in form.errors + + dados['rotulo_prefixo_html'] = '
' + dados['rotulo_sufixo_html'] = '' + form = TipoDispositivoForm(data=dados, instance=td) + assert 'rotulo_prefixo_html' not in form.errors diff --git a/sapl/compilacao/forms.py b/sapl/compilacao/forms.py index c4154ec00..3ff3b521d 100644 --- a/sapl/compilacao/forms.py +++ b/sapl/compilacao/forms.py @@ -26,6 +26,7 @@ from sapl.compilacao.models import (NOTAS_PUBLICIDADE_CHOICES, from sapl.compilacao.utils import DISPOSITIVO_SELECT_RELATED from sapl.crispy_layout_mixin import SaplFormHelper from sapl.crispy_layout_mixin import SaplFormLayout, to_column, to_row +from sapl.sanitize import RICH_TEXT_FIELDS, html_fragment_is_balanced from sapl.utils import YES_NO_CHOICES, FileFieldCheckMixin @@ -51,6 +52,33 @@ ta_error_messages = { } +class TipoDispositivoForm(ModelForm): + + class Meta: + model = TipoDispositivo + fields = ['rotulo_ordinal', 'formato_variacao0', + 'rotulo_separador_variacao01', 'formato_variacao1', + 'rotulo_separador_variacao12', 'formato_variacao2', + 'rotulo_separador_variacao23', 'formato_variacao3', + 'rotulo_separador_variacao34', 'formato_variacao4', + 'rotulo_separador_variacao45', 'formato_variacao5', + 'rotulo_prefixo_html', 'rotulo_sufixo_html', + 'texto_prefixo_html', 'texto_sufixo_html', + 'nota_automatica_prefixo_html', + 'nota_automatica_sufixo_html'] + + def clean(self): + cleaned_data = super().clean() + for fieldname in RICH_TEXT_FIELDS['compilacao.TipoDispositivo']: + value = cleaned_data.get(fieldname) + if value and not html_fragment_is_balanced(value): + self.add_error(fieldname, _( + 'Toda tag aberta neste campo deve ser fechada nele ' + 'mesmo; tags divididas entre prefixo e sufixo são ' + 'descartadas ao salvar.')) + return cleaned_data + + class TipoTaForm(ModelForm): sigla = forms.CharField( label=TipoTextoArticulado._meta.get_field( diff --git a/sapl/compilacao/views.py b/sapl/compilacao/views.py index 93a066b3f..995b78368 100644 --- a/sapl/compilacao/views.py +++ b/sapl/compilacao/views.py @@ -39,7 +39,8 @@ from sapl.compilacao.forms import (DispositivoDefinidorVigenciaForm, DispositivoRegistroRevogacaoForm, DispositivoSearchModalForm, NotaForm, PublicacaoForm, TaForm, - TextNotificacoesForm, TipoTaForm, VideForm) + TextNotificacoesForm, TipoDispositivoForm, + TipoTaForm, VideForm) from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE, STATUS_TA_PUBLIC, Dispositivo, Nota, PerfilEstruturalTextoArticulado, @@ -85,6 +86,7 @@ class TipoDispositivoCrud(CrudAux): class UpdateView(CrudAux.UpdateView): layout_key = 'TipoDispositivoUpdate' + form_class = TipoDispositivoForm class ListView(CrudAux.ListView): paginate_by = 100 diff --git a/sapl/crispy_layout_mixin.py b/sapl/crispy_layout_mixin.py index 57bc0d538..711f9aaa0 100644 --- a/sapl/crispy_layout_mixin.py +++ b/sapl/crispy_layout_mixin.py @@ -8,9 +8,12 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse, reverse_lazy from django.utils import formats from django.utils.encoding import force_text +from django.utils.html import escape from django.utils.translation import ugettext as _ import yaml +from sapl.sanitize import sanitize_html, sanitize_scope + def heads_and_tails(list_of_lists): for alist in list_of_lists: @@ -167,7 +170,14 @@ def get_field_display(obj, fieldname): args=(value.id,)), value) elif 'TextField' in str_type_from_field: - display = value.replace('\n', '
') + scope = sanitize_scope(obj._meta.model, fieldname) + if scope == 'rich': + display = sanitize_html(value, rich=True) + elif scope == 'plain': + display = escape(sanitize_html(value)) + else: + display = escape(value) + display = display.replace('\n', '
') display = '
{}
'.format(display) else: display = str(value) diff --git a/sapl/sanitize.py b/sapl/sanitize.py new file mode 100644 index 000000000..fcbe9db02 --- /dev/null +++ b/sapl/sanitize.py @@ -0,0 +1,182 @@ +"""Sanitização de HTML/JavaScript nos campos de texto livre do SAPL. + +Módulo sem dependências internas do SAPL de propósito: é importado por +``sapl.crispy_layout_mixin``, ``sapl.base.receivers`` e pelos templatetags, +e ``sapl.utils`` já importa ``sapl.crispy_layout_mixin``. + +Duas políticas: + +* ``plain`` — remove toda a marcação e guarda texto puro, com as entidades + já decodificadas: o escape fica por conta da renderização. É o padrão para + qualquer ``TextField``. +* ``rich`` — allowlist para os campos editados via TinyMCE, que contêm HTML + legítimo (negrito, listas, tabelas e links). + +A ``rich`` é idempotente, o que permite aplicá-la tanto no ``pre_save`` quanto +na renderização. A ``plain`` só não é idempotente para texto que seja HTML +codificado em entidades (``<b>``): a primeira passada decodifica, a +segunda remove a tag. Na renderização, campos ``plain`` são escapados em vez +de re-sanitizados. +""" + +import html +from html.parser import HTMLParser + +import nh3 + +SANITIZE_RICH_TAGS = { + 'p', 'br', 'hr', 'div', 'span', + 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', + 'col', 'colgroup', + 'a', 'img', +} + +# 'style' mantém o alinhamento produzido pelos botões do TinyMCE; +# 'target' mantém o "abrir em nova aba" dos links já cadastrados. +# 'rel' não pode entrar aqui: o nh3 aborta se a tag 'a' declarar 'rel' +# ao mesmo tempo em que link_rel está definido — ele mesmo escreve o atributo. +SANITIZE_RICH_ATTRS = { + '*': {'style', 'class', 'align', 'title', 'dir', 'lang'}, + 'a': {'href', 'target', 'name'}, + 'img': {'src', 'alt', 'width', 'height'}, + 'td': {'colspan', 'rowspan', 'headers'}, + 'th': {'colspan', 'rowspan', 'scope', 'headers'}, + 'col': {'span'}, + 'colgroup': {'span'}, + 'table': {'border', 'cellpadding', 'cellspacing', 'summary'}, +} + +SANITIZE_URL_SCHEMES = {'http', 'https', 'mailto', 'tel'} + +# O conteúdo destas tags é descartado junto com a tag. Sem isso o texto de +# dentro de um