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/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..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,14 +38,17 @@ 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 configTinymce.toolbar = false } - window.tinymce.init(configTinymce) + return window.tinymce.init(configTinymce) } 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/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 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/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 4bb1015f1..995b78368 100644 --- a/sapl/compilacao/views.py +++ b/sapl/compilacao/views.py @@ -50,7 +50,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 @@ -3179,14 +3179,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 @@ -3230,20 +3230,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 = '' @@ -3290,17 +3294,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() 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 8b89342fd..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'), @@ -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 613068afe..f86f2f414 100644 --- a/sapl/sessao/models.py +++ b/sapl/sessao/models.py @@ -153,6 +153,31 @@ 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 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 + 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) | + Q(data_inicio__lte=timezone.localdate())) + + class SessaoPlenaria(models.Model): # TODO trash??? Seems to have been a FK in the past. Would be: # andamento_sessao = models.ForeignKey( @@ -574,6 +599,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 +744,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 00284465b..9e68953a6 100644 --- a/sapl/sessao/tests/test_sessao_view.py +++ b/sapl/sessao/tests/test_sessao_view.py @@ -1,14 +1,21 @@ import pytest +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 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 +153,215 @@ 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 + + +# 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, + 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, + data_inicio=data_futura()) + + 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_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, + data_inicio=data_futura()) + + 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=data_futura()) + iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False, + data_inicio=data_futura()) + + response = client.get(reverse('sapl.sessao:pesquisar_sessao'), + {'data_inicio__year': data_futura().year}) + + 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=data_futura()) + + response = admin_client.get(reverse('sapl.sessao:pesquisar_sessao'), + {'data_inicio__year': data_futura().year}) + + 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, + data_inicio=data_futura()) + + 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, + data_inicio=data_futura()) + + 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) diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index ab30c2c6a..2c1df1442 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 @@ -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) @@ -2334,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) @@ -2383,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) @@ -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') 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/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 79b285a3a..e3582e2ef 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -40,6 +40,13 @@