Browse Source

Merge branch '3.1.x' into hotfix/sanitize-textfield-xss

hotfix/sanitize-textfield-xss
Edward 1 week ago
committed by GitHub
parent
commit
9bea87062b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      CHANGES.md
  2. 2
      docker/docker-compose.yaml
  3. 70
      frontend/src/__apps/compilacao/js/old/compilacao.js
  4. 4
      frontend/src/__apps/compilacao/js/old/compilacao_edit.js
  5. 9
      frontend/src/__apps/compilacao/scss/compilacao.scss
  6. 25
      frontend/src/__global/js/tinymce/index.js
  7. 20
      sapl/audiencia/forms.py
  8. 59
      sapl/audiencia/migrations/0021_auto_20260623_1938.py
  9. 18
      sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
  10. 11
      sapl/audiencia/models.py
  11. 19
      sapl/base/migrations/0061_casalegislativa_informacao_geral.py
  12. 4
      sapl/base/models.py
  13. 27
      sapl/base/tests/test_form.py
  14. 25
      sapl/comissoes/forms.py
  15. 18
      sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
  16. 3
      sapl/comissoes/models.py
  17. 11
      sapl/comissoes/views.py
  18. 13
      sapl/compilacao/utils.py
  19. 44
      sapl/compilacao/views.py
  20. 5
      sapl/crud/base.py
  21. 18
      sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
  22. 4
      sapl/parlamentares/models.py
  23. 28
      sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
  24. 3
      sapl/protocoloadm/models.py
  25. 17
      sapl/relatorios/views.py
  26. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  27. 31
      sapl/sessao/models.py
  28. 220
      sapl/sessao/tests/test_sessao_view.py
  29. 77
      sapl/sessao/views.py
  30. 2
      sapl/settings.py
  31. 4
      sapl/templates/audiencia/layouts.yaml
  32. 9
      sapl/templates/base.html
  33. 2
      sapl/templates/compilacao/dispositivo_form_alteracao.html
  34. 2
      sapl/templates/compilacao/dispositivo_form_edicao_basica.html
  35. 6
      sapl/templates/compilacao/text_edit.html
  36. 2
      sapl/templates/relatorios/relatorio_materia_tramitacao.html
  37. 7
      sapl/templates/sessao/sessaoplenaria_filter.html
  38. 12
      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 3.1.165-RC2 / 2026-03-26
======================== ========================

2
docker/docker-compose.yaml

@ -33,7 +33,7 @@ services:
networks: networks:
- sapl-net - sapl-net
sapl: sapl:
image: interlegis/sapl:3.1.165-RC2 image: interlegis/sapl:3.1.165-RC3
# build: # build:
# context: ../ # context: ../
# dockerfile: ./docker/Dockerfile # dockerfile: ./docker/Dockerfile

70
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 () { function InitViewTAs () {
setTimeout(function () { setTimeout(function () {
var href = location.href.split('#') var href = location.href.split('#')
@ -297,11 +360,12 @@ function InitViewTAs () {
} }
}, 100) }, 100)
aplicarEscalaFonte(lerEscalaFonte())
$('#btn_font_menos').click(function () { $('#btn_font_menos').click(function () {
$('.dpt').css('font-size', '-=1') salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO)
}) })
$('#btn_font_mais').click(function () { $('#btn_font_mais').click(function () {
$('.dpt').css('font-size', '+=1') salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO)
}) })
$('.dpt.bloco_alteracao .dpt').each(function () { $('.dpt.bloco_alteracao .dpt').each(function () {
@ -327,5 +391,7 @@ export default {
ReadCookie, ReadCookie,
insertWaitAjax, insertWaitAjax,
InitViewTAs, InitViewTAs,
lerEscalaFonte,
aplicarEscalaFonte,
DispositivoSearch DispositivoSearch
} }

4
frontend/src/__apps/compilacao/js/old/compilacao_edit.js

@ -244,7 +244,9 @@ window.DispositivoEdit = function () {
if (editortype !== 'construct') { if (editortype !== 'construct') {
dpt_form.html(data) dpt_form.html(data)
if (editortype === 'tinymce') { if (editortype === 'tinymce') {
window.initTextRichEditor(null, false, false) window.initTextRichEditor(null, false, false).then(function () {
window.aplicarEscalaFonte(window.lerEscalaFonte())
})
} }
// OptionalCustomFrontEnd().init() // OptionalCustomFrontEnd().init()
} }

9
frontend/src/__apps/compilacao/scss/compilacao.scss

@ -164,12 +164,21 @@ a:link:after, a:visited:after {
} }
.cp { .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 { .vigencia-active {
margin-top: 30px; margin-top: 30px;
display: block; display: block;
} }
.cp-linha-vigencias { .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; display: flex;
flex-direction: row; flex-direction: row;
list-style: none; list-style: none;

25
frontend/src/__global/js/tinymce/index.js

@ -14,7 +14,23 @@ import 'tinymce/plugins/table'
import './langs/pt_BR.js' import './langs/pt_BR.js'
window.tinymce = tinymce 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 = { const configTinymce = {
selector: elements === null || elements === undefined ? 'textarea' : elements, selector: elements === null || elements === undefined ? 'textarea' : elements,
language: 'pt_BR', language: 'pt_BR',
@ -22,14 +38,17 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text
forced_root_block: 'p', forced_root_block: 'p',
paste_as_text, paste_as_text,
plugins: 'table lists advlist link code', 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', menubar: 'file edit view insert format table',
license_key: 'gpl' license_key: 'gpl'
} }
if (paste_titulos_como_paragrafo) {
configTinymce.paste_postprocess = colarTitulosComoParagrafo
}
if (readonly) { if (readonly) {
configTinymce.readonly = 1 configTinymce.readonly = 1
configTinymce.menubar = false configTinymce.menubar = false
configTinymce.toolbar = false configTinymce.toolbar = false
} }
window.tinymce.init(configTinymce) return window.tinymce.init(configTinymce)
} }

20
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 crispy_forms.layout import Button, Column, Fieldset, HTML, Layout
from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica 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.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import Parlamentar
from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo
@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
widget=forms.HiddenInput(), widget=forms.HiddenInput(),
queryset=MateriaLegislativa.objects.all()) queryset=MateriaLegislativa.objects.all())
parlamentar_autor = forms.ModelChoiceField( autor = forms.ModelChoiceField(
label=_("Parlamentar Autor"), label=_("Autor"),
required=False, required=False,
queryset=Parlamentar.objects.all()) queryset=Autor.objects.all().order_by('nome'))
requerimento = forms.ModelChoiceField( requerimento = forms.ModelChoiceField(
label=_("Requerimento"), label=_("Requerimento"),
@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
model = AudienciaPublica model = AudienciaPublica
fields = ['tipo', 'numero', 'ano', 'nome', fields = ['tipo', 'numero', 'ano', 'nome',
'tema', 'data', 'hora_inicio', 'hora_fim', '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', 'url_video', 'upload_pauta', 'upload_ata',
'upload_anexo', 'tipo_materia', 'numero_materia', 'upload_anexo', 'tipo_materia', 'numero_materia',
'ano_materia', 'materia'] 'ano_materia', 'materia']
@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
materia = cleaned_data['numero_materia'] materia = cleaned_data['numero_materia']
ano_materia = cleaned_data['ano_materia'] ano_materia = cleaned_data['ano_materia']
tipo_materia = cleaned_data['tipo_materia'] tipo_materia = cleaned_data['tipo_materia']
parlamentar_autor = cleaned_data["parlamentar_autor"] autor = cleaned_data["autor"]
requerimento = cleaned_data["requerimento"] requerimento = cleaned_data["requerimento"]
if cleaned_data["ano"] != cleaned_data["data"].year: if cleaned_data["ano"] != cleaned_data["data"].year:
@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
raise ValidationError(msg) raise ValidationError(msg)
# requerimento é optativo # requerimento é optativo
if parlamentar_autor and requerimento: if autor and requerimento:
if parlamentar_autor.autor.first() not in requerimento.autores.all(): if autor not in requerimento.autores.all():
raise ValidationError("Parlamentar Autor selecionado não faz" raise ValidationError("Autor selecionado não faz"
" parte da autoria do Requerimento " " parte da autoria do Requerimento "
"selecionado.") "selecionado.")
elif parlamentar_autor: elif autor:
raise ValidationError("Para informar um autor deve-se informar um requerimento.") raise ValidationError("Para informar um autor deve-se informar um requerimento.")
elif requerimento: elif requerimento:
raise ValidationError("Para informar um requerimento deve-se informar um autor.") raise ValidationError("Para informar um requerimento deve-se informar um autor.")

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

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

11
sapl/audiencia/models.py

@ -3,7 +3,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_utils import Choices from model_utils import Choices
from sapl.materia.models import MateriaLegislativa 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, from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation,
restringe_tipos_de_arquivo_txt, texto_upload_path, restringe_tipos_de_arquivo_txt, texto_upload_path,
@ -79,12 +79,15 @@ class AudienciaPublica(models.Model):
default=False, default=False,
choices=YES_NO_CHOICES, choices=YES_NO_CHOICES,
verbose_name=_('Audiência Cancelada?')) verbose_name=_('Audiência Cancelada?'))
parlamentar_autor = models.ForeignKey( autor = models.ForeignKey(
Parlamentar, 'base.Autor',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=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( requerimento = models.ForeignKey(
MateriaLegislativa, MateriaLegislativa,
null=True, null=True,

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')) max_length=100, blank=True, verbose_name=_('HomePage'))
email = models.EmailField( email = models.EmailField(
max_length=100, blank=True, verbose_name=_('E-mail')) 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( informacao_geral = models.TextField(
max_length=100,
blank=True, blank=True,
verbose_name=_('Informação Geral')) 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() 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

25
sapl/comissoes/forms.py

@ -558,3 +558,28 @@ class DocumentoAcessorioEditForm(FileFieldCheckMixin, forms.ModelForm):
# raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}') # raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}')
return self.cleaned_data 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'))))

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

3
sapl/comissoes/models.py

@ -34,6 +34,9 @@ class Comissao(models.Model):
tipo = models.ForeignKey(TipoComissao, tipo = models.ForeignKey(TipoComissao,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_('Tipo')) 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')) nome = models.CharField(max_length=100, verbose_name=_('Nome'))
sigla = models.CharField(max_length=10, verbose_name=_('Sigla')) sigla = models.CharField(max_length=10, verbose_name=_('Sigla'))
data_criacao = models.DateField(verbose_name=_('Data de Criação')) data_criacao = models.DateField(verbose_name=_('Data de Criação'))

11
sapl/comissoes/views.py

@ -22,7 +22,7 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm,
ParticipacaoCreateForm, ParticipacaoCreateForm,
ParticipacaoEditForm, ParticipacaoEditForm,
PautaReuniaoFilterSet, PautaReuniaoForm, PautaReuniaoFilterSet, PautaReuniaoForm,
PeriodoForm, ReuniaoForm) PeriodoForm, ReuniaoFilterSet, ReuniaoForm)
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
PermissionRequiredForAppCrudMixin, RP_DETAIL, PermissionRequiredForAppCrudMixin, RP_DETAIL,
RP_LIST) RP_LIST)
@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud):
public = [RP_LIST, RP_DETAIL, ] public = [RP_LIST, RP_DETAIL, ]
class BaseMixin(MasterDetailCrud.BaseMixin): 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): class DetailView(MasterDetailCrud.DetailView):
template_name = "comissoes/reuniao_detail.html" template_name = "comissoes/reuniao_detail.html"
@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
paginate_by = 10 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): def take_reuniao_pk(self):
username = self.request.user.username username = self.request.user.username
@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud):
context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter( context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter(
reuniao__pk=context['reuniao_pk'] reuniao__pk=context['reuniao_pk']
).order_by('id') ).order_by('id')
context['form'] = self.filterset.form
return context return context
class UpdateView(MasterDetailCrud.UpdateView): class UpdateView(MasterDetailCrud.UpdateView):

13
sapl/compilacao/utils.py

@ -1,5 +1,7 @@
import sys import sys
from django.utils.translation import ugettext_lazy as _
DISPOSITIVO_SELECT_RELATED = ( DISPOSITIVO_SELECT_RELATED = (
'tipo_dispositivo', 'tipo_dispositivo',
'ta_publicado', 'ta_publicado',
@ -66,3 +68,14 @@ def get_integrations_view_names():
if 'IntegracaoTaView' in str(base): if 'IntegracaoTaView' in str(base):
result.append(v) result.append(v)
return result 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

@ -50,7 +50,7 @@ from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE,
VeiculoPublicacao, Vide) VeiculoPublicacao, Vide)
from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED, from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED,
DISPOSITIVO_SELECT_RELATED_EDIT, 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,\ from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, CrudAux, CrudListView,\
make_pagination make_pagination
from sapl.settings import BASE_DIR from sapl.settings import BASE_DIR
@ -3179,14 +3179,14 @@ class DispositivoSearchFragmentFormView(ListView):
result = [] result = []
try: 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)) 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', '') 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', '') rotulo = self.request.GET.get('rotulo', '')
str_texto = self.request.GET.get('texto', '') 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 = self.request.GET.get('tipo_resultado', '')
tipo_resultado = '' if tipo_resultado == 'False' else 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_CONTROLS = '''AND td.dispositivo_de_alteracao = true
AND td.dispositivo_de_articulacao = true''' AND td.dispositivo_de_articulacao = true'''
texto = list(map("d.texto ~* '{}'".format, texto)) texto = ['d.texto ~* %s'] * len(texto_valores)
AND_TEXTO_ROTULO = '' AND_TEXTO_ROTULO = ''
texto_rotulo_params = []
if str_texto and rotulo: if str_texto and rotulo:
AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* '{BUSCA_ROTULO}') OR busca_texto = ' AND '.join(texto)
({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* '{BUSCA_ROTULO}') AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* %s) OR
({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* %s)
)'''.format( )'''.format(
BUSCA_TEXTO=' AND '.join(texto), BUSCA_TEXTO=busca_texto,
BUSCA_ROTULO=rotulo
) )
texto_rotulo_params = (
texto_valores + [rotulo] + texto_valores + [rotulo])
elif str_texto: 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: elif rotulo:
AND_TEXTO_ROTULO = "AND d.rotulo ~* '{BUSCA_ROTULO}'".format( AND_TEXTO_ROTULO = "AND d.rotulo ~* %s"
BUSCA_ROTULO=rotulo) texto_rotulo_params = [rotulo]
else: else:
AND_TEXTO_ROTULO = '' AND_TEXTO_ROTULO = ''
@ -3290,17 +3294,21 @@ class DispositivoSearchFragmentFormView(ListView):
AND3_TIPO_TA="AND ta.tipo_ta_id = {}".format( AND3_TIPO_TA="AND ta.tipo_ta_id = {}".format(
tipo_ta.id) if tipo_ta else '', tipo_ta.id) if tipo_ta else '',
AND2_ANO="AND ta.ano = {}".format( AND2_ANO="AND ta.ano = %s" if ano_ta else '',
ano_ta) if ano_ta else '',
AND1_NUMERO="AND ta.numero ~* '{}'".format( AND1_NUMERO="AND ta.numero ~* %s" if num_ta else '',
num_ta) if num_ta else '',
AND_TEXTO_ROTULO=AND_TEXTO_ROTULO if AND_TEXTO_ROTULO else '', AND_TEXTO_ROTULO=AND_TEXTO_ROTULO if AND_TEXTO_ROTULO else '',
AND_CONTROLS=AND_CONTROLS if AND_CONTROLS 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 = [] r = []
ids = set() ids = set()

5
sapl/crud/base.py

@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
context[context_object_name] = self.object context[context_object_name] = self.object
context.update(kwargs) 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 return context
@property @property

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

4
sapl/parlamentares/models.py

@ -193,6 +193,10 @@ class Parlamentar(models.Model):
SEXO_CHOICE = ((FEMININO, _('Feminino')), SEXO_CHOICE = ((FEMININO, _('Feminino')),
(MASCULINO, _('Masculino'))) (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( nivel_instrucao = models.ForeignKey(
NivelInstrucao, NivelInstrucao,
blank=True, blank=True,

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

3
sapl/protocoloadm/models.py

@ -158,6 +158,9 @@ class DocumentoAdministrativo(models.Model):
tipo = models.ForeignKey( tipo = models.ForeignKey(
TipoDocumentoAdministrativo, on_delete=models.PROTECT, TipoDocumentoAdministrativo, on_delete=models.PROTECT,
verbose_name=_('Tipo Documento')) 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')) numero = models.PositiveIntegerField(verbose_name=_('Número'))
complemento = models.CharField(max_length=10, blank=True, complemento = models.CharField(max_length=10, blank=True,

17
sapl/relatorios/views.py

@ -711,7 +711,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_expediente_materia_vot_nom = [] lst_expediente_materia_vot_nom = []
materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ 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: for mevn in materias_expediente_votacao_nominal:
votos_materia = [] votos_materia = []
@ -824,7 +824,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_votacao_vot_nom = [] lst_votacao_vot_nom = []
materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ 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: for modvn in materias_ordem_dia_votacao_nominal:
votos_materia_od = [] votos_materia_od = []
@ -1792,7 +1792,7 @@ def relatorio_materia_tramitacao(request, pk):
'materia': materia_legislativa, 'materia': materia_legislativa,
'ano': materia_legislativa.ano, 'ano': materia_legislativa.ano,
'numero': materia_legislativa.numero, 'numero': materia_legislativa.numero,
'autor': materia_legislativa.autores.first(), 'autores': materia_legislativa.autores.all(),
'tipo': materia_legislativa.tipo.descricao, 'tipo': materia_legislativa.tipo.descricao,
'rodape': rodape, 'rodape': rodape,
'data': dt.today().strftime('%d/%m/%Y'), 'data': dt.today().strftime('%d/%m/%Y'),
@ -2080,17 +2080,22 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
parlamentares_id = parlamentares_qs.values_list('id', flat=True) parlamentares_id = parlamentares_qs.values_list('id', flat=True)
# Presenças de cada Parlamentar em Sessões # 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( 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 # Presenças de cada Ordem do Dia
presenca_ordem = PresencaOrdemDia.objects.filter( 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 # Ausencias justificadas
ausencia_justificadas = JustificativaAusencia.objects.filter( ausencia_justificadas = JustificativaAusencia.objects.filter(
**param0, ausencia=2).values_list('parlamentar_id')\ **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( total_ordemdia = PresencaOrdemDia.objects.filter(
**param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count() **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')},
),
]

31
sapl/sessao/models.py

@ -153,6 +153,31 @@ def anexo_upload_path(instance, filename):
# return get_sessao_media_path(instance, 'anexo', 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): class SessaoPlenaria(models.Model):
# TODO trash??? Seems to have been a FK in the past. Would be: # TODO trash??? Seems to have been a FK in the past. Would be:
# andamento_sessao = models.ForeignKey( # andamento_sessao = models.ForeignKey(
@ -574,6 +599,9 @@ class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
verbose_name = _('Presença da Ordem do Dia') verbose_name = _('Presença da Ordem do Dia')
verbose_name_plural = _('Presenças da Ordem do Dia') verbose_name_plural = _('Presenças da Ordem do Dia')
ordering = ['parlamentar__nome_parlamentar'] 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): def __str__(self):
# FIXME ambigous # FIXME ambigous
@ -716,6 +744,9 @@ class SessaoPlenariaPresenca(models.Model):
verbose_name = _('Presença em Sessão Plenária') verbose_name = _('Presença em Sessão Plenária')
verbose_name_plural = _('Presenças em Sessões Plenárias') verbose_name_plural = _('Presenças em Sessões Plenárias')
ordering = ['parlamentar__nome_parlamentar'] 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 = [ ORDENACAO_RESUMO = [

220
sapl/sessao/tests/test_sessao_view.py

@ -1,14 +1,21 @@
import pytest 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.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_bakery import baker from model_bakery import baker
from sapl.parlamentares.models import Legislatura, SessaoLegislativa from sapl.parlamentares.models import Legislatura, SessaoLegislativa
from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria, from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria,
IntegranteMesa, SessaoPlenariaPresenca, IntegranteMesa, SessaoPlenariaPresenca,
PresencaOrdemDia,
JustificativaAusencia, ExpedienteSessao, JustificativaAusencia, ExpedienteSessao,
TipoExpediente, ExpedienteMateria, TipoExpediente, ExpedienteMateria,
Orador, OcorrenciaSessao) Orador, OcorrenciaSessao,
restringe_sessoes_visiveis)
from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao
@ -147,3 +154,214 @@ class TestResumoView():
resultado_get_ocorrencia = get_ocorrencias_da_sessao(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
# 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)

77
sapl/sessao/views.py

@ -64,7 +64,7 @@ from .models import (Bancada, CargoBancada, CargoMesa,
SessaoPlenaria, SessaoPlenariaPresenca, TipoExpediente, SessaoPlenaria, SessaoPlenariaPresenca, TipoExpediente,
TipoResultadoVotacao, TipoSessaoPlenaria, VotoParlamentar, TipoRetiradaPauta, TipoResultadoVotacao, TipoSessaoPlenaria, VotoParlamentar, TipoRetiradaPauta,
RetiradaPauta, TipoJustificativa, JustificativaAusencia, OradorOrdemDia, RetiradaPauta, TipoJustificativa, JustificativaAusencia, OradorOrdemDia,
ORDENACAO_RESUMO, RegistroLeitura) ORDENACAO_RESUMO, RegistroLeitura, restringe_sessoes_visiveis)
TipoSessaoCrud = CrudAux.build(TipoSessaoPlenaria, 'tipo_sessao_plenaria') TipoSessaoCrud = CrudAux.build(TipoSessaoPlenaria, 'tipo_sessao_plenaria')
TipoJustificativaCrud = CrudAux.build(TipoJustificativa, 'tipo_justificativa') TipoJustificativaCrud = CrudAux.build(TipoJustificativa, 'tipo_justificativa')
@ -1348,6 +1348,13 @@ class SessaoCrud(Crud):
class DetailView(Crud.DetailView): 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 @property
def layout_key(self): def layout_key(self):
sessao = self.object sessao = self.object
@ -1413,26 +1420,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # 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( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
SessaoPlenariaPresenca.objects.filter( SessaoPlenariaPresenca.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. O
sessao = SessaoPlenariaPresenca() # ignore_conflicts descarta a inserção duplicada quando o
sessao.sessao_plenaria = self.object # formulário é submetido duas vezes em paralelo, em vez de
sessao.parlamentar = Parlamentar.objects.get(id=p) # gravar uma segunda linha para o mesmo parlamentar.
sessao.save()
username = request.user.username 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( self.logger.info(
"user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p)) "user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p))
msg = _('Presença em Sessão salva com sucesso!') msg = _('Presença em Sessão salva com sucesso!')
@ -1528,26 +1540,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # 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( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
PresencaOrdemDia.objects.filter( PresencaOrdemDia.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. Ver
ordem = PresencaOrdemDia() # comentário equivalente em PresencaView.post.
ordem.sessao_plenaria = self.object
ordem.parlamentar = Parlamentar.objects.get(id=p)
ordem.save()
username = request.user.username 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( self.logger.info(
'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p)) 'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p))
@ -2327,6 +2342,10 @@ class ResumoView(DetailView):
model = SessaoPlenaria model = SessaoPlenaria
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_queryset(self):
return restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), self.request.user)
def get_context(self, *args, **kwargs): def get_context(self, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
context = self.get_context_data(object=self.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 # Votos de Votação Nominal de Matérias Expediente
votacoes = [] votacoes = []
for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \ for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \
.order_by('-materia'): .order_by('numero_ordem'):
votos_materia = [] votos_materia = []
titulo_materia = mevn.materia titulo_materia = mevn.materia
registro = RegistroVotacao.objects.filter(expediente=mevn) registro = RegistroVotacao.objects.filter(expediente=mevn)
@ -2383,7 +2402,7 @@ class ResumoView(DetailView):
# Matérias Ordem do Dia # Matérias Ordem do Dia
# Votos de Votação Nominal de Matérias Ordem do Dia # Votos de Votação Nominal de Matérias Ordem do Dia
votacoes_od = [] 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 = [] votos_materia_od = []
t_materia = modvn.materia t_materia = modvn.materia
registro_od = RegistroVotacao.objects.filter(ordem=modvn) registro_od = RegistroVotacao.objects.filter(ordem=modvn)
@ -3833,6 +3852,12 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
('situacao', 'Situação') ('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): def hook_autor(self, obj):
return ','.join(obj['autor']) return ','.join(obj['autor'])
@ -4028,6 +4053,8 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
qs = self.get_queryset().select_related( qs = self.get_queryset().select_related(
'tipo', 'sessao_legislativa', 'legislatura') 'tipo', 'sessao_legislativa', 'legislatura')
qs = restringe_sessoes_visiveis(qs, self.request.user)
qs = qs.distinct().order_by( qs = qs.distinct().order_by(
'-legislatura__numero', '-data_inicio', '-hora_inicio') '-legislatura__numero', '-data_inicio', '-hora_inicio')

2
sapl/settings.py

@ -43,7 +43,7 @@ ALLOWED_HOSTS = ['*']
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/?next=' LOGIN_URL = '/login/?next='
SAPL_VERSION = '3.1.165-RC2' SAPL_VERSION = '3.1.165-RC3'
if DEBUG: if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

4
sapl/templates/audiencia/layouts.yaml

@ -6,7 +6,7 @@ AudienciaPublica:
{% trans 'Dados' %}: {% trans 'Dados' %}:
- tipo_materia numero_materia ano_materia - tipo_materia numero_materia ano_materia
- tipo:4 data:2 hora_inicio:3 hora_fim:3 - tipo:4 data:2 hora_inicio:3 hora_fim:3
- parlamentar_autor requerimento - autor requerimento
- url_audio url_video - url_audio url_video
- upload_pauta upload_ata upload_anexo - upload_pauta upload_ata upload_anexo
- observacao - observacao
@ -19,7 +19,7 @@ AudienciaPublicaDetail:
{% trans 'Dados' %}: {% trans 'Dados' %}:
- materia tipo - materia tipo
- data hora_inicio hora_fim - data hora_inicio hora_fim
- parlamentar_autor requerimento - autor requerimento
- url_audio url_video - url_audio url_video
- upload_pauta upload_ata upload_anexo - upload_pauta upload_ata upload_anexo
- observacao - observacao

9
sapl/templates/base.html

@ -40,6 +40,13 @@
<body> <body>
<div class="page fadein"> <div class="page fadein">
{% if not request|has_iframe %}
<div class="info-atualizacao hidden-print" style="background-color:#0d2b45;color:#fff;text-align:center;padding:6px 12px;font-size:14px;font-weight:600;">
{% trans 'Informações foram atualizadas em' %}
{% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %}
</div>
{% endif %}
{% if not sapl_as_sapn or user.is_authenticated%} {% if not sapl_as_sapn or user.is_authenticated%}
{% if not request|has_iframe %} {% if not request|has_iframe %}
{% block navigation %} {% block navigation %}
@ -200,7 +207,7 @@
<small> <small>
Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto. Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto.
</small> </small>
<span>Release: 3.1.165-RC2</span> <span>Release: 3.1.165-RC3</span>
</p> </p>
</div> </div>

2
sapl/templates/compilacao/dispositivo_form_alteracao.html

@ -7,6 +7,6 @@
'url_form': '{% url 'sapl.compilacao:dispositivo_search_form'%}', 'url_form': '{% url 'sapl.compilacao:dispositivo_search_form'%}',
'text_button': '{% trans 'Definir Dispositivo'%}' 'text_button': '{% trans 'Definir Dispositivo'%}'
}); });
initTextRichEditor(); initTextRichEditor(null, false, false, true);
</script> </script>
{% endblock %} {% endblock %}

2
sapl/templates/compilacao/dispositivo_form_edicao_basica.html

@ -31,6 +31,6 @@
}); });
} }
initTextRichEditor(); initTextRichEditor(null, false, false, true);
</script> </script>
{% endblock %} {% endblock %}

6
sapl/templates/compilacao/text_edit.html

@ -17,10 +17,16 @@
<a href="{% url 'sapl.compilacao:ta_text_edit' object.pk %}?{% if object.editing_locked %}unlock{%else%}lock{% endif %}" class="btn {% if object.editing_locked %}btn-outline-danger{%else%}btn-outline-primary{% endif %}">{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}</a> <a href="{% url 'sapl.compilacao:ta_text_edit' object.pk %}?{% if object.editing_locked %}unlock{%else%}lock{% endif %}" class="btn {% if object.editing_locked %}btn-outline-danger{%else%}btn-outline-primary{% endif %}">{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}</a>
{% endif %} {% endif %}
</div> </div>
<div class="d-flex">
<div class="actions btn-group mr-2" role="group">
<a class="btn btn-outline-primary" id="btn_font_menos" title="{% trans 'Diminuir tamanho da letra' %}">a</a>
<a class="btn btn-outline-primary" id="btn_font_mais" title="{% trans 'Aumentar tamanho da letra' %}">A</a>
</div>
<div class="actions btn-group dropleft" role="group"> <div class="actions btn-group dropleft" role="group">
{% include 'compilacao/textoarticulado_menu_config.html' %} {% include 'compilacao/textoarticulado_menu_config.html' %}
</div> </div>
</div> </div>
</div>
{% if object.dispositivos_set.count == 1 %} {% if object.dispositivos_set.count == 1 %}
{% with view.materia_da_norma_deste_texto_articulado as materia %} {% with view.materia_da_norma_deste_texto_articulado as materia %}
{% if materia %} {% if materia %}

2
sapl/templates/relatorios/relatorio_materia_tramitacao.html

@ -9,7 +9,7 @@
<h2>Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}</h2> <h2>Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}</h2>
Tipo de matéria: {{ tipo }}<br /> Tipo de matéria: {{ tipo }}<br />
Autor: {{ autor }}<br /> {% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}<br />
<table class="grayTable"> <table class="grayTable">
{% for tramitacao in object%} {% for tramitacao in object%}

7
sapl/templates/sessao/sessaoplenaria_filter.html

@ -53,6 +53,13 @@
{% if s.upload_ata %} {% if s.upload_ata %}
<strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br> <strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br>
{% endif %} {% endif %}
{% if s.upload_pauta %}
<strong><a href="{{s.upload_pauta.url}}">Pauta da Sessão</strong></a></br>
{% endif %}
{% if s.upload_anexo %}
<strong><a href="{{s.upload_anexo.url}}">Anexo da Sessão</strong></a></br>
{% endif %}
<strong><a href="{% url 'sapl.sessao:resumo' s.id %}">Votações Nominais</strong></a></br>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

12
sapl/utils.py

@ -611,6 +611,9 @@ TIPOS_TEXTO_PERMITIDOS = (
'text/pdf', 'text/pdf',
'text/x-pdf', 'text/x-pdf',
'text/plain', 'text/plain',
'text/csv',
'application/vnd.ms-excel',
'application/csv',
'application/txt', 'application/txt',
'browser/internal', 'browser/internal',
'text/anytext', 'text/anytext',
@ -620,6 +623,15 @@ TIPOS_TEXTO_PERMITIDOS = (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/xml', 'application/xml',
'text/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 = ( TIPOS_IMG_PERMITIDOS = (

Loading…
Cancel
Save