Browse Source

Merge branch '3.1.x' into feat/melhorias-pntp-oficio-017

pull/3842/head
Edward 2 weeks ago
committed by GitHub
parent
commit
86a959c65e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      CHANGES.md
  2. 2
      docker/docker-compose.yaml
  3. 19
      sapl/base/migrations/0061_casalegislativa_informacao_geral.py
  4. 4
      sapl/base/models.py
  5. 27
      sapl/base/tests/test_form.py
  6. 13
      sapl/compilacao/utils.py
  7. 44
      sapl/compilacao/views.py
  8. 4
      sapl/materia/forms.py
  9. 53
      sapl/relatorios/tests.py
  10. 15
      sapl/relatorios/views.py
  11. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  12. 26
      sapl/sessao/models.py
  13. 194
      sapl/sessao/tests/test_sessao_view.py
  14. 77
      sapl/sessao/views.py
  15. 2
      sapl/settings.py
  16. 2
      sapl/templates/base.html
  17. 3
      sapl/utils.py

24
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
========================

2
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

19
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'),
),
]

4
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'))

27
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('<p>%s</p>' % 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

13
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

44
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()

4
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)),

53
sapl/relatorios/tests.py

@ -1,3 +1,52 @@
# from django.test import TestCase
import pytest
from model_bakery import baker
# Create your tests here.
from sapl.base.models import CasaLegislativa
from sapl.relatorios.views import get_sessao_plenaria
from sapl.sessao.models import ExpedienteSessao, SessaoPlenaria, TipoExpediente
from sapl.sessao.views import get_expedientes
def cria_sessao_com_expedientes():
"""Cria uma sessão cujos expedientes têm `ordenacao` inversa à ordem
alfabética dos nomes -- o caso que expõe o OSTicket #125461."""
sessao = baker.make(SessaoPlenaria)
tipo_leitura = baker.make(
TipoExpediente, nome='Leitura e Aprovação da Ata', ordenacao=1)
tipo_grande = baker.make(
TipoExpediente, nome='Grande Expediente', ordenacao=2)
for tipo in (tipo_grande, tipo_leitura):
baker.make(ExpedienteSessao, sessao_plenaria=sessao, tipo=tipo,
conteudo='<p>Conteúdo de {}.</p>'.format(tipo.nome))
return sessao
@pytest.mark.django_db(transaction=False)
def test_relatorio_sessao_respeita_ordenacao_do_tipo_expediente():
"""O PDF da Sessão Plenária deve seguir o campo `ordenacao` de
TipoExpediente, e não a ordem alfabética do nome."""
sessao = cria_sessao_com_expedientes()
casa = baker.make(CasaLegislativa)
user = baker.make('auth.User')
lst_expedientes = get_sessao_plenaria(sessao, casa, user)[6]
nomes = [e['nom_expediente'] for e in lst_expedientes]
assert nomes == ['Leitura e Aprovação da Ata', 'Grande Expediente']
@pytest.mark.django_db(transaction=False)
def test_relatorio_sessao_tem_mesma_ordem_de_expedientes_do_resumo():
"""O PDF e o Resumo exibido em tela não podem divergir: foi essa
divergência que originou o OSTicket #125461."""
sessao = cria_sessao_com_expedientes()
casa = baker.make(CasaLegislativa)
user = baker.make('auth.User')
do_pdf = [e['nom_expediente']
for e in get_sessao_plenaria(sessao, casa, user)[6]]
da_tela = [e['tipo'].nome for e in get_expedientes(sessao)['expedientes']]
assert do_pdf == da_tela

15
sapl/relatorios/views.py

@ -609,8 +609,10 @@ def get_sessao_plenaria(sessao, casa, user):
# Exibe os Expedientes
lst_expedientes = []
# A ordenação deve ser a mesma de sapl.sessao.views.get_expedientes, para
# que o PDF confira com o Resumo exibido em tela. OSTicket #125461
expedientes = ExpedienteSessao.objects.filter(
sessao_plenaria=sessao).order_by('tipo__nome')
sessao_plenaria=sessao).order_by('tipo__ordenacao', 'tipo__nome')
for e in expedientes:
conteudo = e.conteudo
if not is_empty(conteudo):
@ -2078,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()

40
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')},
),
]

26
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(
@ -574,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
@ -716,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 = [

194
sapl/sessao/tests/test_sessao_view.py

@ -1,4 +1,8 @@
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
@ -6,9 +10,11 @@ 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)
Orador, OcorrenciaSessao,
restringe_sessoes_visiveis)
from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao
@ -146,4 +152,188 @@ 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
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
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)

77
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
@ -1413,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!')
@ -1528,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))
@ -2327,6 +2342,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 +3852,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 +4053,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')

2
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'

2
sapl/templates/base.html

@ -207,7 +207,7 @@
<small>
Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto.
</small>
<span>Release: 3.1.165-RC2</span>
<span>Release: 3.1.165-RC3</span>
</p>
</div>

3
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',

Loading…
Cancel
Save